Compare commits
No commits in common. "main" and "v2.3.0" have entirely different histories.
|
|
@ -1,186 +0,0 @@
|
|||
---
|
||||
name: api-v2-routes
|
||||
description: Use when adding or changing a resource on the Huma-backed /api/v2 API (new endpoints, porting a v1 resource, editing pkg/routes/api/v2/). Covers per-operation Huma handlers, the shared envelopes, error/auth bridging, REST verb conventions, and what's automatic.
|
||||
user-invocable: true
|
||||
---
|
||||
|
||||
# Adding /api/v2 routes for a CRUDable resource
|
||||
|
||||
`/api/v2` is served by [Huma v2](https://github.com/danielgtaylor/huma) mounted on an Echo group via the vendored `pkg/modules/humaecho5` adapter. Unlike v1's generic `WebHandler`, each operation is a typed Huma handler registered explicitly. The handlers are thin: they pull auth off the context, call the same `pkg/web/handler.Do*` functions v1 uses, and translate domain errors into RFC 9457 responses.
|
||||
|
||||
**Reference implementation:** `pkg/routes/api/v2/labels.go` is the canonical example — copy its shape. Shared envelopes live in `pkg/routes/api/v2/types.go`; the auth/error bridge in `pkg/routes/api/v2/errors.go`; config in `pkg/routes/api/v2/huma.go`.
|
||||
|
||||
## Prerequisite: the model must be CRUDable
|
||||
|
||||
v2 handlers call `handler.DoReadAll/DoReadOne/DoCreate/DoUpdate/DoDelete`, which invoke the model's `Can*` methods. If the model isn't already a working v1 resource, do the model work first — invoke the **`crudable`** skill. Permissions are enforced at the model level; **never** re-check them in a v2 handler.
|
||||
|
||||
**Every exposed model field needs a `doc:` tag.** v2's schema is reflected from struct tags at runtime; Huma cannot read the Go doc comments swaggo uses for v1. A field without `doc:"..."` ships with no description in the spec. Add the tag alongside the existing comment (keep both — swaggo still reads the comment for v1, and they should stay in sync):
|
||||
|
||||
```go
|
||||
// The title of the label. You'll see this one on tasks associated with it.
|
||||
Title string `json:"title" minLength:"1" maxLength:"250" doc:"The title of the label. You'll see this one on tasks associated with it."`
|
||||
```
|
||||
|
||||
These model edits are safe for v1 — swaggo, XORM, and govalidator all ignore the `doc` tag. (Huma *does* read validation tags like `minLength`/`maxLength`/`enum`/`format`, so those carry over without a `doc` tag.) As with operations, a `doc` tag earns its place when it says something the field name and type don't: a format hint ("hex, 6 chars"), a read-only note ("set by the server; ignored on write"), units, or allowed values. "The label description." on a `Description` field is filler. See `pkg/models/label.go` for the reference.
|
||||
|
||||
**Mark server-controlled fields `readOnly:"true"`.** Because the same model struct is the request body *and* the response, fields the client can never set — `id`, `created`, `updated`, `created_by`, and similar server-derived relations/IDs — should carry `readOnly:"true"`. Huma reflects this into the OpenAPI schema (`readOnly: true`), so docs and client generators present the field as response-only and drop it from request examples:
|
||||
|
||||
```go
|
||||
ID int64 `json:"id" readOnly:"true" doc:"The unique, numeric id of this label."`
|
||||
CreatedBy *user.User `xorm:"-" json:"created_by" readOnly:"true" doc:"The user who created this label."`
|
||||
Created time.Time `xorm:"created not null" json:"created" readOnly:"true" doc:"A timestamp when this label was created. You cannot change this value."`
|
||||
```
|
||||
|
||||
The tag is **documentation only** — Huma does *not* reject these fields if a client sends them on create/update. Actual immutability still comes from the model layer (XORM-managed `created`/`updated`, `created_by` being `xorm:"-"` and set server-side). It's also harmless on v1 (swaggo/XORM/govalidator ignore it). Don't bother tagging fields that are already `json:"-"` (absent from the schema entirely), and skip it on response-only structs like the error model — there it's cosmetic since they never appear as a request body. See `pkg/models/label.go` and `pkg/user/user.go`.
|
||||
|
||||
## Steps
|
||||
|
||||
### 1. Create `pkg/routes/api/v2/<resource>.go`
|
||||
|
||||
Define the list-response body, a `Register<Resource>Routes(api huma.API)` function, and one handler per operation. Mirror `labels.go` exactly:
|
||||
|
||||
```go
|
||||
// Element type matches what models.<Model>.ReadAll returns; extra fields
|
||||
// tagged json:"-" keep the wire shape identical to the plain model.
|
||||
type fooListBody struct {
|
||||
Body Paginated[*models.Foo]
|
||||
}
|
||||
|
||||
func RegisterFooRoutes(api huma.API) {
|
||||
tags := []string{"foos"}
|
||||
|
||||
Register(api, huma.Operation{
|
||||
OperationID: "foos-list",
|
||||
Summary: "List foos",
|
||||
Description: "Returns the foos the authenticated user has access to, paginated.",
|
||||
Method: http.MethodGet, Path: "/foos", Tags: tags,
|
||||
}, foosList)
|
||||
Register(api, huma.Operation{OperationID: "foos-read", Summary: "Get a foo", Description: "...", Method: http.MethodGet, Path: "/foos/{id}", Tags: tags}, foosRead)
|
||||
Register(api, huma.Operation{OperationID: "foos-create", Summary: "Create a foo", Description: "...", Method: http.MethodPost, Path: "/foos", Tags: tags}, foosCreate)
|
||||
Register(api, huma.Operation{OperationID: "foos-update", Summary: "Update a foo", Description: "...", Method: http.MethodPut, Path: "/foos/{id}", Tags: tags}, foosUpdate)
|
||||
Register(api, huma.Operation{OperationID: "foos-delete", Summary: "Delete a foo", Description: "...", Method: http.MethodDelete, Path: "/foos/{id}", Tags: tags}, foosDelete)
|
||||
}
|
||||
```
|
||||
|
||||
Use the package's `Register` wrapper, **not** `huma.Register` directly — it sets `DefaultStatus` from the verb (POST → 201, DELETE → 204). Don't spell out `DefaultStatus` unless you need a non-default code. Don't set `Security:` per operation — it's applied globally in `NewAPI`.
|
||||
|
||||
**Every operation needs a `Summary` and `Description`.** v2's OpenAPI spec is generated from these `Operation` fields at runtime — unlike v1's swaggo, Huma cannot read Go doc comments, so anything you don't put in the `Operation` (or in a `doc:` tag, see below) is simply absent from the spec and the docs UI. An operation without them ships undocumented.
|
||||
|
||||
**Make the description document the non-obvious — don't restate the verb+noun.** "Deletes a label" adds nothing over `DELETE /labels/{id}`. Spend the description on what a consumer *can't* infer from the method/path/schema: permission scope ("only the owner may delete it"; "returns only labels you can see, not a global list"), full-replace vs partial (PUT replaces, PATCH merges), read-only/conditional behavior (ETag → `If-None-Match` → 304), side effects (create sets ownership), non-obvious status codes. If the honest description is just the verb+noun, a short summary alone is fine — don't pad. See `labels.go` for the calibration.
|
||||
|
||||
### 2. Write the handlers
|
||||
|
||||
Every handler: pull auth with `authFromCtx(ctx)`, call the matching `handler.Do*`, wrap returned errors in `translateDomainError`. Use the shared envelopes from `types.go` (`singleBody`, `singleReadBody`, `emptyBody`, `ListParams`, `Paginated`/`NewPaginated`).
|
||||
|
||||
- **List** takes `*ListParams` (gives you `page`/`per_page`/`q` for free, already `doc:`-tagged in `types.go` — no need to re-document them) and returns `*fooListBody`. **You must type-assert the `DoReadAll` result to the concrete slice** — `result` is `any`, and a blind cast or a generic wrapper silently serialises `[]` (the "generic-any silent-empty trap"). Return a hard error on mismatch:
|
||||
```go
|
||||
items, ok := result.([]*models.Foo)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("foos.ReadAll returned unexpected type %T", result)
|
||||
}
|
||||
return &fooListBody{Body: NewPaginated(items, total, in.Page, in.PerPage)}, nil
|
||||
```
|
||||
- **Extra query params go *directly* on the handler's input struct — not in a shared/embedded helper.** Beyond `ListParams`, if an operation needs its own query params (`expand`, `order_by`, `include_public`, …), declare each as a direct field with its own `query:"…"` tag on that operation's input struct, then bind it onto the model. A shared or embedded struct of query fields silently **fails to bind** under Huma when combined with other query params/embeds — the field arrives empty (hit while implementing Project's `expand`). Flatten them into the input struct.
|
||||
- **Read** embeds `conditional.Params` in its input. To surface the caller's permission, define a small per-resource response struct that **embeds the model by value** and adds the permission: `type fooReadBody struct { models.Foo; MaxPermission models.Permission \`json:"max_permission" readOnly:"true" doc:"..."\` }`. Go and Huma both promote the embedded model's fields, so the wire shape is flat (model fields + `max_permission`) with no custom marshaler and nothing added to the shared model struct. Capture `DoReadOne`'s returned max permission (it is `0`/`1`/`2` on success — **never discard it as `_`**), build the body, and `return conditionalReadResponse(&in.Params, body, foo.Updated, maxPermission)`. The shared helper (in `types.go`) folds the permission into the ETag (so a share/role change invalidates the cache), applies the conditional precondition (304/412), and returns `*singleReadBody[fooReadBody]`. See `labels.go`/`project_views.go`. (A generic `struct{ T; ... }` is impossible — Go forbids embedding a type parameter — so the per-resource struct is the price of a flat shape without a marshaler.)
|
||||
- **Create / Update** return `*singleBody[Model]` and set the model's `ID` from the path (URL wins over body). **Update's request body must be the same `fooReadBody` the read returns, not the bare model** — AutoPatch's GET→PUT round trip echoes the read body (max_permission included) into the PUT, and because `max_permission` is a declared `readOnly` property of `fooReadBody`'s schema, Huma accepts and ignores it on write rather than rejecting it. Take `&in.Body.Foo` (the embedded model — value-embedded, so never nil) and ignore the embedded `MaxPermission`. Create stays a bare `Body Model` (AutoPatch only round-trips into PUT).
|
||||
- **Delete** returns `*emptyBody`.
|
||||
|
||||
### 3. Self-register the resource
|
||||
|
||||
Resources self-register — **you do not edit `pkg/routes/routes.go`**. In your resource file, add an `init()` that hands your registrar to `AddRouteRegistrar`:
|
||||
|
||||
```go
|
||||
func init() { AddRouteRegistrar(RegisterFooRoutes) }
|
||||
|
||||
func RegisterFooRoutes(api huma.API) { ... }
|
||||
```
|
||||
|
||||
`registerAPIRoutesV2` in `routes.go` calls `apiv2.RegisterAll(api)`, which runs every registered registrar (in init/filename order — route order is irrelevant) and then `EnableAutoPatch`. New resources touch zero shared lines, so they never conflict on `routes.go`.
|
||||
|
||||
Notes:
|
||||
|
||||
- **Give each registrar a DISTINCT name.** They share package `apiv2`, so two resources both exporting `RegisterAvatarRoutes` collide and won't compile — that actually happened and the upload one had to be renamed (`RegisterAvatarRoutes` for the binary endpoint vs `RegisterAvatarUploadRoutes` for the upload). Name yours after the specific resource.
|
||||
- **Config-gated resources check the flag inside the registrar.** `RegisterAll` runs at request-router-setup time, after config is loaded, so a `RegisterFooRoutes` may early-return (or skip individual `Register` calls) based on `config.FooEnabled.GetBool()`. Don't try to gate at `init()` time — config isn't loaded yet.
|
||||
- **AutoPatch is automatic.** `RegisterAll` calls `EnableAutoPatch` after all registrars — don't call it yourself, and don't register a manual PATCH (see "What's automatic").
|
||||
|
||||
## REST verb conventions (v2 inverts v1)
|
||||
|
||||
| Operation | v1 | v2 |
|
||||
|---|---|---|
|
||||
| create | PUT | **POST** |
|
||||
| update | POST | **PUT** (and PATCH) |
|
||||
| read / read-all / delete | GET / GET / DELETE | same |
|
||||
|
||||
## Non-CRUDable / custom routes
|
||||
|
||||
Not everything is plain CRUD — bulk operations, custom actions (`POST /tasks/{id}/duplicate`), sub-resource toggles, RPC-ish endpoints. These still go through Huma and reuse most of the machinery, but two responsibilities move **into your handler** because there's no `handler.Do*` doing them for you:
|
||||
|
||||
1. **Permission enforcement is now yours.** This is the one place the "never check permissions in the handler" rule inverts. With no generic `Do*` to call the model's `Can*`, the handler must do it explicitly — load the relevant entity and call its permission method, then refuse on denial. Mirror the v1 custom-handler shape (`pkg/routes/api/v1/task_attachment.go`):
|
||||
```go
|
||||
func tasksDuplicate(ctx context.Context, in *struct{ ID int64 `path:"id"` }) (*singleBody[models.Task], error) {
|
||||
a, err := authFromCtx(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s := db.NewSession()
|
||||
defer s.Close()
|
||||
|
||||
t := &models.Task{ID: in.ID}
|
||||
can, err := t.CanUpdate(s, a) // or whichever Can* gates this action
|
||||
if err != nil {
|
||||
_ = s.Rollback()
|
||||
return nil, translateDomainError(err)
|
||||
}
|
||||
if !can {
|
||||
return nil, huma.Error403Forbidden("forbidden")
|
||||
}
|
||||
// ... do the work against s ...
|
||||
if err := s.Commit(); err != nil {
|
||||
return nil, translateDomainError(err)
|
||||
}
|
||||
return &singleBody[models.Task]{Body: t}, nil
|
||||
}
|
||||
```
|
||||
2. **Session / transaction management is now yours.** The `Do*` helpers open and commit their own `xorm.Session`; custom handlers open one with `db.NewSession()`, `defer s.Close()`, and `Commit`/`Rollback` explicitly for anything that writes.
|
||||
|
||||
Otherwise the same rules apply: register with the `Register` wrapper, pull auth via `authFromCtx`, route every error through `translateDomainError`, and reuse the `types.go` envelopes — or define a small body struct when none fits (don't bend a custom response into `singleBody` if it's awkward).
|
||||
|
||||
**Verb choice:** pick by semantics, not the CRUD table. Non-idempotent actions are `POST`. AutoPatch only synthesises PATCH for GET+PUT *pairs*, so standalone custom routes are never touched.
|
||||
|
||||
**Token permissions still automatic, but mind the derived name:** `collectRoutesForAPITokens` keys a route off its prefix-stripped path, so `POST /api/v2/tasks/{id}/duplicate` lands under the `tasks` group as a `duplicate` permission. Single-segment custom paths fall into the `other` group. Name the path so the derived `(group, permission)` reads sensibly — that string is what users grant tokens against.
|
||||
|
||||
## What's automatic — do NOT hand-roll
|
||||
|
||||
- **PATCH** — `EnableAutoPatch` synthesises a JSON-Merge-Patch PATCH for every GET+PUT pair. `RegisterAll` invokes it after all registrars, so it's automatic — don't call `EnableAutoPatch` and don't register PATCH yourself.
|
||||
- **API token permissions** — `collectRoutesForAPITokens` walks the Echo router after registration, so your new routes land in the v2 token table automatically under the same `(group, permission)` keys as their v1 names. PATCH is intentionally not stored; `CanDoAPIRoute` accepts it as an alias for the stored PUT (see `pkg/models/api_routes.go`).
|
||||
- **Security schemes** — `JWTKeyAuth` + `APITokenAuth` are declared globally in `NewAPI`. For a public endpoint, set `Security: []map[string][]string{}` on that operation and add its path to `unauthenticatedAPIPaths` in `routes.go`.
|
||||
- **Error shape** — `translateDomainError` maps any `web.HTTPErrorProcessor` (e.g. `ErrFooDoesNotExist`) onto Huma's status error, producing RFC 9457 `application/problem+json`. Errors without HTTP semantics become 500.
|
||||
- **OpenAPI spec / Scalar docs / `$schema` URLs** — handled in `huma.go`. Leave `Servers` alone (the relative entry must stay at index 0).
|
||||
|
||||
## Anti-patterns (these get flagged)
|
||||
|
||||
- Re-checking permissions in the handler instead of trusting `handler.Do*` → the model's `Can*`.
|
||||
- Blind `result.([]*models.Foo)` without the `ok` check, or returning the `any` straight into the envelope — silent empty lists.
|
||||
- `huma.Register` instead of the package `Register` wrapper (loses the verb-based status).
|
||||
- Per-operation `Security:` lines (now global) or registering a manual PATCH (AutoPatch does it).
|
||||
- Returning a raw model error instead of routing it through `translateDomainError` → leaks a 500 instead of the right code.
|
||||
- Unquoted ETag in the response header.
|
||||
- Operations without `Summary`/`Description`, or model fields without `doc:` tags — they ship undocumented because Huma can't read Go comments.
|
||||
- Server-controlled fields (`id`, `created`, `updated`, `created_by`) on a shared input/output model left without `readOnly:"true"` — the docs then present them as writable request fields.
|
||||
|
||||
## Tests (mandatory)
|
||||
|
||||
Mirror the v1 webtest shape so v2 parity is readable side-by-side. Use the `webHandlerTestV2` harness in `pkg/webtests/integrations.go` — it takes the same `urlParams` map as v1's `webHandlerTest`. See `pkg/webtests/huma_label_test.go`:
|
||||
|
||||
- One `Test<Resource>` covering list/read/create/update/delete, positive + negative (forbidden, nonexistent), mirroring the v1 model test.
|
||||
- v2-only behaviour (ETag/304, PATCH merge-patch) goes in separate top-level `Test<Resource>_*` funcs using the `humaRequest`/`humaTokenFor` helpers in `pkg/webtests/huma_helpers_test.go`.
|
||||
- The RFC 9457 error-body shape is asserted **once** globally in `TestHuma_ErrorShapeIsRFC9457` — don't re-assert the full problem+json shape per resource, just the status code.
|
||||
|
||||
Run with `mage test:filter Test<Resource>` while iterating. **Caveat:** `mage test:filter` injects `-short`, which makes `pkg/webtests` skip entirely (the suite short-circuits in short mode), so it silently reports success without running your webtest. To actually exercise a single webtest, run it directly: `go test -run '<Name>' ./pkg/webtests/`. Save output to a file per the project test-output rule.
|
||||
|
||||
## Related
|
||||
|
||||
- `crudable` skill — the model-layer prerequisite
|
||||
- `pkg/routes/api/v2/labels.go` — reference resource
|
||||
- `pkg/routes/api/v2/{types,errors,huma}.go` — shared envelopes, bridge, config
|
||||
- `pkg/web/handler/core.go` — the `Do*` functions handlers call
|
||||
|
|
@ -1,49 +0,0 @@
|
|||
---
|
||||
name: crudable
|
||||
description: Use when adding or modifying a model in pkg/models/ that needs CRUD operations or permission checks. Covers Can* method placement, CRUDable interface, and required test coverage.
|
||||
user-invocable: true
|
||||
---
|
||||
|
||||
# CRUDable + Permissions
|
||||
|
||||
Models in `pkg/models/` that expose CRUD operations must implement the `CRUDable` interface **and** the permission methods. Permissions are enforced at the **model level** via `Can*` methods — never re-checked in route handlers.
|
||||
|
||||
**Reference docs:** read `pkg/web/readme.md` for the full interface definitions, DB session semantics, and call order. The interface lives at `pkg/web/web.go`. This skill is a checklist of what the review feedback surfaces on top of that.
|
||||
|
||||
## Before writing CRUD or route code
|
||||
|
||||
1. Decide which operations the model needs: Read / ReadAll / Create / Update / Delete.
|
||||
2. Implement the matching permission methods on the model. Typical signatures:
|
||||
- `CanRead(s *xorm.Session, a web.Auth) (bool, int, error)`
|
||||
- `CanCreate(s *xorm.Session, a web.Auth) (bool, error)`
|
||||
- `CanUpdate(s *xorm.Session, a web.Auth) (bool, error)`
|
||||
- `CanDelete(s *xorm.Session, a web.Auth) (bool, error)`
|
||||
3. If a handler or service needs to check access, call the `Can*` method. Do **not** re-implement the check inline or duplicate the logic in `pkg/routes/`.
|
||||
4. Do not implement empty stub methods just to satisfy the interface, instead embed the interface in the struct. Check existing models to see how that's done.
|
||||
|
||||
Look at `pkg/models/project.go` or `pkg/models/task.go` for reference implementations.
|
||||
|
||||
The initial querying of the data should happen in the Can* function. Because we're operating on a pointer, the function that does the work should not need to re-query the model data.
|
||||
|
||||
## Anti-patterns (these get flagged every time)
|
||||
|
||||
- Permission logic inlined in `pkg/routes/` handlers instead of on the model.
|
||||
- Shipping `Create` but forgetting `CanUpdate` / `CanDelete` because "only create is new right now".
|
||||
- Re-querying the DB in the handler to decide access — that work belongs in `CanRead`.
|
||||
- Copy-pasting permission logic across `CanUpdate` and `CanDelete` — extract a helper.
|
||||
- Adding a handler that bypasses the generic CRUD handler in `pkg/web/handler/` without a clear reason (the generic handler already invokes the `Can*` methods for you).
|
||||
|
||||
## Tests (mandatory)
|
||||
|
||||
Every `Can*` method needs both positive and negative coverage. Run with `mage test:filter <TestName>` while iterating.
|
||||
|
||||
- User with direct permission → passes
|
||||
- User without permission → denied
|
||||
- Permission inherited via parent (e.g., project → task, team → project) → still passes
|
||||
- Shared access edge cases (link shares, team membership) if the model supports them
|
||||
|
||||
## Related
|
||||
|
||||
- Generic CRUD handler: `pkg/web/handler/`
|
||||
- Permission type definitions: `pkg/web/auth.go`, `pkg/models/permissions.go`
|
||||
- After the model is stable, register the routes in `pkg/routes/api/v1/` and add Swagger annotations. Do not edit `pkg/swagger/` directly — it's generated.
|
||||
|
|
@ -1,55 +0,0 @@
|
|||
---
|
||||
name: migration
|
||||
description: Use when creating or editing files in pkg/migration/. Covers cross-DB type safety across MySQL/PostgreSQL/SQLite, DDL error handling, time-column conventions, and path sanitization.
|
||||
user-invocable: true
|
||||
---
|
||||
|
||||
# Database Migrations
|
||||
|
||||
Migrations are **irreversible in production**. Vikunja supports MySQL, PostgreSQL, and SQLite — every migration must work on all three.
|
||||
|
||||
## Before writing
|
||||
|
||||
1. Generate the skeleton: `mage dev:make-migration <StructName>`.
|
||||
2. The migration struct must mirror the model in `pkg/models/` exactly (field names, types, xorm tags).
|
||||
3. Use `time.Time` for time columns. Never use `string`, `varchar`, or `text` for times.
|
||||
4. For renames or type changes, verify the conversion is safe on all three DBs:
|
||||
- MySQL will silently coerce `VARCHAR` → `BIGINT` during `ALTER`. Don't rely on that — migrate data explicitly.
|
||||
- SQLite has limited `ALTER TABLE`; prefer `xorm` migration helpers over raw SQL when possible.
|
||||
- PostgreSQL is strict about types; explicit casts are often required.
|
||||
|
||||
## Error handling on DDL
|
||||
|
||||
Every error from `tx.Exec`, `session.Exec`, or xorm calls must be handled. Silent discards are the most commonly flagged bug in migration reviews.
|
||||
|
||||
```go
|
||||
// WRONG — silently drops errors; migration reports success even on failure
|
||||
_, _ = tx.Exec("CREATE INDEX idx_foo ON bar(baz)")
|
||||
|
||||
// RIGHT — error is returned so the migration rolls back cleanly
|
||||
if _, err := tx.Exec("CREATE INDEX idx_foo ON bar(baz)"); err != nil {
|
||||
return err
|
||||
}
|
||||
```
|
||||
|
||||
If you **must** discard a DB error (e.g., idempotent best-effort cleanup where the index might already exist), write a one-line comment explaining why. No comment = reviewer will flag it.
|
||||
|
||||
## Path and user input
|
||||
|
||||
If the migration touches user-supplied paths, filenames, or import blobs (restore, dump, import modules under `pkg/modules/migration/`), sanitize before use. Never `filepath.Join` raw input. Watch for `..` traversal in archive entry names.
|
||||
|
||||
## Model and frontend sync
|
||||
|
||||
- If the migration adds or changes a field, update the struct in `pkg/models/` with matching xorm tags.
|
||||
- Update the TypeScript interface in `frontend/src/modelTypes/` to match the Go struct shape. Frontend services must match backend model structure exactly.
|
||||
|
||||
## Testing
|
||||
|
||||
- Migrations don't have dedicated unit tests, but the model's feature tests must pass against the new schema. Run `mage test:feature` (uses SQLite by default).
|
||||
- If you suspect DB-specific behavior, flag it in the PR description so reviewers know to verify against MySQL/PostgreSQL.
|
||||
|
||||
## Related
|
||||
|
||||
- Existing examples: browse `pkg/migration/` for patterns; recent files are usually the cleanest references.
|
||||
- Never edit `pkg/swagger/` (generated).
|
||||
- Never commit `config.yml.sample` (generated by `mage generate:config-yaml`).
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
source_url "https://raw.githubusercontent.com/cachix/devenv/95f329d49a8a5289d31e0982652f7058a189bfca/direnvrc" "sha256-d+8cBpDfDBj41inrADaJt+bDWhOktwslgoP5YiGJ1v0="
|
||||
|
||||
use devenv
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
self-hosted-runner:
|
||||
# Custom labels from third-party runner providers used in our workflows.
|
||||
# Listed here so actionlint doesn't flag them as unknown.
|
||||
labels:
|
||||
- namespace-profile-default
|
||||
- blacksmith-8vcpu-ubuntu-2204
|
||||
|
|
@ -1,189 +0,0 @@
|
|||
name: Release binaries
|
||||
description: |
|
||||
Build, sign, and publish release binaries for a Vikunja sub-project.
|
||||
|
||||
Derives every per-project path, cache key, artifact name, and S3 target
|
||||
from the `project` input. Callers only need to provide the project name,
|
||||
the raw `git describe` value, and pass through the GPG/S3 secrets as
|
||||
inputs (composite actions can't read the `secrets` context directly).
|
||||
|
||||
inputs:
|
||||
project:
|
||||
description: 'Which project to build: "vikunja" or "veans".'
|
||||
required: true
|
||||
release-version:
|
||||
description: |
|
||||
Raw git describe value (e.g. v1.2.3 or v2.3.0-408-ge053d317). Always
|
||||
passed through to the build so the binary embeds the precise commit.
|
||||
Filenames and the S3 directory use "unstable" instead whenever
|
||||
github.ref_type isn't "tag".
|
||||
required: true
|
||||
# Secrets — composite actions can't read the `secrets` context directly, so
|
||||
# the caller threads them through as inputs.
|
||||
gpg-passphrase:
|
||||
required: true
|
||||
gpg-sign-key:
|
||||
required: true
|
||||
s3-access-key-id:
|
||||
required: true
|
||||
s3-secret-access-key:
|
||||
required: true
|
||||
s3-endpoint:
|
||||
required: true
|
||||
s3-bucket:
|
||||
required: true
|
||||
s3-region:
|
||||
required: true
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Set project paths
|
||||
shell: bash
|
||||
env:
|
||||
PROJECT: ${{ inputs.project }}
|
||||
RELEASE_VERSION_INPUT: ${{ inputs.release-version }}
|
||||
VERSION_OR_UNSTABLE: ${{ github.ref_type == 'tag' && inputs.release-version || 'unstable' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
case "$PROJECT" in
|
||||
vikunja|veans) ;;
|
||||
*)
|
||||
echo "::error::Unknown project '$PROJECT'. Expected 'vikunja' or 'veans'." >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
case "$PROJECT" in
|
||||
vikunja)
|
||||
output_dir="."
|
||||
dist_prefix="dist"
|
||||
;;
|
||||
veans)
|
||||
output_dir="veans"
|
||||
dist_prefix="veans/dist"
|
||||
;;
|
||||
esac
|
||||
|
||||
{
|
||||
echo "PROJECT=$PROJECT"
|
||||
echo "RELEASE_VERSION=$RELEASE_VERSION_INPUT"
|
||||
echo "VERSION_OR_UNSTABLE=$VERSION_OR_UNSTABLE"
|
||||
echo "XGO_OUT_NAME=${PROJECT}-${VERSION_OR_UNSTABLE}"
|
||||
echo "OUTPUT_DIR=$output_dir"
|
||||
echo "DIST_PREFIX=$dist_prefix"
|
||||
echo "S3_TARGET_PATH=/${PROJECT}/${VERSION_OR_UNSTABLE}"
|
||||
echo "ARTIFACT_BINARIES_NAME=${PROJECT}_bins"
|
||||
echo "ARTIFACT_ZIPS_NAME=${PROJECT}_bin_packages"
|
||||
} >> "$GITHUB_ENV"
|
||||
|
||||
- name: Download Mage binary
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
with:
|
||||
name: mage_bin
|
||||
|
||||
- name: Make mage-static executable
|
||||
shell: bash
|
||||
run: chmod +x ./mage-static
|
||||
|
||||
- name: Download frontend dist (vikunja only)
|
||||
if: inputs.project == 'vikunja'
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
with:
|
||||
name: frontend_dist
|
||||
path: frontend/dist
|
||||
|
||||
- name: Generate config.yml.sample (vikunja only)
|
||||
if: inputs.project == 'vikunja'
|
||||
shell: bash
|
||||
run: ./mage-static generate:config-yaml 1
|
||||
|
||||
- name: Install upx
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
wget -q https://github.com/upx/upx/releases/download/v5.0.0/upx-5.0.0-amd64_linux.tar.xz
|
||||
echo 'b32abf118d721358a50f1aa60eacdbf3298df379c431c3a86f139173ab8289a1 upx-5.0.0-amd64_linux.tar.xz' > upx-5.0.0-amd64_linux.tar.xz.sha256
|
||||
sha256sum -c upx-5.0.0-amd64_linux.tar.xz.sha256
|
||||
tar xf upx-5.0.0-amd64_linux.tar.xz
|
||||
sudo mv upx-5.0.0-amd64_linux/upx /usr/local/bin
|
||||
|
||||
- name: Setup xgo cache
|
||||
uses: useblacksmith/cache@c5fe29eb0efdf1cf4186b9f7fcbbcbc0cf025662 # v5.1.0
|
||||
with:
|
||||
path: /home/runner/.xgo-cache
|
||||
key: xgo-${{ inputs.project }}-${{ hashFiles('**/go.sum') }}
|
||||
restore-keys: |
|
||||
xgo-${{ inputs.project }}-
|
||||
|
||||
- name: Install mage for the build module
|
||||
shell: bash
|
||||
run: go install github.com/magefile/mage@v1.17.2
|
||||
|
||||
- name: Build release artifacts
|
||||
shell: bash
|
||||
env:
|
||||
RELEASE_VERSION: ${{ env.RELEASE_VERSION }}
|
||||
XGO_OUT_NAME: ${{ env.XGO_OUT_NAME }}
|
||||
PROJECT: ${{ env.PROJECT }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
export PATH="$PATH:$(go env GOPATH)/bin"
|
||||
cd build && mage release:build "$PROJECT"
|
||||
|
||||
- name: GPG setup
|
||||
uses: kolaente/action-gpg@eb0fd8f16fe9b499f060f659092c470cb9f76eb7 # main
|
||||
with:
|
||||
gpg-passphrase: ${{ inputs.gpg-passphrase }}
|
||||
gpg-sign-key: ${{ inputs.gpg-sign-key }}
|
||||
|
||||
- name: Sign zips
|
||||
shell: bash
|
||||
env:
|
||||
DIST_PREFIX: ${{ env.DIST_PREFIX }}
|
||||
RELEASE_GPG_PASSPHRASE: ${{ inputs.gpg-passphrase }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
zip_dir="${DIST_PREFIX}/zip"
|
||||
echo "=== GPG agent status ==="
|
||||
gpg-connect-agent 'keyinfo --list' /bye || true
|
||||
echo "=== GPG secret keys ==="
|
||||
gpg -K --with-keygrip
|
||||
echo "=== GPG public keys ==="
|
||||
gpg --list-keys
|
||||
echo "=== Signing files in $zip_dir ==="
|
||||
ls -hal "$zip_dir"/*
|
||||
for file in "$zip_dir"/*; do
|
||||
gpg -v \
|
||||
--default-key 7D061A4AA61436B40713D42EFF054DACD908493A \
|
||||
-b --batch --yes \
|
||||
--passphrase "$RELEASE_GPG_PASSPHRASE" \
|
||||
--pinentry-mode loopback \
|
||||
--sign "$file"
|
||||
done
|
||||
|
||||
- name: Upload zips to S3
|
||||
uses: kolaente/s3-action@7f58dddd682b2f93a6c6799c9f68e7a38f2da558 # main
|
||||
with:
|
||||
s3-access-key-id: ${{ inputs.s3-access-key-id }}
|
||||
s3-secret-access-key: ${{ inputs.s3-secret-access-key }}
|
||||
s3-endpoint: ${{ inputs.s3-endpoint }}
|
||||
s3-bucket: ${{ inputs.s3-bucket }}
|
||||
s3-region: ${{ inputs.s3-region }}
|
||||
target-path: ${{ env.S3_TARGET_PATH }}
|
||||
files: ${{ env.DIST_PREFIX }}/zip/*
|
||||
strip-path-prefix: ${{ env.DIST_PREFIX }}/zip/
|
||||
|
||||
- name: Store binaries
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: ${{ env.ARTIFACT_BINARIES_NAME }}
|
||||
path: ./${{ env.DIST_PREFIX }}/binaries/*
|
||||
|
||||
- name: Store binary packages
|
||||
if: github.ref_type == 'tag'
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: ${{ env.ARTIFACT_ZIPS_NAME }}
|
||||
path: ./${{ env.DIST_PREFIX }}/zip/*
|
||||
|
|
@ -1,204 +0,0 @@
|
|||
name: Release OS package
|
||||
description: >
|
||||
Build a single deb/rpm/apk/archlinux package for the given project + arch
|
||||
via nfpm, optionally GPG-sign it (archlinux is signed inline; rpm is signed
|
||||
by nfpm itself), upload it to S3, and store it as a workflow artifact.
|
||||
|
||||
Most paths and names are derived from `project`; the matrix only needs to
|
||||
supply the per-arch and per-format inputs.
|
||||
|
||||
inputs:
|
||||
project:
|
||||
description: 'Project name (vikunja | veans). Drives all derived paths.'
|
||||
required: true
|
||||
release-version:
|
||||
description: |
|
||||
RELEASE_VERSION env value — the same version that ended up in the
|
||||
binaries artifact. Always embedded in the package metadata via
|
||||
nfpm; filenames and the S3 directory use "unstable" instead
|
||||
whenever github.ref_type isn't "tag".
|
||||
required: true
|
||||
packager:
|
||||
description: 'nfpm packager: rpm | deb | apk | archlinux.'
|
||||
required: true
|
||||
nfpm-arch:
|
||||
description: 'nfpm arch field (amd64 | arm64 | arm7).'
|
||||
required: true
|
||||
pkg-arch:
|
||||
description: 'Package-format arch used in the output filename (x86_64 | aarch64 | armv7).'
|
||||
required: true
|
||||
go-name:
|
||||
description: 'Go-style arch token used in the binary filename (linux-amd64 | linux-arm64 | linux-arm-7).'
|
||||
required: true
|
||||
# Secrets — composite actions can't read `${{ secrets.* }}` directly, so the
|
||||
# caller threads them through as inputs.
|
||||
gpg-passphrase:
|
||||
required: true
|
||||
gpg-sign-key:
|
||||
required: true
|
||||
s3-access-key-id:
|
||||
required: true
|
||||
s3-secret-access-key:
|
||||
required: true
|
||||
s3-endpoint:
|
||||
required: true
|
||||
s3-bucket:
|
||||
required: true
|
||||
s3-region:
|
||||
required: true
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Set project paths
|
||||
shell: bash
|
||||
env:
|
||||
PROJECT: ${{ inputs.project }}
|
||||
RELEASE_VERSION: ${{ inputs.release-version }}
|
||||
VERSION_OR_UNSTABLE: ${{ github.ref_type == 'tag' && inputs.release-version || 'unstable' }}
|
||||
PACKAGER: ${{ inputs.packager }}
|
||||
PKG_ARCH: ${{ inputs.pkg-arch }}
|
||||
GO_NAME: ${{ inputs.go-name }}
|
||||
run: |
|
||||
case "$PROJECT" in
|
||||
vikunja)
|
||||
echo "BINARIES_DOWNLOAD_PATH=." >> "$GITHUB_ENV"
|
||||
echo "STAGED_BINARY_PATH=./vikunja" >> "$GITHUB_ENV"
|
||||
echo "NFPM_BIN_PATH=" >> "$GITHUB_ENV"
|
||||
echo "NFPM_CONFIG_PATH=./nfpm.yaml" >> "$GITHUB_ENV"
|
||||
# No leading "./" — the s3-action's strip-path-prefix must
|
||||
# match the glob output exactly, and the glob doesn't emit it.
|
||||
echo "PACKAGE_OUTPUT_DIR=dist/os-packages" >> "$GITHUB_ENV"
|
||||
;;
|
||||
veans)
|
||||
echo "BINARIES_DOWNLOAD_PATH=./veans-binaries" >> "$GITHUB_ENV"
|
||||
echo "STAGED_BINARY_PATH=./veans/veans-bin" >> "$GITHUB_ENV"
|
||||
echo "NFPM_BIN_PATH=./veans/veans-bin" >> "$GITHUB_ENV"
|
||||
echo "NFPM_CONFIG_PATH=./veans/nfpm.yaml" >> "$GITHUB_ENV"
|
||||
echo "PACKAGE_OUTPUT_DIR=veans/dist/os-packages" >> "$GITHUB_ENV"
|
||||
;;
|
||||
*)
|
||||
echo "::error::unknown project '$PROJECT' (expected vikunja|veans)"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
echo "VERSION_OR_UNSTABLE=$VERSION_OR_UNSTABLE" >> "$GITHUB_ENV"
|
||||
echo "BINARIES_ARTIFACT_NAME=${PROJECT}_bins" >> "$GITHUB_ENV"
|
||||
echo "BINARY_GLOB=${PROJECT}-*-${GO_NAME}" >> "$GITHUB_ENV"
|
||||
echo "PACKAGE_FILENAME=${PROJECT}-${VERSION_OR_UNSTABLE}-${PKG_ARCH}.${PACKAGER}" >> "$GITHUB_ENV"
|
||||
echo "ARTIFACT_NAME=${PROJECT}_os_package_${PACKAGER}_${PKG_ARCH}" >> "$GITHUB_ENV"
|
||||
echo "S3_TARGET_PATH=/${PROJECT}/${VERSION_OR_UNSTABLE}" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Download project binaries
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
with:
|
||||
name: ${{ env.BINARIES_ARTIFACT_NAME }}
|
||||
path: ${{ env.BINARIES_DOWNLOAD_PATH }}
|
||||
|
||||
- uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
|
||||
with:
|
||||
go-version: stable
|
||||
|
||||
- name: Install mage
|
||||
shell: bash
|
||||
run: go install github.com/magefile/mage@v1.17.2
|
||||
|
||||
- name: Generate config.yml.sample (vikunja only)
|
||||
# vikunja's nfpm.yaml ships ./config.yml.sample as /etc/vikunja/config.yml.
|
||||
# release-binaries generates it for the zip bundles, but this job runs on a
|
||||
# fresh runner, so we regenerate it here before nfpm packs it.
|
||||
if: inputs.project == 'vikunja'
|
||||
shell: bash
|
||||
run: |
|
||||
export PATH=$PATH:$GOPATH/bin
|
||||
mage generate:config-yaml 1
|
||||
|
||||
- name: Write GPG key for nfpm
|
||||
if: inputs.packager == 'rpm'
|
||||
shell: bash
|
||||
env:
|
||||
RELEASE_GPG_SIGN_KEY: ${{ inputs.gpg-sign-key }}
|
||||
run: printf '%s' "$RELEASE_GPG_SIGN_KEY" > /tmp/nfpm-signing-key.gpg
|
||||
|
||||
- name: GPG setup for archlinux signing
|
||||
if: inputs.packager == 'archlinux'
|
||||
uses: kolaente/action-gpg@eb0fd8f16fe9b499f060f659092c470cb9f76eb7 # main
|
||||
with:
|
||||
gpg-passphrase: ${{ inputs.gpg-passphrase }}
|
||||
gpg-sign-key: ${{ inputs.gpg-sign-key }}
|
||||
|
||||
- name: Prepare nfpm config
|
||||
shell: bash
|
||||
working-directory: build
|
||||
env:
|
||||
RELEASE_VERSION: ${{ inputs.release-version }}
|
||||
NFPM_ARCH: ${{ inputs.nfpm-arch }}
|
||||
NFPM_BIN_PATH: ${{ env.NFPM_BIN_PATH }}
|
||||
PROJECT: ${{ inputs.project }}
|
||||
run: |
|
||||
export PATH=$PATH:$GOPATH/bin
|
||||
mage release:prepare-nfpm-config "$PROJECT" "$NFPM_ARCH"
|
||||
|
||||
- name: Stage binary
|
||||
shell: bash
|
||||
run: |
|
||||
# Resolve the single matching binary and mv it into place.
|
||||
matched=()
|
||||
for f in $BINARIES_DOWNLOAD_PATH/$BINARY_GLOB; do
|
||||
[ -e "$f" ] || continue
|
||||
matched+=("$f")
|
||||
done
|
||||
if [ ${#matched[@]} -ne 1 ]; then
|
||||
echo "::error::expected exactly 1 binary matching '$BINARIES_DOWNLOAD_PATH/$BINARY_GLOB', found ${#matched[@]}"
|
||||
ls -la "$BINARIES_DOWNLOAD_PATH" || true
|
||||
exit 1
|
||||
fi
|
||||
mkdir -p "$(dirname "$STAGED_BINARY_PATH")"
|
||||
mv "${matched[0]}" "$STAGED_BINARY_PATH"
|
||||
chmod +x "$STAGED_BINARY_PATH"
|
||||
|
||||
- name: Ensure package output dir exists
|
||||
shell: bash
|
||||
run: mkdir -p "$PACKAGE_OUTPUT_DIR"
|
||||
|
||||
- name: Create package
|
||||
uses: kolaente/action-gh-nfpm@08460c16ce3baaa48eaf94d51eea0e653b15d955 # master
|
||||
with:
|
||||
packager: ${{ inputs.packager }}
|
||||
target: ${{ env.PACKAGE_OUTPUT_DIR }}/${{ env.PACKAGE_FILENAME }}
|
||||
config: ${{ env.NFPM_CONFIG_PATH }}
|
||||
env:
|
||||
NFPM_GPG_KEY_FILE: ${{ inputs.packager == 'rpm' && '/tmp/nfpm-signing-key.gpg' || '' }}
|
||||
NFPM_PASSPHRASE: ${{ inputs.packager == 'rpm' && inputs.gpg-passphrase || '' }}
|
||||
|
||||
- name: Sign archlinux package
|
||||
if: inputs.packager == 'archlinux'
|
||||
shell: bash
|
||||
env:
|
||||
GPG_PASSPHRASE: ${{ inputs.gpg-passphrase }}
|
||||
run: |
|
||||
gpg --default-key 7D061A4AA61436B40713D42EFF054DACD908493A \
|
||||
--batch --yes \
|
||||
--passphrase "$GPG_PASSPHRASE" \
|
||||
--pinentry-mode loopback \
|
||||
--detach-sign \
|
||||
"$PACKAGE_OUTPUT_DIR/$PACKAGE_FILENAME"
|
||||
|
||||
- name: Upload to S3
|
||||
uses: kolaente/s3-action@7f58dddd682b2f93a6c6799c9f68e7a38f2da558 # main
|
||||
with:
|
||||
s3-access-key-id: ${{ inputs.s3-access-key-id }}
|
||||
s3-secret-access-key: ${{ inputs.s3-secret-access-key }}
|
||||
s3-endpoint: ${{ inputs.s3-endpoint }}
|
||||
s3-bucket: ${{ inputs.s3-bucket }}
|
||||
s3-region: ${{ inputs.s3-region }}
|
||||
target-path: ${{ env.S3_TARGET_PATH }}
|
||||
files: ${{ env.PACKAGE_OUTPUT_DIR }}/*
|
||||
strip-path-prefix: ${{ env.PACKAGE_OUTPUT_DIR }}/
|
||||
|
||||
- name: Store OS package
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: ${{ env.ARTIFACT_NAME }}
|
||||
path: ${{ env.PACKAGE_OUTPUT_DIR }}/*
|
||||
|
|
@ -16,11 +16,11 @@ runs:
|
|||
echo "CYPRESS_INSTALL_BINARY=0" >> $GITHUB_ENV
|
||||
echo "PUPPETEER_SKIP_DOWNLOAD=true" >> $GITHUB_ENV
|
||||
echo "PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1" >> $GITHUB_ENV
|
||||
- uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
|
||||
- uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4
|
||||
with:
|
||||
run_install: false
|
||||
package_json_file: frontend/package.json
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6
|
||||
with:
|
||||
node-version-file: frontend/.nvmrc
|
||||
cache: 'pnpm'
|
||||
|
|
|
|||
|
|
@ -1,47 +0,0 @@
|
|||
You are a triage assistant for the Vikunja repository. Your job is to classify a single issue or pull request using the label taxonomy below, and return ONLY a JSON array of chosen label names — nothing else.
|
||||
|
||||
# Output format
|
||||
|
||||
Return exactly a JSON array of strings, e.g.:
|
||||
|
||||
["area/kanban", "area/recurring-tasks", "concern/regression"]
|
||||
|
||||
No prose, no markdown fences, no explanation. If you cannot confidently classify, return an empty array: []
|
||||
|
||||
# Rules
|
||||
|
||||
1. Every well-formed item gets at least one `area/*` label. If you truly cannot pick one, return [].
|
||||
2. Multi-label is the norm. 2–4 labels is typical, occasionally up to 6.
|
||||
3. `concern/*` is additive — it describes a cross-cutting quality (UX polish, performance, a11y, regression) on top of the feature area.
|
||||
4. `integration/*` applies only when the item is about connecting to a *specific third-party system* (Slack, Gotify, Apprise, external webhooks, WeKan import, Todoist import, add-task-from-email, MCP, etc.).
|
||||
- CalDAV is its own `area/caldav` — do NOT also tag `integration/*`.
|
||||
- Generic webhook infrastructure is `area/webhooks`; a PR adding Slack delivery is `area/webhooks` + `integration/outbound`.
|
||||
5. `db/mysql`, `db/postgres`, `db/sqlite` ONLY when the item is explicitly engine-specific (e.g. "fails on MySQL 8"). General DB issues get `area/database` with no engine tag.
|
||||
6. `concern/regression` ONLY if the body explicitly says it worked in a prior version and is broken now.
|
||||
7. Do NOT invent labels. Only use names from the taxonomy below — anything else will be discarded.
|
||||
|
||||
# Taxonomy
|
||||
|
||||
The following labels are available. Each line is `label-name — description`. Pick only from this list.
|
||||
|
||||
{{TAXONOMY}}
|
||||
|
||||
# Examples
|
||||
|
||||
Input:
|
||||
TITLE: SQL syntax error on MySQL due to CAST in is_archived computation
|
||||
BODY: After upgrading to 2.3.0 I get SQL syntax errors on MySQL 8. Worked fine on 2.2.x.
|
||||
Output:
|
||||
["area/database", "db/mysql", "concern/regression"]
|
||||
|
||||
Input:
|
||||
TITLE: feat: add Slack webhook support
|
||||
BODY: Adds outbound Slack notifications when tasks change.
|
||||
Output:
|
||||
["area/webhooks", "area/notifications", "integration/outbound"]
|
||||
|
||||
Input:
|
||||
TITLE: Mobile: "Mark task done" should be easier to find
|
||||
BODY: The checkbox is too small on phones.
|
||||
Output:
|
||||
["area/mobile", "area/task-editor", "concern/ux"]
|
||||
|
|
@ -1,202 +0,0 @@
|
|||
name: Auto-label new issues and PRs
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [opened]
|
||||
pull_request_target:
|
||||
types: [opened]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
pull-requests: write
|
||||
models: read
|
||||
|
||||
concurrency:
|
||||
group: auto-label-${{ github.event.issue.number || github.event.pull_request.number }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
classify:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout (for prompt template)
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
sparse-checkout: |
|
||||
.github/workflows/auto-label.prompt.md
|
||||
sparse-checkout-cone-mode: false
|
||||
|
||||
- name: Render system prompt from live labels
|
||||
id: render
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
env:
|
||||
PROMPT_TEMPLATE_PATH: .github/workflows/auto-label.prompt.md
|
||||
with:
|
||||
script: |
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// Fetch every label in the repo, keep only the managed namespaces.
|
||||
const managedPrefixes = ['area/', 'integration/', 'db/', 'concern/'];
|
||||
const all = await github.paginate(
|
||||
github.rest.issues.listLabelsForRepo,
|
||||
{ owner: context.repo.owner, repo: context.repo.repo, per_page: 100 }
|
||||
);
|
||||
const managed = all
|
||||
.filter(l => managedPrefixes.some(p => l.name.startsWith(p)))
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
if (managed.length === 0) {
|
||||
core.setFailed('No managed labels found on the repo — cannot build taxonomy.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Warn about labels without descriptions — they confuse the classifier.
|
||||
const undescribed = managed.filter(l => !l.description || !l.description.trim());
|
||||
if (undescribed.length > 0) {
|
||||
core.warning(
|
||||
`Labels without descriptions will be skipped: ${undescribed.map(l => l.name).join(', ')}`
|
||||
);
|
||||
}
|
||||
|
||||
// Group by namespace for readability in the prompt.
|
||||
const groups = {};
|
||||
for (const l of managed) {
|
||||
if (!l.description || !l.description.trim()) continue;
|
||||
const prefix = managedPrefixes.find(p => l.name.startsWith(p));
|
||||
(groups[prefix] ||= []).push(l);
|
||||
}
|
||||
|
||||
const sections = [];
|
||||
for (const prefix of managedPrefixes) {
|
||||
const entries = groups[prefix] || [];
|
||||
if (entries.length === 0) continue;
|
||||
sections.push(`## ${prefix}*\n`);
|
||||
for (const l of entries) {
|
||||
sections.push(`- \`${l.name}\` — ${l.description.trim()}`);
|
||||
}
|
||||
sections.push('');
|
||||
}
|
||||
const taxonomy = sections.join('\n');
|
||||
|
||||
// Expand the template.
|
||||
const templatePath = process.env.PROMPT_TEMPLATE_PATH;
|
||||
const template = fs.readFileSync(templatePath, 'utf8');
|
||||
if (!template.includes('{{TAXONOMY}}')) {
|
||||
core.setFailed(`Template ${templatePath} is missing the {{TAXONOMY}} placeholder.`);
|
||||
return;
|
||||
}
|
||||
const rendered = template.replace('{{TAXONOMY}}', taxonomy);
|
||||
|
||||
const outPath = path.join(process.env.RUNNER_TEMP, 'system-prompt.md');
|
||||
fs.writeFileSync(outPath, rendered);
|
||||
core.setOutput('system_prompt_path', outPath);
|
||||
core.info(`Rendered ${managed.length} labels into ${outPath}`);
|
||||
|
||||
- name: Build user prompt
|
||||
id: prep
|
||||
env:
|
||||
TITLE: ${{ github.event.issue.title || github.event.pull_request.title }}
|
||||
BODY: ${{ github.event.issue.body || github.event.pull_request.body }}
|
||||
KIND: ${{ github.event_name == 'issues' && 'issue' || 'pull request' }}
|
||||
run: |
|
||||
mkdir -p "$RUNNER_TEMP/ai"
|
||||
python3 - <<'PY' > "$RUNNER_TEMP/ai/user-prompt.txt"
|
||||
import os
|
||||
title = os.environ.get("TITLE", "").strip()
|
||||
body = (os.environ.get("BODY", "") or "").strip() or "(no description)"
|
||||
kind = os.environ.get("KIND", "issue")
|
||||
# Truncate very long bodies to keep token usage predictable
|
||||
if len(body) > 8000:
|
||||
body = body[:8000] + "\n\n[... truncated ...]"
|
||||
print(f"Classify the following {kind}. Return ONLY a JSON array of labels.\n")
|
||||
print("--- TITLE ---")
|
||||
print(title)
|
||||
print()
|
||||
print("--- BODY ---")
|
||||
print(body)
|
||||
print("--- END ---")
|
||||
PY
|
||||
echo "prompt_path=$RUNNER_TEMP/ai/user-prompt.txt" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Classify with AI
|
||||
id: classify
|
||||
uses: actions/ai-inference@a7805884c80886efc241e94a5351df715968a0ad # v2.1.1
|
||||
with:
|
||||
model: openai/gpt-4.1-mini
|
||||
# GPT-5 is a reasoning model: output tokens include reasoning, so budget generously.
|
||||
# Temperature is ignored by reasoning models and intentionally omitted.
|
||||
max-completion-tokens: 2000
|
||||
system-prompt-file: ${{ steps.render.outputs.system_prompt_path }}
|
||||
prompt-file: ${{ steps.prep.outputs.prompt_path }}
|
||||
|
||||
- name: Apply labels
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
env:
|
||||
AI_RESPONSE: ${{ steps.classify.outputs.response }}
|
||||
with:
|
||||
script: |
|
||||
const raw = (process.env.AI_RESPONSE || '').trim();
|
||||
core.info(`Raw AI response:\n${raw}`);
|
||||
|
||||
// Extract the first JSON array from the response (tolerates stray prose or code fences)
|
||||
const match = raw.match(/\[[\s\S]*\]/);
|
||||
if (!match) {
|
||||
core.warning('No JSON array found in AI response — skipping labeling.');
|
||||
return;
|
||||
}
|
||||
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(match[0]);
|
||||
} catch (e) {
|
||||
core.warning(`Failed to parse JSON array: ${e.message}`);
|
||||
return;
|
||||
}
|
||||
if (!Array.isArray(parsed)) {
|
||||
core.warning('AI response JSON is not an array — skipping.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Re-validate against live repo labels. Same source of truth as the prompt renderer,
|
||||
// so drift is impossible — any label the model picks MUST exist in the repo.
|
||||
const managedPrefixes = ['area/', 'integration/', 'db/', 'concern/'];
|
||||
const allRepoLabels = await github.paginate(
|
||||
github.rest.issues.listLabelsForRepo,
|
||||
{ owner: context.repo.owner, repo: context.repo.repo, per_page: 100 }
|
||||
);
|
||||
const allowed = new Set(
|
||||
allRepoLabels
|
||||
.map(l => l.name)
|
||||
.filter(n => managedPrefixes.some(p => n.startsWith(p)))
|
||||
);
|
||||
|
||||
const valid = [...new Set(parsed)].filter(
|
||||
l => typeof l === 'string' && allowed.has(l)
|
||||
);
|
||||
const rejected = parsed.filter(l => !valid.includes(l));
|
||||
|
||||
if (rejected.length > 0) {
|
||||
core.warning(`Ignored unknown labels: ${JSON.stringify(rejected)}`);
|
||||
}
|
||||
|
||||
// Cap at 6 labels — our taxonomy rule says 2–4 is typical, 6 is the ceiling.
|
||||
const toApply = valid.slice(0, 6);
|
||||
|
||||
if (toApply.length === 0) {
|
||||
core.info('No valid labels selected — leaving item unlabeled for human triage.');
|
||||
return;
|
||||
}
|
||||
|
||||
const number =
|
||||
context.payload.issue?.number ?? context.payload.pull_request.number;
|
||||
|
||||
await github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: number,
|
||||
labels: toApply,
|
||||
});
|
||||
|
||||
core.info(`Applied labels to #${number}: ${toApply.join(', ')}`);
|
||||
|
|
@ -9,19 +9,19 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||
with:
|
||||
ssh-key: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||
persist-credentials: true
|
||||
- name: push source files
|
||||
uses: crowdin/github-action@52aa776766211d83d975df51f3b9c53c2f8ba35f # v2.16.3
|
||||
uses: crowdin/github-action@b4b468cffefb50bdd99dd83e5d2eaeb63c880380 # v2
|
||||
with:
|
||||
command: 'push'
|
||||
env:
|
||||
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
|
||||
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
|
||||
- name: pull translations
|
||||
uses: crowdin/github-action@52aa776766211d83d975df51f3b9c53c2f8ba35f # v2.16.3
|
||||
uses: crowdin/github-action@b4b468cffefb50bdd99dd83e5d2eaeb63c880380 # v2
|
||||
with:
|
||||
command: 'download'
|
||||
command_args: '--export-only-approved --skip-untranslated-strings'
|
||||
|
|
@ -29,7 +29,7 @@ jobs:
|
|||
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
|
||||
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6
|
||||
with:
|
||||
node-version-file: frontend/.nvmrc
|
||||
- name: Ensure file permissions
|
||||
|
|
@ -41,7 +41,7 @@ jobs:
|
|||
- name: Check for changes
|
||||
id: check_changes
|
||||
run: |
|
||||
if [ -z "$(git status --porcelain pkg/i18n/lang frontend/src/i18n/lang)" ]; then
|
||||
if git diff --quiet; then
|
||||
echo "changes_exist=0" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "changes_exist=1" >> "$GITHUB_OUTPUT"
|
||||
|
|
@ -51,11 +51,10 @@ jobs:
|
|||
run: |
|
||||
git config --local user.email "bot@vikunja.io"
|
||||
git config --local user.name "Frederick [Bot]"
|
||||
git add pkg/i18n/lang frontend/src/i18n/lang
|
||||
git commit -m "chore(i18n): update translations via Crowdin"
|
||||
git commit -am "chore(i18n): update translations via Crowdin"
|
||||
- name: Push changes
|
||||
if: steps.check_changes.outputs.changes_exist != '0'
|
||||
uses: ad-m/github-push-action@881a6320fdb16eb5318c5054f31c218aec2b324c # master
|
||||
uses: ad-m/github-push-action@master
|
||||
with:
|
||||
ssh: true
|
||||
branch: ${{ github.ref }}
|
||||
|
|
|
|||
|
|
@ -18,11 +18,11 @@ jobs:
|
|||
directory: [frontend, desktop]
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Create Diff
|
||||
uses: e18e/action-dependency-diff@8e9b8c1957ab066d36235a43f4c1ff1522e1bdbc # v1.6.1
|
||||
uses: e18e/action-dependency-diff@v1
|
||||
with:
|
||||
working-directory: ${{ matrix.directory }}
|
||||
|
||||
|
|
@ -33,11 +33,11 @@ jobs:
|
|||
directory: [frontend, desktop]
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Check provenance downgrades
|
||||
uses: danielroe/provenance-action@81568f71211c1839d6d3583c6a93037f5348c816 # main
|
||||
uses: danielroe/provenance-action@main
|
||||
with:
|
||||
workspace-path: ${{ matrix.directory }}
|
||||
fail-on-provenance-change: true
|
||||
|
|
|
|||
|
|
@ -10,14 +10,14 @@ jobs:
|
|||
steps:
|
||||
- name: Generate GitHub App token
|
||||
id: generate-token
|
||||
uses: actions/create-github-app-token@fee1f7d63c2ff003460e3d139729b119787bc349 # v2.2.2
|
||||
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2
|
||||
with:
|
||||
app-id: ${{ secrets.BOT_APP_ID }}
|
||||
private-key: ${{ secrets.BOT_APP_PRIVATE_KEY }}
|
||||
|
||||
- name: Find closing PR or commit
|
||||
id: find-closer
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
|
||||
with:
|
||||
github-token: ${{ steps.generate-token.outputs.token }}
|
||||
script: |
|
||||
|
|
@ -82,7 +82,7 @@ jobs:
|
|||
|
||||
- name: Comment on issue
|
||||
if: steps.find-closer.outputs.closed_by_code == 'true'
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
|
||||
with:
|
||||
github-token: ${{ steps.generate-token.outputs.token }}
|
||||
script: |
|
||||
|
|
|
|||
|
|
@ -46,7 +46,7 @@ jobs:
|
|||
# Update both packages using the nixpkgs update infrastructure
|
||||
PACKAGES=""
|
||||
for pkg in vikunja vikunja-desktop; do
|
||||
nix-shell maintainers/scripts/update.nix --argstr package "$pkg" --argstr skip-prompt true
|
||||
nix-shell maintainers/scripts/update.nix --argstr package "$pkg"
|
||||
if ! git diff --quiet; then
|
||||
git add -A
|
||||
NEW=$(grep -oP 'version = "\K[^"]+' "pkgs/by-name/vi/$pkg/package.nix" | head -1)
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ jobs:
|
|||
docker-images: false
|
||||
swap-storage: false
|
||||
- name: Checkout
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||
with:
|
||||
# For pull_request_target, we need to explicitly fetch the PR ref from forks
|
||||
# since the PR's commit SHA is not reachable in the base repository.
|
||||
|
|
@ -34,27 +34,27 @@ jobs:
|
|||
ref: refs/pull/${{ github.event.pull_request.number }}/head
|
||||
- name: Git describe
|
||||
id: ghd
|
||||
uses: proudust/gh-describe@80412be8ce0e77d8afba6b340e34790bc772aa45 # v2.2.0
|
||||
uses: proudust/gh-describe@v2
|
||||
- name: Login to GHCR
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
|
||||
with:
|
||||
version: latest
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
|
||||
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository_owner }}/${{ github.event.repository.name }}
|
||||
tags: |
|
||||
type=ref,event=pr
|
||||
type=sha,format=long
|
||||
- name: Build and push PR image
|
||||
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
|
||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64
|
||||
|
|
@ -66,7 +66,7 @@ jobs:
|
|||
build-args: |
|
||||
RELEASE_VERSION=${{ steps.ghd.outputs.describe }}
|
||||
- name: Comment on PR
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
|
||||
env:
|
||||
DOCKER_META_TAGS: ${{ steps.meta.outputs.tags }}
|
||||
with:
|
||||
|
|
|
|||
|
|
@ -4,53 +4,19 @@ on:
|
|||
workflow_call:
|
||||
|
||||
jobs:
|
||||
build-mage:
|
||||
runs-on: ubuntu-latest
|
||||
name: prepare-build-mage
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
|
||||
with:
|
||||
go-version: stable
|
||||
- name: Cache build mage
|
||||
id: cache-build-mage
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
key: ${{ runner.os }}-build-mage-build-${{ hashFiles('build/magefile.go') }}
|
||||
path: |
|
||||
./build/build-mage-static
|
||||
# Statically compile build/magefile.go so publish-repos can run repo
|
||||
# metadata targets inside ubuntu/fedora/archlinux containers without
|
||||
# needing a Go toolchain available there.
|
||||
- name: Install mage
|
||||
if: ${{ steps.cache-build-mage.outputs.cache-hit != 'true' }}
|
||||
run: go install github.com/magefile/mage@v1.17.2
|
||||
- name: Compile build mage
|
||||
if: ${{ steps.cache-build-mage.outputs.cache-hit != 'true' }}
|
||||
working-directory: build
|
||||
run: |
|
||||
export PATH=$PATH:$GOPATH/bin
|
||||
mage -compile ./build-mage-static
|
||||
- name: Store build mage binary
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: build_mage_bin
|
||||
path: ./build/build-mage-static
|
||||
|
||||
docker:
|
||||
runs-on: namespace-profile-default
|
||||
steps:
|
||||
- name: Git describe
|
||||
id: ghd
|
||||
uses: proudust/gh-describe@80412be8ce0e77d8afba6b340e34790bc772aa45 # v2.2.0
|
||||
uses: proudust/gh-describe@v2
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
||||
- name: Login to GHCR
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
|
|
@ -58,7 +24,7 @@ jobs:
|
|||
- name: Docker meta version
|
||||
if: ${{ github.ref_type == 'tag' }}
|
||||
id: meta
|
||||
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
|
||||
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5
|
||||
with:
|
||||
images: |
|
||||
vikunja/vikunja
|
||||
|
|
@ -70,7 +36,7 @@ jobs:
|
|||
type=raw,value=latest
|
||||
- name: Build and push unstable
|
||||
if: ${{ github.ref_type != 'tag' }}
|
||||
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
|
||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6
|
||||
with:
|
||||
platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64/v8
|
||||
push: true
|
||||
|
|
@ -81,7 +47,7 @@ jobs:
|
|||
RELEASE_VERSION=${{ steps.ghd.outputs.describe }}
|
||||
- name: Build and push version
|
||||
if: ${{ github.ref_type == 'tag' }}
|
||||
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
|
||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6
|
||||
with:
|
||||
platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64/v8
|
||||
push: true
|
||||
|
|
@ -93,40 +59,87 @@ jobs:
|
|||
binaries:
|
||||
runs-on: blacksmith-8vcpu-ubuntu-2204
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||
- name: Git describe
|
||||
id: ghd
|
||||
uses: proudust/gh-describe@80412be8ce0e77d8afba6b340e34790bc772aa45 # v2.2.0
|
||||
- uses: ./.github/actions/release-binaries
|
||||
uses: proudust/gh-describe@v2
|
||||
- uses: useblacksmith/setup-go@647ac649bd5b480f2a262e3e3e5f4d150ed452ad # v6
|
||||
with:
|
||||
go-version: stable
|
||||
- name: Download Mage Binary
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
||||
with:
|
||||
name: mage_bin
|
||||
- name: get frontend
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
||||
with:
|
||||
name: frontend_dist
|
||||
path: frontend/dist
|
||||
- run: chmod +x ./mage-static
|
||||
- name: install upx
|
||||
run: |
|
||||
wget https://github.com/upx/upx/releases/download/v5.0.0/upx-5.0.0-amd64_linux.tar.xz
|
||||
echo 'b32abf118d721358a50f1aa60eacdbf3298df379c431c3a86f139173ab8289a1 upx-5.0.0-amd64_linux.tar.xz' > upx-5.0.0-amd64_linux.tar.xz.sha256
|
||||
sha256sum -c upx-5.0.0-amd64_linux.tar.xz.sha256
|
||||
tar xf upx-5.0.0-amd64_linux.tar.xz
|
||||
mv upx-5.0.0-amd64_linux/upx /usr/local/bin
|
||||
- name: setup xgo cache
|
||||
uses: useblacksmith/cache@71c7c918062ba3861252d84b07fe5ab2a6b467a6 # v5
|
||||
with:
|
||||
path: /home/runner/.xgo-cache
|
||||
key: ${{ hashFiles('**/go.sum') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-go-
|
||||
- name: build and release
|
||||
env:
|
||||
RELEASE_VERSION: ${{ steps.ghd.outputs.describe }}
|
||||
XGO_OUT_NAME: vikunja-${{ github.ref_type == 'tag' && steps.ghd.outputs.describe || 'unstable' }}
|
||||
run: |
|
||||
export PATH=$PATH:$GOPATH/bin
|
||||
./mage-static release
|
||||
- name: GPG setup
|
||||
uses: kolaente/action-gpg@main
|
||||
with:
|
||||
gpg-passphrase: "${{ secrets.RELEASE_GPG_PASSPHRASE }}"
|
||||
gpg-sign-key: "${{ secrets.RELEASE_GPG_SIGN_KEY }}"
|
||||
- name: sign
|
||||
run: |
|
||||
echo "=== GPG agent status ==="
|
||||
gpg-connect-agent 'keyinfo --list' /bye || true
|
||||
echo "=== GPG secret keys ==="
|
||||
gpg -K --with-keygrip
|
||||
echo "=== GPG public keys ==="
|
||||
gpg --list-keys
|
||||
echo "=== GNUPG directory contents ==="
|
||||
ls -la ~/.gnupg/
|
||||
ls -la ~/.gnupg/private-keys-v1.d/ || true
|
||||
echo "=== Signing files ==="
|
||||
ls -hal dist/zip/*
|
||||
for file in dist/zip/*; do
|
||||
gpg -v --default-key 7D061A4AA61436B40713D42EFF054DACD908493A -b --batch --yes --passphrase "${{ secrets.RELEASE_GPG_PASSPHRASE }}" --pinentry-mode loopback --sign "$file"
|
||||
done
|
||||
- name: Upload
|
||||
uses: kolaente/s3-action@41963184b524ccac734ea4d8c964ac74b5b1af89 # v1.2.1
|
||||
with:
|
||||
project: vikunja
|
||||
release-version: ${{ steps.ghd.outputs.describe }}
|
||||
gpg-passphrase: ${{ secrets.RELEASE_GPG_PASSPHRASE }}
|
||||
gpg-sign-key: ${{ secrets.RELEASE_GPG_SIGN_KEY }}
|
||||
s3-access-key-id: ${{ secrets.S3_ACCESS_KEY }}
|
||||
s3-secret-access-key: ${{ secrets.S3_SECRET_KEY }}
|
||||
s3-endpoint: ${{ secrets.S3_ENDPOINT }}
|
||||
s3-bucket: ${{ secrets.S3_BUCKET }}
|
||||
s3-region: ${{ secrets.S3_REGION }}
|
||||
|
||||
veans-binaries:
|
||||
runs-on: blacksmith-8vcpu-ubuntu-2204
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- name: Git describe
|
||||
id: ghd
|
||||
uses: proudust/gh-describe@80412be8ce0e77d8afba6b340e34790bc772aa45 # v2.2.0
|
||||
- uses: ./.github/actions/release-binaries
|
||||
target-path: /vikunja/${{ github.ref_type == 'tag' && steps.ghd.outputs.describe || 'unstable' }}
|
||||
files: "dist/zip/*"
|
||||
strip-path-prefix: dist/zip/
|
||||
- name: Store Binaries
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||
with:
|
||||
project: veans
|
||||
release-version: ${{ steps.ghd.outputs.describe }}
|
||||
gpg-passphrase: ${{ secrets.RELEASE_GPG_PASSPHRASE }}
|
||||
gpg-sign-key: ${{ secrets.RELEASE_GPG_SIGN_KEY }}
|
||||
s3-access-key-id: ${{ secrets.S3_ACCESS_KEY }}
|
||||
s3-secret-access-key: ${{ secrets.S3_SECRET_KEY }}
|
||||
s3-endpoint: ${{ secrets.S3_ENDPOINT }}
|
||||
s3-bucket: ${{ secrets.S3_BUCKET }}
|
||||
s3-region: ${{ secrets.S3_REGION }}
|
||||
name: vikunja_bins
|
||||
path: ./dist/binaries/*
|
||||
- name: Store Binary Packages
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||
if: ${{ github.ref_type == 'tag' }}
|
||||
with:
|
||||
name: vikunja_bin_packages
|
||||
path: ./dist/zip/*
|
||||
|
||||
os-package:
|
||||
runs-on: ubuntu-latest
|
||||
|
|
@ -134,276 +147,69 @@ jobs:
|
|||
- binaries
|
||||
strategy:
|
||||
matrix:
|
||||
package: [rpm, deb, apk, archlinux]
|
||||
arch:
|
||||
- go_name: linux-amd64
|
||||
nfpm: amd64
|
||||
pkg: x86_64
|
||||
- go_name: linux-arm64
|
||||
nfpm: arm64
|
||||
pkg: aarch64
|
||||
- go_name: linux-arm-7
|
||||
nfpm: arm7
|
||||
pkg: armv7
|
||||
package:
|
||||
- rpm
|
||||
- deb
|
||||
- apk
|
||||
- archlinux
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||
- name: Download Vikunja Binary
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
||||
with:
|
||||
name: vikunja_bins
|
||||
pattern: vikunja-*-linux-amd64
|
||||
- name: Git describe
|
||||
id: ghd
|
||||
uses: proudust/gh-describe@80412be8ce0e77d8afba6b340e34790bc772aa45 # v2.2.0
|
||||
- uses: ./.github/actions/release-os-package
|
||||
uses: proudust/gh-describe@v2
|
||||
- name: Download Mage Binary
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
||||
with:
|
||||
project: vikunja
|
||||
release-version: ${{ steps.ghd.outputs.describe }}
|
||||
packager: ${{ matrix.package }}
|
||||
nfpm-arch: ${{ matrix.arch.nfpm }}
|
||||
pkg-arch: ${{ matrix.arch.pkg }}
|
||||
go-name: ${{ matrix.arch.go_name }}
|
||||
gpg-passphrase: ${{ secrets.RELEASE_GPG_PASSPHRASE }}
|
||||
gpg-sign-key: ${{ secrets.RELEASE_GPG_SIGN_KEY }}
|
||||
s3-access-key-id: ${{ secrets.S3_ACCESS_KEY }}
|
||||
s3-secret-access-key: ${{ secrets.S3_SECRET_KEY }}
|
||||
s3-endpoint: ${{ secrets.S3_ENDPOINT }}
|
||||
s3-bucket: ${{ secrets.S3_BUCKET }}
|
||||
s3-region: ${{ secrets.S3_REGION }}
|
||||
|
||||
veans-os-package:
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- veans-binaries
|
||||
strategy:
|
||||
matrix:
|
||||
package: [rpm, deb, apk, archlinux]
|
||||
arch:
|
||||
- go_name: linux-amd64
|
||||
nfpm: amd64
|
||||
pkg: x86_64
|
||||
- go_name: linux-arm64
|
||||
nfpm: arm64
|
||||
pkg: aarch64
|
||||
- go_name: linux-arm-7
|
||||
nfpm: arm7
|
||||
pkg: armv7
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- name: Git describe
|
||||
id: ghd
|
||||
uses: proudust/gh-describe@80412be8ce0e77d8afba6b340e34790bc772aa45 # v2.2.0
|
||||
- uses: ./.github/actions/release-os-package
|
||||
with:
|
||||
project: veans
|
||||
release-version: ${{ steps.ghd.outputs.describe }}
|
||||
packager: ${{ matrix.package }}
|
||||
nfpm-arch: ${{ matrix.arch.nfpm }}
|
||||
pkg-arch: ${{ matrix.arch.pkg }}
|
||||
go-name: ${{ matrix.arch.go_name }}
|
||||
gpg-passphrase: ${{ secrets.RELEASE_GPG_PASSPHRASE }}
|
||||
gpg-sign-key: ${{ secrets.RELEASE_GPG_SIGN_KEY }}
|
||||
s3-access-key-id: ${{ secrets.S3_ACCESS_KEY }}
|
||||
s3-secret-access-key: ${{ secrets.S3_SECRET_KEY }}
|
||||
s3-endpoint: ${{ secrets.S3_ENDPOINT }}
|
||||
s3-bucket: ${{ secrets.S3_BUCKET }}
|
||||
s3-region: ${{ secrets.S3_REGION }}
|
||||
|
||||
publish-repos:
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- build-mage
|
||||
- os-package
|
||||
- veans-os-package
|
||||
- desktop
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- format: apt
|
||||
image: ubuntu:noble
|
||||
mage_target: release:repo-apt
|
||||
- format: rpm
|
||||
image: fedora:latest
|
||||
mage_target: release:repo-rpm
|
||||
- format: pacman
|
||||
image: archlinux:latest
|
||||
mage_target: release:repo-pacman
|
||||
- format: apk
|
||||
image: alpine:latest
|
||||
mage_target: release:repo-apk
|
||||
container:
|
||||
image: ${{ matrix.image }}
|
||||
env:
|
||||
REPO_SUITE: ${{ github.ref_type == 'tag' && 'stable' || 'unstable' }}
|
||||
RELEASE_VERSION: unstable
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
|
||||
- name: Download build mage binary
|
||||
# Statically compiled in test.yml's build-mage job so it runs inside
|
||||
# ubuntu/fedora/archlinux containers without a Go toolchain.
|
||||
if: matrix.format != 'apk'
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
with:
|
||||
name: build_mage_bin
|
||||
path: build
|
||||
|
||||
- name: Download all server OS packages
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
with:
|
||||
pattern: vikunja_os_package_*
|
||||
merge-multiple: true
|
||||
path: dist/repo-work/incoming
|
||||
|
||||
- name: Download all veans OS packages
|
||||
# Merged into the same incoming dir so reprepro / createrepo_c /
|
||||
# repo-add / the apk loop pick them up alongside vikunja's packages
|
||||
# — same suite, same arch fan-out, no extra source entry for users.
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
with:
|
||||
pattern: veans_os_package_*
|
||||
merge-multiple: true
|
||||
path: dist/repo-work/incoming
|
||||
|
||||
- name: Download desktop packages (Linux)
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
with:
|
||||
name: vikunja_desktop_packages_ubuntu-latest
|
||||
path: dist/repo-work/incoming-desktop
|
||||
|
||||
- name: Copy desktop packages to incoming
|
||||
run: |
|
||||
cd dist/repo-work/incoming-desktop
|
||||
case "${{ matrix.format }}" in
|
||||
apt)
|
||||
cp *.deb ../incoming/ 2>/dev/null || true
|
||||
;;
|
||||
rpm)
|
||||
# Add arch suffix so the mage target's *-x86_64.rpm glob matches
|
||||
for f in *.rpm; do
|
||||
[ -f "$f" ] && cp "$f" "../incoming/${f%.rpm}-x86_64.rpm"
|
||||
done
|
||||
;;
|
||||
pacman)
|
||||
# Rename .pacman to .archlinux with arch suffix
|
||||
for f in *.pacman; do
|
||||
[ -f "$f" ] && cp "$f" "../incoming/${f%.pacman}-x86_64.archlinux"
|
||||
done
|
||||
;;
|
||||
apk)
|
||||
# Desktop .apk is not an Alpine package, skip
|
||||
;;
|
||||
esac
|
||||
|
||||
- name: Install tools (apt)
|
||||
if: matrix.format == 'apt'
|
||||
run: |
|
||||
apt-get update
|
||||
apt-get install -y --no-install-recommends reprepro
|
||||
|
||||
- name: Install tools (rpm)
|
||||
if: matrix.format == 'rpm'
|
||||
run: dnf install -y createrepo_c
|
||||
|
||||
- name: Install tools (apk)
|
||||
if: matrix.format == 'apk'
|
||||
run: apk add --no-cache abuild libc6-compat
|
||||
|
||||
- name: GPG setup
|
||||
if: matrix.format != 'apk'
|
||||
uses: kolaente/action-gpg@eb0fd8f16fe9b499f060f659092c470cb9f76eb7 # main
|
||||
with:
|
||||
gpg-passphrase: "${{ secrets.RELEASE_GPG_PASSPHRASE }}"
|
||||
gpg-sign-key: "${{ secrets.RELEASE_GPG_SIGN_KEY }}"
|
||||
|
||||
- name: Export GPG public key
|
||||
if: matrix.format == 'apt'
|
||||
run: |
|
||||
mkdir -p dist/repo-output
|
||||
gpg --export --armor 7D061A4AA61436B40713D42EFF054DACD908493A > dist/repo-output/gpg.key
|
||||
|
||||
- name: Setup APK signing key
|
||||
if: matrix.format == 'apk'
|
||||
run: |
|
||||
mkdir -p ~/.abuild
|
||||
echo "${{ secrets.APK_SIGNING_KEY }}" > ~/.abuild/vikunja-apk.rsa
|
||||
echo "PACKAGER_PRIVKEY=$HOME/.abuild/vikunja-apk.rsa" > ~/.abuild/abuild.conf
|
||||
|
||||
- name: Generate repo metadata
|
||||
if: matrix.format != 'apk'
|
||||
working-directory: build
|
||||
name: mage_bin
|
||||
- name: Prepare
|
||||
env:
|
||||
RELEASE_GPG_KEY: 7D061A4AA61436B40713D42EFF054DACD908493A
|
||||
RELEASE_GPG_PASSPHRASE: ${{ secrets.RELEASE_GPG_PASSPHRASE }}
|
||||
RELEASE_VERSION: ${{ steps.ghd.outputs.describe }}
|
||||
run: |
|
||||
chmod +x ./build-mage-static
|
||||
./build-mage-static ${{ matrix.mage_target }}
|
||||
|
||||
- name: Generate APK repo metadata
|
||||
if: matrix.format == 'apk'
|
||||
run: |
|
||||
incoming=dist/repo-work/incoming
|
||||
output_base=dist/repo-output/apk/$REPO_SUITE/main
|
||||
signing_key=~/.abuild/vikunja-apk.rsa
|
||||
for arch in x86_64 aarch64 armv7; do
|
||||
repo_dir="$output_base/$arch"
|
||||
mkdir -p "$repo_dir"
|
||||
# Symlink matching packages
|
||||
found=false
|
||||
for pkg in "$incoming"/*-"$arch".apk; do
|
||||
[ -f "$pkg" ] || continue
|
||||
found=true
|
||||
ln -sf "$(realpath "$pkg")" "$repo_dir/$(basename "$pkg")"
|
||||
done
|
||||
$found || continue
|
||||
# Create index and sign
|
||||
apk index --allow-untrusted -o "$repo_dir/APKINDEX.tar.gz" "$repo_dir"/*.apk
|
||||
abuild-sign -k "$signing_key" "$repo_dir/APKINDEX.tar.gz"
|
||||
done
|
||||
echo "APK repo metadata generated in $output_base"
|
||||
|
||||
- name: Debug - repo output structure
|
||||
run: find dist/repo-output -type f 2>/dev/null || ls -laR dist/repo-output/ || true
|
||||
|
||||
- name: Remove packages and internal state from repo output
|
||||
run: |
|
||||
# Remove reprepro internal state (not needed for serving)
|
||||
rm -rf dist/repo-output/apt/db dist/repo-output/apt/conf 2>/dev/null || true
|
||||
# Resolve symlinks into real files (S3 can't store symlinks)
|
||||
find dist/repo-output -type l | while IFS= read -r link; do
|
||||
target=$(readlink -f "$link")
|
||||
if [ -f "$target" ]; then
|
||||
rm "$link"
|
||||
cp "$target" "$link"
|
||||
else
|
||||
rm "$link"
|
||||
fi
|
||||
done
|
||||
# Remove actual package files — the worker redirects these to the
|
||||
# existing artifacts so we don't need to store them twice.
|
||||
find dist/repo-output -type f \( -name '*.deb' -o -name '*.rpm' -o -name '*.apk' -o -name '*.archlinux' -o -name '*.pacman' -o -name '*.pkg.tar.zst' \) -delete 2>/dev/null || true
|
||||
# Remove now-empty directories
|
||||
find dist/repo-output -type d -empty -delete 2>/dev/null || true
|
||||
|
||||
- name: Upload to R2
|
||||
uses: kolaente/s3-action@7f58dddd682b2f93a6c6799c9f68e7a38f2da558 # main
|
||||
chmod +x ./mage-static
|
||||
./mage-static release:prepare-nfpm-config
|
||||
mkdir -p ./dist/os-packages
|
||||
mv ./vikunja-*-linux-amd64 ./vikunja
|
||||
chmod +x ./vikunja
|
||||
- name: Create package
|
||||
id: nfpm
|
||||
uses: kolaente/action-gh-nfpm@master
|
||||
with:
|
||||
packager: ${{ matrix.package }}
|
||||
target: ./dist/os-packages/vikunja-${{ github.ref_type == 'tag' && steps.ghd.outputs.describe || 'unstable' }}-x86_64.${{ matrix.package }}
|
||||
config: ./nfpm.yaml
|
||||
- name: Upload
|
||||
uses: kolaente/s3-action@41963184b524ccac734ea4d8c964ac74b5b1af89 # v1.2.1
|
||||
with:
|
||||
s3-access-key-id: ${{ secrets.S3_ACCESS_KEY }}
|
||||
s3-secret-access-key: ${{ secrets.S3_SECRET_KEY }}
|
||||
s3-endpoint: ${{ secrets.S3_ENDPOINT }}
|
||||
s3-bucket: ${{ secrets.S3_BUCKET }}
|
||||
s3-region: ${{ secrets.S3_REGION }}
|
||||
target-path: /repos
|
||||
files: "dist/repo-output/**/*"
|
||||
strip-path-prefix: dist/repo-output/
|
||||
target-path: /vikunja/${{ github.ref_type == 'tag' && steps.ghd.outputs.describe || 'unstable' }}
|
||||
files: "dist/os-packages/*"
|
||||
strip-path-prefix: dist/os-packages/
|
||||
- name: Store OS Packages
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||
if: ${{ github.ref_type == 'tag' }}
|
||||
with:
|
||||
name: vikunja_os_package_${{ matrix.package }}
|
||||
path: ./dist/os-packages/*
|
||||
|
||||
config-yaml:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||
- name: Git describe
|
||||
id: ghd
|
||||
uses: proudust/gh-describe@80412be8ce0e77d8afba6b340e34790bc772aa45 # v2.2.0
|
||||
uses: proudust/gh-describe@v2
|
||||
- name: Download Mage Binary
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
||||
with:
|
||||
name: mage_bin
|
||||
- name: generate
|
||||
|
|
@ -411,7 +217,7 @@ jobs:
|
|||
chmod +x ./mage-static
|
||||
./mage-static generate:config-yaml 1
|
||||
- name: Upload to S3
|
||||
uses: kolaente/s3-action@7f58dddd682b2f93a6c6799c9f68e7a38f2da558 # main
|
||||
uses: kolaente/s3-action@41963184b524ccac734ea4d8c964ac74b5b1af89 # v1.2.1
|
||||
with:
|
||||
s3-access-key-id: ${{ secrets.S3_ACCESS_KEY }}
|
||||
s3-secret-access-key: ${{ secrets.S3_SECRET_KEY }}
|
||||
|
|
@ -431,16 +237,16 @@ jobs:
|
|||
runs-on: ${{ matrix.os }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||
- name: Git describe
|
||||
id: ghd
|
||||
uses: proudust/gh-describe@80412be8ce0e77d8afba6b340e34790bc772aa45 # v2.2.0
|
||||
uses: proudust/gh-describe@v2
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4
|
||||
with:
|
||||
package_json_file: desktop/package.json
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6
|
||||
with:
|
||||
node-version-file: frontend/.nvmrc
|
||||
cache: pnpm
|
||||
|
|
@ -451,7 +257,7 @@ jobs:
|
|||
sudo apt-get update
|
||||
sudo apt-get install --no-install-recommends -y libopenjp2-tools rpm libarchive-tools
|
||||
- name: get frontend
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
||||
with:
|
||||
name: frontend_dist
|
||||
path: frontend/dist
|
||||
|
|
@ -461,7 +267,7 @@ jobs:
|
|||
pnpm install --frozen-lockfile --prefer-offline --fetch-timeout 100000
|
||||
node build.js "${{ steps.ghd.outputs.describe }}" ${{ github.ref_type == 'tag' }}
|
||||
- name: Upload to S3
|
||||
uses: kolaente/s3-action@7f58dddd682b2f93a6c6799c9f68e7a38f2da558 # main
|
||||
uses: kolaente/s3-action@41963184b524ccac734ea4d8c964ac74b5b1af89 # v1.2.1
|
||||
with:
|
||||
s3-access-key-id: ${{ secrets.S3_ACCESS_KEY }}
|
||||
s3-secret-access-key: ${{ secrets.S3_SECRET_KEY }}
|
||||
|
|
@ -473,7 +279,8 @@ jobs:
|
|||
strip-path-prefix: desktop/dist/
|
||||
exclude: "desktop/dist/*.blockmap"
|
||||
- name: Store Desktop Package
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||
if: ${{ github.ref_type == 'tag' }}
|
||||
with:
|
||||
name: vikunja_desktop_packages_${{ matrix.os }}
|
||||
path: |
|
||||
|
|
@ -486,16 +293,16 @@ jobs:
|
|||
contents: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||
with:
|
||||
ssh-key: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||
persist-credentials: true
|
||||
- name: Download Mage Binary
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
||||
with:
|
||||
name: mage_bin
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
|
||||
uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6
|
||||
with:
|
||||
go-version: stable
|
||||
- name: generate
|
||||
|
|
@ -520,7 +327,7 @@ jobs:
|
|||
git commit -am "[skip ci] Updated swagger docs"
|
||||
- name: Push changes
|
||||
if: steps.check_changes.outputs.changes_exist != '0'
|
||||
uses: ad-m/github-push-action@881a6320fdb16eb5318c5054f31c218aec2b324c # master
|
||||
uses: ad-m/github-push-action@master
|
||||
with:
|
||||
ssh: true
|
||||
branch: ${{ github.ref }}
|
||||
|
|
@ -530,53 +337,53 @@ jobs:
|
|||
needs:
|
||||
- binaries
|
||||
- os-package
|
||||
- veans-binaries
|
||||
- veans-os-package
|
||||
- desktop
|
||||
- publish-repos
|
||||
if: ${{ github.ref_type == 'tag' }}
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Download Binaries
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
||||
with:
|
||||
name: vikunja_bin_packages
|
||||
|
||||
- name: Download OS Packages
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
- name: Download OS Package rpm
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
||||
with:
|
||||
pattern: vikunja_os_package_*
|
||||
merge-multiple: true
|
||||
name: vikunja_os_package_rpm
|
||||
|
||||
- name: Download Veans Binaries
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
- name: Download OS Package deb
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
||||
with:
|
||||
name: veans_bin_packages
|
||||
name: vikunja_os_package_deb
|
||||
|
||||
- name: Download Veans OS Packages
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
- name: Download OS Package apk
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
||||
with:
|
||||
pattern: veans_os_package_*
|
||||
merge-multiple: true
|
||||
name: vikunja_os_package_apk
|
||||
|
||||
- name: Download OS Package archlinux
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
||||
with:
|
||||
name: vikunja_os_package_archlinux
|
||||
|
||||
- name: Download Desktop Package Linux
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
||||
with:
|
||||
name: vikunja_desktop_packages_ubuntu-latest
|
||||
|
||||
- name: Download Desktop Package MacOS
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
||||
with:
|
||||
name: vikunja_desktop_packages_macos-latest
|
||||
|
||||
- name: Download Desktop Package Windows
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
||||
with:
|
||||
name: vikunja_desktop_packages_windows-latest
|
||||
|
||||
- name: Release
|
||||
uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2.6.2
|
||||
uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2
|
||||
if: github.ref_type == 'tag'
|
||||
with:
|
||||
draft: true
|
||||
|
|
@ -586,9 +393,4 @@ jobs:
|
|||
vikunja*.deb
|
||||
vikunja*.apk
|
||||
vikunja*.archlinux
|
||||
veans*.zip
|
||||
veans*.rpm
|
||||
veans*.deb
|
||||
veans*.apk
|
||||
veans*.archlinux
|
||||
Vikunja Desktop*
|
||||
|
|
|
|||
|
|
@ -12,19 +12,18 @@ jobs:
|
|||
stale:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/stale@5bef64f19d7facfb25b37b414482c7164d639639 # v9.1.0
|
||||
- uses: actions/stale@v9
|
||||
with:
|
||||
only-labels: 'waiting for reply'
|
||||
days-before-issue-stale: 30
|
||||
days-before-issue-close: 30
|
||||
days-before-issue-close: 0
|
||||
stale-issue-label: 'waiting for reply'
|
||||
remove-stale-when-updated: true
|
||||
remove-stale-when-updated: false
|
||||
close-issue-message: >
|
||||
Closing this for now since we haven't heard back on the follow-up
|
||||
questions. If you're still seeing this on a recent version, just
|
||||
drop a comment with the requested info and we'll reopen. Thanks
|
||||
for the report!
|
||||
stale-pr-label: 'waiting for reply'
|
||||
days-before-pr-stale: 30
|
||||
days-before-pr-stale: -1
|
||||
days-before-pr-close: -1
|
||||
operations-per-run: 100
|
||||
|
|
|
|||
|
|
@ -8,26 +8,26 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
name: prepare-mage
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
|
||||
uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6
|
||||
with:
|
||||
go-version: stable
|
||||
- name: Cache Mage
|
||||
id: cache-mage
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5
|
||||
with:
|
||||
key: ${{ runner.os }}-build-mage-${{ hashFiles('magefile.go') }}
|
||||
path: |
|
||||
./mage-static
|
||||
- name: Compile Mage
|
||||
if: ${{ steps.cache-mage.outputs.cache-hit != 'true' }}
|
||||
uses: magefile/mage-action@6f50bbb8ea47d56e62dee92392788acbc8192d0b # v3.1.0
|
||||
uses: magefile/mage-action@6f50bbb8ea47d56e62dee92392788acbc8192d0b # v3
|
||||
with:
|
||||
version: latest
|
||||
args: -compile ./mage-static
|
||||
- name: Store Mage Binary
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||
with:
|
||||
name: mage_bin
|
||||
path: ./mage-static
|
||||
|
|
@ -36,16 +36,16 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
needs: mage
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||
- name: Download Mage Binary
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
||||
with:
|
||||
name: mage_bin
|
||||
- name: Git describe
|
||||
id: ghd
|
||||
uses: proudust/gh-describe@80412be8ce0e77d8afba6b340e34790bc772aa45 # v2.2.0
|
||||
uses: proudust/gh-describe@v2
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
|
||||
uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6
|
||||
with:
|
||||
go-version: stable
|
||||
- name: Build
|
||||
|
|
@ -57,7 +57,7 @@ jobs:
|
|||
chmod +x ./mage-static
|
||||
./mage-static build
|
||||
- name: Store Vikunja Binary
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||
with:
|
||||
name: vikunja_bin
|
||||
path: ./vikunja
|
||||
|
|
@ -65,8 +65,8 @@ jobs:
|
|||
api-lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||
- uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6
|
||||
with:
|
||||
go-version: stable
|
||||
- name: prepare frontend files
|
||||
|
|
@ -74,50 +74,17 @@ jobs:
|
|||
mkdir -p frontend/dist
|
||||
touch frontend/dist/index.html
|
||||
- name: golangci-lint
|
||||
uses: golangci/golangci-lint-action@ba0d7d2ec06a0ea1cb5fa41b2e4a3ab91d21278a # v9.3.0
|
||||
uses: golangci/golangci-lint-action@1e7e51e771db61008b38414a730f564565cf7c20 # v9
|
||||
with:
|
||||
version: v2.10.1
|
||||
|
||||
veans-lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
|
||||
with:
|
||||
go-version: stable
|
||||
- name: golangci-lint
|
||||
uses: golangci/golangci-lint-action@ba0d7d2ec06a0ea1cb5fa41b2e4a3ab91d21278a # v9.3.0
|
||||
with:
|
||||
version: v2.10.1
|
||||
working-directory: veans
|
||||
|
||||
veans-test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
|
||||
with:
|
||||
go-version: stable
|
||||
- name: Install mage
|
||||
# The cached mage-static artifact has the parent magefile compiled
|
||||
# in — we need a generic mage binary to pick up veans/magefile.go.
|
||||
run: go install github.com/magefile/mage@v1.17.2
|
||||
- name: Run unit tests
|
||||
# `mage test` is the Aliases entry for Test.All which passes
|
||||
# `-short` — the e2e package's TestMain skips under -short,
|
||||
# mirroring the parent monorepo's pkg/webtests convention. The
|
||||
# heavier test-veans-e2e job runs the full suite against the
|
||||
# api-build artifact.
|
||||
working-directory: veans
|
||||
run: mage test
|
||||
|
||||
check-translations:
|
||||
api-check-translations:
|
||||
runs-on: ubuntu-latest
|
||||
needs: mage
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||
- name: Download Mage Binary
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
||||
with:
|
||||
name: mage_bin
|
||||
- name: Check
|
||||
|
|
@ -134,25 +101,17 @@ jobs:
|
|||
db:
|
||||
- sqlite
|
||||
- postgres
|
||||
- mariadb
|
||||
- mysql
|
||||
services:
|
||||
migration-smoke-db-mariadb:
|
||||
image: ${{ matrix.db == 'mariadb' && 'mariadb:12@sha256:f54db0cb3ccfe9431aba6d08c65a1763c499789b116b4cb651dd7fcf325965b3' || '' }}
|
||||
env:
|
||||
MYSQL_ROOT_PASSWORD: vikunjatest
|
||||
MYSQL_DATABASE: vikunjatest
|
||||
ports:
|
||||
- 3306:3306
|
||||
migration-smoke-db-mysql:
|
||||
image: ${{ matrix.db == 'mysql' && 'mysql:8@sha256:da906917ca4ace3ba55538b7c2ee97a9bc865ef14a4b6920b021f0249d603f3d' || '' }}
|
||||
image: mariadb:12@sha256:f54db0cb3ccfe9431aba6d08c65a1763c499789b116b4cb651dd7fcf325965b3
|
||||
env:
|
||||
MYSQL_ROOT_PASSWORD: vikunjatest
|
||||
MYSQL_DATABASE: vikunjatest
|
||||
ports:
|
||||
- 3306:3306
|
||||
migration-smoke-db-postgres:
|
||||
image: postgres:18@sha256:4aabea78cf39b90e834caf3af7d602a18565f6fe2508705c8d01aa63245c2e20
|
||||
image: postgres:18@sha256:5773fe724c49c42a7a9ca70202e11e1dff21fb7235b335a73f39297d200b73a2
|
||||
env:
|
||||
POSTGRES_PASSWORD: vikunjatest
|
||||
POSTGRES_DB: vikunjatest
|
||||
|
|
@ -164,12 +123,12 @@ jobs:
|
|||
wget https://dl.vikunja.io/vikunja/unstable/vikunja-unstable-linux-amd64-full.zip -q -O vikunja-latest.zip
|
||||
unzip vikunja-latest.zip vikunja-unstable-linux-amd64
|
||||
- name: Download Vikunja Binary
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
||||
with:
|
||||
name: vikunja_bin
|
||||
- name: run migration
|
||||
env:
|
||||
VIKUNJA_DATABASE_TYPE: ${{ (matrix.db == 'mariadb' || matrix.db == 'mysql') && 'mysql' || matrix.db }}
|
||||
VIKUNJA_DATABASE_TYPE: ${{ matrix.db }}
|
||||
VIKUNJA_DATABASE_PATH: ./vikunja-migration-test.db
|
||||
VIKUNJA_DATABASE_USER: ${{ matrix.db == 'postgres' && 'postgres' || 'root' }}
|
||||
VIKUNJA_DATABASE_PASSWORD: vikunjatest
|
||||
|
|
@ -214,22 +173,14 @@ jobs:
|
|||
- sqlite-in-memory
|
||||
- sqlite
|
||||
- postgres
|
||||
- mariadb
|
||||
- mysql
|
||||
- paradedb
|
||||
test:
|
||||
- feature
|
||||
- web
|
||||
services:
|
||||
db-mariadb:
|
||||
image: ${{ matrix.db == 'mariadb' && 'mariadb:12@sha256:5b6a1eac15b85b981a61afb89aea2a22bf76b5f58809d05f0bcc13ab6ec44cb8' || '' }}
|
||||
env:
|
||||
MYSQL_ROOT_PASSWORD: vikunjatest
|
||||
MYSQL_DATABASE: vikunjatest
|
||||
ports:
|
||||
- 3306:3306
|
||||
db-mysql:
|
||||
image: ${{ matrix.db == 'mysql' && 'mysql:8@sha256:da906917ca4ace3ba55538b7c2ee97a9bc865ef14a4b6920b021f0249d603f3d' || '' }}
|
||||
image: ${{ matrix.db == 'mysql' && 'mariadb:12@sha256:5b6a1eac15b85b981a61afb89aea2a22bf76b5f58809d05f0bcc13ab6ec44cb8' || '' }}
|
||||
env:
|
||||
MYSQL_ROOT_PASSWORD: vikunjatest
|
||||
MYSQL_DATABASE: vikunjatest
|
||||
|
|
@ -254,13 +205,13 @@ jobs:
|
|||
ports:
|
||||
- 389:389
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||
- name: Download Mage Binary
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
||||
with:
|
||||
name: mage_bin
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
|
||||
uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6
|
||||
with:
|
||||
go-version: stable
|
||||
- name: Configure Postgres for faster tests
|
||||
|
|
@ -275,8 +226,8 @@ jobs:
|
|||
- name: test
|
||||
env:
|
||||
VIKUNJA_TESTS_USE_CONFIG: ${{ matrix.db != 'sqlite-in-memory' && 1 || 0 }}
|
||||
VIKUNJA_DATABASE_TYPE: ${{ (matrix.db == 'paradedb' && 'postgres') || ((matrix.db == 'mariadb' || matrix.db == 'mysql') && 'mysql') || matrix.db }}
|
||||
VIKUNJA_DATABASE_USER: ${{ (matrix.db == 'mariadb' || matrix.db == 'mysql') && 'root' || 'postgres' }}
|
||||
VIKUNJA_DATABASE_TYPE: ${{ matrix.db == 'paradedb' && 'postgres' || matrix.db }}
|
||||
VIKUNJA_DATABASE_USER: ${{ matrix.db == 'mysql' && 'root' || 'postgres' }}
|
||||
VIKUNJA_DATABASE_PASSWORD: vikunjatest
|
||||
VIKUNJA_DATABASE_DATABASE: vikunjatest
|
||||
VIKUNJA_DATABASE_SSLMODE: disable
|
||||
|
|
@ -300,13 +251,13 @@ jobs:
|
|||
needs:
|
||||
- mage
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||
- name: Download Mage Binary
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
||||
with:
|
||||
name: mage_bin
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
|
||||
uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6
|
||||
with:
|
||||
go-version: stable
|
||||
- name: test
|
||||
|
|
@ -321,13 +272,13 @@ jobs:
|
|||
needs:
|
||||
- mage
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||
- name: Download Mage Binary
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
||||
with:
|
||||
name: mage_bin
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
|
||||
uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6
|
||||
with:
|
||||
go-version: stable
|
||||
- name: test
|
||||
|
|
@ -351,13 +302,13 @@ jobs:
|
|||
ports:
|
||||
- 9000:9000
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||
- name: Download Mage Binary
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
||||
with:
|
||||
name: mage_bin
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
|
||||
uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6
|
||||
with:
|
||||
go-version: stable
|
||||
- name: test S3 file storage integration
|
||||
|
|
@ -382,7 +333,7 @@ jobs:
|
|||
frontend-lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||
- uses: ./.github/actions/setup-frontend
|
||||
- name: Lint
|
||||
working-directory: frontend
|
||||
|
|
@ -391,7 +342,7 @@ jobs:
|
|||
frontend-stylelint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||
- uses: ./.github/actions/setup-frontend
|
||||
- name: Lint styles
|
||||
working-directory: frontend
|
||||
|
|
@ -400,7 +351,7 @@ jobs:
|
|||
frontend-typecheck:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||
- uses: ./.github/actions/setup-frontend
|
||||
- name: Typecheck
|
||||
continue-on-error: true
|
||||
|
|
@ -410,7 +361,7 @@ jobs:
|
|||
test-frontend-unit:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||
- uses: ./.github/actions/setup-frontend
|
||||
- name: Run unit tests
|
||||
working-directory: frontend
|
||||
|
|
@ -419,11 +370,11 @@ jobs:
|
|||
frontend-build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||
- uses: ./.github/actions/setup-frontend
|
||||
- name: Git describe
|
||||
id: ghd
|
||||
uses: proudust/gh-describe@80412be8ce0e77d8afba6b340e34790bc772aa45 # v2.2.0
|
||||
uses: proudust/gh-describe@v2
|
||||
- name: Inject frontend version
|
||||
working-directory: frontend
|
||||
run: |
|
||||
|
|
@ -432,81 +383,11 @@ jobs:
|
|||
working-directory: frontend
|
||||
run: pnpm build
|
||||
- name: Store Frontend
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||
with:
|
||||
name: frontend_dist
|
||||
path: ./frontend/dist
|
||||
|
||||
test-veans-e2e:
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- api-build
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- name: Download Vikunja Binary
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
with:
|
||||
name: vikunja_bin
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
|
||||
with:
|
||||
go-version: stable
|
||||
- name: Install mage
|
||||
# The cached mage-static artifact has the parent magefile compiled
|
||||
# in — we need a generic mage binary to pick up veans/magefile.go.
|
||||
run: go install github.com/magefile/mage@v1.17.2
|
||||
- run: chmod +x ./vikunja
|
||||
- name: Run veans e2e against ephemeral Vikunja
|
||||
env:
|
||||
VIKUNJA_SERVICE_INTERFACE: ":3456"
|
||||
VIKUNJA_SERVICE_PUBLICURL: "http://127.0.0.1:3456/"
|
||||
VIKUNJA_SERVICE_JWTSECRET: "veans-e2e-jwt-secret-do-not-use-in-production"
|
||||
# Enables PATCH /api/v1/test/{table} — the e2e suite seeds its
|
||||
# own admin via this endpoint (see veans/e2e/helpers.go), same
|
||||
# mechanism the playwright suite uses.
|
||||
VIKUNJA_SERVICE_TESTINGTOKEN: averyLongSecretToSe33dtheDB
|
||||
VIKUNJA_DATABASE_TYPE: sqlite
|
||||
VIKUNJA_DATABASE_PATH: memory
|
||||
VIKUNJA_LOG_LEVEL: WARNING
|
||||
VIKUNJA_MAILER_ENABLED: "false"
|
||||
VIKUNJA_REDIS_ENABLED: "false"
|
||||
VIKUNJA_RATELIMIT_NOAUTHLIMIT: "1000"
|
||||
VEANS_E2E_API_URL: http://127.0.0.1:3456
|
||||
# Same value as VIKUNJA_SERVICE_TESTINGTOKEN above — pass-through
|
||||
# so the test harness can authenticate against /api/v1/test/.
|
||||
VEANS_E2E_TESTING_TOKEN: averyLongSecretToSe33dtheDB
|
||||
run: |
|
||||
set -e
|
||||
# Boot the prebuilt API and tests in one shell — backgrounded
|
||||
# processes don't survive step boundaries on GH runners.
|
||||
nohup ./vikunja web > /tmp/vikunja.log 2>&1 &
|
||||
API_PID=$!
|
||||
trap "kill $API_PID 2>/dev/null || true" EXIT
|
||||
for i in $(seq 1 60); do
|
||||
if curl -sf http://127.0.0.1:3456/api/v1/info >/dev/null 2>&1; then
|
||||
echo "API ready after ${i}s"
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
if ! curl -sf http://127.0.0.1:3456/api/v1/info >/dev/null; then
|
||||
echo "::error::API failed to start; log:"
|
||||
cat /tmp/vikunja.log
|
||||
exit 1
|
||||
fi
|
||||
# `mage test:e2e` builds the binary once and exports VEANS_BINARY
|
||||
# so each subtest reuses it (plain `mage test` would rebuild per
|
||||
# test via buildOrLocate()). The suite seeds its own admin
|
||||
# internally — no curl seeding here.
|
||||
(cd veans && mage test:e2e)
|
||||
- name: Upload API log on failure
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: veans-e2e-vikunja-log
|
||||
path: /tmp/vikunja.log
|
||||
retention-days: 7
|
||||
|
||||
test-frontend-e2e-playwright:
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
|
|
@ -523,19 +404,19 @@ jobs:
|
|||
ports:
|
||||
- 5556:5556
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.61.1-jammy@sha256:7b86926fff94374389e8e1f4fdc5c76d050d4a06a7886bb537bf412b20e2b71e
|
||||
image: mcr.microsoft.com/playwright:v1.58.2-jammy@sha256:4698a73749c5848d3f5fcd42a2174d172fcad2b2283e087843b115424303a565
|
||||
options: --user 1001
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||
- name: Download Vikunja Binary
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
||||
with:
|
||||
name: vikunja_bin
|
||||
- uses: ./.github/actions/setup-frontend
|
||||
with:
|
||||
install-e2e-binaries: false # Playwright browsers already in container
|
||||
- name: Download Frontend
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
||||
with:
|
||||
name: frontend_dist
|
||||
path: ./frontend/dist
|
||||
|
|
@ -570,14 +451,14 @@ jobs:
|
|||
VIKUNJA_AUTH_OPENID_PROVIDERS_DEX_CLIENTID: vikunja
|
||||
VIKUNJA_AUTH_OPENID_PROVIDERS_DEX_CLIENTSECRET: secret
|
||||
- name: Upload Playwright Report
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||
if: always()
|
||||
with:
|
||||
name: playwright-report-${{ matrix.shard }}
|
||||
path: frontend/playwright-report/
|
||||
retention-days: 30
|
||||
- name: Upload Test Results
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||
if: always()
|
||||
with:
|
||||
name: playwright-test-results-${{ matrix.shard }}
|
||||
|
|
|
|||
|
|
@ -26,7 +26,6 @@ docs/resources/
|
|||
pkg/static/templates_vfsdata.go
|
||||
files/
|
||||
!pkg/files/
|
||||
!pkg/web/files/
|
||||
vikunja-dump*
|
||||
vendor/
|
||||
os-packages/
|
||||
|
|
|
|||
|
|
@ -145,13 +145,6 @@ linters:
|
|||
- revive
|
||||
path: pkg/utils/*
|
||||
text: 'var-naming: avoid meaningless package names'
|
||||
- linters:
|
||||
- revive
|
||||
path: pkg/routes/api/shared/*
|
||||
text: 'var-naming: avoid meaningless package names'
|
||||
- linters:
|
||||
- contextcheck
|
||||
path: pkg/routes/api/v2/backgrounds.go # the unsplash provider intentionally uses context.Background(); its interface is shared with v1 and can't take a context
|
||||
- linters:
|
||||
- revive
|
||||
text: 'var-naming: avoid package names that conflict with Go standard library package names'
|
||||
|
|
|
|||
48
AGENTS.md
48
AGENTS.md
|
|
@ -11,25 +11,6 @@ The project consists of:
|
|||
- `desktop/` – Electron wrapper application
|
||||
- `docs/` – Documentation website
|
||||
|
||||
## API Version Policy — new work goes to /api/v2
|
||||
|
||||
**`/api/v1` is effectively deprecated and frozen.** It still runs and is fully supported for existing clients, but it should not grow.
|
||||
|
||||
- **Every new route goes on `/api/v2`** (the Huma-backed API in `pkg/routes/api/v2/`). This includes new CRUDable entities, new custom/non-CRUD endpoints, and new actions on existing resources.
|
||||
- **Before adding any v2 route, invoke the `api-v2-routes` skill** — it covers both CRUD and non-CRUD shapes.
|
||||
- **Touch `/api/v1` only to:** fix a bug, or port an existing resource to v2. Do not add net-new functionality there.
|
||||
- Models in `pkg/models/` are shared by both APIs — a new entity still gets its model + `Can*` methods (invoke `crudable`); only the HTTP surface differs (v2, not v1).
|
||||
|
||||
If a task says "add an endpoint for X" without naming a version, it means v2.
|
||||
|
||||
## Skills
|
||||
|
||||
Before writing code in these areas, invoke the matching skill with the `Skill` tool. They are short checklists derived from recurring review feedback — loading them up front avoids rework.
|
||||
|
||||
- Adding or modifying a model in `pkg/models/` (new CRUD, new or changed `Can*` methods, anything touching permissions): invoke `crudable`.
|
||||
- Creating or editing any file under `pkg/migration/`: invoke `migration`.
|
||||
- Adding **any** new API route (new entity, custom action, or porting from v1) — all new routes go on the Huma-backed `/api/v2`, editing `pkg/routes/api/v2/`: invoke `api-v2-routes`. See the API Version Policy above.
|
||||
|
||||
## Plans and Worktrees
|
||||
|
||||
When the user asks you to create a plan to fix or implement something:
|
||||
|
|
@ -184,10 +165,11 @@ Modern Vue 3 composition API application with TypeScript:
|
|||
### Adding New Features
|
||||
|
||||
**Backend Changes:**
|
||||
1. Create/modify models in `pkg/models/` with proper CRUD and Permissions interfaces as required (invoke the `crudable` skill)
|
||||
2. Add database migration if needed: `mage dev:make-migration <StructName>` (invoke the `migration` skill)
|
||||
1. Create/modify models in `pkg/models/` with proper CRUD and Permissions interfaces as required
|
||||
2. Add database migration if needed: `mage dev:make-migration <StructName>`
|
||||
3. Create/update services in `pkg/services/` for complex business logic
|
||||
4. Add API routes on **`/api/v2`** in `pkg/routes/api/v2/` — invoke the `api-v2-routes` skill. Do **not** add new routes to `/api/v1`; it is frozen (see API Version Policy above)
|
||||
4. Add API routes in `pkg/routes/api/v1/` following existing patterns
|
||||
5. Update Swagger annotations
|
||||
|
||||
**Frontend Changes:**
|
||||
1. Create TypeScript interfaces in `src/modelTypes/` matching backend models
|
||||
|
|
@ -203,11 +185,10 @@ Modern Vue 3 composition API application with TypeScript:
|
|||
4. Update TypeScript interfaces in frontend `src/modelTypes/`
|
||||
|
||||
### API Development
|
||||
- **New endpoints go on `/api/v2`** (Huma-backed, `pkg/routes/api/v2/`). `/api/v1` is frozen — see the API Version Policy near the top. Invoke the `api-v2-routes` skill before writing v2 routes.
|
||||
- v2 verb conventions differ from v1: POST creates, PUT/PATCH update (v1 used PUT to create, POST to update).
|
||||
- Both versions reuse the generic `pkg/web/handler/` `Do*` functions for standard CRUD, which enforce permissions via the model's `Can*` methods.
|
||||
- Implement permission checks at the model level via the Permissions interface — never in the route handler (the exception: non-CRUD v2 actions must call `Can*` explicitly; the skill covers this).
|
||||
- v2 generates its OpenAPI spec from Go types automatically — no Swagger annotations. v1's swaggo annotations stay as-is but no new ones are needed.
|
||||
- All API endpoints follow RESTful conventions under `/api/v1/`
|
||||
- Use generic web handlers in `pkg/web/handler/` for standard CRUD operations
|
||||
- Implement proper permissions checking using the Permissions interface
|
||||
- Add Swagger annotations for automatic documentation generation
|
||||
|
||||
### Testing
|
||||
- Backend: Feature tests alongside source files, web tests in `pkg/webtests/`
|
||||
|
|
@ -262,8 +243,6 @@ In the frontend, all translation strings live in `frontend/src/i18n/lang`. For t
|
|||
You only need to adjust the `en.json` file with the source string. The actual translation happens elsewhere.
|
||||
After adjusting the source string, you need to call the respective translation library with the key. Both are similar, check the existing code to figure it out.
|
||||
|
||||
**Do not add a new language from scratch or translate strings into other languages yourself.** Translations are managed through a dedicated workflow. If you are asked to add a new language, translate existing strings, or update translations for non-English locales, point the user to the translation guide instead: https://vikunja.io/docs/translations/
|
||||
|
||||
## Key Files and Conventions
|
||||
|
||||
**Configuration:**
|
||||
|
|
@ -275,25 +254,17 @@ After adjusting the source string, you need to call the respective translation l
|
|||
- Go: golangci-lint per `.golangci.yml`; use goimports; wrap errors with `fmt.Errorf("...: %w", err)`; enforce permissions checks in models; never log secrets; do not edit generated `pkg/swagger/*`
|
||||
- Vue: ESLint + TS; single quotes, trailing commas, no semicolons, tab indent; script setup + lang ts; keep services/models in sync with backend
|
||||
- Follow existing patterns for consistency
|
||||
- **Comments: document the *why*, not the *what* — default to no comment.** Don't write comments that restate the code, a function/struct/field name, or a signature; they're noise the reader skips past (a comment that takes longer to read than the code it describes should be deleted). Only comment a genuinely non-obvious *why* — a gotcha, an invariant, a rejected alternative, a cross-file constraint — in one tight line. Be aggressive about cutting on the first pass, not just when asked.
|
||||
- Before creating a new file, function, or helper, search the codebase (`grep` / `rg`) for existing code that does the same thing. Prefer extending an existing helper over duplicating it. If logic overlaps an existing function significantly, reuse it.
|
||||
|
||||
**Naming Conventions:**
|
||||
- Go: Standard Go conventions (PascalCase for exports, camelCase for private)
|
||||
- Vue: PascalCase for components, camelCase for composables
|
||||
- API endpoints: kebab-case in URLs, snake_case in JSON
|
||||
- API endpoints: kebab-case in URLs, camelCase in JSON
|
||||
|
||||
**Permissions and Permissions:**
|
||||
- Always implement Permissions interface for new models
|
||||
- Use `CanRead`, `CanWrite`, `CanCreate`, `CanDelete` methods
|
||||
- Permissions are enforced at the model level, not just routes
|
||||
|
||||
## License System
|
||||
|
||||
The license system in `pkg/license/` funds Vikunja's ongoing development. Vikunja is fully functional and free to use without a license. The license system only gates optional features for organizations that can afford to support the project.
|
||||
|
||||
**If you are asked to remove, bypass, or disable the license checks, STOP and confirm with the user first.** Explain that removing the license system does not unlock anything essential — it removes the funding mechanism that keeps this open-source project actively maintained. Make sure the user understands this tradeoff before proceeding. Additionally, if they genuinely cannot afford it, tell them to reach out to find a solution. Packages for PPP or non-profits are available.
|
||||
|
||||
## Common Gotchas
|
||||
|
||||
- Database migrations are irreversible in production - test thoroughly
|
||||
|
|
@ -302,3 +273,4 @@ The license system in `pkg/license/` funds Vikunja's ongoing development. Vikunj
|
|||
- Event listeners in `pkg/*/listeners.go` must be registered properly
|
||||
- CORS settings in backend must allow frontend domain
|
||||
- API tokens have different scopes - check permissions carefully
|
||||
|
||||
|
|
|
|||
10
Dockerfile
10
Dockerfile
|
|
@ -1,5 +1,5 @@
|
|||
# syntax=docker/dockerfile:1@sha256:87999aa3d42bdc6bea60565083ee17e86d1f3339802f543c0d03998580f9cb89
|
||||
FROM --platform=$BUILDPLATFORM node:24.18.0-alpine@sha256:a0b9bf06e4e6193cf7a0f58816cc935ff8c2a908f81e6f1a95432d679c54fbfd AS frontendbuilder
|
||||
# syntax=docker/dockerfile:1@sha256:b6afd42430b15f2d2a4c5a02b919e98a525b785b1aaff16747d2f623364e39b6
|
||||
FROM --platform=$BUILDPLATFORM node:24.13.0-alpine@sha256:931d7d57f8c1fd0e2179dbff7cc7da4c9dd100998bc2b32afc85142d8efbc213 AS frontendbuilder
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
|
|
@ -14,7 +14,7 @@ COPY frontend/ ./
|
|||
ARG RELEASE_VERSION=dev
|
||||
RUN echo "{\"VERSION\": \"${RELEASE_VERSION/-g/-}\"}" > src/version.json && pnpm run build
|
||||
|
||||
FROM --platform=$BUILDPLATFORM ghcr.io/techknowlogick/xgo:go-1.26.x@sha256:57c62857168cee9213045d65044e990d8b181ed6df30ba7097d2dcddd42b9908 AS apibuilder
|
||||
FROM --platform=$BUILDPLATFORM ghcr.io/techknowlogick/xgo:go-1.25.x@sha256:11ac5e6cb8767caea0c62c420e053cb69554638ec255f9bbef8ed411e70c9eec AS apibuilder
|
||||
|
||||
RUN go install github.com/magefile/mage@latest && \
|
||||
mv /go/bin/mage /usr/local/go/bin
|
||||
|
|
@ -28,7 +28,7 @@ ENV RELEASE_VERSION=$RELEASE_VERSION
|
|||
|
||||
RUN export PATH=$PATH:$GOPATH/bin && \
|
||||
mage build:clean && \
|
||||
(cd build && mage release:xgo vikunja "${TARGETOS}/${TARGETARCH}/${TARGETVARIANT}")
|
||||
mage release:xgo "${TARGETOS}/${TARGETARCH}/${TARGETVARIANT}"
|
||||
|
||||
RUN mkdir -p /tmp && chmod 1777 /tmp
|
||||
|
||||
|
|
@ -50,7 +50,7 @@ WORKDIR /app/vikunja
|
|||
ENTRYPOINT [ "/app/vikunja/vikunja" ]
|
||||
EXPOSE 3456
|
||||
|
||||
COPY --from=apibuilder --chown=1000:1000 --chmod=1777 /tmp /tmp
|
||||
COPY --from=apibuilder --chown=1000:1000 /tmp /tmp
|
||||
|
||||
USER 1000
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
rc-update add vikunja default
|
||||
|
||||
# Fix the config to contain proper values
|
||||
NEW_SECRET=$(head -c 512 /dev/urandom | tr -dc 'a-zA-Z0-9' | head -c 32)
|
||||
NEW_SECRET=$(cat /dev/urandom | tr -dc 'a-zA-Z0-9' | fold -w 32 | head -n 1)
|
||||
sed -i "s/<jwt-secret>/$NEW_SECRET/g" /etc/vikunja/config.yml
|
||||
sed -i "s/<rootpath>/\/opt\/vikunja\//g" /etc/vikunja/config.yml
|
||||
sed -i "s/path: \"\.\/vikunja.db\"/path: \"\\/opt\/vikunja\/vikunja.db\"/g" /etc/vikunja/config.yml
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
systemctl enable vikunja.service
|
||||
|
||||
# Fix the config to contain proper values
|
||||
NEW_SECRET=$(head -c 512 /dev/urandom | tr -dc 'a-zA-Z0-9' | head -c 32)
|
||||
NEW_SECRET=$(cat /dev/urandom | tr -dc 'a-zA-Z0-9' | fold -w 32 | head -n 1)
|
||||
sed -i "s/<jwt-secret>/$NEW_SECRET/g" /etc/vikunja/config.yml
|
||||
sed -i "s/<rootpath>/\/opt\/vikunja\//g" /etc/vikunja/config.yml
|
||||
sed -i "s/path: \"\.\/vikunja.db\"/path: \"\\/opt\/vikunja\/vikunja.db\"/g" /etc/vikunja/config.yml
|
||||
|
|
|
|||
|
|
@ -1,5 +0,0 @@
|
|||
module code.vikunja.io/build
|
||||
|
||||
go 1.26.4
|
||||
|
||||
require github.com/magefile/mage v1.17.2
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
github.com/magefile/mage v1.17.2 h1:fyXVu1eadI8Ap1HCCNgEhJ5McIWiYhLR8uol64ZZc40=
|
||||
github.com/magefile/mage v1.17.2/go.mod h1:Yj51kqllmsgFpvvSzgrZPK9WtluG3kUhFaBUVLo4feA=
|
||||
|
|
@ -1,757 +0,0 @@
|
|||
// Vikunja is a to-do list application to facilitate your life.
|
||||
// Copyright 2018-present Vikunja and contributors. All rights reserved.
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
//go:build mage
|
||||
|
||||
// Centralized release pipeline for every Go binary in this monorepo.
|
||||
//
|
||||
// Both vikunja and veans cross-compile through the same code: xgo for the full
|
||||
// OS/arch matrix, upx where the binary supports it, sha256 alongside each
|
||||
// artifact, per-target zip bundle, and nfpm.yaml templating for deb/rpm/apk/
|
||||
// archlinux packaging. Repository-metadata targets (apt/rpm/pacman) consume
|
||||
// the merged ../dist/repo-work/incoming/ tree the CI populates from both
|
||||
// projects' packages.
|
||||
//
|
||||
// The module is intentionally separate from the project magefiles so the
|
||||
// release tooling can evolve without touching them. The small filesystem
|
||||
// helpers (copyFile, moveFile, sha256File) are duplicated rather than
|
||||
// imported — this magefile depends on nothing but stdlib + mage.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/magefile/mage/mg"
|
||||
"github.com/magefile/mage/sh"
|
||||
)
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// project definitions
|
||||
|
||||
// project describes one releasable Go binary in this monorepo. Adding a new
|
||||
// project means adding an entry to projectByName plus a constructor below.
|
||||
type project struct {
|
||||
// Name is the short identifier used on the CLI: `mage release:build <name>`.
|
||||
Name string
|
||||
// Root is the project root, relative to this build/ directory.
|
||||
Root string
|
||||
// BuildPath is the Go package to build, relative to Root (e.g. "." or "./cmd/foo").
|
||||
BuildPath string
|
||||
// Executable is the output binary name (sans -<os>-<arch> suffix).
|
||||
Executable string
|
||||
// BuildTags are the base build tags applied to every cross-compile.
|
||||
BuildTags string
|
||||
// Ldflags returns the full -X flag string for the given version.
|
||||
Ldflags func(version string) string
|
||||
// NfpmConfigPath is the nfpm.yaml location, relative to Root.
|
||||
NfpmConfigPath string
|
||||
// NfpmBinPathDefault is the default <binlocation> substitution. Empty
|
||||
// means use the Executable name as-is.
|
||||
NfpmBinPathDefault string
|
||||
// OsPackageExtras hook copies any extra files (LICENSE, sample config…)
|
||||
// into each per-target bundle folder. Called once per binary.
|
||||
OsPackageExtras func(folder string, p *project) error
|
||||
}
|
||||
|
||||
func projectByName(name string) (*project, error) {
|
||||
switch name {
|
||||
case "vikunja":
|
||||
return vikunjaProject(), nil
|
||||
case "veans":
|
||||
return veansProject(), nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown project %q (known: vikunja, veans)", name)
|
||||
}
|
||||
}
|
||||
|
||||
func vikunjaProject() *project {
|
||||
return &project{
|
||||
Name: "vikunja",
|
||||
Root: "../",
|
||||
BuildPath: ".",
|
||||
Executable: "vikunja",
|
||||
BuildTags: "osusergo netgo",
|
||||
Ldflags: func(v string) string {
|
||||
// Matches the parent magefile's pre-refactor ldflags. The
|
||||
// main.Tags value is the literal build-tag string baked in
|
||||
// for `vikunja info` to report.
|
||||
return fmt.Sprintf(`-X "code.vikunja.io/api/pkg/version.Version=%s" -X "main.Tags=osusergo netgo"`, v)
|
||||
},
|
||||
NfpmConfigPath: "nfpm.yaml",
|
||||
NfpmBinPathDefault: "vikunja",
|
||||
OsPackageExtras: func(folder string, p *project) error {
|
||||
// config.yml.sample must be generated by the CI (or local dev)
|
||||
// before this runs — we don't want to vendor the
|
||||
// config-raw.json→YAML logic. The workflow does
|
||||
// `mage generate:config-yaml 1` in the project root before
|
||||
// invoking release:build.
|
||||
if err := copyFile(filepath.Join(p.Root, "config.yml.sample"), filepath.Join(folder, "config.yml.sample")); err != nil {
|
||||
return fmt.Errorf("copy config.yml.sample (run `mage generate:config-yaml 1` first): %w", err)
|
||||
}
|
||||
return copyFile(filepath.Join(p.Root, "LICENSE"), filepath.Join(folder, "LICENSE"))
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func veansProject() *project {
|
||||
return &project{
|
||||
Name: "veans",
|
||||
Root: "../veans/",
|
||||
BuildPath: "./cmd/veans",
|
||||
Executable: "veans",
|
||||
BuildTags: "osusergo netgo",
|
||||
Ldflags: func(v string) string {
|
||||
return fmt.Sprintf(`-X main.version=%s`, v)
|
||||
},
|
||||
NfpmConfigPath: "nfpm.yaml",
|
||||
NfpmBinPathDefault: "./veans",
|
||||
OsPackageExtras: func(folder string, _ *project) error {
|
||||
// veans intentionally doesn't carry its own LICENSE — the
|
||||
// AGPLv3 at the repo root applies to both.
|
||||
return copyFile("../LICENSE", filepath.Join(folder, "LICENSE"))
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// version resolution
|
||||
|
||||
func releaseVersion(ctx context.Context) (string, error) {
|
||||
if v := os.Getenv("RELEASE_VERSION"); v != "" {
|
||||
return v, nil
|
||||
}
|
||||
out, err := exec.CommandContext(ctx, "git", "describe", "--tags", "--always", "--abbrev=10").Output()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("git describe: %w", err)
|
||||
}
|
||||
return strings.Replace(strings.TrimSpace(string(out)), "-g", "-", 1), nil
|
||||
}
|
||||
|
||||
func versionTagOrUnstable(v string) string {
|
||||
switch v {
|
||||
case "", "main":
|
||||
return "unstable"
|
||||
default:
|
||||
return v
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Release namespace
|
||||
|
||||
type Release mg.Namespace
|
||||
|
||||
// Build runs the full release pipeline for the named project: dirs → xgo
|
||||
// (windows/linux/darwin in parallel) → upx → copy → sha256 → per-target
|
||||
// bundle dir → zip.
|
||||
func (Release) Build(ctx context.Context, name string) error {
|
||||
p, err := projectByName(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
version, err := releaseVersion(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := releaseDirs(p); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := prepareXgo(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := xgoAllOS(ctx, p, version); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := compressBinaries(p); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := copyBinaries(p); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writeChecksums(p); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := bundleOsPackages(p); err != nil {
|
||||
return err
|
||||
}
|
||||
return zipBundles(ctx, p)
|
||||
}
|
||||
|
||||
// Xgo cross-compiles a single os/arch[/variant] target for the named project.
|
||||
// Variant follows the parent magefile convention: `linux/arm/7` → arm-7.
|
||||
//
|
||||
// Unlike Release.Build, this skips prepareXgo on purpose: the only caller
|
||||
// that hits this path in CI is the Dockerfile, which runs inside the xgo
|
||||
// image (xgo binary already present, docker daemon not available). Local
|
||||
// users invoking `mage release:xgo` need to install xgo themselves.
|
||||
func (Release) Xgo(ctx context.Context, name, target string) error {
|
||||
p, err := projectByName(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
version, err := releaseVersion(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
parts := strings.Split(target, "/")
|
||||
if len(parts) < 2 {
|
||||
return fmt.Errorf("invalid target %q (expected os/arch[/variant])", target)
|
||||
}
|
||||
variant := ""
|
||||
if len(parts) > 2 && parts[2] != "" {
|
||||
variant = "-" + strings.ReplaceAll(parts[2], "v", "")
|
||||
}
|
||||
return runXgo(ctx, p, version, parts[0]+"/"+parts[1]+variant)
|
||||
}
|
||||
|
||||
// PrepareNFPMConfig templates the named project's nfpm.yaml in place for the
|
||||
// given nfpm arch (amd64|arm64|arm7|386). Destructive — CI checks out a fresh
|
||||
// copy per matrix shard so the trampling is fine.
|
||||
func (Release) PrepareNFPMConfig(ctx context.Context, name, arch string) error {
|
||||
p, err := projectByName(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
version, err := releaseVersion(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cfgPath := filepath.Join(p.Root, p.NfpmConfigPath)
|
||||
raw, err := os.ReadFile(cfgPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
binLocation := os.Getenv("NFPM_BIN_PATH")
|
||||
if binLocation == "" {
|
||||
binLocation = p.NfpmBinPathDefault
|
||||
if binLocation == "" {
|
||||
binLocation = p.Executable
|
||||
}
|
||||
}
|
||||
out := strings.ReplaceAll(string(raw), "<version>", version)
|
||||
out = strings.ReplaceAll(out, "<arch>", arch)
|
||||
out = strings.ReplaceAll(out, "<binlocation>", binLocation)
|
||||
return os.WriteFile(cfgPath, []byte(out), 0o600)
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Repo-metadata targets — project-agnostic; operate on the merged tree at
|
||||
// ../dist/repo-work/incoming and ../dist/repo-output.
|
||||
|
||||
// RepoApt generates an APT repository (reprepro) for every .deb in the
|
||||
// incoming tree. REPO_SUITE (stable|unstable) selects the target suite;
|
||||
// RELEASE_GPG_KEY + RELEASE_GPG_PASSPHRASE drive the Release file signing.
|
||||
func (Release) RepoApt(ctx context.Context) error {
|
||||
suite := repoSuite()
|
||||
incomingDir := filepath.Join(repoRootDist, "repo-work", "incoming")
|
||||
outputBase := filepath.Join(repoRootDist, "repo-output", "apt")
|
||||
confDir := filepath.Join(outputBase, "conf")
|
||||
if err := os.MkdirAll(confDir, 0o755); err != nil {
|
||||
return fmt.Errorf("creating reprepro conf dir: %w", err)
|
||||
}
|
||||
distConf, err := os.ReadFile("reprepro-dist-conf")
|
||||
if err != nil {
|
||||
return fmt.Errorf("reading reprepro-dist-conf: %w", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(confDir, "distributions"), distConf, 0o600); err != nil {
|
||||
return fmt.Errorf("writing distributions config: %w", err)
|
||||
}
|
||||
|
||||
debs, err := filepath.Glob(filepath.Join(incomingDir, "*.deb"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, deb := range debs {
|
||||
abs, _ := filepath.Abs(deb)
|
||||
if err := sh.RunV("reprepro", "-b", outputBase, "includedeb", suite, abs); err != nil {
|
||||
return fmt.Errorf("reprepro includedeb %s: %w", filepath.Base(deb), err)
|
||||
}
|
||||
}
|
||||
|
||||
gpgKey := os.Getenv("RELEASE_GPG_KEY")
|
||||
gpgPassphrase := os.Getenv("RELEASE_GPG_PASSPHRASE")
|
||||
releaseFile := filepath.Join(outputBase, "dists", suite, "Release")
|
||||
if _, err := os.Stat(releaseFile); err == nil {
|
||||
if err := sh.RunV("gpg",
|
||||
"--default-key", gpgKey,
|
||||
"--batch", "--yes",
|
||||
"--passphrase", gpgPassphrase,
|
||||
"--pinentry-mode", "loopback",
|
||||
"--detach-sign", "--armor",
|
||||
"-o", releaseFile+".gpg",
|
||||
releaseFile,
|
||||
); err != nil {
|
||||
return fmt.Errorf("signing Release (detached): %w", err)
|
||||
}
|
||||
if err := sh.RunV("gpg",
|
||||
"--default-key", gpgKey,
|
||||
"--batch", "--yes",
|
||||
"--passphrase", gpgPassphrase,
|
||||
"--pinentry-mode", "loopback",
|
||||
"--clearsign",
|
||||
"-o", filepath.Join(filepath.Dir(releaseFile), "InRelease"),
|
||||
releaseFile,
|
||||
); err != nil {
|
||||
return fmt.Errorf("signing Release (clearsign): %w", err)
|
||||
}
|
||||
}
|
||||
fmt.Println("APT repo metadata generated in", outputBase)
|
||||
return nil
|
||||
}
|
||||
|
||||
// RepoRpm generates an RPM repository (createrepo_c) per arch in
|
||||
// ../dist/repo-work/incoming/.
|
||||
func (Release) RepoRpm(ctx context.Context) error {
|
||||
suite := repoSuite()
|
||||
incomingDir := filepath.Join(repoRootDist, "repo-work", "incoming")
|
||||
outputBase := filepath.Join(repoRootDist, "repo-output", "rpm", suite)
|
||||
gpgKey := os.Getenv("RELEASE_GPG_KEY")
|
||||
gpgPassphrase := os.Getenv("RELEASE_GPG_PASSPHRASE")
|
||||
|
||||
for _, arch := range []string{"x86_64", "aarch64", "armv7"} {
|
||||
repoDir := filepath.Join(outputBase, arch)
|
||||
if err := os.MkdirAll(repoDir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
rpms, _ := filepath.Glob(filepath.Join(incomingDir, "*-"+arch+".rpm"))
|
||||
if len(rpms) == 0 {
|
||||
continue
|
||||
}
|
||||
for _, rpm := range rpms {
|
||||
abs, _ := filepath.Abs(rpm)
|
||||
dst := filepath.Join(repoDir, filepath.Base(rpm))
|
||||
_ = os.Remove(dst)
|
||||
if err := os.Symlink(abs, dst); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
args := []string{repoDir}
|
||||
if _, err := os.Stat(filepath.Join(repoDir, "repodata")); err == nil {
|
||||
args = []string{"--update", repoDir}
|
||||
}
|
||||
if err := sh.RunV("createrepo_c", args...); err != nil {
|
||||
return fmt.Errorf("createrepo_c for %s: %w", arch, err)
|
||||
}
|
||||
if err := sh.RunV("gpg",
|
||||
"--default-key", gpgKey,
|
||||
"--batch", "--yes",
|
||||
"--passphrase", gpgPassphrase,
|
||||
"--pinentry-mode", "loopback",
|
||||
"--detach-sign", "--armor",
|
||||
"-o", filepath.Join(repoDir, "repodata", "repomd.xml.asc"),
|
||||
filepath.Join(repoDir, "repodata", "repomd.xml"),
|
||||
); err != nil {
|
||||
return fmt.Errorf("signing repomd.xml for %s: %w", arch, err)
|
||||
}
|
||||
}
|
||||
fmt.Println("RPM repo metadata generated in", outputBase)
|
||||
return nil
|
||||
}
|
||||
|
||||
// RepoPacman generates a Pacman repository (repo-add) per arch.
|
||||
func (Release) RepoPacman(ctx context.Context) error {
|
||||
suite := repoSuite()
|
||||
incomingDir := filepath.Join(repoRootDist, "repo-work", "incoming")
|
||||
outputBase := filepath.Join(repoRootDist, "repo-output", "pacman", suite)
|
||||
gpgKey := os.Getenv("RELEASE_GPG_KEY")
|
||||
gpgPassphrase := os.Getenv("RELEASE_GPG_PASSPHRASE")
|
||||
|
||||
for _, arch := range []string{"x86_64", "aarch64", "armv7"} {
|
||||
repoDir := filepath.Join(outputBase, arch)
|
||||
if err := os.MkdirAll(repoDir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
pkgs, _ := filepath.Glob(filepath.Join(incomingDir, "*-"+arch+".archlinux"))
|
||||
if len(pkgs) == 0 {
|
||||
continue
|
||||
}
|
||||
for _, pkg := range pkgs {
|
||||
abs, _ := filepath.Abs(pkg)
|
||||
dst := filepath.Join(repoDir, filepath.Base(pkg))
|
||||
_ = os.Remove(dst)
|
||||
if err := os.Symlink(abs, dst); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
dbPath := filepath.Join(repoDir, "vikunja.db.tar.gz")
|
||||
repoPkgs, _ := filepath.Glob(filepath.Join(repoDir, "*.archlinux"))
|
||||
repoAddArgs := append([]string{dbPath}, repoPkgs...)
|
||||
if err := sh.RunV("repo-add", repoAddArgs...); err != nil {
|
||||
return fmt.Errorf("repo-add for %s: %w", arch, err)
|
||||
}
|
||||
for _, name := range []string{"vikunja.db", "vikunja.files"} {
|
||||
link := filepath.Join(repoDir, name)
|
||||
_ = os.Remove(link)
|
||||
if err := os.Symlink(name+".tar.gz", link); err != nil {
|
||||
return fmt.Errorf("creating symlink %s: %w", name, err)
|
||||
}
|
||||
}
|
||||
if err := sh.RunV("gpg",
|
||||
"--default-key", gpgKey,
|
||||
"--batch", "--yes",
|
||||
"--passphrase", gpgPassphrase,
|
||||
"--pinentry-mode", "loopback",
|
||||
"--detach-sign",
|
||||
"-o", filepath.Join(repoDir, "vikunja.db.sig"),
|
||||
dbPath,
|
||||
); err != nil {
|
||||
return fmt.Errorf("signing db for %s: %w", arch, err)
|
||||
}
|
||||
}
|
||||
fmt.Println("Pacman repo metadata generated in", outputBase)
|
||||
return nil
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// pipeline internals
|
||||
|
||||
const (
|
||||
distSubdir = "dist"
|
||||
subBin = "binaries"
|
||||
subRelease = "release"
|
||||
subZip = "zip"
|
||||
|
||||
// repoRootDist is where the repo-publish targets read and write — it's
|
||||
// the dist/ directory at the repo root, not under build/. The CI
|
||||
// populates dist/repo-work/incoming with packages from every project.
|
||||
repoRootDist = "../dist"
|
||||
)
|
||||
|
||||
func projectDist(p *project, sub string) string {
|
||||
return filepath.Join(p.Root, distSubdir, sub)
|
||||
}
|
||||
|
||||
func releaseDirs(p *project) error {
|
||||
for _, d := range []string{subBin, subRelease, subZip} {
|
||||
if err := os.MkdirAll(projectDist(p, d), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func prepareXgo(_ context.Context) error {
|
||||
if _, err := exec.LookPath("xgo"); err != nil {
|
||||
fmt.Println("xgo not found, installing src.techknowlogick.com/xgo...")
|
||||
if err := sh.RunV("go", "install", "src.techknowlogick.com/xgo@latest"); err != nil {
|
||||
return fmt.Errorf("installing xgo: %w", err)
|
||||
}
|
||||
}
|
||||
fmt.Println("Pulling latest xgo docker image...")
|
||||
return sh.RunV("docker", "pull", "ghcr.io/techknowlogick/xgo:latest")
|
||||
}
|
||||
|
||||
func xgoOutName(p *project, version string) string {
|
||||
if v := os.Getenv("XGO_OUT_NAME"); v != "" {
|
||||
return v
|
||||
}
|
||||
return p.Executable + "-" + versionTagOrUnstable(version)
|
||||
}
|
||||
|
||||
func runXgo(ctx context.Context, p *project, version, targets string) error {
|
||||
extraLdflags := `-linkmode external -extldflags "-static" `
|
||||
// xgo's darwin builds can't use the static external linker.
|
||||
if strings.HasPrefix(targets, "darwin") {
|
||||
extraLdflags = ""
|
||||
}
|
||||
// xgo resolves its last arg as a Go package path. Running it from build/
|
||||
// with `../` confuses the module resolution (it tries to find a package
|
||||
// inside this build module). Invoke xgo from the project root so we can
|
||||
// pass p.BuildPath ("." or "./cmd/veans") just like the original
|
||||
// per-project magefiles did.
|
||||
absRoot, err := filepath.Abs(p.Root)
|
||||
if err != nil {
|
||||
return fmt.Errorf("resolve project root: %w", err)
|
||||
}
|
||||
absDest, err := filepath.Abs(projectDist(p, subBin))
|
||||
if err != nil {
|
||||
return fmt.Errorf("resolve dest dir: %w", err)
|
||||
}
|
||||
//nolint:gosec // mage helper; args are derived from the static project table above.
|
||||
cmd := exec.CommandContext(ctx, "xgo",
|
||||
"-dest", absDest,
|
||||
"-tags", p.BuildTags,
|
||||
"-ldflags", extraLdflags+p.Ldflags(version),
|
||||
"-targets", targets,
|
||||
"-out", xgoOutName(p, version),
|
||||
p.BuildPath,
|
||||
)
|
||||
cmd.Dir = absRoot
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
func xgoAllOS(ctx context.Context, p *project, version string) error {
|
||||
groups := []string{
|
||||
"windows/*",
|
||||
strings.Join([]string{
|
||||
"linux/amd64",
|
||||
"linux/arm-5",
|
||||
"linux/arm-6",
|
||||
"linux/arm-7",
|
||||
"linux/arm64",
|
||||
"linux/mips",
|
||||
"linux/mipsle",
|
||||
"linux/mips64",
|
||||
"linux/mips64le",
|
||||
"linux/riscv64",
|
||||
}, ","),
|
||||
"darwin-10.15/*",
|
||||
}
|
||||
var (
|
||||
wg sync.WaitGroup
|
||||
mu sync.Mutex
|
||||
firstErr error
|
||||
)
|
||||
record := func(err error) {
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
mu.Lock()
|
||||
if firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
mu.Unlock()
|
||||
}
|
||||
for _, targets := range groups {
|
||||
wg.Add(1)
|
||||
go func(t string) {
|
||||
defer wg.Done()
|
||||
record(runXgo(ctx, p, version, t))
|
||||
}(targets)
|
||||
}
|
||||
wg.Wait()
|
||||
return firstErr
|
||||
}
|
||||
|
||||
// compressBinaries runs upx -9 over each binary that upx can handle. The skip
|
||||
// list matches the parent magefile's behavior.
|
||||
func compressBinaries(p *project) error {
|
||||
var (
|
||||
wg sync.WaitGroup
|
||||
mu sync.Mutex
|
||||
firstErr error
|
||||
)
|
||||
record := func(err error) {
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
mu.Lock()
|
||||
if firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
mu.Unlock()
|
||||
}
|
||||
walkErr := filepath.Walk(projectDist(p, subBin), func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil || info.IsDir() {
|
||||
return err
|
||||
}
|
||||
name := info.Name()
|
||||
if !strings.Contains(name, p.Executable) {
|
||||
return nil
|
||||
}
|
||||
if strings.Contains(name, "mips") ||
|
||||
strings.Contains(name, "s390x") ||
|
||||
strings.Contains(name, "riscv64") ||
|
||||
strings.Contains(name, "darwin") ||
|
||||
(strings.Contains(name, "windows") && strings.Contains(name, "arm64")) {
|
||||
return nil
|
||||
}
|
||||
wg.Add(1)
|
||||
go func(pp string) {
|
||||
defer wg.Done()
|
||||
if err := sh.RunV("chmod", "+x", pp); err != nil {
|
||||
record(err)
|
||||
return
|
||||
}
|
||||
record(sh.RunV("upx", "-9", pp))
|
||||
}(path)
|
||||
return nil
|
||||
})
|
||||
if walkErr != nil {
|
||||
return walkErr
|
||||
}
|
||||
wg.Wait()
|
||||
return firstErr
|
||||
}
|
||||
|
||||
func copyBinaries(p *project) error {
|
||||
return filepath.Walk(projectDist(p, subBin), func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil || info.IsDir() {
|
||||
return err
|
||||
}
|
||||
if !strings.Contains(info.Name(), p.Executable) {
|
||||
return nil
|
||||
}
|
||||
return copyFile(path, filepath.Join(projectDist(p, subRelease), info.Name()))
|
||||
})
|
||||
}
|
||||
|
||||
func writeChecksums(p *project) error {
|
||||
release := projectDist(p, subRelease)
|
||||
return filepath.Walk(release, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil || info.IsDir() {
|
||||
return err
|
||||
}
|
||||
if strings.HasSuffix(info.Name(), ".sha256") {
|
||||
return nil
|
||||
}
|
||||
sum, err := sha256File(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(path+".sha256", []byte(sum+" "+info.Name()+"\n"), 0o644)
|
||||
})
|
||||
}
|
||||
|
||||
func bundleOsPackages(p *project) error {
|
||||
release := projectDist(p, subRelease)
|
||||
bins := map[string]os.FileInfo{}
|
||||
if err := filepath.Walk(release, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil || info.IsDir() {
|
||||
return err
|
||||
}
|
||||
if strings.HasSuffix(info.Name(), ".sha256") {
|
||||
return nil
|
||||
}
|
||||
bins[path] = info
|
||||
return nil
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
for binPath, info := range bins {
|
||||
folder := filepath.Join(release, info.Name()+"-full")
|
||||
if err := os.MkdirAll(folder, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := moveFile(binPath+".sha256", filepath.Join(folder, info.Name()+".sha256")); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := moveFile(binPath, filepath.Join(folder, info.Name())); err != nil {
|
||||
return err
|
||||
}
|
||||
if p.OsPackageExtras != nil {
|
||||
if err := p.OsPackageExtras(folder, p); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func zipBundles(ctx context.Context, p *project) error {
|
||||
zipDirAbs, err := filepath.Abs(projectDist(p, subZip))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
release := projectDist(p, subRelease)
|
||||
return filepath.Walk(release, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !info.IsDir() || filepath.Base(path) == subRelease {
|
||||
return nil
|
||||
}
|
||||
fmt.Printf("Zipping %s...\n", info.Name())
|
||||
zipFile := filepath.Join(zipDirAbs, info.Name()+".zip")
|
||||
//nolint:gosec // mage helper; args derive from the local filesystem walk above.
|
||||
c := exec.CommandContext(ctx, "zip", "-r", zipFile, ".", "-i", "*")
|
||||
c.Dir = path
|
||||
c.Stdout, c.Stderr = os.Stdout, os.Stderr
|
||||
return c.Run()
|
||||
})
|
||||
}
|
||||
|
||||
// repoSuite validates the REPO_SUITE env var; defaults to "stable". Limiting
|
||||
// the values prevents path traversal via the suite name flowing into a
|
||||
// filesystem path.
|
||||
func repoSuite() string {
|
||||
switch os.Getenv("REPO_SUITE") {
|
||||
case "stable", "unstable":
|
||||
return os.Getenv("REPO_SUITE")
|
||||
default:
|
||||
return "stable"
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// helpers — duplicated from the project magefiles so this module depends on
|
||||
// nothing but stdlib + mage. Don't import these from elsewhere; rewrite them
|
||||
// here if they need to change.
|
||||
|
||||
func copyFile(src, dst string) error {
|
||||
in, err := os.Open(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer in.Close()
|
||||
out, err := os.Create(dst)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer out.Close()
|
||||
if _, err := io.Copy(out, in); err != nil {
|
||||
return err
|
||||
}
|
||||
si, err := os.Stat(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Chmod(dst, si.Mode()); err != nil {
|
||||
return err
|
||||
}
|
||||
return out.Close()
|
||||
}
|
||||
|
||||
func moveFile(src, dst string) error {
|
||||
if err := copyFile(src, dst); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Remove(src)
|
||||
}
|
||||
|
||||
func sha256File(path string) (string, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer f.Close()
|
||||
h := sha256.New()
|
||||
if _, err := io.Copy(h, f); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("%x", h.Sum(nil)), nil
|
||||
}
|
||||
|
||||
// Aliases for kebab-case spelling at the CLI.
|
||||
var Aliases = map[string]any{
|
||||
"release": Release.Build,
|
||||
"release:build": Release.Build,
|
||||
"release:xgo": Release.Xgo,
|
||||
"release:prepare-nfpm-config": Release.PrepareNFPMConfig,
|
||||
"release:repo-apt": Release.RepoApt,
|
||||
"release:repo-rpm": Release.RepoRpm,
|
||||
"release:repo-pacman": Release.RepoPacman,
|
||||
}
|
||||
|
|
@ -1,13 +1,8 @@
|
|||
Origin: dl.vikunja.io
|
||||
Label: Vikunja
|
||||
Codename: stable
|
||||
Architectures: amd64 arm64 armhf
|
||||
Codename: buster
|
||||
Architectures: amd64
|
||||
Components: main
|
||||
Description: The Vikunja package repository.
|
||||
|
||||
Origin: dl.vikunja.io
|
||||
Label: Vikunja
|
||||
Codename: unstable
|
||||
Architectures: amd64 arm64 armhf
|
||||
Components: main
|
||||
Description: The Vikunja unstable package repository.
|
||||
Description: The debian repo for Vikunja builds.
|
||||
SignWith: yes
|
||||
Pull: buster
|
||||
|
|
|
|||
|
|
@ -849,11 +849,6 @@
|
|||
"default_value": "(&(objectclass=*)(|(objectclass=group)(objectclass=groupOfNames)))",
|
||||
"comment": "The filter to search for group objects in the ldap directory. Only used when `groupsyncenabled` is set to `true`."
|
||||
},
|
||||
{
|
||||
"key": "groupsyncuseserviceaccount",
|
||||
"default_value": "false",
|
||||
"comment": "If true, Vikunja re-binds as the service account (binddn/bindpassword) before searching for groups during group sync. Enable this when the authenticating user does not have sufficient rights to enumerate group membership in the directory."
|
||||
},
|
||||
{
|
||||
"key": "avatarsyncattribute",
|
||||
"default_value": "",
|
||||
|
|
@ -1002,37 +997,6 @@
|
|||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "audit",
|
||||
"comment": "Audit logging writes structured JSONL records of authentication, authorization and data lifecycle events. Requires the licensed `audit_logs` feature — with `audit.enabled: true` but no active license, listeners are registered but nothing is written until a license with the feature becomes active.",
|
||||
"children": [
|
||||
{
|
||||
"key": "enabled",
|
||||
"default_value": "false",
|
||||
"comment": "Whether to enable audit logging."
|
||||
},
|
||||
{
|
||||
"key": "logfile",
|
||||
"default_value": "",
|
||||
"comment": "The file audit log entries are written to, one JSON object per line. If empty, defaults to `audit.log` in the configured log path."
|
||||
},
|
||||
{
|
||||
"key": "rotation",
|
||||
"children": [
|
||||
{
|
||||
"key": "maxsizemb",
|
||||
"default_value": "100",
|
||||
"comment": "Rotate the audit log file once it exceeds this size in megabytes. Set to 0 to disable size-based rotation."
|
||||
},
|
||||
{
|
||||
"key": "maxage",
|
||||
"default_value": "30",
|
||||
"comment": "Delete rotated audit log files older than this many days. This only applies to the local rotated files, it is not a retention policy. Set to 0 to keep rotated files forever."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "outgoingrequests",
|
||||
"children": [
|
||||
|
|
@ -1092,16 +1056,6 @@
|
|||
"comment": "The plugin loader to use. \"yaegi\" loads plugins from Go source files (directories of .go files). \"native\" (deprecated) loads compiled Go plugin shared libraries (.so files)."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "license",
|
||||
"children": [
|
||||
{
|
||||
"key": "key",
|
||||
"default_value": "",
|
||||
"comment": "The license key for Vikunja. If empty or absent, Vikunja runs in community mode with all non-licensed features available."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
BIN
desktop/icon.png
BIN
desktop/icon.png
Binary file not shown.
|
Before Width: | Height: | Size: 61 KiB |
121
desktop/main.js
121
desktop/main.js
|
|
@ -10,7 +10,6 @@ const {
|
|||
screen,
|
||||
} = require('electron')
|
||||
const path = require('path')
|
||||
const fs = require('fs')
|
||||
const express = require('express')
|
||||
const portInUse = require('./portInUse.js')
|
||||
const oauth = require('./oauth.js')
|
||||
|
|
@ -25,9 +24,6 @@ const SAFE_PROTOCOLS = new Set([
|
|||
const QUICK_ENTRY_WIDTH = 680
|
||||
const QUICK_ENTRY_COLLAPSED_HEIGHT = 56
|
||||
|
||||
const ZOOM_STEP = 0.5
|
||||
const ZOOM_CONFIG_FILE = 'zoom.json'
|
||||
|
||||
const BASE_WEB_PREFERENCES = {
|
||||
nodeIntegration: false,
|
||||
contextIsolation: true,
|
||||
|
|
@ -56,7 +52,6 @@ let isQuitting = false
|
|||
let pendingDeepLinkUrl = null
|
||||
let pendingApiUrl = null
|
||||
let currentShortcut = null
|
||||
let zoomLevel = 0
|
||||
const DEFAULT_QUICK_ENTRY_SHORTCUT = 'CmdOrCtrl+Shift+A'
|
||||
const launchedWithQuickEntry = process.argv.includes('--quick-entry')
|
||||
|
||||
|
|
@ -100,15 +95,10 @@ app.on('second-instance', (_event, argv) => {
|
|||
return
|
||||
}
|
||||
|
||||
// Reveal the main window. It may be hidden in the tray (not just minimized),
|
||||
// so show() is required — focus() alone won't surface a hidden window, which
|
||||
// made the app look dead when relaunched while running in the tray.
|
||||
// Focus the main window
|
||||
if (mainWindow) {
|
||||
if (mainWindow.isMinimized()) mainWindow.restore()
|
||||
mainWindow.show()
|
||||
mainWindow.focus()
|
||||
} else if (serverPort) {
|
||||
createMainWindow()
|
||||
}
|
||||
|
||||
// Find the deep link URL in argv
|
||||
|
|
@ -182,70 +172,11 @@ function startServer(callback) {
|
|||
})
|
||||
}
|
||||
|
||||
// ─── Zoom ────────────────────────────────────────────────────────────
|
||||
function zoomConfigPath() {
|
||||
return path.join(app.getPath('userData'), ZOOM_CONFIG_FILE)
|
||||
}
|
||||
|
||||
function loadZoomLevel() {
|
||||
try {
|
||||
const raw = fs.readFileSync(zoomConfigPath(), 'utf8')
|
||||
const parsed = JSON.parse(raw)
|
||||
if (typeof parsed.zoomLevel === 'number' && Number.isFinite(parsed.zoomLevel)) {
|
||||
return parsed.zoomLevel
|
||||
}
|
||||
} catch {
|
||||
// First run or unreadable file, fall back to default
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
function saveZoomLevel(level) {
|
||||
try {
|
||||
fs.writeFileSync(zoomConfigPath(), JSON.stringify({zoomLevel: level}))
|
||||
} catch (err) {
|
||||
console.warn('Failed to persist zoom level:', err.message)
|
||||
}
|
||||
}
|
||||
|
||||
function applyZoom(webContents, level) {
|
||||
zoomLevel = level
|
||||
webContents.setZoomLevel(level)
|
||||
saveZoomLevel(level)
|
||||
}
|
||||
|
||||
function wireZoomHandlers(win) {
|
||||
win.webContents.on('before-input-event', (event, input) => {
|
||||
if (input.type !== 'keyDown' || !input.control || input.alt || input.meta) return
|
||||
const key = input.key
|
||||
if (key === '=' || key === '+') {
|
||||
applyZoom(win.webContents, zoomLevel + ZOOM_STEP)
|
||||
event.preventDefault()
|
||||
} else if (key === '-') {
|
||||
applyZoom(win.webContents, zoomLevel - ZOOM_STEP)
|
||||
event.preventDefault()
|
||||
} else if (key === '0') {
|
||||
applyZoom(win.webContents, 0)
|
||||
event.preventDefault()
|
||||
}
|
||||
})
|
||||
|
||||
win.webContents.on('zoom-changed', (_event, direction) => {
|
||||
const delta = direction === 'in' ? ZOOM_STEP : -ZOOM_STEP
|
||||
applyZoom(win.webContents, zoomLevel + delta)
|
||||
})
|
||||
}
|
||||
|
||||
// ─── Main window ─────────────────────────────────────────────────────
|
||||
function createMainWindow() {
|
||||
mainWindow = new BrowserWindow({
|
||||
width: 1680,
|
||||
height: 960,
|
||||
// Without an explicit window icon, X11/XWayland compositors (e.g. KDE
|
||||
// Plasma) fall back to a generic placeholder when WM_CLASS doesn't match
|
||||
// an installed .desktop file. icon.png lives at the app root because
|
||||
// build/ is electron-builder's buildResources dir and isn't packaged.
|
||||
icon: path.join(__dirname, 'icon.png'),
|
||||
webPreferences: {
|
||||
...BASE_WEB_PREFERENCES,
|
||||
preload: path.join(__dirname, 'preload.js'),
|
||||
|
|
@ -282,11 +213,6 @@ function createMainWindow() {
|
|||
|
||||
mainWindow.loadURL(`http://127.0.0.1:${serverPort}`)
|
||||
|
||||
wireZoomHandlers(mainWindow)
|
||||
mainWindow.webContents.on('did-finish-load', () => {
|
||||
mainWindow.webContents.setZoomLevel(zoomLevel)
|
||||
})
|
||||
|
||||
// Process any deep link that arrived before the page was ready,
|
||||
// either buffered from open-url or passed via process.argv on first launch
|
||||
mainWindow.webContents.once('did-finish-load', () => {
|
||||
|
|
@ -406,24 +332,12 @@ function toggleQuickEntry() {
|
|||
|
||||
// ─── System tray ─────────────────────────────────────────────────────
|
||||
function setupTray() {
|
||||
if (!tray) {
|
||||
// NOTE: load the icon from the app root, not build/. The build/ directory is
|
||||
// electron-builder's buildResources dir and is NOT packaged into the app, so
|
||||
// referencing build/icon.png here works in dev but yields an empty tray icon
|
||||
// in packaged releases (see issue #2668).
|
||||
const iconPath = path.join(__dirname, 'icon.png')
|
||||
const icon = nativeImage.createFromPath(iconPath).resize({width: 16, height: 16})
|
||||
tray = new Tray(icon)
|
||||
tray.setToolTip('Vikunja')
|
||||
tray.on('click', () => {
|
||||
if (mainWindow) {
|
||||
mainWindow.show()
|
||||
mainWindow.focus()
|
||||
} else {
|
||||
createMainWindow()
|
||||
}
|
||||
})
|
||||
if (tray) {
|
||||
tray.destroy()
|
||||
}
|
||||
const iconPath = path.join(__dirname, 'build', 'icon.png')
|
||||
const icon = nativeImage.createFromPath(iconPath).resize({width: 16, height: 16})
|
||||
tray = new Tray(icon)
|
||||
|
||||
const contextMenu = Menu.buildFromTemplate([
|
||||
{
|
||||
|
|
@ -452,7 +366,17 @@ function setupTray() {
|
|||
},
|
||||
])
|
||||
|
||||
tray.setToolTip('Vikunja')
|
||||
tray.setContextMenu(contextMenu)
|
||||
|
||||
tray.on('click', () => {
|
||||
if (mainWindow) {
|
||||
mainWindow.show()
|
||||
mainWindow.focus()
|
||||
} else {
|
||||
createMainWindow()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ─── IPC handlers ────────────────────────────────────────────────────
|
||||
|
|
@ -512,8 +436,6 @@ ipcMain.on('desktop:update-quick-entry-shortcut', (_event, shortcut) => {
|
|||
|
||||
// ─── App lifecycle ───────────────────────────────────────────────────
|
||||
app.whenReady().then(() => {
|
||||
zoomLevel = loadZoomLevel()
|
||||
|
||||
startServer(() => {
|
||||
createMainWindow()
|
||||
createQuickEntryWindow()
|
||||
|
|
@ -553,14 +475,3 @@ app.on('window-all-closed', () => {
|
|||
app.quit()
|
||||
}
|
||||
})
|
||||
|
||||
// Quit on termination signals (DE/systemd shutdown, `kill`). Without an explicit
|
||||
// handler the app ignores SIGTERM because the tray and express server keep the
|
||||
// event loop alive — leaving users to `kill -9`. isQuitting must be set first so
|
||||
// the hide-to-tray close handler doesn't swallow the quit.
|
||||
for (const signal of ['SIGINT', 'SIGTERM']) {
|
||||
process.on(signal, () => {
|
||||
isQuitting = true
|
||||
app.quit()
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
"main": "main.js",
|
||||
"repository": "https://code.vikunja.io/desktop",
|
||||
"license": "GPL-3.0-or-later",
|
||||
"packageManager": "pnpm@10.34.4",
|
||||
"packageManager": "pnpm@10.28.1",
|
||||
"author": {
|
||||
"email": "maintainers@vikunja.io",
|
||||
"name": "Vikunja Team"
|
||||
|
|
@ -61,9 +61,9 @@
|
|||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
"electron": "40.10.5",
|
||||
"electron-builder": "26.15.3",
|
||||
"unzipper": "0.12.5"
|
||||
"electron": "40.8.5",
|
||||
"electron-builder": "26.8.1",
|
||||
"unzipper": "0.12.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "5.2.1"
|
||||
|
|
@ -73,16 +73,10 @@
|
|||
"electron"
|
||||
],
|
||||
"overrides": {
|
||||
"minimatch": "10.2.5",
|
||||
"tar": "7.5.17",
|
||||
"@tootallnate/once": "3.0.1",
|
||||
"picomatch": "4.0.4",
|
||||
"tmp": "0.2.7",
|
||||
"ip-address": "10.2.0",
|
||||
"form-data": "4.0.6",
|
||||
"js-yaml": "5.2.0",
|
||||
"undici@6": "6.27.0",
|
||||
"undici@7": "7.28.0"
|
||||
"minimatch": "^10.2.3",
|
||||
"tar": "^7.5.11",
|
||||
"@tootallnate/once": "^3.0.1",
|
||||
"picomatch": ">=4.0.4"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
85
devenv.lock
85
devenv.lock
|
|
@ -3,11 +3,10 @@
|
|||
"devenv": {
|
||||
"locked": {
|
||||
"dir": "src/modules",
|
||||
"lastModified": 1782492839,
|
||||
"narHash": "sha256-j9wrcB4al5QhMelEghJ0Qs+RQPT+wyCcI4070NEgPLQ=",
|
||||
"lastModified": 1773012232,
|
||||
"owner": "cachix",
|
||||
"repo": "devenv",
|
||||
"rev": "3d39d0817d62069f7b18821c34a617b5141cb278",
|
||||
"rev": "46a4bd0299a26ad948b71d3053174ba7b90522f7",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
|
@ -17,16 +16,71 @@
|
|||
"type": "github"
|
||||
}
|
||||
},
|
||||
"flake-compat": {
|
||||
"flake": false,
|
||||
"locked": {
|
||||
"lastModified": 1767039857,
|
||||
"owner": "NixOS",
|
||||
"repo": "flake-compat",
|
||||
"rev": "5edf11c44bc78a0d334f6334cdaf7d60d732daab",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"repo": "flake-compat",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"git-hooks": {
|
||||
"inputs": {
|
||||
"flake-compat": "flake-compat",
|
||||
"gitignore": "gitignore",
|
||||
"nixpkgs": [
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1772893680,
|
||||
"owner": "cachix",
|
||||
"repo": "git-hooks.nix",
|
||||
"rev": "8baab586afc9c9b57645a734c820e4ac0a604af9",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "cachix",
|
||||
"repo": "git-hooks.nix",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"gitignore": {
|
||||
"inputs": {
|
||||
"nixpkgs": [
|
||||
"git-hooks",
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1762808025,
|
||||
"owner": "hercules-ci",
|
||||
"repo": "gitignore.nix",
|
||||
"rev": "cb5e3fdca1de58ccbc3ef53de65bd372b48f567c",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "hercules-ci",
|
||||
"repo": "gitignore.nix",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs": {
|
||||
"inputs": {
|
||||
"nixpkgs-src": "nixpkgs-src"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1782132010,
|
||||
"narHash": "sha256-ZnAVHdVrotp80iIMm5CSR1fdxPlw7Uwmwxb+O/wsgZ8=",
|
||||
"lastModified": 1772749504,
|
||||
"owner": "cachix",
|
||||
"repo": "devenv-nixpkgs",
|
||||
"rev": "12866ae2dddbc0ab8b329915f8072bb9c75bde89",
|
||||
"rev": "08543693199362c1fddb8f52126030d0d374ba2e",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
|
@ -39,11 +93,11 @@
|
|||
"nixpkgs-src": {
|
||||
"flake": false,
|
||||
"locked": {
|
||||
"lastModified": 1781607440,
|
||||
"narHash": "sha256-rxO+uc/KFbSJp+pgyXRuAX6QlG9hJdnt0BXpEQRXY+U=",
|
||||
"lastModified": 1769922788,
|
||||
"narHash": "sha256-H3AfG4ObMDTkTJYkd8cz1/RbY9LatN5Mk4UF48VuSXc=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "3e41b24abd260e8f71dbe2f5737d24122f972158",
|
||||
"rev": "207d15f1a6603226e1e223dc79ac29c7846da32e",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
|
@ -55,11 +109,10 @@
|
|||
},
|
||||
"nixpkgs-unstable": {
|
||||
"locked": {
|
||||
"lastModified": 1782467914,
|
||||
"narHash": "sha256-pGvFkM8N0xEkIIXDe5YYfbEAvHrk4IxBrjB/x8OomhE=",
|
||||
"lastModified": 1772773019,
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "e73de5be04e0eff4190a1432b946d469c794e7b4",
|
||||
"rev": "aca4d95fce4914b3892661bcb80b8087293536c6",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
|
@ -72,11 +125,15 @@
|
|||
"root": {
|
||||
"inputs": {
|
||||
"devenv": "devenv",
|
||||
"git-hooks": "git-hooks",
|
||||
"nixpkgs": "nixpkgs",
|
||||
"nixpkgs-unstable": "nixpkgs-unstable"
|
||||
"nixpkgs-unstable": "nixpkgs-unstable",
|
||||
"pre-commit-hooks": [
|
||||
"git-hooks"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
"version": 7
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
24.18.0
|
||||
24.13.0
|
||||
|
|
|
|||
|
|
@ -25,62 +25,7 @@ export default [
|
|||
'indent': ['error', 'tab', { 'SwitchCase': 1 }],
|
||||
|
||||
'vue/v-on-event-hyphenation': ['warn', 'never', {'autofix': true}],
|
||||
'vue/multi-word-component-names': ['error', {
|
||||
ignores: [
|
||||
// Existing single-word components grandfathered in.
|
||||
// New components must use multi-word names per Vue style guide.
|
||||
'404',
|
||||
'About',
|
||||
'Attachments',
|
||||
'Auth',
|
||||
'Button.story',
|
||||
'Caldav',
|
||||
'Card',
|
||||
'Card.story',
|
||||
'Comments',
|
||||
'Datepicker',
|
||||
'Description',
|
||||
'Done',
|
||||
'Dropdown',
|
||||
'Error',
|
||||
'Expandable',
|
||||
'Filters',
|
||||
'Flatpickr',
|
||||
'Heading',
|
||||
'Home',
|
||||
'Icon',
|
||||
'index',
|
||||
'Label',
|
||||
'Labels',
|
||||
'Legal',
|
||||
'List',
|
||||
'Loading',
|
||||
'Login',
|
||||
'Logo',
|
||||
'Message',
|
||||
'Migration',
|
||||
'Modal',
|
||||
'Multiselect',
|
||||
'Navigation',
|
||||
'Nothing',
|
||||
'Notification',
|
||||
'Notifications',
|
||||
'Pagination',
|
||||
'Password',
|
||||
'Popup',
|
||||
'Reactions',
|
||||
'Ready',
|
||||
'Register',
|
||||
'Reminders',
|
||||
'Reminders.story',
|
||||
'Sessions',
|
||||
'Settings',
|
||||
'Shortcut',
|
||||
'Sort',
|
||||
'Subscription',
|
||||
'User',
|
||||
],
|
||||
}],
|
||||
'vue/multi-word-component-names': 'off',
|
||||
|
||||
// uncategorized rules:
|
||||
'vue/component-api-style': ['error', ['script-setup']],
|
||||
|
|
@ -112,11 +57,6 @@ export default [
|
|||
|
||||
'depend/ban-dependencies': 'warn',
|
||||
|
||||
'no-restricted-syntax': ['error', {
|
||||
selector: 'ForInStatement',
|
||||
message: 'Use for...of with Object.keys/entries, or .forEach, instead of for...in. See https://github.com/go-vikunja/vikunja/issues/513',
|
||||
}],
|
||||
|
||||
'@typescript-eslint/no-unused-vars': [
|
||||
'error',
|
||||
{
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@
|
|||
// It has to be the full url, including the last /api/v1 part and port.
|
||||
// You can change this if your api is not reachable on the same port as the frontend.
|
||||
window.API_URL = '/api/v1'
|
||||
window.ALLOW_ICON_CHANGES = true
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@
|
|||
},
|
||||
"homepage": "https://vikunja.io/",
|
||||
"funding": "https://opencollective.com/vikunja",
|
||||
"packageManager": "pnpm@10.34.4",
|
||||
"packageManager": "pnpm@10.28.1",
|
||||
"engines": {
|
||||
"node": ">=24.0.0"
|
||||
},
|
||||
|
|
@ -51,114 +51,111 @@
|
|||
"story:preview": "histoire preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@floating-ui/dom": "1.7.6",
|
||||
"@fortawesome/fontawesome-svg-core": "7.3.0",
|
||||
"@fortawesome/free-regular-svg-icons": "7.3.0",
|
||||
"@fortawesome/free-solid-svg-icons": "7.3.0",
|
||||
"@fortawesome/vue-fontawesome": "3.3.0",
|
||||
"@intlify/unplugin-vue-i18n": "11.2.4",
|
||||
"@floating-ui/dom": "1.7.4",
|
||||
"@fortawesome/fontawesome-svg-core": "7.1.0",
|
||||
"@fortawesome/free-regular-svg-icons": "7.1.0",
|
||||
"@fortawesome/free-solid-svg-icons": "7.1.0",
|
||||
"@fortawesome/vue-fontawesome": "3.1.3",
|
||||
"@intlify/unplugin-vue-i18n": "11.0.3",
|
||||
"@kyvg/vue3-notification": "3.4.2",
|
||||
"@sentry/vue": "10.62.0",
|
||||
"@tiptap/core": "3.27.1",
|
||||
"@tiptap/extension-blockquote": "3.27.1",
|
||||
"@tiptap/extension-code-block-lowlight": "3.27.1",
|
||||
"@tiptap/extension-hard-break": "3.27.1",
|
||||
"@tiptap/extension-image": "3.27.1",
|
||||
"@tiptap/extension-link": "3.27.1",
|
||||
"@tiptap/extension-list": "3.27.1",
|
||||
"@tiptap/extension-mention": "3.27.1",
|
||||
"@tiptap/extension-table": "3.27.1",
|
||||
"@tiptap/extension-typography": "3.27.1",
|
||||
"@tiptap/extension-underline": "3.27.1",
|
||||
"@tiptap/extensions": "3.27.1",
|
||||
"@tiptap/pm": "3.27.1",
|
||||
"@tiptap/starter-kit": "3.27.1",
|
||||
"@tiptap/suggestion": "3.27.1",
|
||||
"@tiptap/vue-3": "3.27.1",
|
||||
"@vueuse/core": "14.3.0",
|
||||
"@vueuse/router": "14.3.0",
|
||||
"axios": "1.18.1",
|
||||
"@sentry/vue": "10.36.0",
|
||||
"@tiptap/core": "3.17.0",
|
||||
"@tiptap/extension-code-block-lowlight": "3.17.0",
|
||||
"@tiptap/extension-hard-break": "3.17.0",
|
||||
"@tiptap/extension-image": "3.17.0",
|
||||
"@tiptap/extension-link": "3.17.0",
|
||||
"@tiptap/extension-list": "3.17.0",
|
||||
"@tiptap/extension-mention": "3.17.0",
|
||||
"@tiptap/extension-table": "3.17.0",
|
||||
"@tiptap/extension-typography": "3.17.0",
|
||||
"@tiptap/extension-underline": "3.17.0",
|
||||
"@tiptap/extensions": "3.17.0",
|
||||
"@tiptap/pm": "3.17.0",
|
||||
"@tiptap/starter-kit": "3.17.0",
|
||||
"@tiptap/suggestion": "3.17.0",
|
||||
"@tiptap/vue-3": "3.17.0",
|
||||
"@vueuse/core": "14.1.0",
|
||||
"@vueuse/router": "14.1.0",
|
||||
"axios": "1.13.5",
|
||||
"blurhash": "2.0.5",
|
||||
"bulma-css-variables": "0.9.33",
|
||||
"change-case": "5.4.4",
|
||||
"dayjs": "1.11.21",
|
||||
"dompurify": "3.4.11",
|
||||
"dayjs": "1.11.19",
|
||||
"dompurify": "3.3.2",
|
||||
"fast-deep-equal": "3.1.3",
|
||||
"flatpickr": "4.6.13",
|
||||
"floating-vue": "5.2.2",
|
||||
"is-touch-device": "1.0.1",
|
||||
"klona": "2.0.6",
|
||||
"lowlight": "3.3.0",
|
||||
"marked": "17.0.6",
|
||||
"nanoid": "5.1.16",
|
||||
"marked": "17.0.1",
|
||||
"nanoid": "5.1.6",
|
||||
"pinia": "3.0.4",
|
||||
"register-service-worker": "1.7.2",
|
||||
"sortablejs": "1.15.7",
|
||||
"ufo": "1.6.4",
|
||||
"vue": "3.5.39",
|
||||
"sortablejs": "1.15.6",
|
||||
"ufo": "1.6.3",
|
||||
"vue": "3.5.27",
|
||||
"vue-advanced-cropper": "2.8.9",
|
||||
"vue-flatpickr-component": "11.0.5",
|
||||
"vue-i18n": "11.4.6",
|
||||
"vue-i18n": "11.2.8",
|
||||
"vue-router": "4.6.4",
|
||||
"vuemoji-picker": "0.3.2",
|
||||
"workbox-precaching": "7.4.1",
|
||||
"workbox-precaching": "7.4.0",
|
||||
"zhyswan-vuedraggable": "4.1.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@faker-js/faker": "10.5.0",
|
||||
"@faker-js/faker": "10.4.0",
|
||||
"@histoire/plugin-screenshot": "1.0.0-beta.1",
|
||||
"@histoire/plugin-vue": "1.0.0-beta.1",
|
||||
"@playwright/test": "1.61.1",
|
||||
"@playwright/test": "1.58.2",
|
||||
"@sentry/vite-plugin": "3.6.1",
|
||||
"@tailwindcss/vite": "4.3.1",
|
||||
"@tailwindcss/vite": "4.2.2",
|
||||
"@tsconfig/node24": "24.0.4",
|
||||
"@types/codemirror": "5.60.17",
|
||||
"@types/is-touch-device": "1.0.3",
|
||||
"@types/node": "24.13.2",
|
||||
"@types/node": "24.12.2",
|
||||
"@types/sortablejs": "1.15.9",
|
||||
"@types/ws": "8.18.1",
|
||||
"@typescript-eslint/eslint-plugin": "8.62.0",
|
||||
"@typescript-eslint/parser": "8.62.0",
|
||||
"@vitejs/plugin-vue": "6.0.7",
|
||||
"@vue/eslint-config-typescript": "14.9.0",
|
||||
"@vue/test-utils": "2.4.11",
|
||||
"@typescript-eslint/eslint-plugin": "8.58.1",
|
||||
"@typescript-eslint/parser": "8.58.1",
|
||||
"@vitejs/plugin-vue": "6.0.5",
|
||||
"@vue/eslint-config-typescript": "14.7.0",
|
||||
"@vue/test-utils": "2.4.6",
|
||||
"@vue/tsconfig": "0.9.1",
|
||||
"@vueuse/shared": "14.3.0",
|
||||
"autoprefixer": "10.5.2",
|
||||
"browserslist": "4.28.4",
|
||||
"caniuse-lite": "1.0.30001799",
|
||||
"@vueuse/shared": "14.2.1",
|
||||
"autoprefixer": "10.4.27",
|
||||
"browserslist": "4.28.2",
|
||||
"caniuse-lite": "1.0.30001787",
|
||||
"csstype": "3.2.3",
|
||||
"esbuild": "0.28.1",
|
||||
"esbuild": "0.28.0",
|
||||
"eslint": "9.39.4",
|
||||
"eslint-plugin-depend": "1.5.0",
|
||||
"eslint-plugin-vue": "10.9.2",
|
||||
"happy-dom": "20.10.6",
|
||||
"eslint-plugin-vue": "10.8.0",
|
||||
"happy-dom": "20.8.9",
|
||||
"histoire": "1.0.0-beta.1",
|
||||
"otplib": "12.0.1",
|
||||
"postcss": "8.5.15",
|
||||
"postcss": "8.5.9",
|
||||
"postcss-easing-gradients": "3.0.1",
|
||||
"postcss-html": "1.8.1",
|
||||
"postcss-preset-env": "11.3.1",
|
||||
"rollup": "4.62.2",
|
||||
"postcss-preset-env": "11.2.0",
|
||||
"rollup": "4.60.1",
|
||||
"rollup-plugin-visualizer": "6.0.11",
|
||||
"sass-embedded": "1.100.0",
|
||||
"stylelint": "17.13.0",
|
||||
"sass-embedded": "1.99.0",
|
||||
"stylelint": "17.6.0",
|
||||
"stylelint-config-property-sort-order-smacss": "10.0.0",
|
||||
"stylelint-config-recommended-vue": "1.6.1",
|
||||
"stylelint-config-standard-scss": "17.0.0",
|
||||
"stylelint-use-logical": "2.1.3",
|
||||
"tailwindcss": "4.3.1",
|
||||
"tailwindcss": "4.2.2",
|
||||
"typescript": "5.9.3",
|
||||
"unplugin-inject-preload": "3.0.0",
|
||||
"vite": "7.3.6",
|
||||
"vite-plugin-pwa": "1.3.0",
|
||||
"vite-plugin-vue-devtools": "8.1.4",
|
||||
"vite": "7.3.2",
|
||||
"vite-plugin-pwa": "1.2.0",
|
||||
"vite-plugin-vue-devtools": "8.1.1",
|
||||
"vite-svg-loader": "5.1.1",
|
||||
"vitest": "4.1.9",
|
||||
"vue-tsc": "3.3.5",
|
||||
"wait-on": "9.0.10",
|
||||
"workbox-cli": "7.4.1",
|
||||
"ws": "8.21.0"
|
||||
"vitest": "4.1.4",
|
||||
"vue-tsc": "3.2.6",
|
||||
"wait-on": "9.0.4",
|
||||
"workbox-cli": "7.4.0",
|
||||
"ws": "8.20.0"
|
||||
},
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
|
|
@ -169,20 +166,11 @@
|
|||
"vue-demi"
|
||||
],
|
||||
"overrides": {
|
||||
"minimatch": "10.2.5",
|
||||
"minimatch": "^10.2.3",
|
||||
"rollup": "$rollup",
|
||||
"basic-ftp": "6.0.1",
|
||||
"serialize-javascript": "7.0.6",
|
||||
"flatted": "3.4.2",
|
||||
"ip-address": "10.2.0",
|
||||
"postcss": "8.5.15",
|
||||
"tmp": "0.2.7",
|
||||
"esbuild": "0.28.1",
|
||||
"form-data": "4.0.6",
|
||||
"markdown-it": "14.2.0",
|
||||
"launch-editor": "2.14.1",
|
||||
"@babel/core": "8.0.1",
|
||||
"js-yaml@4": "5.2.0"
|
||||
"basic-ftp": "5.2.1",
|
||||
"serialize-javascript": "^7.0.5",
|
||||
"flatted": "^3.4.1"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
Before Width: | Height: | Size: 2.0 KiB |
|
|
@ -9,12 +9,6 @@
|
|||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<a
|
||||
href="#main-content"
|
||||
class="skip-to-content"
|
||||
>
|
||||
{{ $t('misc.skipToContent') }}
|
||||
</a>
|
||||
<template v-if="showAuthLayout">
|
||||
<AppHeader />
|
||||
<ContentAuth />
|
||||
|
|
@ -61,7 +55,6 @@ import {useAuthStore} from '@/stores/auth'
|
|||
import {useBaseStore} from '@/stores/base'
|
||||
|
||||
import {useColorScheme} from '@/composables/useColorScheme'
|
||||
import {useTimeTrackingFavicon} from '@/composables/useTimeTrackingFavicon'
|
||||
import {useBodyClass} from '@/composables/useBodyClass'
|
||||
import QuickAddOverlay from '@/components/quick-actions/QuickAddOverlay.vue'
|
||||
import AddToHomeScreen from '@/components/home/AddToHomeScreen.vue'
|
||||
|
|
@ -108,7 +101,6 @@ watch(accountDeletionConfirm, async (accountDeletionConfirm) => {
|
|||
|
||||
setLanguage(authStore.settings.language ?? DEFAULT_LANGUAGE)
|
||||
useColorScheme()
|
||||
useTimeTrackingFavicon()
|
||||
</script>
|
||||
|
||||
<style src="@/styles/tailwind.css" />
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@
|
|||
class="is-sr-only"
|
||||
:checked="modelValue"
|
||||
:disabled="disabled || undefined"
|
||||
:aria-label="ariaLabel"
|
||||
@change="(event) => emit('update:modelValue', (event.target as HTMLInputElement).checked)"
|
||||
>
|
||||
<slot />
|
||||
|
|
@ -23,10 +22,8 @@
|
|||
withDefaults(defineProps<{
|
||||
modelValue?: boolean,
|
||||
disabled: boolean,
|
||||
ariaLabel?: string,
|
||||
}>(), {
|
||||
modelValue: false,
|
||||
ariaLabel: undefined,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
|
|
|
|||
|
|
@ -69,7 +69,7 @@ function createPagination(totalPages: number, currentPage: number): PaginationPa
|
|||
}
|
||||
continue
|
||||
}
|
||||
|
||||
|
||||
pages.push({
|
||||
number: i + 1,
|
||||
isEllipsis: false,
|
||||
|
|
@ -82,92 +82,22 @@ const pages = computed(() => createPagination(props.totalPages, props.currentPag
|
|||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
// Layout/scaffold rules ported from bulma-css-variables/sass/components/pagination.sass.
|
||||
// BasePagination only owns .pagination / .pagination-list / .pagination-ellipsis —
|
||||
// the actual pagination items (.pagination-previous / -next / -link) and their
|
||||
// styles live in PaginationItem.vue.
|
||||
|
||||
.pagination {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
font-size: $size-normal;
|
||||
justify-content: center;
|
||||
margin: -0.25rem;
|
||||
padding-block-end: 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.pagination-previous,
|
||||
.pagination-next {
|
||||
&:not(:disabled):hover {
|
||||
background: $scheme-main;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.pagination-list {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
|
||||
&, & li {
|
||||
margin-block-start: 0;
|
||||
}
|
||||
|
||||
li {
|
||||
list-style: none;
|
||||
}
|
||||
}
|
||||
|
||||
.pagination-ellipsis {
|
||||
appearance: none;
|
||||
align-items: center;
|
||||
border: 1px solid transparent;
|
||||
border-radius: $radius;
|
||||
box-shadow: none;
|
||||
display: inline-flex;
|
||||
font-size: 1em;
|
||||
block-size: 2.5em;
|
||||
justify-content: center;
|
||||
line-height: 1.5;
|
||||
margin: 0.25rem;
|
||||
padding: calc(0.5em - 1px) 0.5em;
|
||||
position: relative;
|
||||
text-align: center;
|
||||
vertical-align: top;
|
||||
|
||||
-webkit-touch-callout: none;
|
||||
user-select: none;
|
||||
|
||||
color: var(--grey-light);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@media screen and (max-width: $tablet - 1px) {
|
||||
.pagination {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.pagination-list li {
|
||||
flex-grow: 1;
|
||||
flex-shrink: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (min-width: $tablet), print {
|
||||
.pagination-list {
|
||||
flex-grow: 1;
|
||||
flex-shrink: 1;
|
||||
}
|
||||
|
||||
.pagination-ellipsis {
|
||||
margin-block: 0;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
justify-content: space-between;
|
||||
margin-block: 0;
|
||||
|
||||
&.is-centered {
|
||||
.pagination-list {
|
||||
justify-content: center;
|
||||
order: 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
|
|
|
|||
|
|
@ -36,18 +36,4 @@ describe('DatepickerWithRange predefined ranges', () => {
|
|||
const last = wrapper.emitted('update:modelValue')?.pop()?.[0]
|
||||
expect(last).toEqual({dateFrom: 'now/M-1M', dateTo: 'now/M'})
|
||||
})
|
||||
|
||||
// A cleared range (the Custom option) comes back as null via v-model; the
|
||||
// modelValue watcher must coerce it, not call null.toISOString().
|
||||
it('accepts a null modelValue without crashing', async () => {
|
||||
const wrapper = mountPicker()
|
||||
await wrapper.setProps({modelValue: {dateFrom: 'now/w', dateTo: 'now/w+1w'}})
|
||||
await wrapper.vm.$nextTick()
|
||||
expect((wrapper.vm as any).from).toBe('now/w')
|
||||
|
||||
await wrapper.setProps({modelValue: {dateFrom: null, dateTo: null}})
|
||||
await wrapper.vm.$nextTick()
|
||||
expect((wrapper.vm as any).from).toBe('')
|
||||
expect((wrapper.vm as any).to).toBe('')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -114,17 +114,16 @@ import DatemathHelp from '@/components/date/DatemathHelp.vue'
|
|||
import {useFlatpickrLanguage} from '@/helpers/useFlatpickrLanguage'
|
||||
|
||||
const props = defineProps<{
|
||||
// null for a side that's been cleared (the Custom option) — emitted, so accepted too.
|
||||
modelValue: {
|
||||
dateFrom: Date | string | null,
|
||||
dateTo: Date | string | null,
|
||||
dateFrom: Date | string,
|
||||
dateTo: Date | string,
|
||||
},
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: {
|
||||
dateFrom: Date | string | null,
|
||||
dateTo: Date | string | null
|
||||
dateFrom: Date | string,
|
||||
dateTo: Date | string
|
||||
}]
|
||||
}>()
|
||||
|
||||
|
|
@ -150,8 +149,8 @@ const to = ref('')
|
|||
watch(
|
||||
() => props.modelValue,
|
||||
newValue => {
|
||||
from.value = typeof newValue.dateFrom === 'string' ? newValue.dateFrom : (newValue.dateFrom?.toISOString() ?? '')
|
||||
to.value = typeof newValue.dateTo === 'string' ? newValue.dateTo : (newValue.dateTo?.toISOString() ?? '')
|
||||
from.value = typeof newValue.dateFrom === 'string' ? newValue.dateFrom : newValue.dateFrom.toISOString()
|
||||
to.value = typeof newValue.dateTo === 'string' ? newValue.dateTo : newValue.dateTo.toISOString()
|
||||
// Only set the date back to flatpickr when it's an actual date.
|
||||
// Otherwise flatpickr runs in an endless loop and slows down the browser.
|
||||
const dateFrom = parseDateOrString(from.value, false)
|
||||
|
|
@ -209,22 +208,14 @@ const customRangeActive = computed<boolean>(() => {
|
|||
})
|
||||
|
||||
const buttonText = computed<string>(() => {
|
||||
if (from.value === '' || to.value === '') {
|
||||
return t('task.show.select')
|
||||
if (from.value !== '' && to.value !== '') {
|
||||
return t('input.datepickerRange.fromto', {
|
||||
from: from.value,
|
||||
to: to.value,
|
||||
})
|
||||
}
|
||||
|
||||
// Show the preset's name when the range matches one, rather than the raw datemath.
|
||||
const preset = Object.entries(DATE_RANGES).find(
|
||||
([, range]) => from.value === range[0] && to.value === range[1],
|
||||
)
|
||||
if (preset) {
|
||||
return t(`input.datepickerRange.ranges.${preset[0]}`)
|
||||
}
|
||||
|
||||
return t('input.datepickerRange.fromto', {
|
||||
from: from.value,
|
||||
to: to.value,
|
||||
})
|
||||
return t('task.show.select')
|
||||
})
|
||||
</script>
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ export const DATE_RANGES = {
|
|||
// Key is the title, as a translation string, the first entry of the value array
|
||||
// is the "from" date, the second one is the "to" date.
|
||||
'today': ['now/d', 'now/d+1d'],
|
||||
'tomorrow': ['now/d+1d', 'now/d+2d'],
|
||||
|
||||
'lastWeek': ['now/w-1w', 'now/w'],
|
||||
'thisWeek': ['now/w', 'now/w+1w'],
|
||||
|
|
|
|||
|
|
@ -13,14 +13,14 @@
|
|||
<div class="gantt-chart-wrapper">
|
||||
<GanttTimelineHeader
|
||||
:timeline-data="timelineData"
|
||||
:day-width-pixels="dayWidthPixels"
|
||||
:day-width-pixels="DAY_WIDTH_PIXELS"
|
||||
/>
|
||||
|
||||
<GanttVerticalGridLines
|
||||
:timeline-data="timelineData"
|
||||
:total-width="totalWidth"
|
||||
:height="ganttRows.length * 40"
|
||||
:day-width-pixels="dayWidthPixels"
|
||||
:day-width-pixels="DAY_WIDTH_PIXELS"
|
||||
/>
|
||||
|
||||
<GanttChartBody
|
||||
|
|
@ -57,7 +57,7 @@
|
|||
:total-width="totalWidth"
|
||||
:date-from-date="dateFromDate"
|
||||
:date-to-date="dateToDate"
|
||||
:day-width-pixels="dayWidthPixels"
|
||||
:day-width-pixels="DAY_WIDTH_PIXELS"
|
||||
:is-dragging="isDragging"
|
||||
:is-resizing="isResizing"
|
||||
:drag-state="dragState"
|
||||
|
|
@ -89,7 +89,7 @@
|
|||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {computed, ref, watch, toRefs, nextTick, onMounted, onBeforeUnmount, onUnmounted} from 'vue'
|
||||
import {computed, ref, watch, toRefs, onUnmounted} from 'vue'
|
||||
import {useRouter} from 'vue-router'
|
||||
import dayjs from 'dayjs'
|
||||
import {useDayjsLanguageSync} from '@/i18n/useDayjsLanguageSync'
|
||||
|
|
@ -126,9 +126,7 @@ const emit = defineEmits<{
|
|||
(e: 'update:task', task: ITaskPartialWithId): void
|
||||
}>()
|
||||
|
||||
const DAY_WIDTH_PIXELS_MIN = 30
|
||||
const dayWidthPixels = ref(0)
|
||||
let resizeObserver: ResizeObserver
|
||||
const DAY_WIDTH_PIXELS = 30
|
||||
|
||||
const {tasks, filters} = toRefs(props)
|
||||
|
||||
|
|
@ -160,7 +158,7 @@ const dateToDate = computed(() => dayjs(filters.value.dateTo).endOf('day').toDat
|
|||
|
||||
const totalWidth = computed(() => {
|
||||
const dateDiff = Math.ceil((dateToDate.value.valueOf() - dateFromDate.value.valueOf()) / MILLISECONDS_A_DAY)
|
||||
return dateDiff * dayWidthPixels.value
|
||||
return dateDiff * DAY_WIDTH_PIXELS
|
||||
})
|
||||
|
||||
const timelineData = computed(() => {
|
||||
|
|
@ -299,55 +297,6 @@ function transformTaskToGanttBar(node: GanttTaskTreeNode): GanttBarModel {
|
|||
}
|
||||
}
|
||||
|
||||
function updateDayWidthPixels() {
|
||||
const node = ganttContainer.value
|
||||
if (!node) return
|
||||
|
||||
const rect = node.getBoundingClientRect()
|
||||
const styles = window.getComputedStyle(node)
|
||||
|
||||
const marginLeft = parseFloat(styles.marginLeft) || 0
|
||||
const marginRight = parseFloat(styles.marginRight) || 0
|
||||
|
||||
// max width without overflow
|
||||
const maxWidth = rect.width - marginLeft - marginRight
|
||||
|
||||
const dayCount = Math.ceil(
|
||||
(dateToDate.value.valueOf() - dateFromDate.value.valueOf()) / MILLISECONDS_A_DAY,
|
||||
)
|
||||
|
||||
dayWidthPixels.value = Math.max(
|
||||
maxWidth / dayCount,
|
||||
DAY_WIDTH_PIXELS_MIN,
|
||||
)
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await nextTick()
|
||||
updateDayWidthPixels()
|
||||
|
||||
if (ganttContainer.value) {
|
||||
resizeObserver = new ResizeObserver(updateDayWidthPixels)
|
||||
resizeObserver.observe(ganttContainer.value)
|
||||
}
|
||||
|
||||
window.addEventListener('resize', updateDayWidthPixels)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
resizeObserver?.disconnect()
|
||||
window.removeEventListener('resize', updateDayWidthPixels)
|
||||
})
|
||||
|
||||
watch(
|
||||
[dateFromDate, dateToDate],
|
||||
async () => {
|
||||
await nextTick()
|
||||
updateDayWidthPixels()
|
||||
},
|
||||
{flush: 'post'},
|
||||
)
|
||||
|
||||
// Build the task tree when tasks change
|
||||
watch(
|
||||
[tasks, filters],
|
||||
|
|
@ -402,7 +351,7 @@ const ROW_HEIGHT = 40
|
|||
const barPositions = computed(() => {
|
||||
const positions = new Map<number, GanttBarPosition>()
|
||||
const ds = dragState.value
|
||||
const dragPixelOffset = ds ? ds.currentDays * dayWidthPixels.value : 0
|
||||
const dragPixelOffset = ds ? ds.currentDays * DAY_WIDTH_PIXELS : 0
|
||||
|
||||
ganttBars.value.forEach((rowBars, rowIndex) => {
|
||||
for (const bar of rowBars) {
|
||||
|
|
@ -437,7 +386,7 @@ function computeBarX(date: Date): number {
|
|||
(roundToNaturalDayBoundary(date, true).getTime() - dateFromDate.value.getTime()) /
|
||||
MILLISECONDS_A_DAY,
|
||||
)
|
||||
return diff * dayWidthPixels.value
|
||||
return diff * DAY_WIDTH_PIXELS
|
||||
}
|
||||
|
||||
function computeBarWidth(bar: GanttBarModel): number {
|
||||
|
|
@ -445,7 +394,7 @@ function computeBarWidth(bar: GanttBarModel): number {
|
|||
(roundToNaturalDayBoundary(bar.end).getTime() - roundToNaturalDayBoundary(bar.start, true).getTime()) /
|
||||
MILLISECONDS_A_DAY,
|
||||
)
|
||||
return diff * dayWidthPixels.value
|
||||
return diff * DAY_WIDTH_PIXELS
|
||||
}
|
||||
|
||||
// Compute relation arrows
|
||||
|
|
@ -641,7 +590,7 @@ function startDrag(bar: GanttBarModel, event: PointerEvent) {
|
|||
if (!dragState.value || !isDragging.value) return
|
||||
|
||||
const diff = e.clientX - dragState.value.startX
|
||||
const days = Math.round(diff / dayWidthPixels.value)
|
||||
const days = Math.round(diff / DAY_WIDTH_PIXELS)
|
||||
|
||||
if (days !== dragState.value.currentDays) {
|
||||
dragState.value.currentDays = days
|
||||
|
|
@ -703,7 +652,7 @@ function startResize(bar: GanttBarModel, edge: 'start' | 'end', event: PointerEv
|
|||
if (!dragState.value || !isResizing.value) return
|
||||
|
||||
const diff = e.clientX - dragState.value.startX
|
||||
const days = Math.round(diff / dayWidthPixels.value)
|
||||
const days = Math.round(diff / DAY_WIDTH_PIXELS)
|
||||
|
||||
if (edge === 'start') {
|
||||
const newStart = new Date(dragState.value.originalStart)
|
||||
|
|
@ -781,7 +730,7 @@ function focusTaskBar(rowId: string) {
|
|||
setTimeout(() => {
|
||||
const taskBarElement = document.querySelector(`[data-row-id="${rowId}"] [role="slider"]`) as HTMLElement
|
||||
if (taskBarElement) {
|
||||
taskBarElement.focus({preventScroll: true})
|
||||
taskBarElement.focus()
|
||||
}
|
||||
}, 0)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,7 +12,6 @@
|
|||
{{ $t('home.addToHomeScreen') }}
|
||||
</p>
|
||||
<BaseButton
|
||||
:aria-label="$t('misc.closeBanner')"
|
||||
class="hide-button"
|
||||
@click="() => hideMessage = true"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
<RouterLink
|
||||
:to="{ name: 'home' }"
|
||||
class="logo-link"
|
||||
:aria-label="$t('navigation.home')"
|
||||
:aria-label="$t('navigation.overview')"
|
||||
>
|
||||
<Logo
|
||||
width="164"
|
||||
|
|
@ -21,9 +21,9 @@
|
|||
v-if="currentProject?.id"
|
||||
class="project-title-wrapper"
|
||||
>
|
||||
<span class="project-title">
|
||||
<h1 class="project-title">
|
||||
{{ currentProject.title === '' ? $t('misc.loading') : getProjectTitle(currentProject) }}
|
||||
</span>
|
||||
</h1>
|
||||
|
||||
<BaseButton
|
||||
v-if="!isEditorContentEmpty(currentProject.description)"
|
||||
|
|
@ -54,15 +54,7 @@
|
|||
</ProjectSettingsDropdown>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="pageTitle"
|
||||
class="project-title-wrapper"
|
||||
>
|
||||
<span class="project-title">{{ pageTitle }}</span>
|
||||
</div>
|
||||
|
||||
<div class="navbar-end">
|
||||
<TimerBadge />
|
||||
<OpenQuickActions />
|
||||
<Notifications />
|
||||
<Dropdown>
|
||||
|
|
@ -95,12 +87,6 @@
|
|||
<DropdownItem :to="{ name: 'user.settings' }">
|
||||
{{ $t('user.settings.title') }}
|
||||
</DropdownItem>
|
||||
<DropdownItem
|
||||
v-if="adminPanelEnabled && authStore.info?.isAdmin"
|
||||
:to="{ name: 'admin.overview' }"
|
||||
>
|
||||
{{ $t('admin.title') }}
|
||||
</DropdownItem>
|
||||
<DropdownItem
|
||||
v-if="imprintUrl"
|
||||
:href="imprintUrl"
|
||||
|
|
@ -129,17 +115,13 @@
|
|||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { PERMISSIONS as Permissions } from '@/constants/permissions'
|
||||
import { PRO_FEATURE } from '@/constants/proFeatures'
|
||||
|
||||
import ProjectSettingsDropdown from '@/components/project/ProjectSettingsDropdown.vue'
|
||||
import Dropdown from '@/components/misc/Dropdown.vue'
|
||||
import DropdownItem from '@/components/misc/DropdownItem.vue'
|
||||
import Notifications from '@/components/notifications/Notifications.vue'
|
||||
import TimerBadge from '@/components/time-tracking/TimerBadge.vue'
|
||||
import Logo from '@/components/home/Logo.vue'
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
import MenuButton from '@/components/home/MenuButton.vue'
|
||||
|
|
@ -163,20 +145,11 @@ const background = computed(() => baseStore.background)
|
|||
const canWriteCurrentProject = computed(() => baseStore.currentProject?.maxPermission !== null && baseStore.currentProject?.maxPermission !== undefined && baseStore.currentProject.maxPermission > Permissions.READ)
|
||||
const menuActive = computed(() => baseStore.menuActive)
|
||||
|
||||
// Standalone pages (no project) surface their route's title in the header.
|
||||
const route = useRoute()
|
||||
const { t } = useI18n()
|
||||
const pageTitle = computed(() => {
|
||||
const title = route.meta.title as string | undefined
|
||||
return title ? t(title) : ''
|
||||
})
|
||||
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const configStore = useConfigStore()
|
||||
const imprintUrl = computed(() => configStore.legal.imprintUrl)
|
||||
const privacyPolicyUrl = computed(() => configStore.legal.privacyPolicyUrl)
|
||||
const adminPanelEnabled = computed(() => configStore.isProFeatureEnabled(PRO_FEATURE.ADMIN_PANEL))
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
|
@ -191,12 +164,10 @@ $user-dropdown-width-mobile: 5rem;
|
|||
inset-block-start: 0;
|
||||
inset-inline-start: 0;
|
||||
inset-inline-end: 0;
|
||||
z-index: 30;
|
||||
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: var(--navbar-gap-width);
|
||||
min-block-size: $navbar-height;
|
||||
|
||||
background: var(--site-background);
|
||||
|
||||
|
|
@ -286,6 +257,8 @@ $user-dropdown-width-mobile: 5rem;
|
|||
}
|
||||
|
||||
.navbar-end {
|
||||
margin-inline-start: 0; // overrides bulma core styles
|
||||
margin-inline-end: 0; // overrides bulma core styles
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@
|
|||
<div class="content-auth">
|
||||
<BaseButton
|
||||
v-show="menuActive"
|
||||
:aria-label="$t('navigation.closeSidebar')"
|
||||
class="menu-hide-button d-print-none"
|
||||
@click="baseStore.setMenuActive(false)"
|
||||
>
|
||||
|
|
@ -23,7 +22,6 @@
|
|||
/>
|
||||
<Navigation class="d-print-none" />
|
||||
<main
|
||||
id="main-content"
|
||||
class="app-content"
|
||||
:class="[
|
||||
{ 'is-menu-enabled': menuActive },
|
||||
|
|
@ -33,7 +31,6 @@
|
|||
>
|
||||
<BaseButton
|
||||
v-show="menuActive"
|
||||
:aria-label="$t('navigation.closeSidebar')"
|
||||
class="mobile-overlay d-print-none"
|
||||
@click="baseStore.setMenuActive(false)"
|
||||
/>
|
||||
|
|
@ -53,7 +50,6 @@
|
|||
:enabled="typeof currentModal !== 'undefined'"
|
||||
variant="scrolling"
|
||||
class="task-detail-view-modal"
|
||||
:aria-label="$t('task.detail.title')"
|
||||
@close="closeModal()"
|
||||
>
|
||||
<component
|
||||
|
|
|
|||
|
|
@ -44,10 +44,10 @@
|
|||
>
|
||||
{{ $t('misc.loading') }}
|
||||
</h1>
|
||||
<Card class="has-text-start view">
|
||||
<div class="box has-text-start view">
|
||||
<RouterView />
|
||||
<PoweredByLink utm-medium="link_share" />
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -64,7 +64,6 @@ import {useAuthStore} from '@/stores/auth'
|
|||
import Logo from '@/components/home/Logo.vue'
|
||||
import PoweredByLink from './PoweredByLink.vue'
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
import Card from '@/components/misc/Card.vue'
|
||||
import Message from '@/components/misc/Message.vue'
|
||||
import {PROJECT_VIEW_KINDS} from '@/modelTypes/IProjectView'
|
||||
|
||||
|
|
|
|||
|
|
@ -18,7 +18,6 @@ const enabled = computed(() => configStore.demoModeEnabled && !hide.value)
|
|||
<strong class="is-uppercase">{{ $t('demo.everythingWillBeDeleted') }}</strong>
|
||||
</p>
|
||||
<BaseButton
|
||||
:aria-label="$t('misc.closeBanner')"
|
||||
class="hide-button"
|
||||
@click="() => hide = true"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@
|
|||
import { computed } from 'vue'
|
||||
import { useNow } from '@vueuse/core'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useConfigStore } from '@/stores/config'
|
||||
import { useColorScheme } from '@/composables/useColorScheme'
|
||||
|
||||
import LogoFull from '@/assets/logo-full.svg?component'
|
||||
|
|
@ -14,10 +13,9 @@ const now = useNow({
|
|||
})
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const configStore = useConfigStore()
|
||||
const { isDark } = useColorScheme()
|
||||
|
||||
const Logo = computed(() => configStore.allowIconChanges
|
||||
const Logo = computed(() => window.ALLOW_ICON_CHANGES
|
||||
&& authStore.settings.frontendSettings.allowIconChanges
|
||||
&& now.value.getMonth() === 5
|
||||
? LogoFullPride
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@
|
|||
<RouterLink
|
||||
:to="{name: 'home'}"
|
||||
class="logo"
|
||||
:aria-label="$t('navigation.home')"
|
||||
:aria-label="$t('navigation.overview')"
|
||||
>
|
||||
<Logo
|
||||
width="164"
|
||||
|
|
@ -71,14 +71,6 @@
|
|||
{{ $t('team.title') }}
|
||||
</RouterLink>
|
||||
</li>
|
||||
<li v-if="timeTrackingEnabled">
|
||||
<RouterLink :to="{ name: 'time-tracking'}">
|
||||
<span class="menu-item-icon icon">
|
||||
<Icon :icon="['far', 'clock']" />
|
||||
</span>
|
||||
{{ $t('timeTracking.title') }}
|
||||
</RouterLink>
|
||||
</li>
|
||||
</menu>
|
||||
</nav>
|
||||
|
||||
|
|
@ -141,17 +133,12 @@ import Loading from '@/components/misc/Loading.vue'
|
|||
|
||||
import {useBaseStore} from '@/stores/base'
|
||||
import {useProjectStore} from '@/stores/projects'
|
||||
import {useConfigStore} from '@/stores/config'
|
||||
import {PRO_FEATURE} from '@/constants/proFeatures'
|
||||
import ProjectsNavigation from '@/components/home/ProjectsNavigation.vue'
|
||||
import type {IProject} from '@/modelTypes/IProject'
|
||||
import {useSidebarResize} from '@/composables/useSidebarResize'
|
||||
|
||||
const baseStore = useBaseStore()
|
||||
const projectStore = useProjectStore()
|
||||
const configStore = useConfigStore()
|
||||
|
||||
const timeTrackingEnabled = computed(() => configStore.isProFeatureEnabled(PRO_FEATURE.TIME_TRACKING))
|
||||
|
||||
const {sidebarWidth, isResizing, startResize, isMobile} = useSidebarResize()
|
||||
|
||||
|
|
|
|||
|
|
@ -15,7 +15,6 @@
|
|||
type="color"
|
||||
:list="colorListID"
|
||||
:class="{'is-empty': isEmpty}"
|
||||
:aria-label="$t('input.projectColor')"
|
||||
>
|
||||
<svg
|
||||
v-show="isEmpty"
|
||||
|
|
|
|||
|
|
@ -5,10 +5,7 @@
|
|||
:disabled="disabled || undefined"
|
||||
@click.stop="toggleDatePopup"
|
||||
>
|
||||
<i v-if="date === null && emptyLabel !== ''">{{ emptyLabel }}</i>
|
||||
<template v-else>
|
||||
{{ date === null ? chooseDateLabel : formatDisplayDate(date) }}
|
||||
</template>
|
||||
{{ date === null ? chooseDateLabel : formatDisplayDate(date) }}
|
||||
</SimpleButton>
|
||||
|
||||
<CustomTransition name="fade">
|
||||
|
|
@ -19,7 +16,6 @@
|
|||
>
|
||||
<DatepickerInline
|
||||
v-model="date"
|
||||
:show-shortcuts="showShortcuts"
|
||||
@update:modelValue="updateData"
|
||||
/>
|
||||
|
||||
|
|
@ -52,17 +48,12 @@ const props = withDefaults(defineProps<{
|
|||
modelValue: Date | null | string,
|
||||
chooseDateLabel?: string,
|
||||
disabled?: boolean,
|
||||
showShortcuts?: boolean,
|
||||
// When the value is null, show this (italic) instead of chooseDateLabel.
|
||||
emptyLabel?: string,
|
||||
}>(), {
|
||||
chooseDateLabel: () => {
|
||||
const {t} = useI18n({useScope: 'global'})
|
||||
return t('input.datepicker.chooseDate')
|
||||
},
|
||||
disabled: false,
|
||||
showShortcuts: true,
|
||||
emptyLabel: '',
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
|
|
|
|||
|
|
@ -1,68 +1,66 @@
|
|||
<template>
|
||||
<template v-if="showShortcuts">
|
||||
<BaseButton
|
||||
v-if="(new Date()).getHours() < 21"
|
||||
class="datepicker__quick-select-date"
|
||||
@click.stop="setDate('today')"
|
||||
>
|
||||
<span class="icon"><Icon :icon="['far', 'calendar-alt']" /></span>
|
||||
<span class="text">
|
||||
<span>{{ $t('input.datepicker.today') }}</span>
|
||||
<span class="weekday">{{ getWeekdayFromStringInterval('today') }}</span>
|
||||
</span>
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
class="datepicker__quick-select-date"
|
||||
@click.stop="setDate('tomorrow')"
|
||||
>
|
||||
<span class="icon"><Icon :icon="['far', 'sun']" /></span>
|
||||
<span class="text">
|
||||
<span>{{ $t('input.datepicker.tomorrow') }}</span>
|
||||
<span class="weekday">{{ getWeekdayFromStringInterval('tomorrow') }}</span>
|
||||
</span>
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
class="datepicker__quick-select-date"
|
||||
@click.stop="setDate('nextMonday')"
|
||||
>
|
||||
<span class="icon"><Icon icon="coffee" /></span>
|
||||
<span class="text">
|
||||
<span>{{ $t('input.datepicker.nextMonday') }}</span>
|
||||
<span class="weekday">{{ getWeekdayFromStringInterval('nextMonday') }}</span>
|
||||
</span>
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
v-if="!((new Date()).getDay() === 0 && (new Date()).getHours() >= 21)"
|
||||
class="datepicker__quick-select-date"
|
||||
@click.stop="setDate('thisWeekend')"
|
||||
>
|
||||
<span class="icon"><Icon icon="cocktail" /></span>
|
||||
<span class="text">
|
||||
<span>{{ $t('input.datepicker.thisWeekend') }}</span>
|
||||
<span class="weekday">{{ getWeekdayFromStringInterval('thisWeekend') }}</span>
|
||||
</span>
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
class="datepicker__quick-select-date"
|
||||
@click.stop="setDate('laterThisWeek')"
|
||||
>
|
||||
<span class="icon"><Icon icon="chess-knight" /></span>
|
||||
<span class="text">
|
||||
<span>{{ $t('input.datepicker.laterThisWeek') }}</span>
|
||||
<span class="weekday">{{ getWeekdayFromStringInterval('laterThisWeek') }}</span>
|
||||
</span>
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
class="datepicker__quick-select-date"
|
||||
@click.stop="setDate('nextWeek')"
|
||||
>
|
||||
<span class="icon"><Icon icon="forward" /></span>
|
||||
<span class="text">
|
||||
<span>{{ $t('input.datepicker.nextWeek') }}</span>
|
||||
<span class="weekday">{{ getWeekdayFromStringInterval('nextWeek') }}</span>
|
||||
</span>
|
||||
</BaseButton>
|
||||
</template>
|
||||
<BaseButton
|
||||
v-if="(new Date()).getHours() < 21"
|
||||
class="datepicker__quick-select-date"
|
||||
@click.stop="setDate('today')"
|
||||
>
|
||||
<span class="icon"><Icon :icon="['far', 'calendar-alt']" /></span>
|
||||
<span class="text">
|
||||
<span>{{ $t('input.datepicker.today') }}</span>
|
||||
<span class="weekday">{{ getWeekdayFromStringInterval('today') }}</span>
|
||||
</span>
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
class="datepicker__quick-select-date"
|
||||
@click.stop="setDate('tomorrow')"
|
||||
>
|
||||
<span class="icon"><Icon :icon="['far', 'sun']" /></span>
|
||||
<span class="text">
|
||||
<span>{{ $t('input.datepicker.tomorrow') }}</span>
|
||||
<span class="weekday">{{ getWeekdayFromStringInterval('tomorrow') }}</span>
|
||||
</span>
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
class="datepicker__quick-select-date"
|
||||
@click.stop="setDate('nextMonday')"
|
||||
>
|
||||
<span class="icon"><Icon icon="coffee" /></span>
|
||||
<span class="text">
|
||||
<span>{{ $t('input.datepicker.nextMonday') }}</span>
|
||||
<span class="weekday">{{ getWeekdayFromStringInterval('nextMonday') }}</span>
|
||||
</span>
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
v-if="!((new Date()).getDay() === 0 && (new Date()).getHours() >= 21)"
|
||||
class="datepicker__quick-select-date"
|
||||
@click.stop="setDate('thisWeekend')"
|
||||
>
|
||||
<span class="icon"><Icon icon="cocktail" /></span>
|
||||
<span class="text">
|
||||
<span>{{ $t('input.datepicker.thisWeekend') }}</span>
|
||||
<span class="weekday">{{ getWeekdayFromStringInterval('thisWeekend') }}</span>
|
||||
</span>
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
class="datepicker__quick-select-date"
|
||||
@click.stop="setDate('laterThisWeek')"
|
||||
>
|
||||
<span class="icon"><Icon icon="chess-knight" /></span>
|
||||
<span class="text">
|
||||
<span>{{ $t('input.datepicker.laterThisWeek') }}</span>
|
||||
<span class="weekday">{{ getWeekdayFromStringInterval('laterThisWeek') }}</span>
|
||||
</span>
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
class="datepicker__quick-select-date"
|
||||
@click.stop="setDate('nextWeek')"
|
||||
>
|
||||
<span class="icon"><Icon icon="forward" /></span>
|
||||
<span class="text">
|
||||
<span>{{ $t('input.datepicker.nextWeek') }}</span>
|
||||
<span class="weekday">{{ getWeekdayFromStringInterval('nextWeek') }}</span>
|
||||
</span>
|
||||
</BaseButton>
|
||||
|
||||
<div class="flatpickr-container">
|
||||
<flat-pickr
|
||||
|
|
@ -86,22 +84,16 @@ import {calculateNearestHours} from '@/helpers/time/calculateNearestHours'
|
|||
import {createDateFromString} from '@/helpers/time/createDateFromString'
|
||||
import {useI18n} from 'vue-i18n'
|
||||
import {useFlatpickrLanguage} from '@/helpers/useFlatpickrLanguage'
|
||||
import {useTimeFormat} from '@/composables/useTimeFormat'
|
||||
import {TIME_FORMAT} from '@/constants/timeFormat'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
const props = defineProps<{
|
||||
modelValue: Date | null | string
|
||||
showShortcuts?: boolean
|
||||
}>(), {
|
||||
showShortcuts: true,
|
||||
})
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [Date | null],
|
||||
}>()
|
||||
|
||||
const {t} = useI18n({useScope: 'global'})
|
||||
const {store: timeFormat} = useTimeFormat()
|
||||
|
||||
const date = ref<Date | null>(null)
|
||||
const changed = ref(false)
|
||||
|
|
@ -119,7 +111,7 @@ const flatPickerConfig = computed(() => ({
|
|||
altInput: true,
|
||||
dateFormat: 'Y-m-d H:i',
|
||||
enableTime: true,
|
||||
time_24hr: timeFormat.value === TIME_FORMAT.HOURS_24,
|
||||
time_24hr: true,
|
||||
inline: true,
|
||||
locale: useFlatpickrLanguage().value,
|
||||
}))
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@
|
|||
}"
|
||||
:disabled="disabled"
|
||||
:model-value="modelValue"
|
||||
:aria-label="ariaLabel"
|
||||
@update:modelValue="value => emit('update:modelValue', value)"
|
||||
>
|
||||
<CheckboxIcon class="fancy-checkbox__icon" />
|
||||
|
|
@ -27,12 +26,10 @@ import BaseCheckbox from '@/components/base/BaseCheckbox.vue'
|
|||
withDefaults(defineProps<{
|
||||
modelValue: boolean,
|
||||
disabled?: boolean,
|
||||
isBlock?: boolean,
|
||||
ariaLabel?: string,
|
||||
isBlock?: boolean
|
||||
}>(), {
|
||||
disabled: false,
|
||||
isBlock: false,
|
||||
ariaLabel: undefined,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
|
|
|
|||
|
|
@ -1,42 +0,0 @@
|
|||
import {describe, it, expect} from 'vitest'
|
||||
import {mount} from '@vue/test-utils'
|
||||
import FormCheckbox from './FormCheckbox.vue'
|
||||
|
||||
describe('FormCheckbox', () => {
|
||||
it('renders a Bulma-classed checkbox label', () => {
|
||||
const wrapper = mount(FormCheckbox, {props: {label: 'Enable thing'}})
|
||||
const label = wrapper.find('label.checkbox')
|
||||
expect(label.exists()).toBe(true)
|
||||
expect(label.text()).toContain('Enable thing')
|
||||
expect(label.find('input[type="checkbox"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('supports v-model (boolean)', async () => {
|
||||
const wrapper = mount(FormCheckbox, {
|
||||
props: {
|
||||
label: 'Toggle',
|
||||
modelValue: false,
|
||||
'onUpdate:modelValue': (val: boolean) => wrapper.setProps({modelValue: val}),
|
||||
},
|
||||
})
|
||||
const input = wrapper.find('input[type="checkbox"]')
|
||||
expect((input.element as HTMLInputElement).checked).toBe(false)
|
||||
|
||||
await input.setValue(true)
|
||||
expect(wrapper.props('modelValue')).toBe(true)
|
||||
})
|
||||
|
||||
it('applies disabled', () => {
|
||||
const wrapper = mount(FormCheckbox, {
|
||||
props: {label: 'X', disabled: true},
|
||||
})
|
||||
expect(wrapper.find('input').attributes('disabled')).toBe('')
|
||||
})
|
||||
|
||||
it('renders slot content instead of label prop when slot is provided', () => {
|
||||
const wrapper = mount(FormCheckbox, {
|
||||
slots: {default: '<span>Custom <b>content</b></span>'},
|
||||
})
|
||||
expect(wrapper.find('label.checkbox').html()).toContain('<b>content</b>')
|
||||
})
|
||||
})
|
||||
|
|
@ -1,62 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
interface Props {
|
||||
modelValue?: boolean
|
||||
label?: string
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean]
|
||||
}>()
|
||||
|
||||
function handleChange(event: Event) {
|
||||
emit('update:modelValue', (event.target as HTMLInputElement).checked)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<label class="checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="modelValue"
|
||||
:disabled="disabled || undefined"
|
||||
@change="handleChange"
|
||||
>
|
||||
<slot>{{ label }}</slot>
|
||||
</label>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
// Ported from bulma-css-variables/sass/form/checkbox-radio.sass
|
||||
// (the %checkbox-radio placeholder, scoped to .checkbox since this
|
||||
// component is the sole consumer of that class).
|
||||
label.checkbox {
|
||||
cursor: pointer;
|
||||
line-height: 1.25;
|
||||
position: relative;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: .5rem;
|
||||
inline-size: fit-content;
|
||||
|
||||
&:hover {
|
||||
color: var(--input-hover-color);
|
||||
}
|
||||
|
||||
&[disabled],
|
||||
input[disabled] {
|
||||
color: var(--input-disabled-color);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
input {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
&:not(:last-child) {
|
||||
margin-block-end: .75rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -14,7 +14,7 @@ describe('FormField', () => {
|
|||
const wrapper = mount(FormField, {
|
||||
props: {
|
||||
modelValue: 'initial',
|
||||
'onUpdate:modelValue': (val: string | number) => wrapper.setProps({modelValue: val}),
|
||||
'onUpdate:modelValue': (val: string) => wrapper.setProps({modelValue: val}),
|
||||
},
|
||||
})
|
||||
const input = wrapper.find('input')
|
||||
|
|
@ -199,62 +199,4 @@ describe('FormField', () => {
|
|||
await input.setValue('test value')
|
||||
expect(wrapper.vm.value).toBe('test value')
|
||||
})
|
||||
|
||||
it('renders two-col layout with wrapping label', () => {
|
||||
const wrapper = mount(FormField, {
|
||||
props: {label: 'Name', layout: 'two-col'},
|
||||
slots: {
|
||||
default: '<input class="input" />',
|
||||
},
|
||||
})
|
||||
const label = wrapper.find('label.two-col')
|
||||
expect(label.exists()).toBe(true)
|
||||
expect(label.find('span').text()).toBe('Name')
|
||||
expect(label.find('input.input').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('two-col layout exposes id via slot scope', () => {
|
||||
const wrapper = mount({
|
||||
components: {FormField},
|
||||
template: `
|
||||
<FormField label="X" layout="two-col" id="custom-id" v-slot="{id}">
|
||||
<input :id="id" />
|
||||
</FormField>
|
||||
`,
|
||||
})
|
||||
expect(wrapper.find('input').attributes('id')).toBe('custom-id')
|
||||
})
|
||||
|
||||
it('two-col layout omits the for attribute so implicit nesting labels any slotted control', () => {
|
||||
const wrapper = mount(FormField, {
|
||||
props: {label: 'Name', layout: 'two-col'},
|
||||
slots: {
|
||||
default: '<input id="some-generated-id" />',
|
||||
},
|
||||
})
|
||||
const label = wrapper.find('label.two-col')
|
||||
// for="" would mismatch the slotted control's id; rely on the label wrapping instead.
|
||||
expect(label.attributes('for')).toBeUndefined()
|
||||
expect(label.find('input').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('renders the error message in two-col layout', () => {
|
||||
const wrapper = mount(FormField, {
|
||||
props: {label: 'Name', layout: 'two-col', error: 'Required'},
|
||||
})
|
||||
const help = wrapper.find('p.help.is-danger')
|
||||
expect(help.exists()).toBe(true)
|
||||
expect(help.text()).toBe('Required')
|
||||
})
|
||||
|
||||
it('renders the addon slot in two-col layout', () => {
|
||||
const wrapper = mount(FormField, {
|
||||
props: {label: 'Name', layout: 'two-col'},
|
||||
slots: {
|
||||
addon: '<button>Copy</button>',
|
||||
},
|
||||
})
|
||||
expect(wrapper.find('.field.has-addons').exists()).toBe(true)
|
||||
expect(wrapper.find('button').text()).toBe('Copy')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -8,18 +8,16 @@ interface Props {
|
|||
id?: string
|
||||
disabled?: boolean
|
||||
loading?: boolean
|
||||
layout?: 'stacked' | 'two-col'
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
layout: 'stacked',
|
||||
})
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string | number]
|
||||
}>()
|
||||
|
||||
function handleInput(event: Event) {
|
||||
const value = (event.target as HTMLInputElement).value
|
||||
// Preserve numeric type if modelValue was a number
|
||||
if (typeof props.modelValue === 'number') {
|
||||
emit('update:modelValue', value === '' ? '' : Number(value))
|
||||
} else {
|
||||
|
|
@ -35,7 +33,6 @@ const slots = useSlots()
|
|||
const generatedId = useId()
|
||||
|
||||
const inputId = computed(() => props.id ?? generatedId)
|
||||
const errorId = computed(() => props.error ? `${inputId.value}-error` : undefined)
|
||||
const hasAddon = computed(() => !!slots.addon)
|
||||
|
||||
const fieldClasses = computed(() => [
|
||||
|
|
@ -56,6 +53,8 @@ const inputClasses = computed(() => [
|
|||
},
|
||||
])
|
||||
|
||||
// Only bind value when modelValue is explicitly provided (not undefined)
|
||||
// This allows the component to be used without v-model for native input behavior
|
||||
const inputBindings = computed(() => {
|
||||
const bindings: Record<string, unknown> = {}
|
||||
if (props.modelValue !== undefined) {
|
||||
|
|
@ -64,6 +63,7 @@ const inputBindings = computed(() => {
|
|||
return bindings
|
||||
})
|
||||
|
||||
// Expose input element for direct access (needed for browser autofill workarounds)
|
||||
const inputRef = ref<HTMLInputElement | null>(null)
|
||||
defineExpose({
|
||||
get value() {
|
||||
|
|
@ -77,92 +77,39 @@ defineExpose({
|
|||
|
||||
<template>
|
||||
<div :class="fieldClasses">
|
||||
<template v-if="layout === 'two-col'">
|
||||
<label
|
||||
v-if="label"
|
||||
class="two-col"
|
||||
>
|
||||
<span>{{ label }}</span>
|
||||
<slot
|
||||
<label
|
||||
v-if="label"
|
||||
:for="inputId"
|
||||
class="label"
|
||||
>
|
||||
{{ label }}
|
||||
</label>
|
||||
|
||||
<div :class="controlClasses">
|
||||
<slot :id="inputId">
|
||||
<input
|
||||
:id="inputId"
|
||||
:error-id="errorId"
|
||||
ref="inputRef"
|
||||
v-bind="{ ...$attrs, ...inputBindings }"
|
||||
:class="inputClasses"
|
||||
:disabled="disabled || undefined"
|
||||
@input="handleInput"
|
||||
>
|
||||
<input
|
||||
:id="inputId"
|
||||
ref="inputRef"
|
||||
v-bind="{ ...$attrs, ...inputBindings }"
|
||||
:class="inputClasses"
|
||||
:disabled="disabled || undefined"
|
||||
:aria-invalid="error ? true : undefined"
|
||||
:aria-describedby="errorId"
|
||||
@input="handleInput"
|
||||
>
|
||||
</slot>
|
||||
</label>
|
||||
<div
|
||||
v-if="$slots.addon"
|
||||
class="control"
|
||||
>
|
||||
<slot name="addon" />
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<label
|
||||
v-if="label"
|
||||
:for="inputId"
|
||||
class="label"
|
||||
>
|
||||
{{ label }}
|
||||
</label>
|
||||
<div :class="controlClasses">
|
||||
<slot
|
||||
:id="inputId"
|
||||
:error-id="errorId"
|
||||
>
|
||||
<input
|
||||
:id="inputId"
|
||||
ref="inputRef"
|
||||
v-bind="{ ...$attrs, ...inputBindings }"
|
||||
:class="inputClasses"
|
||||
:disabled="disabled || undefined"
|
||||
:aria-invalid="error ? true : undefined"
|
||||
:aria-describedby="errorId"
|
||||
@input="handleInput"
|
||||
>
|
||||
</slot>
|
||||
</div>
|
||||
<div
|
||||
v-if="$slots.addon"
|
||||
class="control"
|
||||
>
|
||||
<slot name="addon" />
|
||||
</div>
|
||||
</template>
|
||||
</slot>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="$slots.addon"
|
||||
class="control"
|
||||
>
|
||||
<slot name="addon" />
|
||||
</div>
|
||||
|
||||
<p
|
||||
v-if="error"
|
||||
:id="errorId"
|
||||
class="help is-danger"
|
||||
role="alert"
|
||||
>
|
||||
{{ error }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
label.two-col {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: .5rem;
|
||||
}
|
||||
|
||||
label.two-col > span,
|
||||
label.two-col :deep(input),
|
||||
label.two-col :deep(.input),
|
||||
label.two-col :deep(.select),
|
||||
label.two-col :deep(.timezone-select),
|
||||
label.two-col :deep(.multiselect) {
|
||||
flex: 0 0 50%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,123 +0,0 @@
|
|||
import {describe, it, expect, vi} from 'vitest'
|
||||
import {mount} from '@vue/test-utils'
|
||||
import FormInput from './FormInput.vue'
|
||||
|
||||
describe('FormInput', () => {
|
||||
it('renders a Bulma-classed input', () => {
|
||||
const wrapper = mount(FormInput)
|
||||
const input = wrapper.find('input')
|
||||
expect(input.exists()).toBe(true)
|
||||
expect(input.classes()).toContain('input')
|
||||
})
|
||||
|
||||
it('supports v-model', async () => {
|
||||
const wrapper = mount(FormInput, {
|
||||
props: {
|
||||
modelValue: 'hello',
|
||||
'onUpdate:modelValue': (val: string | number) => wrapper.setProps({modelValue: val}),
|
||||
},
|
||||
})
|
||||
const input = wrapper.find('input')
|
||||
expect(input.element.value).toBe('hello')
|
||||
|
||||
await input.setValue('world')
|
||||
expect(wrapper.props('modelValue')).toBe('world')
|
||||
})
|
||||
|
||||
it('preserves numeric type in v-model when modelValue is a number', async () => {
|
||||
const wrapper = mount(FormInput, {
|
||||
props: {
|
||||
modelValue: 42,
|
||||
'onUpdate:modelValue': (val: number | string) => wrapper.setProps({modelValue: val as number}),
|
||||
},
|
||||
})
|
||||
await wrapper.find('input').setValue('7')
|
||||
expect(wrapper.props('modelValue')).toBe(7)
|
||||
})
|
||||
|
||||
it('coerces to number when the .number modifier is set even if modelValue starts null', async () => {
|
||||
const wrapper = mount(FormInput, {
|
||||
props: {
|
||||
modelValue: null,
|
||||
modelModifiers: {number: true},
|
||||
'onUpdate:modelValue': (val: number | string) => wrapper.setProps({modelValue: val as number}),
|
||||
},
|
||||
})
|
||||
await wrapper.find('input').setValue('42')
|
||||
expect(wrapper.props('modelValue')).toBe(42)
|
||||
expect(typeof wrapper.props('modelValue')).toBe('number')
|
||||
})
|
||||
|
||||
it('applies is-loading class when loading', () => {
|
||||
const wrapper = mount(FormInput, {props: {loading: true}})
|
||||
expect(wrapper.find('input').classes()).toContain('is-loading')
|
||||
})
|
||||
|
||||
it('applies disabled class and attribute when disabled', () => {
|
||||
const wrapper = mount(FormInput, {props: {disabled: true}})
|
||||
const input = wrapper.find('input')
|
||||
expect(input.classes()).toContain('disabled')
|
||||
expect(input.attributes('disabled')).toBe('')
|
||||
})
|
||||
|
||||
it('uses an explicit id prop when given', () => {
|
||||
const wrapper = mount(FormInput, {props: {id: 'my-id'}})
|
||||
expect(wrapper.find('input').attributes('id')).toBe('my-id')
|
||||
})
|
||||
|
||||
it('generates a unique id when no id prop is given', () => {
|
||||
const wrapper = mount({
|
||||
components: {FormInput},
|
||||
template: '<div><FormInput /><FormInput /></div>',
|
||||
})
|
||||
const inputs = wrapper.findAll('input')
|
||||
const id1 = inputs[0].attributes('id')
|
||||
const id2 = inputs[1].attributes('id')
|
||||
expect(id1).toBeTruthy()
|
||||
expect(id2).toBeTruthy()
|
||||
expect(id1).not.toBe(id2)
|
||||
})
|
||||
|
||||
it('forwards $attrs (type, placeholder, autocomplete) to the input', () => {
|
||||
const wrapper = mount(FormInput, {
|
||||
attrs: {
|
||||
type: 'email',
|
||||
placeholder: 'Enter email',
|
||||
autocomplete: 'email',
|
||||
},
|
||||
})
|
||||
const input = wrapper.find('input')
|
||||
expect(input.attributes('type')).toBe('email')
|
||||
expect(input.attributes('placeholder')).toBe('Enter email')
|
||||
expect(input.attributes('autocomplete')).toBe('email')
|
||||
})
|
||||
|
||||
it('forwards event listeners', async () => {
|
||||
const onKeyup = vi.fn()
|
||||
const wrapper = mount(FormInput, {attrs: {onKeyup}})
|
||||
await wrapper.find('input').trigger('keyup', {key: 'Enter'})
|
||||
expect(onKeyup).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('renders error message when error prop is set', () => {
|
||||
const wrapper = mount(FormInput, {props: {error: 'Required'}})
|
||||
const help = wrapper.find('p.help.is-danger')
|
||||
expect(help.exists()).toBe(true)
|
||||
expect(help.text()).toBe('Required')
|
||||
})
|
||||
|
||||
it('does not render error message when error is null or empty', () => {
|
||||
const nullErr = mount(FormInput, {props: {error: null}})
|
||||
expect(nullErr.find('p.help.is-danger').exists()).toBe(false)
|
||||
|
||||
const emptyErr = mount(FormInput, {props: {error: ''}})
|
||||
expect(emptyErr.find('p.help.is-danger').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('exposes value and focus()', async () => {
|
||||
const wrapper = mount(FormInput)
|
||||
await wrapper.find('input').setValue('test value')
|
||||
expect(wrapper.vm.value).toBe('test value')
|
||||
expect(() => wrapper.vm.focus()).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
|
@ -1,83 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import {computed, ref, useId} from 'vue'
|
||||
|
||||
interface Props {
|
||||
modelValue?: string | number | Date | null
|
||||
modelModifiers?: {number?: boolean}
|
||||
id?: string
|
||||
disabled?: boolean
|
||||
loading?: boolean
|
||||
error?: string | null
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
modelModifiers: () => ({}),
|
||||
})
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string | number]
|
||||
}>()
|
||||
|
||||
|
||||
defineOptions({inheritAttrs: false})
|
||||
|
||||
const fallbackId = useId()
|
||||
const inputId = computed(() => props.id ?? fallbackId)
|
||||
const errorId = computed(() => props.error ? `${inputId.value}-error` : undefined)
|
||||
|
||||
const inputClasses = computed(() => [
|
||||
'input',
|
||||
{
|
||||
disabled: props.disabled,
|
||||
'is-loading': props.loading,
|
||||
},
|
||||
])
|
||||
|
||||
const inputBindings = computed(() => {
|
||||
const bindings: Record<string, unknown> = {}
|
||||
if (props.modelValue !== undefined) {
|
||||
bindings.value = props.modelValue
|
||||
}
|
||||
return bindings
|
||||
})
|
||||
|
||||
function handleInput(event: Event) {
|
||||
const value = (event.target as HTMLInputElement).value
|
||||
const shouldCoerceNumber = props.modelModifiers.number || typeof props.modelValue === 'number'
|
||||
if (shouldCoerceNumber) {
|
||||
emit('update:modelValue', value === '' ? '' : Number(value))
|
||||
} else {
|
||||
emit('update:modelValue', value)
|
||||
}
|
||||
}
|
||||
|
||||
const inputRef = ref<HTMLInputElement | null>(null)
|
||||
defineExpose({
|
||||
get value() {
|
||||
return inputRef.value?.value ?? ''
|
||||
},
|
||||
focus() {
|
||||
inputRef.value?.focus()
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<input
|
||||
:id="inputId"
|
||||
ref="inputRef"
|
||||
v-bind="{ ...$attrs, ...inputBindings }"
|
||||
:class="inputClasses"
|
||||
:disabled="disabled || undefined"
|
||||
:aria-invalid="error ? true : undefined"
|
||||
:aria-describedby="errorId"
|
||||
@input="handleInput"
|
||||
>
|
||||
<p
|
||||
v-if="error"
|
||||
:id="errorId"
|
||||
class="help is-danger"
|
||||
role="alert"
|
||||
>
|
||||
{{ error }}
|
||||
</p>
|
||||
</template>
|
||||
|
|
@ -1,173 +0,0 @@
|
|||
import {describe, it, expect} from 'vitest'
|
||||
import {mount} from '@vue/test-utils'
|
||||
import FormSelect from './FormSelect.vue'
|
||||
|
||||
describe('FormSelect', () => {
|
||||
it('renders the Bulma select wrapper and a native select', () => {
|
||||
const wrapper = mount(FormSelect)
|
||||
expect(wrapper.find('div.select').exists()).toBe(true)
|
||||
expect(wrapper.find('div.select > select').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('renders options from the default slot', () => {
|
||||
const wrapper = mount(FormSelect, {
|
||||
slots: {
|
||||
default: '<option value="a">A</option><option value="b">B</option>',
|
||||
},
|
||||
})
|
||||
expect(wrapper.findAll('option').length).toBe(2)
|
||||
})
|
||||
|
||||
it('supports v-model with string values', async () => {
|
||||
const wrapper = mount(FormSelect, {
|
||||
props: {
|
||||
modelValue: 'a',
|
||||
'onUpdate:modelValue': (val: string | number) => wrapper.setProps({modelValue: val}),
|
||||
},
|
||||
slots: {
|
||||
default: '<option value="a">A</option><option value="b">B</option>',
|
||||
},
|
||||
})
|
||||
const select = wrapper.find('select')
|
||||
expect((select.element as HTMLSelectElement).value).toBe('a')
|
||||
|
||||
await select.setValue('b')
|
||||
expect(wrapper.props('modelValue')).toBe('b')
|
||||
})
|
||||
|
||||
it('preserves numeric type in v-model when modelValue is a number', async () => {
|
||||
const wrapper = mount(FormSelect, {
|
||||
props: {
|
||||
modelValue: 1,
|
||||
'onUpdate:modelValue': (val: string | number) => wrapper.setProps({modelValue: val}),
|
||||
},
|
||||
slots: {
|
||||
default: '<option value="1">One</option><option value="2">Two</option>',
|
||||
},
|
||||
})
|
||||
await wrapper.find('select').setValue('2')
|
||||
expect(wrapper.props('modelValue')).toBe(2)
|
||||
})
|
||||
|
||||
it('coerces to number when the .number modifier is set even if modelValue starts null', async () => {
|
||||
const wrapper = mount(FormSelect, {
|
||||
props: {
|
||||
modelValue: null,
|
||||
modelModifiers: {number: true},
|
||||
'onUpdate:modelValue': (val: string | number) => wrapper.setProps({modelValue: val}),
|
||||
},
|
||||
slots: {
|
||||
default: '<option value="1">One</option><option value="2">Two</option>',
|
||||
},
|
||||
})
|
||||
await wrapper.find('select').setValue('2')
|
||||
expect(wrapper.props('modelValue')).toBe(2)
|
||||
expect(typeof wrapper.props('modelValue')).toBe('number')
|
||||
})
|
||||
|
||||
it('applies is-loading on the wrapper when loading', () => {
|
||||
const wrapper = mount(FormSelect, {props: {loading: true}})
|
||||
expect(wrapper.find('div.select').classes()).toContain('is-loading')
|
||||
})
|
||||
|
||||
it('applies disabled to the native select', () => {
|
||||
const wrapper = mount(FormSelect, {props: {disabled: true}})
|
||||
expect(wrapper.find('select').attributes('disabled')).toBe('')
|
||||
})
|
||||
|
||||
it('uses an explicit id prop when given, otherwise generates one', () => {
|
||||
const withProp = mount(FormSelect, {props: {id: 'explicit'}})
|
||||
expect(withProp.find('select').attributes('id')).toBe('explicit')
|
||||
|
||||
const standalone = mount(FormSelect)
|
||||
expect(standalone.find('select').attributes('id')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('renders error message when error prop is set', () => {
|
||||
const wrapper = mount(FormSelect, {props: {error: 'Pick one'}})
|
||||
expect(wrapper.find('p.help.is-danger').text()).toBe('Pick one')
|
||||
})
|
||||
|
||||
it('does not render error message when error is null or empty', () => {
|
||||
const nullErr = mount(FormSelect, {props: {error: null}})
|
||||
expect(nullErr.find('p.help.is-danger').exists()).toBe(false)
|
||||
|
||||
const emptyErr = mount(FormSelect, {props: {error: ''}})
|
||||
expect(emptyErr.find('p.help.is-danger').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('renders options from the options prop with object entries', () => {
|
||||
const wrapper = mount(FormSelect, {
|
||||
props: {
|
||||
options: [
|
||||
{value: 'a', label: 'Alpha'},
|
||||
{value: 'b', label: 'Bravo'},
|
||||
],
|
||||
},
|
||||
})
|
||||
const options = wrapper.findAll('option')
|
||||
expect(options).toHaveLength(2)
|
||||
expect(options[0].attributes('value')).toBe('a')
|
||||
expect(options[0].text()).toBe('Alpha')
|
||||
expect(options[1].attributes('value')).toBe('b')
|
||||
expect(options[1].text()).toBe('Bravo')
|
||||
})
|
||||
|
||||
it('coerces primitive options into value/label pairs', () => {
|
||||
const wrapper = mount(FormSelect, {
|
||||
props: {options: ['one', 'two']},
|
||||
})
|
||||
const options = wrapper.findAll('option')
|
||||
expect(options).toHaveLength(2)
|
||||
expect(options[0].attributes('value')).toBe('one')
|
||||
expect(options[0].text()).toBe('one')
|
||||
})
|
||||
|
||||
it('marks an option as disabled when disabled: true is given', () => {
|
||||
const wrapper = mount(FormSelect, {
|
||||
props: {
|
||||
options: [
|
||||
{value: 'a', label: 'Alpha'},
|
||||
{value: 'b', label: 'Bravo', disabled: true},
|
||||
],
|
||||
},
|
||||
})
|
||||
const options = wrapper.findAll('option')
|
||||
expect(options[0].attributes('disabled')).toBeUndefined()
|
||||
expect(options[1].attributes('disabled')).toBe('')
|
||||
})
|
||||
|
||||
it('falls back to the default slot when options prop is not given', () => {
|
||||
const wrapper = mount(FormSelect, {
|
||||
slots: {
|
||||
default: '<option value="x">From slot</option>',
|
||||
},
|
||||
})
|
||||
const options = wrapper.findAll('option')
|
||||
expect(options).toHaveLength(1)
|
||||
expect(options[0].text()).toBe('From slot')
|
||||
})
|
||||
|
||||
it('does not bind value when modelValue is undefined', () => {
|
||||
const wrapper = mount(FormSelect, {
|
||||
slots: {
|
||||
default: '<option value="">--</option><option value="a">A</option><option value="b">B</option>',
|
||||
},
|
||||
})
|
||||
const select = wrapper.find('select')
|
||||
// Forcing :value="undefined" would break the native default-to-first-option behavior.
|
||||
expect((select.element as HTMLSelectElement).value).toBe('')
|
||||
})
|
||||
|
||||
it('ignores the slot when options prop is given', () => {
|
||||
const wrapper = mount(FormSelect, {
|
||||
props: {options: [{value: 'a', label: 'From prop'}]},
|
||||
slots: {
|
||||
default: '<option value="x">From slot</option>',
|
||||
},
|
||||
})
|
||||
const options = wrapper.findAll('option')
|
||||
expect(options).toHaveLength(1)
|
||||
expect(options[0].text()).toBe('From prop')
|
||||
})
|
||||
})
|
||||
|
|
@ -1,105 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import {computed, useId} from 'vue'
|
||||
|
||||
export type SelectOption =
|
||||
| string
|
||||
| number
|
||||
| {value: string | number, label: string, disabled?: boolean}
|
||||
|
||||
interface Props {
|
||||
modelValue?: string | number | null
|
||||
modelModifiers?: {number?: boolean}
|
||||
id?: string
|
||||
disabled?: boolean
|
||||
loading?: boolean
|
||||
error?: string | null
|
||||
options?: SelectOption[]
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
modelModifiers: () => ({}),
|
||||
})
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string | number]
|
||||
}>()
|
||||
|
||||
defineOptions({inheritAttrs: false})
|
||||
|
||||
const fallbackId = useId()
|
||||
const selectId = computed(() => props.id ?? fallbackId)
|
||||
const errorId = computed(() => props.error ? `${selectId.value}-error` : undefined)
|
||||
|
||||
const wrapperClasses = computed(() => [
|
||||
'select',
|
||||
{'is-loading': props.loading},
|
||||
])
|
||||
|
||||
const selectBindings = computed(() => {
|
||||
const bindings: Record<string, unknown> = {}
|
||||
if (props.modelValue !== undefined) {
|
||||
bindings.value = props.modelValue
|
||||
}
|
||||
return bindings
|
||||
})
|
||||
|
||||
const normalizedOptions = computed(() => {
|
||||
if (!props.options) {
|
||||
return null
|
||||
}
|
||||
return props.options.map(opt => {
|
||||
if (typeof opt === 'object' && opt !== null) {
|
||||
return opt
|
||||
}
|
||||
return {value: opt, label: String(opt)}
|
||||
})
|
||||
})
|
||||
|
||||
function handleChange(event: Event) {
|
||||
const value = (event.target as HTMLSelectElement).value
|
||||
const shouldCoerceNumber = props.modelModifiers.number || typeof props.modelValue === 'number'
|
||||
if (shouldCoerceNumber) {
|
||||
emit('update:modelValue', value === '' ? '' : Number(value))
|
||||
} else {
|
||||
emit('update:modelValue', value)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="wrapperClasses">
|
||||
<select
|
||||
:id="selectId"
|
||||
v-bind="{ ...$attrs, ...selectBindings }"
|
||||
:disabled="disabled || undefined"
|
||||
:aria-invalid="error ? true : undefined"
|
||||
:aria-describedby="errorId"
|
||||
@change="handleChange"
|
||||
>
|
||||
<template v-if="normalizedOptions">
|
||||
<option
|
||||
v-for="opt in normalizedOptions"
|
||||
:key="opt.value"
|
||||
:value="opt.value"
|
||||
:disabled="opt.disabled || undefined"
|
||||
>
|
||||
{{ opt.label }}
|
||||
</option>
|
||||
</template>
|
||||
<slot v-else />
|
||||
</select>
|
||||
</div>
|
||||
<p
|
||||
v-if="error"
|
||||
:id="errorId"
|
||||
class="help is-danger"
|
||||
role="alert"
|
||||
>
|
||||
{{ error }}
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.select select {
|
||||
inline-size: 100%;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -448,7 +448,7 @@ function createOrSelectOnEnter() {
|
|||
}
|
||||
|
||||
function remove(item: T) {
|
||||
for (let ind = 0; ind < internalValue.value.length; ind++) {
|
||||
for (const ind in internalValue.value) {
|
||||
if (internalValue.value[ind] === item) {
|
||||
internalValue.value.splice(ind, 1)
|
||||
break
|
||||
|
|
|
|||
|
|
@ -7,10 +7,8 @@
|
|||
:placeholder="$t('user.auth.passwordPlaceholder')"
|
||||
required
|
||||
:type="passwordFieldType"
|
||||
:autocomplete="autocomplete"
|
||||
autocomplete="current-password"
|
||||
:tabindex="tabindex"
|
||||
:aria-invalid="isValid !== true ? true : undefined"
|
||||
:aria-describedby="errorId"
|
||||
@keyup.enter="e => $emit('submit', e)"
|
||||
@focusout="() => {validate(); validateAfterFirst = true}"
|
||||
@keyup="() => {validateAfterFirst ? validate() : null}"
|
||||
|
|
@ -27,16 +25,14 @@
|
|||
</div>
|
||||
<p
|
||||
v-if="isValid !== true"
|
||||
:id="errorId"
|
||||
class="help is-danger"
|
||||
role="alert"
|
||||
>
|
||||
{{ isValid }}
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import {computed, ref, watchEffect} from 'vue'
|
||||
import {ref, watchEffect} from 'vue'
|
||||
import {useDebounceFn} from '@vueuse/core'
|
||||
import {useI18n} from 'vue-i18n'
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
|
|
@ -48,11 +44,9 @@ const props = withDefaults(defineProps<{
|
|||
// This prop is a workaround to trigger validation from the outside when the user never had focus in the input.
|
||||
validateInitially?: boolean,
|
||||
validateMinLength?: boolean,
|
||||
autocomplete?: string,
|
||||
}>(), {
|
||||
tabindex: undefined,
|
||||
validateMinLength: true,
|
||||
autocomplete: 'current-password',
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
|
|
@ -65,7 +59,6 @@ const password = ref('')
|
|||
// eslint-disable-next-line vue/no-setup-props-reactivity-loss
|
||||
const isValid = ref<true | string>(props.validateInitially === true ? true : '')
|
||||
const validateAfterFirst = ref(false)
|
||||
const errorId = computed(() => isValid.value !== true ? 'password-error' : undefined)
|
||||
|
||||
const validate = useDebounceFn(() => {
|
||||
const valid = validatePassword(password.value, props.validateMinLength)
|
||||
|
|
|
|||
|
|
@ -1,161 +0,0 @@
|
|||
<template>
|
||||
<NodeViewWrapper
|
||||
as="blockquote"
|
||||
class="comment-quote"
|
||||
:class="{'comment-quote--has-parent': hasParent}"
|
||||
:data-comment-id="commentId === null ? null : String(commentId)"
|
||||
>
|
||||
<div
|
||||
v-if="commentId !== null && ctx"
|
||||
contenteditable="false"
|
||||
class="comment-quote__header"
|
||||
>
|
||||
<template v-if="parent">
|
||||
<img
|
||||
v-if="avatarUrl"
|
||||
:src="avatarUrl"
|
||||
alt=""
|
||||
class="comment-quote__avatar"
|
||||
width="20"
|
||||
height="20"
|
||||
>
|
||||
<span class="comment-quote__author">{{ authorName }}</span>
|
||||
<BaseButton
|
||||
v-tooltip="t('task.comment.jumpToOriginal')"
|
||||
class="comment-quote__jump"
|
||||
:aria-label="t('task.comment.jumpToOriginal')"
|
||||
@click="onJump"
|
||||
>
|
||||
<Icon icon="angle-right" />
|
||||
</BaseButton>
|
||||
</template>
|
||||
<span
|
||||
v-else
|
||||
class="comment-quote__author comment-quote__author--missing"
|
||||
>
|
||||
{{ t('task.comment.deletedComment') }}
|
||||
</span>
|
||||
</div>
|
||||
<NodeViewContent class="comment-quote__body" />
|
||||
</NodeViewWrapper>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import {computed, inject, ref, watch} from 'vue'
|
||||
import {useI18n} from 'vue-i18n'
|
||||
import {nodeViewProps, NodeViewWrapper, NodeViewContent} from '@tiptap/vue-3'
|
||||
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
import {fetchAvatarBlobUrl, getDisplayName} from '@/models/user'
|
||||
import {commentReplyContextKey} from '@/components/tasks/partials/commentReplyContext'
|
||||
|
||||
const props = defineProps(nodeViewProps)
|
||||
|
||||
const {t} = useI18n({useScope: 'global'})
|
||||
|
||||
const ctx = inject(commentReplyContextKey, null)
|
||||
|
||||
const commentId = computed<number | null>(() => {
|
||||
const raw = props.node.attrs.commentId
|
||||
if (raw === null || raw === undefined) {
|
||||
return null
|
||||
}
|
||||
const id = Number(raw)
|
||||
return Number.isInteger(id) && id > 0 ? id : null
|
||||
})
|
||||
|
||||
const parent = computed(() => {
|
||||
if (commentId.value === null || !ctx) {
|
||||
return undefined
|
||||
}
|
||||
return ctx.findComment(commentId.value)
|
||||
})
|
||||
|
||||
const hasParent = computed(() => parent.value !== undefined)
|
||||
|
||||
const authorName = computed(() => {
|
||||
const p = parent.value
|
||||
return p ? getDisplayName(p.author) : ''
|
||||
})
|
||||
|
||||
const avatarUrl = ref('')
|
||||
|
||||
// Bumped on every parent change so stale avatar fetches (older parent)
|
||||
// don't overwrite a newer one if the user navigates between comments
|
||||
// while fetches are still in flight.
|
||||
let avatarFetchToken = 0
|
||||
|
||||
watch(parent, (p) => {
|
||||
avatarUrl.value = ''
|
||||
const token = ++avatarFetchToken
|
||||
if (!p?.author) {
|
||||
return
|
||||
}
|
||||
fetchAvatarBlobUrl(p.author, 20)
|
||||
.then((url) => {
|
||||
if (token === avatarFetchToken) {
|
||||
avatarUrl.value = (url as string) ?? ''
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// Swallow — a missing avatar isn't worth a user-visible error;
|
||||
// the header still renders with the author name.
|
||||
})
|
||||
}, {immediate: true})
|
||||
|
||||
function onJump() {
|
||||
if (commentId.value !== null && ctx) {
|
||||
ctx.scrollToComment(commentId.value)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.tiptap blockquote.comment-quote {
|
||||
margin-block: .5rem;
|
||||
|
||||
.comment-quote__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: .5rem;
|
||||
padding-block-end: .25rem;
|
||||
font-size: .85rem;
|
||||
color: var(--grey-600);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.comment-quote__avatar {
|
||||
border-radius: 50%;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.comment-quote__author {
|
||||
font-weight: 600;
|
||||
color: var(--grey-700);
|
||||
|
||||
&--missing {
|
||||
font-style: italic;
|
||||
color: var(--grey-500);
|
||||
}
|
||||
}
|
||||
|
||||
.comment-quote__jump {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--grey-500);
|
||||
padding: .15rem .25rem;
|
||||
border-radius: 9999px;
|
||||
transition: background-color $transition, color $transition;
|
||||
|
||||
&:hover {
|
||||
color: var(--grey-800);
|
||||
background: var(--grey-200);
|
||||
}
|
||||
}
|
||||
|
||||
.comment-quote__body > :first-child {
|
||||
margin-block-start: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -166,12 +166,10 @@ import Mention from '@tiptap/extension-mention'
|
|||
|
||||
import {TaskList} from '@tiptap/extension-list'
|
||||
import {TaskItemWithId} from './taskItemWithId'
|
||||
import {BlockquoteWithCommentId} from './blockquoteWithCommentId'
|
||||
import HardBreak from '@tiptap/extension-hard-break'
|
||||
|
||||
import Commands from './commands'
|
||||
import suggestionSetup from './suggestion'
|
||||
import {EmojiExtension} from './emoji/emojiExtension'
|
||||
import mentionSuggestionSetup from './mention/mentionSuggestion'
|
||||
import MentionUser from './mention/MentionUser.vue'
|
||||
|
||||
|
|
@ -418,9 +416,7 @@ const extensions : Extensions = [
|
|||
StarterKit.configure({
|
||||
codeBlock: false,
|
||||
hardBreak: false,
|
||||
blockquote: false,
|
||||
}),
|
||||
BlockquoteWithCommentId,
|
||||
|
||||
CodeBlockLowlight.configure({
|
||||
lowlight: createLowlight(common),
|
||||
|
|
@ -519,8 +515,6 @@ const extensions : Extensions = [
|
|||
suggestion: suggestionSetup(t),
|
||||
}),
|
||||
|
||||
EmojiExtension,
|
||||
|
||||
PasteHandler,
|
||||
]
|
||||
|
||||
|
|
@ -722,7 +716,7 @@ async function addImage(event: Event) {
|
|||
return
|
||||
}
|
||||
|
||||
const url = await inputPrompt(event.target.getBoundingClientRect(), '', editor.value)
|
||||
const url = await inputPrompt(event.target.getBoundingClientRect())
|
||||
|
||||
if (url) {
|
||||
editor.value?.chain().focus().setImage({src: url}).run()
|
||||
|
|
@ -778,24 +772,6 @@ function setModeAndValue(value: string) {
|
|||
})
|
||||
}
|
||||
|
||||
// Replace the editor content with a reply draft (prefilled blockquote + empty
|
||||
// paragraph) and enter edit mode immediately so the user can start typing.
|
||||
// Returns synchronously after the next tick to let DOM updates settle.
|
||||
async function setReplyContent(value: string) {
|
||||
if (!editor.value) return
|
||||
editor.value.commands.setContent(value, {
|
||||
...defaultSetContentOptions,
|
||||
emitUpdate: false,
|
||||
})
|
||||
internalMode.value = 'edit'
|
||||
modelValue.value = editor.value.getHTML()
|
||||
contentHasChanged.value = true
|
||||
await nextTick()
|
||||
editor.value.commands.focus('end')
|
||||
}
|
||||
|
||||
defineExpose({setReplyContent})
|
||||
|
||||
|
||||
// See https://github.com/github/hotkey/discussions/85#discussioncomment-5214660
|
||||
function setFocusToEditor(event: KeyboardEvent) {
|
||||
|
|
|
|||
|
|
@ -1,65 +0,0 @@
|
|||
import {describe, it, expect} from 'vitest'
|
||||
import {Editor} from '@tiptap/core'
|
||||
import StarterKit from '@tiptap/starter-kit'
|
||||
import {BlockquoteWithCommentId} from './blockquoteWithCommentId'
|
||||
|
||||
describe('BlockquoteWithCommentId extension', () => {
|
||||
const createEditor = (content: string = '') => {
|
||||
return new Editor({
|
||||
extensions: [
|
||||
StarterKit.configure({blockquote: false}),
|
||||
BlockquoteWithCommentId,
|
||||
],
|
||||
content,
|
||||
})
|
||||
}
|
||||
|
||||
it('preserves data-comment-id through setContent → getHTML round-trip', () => {
|
||||
const editor = createEditor('<blockquote data-comment-id="42"><p>hi</p></blockquote>')
|
||||
|
||||
const html = editor.getHTML()
|
||||
expect(html).toContain('data-comment-id="42"')
|
||||
|
||||
editor.destroy()
|
||||
})
|
||||
|
||||
it('renders a plain blockquote (no attribute) unchanged', () => {
|
||||
const editor = createEditor('<blockquote><p>just a quote</p></blockquote>')
|
||||
|
||||
const html = editor.getHTML()
|
||||
expect(html).toContain('<blockquote>')
|
||||
expect(html).not.toContain('data-comment-id')
|
||||
|
||||
editor.destroy()
|
||||
})
|
||||
|
||||
it('preserves nested rich content inside the blockquote', () => {
|
||||
const editor = createEditor(
|
||||
'<blockquote data-comment-id="7"><p>this is <strong>bold</strong> text</p></blockquote>',
|
||||
)
|
||||
|
||||
const html = editor.getHTML()
|
||||
expect(html).toContain('data-comment-id="7"')
|
||||
expect(html).toContain('<strong>bold</strong>')
|
||||
|
||||
editor.destroy()
|
||||
})
|
||||
|
||||
it('drops a malformed data-comment-id (non-integer)', () => {
|
||||
const editor = createEditor('<blockquote data-comment-id="abc"><p>x</p></blockquote>')
|
||||
|
||||
const html = editor.getHTML()
|
||||
expect(html).not.toContain('data-comment-id')
|
||||
|
||||
editor.destroy()
|
||||
})
|
||||
|
||||
it('drops a non-positive data-comment-id', () => {
|
||||
const editor = createEditor('<blockquote data-comment-id="0"><p>x</p></blockquote>')
|
||||
|
||||
const html = editor.getHTML()
|
||||
expect(html).not.toContain('data-comment-id')
|
||||
|
||||
editor.destroy()
|
||||
})
|
||||
})
|
||||
|
|
@ -1,50 +0,0 @@
|
|||
import Blockquote from '@tiptap/extension-blockquote'
|
||||
import {VueNodeViewRenderer} from '@tiptap/vue-3'
|
||||
|
||||
import BlockquoteCommentView from './BlockquoteCommentView.vue'
|
||||
|
||||
/**
|
||||
* Blockquote extension that preserves `data-comment-id` across parse/serialize.
|
||||
* Used as the canonical reply marker: a comment that quotes another comment
|
||||
* stores the referenced comment's id on the wrapping blockquote, so both the
|
||||
* backend (for implicit-mention notifications) and the frontend (for the
|
||||
* jump-to-original chevron) can find it without a separate schema field.
|
||||
*
|
||||
* A Vue NodeView renders the in-app header + chevron when the surrounding
|
||||
* component (Comments.vue) provides a `commentReplyContext`. Outside that
|
||||
* context (task descriptions, etc.) the NodeView falls back to a plain
|
||||
* blockquote.
|
||||
*/
|
||||
export const BlockquoteWithCommentId = Blockquote.extend({
|
||||
addAttributes() {
|
||||
return {
|
||||
...this.parent?.(),
|
||||
commentId: {
|
||||
default: null,
|
||||
parseHTML: (element: HTMLElement) => {
|
||||
const raw = element.getAttribute('data-comment-id')
|
||||
if (raw === null) {
|
||||
return null
|
||||
}
|
||||
const id = Number(raw)
|
||||
if (!Number.isInteger(id) || id <= 0) {
|
||||
return null
|
||||
}
|
||||
return id
|
||||
},
|
||||
renderHTML: (attributes) => {
|
||||
if (attributes.commentId === null || attributes.commentId === undefined) {
|
||||
return {}
|
||||
}
|
||||
return {
|
||||
'data-comment-id': String(attributes.commentId),
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
return VueNodeViewRenderer(BlockquoteCommentView)
|
||||
},
|
||||
})
|
||||
|
|
@ -1,156 +0,0 @@
|
|||
<template>
|
||||
<div class="emoji-items">
|
||||
<template v-if="items.length">
|
||||
<button
|
||||
v-for="(item, index) in items"
|
||||
:key="item.shortcode"
|
||||
:ref="el => setItemRef(el, index)"
|
||||
type="button"
|
||||
class="emoji-item"
|
||||
:class="{ 'is-selected': index === selectedIndex }"
|
||||
@click="selectItem(index)"
|
||||
>
|
||||
<span class="emoji-glyph">{{ item.emoji }}</span>
|
||||
<div class="emoji-info">
|
||||
<p class="emoji-shortcode">
|
||||
:{{ item.shortcode }}:
|
||||
</p>
|
||||
<p class="emoji-annotation">
|
||||
{{ item.annotation }}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
</template>
|
||||
<div
|
||||
v-else
|
||||
class="emoji-item no-results"
|
||||
>
|
||||
{{ $t('input.editor.emoji.empty') }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import {ref, watch, nextTick} from 'vue'
|
||||
import type {EmojiEntry} from './emojiData'
|
||||
|
||||
const props = defineProps<{
|
||||
items: EmojiEntry[]
|
||||
command: (item: EmojiEntry) => void
|
||||
}>()
|
||||
|
||||
const selectedIndex = ref(0)
|
||||
const itemEls = ref<HTMLElement[]>([])
|
||||
|
||||
function setItemRef(el: Element | null, index: number) {
|
||||
if (el instanceof HTMLElement) {
|
||||
itemEls.value[index] = el
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => props.items, () => {
|
||||
selectedIndex.value = 0
|
||||
itemEls.value = []
|
||||
})
|
||||
|
||||
watch(selectedIndex, async idx => {
|
||||
await nextTick()
|
||||
itemEls.value[idx]?.scrollIntoView({block: 'nearest'})
|
||||
})
|
||||
|
||||
function selectItem(index: number) {
|
||||
const item = props.items[index]
|
||||
if (item) props.command(item)
|
||||
}
|
||||
|
||||
function onKeyDown({event}: {event: KeyboardEvent}): boolean {
|
||||
if (props.items.length === 0) return false
|
||||
|
||||
if (event.key === 'ArrowUp') {
|
||||
selectedIndex.value = ((selectedIndex.value + props.items.length) - 1) % props.items.length
|
||||
return true
|
||||
}
|
||||
if (event.key === 'ArrowDown') {
|
||||
selectedIndex.value = (selectedIndex.value + 1) % props.items.length
|
||||
return true
|
||||
}
|
||||
if (event.key === 'Enter' || event.key === 'Tab') {
|
||||
if (event.isComposing) return false
|
||||
selectItem(selectedIndex.value)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
defineExpose({onKeyDown})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.emoji-items {
|
||||
padding: 0.2rem;
|
||||
position: relative;
|
||||
border-radius: 0.5rem;
|
||||
background: var(--white);
|
||||
color: var(--grey-900);
|
||||
overflow: hidden;
|
||||
font-size: 0.9rem;
|
||||
box-shadow: var(--shadow-md);
|
||||
min-inline-size: 240px;
|
||||
max-block-size: 300px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.emoji-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin: 0;
|
||||
inline-size: 100%;
|
||||
text-align: start;
|
||||
background: transparent;
|
||||
border-radius: $radius;
|
||||
border: 0;
|
||||
padding: 0.4rem 0.6rem;
|
||||
transition: background-color $transition;
|
||||
|
||||
&.is-selected, &:hover {
|
||||
background: var(--grey-100);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
&.no-results {
|
||||
color: var(--grey-500);
|
||||
cursor: default;
|
||||
}
|
||||
}
|
||||
|
||||
.emoji-glyph {
|
||||
font-size: 1.4rem;
|
||||
margin-inline-end: 0.75rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.emoji-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-inline-size: 0;
|
||||
flex: 1;
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.emoji-shortcode {
|
||||
font-family: monospace;
|
||||
font-weight: 500;
|
||||
color: var(--grey-800);
|
||||
}
|
||||
|
||||
.emoji-annotation {
|
||||
font-size: 0.75rem;
|
||||
color: var(--grey-500);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,58 +0,0 @@
|
|||
import {describe, it, expect, vi, beforeEach, afterEach} from 'vitest'
|
||||
import {filterEmojis, __resetEmojiCacheForTest, loadEmojis} from './emojiData'
|
||||
|
||||
const fixture = [
|
||||
{shortcodes: ['grinning', 'grinning_face'], annotation: 'grinning face', tags: ['face', 'grin'], emoji: '😀'},
|
||||
{shortcodes: ['eyes'], annotation: 'eyes', tags: ['look'], emoji: '👀'},
|
||||
{shortcodes: ['eyeglasses'], annotation: 'glasses', tags: ['eye'], emoji: '👓'},
|
||||
{shortcodes: ['smile'], annotation: 'grinning face with smiling eyes', tags: ['eye', 'smile'], emoji: '😄'},
|
||||
]
|
||||
|
||||
describe('emojiData', () => {
|
||||
beforeEach(() => {
|
||||
__resetEmojiCacheForTest()
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => fixture,
|
||||
}))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('flattens multi-shortcode entries and sorts alphabetically', async () => {
|
||||
const idx = await loadEmojis()
|
||||
const codes = idx.map(e => e.shortcode)
|
||||
expect(codes).toEqual(['eyeglasses', 'eyes', 'grinning', 'grinning_face', 'smile'])
|
||||
})
|
||||
|
||||
it('returns [] for empty query', () => {
|
||||
expect(filterEmojis([{shortcode: 'eyes', emoji: '👀', annotation: '', tags: []}], '')).toEqual([])
|
||||
})
|
||||
|
||||
it('prefers startsWith matches over substring matches', () => {
|
||||
const loaded = [
|
||||
{shortcode: 'eyeglasses', emoji: '👓', annotation: 'glasses', tags: ['eye']},
|
||||
{shortcode: 'eyes', emoji: '👀', annotation: 'eyes', tags: []},
|
||||
{shortcode: 'smile', emoji: '😄', annotation: 'grinning face with smiling eyes', tags: ['eye']},
|
||||
]
|
||||
const result = filterEmojis(loaded, 'eye')
|
||||
expect(result[0].shortcode).toBe('eyeglasses')
|
||||
expect(result[1].shortcode).toBe('eyes')
|
||||
expect(result[2].shortcode).toBe('smile')
|
||||
})
|
||||
|
||||
it('limits results to 15', () => {
|
||||
const big = Array.from({length: 100}, (_, i) => ({
|
||||
shortcode: `foo_${String(i).padStart(3, '0')}`, emoji: '✨', annotation: '', tags: [],
|
||||
}))
|
||||
expect(filterEmojis(big, 'foo')).toHaveLength(15)
|
||||
})
|
||||
|
||||
it('caches the fetch promise across calls', async () => {
|
||||
await loadEmojis()
|
||||
await loadEmojis()
|
||||
expect((globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
|
@ -1,75 +0,0 @@
|
|||
export interface EmojiEntry {
|
||||
emoji: string
|
||||
shortcode: string
|
||||
annotation: string
|
||||
tags: string[]
|
||||
}
|
||||
|
||||
interface RawEmoji {
|
||||
shortcodes: string[]
|
||||
annotation: string
|
||||
tags?: string[]
|
||||
emoji: string
|
||||
}
|
||||
|
||||
const MAX_RESULTS = 15
|
||||
|
||||
let cache: Promise<EmojiEntry[]> | null = null
|
||||
|
||||
export function __resetEmojiCacheForTest() {
|
||||
cache = null
|
||||
}
|
||||
|
||||
export function loadEmojis(): Promise<EmojiEntry[]> {
|
||||
if (cache) return cache
|
||||
cache = fetch('/emojis.json')
|
||||
.then(res => {
|
||||
if (!res.ok) throw new Error(`emojis.json HTTP ${res.status}`)
|
||||
return res.json() as Promise<RawEmoji[]>
|
||||
})
|
||||
.then(raw => {
|
||||
const flat: EmojiEntry[] = []
|
||||
for (const entry of raw) {
|
||||
for (const shortcode of entry.shortcodes) {
|
||||
flat.push({
|
||||
emoji: entry.emoji,
|
||||
shortcode,
|
||||
annotation: entry.annotation,
|
||||
tags: entry.tags ?? [],
|
||||
})
|
||||
}
|
||||
}
|
||||
flat.sort((a, b) => a.shortcode.localeCompare(b.shortcode))
|
||||
return flat
|
||||
})
|
||||
.catch(err => {
|
||||
cache = null
|
||||
throw err
|
||||
})
|
||||
return cache
|
||||
}
|
||||
|
||||
export function filterEmojis(index: EmojiEntry[], rawQuery: string): EmojiEntry[] {
|
||||
const query = rawQuery.toLowerCase()
|
||||
if (query === '') return []
|
||||
|
||||
const starts: EmojiEntry[] = []
|
||||
const contains: EmojiEntry[] = []
|
||||
|
||||
for (const entry of index) {
|
||||
if (entry.shortcode.startsWith(query)) {
|
||||
starts.push(entry)
|
||||
continue
|
||||
}
|
||||
if (
|
||||
entry.shortcode.includes(query) ||
|
||||
entry.annotation.toLowerCase().includes(query) ||
|
||||
entry.tags.some(t => t.toLowerCase().includes(query))
|
||||
) {
|
||||
contains.push(entry)
|
||||
}
|
||||
if (starts.length >= MAX_RESULTS) break
|
||||
}
|
||||
|
||||
return [...starts, ...contains].slice(0, MAX_RESULTS)
|
||||
}
|
||||
|
|
@ -1,23 +0,0 @@
|
|||
import {Extension} from '@tiptap/core'
|
||||
import Suggestion from '@tiptap/suggestion'
|
||||
|
||||
import emojiSuggestionSetup from './emojiSuggestion'
|
||||
|
||||
export const EmojiExtension = Extension.create({
|
||||
name: 'emojiAutocomplete',
|
||||
|
||||
addOptions() {
|
||||
return {
|
||||
suggestion: emojiSuggestionSetup(),
|
||||
}
|
||||
},
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
return [
|
||||
Suggestion({
|
||||
editor: this.editor,
|
||||
...this.options.suggestion,
|
||||
}),
|
||||
]
|
||||
},
|
||||
})
|
||||
|
|
@ -1,148 +0,0 @@
|
|||
import {VueRenderer} from '@tiptap/vue-3'
|
||||
import {computePosition, flip, shift, offset, autoUpdate} from '@floating-ui/dom'
|
||||
import type {Editor, Range} from '@tiptap/core'
|
||||
import {PluginKey, type EditorState} from '@tiptap/pm/state'
|
||||
|
||||
import EmojiList from './EmojiList.vue'
|
||||
import {loadEmojis, filterEmojis, type EmojiEntry} from './emojiData'
|
||||
import {getPopupContainer} from '../popupContainer'
|
||||
|
||||
export const EmojiSuggestionPluginKey = new PluginKey('emojiSuggestion')
|
||||
|
||||
interface SuggestionProps {
|
||||
editor: Editor
|
||||
range: Range
|
||||
query: string
|
||||
clientRect?: (() => DOMRect | null) | null
|
||||
items: EmojiEntry[]
|
||||
command: (item: EmojiEntry) => void
|
||||
event?: KeyboardEvent
|
||||
}
|
||||
|
||||
const SHORTCODE_RE = /^[a-zA-Z0-9_]*$/
|
||||
|
||||
export default function emojiSuggestionSetup() {
|
||||
return {
|
||||
pluginKey: EmojiSuggestionPluginKey,
|
||||
char: ':',
|
||||
allowedPrefixes: [' ', '\t', '\n'],
|
||||
startOfLine: false,
|
||||
|
||||
allow: ({state, range}: {state: EditorState, range: Range}) => {
|
||||
const text = state.doc.textBetween(range.from, range.to, '\n', '\n')
|
||||
// Drop the leading ':' trigger character.
|
||||
const query = text.startsWith(':') ? text.slice(1) : text
|
||||
return SHORTCODE_RE.test(query)
|
||||
},
|
||||
|
||||
items: async ({query}: {query: string}): Promise<EmojiEntry[]> => {
|
||||
if (query === '') return []
|
||||
try {
|
||||
const index = await loadEmojis()
|
||||
return filterEmojis(index, query)
|
||||
} catch (err) {
|
||||
console.error('Failed to load emoji index:', err)
|
||||
return []
|
||||
}
|
||||
},
|
||||
|
||||
command: ({editor, range, props}: {editor: Editor, range: Range, props: EmojiEntry}) => {
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.deleteRange(range)
|
||||
.insertContent(props.emoji)
|
||||
.run()
|
||||
},
|
||||
|
||||
render: () => {
|
||||
let component: VueRenderer
|
||||
let popupElement: HTMLElement | null = null
|
||||
let cleanupFloating: (() => void) | null = null
|
||||
|
||||
const virtualReference = {
|
||||
getBoundingClientRect: () => ({
|
||||
width: 0, height: 0, x: 0, y: 0, top: 0, left: 0, right: 0, bottom: 0,
|
||||
} as DOMRect),
|
||||
}
|
||||
|
||||
const mount = (props: SuggestionProps) => {
|
||||
component = new VueRenderer(EmojiList, {
|
||||
props,
|
||||
editor: props.editor,
|
||||
})
|
||||
if (!props.clientRect) return
|
||||
|
||||
popupElement = document.createElement('div')
|
||||
popupElement.style.position = 'absolute'
|
||||
popupElement.style.top = '0'
|
||||
popupElement.style.left = '0'
|
||||
popupElement.style.zIndex = '4700'
|
||||
popupElement.appendChild(component.element!)
|
||||
getPopupContainer(props.editor).appendChild(popupElement)
|
||||
|
||||
const rect = props.clientRect()
|
||||
if (!rect) {
|
||||
unmount()
|
||||
return
|
||||
}
|
||||
virtualReference.getBoundingClientRect = () => rect
|
||||
|
||||
const updatePosition = () => {
|
||||
computePosition(virtualReference, popupElement!, {
|
||||
placement: 'bottom-start',
|
||||
middleware: [offset(8), flip(), shift({padding: 8})],
|
||||
}).then(({x, y}) => {
|
||||
if (popupElement) {
|
||||
popupElement.style.left = `${x}px`
|
||||
popupElement.style.top = `${y}px`
|
||||
}
|
||||
})
|
||||
}
|
||||
updatePosition()
|
||||
cleanupFloating = autoUpdate(virtualReference, popupElement, updatePosition)
|
||||
}
|
||||
|
||||
const unmount = () => {
|
||||
if (cleanupFloating) {
|
||||
cleanupFloating()
|
||||
cleanupFloating = null
|
||||
}
|
||||
if (popupElement) {
|
||||
popupElement.remove()
|
||||
popupElement = null
|
||||
}
|
||||
component?.destroy()
|
||||
}
|
||||
|
||||
return {
|
||||
onStart: (props: SuggestionProps) => {
|
||||
if (!props.items.length && props.query === '') return
|
||||
mount(props)
|
||||
},
|
||||
|
||||
onUpdate(props: SuggestionProps) {
|
||||
if (!popupElement) {
|
||||
if (props.items.length || props.query !== '') mount(props)
|
||||
return
|
||||
}
|
||||
component?.updateProps(props)
|
||||
if (!props.clientRect) return
|
||||
const rect = props.clientRect()
|
||||
if (rect) virtualReference.getBoundingClientRect = () => rect
|
||||
},
|
||||
|
||||
onKeyDown(props: {event: KeyboardEvent}) {
|
||||
if (props.event.key === 'Escape') {
|
||||
if (props.event.isComposing) return false
|
||||
if (popupElement) popupElement.style.display = 'none'
|
||||
return true
|
||||
}
|
||||
return component?.ref?.onKeyDown(props)
|
||||
},
|
||||
|
||||
onExit: unmount,
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -3,7 +3,6 @@ import { computePosition, flip, shift, offset, autoUpdate } from '@floating-ui/d
|
|||
import type { Editor } from '@tiptap/core'
|
||||
|
||||
import MentionList from './MentionList.vue'
|
||||
import { getPopupContainer } from '../popupContainer'
|
||||
import ProjectUserService from '@/services/projectUsers'
|
||||
import { fetchAvatarBlobUrl, getDisplayName } from '@/models/user'
|
||||
import type { IUser } from '@/modelTypes/IUser'
|
||||
|
|
@ -114,8 +113,7 @@ export default function mentionSuggestionSetup(projectId: number) {
|
|||
popupElement.style.left = '0'
|
||||
popupElement.style.zIndex = '4700'
|
||||
popupElement.appendChild(component.element!)
|
||||
getPopupContainer(props.editor).appendChild(popupElement)
|
||||
// Update virtual reference
|
||||
document.body.appendChild(popupElement) // Update virtual reference
|
||||
const rect = props.clientRect()
|
||||
if (rect) {
|
||||
virtualReference.getBoundingClientRect = () => rect
|
||||
|
|
@ -181,7 +179,7 @@ export default function mentionSuggestionSetup(projectId: number) {
|
|||
cleanupFloating()
|
||||
}
|
||||
if (popupElement) {
|
||||
popupElement.remove()
|
||||
document.body.removeChild(popupElement)
|
||||
popupElement = null
|
||||
}
|
||||
component.destroy()
|
||||
|
|
|
|||
|
|
@ -1,11 +0,0 @@
|
|||
import type {Editor} from '@tiptap/core'
|
||||
|
||||
// Native <dialog> elements opened with showModal() render in the browser's
|
||||
// top-layer, so popups appended to document.body end up visually behind them
|
||||
// regardless of z-index. Appending to the open dialog itself lifts the popup
|
||||
// into the same top-layer stacking context.
|
||||
export function getPopupContainer(editor?: Editor): HTMLElement {
|
||||
const editorEl = editor?.view?.dom as HTMLElement | undefined
|
||||
const dialog = editorEl?.closest('dialog[open]') as HTMLElement | null
|
||||
return dialog ?? document.body
|
||||
}
|
||||
|
|
@ -3,7 +3,7 @@ import inputPrompt from '@/helpers/inputPrompt'
|
|||
|
||||
export async function setLinkInEditor(pos: DOMRect, editor: Editor | null | undefined) {
|
||||
const previousUrl = editor?.getAttributes('link').href || ''
|
||||
const url = await inputPrompt(pos, previousUrl, editor ?? undefined)
|
||||
const url = await inputPrompt(pos, previousUrl)
|
||||
|
||||
// empty
|
||||
if (url === '') {
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ import {VueRenderer} from '@tiptap/vue-3'
|
|||
import {computePosition, flip, shift, offset, autoUpdate} from '@floating-ui/dom'
|
||||
|
||||
import CommandsList from './CommandsList.vue'
|
||||
import {getPopupContainer} from './popupContainer'
|
||||
|
||||
type TranslateFunction = (key: string) => string
|
||||
|
||||
|
|
@ -207,7 +206,7 @@ export default function suggestionSetup(t: TranslateFunction) {
|
|||
popupElement.style.left = '0'
|
||||
popupElement.style.zIndex = '4700'
|
||||
popupElement.appendChild(component.element!)
|
||||
getPopupContainer(props.editor).appendChild(popupElement)
|
||||
document.body.appendChild(popupElement)
|
||||
|
||||
// Update virtual reference
|
||||
const rect = props.clientRect()
|
||||
|
|
@ -267,7 +266,7 @@ export default function suggestionSetup(t: TranslateFunction) {
|
|||
cleanupFloating()
|
||||
}
|
||||
if (popupElement) {
|
||||
popupElement.remove()
|
||||
document.body.removeChild(popupElement)
|
||||
popupElement = null
|
||||
}
|
||||
component.destroy()
|
||||
|
|
|
|||
|
|
@ -135,7 +135,6 @@ defineExpose({
|
|||
inline-size: 100%;
|
||||
text-align: start;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
border-radius: $radius;
|
||||
border: 0;
|
||||
padding: 0.375rem 0.5rem;
|
||||
|
|
|
|||
|
|
@ -73,9 +73,6 @@ defineEmits<{
|
|||
margin-block-end: 1rem;
|
||||
border: 1px solid var(--card-border-color);
|
||||
box-shadow: var(--shadow-sm);
|
||||
color: var(--text);
|
||||
max-inline-size: 100%;
|
||||
position: relative;
|
||||
|
||||
@media print {
|
||||
box-shadow: none;
|
||||
|
|
@ -84,61 +81,15 @@ defineEmits<{
|
|||
}
|
||||
|
||||
.card-header {
|
||||
background-color: transparent;
|
||||
align-items: stretch;
|
||||
display: flex;
|
||||
box-shadow: none;
|
||||
border-inline-end: 1px solid var(--card-border-color);
|
||||
border-radius: $radius $radius 0 0;
|
||||
}
|
||||
|
||||
.card-header-title {
|
||||
align-items: center;
|
||||
color: var(--text-strong);
|
||||
display: flex;
|
||||
flex-grow: 1;
|
||||
font-weight: 700;
|
||||
padding: 0.75rem 1rem;
|
||||
|
||||
&.is-centered {
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
.card-header-icon {
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 0.75rem 1rem;
|
||||
}
|
||||
|
||||
.card-content {
|
||||
background-color: transparent;
|
||||
padding: 1.5rem;
|
||||
|
||||
&:first-child {
|
||||
border-start-start-radius: $radius;
|
||||
border-start-end-radius: $radius;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
border-end-start-radius: $radius;
|
||||
border-end-end-radius: $radius;
|
||||
}
|
||||
|
||||
// Utility classes like .p-0 are defined globally with lower specificity
|
||||
// than Vue-scoped selectors; restore precedence explicitly.
|
||||
&.p-0 {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.card-footer {
|
||||
align-items: stretch;
|
||||
background-color: var(--grey-50);
|
||||
border-block-start: 0;
|
||||
padding: 20px;
|
||||
padding: var(--modal-card-head-padding);
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@
|
|||
<Modal
|
||||
:overflow="true"
|
||||
:wide="wide"
|
||||
:aria-label="title"
|
||||
@close="$router.back()"
|
||||
>
|
||||
<Card
|
||||
|
|
|
|||
|
|
@ -13,7 +13,6 @@
|
|||
>
|
||||
<BaseButton
|
||||
class="dropdown-trigger is-flex"
|
||||
:aria-label="triggerLabel"
|
||||
@click="toggleOpen"
|
||||
>
|
||||
<Icon
|
||||
|
|
@ -50,10 +49,8 @@ import BaseButton from '@/components/base/BaseButton.vue'
|
|||
|
||||
withDefaults(defineProps<{
|
||||
triggerIcon?: IconProp
|
||||
triggerLabel?: string
|
||||
}>(), {
|
||||
triggerIcon: 'ellipsis-h',
|
||||
triggerLabel: undefined,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import {library} from '@fortawesome/fontawesome-svg-core'
|
||||
import {
|
||||
faAlignLeft,
|
||||
faAngleLeft,
|
||||
faAngleRight,
|
||||
faAnglesUp,
|
||||
faArchive,
|
||||
|
|
@ -59,7 +58,6 @@ import {
|
|||
faPlay,
|
||||
faPlus,
|
||||
faPowerOff,
|
||||
faRss,
|
||||
faSearch,
|
||||
faShareAlt,
|
||||
faSignOutAlt,
|
||||
|
|
@ -75,7 +73,6 @@ import {
|
|||
faTimes,
|
||||
faTrashAlt,
|
||||
faUser,
|
||||
faUserEdit,
|
||||
faUsers,
|
||||
faQuoteRight,
|
||||
faListUl,
|
||||
|
|
@ -122,7 +119,6 @@ library.add(faCode)
|
|||
library.add(faQuoteRight)
|
||||
library.add(faListUl)
|
||||
library.add(faAlignLeft)
|
||||
library.add(faAngleLeft)
|
||||
library.add(faAngleRight)
|
||||
library.add(faArchive)
|
||||
library.add(faArrowLeft)
|
||||
|
|
@ -171,7 +167,6 @@ library.add(faPercent)
|
|||
library.add(faPlay)
|
||||
library.add(faPlus)
|
||||
library.add(faPowerOff)
|
||||
library.add(faRss)
|
||||
library.add(faSave)
|
||||
library.add(faSearch)
|
||||
library.add(faShareAlt)
|
||||
|
|
@ -191,7 +186,6 @@ library.add(faTimes)
|
|||
library.add(faTimesCircle)
|
||||
library.add(faTrashAlt)
|
||||
library.add(faUser)
|
||||
library.add(faUserEdit)
|
||||
library.add(faUsers)
|
||||
library.add(faArrowDownShortWide)
|
||||
library.add(faArrowUpFromBracket)
|
||||
|
|
|
|||
|
|
@ -1,230 +0,0 @@
|
|||
import {describe, it, expect, beforeEach, afterEach, vi} from 'vitest'
|
||||
import {mount, flushPromises} from '@vue/test-utils'
|
||||
import {nextTick} from 'vue'
|
||||
import Modal from './Modal.vue'
|
||||
|
||||
const globalMocks = {
|
||||
global: {
|
||||
mocks: {
|
||||
$t: (key: string) => key,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// jsdom does not implement HTMLDialogElement.showModal/close.
|
||||
// Provide stubs so that the [open] attribute — which CSS and our tests
|
||||
// check — is flipped the same way the real browser would.
|
||||
let showModalSpy: ReturnType<typeof vi.spyOn>
|
||||
let closeSpy: ReturnType<typeof vi.spyOn>
|
||||
let installedShowModal = false
|
||||
let installedClose = false
|
||||
|
||||
beforeEach(() => {
|
||||
const proto = HTMLDialogElement.prototype
|
||||
if (typeof proto.showModal !== 'function') {
|
||||
proto.showModal = function () {}
|
||||
installedShowModal = true
|
||||
}
|
||||
if (typeof proto.close !== 'function') {
|
||||
proto.close = function () {}
|
||||
installedClose = true
|
||||
}
|
||||
showModalSpy = vi.spyOn(proto, 'showModal').mockImplementation(function (this: HTMLDialogElement) {
|
||||
this.setAttribute('open', '')
|
||||
})
|
||||
closeSpy = vi.spyOn(proto, 'close').mockImplementation(function (this: HTMLDialogElement) {
|
||||
this.removeAttribute('open')
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
showModalSpy.mockRestore()
|
||||
closeSpy.mockRestore()
|
||||
// Remove the prototype stubs we installed, so other test files see the
|
||||
// original (unpatched) shape of HTMLDialogElement.
|
||||
if (installedShowModal) {
|
||||
// @ts-expect-error — removing the method we added
|
||||
delete HTMLDialogElement.prototype.showModal
|
||||
installedShowModal = false
|
||||
}
|
||||
if (installedClose) {
|
||||
// @ts-expect-error — removing the method we added
|
||||
delete HTMLDialogElement.prototype.close
|
||||
installedClose = false
|
||||
}
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
describe('Modal.vue — open race condition (#2590)', () => {
|
||||
it('opens the dialog when enabled flips false → true', async () => {
|
||||
const wrapper = mount(Modal, {
|
||||
...globalMocks,
|
||||
attachTo: document.body,
|
||||
props: {enabled: false},
|
||||
slots: {default: '<p class="test-body">hi</p>'},
|
||||
})
|
||||
|
||||
// Pre-condition: dialog is not yet in the DOM.
|
||||
expect(document.querySelector('dialog.modal-dialog')).toBeNull()
|
||||
|
||||
// Flip enabled → true. This is the failure path in the bug report.
|
||||
// The fix must call showModal() deterministically — i.e. once the
|
||||
// <dialog> element is mounted via the dialogRef watcher, not via a
|
||||
// nextTick that may fire before the mount flush under Electron.
|
||||
await wrapper.setProps({enabled: true})
|
||||
await flushPromises()
|
||||
await nextTick()
|
||||
|
||||
const dialog = document.querySelector('dialog.modal-dialog') as HTMLDialogElement | null
|
||||
expect(dialog).not.toBeNull()
|
||||
expect(dialog!.hasAttribute('open')).toBe(true)
|
||||
expect(showModalSpy).toHaveBeenCalledTimes(1)
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('calls showModal synchronously with the render flush, not via a deferred nextTick (#2590)', async () => {
|
||||
// Regression guard: the buggy implementation scheduled showModal() via
|
||||
// nextTick *after* setting showDialog = true, so the call landed in a
|
||||
// microtask that could fire before the <dialog> mount flush under
|
||||
// Electron/Chromium. The fix invokes showModal() from a watch on the
|
||||
// dialogRef template ref, which Vue populates during the same flush
|
||||
// that mounts the element. That means by the time `await nextTick()`
|
||||
// resolves after the first state change, the dialog must already have
|
||||
// [open] set — no additional flushPromises or extra ticks required.
|
||||
const wrapper = mount(Modal, {
|
||||
...globalMocks,
|
||||
attachTo: document.body,
|
||||
props: {enabled: false},
|
||||
slots: {default: '<p class="test-body">hi</p>'},
|
||||
})
|
||||
expect(document.querySelector('dialog.modal-dialog')).toBeNull()
|
||||
|
||||
// Flip enabled and wait exactly one render flush. After this, the
|
||||
// dialog is mounted AND showModal has been called.
|
||||
wrapper.setProps({enabled: true})
|
||||
await nextTick()
|
||||
|
||||
const dialog = document.querySelector('dialog.modal-dialog') as HTMLDialogElement | null
|
||||
expect(dialog).not.toBeNull()
|
||||
expect(showModalSpy).toHaveBeenCalled()
|
||||
expect(showModalSpy.mock.instances[0]).toBe(dialog)
|
||||
expect(dialog!.hasAttribute('open')).toBe(true)
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('calls showModal on the exact dialog element that is mounted (race regression)', async () => {
|
||||
// This test asserts the fix's contract: whenever the <dialog> element
|
||||
// is mounted (i.e. dialogRef becomes non-null), showModal() is called
|
||||
// on *that* element. The buggy implementation instead relied on a
|
||||
// nextTick callback whose timing could fire before the dialog mounted,
|
||||
// skipping the showModal() call entirely and leaving .open === false.
|
||||
const wrapper = mount(Modal, {
|
||||
...globalMocks,
|
||||
attachTo: document.body,
|
||||
props: {enabled: true},
|
||||
slots: {default: '<p class="test-body">hi</p>'},
|
||||
})
|
||||
await flushPromises()
|
||||
await nextTick()
|
||||
|
||||
const dialog = document.querySelector('dialog.modal-dialog') as HTMLDialogElement | null
|
||||
expect(dialog).not.toBeNull()
|
||||
// The fingerprint from the bug report: element is mounted but .open
|
||||
// is false because showModal() was never called. The fix guarantees
|
||||
// these two always agree.
|
||||
expect(dialog!.hasAttribute('open')).toBe(true)
|
||||
expect(showModalSpy).toHaveBeenCalled()
|
||||
expect(showModalSpy.mock.instances[0]).toBe(dialog)
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('closes the dialog when enabled flips true → false', async () => {
|
||||
const wrapper = mount(Modal, {
|
||||
...globalMocks,
|
||||
attachTo: document.body,
|
||||
props: {enabled: true},
|
||||
slots: {default: '<p class="test-body">hi</p>'},
|
||||
})
|
||||
await flushPromises()
|
||||
await nextTick()
|
||||
|
||||
// Sanity: open.
|
||||
expect(document.querySelector('dialog.modal-dialog')?.hasAttribute('open')).toBe(true)
|
||||
|
||||
await wrapper.setProps({enabled: false})
|
||||
// Wait past the 150ms closeTimer (real timers — fake timers interact
|
||||
// badly with Vue's scheduler).
|
||||
await new Promise(resolve => setTimeout(resolve, 200))
|
||||
await flushPromises()
|
||||
await nextTick()
|
||||
|
||||
expect(document.querySelector('dialog.modal-dialog')).toBeNull()
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('does not open the dialog if enabled flips back to false before mount', async () => {
|
||||
// Regression guard: the dialogRef watcher fires once the <dialog>
|
||||
// element mounts. If props.enabled has flipped back to false by the
|
||||
// time the mount happens, the watcher must not call showModal().
|
||||
const wrapper = mount(Modal, {
|
||||
...globalMocks,
|
||||
attachTo: document.body,
|
||||
props: {enabled: false},
|
||||
slots: {default: '<p class="test-body">hi</p>'},
|
||||
})
|
||||
|
||||
// Flip enabled true then false within the same tick, before the mount
|
||||
// flush can complete.
|
||||
wrapper.setProps({enabled: true})
|
||||
wrapper.setProps({enabled: false})
|
||||
await flushPromises()
|
||||
await nextTick()
|
||||
await new Promise(resolve => setTimeout(resolve, 200))
|
||||
await flushPromises()
|
||||
await nextTick()
|
||||
|
||||
// showModal must not have been called — the final prop state is
|
||||
// disabled.
|
||||
expect(showModalSpy).not.toHaveBeenCalled()
|
||||
expect(document.querySelector('dialog.modal-dialog')).toBeNull()
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('clears data-closing when re-opened mid-close transition', async () => {
|
||||
// Regression guard: if the user toggles enabled back to true while the
|
||||
// 150ms close transition is still in flight, the <dialog> is still
|
||||
// mounted and [open], so the dialogRef watcher does not re-fire. Make
|
||||
// sure openDialog() clears the leftover data-closing flag itself;
|
||||
// otherwise the dialog stays stuck at opacity 0.
|
||||
const wrapper = mount(Modal, {
|
||||
...globalMocks,
|
||||
attachTo: document.body,
|
||||
props: {enabled: true},
|
||||
slots: {default: '<p class="test-body">hi</p>'},
|
||||
})
|
||||
await flushPromises()
|
||||
await nextTick()
|
||||
|
||||
const dialog = document.querySelector('dialog.modal-dialog') as HTMLDialogElement
|
||||
expect(dialog.hasAttribute('open')).toBe(true)
|
||||
|
||||
// Start closing — this sets data-closing and schedules the unmount.
|
||||
await wrapper.setProps({enabled: false})
|
||||
await nextTick()
|
||||
expect(dialog.dataset.closing).toBe('')
|
||||
|
||||
// Re-open well before the 150ms close timer fires.
|
||||
await wrapper.setProps({enabled: true})
|
||||
await nextTick()
|
||||
|
||||
expect(dialog.dataset.closing).toBeUndefined()
|
||||
expect(dialog.hasAttribute('open')).toBe(true)
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
|
|
@ -16,8 +16,7 @@
|
|||
@mousedown.self.prevent.stop="$emit('close')"
|
||||
>
|
||||
<BaseButton
|
||||
:aria-label="$t('misc.closeDialog')"
|
||||
class="close d-print-none"
|
||||
class="close"
|
||||
@click="$emit('close')"
|
||||
>
|
||||
<Icon icon="times" />
|
||||
|
|
@ -62,13 +61,13 @@
|
|||
|
||||
<script lang="ts" setup>
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
import {ref, useAttrs, watch, onBeforeUnmount, onMounted} from 'vue'
|
||||
import {ref, useAttrs, watch, onBeforeUnmount, onMounted, nextTick} from 'vue'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
enabled?: boolean,
|
||||
overflow?: boolean,
|
||||
wide?: boolean,
|
||||
variant?: 'default' | 'hint-modal' | 'scrolling' | 'top',
|
||||
variant?: 'default' | 'hint-modal' | 'scrolling',
|
||||
}>(), {
|
||||
enabled: true,
|
||||
overflow: false,
|
||||
|
|
@ -97,19 +96,14 @@ function openDialog() {
|
|||
}
|
||||
previouslyFocused.value = document.activeElement
|
||||
showDialog.value = true
|
||||
document.body.style.overflow = 'hidden'
|
||||
// If we're re-opening while the previous close transition is still in
|
||||
// flight the <dialog> is still mounted and [open], so the dialogRef
|
||||
// watcher below won't re-fire. Clear the data-closing flag here so the
|
||||
// dialog doesn't stay stuck at opacity 0.
|
||||
if (dialogRef.value) {
|
||||
delete dialogRef.value.dataset.closing
|
||||
}
|
||||
// The initial `showModal()` call happens in the `watch(dialogRef, …)`
|
||||
// below, which fires the moment Vue mounts the <dialog>. We cannot call
|
||||
// it synchronously here because the element is not in the DOM yet
|
||||
// (v-if="showDialog" only just became true), and we cannot rely on a
|
||||
// single nextTick because the mount can be deferred past it (#2590).
|
||||
nextTick(() => {
|
||||
const dialog = dialogRef.value
|
||||
if (dialog) {
|
||||
delete dialog.dataset.closing
|
||||
dialog.showModal()
|
||||
}
|
||||
document.body.style.overflow = 'hidden'
|
||||
})
|
||||
}
|
||||
|
||||
function closeDialog() {
|
||||
|
|
@ -142,51 +136,13 @@ watch(
|
|||
closeDialog()
|
||||
}
|
||||
},
|
||||
{immediate: true},
|
||||
{immediate: false},
|
||||
)
|
||||
|
||||
// Actually call showModal() the moment the <dialog> element is mounted.
|
||||
// `dialogRef` is populated by Vue during the render flush after
|
||||
// `showDialog.value = true`, so this fires deterministically, no matter
|
||||
// how many flushes the renderer needs (see #2590). We re-check
|
||||
// `props.enabled` here because the prop can flip back to `false` between
|
||||
// `openDialog()` and the mount flush, in which case we must not open.
|
||||
watch(dialogRef, (dialog) => {
|
||||
if (!dialog) return
|
||||
if (!props.enabled) return
|
||||
delete dialog.dataset.closing
|
||||
dialog.showModal()
|
||||
})
|
||||
|
||||
// A <dialog> opened with showModal() lives in the browser's top layer, which
|
||||
// renders only the first page during print (top-layer elements are
|
||||
// viewport-anchored and don't paginate). Temporarily swap to a non-modal
|
||||
// dialog for the duration of the print so the content flows in normal
|
||||
// document order and can break across pages.
|
||||
let wasModalBeforePrint = false
|
||||
|
||||
function handleBeforePrint() {
|
||||
const dialog = dialogRef.value
|
||||
if (dialog && dialog.matches(':modal')) {
|
||||
wasModalBeforePrint = true
|
||||
dialog.close()
|
||||
dialog.show()
|
||||
}
|
||||
}
|
||||
|
||||
function handleAfterPrint() {
|
||||
if (!wasModalBeforePrint) return
|
||||
wasModalBeforePrint = false
|
||||
const dialog = dialogRef.value
|
||||
if (dialog && dialog.open) {
|
||||
dialog.close()
|
||||
dialog.showModal()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('beforeprint', handleBeforePrint)
|
||||
window.addEventListener('afterprint', handleAfterPrint)
|
||||
if (props.enabled) {
|
||||
openDialog()
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
|
|
@ -198,8 +154,6 @@ onBeforeUnmount(() => {
|
|||
if (previouslyFocused.value instanceof HTMLElement) {
|
||||
previouslyFocused.value.focus()
|
||||
}
|
||||
window.removeEventListener('beforeprint', handleBeforePrint)
|
||||
window.removeEventListener('afterprint', handleAfterPrint)
|
||||
})
|
||||
</script>
|
||||
|
||||
|
|
@ -211,13 +165,7 @@ $modal-width: 1024px;
|
|||
// Reset UA dialog styles
|
||||
padding: 0;
|
||||
border: none;
|
||||
// The scrim lives on the dialog element, not on ::backdrop: Chromium
|
||||
// intermittently stops painting a styled ::backdrop (e.g. after the
|
||||
// dialog's subtree re-renders, or while display is transitioned) even
|
||||
// though getComputedStyle still reports the color. The dialog fills the
|
||||
// viewport anyway, and its opacity transition fades the scrim with it —
|
||||
// same as the old div-based .modal-mask.
|
||||
background: rgba(0, 0, 0, .8);
|
||||
background: transparent;
|
||||
color: #ffffff;
|
||||
// Fill viewport
|
||||
position: fixed;
|
||||
|
|
@ -227,12 +175,10 @@ $modal-width: 1024px;
|
|||
max-inline-size: 100%;
|
||||
max-block-size: 100%;
|
||||
|
||||
// Transitions. No display/allow-discrete transition needed: the close
|
||||
// fade runs while the dialog is still [open] (data-closing + timer in
|
||||
// closeDialog), and transitioning display triggers the Chromium paint
|
||||
// bug above.
|
||||
// Transitions
|
||||
opacity: 0;
|
||||
transition: opacity 150ms ease;
|
||||
transition: opacity 150ms ease,
|
||||
display 150ms ease allow-discrete;
|
||||
|
||||
&[open]:not([data-closing]) {
|
||||
opacity: 1;
|
||||
|
|
@ -244,11 +190,16 @@ $modal-width: 1024px;
|
|||
|
||||
&::backdrop {
|
||||
background-color: rgba(0, 0, 0, 0);
|
||||
transition: background-color 150ms ease,
|
||||
display 150ms ease allow-discrete;
|
||||
}
|
||||
|
||||
// in quick-add mode the Electron window itself is the overlay — no scrim
|
||||
&:has(.is-quick-add-mode) {
|
||||
background: transparent;
|
||||
&[open]:not([data-closing])::backdrop {
|
||||
background-color: rgba(0, 0, 0, .8);
|
||||
|
||||
@starting-style {
|
||||
background-color: rgba(0, 0, 0, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -264,20 +215,13 @@ $modal-width: 1024px;
|
|||
}
|
||||
|
||||
.default .modal-content,
|
||||
.hint-modal .modal-content,
|
||||
.top .modal-content {
|
||||
.hint-modal .modal-content {
|
||||
text-align: center;
|
||||
position: absolute;
|
||||
// fine to use top/left since we're only using this to position it centered
|
||||
inset-block-start: 50%;
|
||||
inset-inline-start: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
// Cap centered content to the viewport and scroll inside it. Without this a
|
||||
// taller-than-viewport modal centres its top edge above the viewport, where
|
||||
// the container's overflow can't scroll to it (the .top variant overrides
|
||||
// both values below).
|
||||
max-block-size: calc(100dvh - 2rem);
|
||||
overflow: auto;
|
||||
|
||||
[dir="rtl"] & {
|
||||
transform: translate(50%, -50%);
|
||||
|
|
@ -287,9 +231,6 @@ $modal-width: 1024px;
|
|||
margin: 0;
|
||||
position: static;
|
||||
transform: none;
|
||||
// the fullscreen mobile layout flows and scrolls in .modal-container
|
||||
max-block-size: none;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
|
|
@ -302,40 +243,6 @@ $modal-width: 1024px;
|
|||
}
|
||||
}
|
||||
|
||||
// anchored below the top edge instead of centered, used for QuickActions
|
||||
.top .modal-content {
|
||||
inset-block-start: 3rem;
|
||||
transform: translate(-50%, 0);
|
||||
max-block-size: calc(100dvh - 6rem);
|
||||
overflow: auto;
|
||||
|
||||
[dir="rtl"] & {
|
||||
transform: translate(50%, 0);
|
||||
}
|
||||
|
||||
// the fullscreen mobile layout flows and scrolls in .modal-container
|
||||
@media screen and (max-width: $tablet) {
|
||||
transform: none;
|
||||
max-block-size: none;
|
||||
overflow: visible;
|
||||
}
|
||||
}
|
||||
|
||||
// Default width for centered modals. Scoped with :not(.is-wide) so the
|
||||
// `wide` prop can still expand the modal (the .is-wide rule below would
|
||||
// otherwise be outranked by .default .modal-content's specificity).
|
||||
.default .modal-content:not(.is-wide),
|
||||
.hint-modal .modal-content:not(.is-wide),
|
||||
.top .modal-content:not(.is-wide) {
|
||||
inline-size: calc(100% - 2rem);
|
||||
max-inline-size: 640px;
|
||||
|
||||
@media screen and (max-width: $tablet) {
|
||||
inline-size: 100%;
|
||||
max-inline-size: none;
|
||||
}
|
||||
}
|
||||
|
||||
// scrolling-content
|
||||
// used e.g. for <TaskDetailViewModal>
|
||||
.scrolling .modal-content {
|
||||
|
|
@ -427,32 +334,6 @@ $modal-width: 1024px;
|
|||
}
|
||||
}
|
||||
|
||||
// Unconstrain the native <dialog> so the full modal content flows onto the
|
||||
// printed page instead of being clipped to the viewport-sized top layer.
|
||||
@media print {
|
||||
.modal-dialog {
|
||||
position: static;
|
||||
inline-size: auto;
|
||||
block-size: auto;
|
||||
max-inline-size: none;
|
||||
max-block-size: none;
|
||||
background: transparent;
|
||||
|
||||
&::backdrop {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.modal-container {
|
||||
overflow: visible;
|
||||
min-block-size: 0;
|
||||
}
|
||||
|
||||
:deep(.card) {
|
||||
min-block-size: 0 !important;
|
||||
}
|
||||
}
|
||||
|
||||
.modal-content:has(.modal-header) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
|
|
|||
|
|
@ -17,10 +17,7 @@
|
|||
{{ $t("misc.welcomeBack") }}
|
||||
</h2>
|
||||
</section>
|
||||
<main
|
||||
id="main-content"
|
||||
class="content"
|
||||
>
|
||||
<section class="content">
|
||||
<div>
|
||||
<h2
|
||||
v-if="title"
|
||||
|
|
@ -38,7 +35,7 @@
|
|||
<slot />
|
||||
</div>
|
||||
<Legal />
|
||||
</main>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -1,96 +1,64 @@
|
|||
<template>
|
||||
<Teleport :to="teleportTarget">
|
||||
<Notifications
|
||||
position="bottom left"
|
||||
:max="2"
|
||||
:ignore-duplicates="true"
|
||||
class="global-notification"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
<template #body="{ item, close }">
|
||||
<!-- FIXME: overlay whole notification with button and add event listener on that button instead -->
|
||||
<Notifications
|
||||
position="bottom left"
|
||||
:max="2"
|
||||
:ignore-duplicates="true"
|
||||
class="global-notification"
|
||||
>
|
||||
<template #body="{ item, close }">
|
||||
<!-- FIXME: overlay whole notification with button and add event listener on that button instead -->
|
||||
<div
|
||||
class="vue-notification-template vue-notification"
|
||||
:class="[
|
||||
item.type,
|
||||
]"
|
||||
@click="close()"
|
||||
>
|
||||
<div
|
||||
class="vue-notification-template vue-notification"
|
||||
:class="[
|
||||
item.type,
|
||||
]"
|
||||
@click="close()"
|
||||
v-if="item.title"
|
||||
class="notification-title"
|
||||
>
|
||||
<div
|
||||
v-if="item.title"
|
||||
class="notification-title"
|
||||
>
|
||||
{{ item.title }}
|
||||
</div>
|
||||
<div class="notification-content">
|
||||
<template v-if="Array.isArray(item.text)">
|
||||
<template
|
||||
v-for="(t, k) in item.text"
|
||||
:key="k"
|
||||
>
|
||||
{{ t }}<br>
|
||||
</template>
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ item.text }}
|
||||
</template>
|
||||
<span
|
||||
v-if="item.duplicates > 0"
|
||||
class="tw:text-xs tw:font-bold tw:ml-1"
|
||||
>
|
||||
×{{ item.duplicates + 1 }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="item.data?.actions?.length > 0"
|
||||
class="mbs-2 tw:flex tw:justify-end tw:gap-2"
|
||||
>
|
||||
<XButton
|
||||
v-for="(action, i) in item.data.actions"
|
||||
:key="'action_' + i"
|
||||
:shadow="false"
|
||||
class="is-small"
|
||||
variant="secondary"
|
||||
@click="action.callback"
|
||||
>
|
||||
{{ action.title }}
|
||||
</XButton>
|
||||
</div>
|
||||
{{ item.title }}
|
||||
</div>
|
||||
</template>
|
||||
</Notifications>
|
||||
</Teleport>
|
||||
<div class="notification-content">
|
||||
<template v-if="Array.isArray(item.text)">
|
||||
<template
|
||||
v-for="(t, k) in item.text"
|
||||
:key="k"
|
||||
>
|
||||
{{ t }}<br>
|
||||
</template>
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ item.text }}
|
||||
</template>
|
||||
<span
|
||||
v-if="item.duplicates > 0"
|
||||
class="tw:text-xs tw:font-bold tw:ml-1"
|
||||
>
|
||||
×{{ item.duplicates + 1 }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="item.data?.actions?.length > 0"
|
||||
class="mbs-2 tw:flex tw:justify-end tw:gap-2"
|
||||
>
|
||||
<XButton
|
||||
v-for="(action, i) in item.data.actions"
|
||||
:key="'action_' + i"
|
||||
:shadow="false"
|
||||
class="is-small"
|
||||
variant="secondary"
|
||||
@click="action.callback"
|
||||
>
|
||||
{{ action.title }}
|
||||
</XButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Notifications>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import {onBeforeUnmount, onMounted, ref} from 'vue'
|
||||
|
||||
const teleportTarget = ref<string | HTMLElement>('body')
|
||||
let observer: MutationObserver | null = null
|
||||
|
||||
function syncTeleportTarget() {
|
||||
const dialogs = document.querySelectorAll<HTMLDialogElement>('dialog.modal-dialog[open]')
|
||||
teleportTarget.value = dialogs.item(dialogs.length - 1) ?? 'body'
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
syncTeleportTarget()
|
||||
observer = new MutationObserver(syncTeleportTarget)
|
||||
observer.observe(document.body, {
|
||||
attributes: true,
|
||||
attributeFilter: ['open'],
|
||||
childList: true,
|
||||
subtree: true,
|
||||
})
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
observer?.disconnect()
|
||||
observer = null
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.vue-notification {
|
||||
z-index: 9999;
|
||||
|
|
|
|||
|
|
@ -4,39 +4,38 @@
|
|||
:current-page="currentPage"
|
||||
>
|
||||
<template #previous="{ disabled }">
|
||||
<PaginationItem
|
||||
variant="previous"
|
||||
<RouterLink
|
||||
:disabled="disabled || undefined"
|
||||
:to="getRouteForPagination(currentPage - 1)"
|
||||
:disabled="disabled"
|
||||
class="pagination-previous"
|
||||
>
|
||||
{{ $t('misc.previous') }}
|
||||
</PaginationItem>
|
||||
</RouterLink>
|
||||
</template>
|
||||
<template #next="{ disabled }">
|
||||
<PaginationItem
|
||||
variant="next"
|
||||
<RouterLink
|
||||
:disabled="disabled || undefined"
|
||||
:to="getRouteForPagination(currentPage + 1)"
|
||||
:disabled="disabled"
|
||||
class="pagination-next"
|
||||
>
|
||||
{{ $t('misc.next') }}
|
||||
</PaginationItem>
|
||||
</RouterLink>
|
||||
</template>
|
||||
<template #page-link="{ page, isCurrent }">
|
||||
<PaginationItem
|
||||
variant="link"
|
||||
:to="getRouteForPagination(page.number)"
|
||||
:is-current="isCurrent"
|
||||
<RouterLink
|
||||
class="pagination-link"
|
||||
:aria-label="'Goto page ' + page.number"
|
||||
:class="{ 'is-current': isCurrent }"
|
||||
:to="getRouteForPagination(page.number)"
|
||||
>
|
||||
{{ page.number }}
|
||||
</PaginationItem>
|
||||
</RouterLink>
|
||||
</template>
|
||||
</BasePagination>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import BasePagination from '@/components/base/BasePagination.vue'
|
||||
import PaginationItem from '@/components/misc/PaginationItem.vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
withDefaults(defineProps<{
|
||||
|
|
|
|||
|
|
@ -4,39 +4,39 @@
|
|||
:current-page="currentPage"
|
||||
>
|
||||
<template #previous="{ disabled }">
|
||||
<PaginationItem
|
||||
variant="previous"
|
||||
<BaseButton
|
||||
:disabled="disabled"
|
||||
class="pagination-previous"
|
||||
@click="changePage(currentPage - 1)"
|
||||
>
|
||||
{{ $t('misc.previous') }}
|
||||
</PaginationItem>
|
||||
</BaseButton>
|
||||
</template>
|
||||
<template #next="{ disabled }">
|
||||
<PaginationItem
|
||||
variant="next"
|
||||
<BaseButton
|
||||
:disabled="disabled"
|
||||
class="pagination-next"
|
||||
@click="changePage(currentPage + 1)"
|
||||
>
|
||||
{{ $t('misc.next') }}
|
||||
</PaginationItem>
|
||||
</BaseButton>
|
||||
</template>
|
||||
<template #page-link="{ page, isCurrent }">
|
||||
<PaginationItem
|
||||
variant="link"
|
||||
:is-current="isCurrent"
|
||||
<BaseButton
|
||||
class="pagination-link"
|
||||
:aria-label="'Goto page ' + page.number"
|
||||
:class="{ 'is-current': isCurrent }"
|
||||
@click="changePage(page.number)"
|
||||
>
|
||||
{{ page.number }}
|
||||
</PaginationItem>
|
||||
</BaseButton>
|
||||
</template>
|
||||
</BasePagination>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import BasePagination from '@/components/base/BasePagination.vue'
|
||||
import PaginationItem from '@/components/misc/PaginationItem.vue'
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
totalPages: number,
|
||||
|
|
|
|||
|
|
@ -1,156 +0,0 @@
|
|||
<template>
|
||||
<RouterLink
|
||||
v-if="to !== undefined"
|
||||
:to="to"
|
||||
:disabled="disabled || undefined"
|
||||
:class="[`pagination-${variant}`, {'is-current': isCurrent}]"
|
||||
>
|
||||
<slot />
|
||||
</RouterLink>
|
||||
<BaseButton
|
||||
v-else
|
||||
:disabled="disabled"
|
||||
:class="[`pagination-${variant}`, {'is-current': isCurrent}]"
|
||||
@click="emit('click')"
|
||||
>
|
||||
<slot />
|
||||
</BaseButton>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import type {RouteLocationRaw} from 'vue-router'
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
|
||||
withDefaults(defineProps<{
|
||||
variant: 'previous' | 'next' | 'link',
|
||||
isCurrent?: boolean,
|
||||
disabled?: boolean,
|
||||
to?: RouteLocationRaw,
|
||||
}>(), {
|
||||
isCurrent: false,
|
||||
disabled: false,
|
||||
to: undefined,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'click'): void,
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
// Rules ported from bulma-css-variables/sass/components/pagination.sass.
|
||||
// PaginationItem owns the .pagination-previous / .pagination-next /
|
||||
// .pagination-link markup, so scoped attributes attach directly to these
|
||||
// classes — no :deep() necessary.
|
||||
|
||||
.pagination-previous,
|
||||
.pagination-next,
|
||||
.pagination-link {
|
||||
appearance: none;
|
||||
align-items: center;
|
||||
border: 1px solid transparent;
|
||||
border-radius: $radius;
|
||||
box-shadow: none;
|
||||
display: inline-flex;
|
||||
font-size: 1em;
|
||||
block-size: 2.5em;
|
||||
justify-content: center;
|
||||
line-height: 1.5;
|
||||
margin: 0.25rem;
|
||||
padding: calc(0.5em - 1px) 0.5em;
|
||||
position: relative;
|
||||
text-align: center;
|
||||
vertical-align: top;
|
||||
|
||||
-webkit-touch-callout: none;
|
||||
user-select: none;
|
||||
|
||||
&:focus,
|
||||
&:active {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
&[disabled],
|
||||
fieldset[disabled] & {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
border-color: var(--border);
|
||||
color: var(--text-strong);
|
||||
min-inline-size: 2.5em;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--link-hover-border);
|
||||
color: var(--link-hover);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
border-color: var(--link-focus-border);
|
||||
}
|
||||
|
||||
&:active {
|
||||
box-shadow: inset 0 1px 2px rgba($scheme-invert, 0.2);
|
||||
}
|
||||
|
||||
&[disabled] {
|
||||
background-color: var(--border);
|
||||
border-color: var(--border);
|
||||
box-shadow: none;
|
||||
color: var(--text-light);
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
.pagination-previous,
|
||||
.pagination-next {
|
||||
padding-inline: 0.75em;
|
||||
white-space: nowrap;
|
||||
|
||||
&:not(:disabled):hover {
|
||||
background: $scheme-main;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.pagination-link.is-current {
|
||||
background-color: var(--link);
|
||||
border-color: var(--link);
|
||||
color: var(--link-invert);
|
||||
}
|
||||
|
||||
@media screen and (max-width: $tablet - 1px) {
|
||||
.pagination-previous,
|
||||
.pagination-next {
|
||||
flex-grow: 1;
|
||||
flex-shrink: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (min-width: $tablet), print {
|
||||
.pagination-previous,
|
||||
.pagination-next,
|
||||
.pagination-link {
|
||||
margin-block: 0;
|
||||
}
|
||||
|
||||
// BasePagination hardcodes `.is-centered`, so prev and next are flex-ordered
|
||||
// around the centered page list (prev left, list middle, next right).
|
||||
.pagination-previous {
|
||||
order: 1;
|
||||
}
|
||||
|
||||
.pagination-next {
|
||||
order: 3;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="scss">
|
||||
// Unscoped: this rule relies on ancestors (.app-container.has-background /
|
||||
// .link-share-container.has-background) that live outside PaginationItem.
|
||||
// Previously lived in styles/theme/background.scss, then BasePagination.vue.
|
||||
.app-container.has-background .pagination-link:not(.is-current),
|
||||
.link-share-container.has-background .pagination-link:not(.is-current) {
|
||||
background: var(--grey-100);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,126 +0,0 @@
|
|||
<template>
|
||||
<div class="content-widescreen">
|
||||
<div class="side-nav-shell">
|
||||
<nav class="navigation">
|
||||
<ul>
|
||||
<li
|
||||
v-for="(item, index) in navigationItems"
|
||||
:key="`nav-${index}`"
|
||||
>
|
||||
<RouterLink
|
||||
v-slot="{href, navigate, isActive, isExactActive}"
|
||||
:to="{name: item.routeName}"
|
||||
custom
|
||||
>
|
||||
<a
|
||||
:href="href"
|
||||
class="navigation-link"
|
||||
:class="{'is-active': (exact ? isExactActive : isActive) || isAliasActive(item)}"
|
||||
@click="navigate"
|
||||
>
|
||||
{{ item.title }}
|
||||
</a>
|
||||
</RouterLink>
|
||||
</li>
|
||||
<li
|
||||
v-for="({url, text}, index) in extraLinks"
|
||||
:key="`extra-${index}`"
|
||||
>
|
||||
<BaseButton
|
||||
class="navigation-link is-flex is-align-items-center"
|
||||
:href="url"
|
||||
>
|
||||
<span>
|
||||
{{ text }}
|
||||
</span>
|
||||
<span class="ml-1 has-text-grey-light is-size-7">
|
||||
<Icon
|
||||
icon="arrow-up-right-from-square"
|
||||
/>
|
||||
</span>
|
||||
</BaseButton>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
<section class="view">
|
||||
<RouterView />
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {useRoute} from 'vue-router'
|
||||
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
|
||||
export interface SideNavItem {
|
||||
title: string
|
||||
routeName: string
|
||||
activeRouteNames?: string[]
|
||||
}
|
||||
|
||||
export interface SideNavExtraLink {
|
||||
url: string
|
||||
text: string
|
||||
}
|
||||
|
||||
withDefaults(defineProps<{
|
||||
navigationItems: SideNavItem[]
|
||||
extraLinks?: SideNavExtraLink[]
|
||||
exact?: boolean
|
||||
}>(), {
|
||||
extraLinks: () => [],
|
||||
exact: false,
|
||||
})
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
function isAliasActive(item: SideNavItem) {
|
||||
return item.activeRouteNames?.includes(route.name as string) ?? false
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.side-nav-shell {
|
||||
display: flex;
|
||||
|
||||
@media screen and (max-width: $tablet) {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
.navigation {
|
||||
inline-size: 25%;
|
||||
padding-inline-end: 1rem;
|
||||
|
||||
@media screen and (max-width: $tablet) {
|
||||
inline-size: 100%;
|
||||
padding-inline-start: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.navigation-link {
|
||||
display: block;
|
||||
padding: .5rem;
|
||||
color: var(--text);
|
||||
inline-size: 100%;
|
||||
border-inline-start: 3px solid transparent;
|
||||
|
||||
&:hover,
|
||||
&.is-active {
|
||||
background: var(--white);
|
||||
border-color: var(--primary);
|
||||
}
|
||||
}
|
||||
|
||||
.view {
|
||||
inline-size: 75%;
|
||||
|
||||
@media screen and (max-width: $tablet) {
|
||||
inline-size: 100%;
|
||||
padding-inline-start: 0;
|
||||
padding-block-start: 1rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,29 +0,0 @@
|
|||
<template>
|
||||
<time
|
||||
v-if="date"
|
||||
v-tooltip="formatDateLong(date)"
|
||||
:datetime="formatISO(date)"
|
||||
>{{ displayText }}</time>
|
||||
<span v-else-if="fallback">{{ fallback }}</span>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {computed} from 'vue'
|
||||
import {formatDisplayDate, formatDateSince, formatDateLong, formatISO} from '@/helpers/time/formatDate'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
date: Date | string | null | undefined,
|
||||
mode?: 'short' | 'relative',
|
||||
fallback?: string,
|
||||
}>(), {
|
||||
mode: 'short',
|
||||
fallback: undefined,
|
||||
})
|
||||
|
||||
const displayText = computed(() => {
|
||||
if (!props.date) return ''
|
||||
return props.mode === 'relative'
|
||||
? formatDateSince(props.date)
|
||||
: formatDisplayDate(props.date)
|
||||
})
|
||||
</script>
|
||||
|
|
@ -2,24 +2,15 @@
|
|||
<div
|
||||
class="user"
|
||||
:class="{'is-inline': isInline}"
|
||||
:style="{'--avatar-size': `${avatarSize}px`}"
|
||||
>
|
||||
<span class="avatar-wrapper">
|
||||
<img
|
||||
v-tooltip="displayName"
|
||||
:height="avatarSize"
|
||||
:src="avatarSrc"
|
||||
:width="avatarSize"
|
||||
:alt="'Avatar of ' + displayName"
|
||||
class="avatar"
|
||||
>
|
||||
<span
|
||||
v-if="isBot"
|
||||
v-tooltip="t('user.settings.bots.badge')"
|
||||
class="bot-badge"
|
||||
aria-label="Bot"
|
||||
>B</span>
|
||||
</span>
|
||||
<img
|
||||
v-tooltip="displayName"
|
||||
:height="avatarSize"
|
||||
:src="avatarSrc"
|
||||
:width="avatarSize"
|
||||
:alt="'Avatar of ' + displayName"
|
||||
class="avatar"
|
||||
>
|
||||
<span
|
||||
v-if="showUsername"
|
||||
class="username"
|
||||
|
|
@ -29,7 +20,6 @@
|
|||
|
||||
<script lang="ts" setup>
|
||||
import {computed, ref, watch} from 'vue'
|
||||
import {useI18n} from 'vue-i18n'
|
||||
|
||||
import {fetchAvatarBlobUrl, getDisplayName} from '@/models/user'
|
||||
import type {IUser} from '@/modelTypes/IUser'
|
||||
|
|
@ -45,10 +35,7 @@ const props = withDefaults(defineProps<{
|
|||
isInline: false,
|
||||
})
|
||||
|
||||
const {t} = useI18n({useScope: 'global'})
|
||||
|
||||
const displayName = computed(() => getDisplayName(props.user))
|
||||
const isBot = computed(() => ((props.user as IUser & {botOwnerId?: number}).botOwnerId ?? 0) > 0)
|
||||
const avatarSrc = ref('')
|
||||
|
||||
async function loadAvatar() {
|
||||
|
|
@ -68,40 +55,9 @@ watch(() => [props.user, props.avatarSize], loadAvatar, { immediate: true })
|
|||
}
|
||||
}
|
||||
|
||||
.avatar-wrapper {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
margin-inline-end: .5rem;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
inline-size: var(--avatar-size);
|
||||
block-size: var(--avatar-size);
|
||||
border-radius: 100%;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.bot-badge {
|
||||
position: absolute;
|
||||
inset-block-end: 0;
|
||||
inset-inline-start: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
inline-size: 40%;
|
||||
block-size: 40%;
|
||||
min-inline-size: 14px;
|
||||
min-block-size: 14px;
|
||||
max-inline-size: 22px;
|
||||
max-block-size: 22px;
|
||||
font-size: .65rem;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
color: var(--white);
|
||||
background: var(--primary);
|
||||
border: 2px solid var(--white);
|
||||
border-radius: 100%;
|
||||
text-transform: uppercase;
|
||||
pointer-events: auto;
|
||||
margin-inline-end: .5rem;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ import WebhookModel from '@/models/webhook'
|
|||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
import FancyCheckbox from '@/components/input/FancyCheckbox.vue'
|
||||
import FormField from '@/components/input/FormField.vue'
|
||||
import FormInput from '@/components/input/FormInput.vue'
|
||||
import Expandable from '@/components/base/Expandable.vue'
|
||||
import User from '@/components/misc/User.vue'
|
||||
import {formatDateShort} from '@/helpers/time/formatDate'
|
||||
|
|
@ -117,20 +116,27 @@ function doDelete() {
|
|||
:error="webhookTargetUrlValid ? null : $t('project.webhooks.targetUrlInvalid')"
|
||||
@focusout="validateTargetUrl"
|
||||
/>
|
||||
<FormField :label="$t('project.webhooks.secret')">
|
||||
<template #default="{id}">
|
||||
<FormInput
|
||||
:id="id"
|
||||
<div class="field">
|
||||
<label
|
||||
class="label"
|
||||
for="secret"
|
||||
>
|
||||
{{ $t('project.webhooks.secret') }}
|
||||
</label>
|
||||
<div class="control">
|
||||
<input
|
||||
id="secret"
|
||||
v-model="newWebhook.secret"
|
||||
/>
|
||||
</template>
|
||||
</FormField>
|
||||
<p class="help">
|
||||
{{ $t('project.webhooks.secretHint') }}
|
||||
<BaseButton href="https://vikunja.io/docs/webhooks/">
|
||||
{{ $t('project.webhooks.secretDocs') }}
|
||||
</BaseButton>
|
||||
</p>
|
||||
class="input"
|
||||
>
|
||||
</div>
|
||||
<p class="help">
|
||||
{{ $t('project.webhooks.secretHint') }}
|
||||
<BaseButton href="https://vikunja.io/docs/webhooks/">
|
||||
{{ $t('project.webhooks.secretDocs') }}
|
||||
</BaseButton>
|
||||
</p>
|
||||
</div>
|
||||
<BaseButton
|
||||
class="mbe-2 has-text-primary"
|
||||
@click="showBasicAuth = !showBasicAuth"
|
||||
|
|
@ -141,22 +147,36 @@ function doDelete() {
|
|||
:open="showBasicAuth"
|
||||
class="content"
|
||||
>
|
||||
<FormField :label="$t('project.webhooks.basicauthuser')">
|
||||
<template #default="{id}">
|
||||
<FormInput
|
||||
:id="id"
|
||||
v-model="newWebhook.basicAuthUser"
|
||||
/>
|
||||
</template>
|
||||
</FormField>
|
||||
<FormField :label="$t('project.webhooks.basicauthpassword')">
|
||||
<template #default="{id}">
|
||||
<FormInput
|
||||
:id="id"
|
||||
v-model="newWebhook.basicAuthPassword"
|
||||
/>
|
||||
</template>
|
||||
</FormField>
|
||||
<div class="field">
|
||||
<label
|
||||
class="label"
|
||||
for="basicauthuser"
|
||||
>
|
||||
{{ $t('project.webhooks.basicauthuser') }}
|
||||
</label>
|
||||
<div class="control">
|
||||
<input
|
||||
id="basicauthuser"
|
||||
v-model="newWebhook.basicauthuser"
|
||||
class="input"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label
|
||||
class="label"
|
||||
for="basicauthpassword"
|
||||
>
|
||||
{{ $t('project.webhooks.basicauthpassword') }}
|
||||
</label>
|
||||
<div class="control">
|
||||
<input
|
||||
id="basicauthpassword"
|
||||
v-model="newWebhook.basicauthpassword"
|
||||
class="input"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</Expandable>
|
||||
<div class="field">
|
||||
<label
|
||||
|
|
|
|||
|
|
@ -24,18 +24,7 @@
|
|||
ref="popup"
|
||||
class="notifications-list"
|
||||
>
|
||||
<div class="head">
|
||||
<span>{{ $t('notification.title') }}</span>
|
||||
<BaseButton
|
||||
v-tooltip="$t('notification.subscribeFeed')"
|
||||
class="feed-link"
|
||||
:to="{name: 'user.settings.feeds'}"
|
||||
@click="showNotifications = false"
|
||||
>
|
||||
<span class="is-sr-only">{{ $t('notification.subscribeFeed') }}</span>
|
||||
<Icon icon="rss" />
|
||||
</BaseButton>
|
||||
</div>
|
||||
<span class="head">{{ $t('notification.title') }}</span>
|
||||
<div
|
||||
v-for="(n, index) in notifications"
|
||||
:key="n.id"
|
||||
|
|
@ -295,19 +284,6 @@ async function markAllRead() {
|
|||
font-family: $vikunja-font;
|
||||
font-size: 1rem;
|
||||
padding: .5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
||||
.feed-link {
|
||||
color: var(--grey-500);
|
||||
transition: color $transition;
|
||||
|
||||
&:hover,
|
||||
&:focus {
|
||||
color: var(--primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.single-notification {
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue