Compare commits

..

17 Commits

Author SHA1 Message Date
kolaente 692249fa04 fix(spike): remove broken TestDoCreate_HappyPath
The handler package has no TestMain to initialize DB/fixtures, so the
test panics on nil pointer deref when run via mage test:feature (which
walks every package) even though it was hidden when running only
mage test:web. DoCreate is already covered end-to-end by
TestHumaLabel_Create_ReadOne_via_OAS31Route in pkg/webtests.
2026-04-20 12:24:07 +02:00
kolaente 0b08131dad style(spike): fix lint issues introduced by Huma spike
License headers on new files, gofmt on routes.go import ordering,
unused imports in test files, testifylint assert.Contains/InDelta,
and infertypeargs cleanups.
2026-04-20 11:14:29 +02:00
kolaente 4b1202df17 test(spike): verify OAS 3.1 spec exposes label paths 2026-04-20 11:11:35 +02:00
kolaente 7bd561ded8 test(spike): Label round-trip through Huma OAS 3.1 routes 2026-04-20 11:05:39 +02:00
kolaente 898ca26627 fix(spike): mount Huma under /oas3 and translate Vikunja errors
Three follow-ups to Task E2 needed to make the routes functional end to end:

- Echo v5 panics on duplicate (method, path) registrations, so the
  Huma-backed label routes live under /api/v1/oas3/labels for the spike.
  The legacy /api/v1/labels endpoints remain unchanged.
- Huma's built-in /openapi.{json,yaml,docs} routes are disabled. The spec
  is re-exposed via a handler on the unauth group so clients and tooling
  can fetch it without a bearer token, matching the /docs.json treatment.
- Errors returned from the shared handler.Do* pipeline (echo.HTTPError,
  web.HTTPErrorProcessor) are translated into Vikunja-shaped
  huma.StatusErrors, preserving the legacy {code, message} body contract
  instead of Huma's default "unexpected error occurred" wrap.

Also sets humaConfig.FieldsOptionalByDefault=true so PUT/POST bodies
don't need to include derived fields like created/updated/created_by.
2026-04-20 11:05:36 +02:00
kolaente aef4cc3f8a feat(spike): register Label via Huma alongside legacy routes 2026-04-20 10:57:35 +02:00
kolaente 75a9ad4555 feat(huma): generic CRUD registrar for CObject resources 2026-04-20 10:56:33 +02:00
kolaente 5ba404ac58 refactor(handler): extract DoDelete from DeleteWeb 2026-04-20 10:52:08 +02:00
kolaente d677e40597 refactor(handler): extract DoUpdate from UpdateWeb 2026-04-20 10:50:51 +02:00
kolaente be988d47e2 refactor(handler): extract DoReadAll from ReadAllWeb 2026-04-20 10:50:02 +02:00
kolaente 12feb63e4c refactor(handler): extract DoReadOne from ReadOneWeb 2026-04-20 10:48:56 +02:00
kolaente 19fa92febf refactor(handler): extract DoCreate from CreateWeb 2026-04-20 10:48:03 +02:00
kolaente 00d394ed9f feat(huma): error formatter matching legacy Vikunja JSON shape 2026-04-20 10:43:42 +02:00
kolaente abc0cdfc6a feat(auth): add GetAuthFromContext for Huma handlers 2026-04-20 10:43:15 +02:00
kolaente 1d6c1cf838 test: humaecho5 adapter roundtrip and spec serving 2026-04-20 10:41:45 +02:00
kolaente 35f9c4b684 feat: vendor humaecho adapter for echo/v5 2026-04-20 10:41:25 +02:00
kolaente 37adc38df7 chore: add github.com/danielgtaylor/huma/v2 dependency 2026-04-20 10:39:18 +02:00
750 changed files with 11548 additions and 109084 deletions

View File

@ -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

View File

@ -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.

View File

@ -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`).

3
.envrc Normal file
View File

@ -0,0 +1,3 @@
source_url "https://raw.githubusercontent.com/cachix/devenv/95f329d49a8a5289d31e0982652f7058a189bfca/direnvrc" "sha256-d+8cBpDfDBj41inrADaJt+bDWhOktwslgoP5YiGJ1v0="
use devenv

View File

@ -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

View File

@ -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/*

View File

@ -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 }}/*

View File

@ -16,11 +16,11 @@ runs:
echo "CYPRESS_INSTALL_BINARY=0" >> $GITHUB_ENV echo "CYPRESS_INSTALL_BINARY=0" >> $GITHUB_ENV
echo "PUPPETEER_SKIP_DOWNLOAD=true" >> $GITHUB_ENV echo "PUPPETEER_SKIP_DOWNLOAD=true" >> $GITHUB_ENV
echo "PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1" >> $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: with:
run_install: false run_install: false
package_json_file: frontend/package.json package_json_file: frontend/package.json
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 - uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6
with: with:
node-version-file: frontend/.nvmrc node-version-file: frontend/.nvmrc
cache: 'pnpm' cache: 'pnpm'

View File

@ -21,7 +21,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout (for prompt template) - name: Checkout (for prompt template)
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with: with:
sparse-checkout: | sparse-checkout: |
.github/workflows/auto-label.prompt.md .github/workflows/auto-label.prompt.md
@ -29,7 +29,7 @@ jobs:
- name: Render system prompt from live labels - name: Render system prompt from live labels
id: render id: render
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
env: env:
PROMPT_TEMPLATE_PATH: .github/workflows/auto-label.prompt.md PROMPT_TEMPLATE_PATH: .github/workflows/auto-label.prompt.md
with: with:
@ -122,7 +122,7 @@ jobs:
- name: Classify with AI - name: Classify with AI
id: classify id: classify
uses: actions/ai-inference@a7805884c80886efc241e94a5351df715968a0ad # v2.1.1 uses: actions/ai-inference@e09e65981758de8b2fdab13c2bfb7c7d5493b0b6 # v2.0.7
with: with:
model: openai/gpt-4.1-mini model: openai/gpt-4.1-mini
# GPT-5 is a reasoning model: output tokens include reasoning, so budget generously. # GPT-5 is a reasoning model: output tokens include reasoning, so budget generously.
@ -132,7 +132,7 @@ jobs:
prompt-file: ${{ steps.prep.outputs.prompt_path }} prompt-file: ${{ steps.prep.outputs.prompt_path }}
- name: Apply labels - name: Apply labels
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
env: env:
AI_RESPONSE: ${{ steps.classify.outputs.response }} AI_RESPONSE: ${{ steps.classify.outputs.response }}
with: with:

View File

@ -9,19 +9,19 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
with: with:
ssh-key: ${{ secrets.SSH_PRIVATE_KEY }} ssh-key: ${{ secrets.SSH_PRIVATE_KEY }}
persist-credentials: true persist-credentials: true
- name: push source files - name: push source files
uses: crowdin/github-action@52aa776766211d83d975df51f3b9c53c2f8ba35f # v2.16.3 uses: crowdin/github-action@b4b468cffefb50bdd99dd83e5d2eaeb63c880380 # v2
with: with:
command: 'push' command: 'push'
env: env:
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }} CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }} CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
- name: pull translations - name: pull translations
uses: crowdin/github-action@52aa776766211d83d975df51f3b9c53c2f8ba35f # v2.16.3 uses: crowdin/github-action@b4b468cffefb50bdd99dd83e5d2eaeb63c880380 # v2
with: with:
command: 'download' command: 'download'
command_args: '--export-only-approved --skip-untranslated-strings' command_args: '--export-only-approved --skip-untranslated-strings'
@ -29,7 +29,7 @@ jobs:
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }} CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }} CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6
with: with:
node-version-file: frontend/.nvmrc node-version-file: frontend/.nvmrc
- name: Ensure file permissions - name: Ensure file permissions
@ -41,7 +41,7 @@ jobs:
- name: Check for changes - name: Check for changes
id: check_changes id: check_changes
run: | 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" echo "changes_exist=0" >> "$GITHUB_OUTPUT"
else else
echo "changes_exist=1" >> "$GITHUB_OUTPUT" echo "changes_exist=1" >> "$GITHUB_OUTPUT"
@ -51,11 +51,10 @@ jobs:
run: | run: |
git config --local user.email "bot@vikunja.io" git config --local user.email "bot@vikunja.io"
git config --local user.name "Frederick [Bot]" git config --local user.name "Frederick [Bot]"
git add pkg/i18n/lang frontend/src/i18n/lang git commit -am "chore(i18n): update translations via Crowdin"
git commit -m "chore(i18n): update translations via Crowdin"
- name: Push changes - name: Push changes
if: steps.check_changes.outputs.changes_exist != '0' if: steps.check_changes.outputs.changes_exist != '0'
uses: ad-m/github-push-action@881a6320fdb16eb5318c5054f31c218aec2b324c # master uses: ad-m/github-push-action@master
with: with:
ssh: true ssh: true
branch: ${{ github.ref }} branch: ${{ github.ref }}

View File

@ -18,11 +18,11 @@ jobs:
directory: [frontend, desktop] directory: [frontend, desktop]
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 uses: actions/checkout@v4
with: with:
fetch-depth: 0 fetch-depth: 0
- name: Create Diff - name: Create Diff
uses: e18e/action-dependency-diff@8e9b8c1957ab066d36235a43f4c1ff1522e1bdbc # v1.6.1 uses: e18e/action-dependency-diff@v1
with: with:
working-directory: ${{ matrix.directory }} working-directory: ${{ matrix.directory }}
@ -33,11 +33,11 @@ jobs:
directory: [frontend, desktop] directory: [frontend, desktop]
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 uses: actions/checkout@v4
with: with:
fetch-depth: 0 fetch-depth: 0
- name: Check provenance downgrades - name: Check provenance downgrades
uses: danielroe/provenance-action@81568f71211c1839d6d3583c6a93037f5348c816 # main uses: danielroe/provenance-action@main
with: with:
workspace-path: ${{ matrix.directory }} workspace-path: ${{ matrix.directory }}
fail-on-provenance-change: true fail-on-provenance-change: true

View File

@ -10,14 +10,14 @@ jobs:
steps: steps:
- name: Generate GitHub App token - name: Generate GitHub App token
id: generate-token id: generate-token
uses: actions/create-github-app-token@fee1f7d63c2ff003460e3d139729b119787bc349 # v2.2.2 uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2
with: with:
app-id: ${{ secrets.BOT_APP_ID }} app-id: ${{ secrets.BOT_APP_ID }}
private-key: ${{ secrets.BOT_APP_PRIVATE_KEY }} private-key: ${{ secrets.BOT_APP_PRIVATE_KEY }}
- name: Find closing PR or commit - name: Find closing PR or commit
id: find-closer id: find-closer
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
with: with:
github-token: ${{ steps.generate-token.outputs.token }} github-token: ${{ steps.generate-token.outputs.token }}
script: | script: |
@ -82,7 +82,7 @@ jobs:
- name: Comment on issue - name: Comment on issue
if: steps.find-closer.outputs.closed_by_code == 'true' if: steps.find-closer.outputs.closed_by_code == 'true'
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
with: with:
github-token: ${{ steps.generate-token.outputs.token }} github-token: ${{ steps.generate-token.outputs.token }}
script: | script: |

View File

@ -25,7 +25,7 @@ jobs:
docker-images: false docker-images: false
swap-storage: false swap-storage: false
- name: Checkout - name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
with: with:
# For pull_request_target, we need to explicitly fetch the PR ref from forks # 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. # 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 ref: refs/pull/${{ github.event.pull_request.number }}/head
- name: Git describe - name: Git describe
id: ghd id: ghd
uses: proudust/gh-describe@80412be8ce0e77d8afba6b340e34790bc772aa45 # v2.2.0 uses: proudust/gh-describe@v2
- name: Login to GHCR - name: Login to GHCR
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.repository_owner }} username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
with: with:
version: latest version: latest
- name: Docker meta - name: Docker meta
id: meta id: meta
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0 uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5
with: with:
images: ghcr.io/${{ github.repository_owner }}/${{ github.event.repository.name }} images: ghcr.io/${{ github.repository_owner }}/${{ github.event.repository.name }}
tags: | tags: |
type=ref,event=pr type=ref,event=pr
type=sha,format=long type=sha,format=long
- name: Build and push PR image - name: Build and push PR image
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2 uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6
with: with:
context: . context: .
platforms: linux/amd64 platforms: linux/amd64
@ -66,7 +66,7 @@ jobs:
build-args: | build-args: |
RELEASE_VERSION=${{ steps.ghd.outputs.describe }} RELEASE_VERSION=${{ steps.ghd.outputs.describe }}
- name: Comment on PR - name: Comment on PR
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
env: env:
DOCKER_META_TAGS: ${{ steps.meta.outputs.tags }} DOCKER_META_TAGS: ${{ steps.meta.outputs.tags }}
with: with:

View File

@ -4,53 +4,19 @@ on:
workflow_call: workflow_call:
jobs: 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: docker:
runs-on: namespace-profile-default runs-on: namespace-profile-default
steps: steps:
- name: Git describe - name: Git describe
id: ghd id: ghd
uses: proudust/gh-describe@80412be8ce0e77d8afba6b340e34790bc772aa45 # v2.2.0 uses: proudust/gh-describe@v2
- name: Login to Docker Hub - name: Login to Docker Hub
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3
with: with:
username: ${{ secrets.DOCKER_HUB_USERNAME }} username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_PASSWORD }} password: ${{ secrets.DOCKER_HUB_PASSWORD }}
- name: Login to GHCR - name: Login to GHCR
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.repository_owner }} username: ${{ github.repository_owner }}
@ -58,7 +24,7 @@ jobs:
- name: Docker meta version - name: Docker meta version
if: ${{ github.ref_type == 'tag' }} if: ${{ github.ref_type == 'tag' }}
id: meta id: meta
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0 uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5
with: with:
images: | images: |
vikunja/vikunja vikunja/vikunja
@ -70,7 +36,7 @@ jobs:
type=raw,value=latest type=raw,value=latest
- name: Build and push unstable - name: Build and push unstable
if: ${{ github.ref_type != 'tag' }} if: ${{ github.ref_type != 'tag' }}
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2 uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6
with: with:
platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64/v8 platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64/v8
push: true push: true
@ -81,7 +47,7 @@ jobs:
RELEASE_VERSION=${{ steps.ghd.outputs.describe }} RELEASE_VERSION=${{ steps.ghd.outputs.describe }}
- name: Build and push version - name: Build and push version
if: ${{ github.ref_type == 'tag' }} if: ${{ github.ref_type == 'tag' }}
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2 uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6
with: with:
platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64/v8 platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64/v8
push: true push: true
@ -93,40 +59,87 @@ jobs:
binaries: binaries:
runs-on: blacksmith-8vcpu-ubuntu-2204 runs-on: blacksmith-8vcpu-ubuntu-2204
steps: steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- name: Git describe - name: Git describe
id: ghd id: ghd
uses: proudust/gh-describe@80412be8ce0e77d8afba6b340e34790bc772aa45 # v2.2.0 uses: proudust/gh-describe@v2
- uses: ./.github/actions/release-binaries - 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@main
with: 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-access-key-id: ${{ secrets.S3_ACCESS_KEY }}
s3-secret-access-key: ${{ secrets.S3_SECRET_KEY }} s3-secret-access-key: ${{ secrets.S3_SECRET_KEY }}
s3-endpoint: ${{ secrets.S3_ENDPOINT }} s3-endpoint: ${{ secrets.S3_ENDPOINT }}
s3-bucket: ${{ secrets.S3_BUCKET }} s3-bucket: ${{ secrets.S3_BUCKET }}
s3-region: ${{ secrets.S3_REGION }} s3-region: ${{ secrets.S3_REGION }}
target-path: /vikunja/${{ github.ref_type == 'tag' && steps.ghd.outputs.describe || 'unstable' }}
veans-binaries: files: "dist/zip/*"
runs-on: blacksmith-8vcpu-ubuntu-2204 strip-path-prefix: dist/zip/
steps: - name: Store Binaries
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
- name: Git describe
id: ghd
uses: proudust/gh-describe@80412be8ce0e77d8afba6b340e34790bc772aa45 # v2.2.0
- uses: ./.github/actions/release-binaries
with: with:
project: veans name: vikunja_bins
release-version: ${{ steps.ghd.outputs.describe }} path: ./dist/binaries/*
gpg-passphrase: ${{ secrets.RELEASE_GPG_PASSPHRASE }} - name: Store Binary Packages
gpg-sign-key: ${{ secrets.RELEASE_GPG_SIGN_KEY }} uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
s3-access-key-id: ${{ secrets.S3_ACCESS_KEY }} if: ${{ github.ref_type == 'tag' }}
s3-secret-access-key: ${{ secrets.S3_SECRET_KEY }} with:
s3-endpoint: ${{ secrets.S3_ENDPOINT }} name: vikunja_bin_packages
s3-bucket: ${{ secrets.S3_BUCKET }} path: ./dist/zip/*
s3-region: ${{ secrets.S3_REGION }}
os-package: os-package:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@ -134,7 +147,11 @@ jobs:
- binaries - binaries
strategy: strategy:
matrix: matrix:
package: [rpm, deb, apk, archlinux] package:
- rpm
- deb
- apk
- archlinux
arch: arch:
- go_name: linux-amd64 - go_name: linux-amd64
nfpm: amd64 nfpm: amd64
@ -147,71 +164,77 @@ jobs:
pkg: armv7 pkg: armv7
steps: 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
- name: Git describe - name: Git describe
id: ghd id: ghd
uses: proudust/gh-describe@80412be8ce0e77d8afba6b340e34790bc772aa45 # v2.2.0 uses: proudust/gh-describe@v2
- uses: ./.github/actions/release-os-package - name: Download Mage Binary
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
with:
name: mage_bin
- name: Write GPG key for nfpm
if: matrix.package == 'rpm'
run: echo -n "${{ secrets.RELEASE_GPG_SIGN_KEY }}" > /tmp/nfpm-signing-key.gpg
- name: GPG setup for package signing
if: matrix.package == 'archlinux'
uses: kolaente/action-gpg@main
with:
gpg-passphrase: "${{ secrets.RELEASE_GPG_PASSPHRASE }}"
gpg-sign-key: "${{ secrets.RELEASE_GPG_SIGN_KEY }}"
- name: Prepare
env:
RELEASE_VERSION: ${{ steps.ghd.outputs.describe }}
NFPM_ARCH: ${{ matrix.arch.nfpm }}
run: |
chmod +x ./mage-static
./mage-static release:prepare-nfpm-config
mkdir -p ./dist/os-packages
mv ./vikunja-*-${{ matrix.arch.go_name }} ./vikunja
chmod +x ./vikunja
- name: Create package
id: nfpm
uses: kolaente/action-gh-nfpm@master
with: with:
project: vikunja
release-version: ${{ steps.ghd.outputs.describe }}
packager: ${{ matrix.package }} packager: ${{ matrix.package }}
nfpm-arch: ${{ matrix.arch.nfpm }} target: ./dist/os-packages/vikunja-${{ github.ref_type == 'tag' && steps.ghd.outputs.describe || 'unstable' }}-${{ matrix.arch.pkg }}.${{ matrix.package }}
pkg-arch: ${{ matrix.arch.pkg }} config: ./nfpm.yaml
go-name: ${{ matrix.arch.go_name }} env:
gpg-passphrase: ${{ secrets.RELEASE_GPG_PASSPHRASE }} NFPM_GPG_KEY_FILE: ${{ (matrix.package == 'rpm') && '/tmp/nfpm-signing-key.gpg' || '' }}
gpg-sign-key: ${{ secrets.RELEASE_GPG_SIGN_KEY }} NFPM_PASSPHRASE: ${{ (matrix.package == 'rpm') && secrets.RELEASE_GPG_PASSPHRASE || '' }}
- name: Sign package
if: matrix.package == 'archlinux'
run: |
gpg --default-key 7D061A4AA61436B40713D42EFF054DACD908493A \
--batch --yes \
--passphrase "${{ secrets.RELEASE_GPG_PASSPHRASE }}" \
--pinentry-mode loopback \
--detach-sign \
./dist/os-packages/vikunja-${{ github.ref_type == 'tag' && steps.ghd.outputs.describe || 'unstable' }}-${{ matrix.arch.pkg }}.${{ matrix.package }}
- name: Upload
uses: kolaente/s3-action@main
with:
s3-access-key-id: ${{ secrets.S3_ACCESS_KEY }} s3-access-key-id: ${{ secrets.S3_ACCESS_KEY }}
s3-secret-access-key: ${{ secrets.S3_SECRET_KEY }} s3-secret-access-key: ${{ secrets.S3_SECRET_KEY }}
s3-endpoint: ${{ secrets.S3_ENDPOINT }} s3-endpoint: ${{ secrets.S3_ENDPOINT }}
s3-bucket: ${{ secrets.S3_BUCKET }} s3-bucket: ${{ secrets.S3_BUCKET }}
s3-region: ${{ secrets.S3_REGION }} s3-region: ${{ secrets.S3_REGION }}
target-path: /vikunja/${{ github.ref_type == 'tag' && steps.ghd.outputs.describe || 'unstable' }}
veans-os-package: files: "dist/os-packages/*"
runs-on: ubuntu-latest strip-path-prefix: dist/os-packages/
needs: - name: Store OS Packages
- veans-binaries uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
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: with:
project: veans name: vikunja_os_package_${{ matrix.package }}_${{ matrix.arch.pkg }}
release-version: ${{ steps.ghd.outputs.describe }} path: ./dist/os-packages/*
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: publish-repos:
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: needs:
- build-mage
- os-package - os-package
- veans-os-package
- desktop - desktop
strategy: strategy:
fail-fast: false fail-fast: false
@ -235,36 +258,22 @@ jobs:
REPO_SUITE: ${{ github.ref_type == 'tag' && 'stable' || 'unstable' }} REPO_SUITE: ${{ github.ref_type == 'tag' && 'stable' || 'unstable' }}
RELEASE_VERSION: unstable RELEASE_VERSION: unstable
steps: steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- name: Download build mage binary - name: Download Mage Binary
# Statically compiled in test.yml's build-mage job so it runs inside uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
# ubuntu/fedora/archlinux containers without a Go toolchain.
if: matrix.format != 'apk'
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
with: with:
name: build_mage_bin name: mage_bin
path: build
- name: Download all server OS packages - name: Download all server OS packages
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
with: with:
pattern: vikunja_os_package_* pattern: vikunja_os_package_*
merge-multiple: true merge-multiple: true
path: dist/repo-work/incoming 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) - name: Download desktop packages (Linux)
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
with: with:
name: vikunja_desktop_packages_ubuntu-latest name: vikunja_desktop_packages_ubuntu-latest
path: dist/repo-work/incoming-desktop path: dist/repo-work/incoming-desktop
@ -309,7 +318,7 @@ jobs:
- name: GPG setup - name: GPG setup
if: matrix.format != 'apk' if: matrix.format != 'apk'
uses: kolaente/action-gpg@eb0fd8f16fe9b499f060f659092c470cb9f76eb7 # main uses: kolaente/action-gpg@main
with: with:
gpg-passphrase: "${{ secrets.RELEASE_GPG_PASSPHRASE }}" gpg-passphrase: "${{ secrets.RELEASE_GPG_PASSPHRASE }}"
gpg-sign-key: "${{ secrets.RELEASE_GPG_SIGN_KEY }}" gpg-sign-key: "${{ secrets.RELEASE_GPG_SIGN_KEY }}"
@ -329,13 +338,12 @@ jobs:
- name: Generate repo metadata - name: Generate repo metadata
if: matrix.format != 'apk' if: matrix.format != 'apk'
working-directory: build
env: env:
RELEASE_GPG_KEY: 7D061A4AA61436B40713D42EFF054DACD908493A RELEASE_GPG_KEY: 7D061A4AA61436B40713D42EFF054DACD908493A
RELEASE_GPG_PASSPHRASE: ${{ secrets.RELEASE_GPG_PASSPHRASE }} RELEASE_GPG_PASSPHRASE: ${{ secrets.RELEASE_GPG_PASSPHRASE }}
run: | run: |
chmod +x ./build-mage-static chmod +x ./mage-static
./build-mage-static ${{ matrix.mage_target }} ./mage-static ${{ matrix.mage_target }}
- name: Generate APK repo metadata - name: Generate APK repo metadata
if: matrix.format == 'apk' if: matrix.format == 'apk'
@ -384,7 +392,7 @@ jobs:
find dist/repo-output -type d -empty -delete 2>/dev/null || true find dist/repo-output -type d -empty -delete 2>/dev/null || true
- name: Upload to R2 - name: Upload to R2
uses: kolaente/s3-action@7f58dddd682b2f93a6c6799c9f68e7a38f2da558 # main uses: kolaente/s3-action@main
with: with:
s3-access-key-id: ${{ secrets.S3_ACCESS_KEY }} s3-access-key-id: ${{ secrets.S3_ACCESS_KEY }}
s3-secret-access-key: ${{ secrets.S3_SECRET_KEY }} s3-secret-access-key: ${{ secrets.S3_SECRET_KEY }}
@ -398,12 +406,12 @@ jobs:
config-yaml: config-yaml:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- name: Git describe - name: Git describe
id: ghd id: ghd
uses: proudust/gh-describe@80412be8ce0e77d8afba6b340e34790bc772aa45 # v2.2.0 uses: proudust/gh-describe@v2
- name: Download Mage Binary - name: Download Mage Binary
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
with: with:
name: mage_bin name: mage_bin
- name: generate - name: generate
@ -411,7 +419,7 @@ jobs:
chmod +x ./mage-static chmod +x ./mage-static
./mage-static generate:config-yaml 1 ./mage-static generate:config-yaml 1
- name: Upload to S3 - name: Upload to S3
uses: kolaente/s3-action@7f58dddd682b2f93a6c6799c9f68e7a38f2da558 # main uses: kolaente/s3-action@main
with: with:
s3-access-key-id: ${{ secrets.S3_ACCESS_KEY }} s3-access-key-id: ${{ secrets.S3_ACCESS_KEY }}
s3-secret-access-key: ${{ secrets.S3_SECRET_KEY }} s3-secret-access-key: ${{ secrets.S3_SECRET_KEY }}
@ -431,16 +439,16 @@ jobs:
runs-on: ${{ matrix.os }} runs-on: ${{ matrix.os }}
steps: steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- name: Git describe - name: Git describe
id: ghd id: ghd
uses: proudust/gh-describe@80412be8ce0e77d8afba6b340e34790bc772aa45 # v2.2.0 uses: proudust/gh-describe@v2
- name: Install pnpm - name: Install pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0 uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4
with: with:
package_json_file: desktop/package.json package_json_file: desktop/package.json
- name: Setup Node - name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6
with: with:
node-version-file: frontend/.nvmrc node-version-file: frontend/.nvmrc
cache: pnpm cache: pnpm
@ -451,7 +459,7 @@ jobs:
sudo apt-get update sudo apt-get update
sudo apt-get install --no-install-recommends -y libopenjp2-tools rpm libarchive-tools sudo apt-get install --no-install-recommends -y libopenjp2-tools rpm libarchive-tools
- name: get frontend - name: get frontend
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
with: with:
name: frontend_dist name: frontend_dist
path: frontend/dist path: frontend/dist
@ -461,7 +469,7 @@ jobs:
pnpm install --frozen-lockfile --prefer-offline --fetch-timeout 100000 pnpm install --frozen-lockfile --prefer-offline --fetch-timeout 100000
node build.js "${{ steps.ghd.outputs.describe }}" ${{ github.ref_type == 'tag' }} node build.js "${{ steps.ghd.outputs.describe }}" ${{ github.ref_type == 'tag' }}
- name: Upload to S3 - name: Upload to S3
uses: kolaente/s3-action@7f58dddd682b2f93a6c6799c9f68e7a38f2da558 # main uses: kolaente/s3-action@main
with: with:
s3-access-key-id: ${{ secrets.S3_ACCESS_KEY }} s3-access-key-id: ${{ secrets.S3_ACCESS_KEY }}
s3-secret-access-key: ${{ secrets.S3_SECRET_KEY }} s3-secret-access-key: ${{ secrets.S3_SECRET_KEY }}
@ -473,7 +481,7 @@ jobs:
strip-path-prefix: desktop/dist/ strip-path-prefix: desktop/dist/
exclude: "desktop/dist/*.blockmap" exclude: "desktop/dist/*.blockmap"
- name: Store Desktop Package - name: Store Desktop Package
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
with: with:
name: vikunja_desktop_packages_${{ matrix.os }} name: vikunja_desktop_packages_${{ matrix.os }}
path: | path: |
@ -486,16 +494,16 @@ jobs:
contents: write contents: write
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
with: with:
ssh-key: ${{ secrets.SSH_PRIVATE_KEY }} ssh-key: ${{ secrets.SSH_PRIVATE_KEY }}
persist-credentials: true persist-credentials: true
- name: Download Mage Binary - name: Download Mage Binary
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
with: with:
name: mage_bin name: mage_bin
- name: Set up Go - name: Set up Go
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6
with: with:
go-version: stable go-version: stable
- name: generate - name: generate
@ -520,7 +528,7 @@ jobs:
git commit -am "[skip ci] Updated swagger docs" git commit -am "[skip ci] Updated swagger docs"
- name: Push changes - name: Push changes
if: steps.check_changes.outputs.changes_exist != '0' if: steps.check_changes.outputs.changes_exist != '0'
uses: ad-m/github-push-action@881a6320fdb16eb5318c5054f31c218aec2b324c # master uses: ad-m/github-push-action@master
with: with:
ssh: true ssh: true
branch: ${{ github.ref }} branch: ${{ github.ref }}
@ -530,8 +538,6 @@ jobs:
needs: needs:
- binaries - binaries
- os-package - os-package
- veans-binaries
- veans-os-package
- desktop - desktop
- publish-repos - publish-repos
if: ${{ github.ref_type == 'tag' }} if: ${{ github.ref_type == 'tag' }}
@ -539,44 +545,33 @@ jobs:
contents: write contents: write
steps: steps:
- name: Download Binaries - name: Download Binaries
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
with: with:
name: vikunja_bin_packages name: vikunja_bin_packages
- name: Download OS Packages - name: Download OS Packages
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
with: with:
pattern: vikunja_os_package_* pattern: vikunja_os_package_*
merge-multiple: true merge-multiple: true
- name: Download Veans Binaries
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
with:
name: veans_bin_packages
- name: Download Veans OS Packages
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
with:
pattern: veans_os_package_*
merge-multiple: true
- name: Download Desktop Package Linux - name: Download Desktop Package Linux
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
with: with:
name: vikunja_desktop_packages_ubuntu-latest name: vikunja_desktop_packages_ubuntu-latest
- name: Download Desktop Package MacOS - name: Download Desktop Package MacOS
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
with: with:
name: vikunja_desktop_packages_macos-latest name: vikunja_desktop_packages_macos-latest
- name: Download Desktop Package Windows - name: Download Desktop Package Windows
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
with: with:
name: vikunja_desktop_packages_windows-latest name: vikunja_desktop_packages_windows-latest
- name: Release - name: Release
uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2.6.2 uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2
if: github.ref_type == 'tag' if: github.ref_type == 'tag'
with: with:
draft: true draft: true
@ -586,9 +581,4 @@ jobs:
vikunja*.deb vikunja*.deb
vikunja*.apk vikunja*.apk
vikunja*.archlinux vikunja*.archlinux
veans*.zip
veans*.rpm
veans*.deb
veans*.apk
veans*.archlinux
Vikunja Desktop* Vikunja Desktop*

View File

@ -12,19 +12,18 @@ jobs:
stale: stale:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/stale@5bef64f19d7facfb25b37b414482c7164d639639 # v9.1.0 - uses: actions/stale@v9
with: with:
only-labels: 'waiting for reply' only-labels: 'waiting for reply'
days-before-issue-stale: 30 days-before-issue-stale: 30
days-before-issue-close: 30 days-before-issue-close: 30
stale-issue-label: 'waiting for reply' stale-issue-label: 'waiting for reply'
remove-stale-when-updated: true remove-stale-when-updated: false
close-issue-message: > close-issue-message: >
Closing this for now since we haven't heard back on the follow-up 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 questions. If you're still seeing this on a recent version, just
drop a comment with the requested info and we'll reopen. Thanks drop a comment with the requested info and we'll reopen. Thanks
for the report! for the report!
stale-pr-label: 'waiting for reply' days-before-pr-stale: -1
days-before-pr-stale: 30
days-before-pr-close: -1 days-before-pr-close: -1
operations-per-run: 100 operations-per-run: 100

View File

@ -8,26 +8,26 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
name: prepare-mage name: prepare-mage
steps: steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- name: Set up Go - name: Set up Go
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6
with: with:
go-version: stable go-version: stable
- name: Cache Mage - name: Cache Mage
id: cache-mage id: cache-mage
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5
with: with:
key: ${{ runner.os }}-build-mage-${{ hashFiles('magefile.go') }} key: ${{ runner.os }}-build-mage-${{ hashFiles('magefile.go') }}
path: | path: |
./mage-static ./mage-static
- name: Compile Mage - name: Compile Mage
if: ${{ steps.cache-mage.outputs.cache-hit != 'true' }} if: ${{ steps.cache-mage.outputs.cache-hit != 'true' }}
uses: magefile/mage-action@6f50bbb8ea47d56e62dee92392788acbc8192d0b # v3.1.0 uses: magefile/mage-action@6f50bbb8ea47d56e62dee92392788acbc8192d0b # v3
with: with:
version: latest version: latest
args: -compile ./mage-static args: -compile ./mage-static
- name: Store Mage Binary - name: Store Mage Binary
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
with: with:
name: mage_bin name: mage_bin
path: ./mage-static path: ./mage-static
@ -36,16 +36,16 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: mage needs: mage
steps: steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- name: Download Mage Binary - name: Download Mage Binary
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
with: with:
name: mage_bin name: mage_bin
- name: Git describe - name: Git describe
id: ghd id: ghd
uses: proudust/gh-describe@80412be8ce0e77d8afba6b340e34790bc772aa45 # v2.2.0 uses: proudust/gh-describe@v2
- name: Set up Go - name: Set up Go
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6
with: with:
go-version: stable go-version: stable
- name: Build - name: Build
@ -57,7 +57,7 @@ jobs:
chmod +x ./mage-static chmod +x ./mage-static
./mage-static build ./mage-static build
- name: Store Vikunja Binary - name: Store Vikunja Binary
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
with: with:
name: vikunja_bin name: vikunja_bin
path: ./vikunja path: ./vikunja
@ -65,8 +65,8 @@ jobs:
api-lint: api-lint:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 - uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6
with: with:
go-version: stable go-version: stable
- name: prepare frontend files - name: prepare frontend files
@ -74,50 +74,17 @@ jobs:
mkdir -p frontend/dist mkdir -p frontend/dist
touch frontend/dist/index.html touch frontend/dist/index.html
- name: golangci-lint - name: golangci-lint
uses: golangci/golangci-lint-action@ba0d7d2ec06a0ea1cb5fa41b2e4a3ab91d21278a # v9.3.0 uses: golangci/golangci-lint-action@1e7e51e771db61008b38414a730f564565cf7c20 # v9
with: with:
version: v2.10.1 version: v2.10.1
veans-lint: api-check-translations:
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:
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: mage needs: mage
steps: steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- name: Download Mage Binary - name: Download Mage Binary
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
with: with:
name: mage_bin name: mage_bin
- name: Check - name: Check
@ -152,7 +119,7 @@ jobs:
ports: ports:
- 3306:3306 - 3306:3306
migration-smoke-db-postgres: migration-smoke-db-postgres:
image: postgres:18@sha256:4aabea78cf39b90e834caf3af7d602a18565f6fe2508705c8d01aa63245c2e20 image: postgres:18@sha256:5773fe724c49c42a7a9ca70202e11e1dff21fb7235b335a73f39297d200b73a2
env: env:
POSTGRES_PASSWORD: vikunjatest POSTGRES_PASSWORD: vikunjatest
POSTGRES_DB: vikunjatest POSTGRES_DB: vikunjatest
@ -164,7 +131,7 @@ jobs:
wget https://dl.vikunja.io/vikunja/unstable/vikunja-unstable-linux-amd64-full.zip -q -O vikunja-latest.zip 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 unzip vikunja-latest.zip vikunja-unstable-linux-amd64
- name: Download Vikunja Binary - name: Download Vikunja Binary
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
with: with:
name: vikunja_bin name: vikunja_bin
- name: run migration - name: run migration
@ -254,13 +221,13 @@ jobs:
ports: ports:
- 389:389 - 389:389
steps: steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- name: Download Mage Binary - name: Download Mage Binary
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
with: with:
name: mage_bin name: mage_bin
- name: Set up Go - name: Set up Go
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6
with: with:
go-version: stable go-version: stable
- name: Configure Postgres for faster tests - name: Configure Postgres for faster tests
@ -300,13 +267,13 @@ jobs:
needs: needs:
- mage - mage
steps: steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- name: Download Mage Binary - name: Download Mage Binary
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
with: with:
name: mage_bin name: mage_bin
- name: Set up Go - name: Set up Go
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6
with: with:
go-version: stable go-version: stable
- name: test - name: test
@ -321,13 +288,13 @@ jobs:
needs: needs:
- mage - mage
steps: steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- name: Download Mage Binary - name: Download Mage Binary
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
with: with:
name: mage_bin name: mage_bin
- name: Set up Go - name: Set up Go
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6
with: with:
go-version: stable go-version: stable
- name: test - name: test
@ -351,13 +318,13 @@ jobs:
ports: ports:
- 9000:9000 - 9000:9000
steps: steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- name: Download Mage Binary - name: Download Mage Binary
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
with: with:
name: mage_bin name: mage_bin
- name: Set up Go - name: Set up Go
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6
with: with:
go-version: stable go-version: stable
- name: test S3 file storage integration - name: test S3 file storage integration
@ -382,7 +349,7 @@ jobs:
frontend-lint: frontend-lint:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- uses: ./.github/actions/setup-frontend - uses: ./.github/actions/setup-frontend
- name: Lint - name: Lint
working-directory: frontend working-directory: frontend
@ -391,7 +358,7 @@ jobs:
frontend-stylelint: frontend-stylelint:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- uses: ./.github/actions/setup-frontend - uses: ./.github/actions/setup-frontend
- name: Lint styles - name: Lint styles
working-directory: frontend working-directory: frontend
@ -400,7 +367,7 @@ jobs:
frontend-typecheck: frontend-typecheck:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- uses: ./.github/actions/setup-frontend - uses: ./.github/actions/setup-frontend
- name: Typecheck - name: Typecheck
continue-on-error: true continue-on-error: true
@ -410,7 +377,7 @@ jobs:
test-frontend-unit: test-frontend-unit:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- uses: ./.github/actions/setup-frontend - uses: ./.github/actions/setup-frontend
- name: Run unit tests - name: Run unit tests
working-directory: frontend working-directory: frontend
@ -419,11 +386,11 @@ jobs:
frontend-build: frontend-build:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- uses: ./.github/actions/setup-frontend - uses: ./.github/actions/setup-frontend
- name: Git describe - name: Git describe
id: ghd id: ghd
uses: proudust/gh-describe@80412be8ce0e77d8afba6b340e34790bc772aa45 # v2.2.0 uses: proudust/gh-describe@v2
- name: Inject frontend version - name: Inject frontend version
working-directory: frontend working-directory: frontend
run: | run: |
@ -432,81 +399,11 @@ jobs:
working-directory: frontend working-directory: frontend
run: pnpm build run: pnpm build
- name: Store Frontend - name: Store Frontend
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
with: with:
name: frontend_dist name: frontend_dist
path: ./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: test-frontend-e2e-playwright:
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: needs:
@ -523,19 +420,19 @@ jobs:
ports: ports:
- 5556:5556 - 5556:5556
container: 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 options: --user 1001
steps: steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- name: Download Vikunja Binary - name: Download Vikunja Binary
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
with: with:
name: vikunja_bin name: vikunja_bin
- uses: ./.github/actions/setup-frontend - uses: ./.github/actions/setup-frontend
with: with:
install-e2e-binaries: false # Playwright browsers already in container install-e2e-binaries: false # Playwright browsers already in container
- name: Download Frontend - name: Download Frontend
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
with: with:
name: frontend_dist name: frontend_dist
path: ./frontend/dist path: ./frontend/dist
@ -570,14 +467,14 @@ jobs:
VIKUNJA_AUTH_OPENID_PROVIDERS_DEX_CLIENTID: vikunja VIKUNJA_AUTH_OPENID_PROVIDERS_DEX_CLIENTID: vikunja
VIKUNJA_AUTH_OPENID_PROVIDERS_DEX_CLIENTSECRET: secret VIKUNJA_AUTH_OPENID_PROVIDERS_DEX_CLIENTSECRET: secret
- name: Upload Playwright Report - name: Upload Playwright Report
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
if: always() if: always()
with: with:
name: playwright-report-${{ matrix.shard }} name: playwright-report-${{ matrix.shard }}
path: frontend/playwright-report/ path: frontend/playwright-report/
retention-days: 30 retention-days: 30
- name: Upload Test Results - name: Upload Test Results
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
if: always() if: always()
with: with:
name: playwright-test-results-${{ matrix.shard }} name: playwright-test-results-${{ matrix.shard }}

1
.gitignore vendored
View File

@ -26,7 +26,6 @@ docs/resources/
pkg/static/templates_vfsdata.go pkg/static/templates_vfsdata.go
files/ files/
!pkg/files/ !pkg/files/
!pkg/web/files/
vikunja-dump* vikunja-dump*
vendor/ vendor/
os-packages/ os-packages/

View File

@ -145,13 +145,6 @@ linters:
- revive - revive
path: pkg/utils/* path: pkg/utils/*
text: 'var-naming: avoid meaningless package names' 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: - linters:
- revive - revive
text: 'var-naming: avoid package names that conflict with Go standard library package names' text: 'var-naming: avoid package names that conflict with Go standard library package names'

View File

@ -11,25 +11,6 @@ The project consists of:
- `desktop/` Electron wrapper application - `desktop/` Electron wrapper application
- `docs/` Documentation website - `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 ## Plans and Worktrees
When the user asks you to create a plan to fix or implement something: 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 ### Adding New Features
**Backend Changes:** **Backend Changes:**
1. Create/modify models in `pkg/models/` with proper CRUD and Permissions interfaces as required (invoke the `crudable` 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>` (invoke the `migration` skill) 2. Add database migration if needed: `mage dev:make-migration <StructName>`
3. Create/update services in `pkg/services/` for complex business logic 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:** **Frontend Changes:**
1. Create TypeScript interfaces in `src/modelTypes/` matching backend models 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/` 4. Update TypeScript interfaces in frontend `src/modelTypes/`
### API Development ### 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. - All API endpoints follow RESTful conventions under `/api/v1/`
- v2 verb conventions differ from v1: POST creates, PUT/PATCH update (v1 used PUT to create, POST to update). - Use generic web handlers in `pkg/web/handler/` for standard CRUD operations
- Both versions reuse the generic `pkg/web/handler/` `Do*` functions for standard CRUD, which enforce permissions via the model's `Can*` methods. - Implement proper permissions checking using the Permissions interface
- 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). - Add Swagger annotations for automatic documentation generation
- 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.
### Testing ### Testing
- Backend: Feature tests alongside source files, web tests in `pkg/webtests/` - 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. 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. 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 ## Key Files and Conventions
**Configuration:** **Configuration:**
@ -275,13 +254,11 @@ 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/*` - 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 - 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 - 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:** **Naming Conventions:**
- Go: Standard Go conventions (PascalCase for exports, camelCase for private) - Go: Standard Go conventions (PascalCase for exports, camelCase for private)
- Vue: PascalCase for components, camelCase for composables - 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:** **Permissions and Permissions:**
- Always implement Permissions interface for new models - Always implement Permissions interface for new models

View File

@ -1,5 +1,5 @@
# syntax=docker/dockerfile:1@sha256:87999aa3d42bdc6bea60565083ee17e86d1f3339802f543c0d03998580f9cb89 # syntax=docker/dockerfile:1@sha256:b6afd42430b15f2d2a4c5a02b919e98a525b785b1aaff16747d2f623364e39b6
FROM --platform=$BUILDPLATFORM node:24.18.0-alpine@sha256:a0b9bf06e4e6193cf7a0f58816cc935ff8c2a908f81e6f1a95432d679c54fbfd AS frontendbuilder FROM --platform=$BUILDPLATFORM node:24.13.0-alpine@sha256:931d7d57f8c1fd0e2179dbff7cc7da4c9dd100998bc2b32afc85142d8efbc213 AS frontendbuilder
WORKDIR /build WORKDIR /build
@ -14,7 +14,7 @@ COPY frontend/ ./
ARG RELEASE_VERSION=dev ARG RELEASE_VERSION=dev
RUN echo "{\"VERSION\": \"${RELEASE_VERSION/-g/-}\"}" > src/version.json && pnpm run build 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 && \ RUN go install github.com/magefile/mage@latest && \
mv /go/bin/mage /usr/local/go/bin mv /go/bin/mage /usr/local/go/bin
@ -28,7 +28,7 @@ ENV RELEASE_VERSION=$RELEASE_VERSION
RUN export PATH=$PATH:$GOPATH/bin && \ RUN export PATH=$PATH:$GOPATH/bin && \
mage build:clean && \ 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 RUN mkdir -p /tmp && chmod 1777 /tmp
@ -50,7 +50,7 @@ WORKDIR /app/vikunja
ENTRYPOINT [ "/app/vikunja/vikunja" ] ENTRYPOINT [ "/app/vikunja/vikunja" ]
EXPOSE 3456 EXPOSE 3456
COPY --from=apibuilder --chown=1000:1000 --chmod=1777 /tmp /tmp COPY --from=apibuilder --chown=1000:1000 /tmp /tmp
USER 1000 USER 1000

View File

@ -2,7 +2,7 @@
rc-update add vikunja default rc-update add vikunja default
# Fix the config to contain proper values # 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/<jwt-secret>/$NEW_SECRET/g" /etc/vikunja/config.yml
sed -i "s/<rootpath>/\/opt\/vikunja\//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 sed -i "s/path: \"\.\/vikunja.db\"/path: \"\\/opt\/vikunja\/vikunja.db\"/g" /etc/vikunja/config.yml

View File

@ -3,7 +3,7 @@
systemctl enable vikunja.service systemctl enable vikunja.service
# Fix the config to contain proper values # 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/<jwt-secret>/$NEW_SECRET/g" /etc/vikunja/config.yml
sed -i "s/<rootpath>/\/opt\/vikunja\//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 sed -i "s/path: \"\.\/vikunja.db\"/path: \"\\/opt\/vikunja\/vikunja.db\"/g" /etc/vikunja/config.yml

View File

@ -1,5 +0,0 @@
module code.vikunja.io/build
go 1.26.4
require github.com/magefile/mage v1.17.2

View File

@ -1,2 +0,0 @@
github.com/magefile/mage v1.17.2 h1:fyXVu1eadI8Ap1HCCNgEhJ5McIWiYhLR8uol64ZZc40=
github.com/magefile/mage v1.17.2/go.mod h1:Yj51kqllmsgFpvvSzgrZPK9WtluG3kUhFaBUVLo4feA=

View File

@ -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,
}

View File

@ -849,11 +849,6 @@
"default_value": "(&(objectclass=*)(|(objectclass=group)(objectclass=groupOfNames)))", "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`." "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", "key": "avatarsyncattribute",
"default_value": "", "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", "key": "outgoingrequests",
"children": [ "children": [

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

View File

@ -10,7 +10,6 @@ const {
screen, screen,
} = require('electron') } = require('electron')
const path = require('path') const path = require('path')
const fs = require('fs')
const express = require('express') const express = require('express')
const portInUse = require('./portInUse.js') const portInUse = require('./portInUse.js')
const oauth = require('./oauth.js') const oauth = require('./oauth.js')
@ -25,9 +24,6 @@ const SAFE_PROTOCOLS = new Set([
const QUICK_ENTRY_WIDTH = 680 const QUICK_ENTRY_WIDTH = 680
const QUICK_ENTRY_COLLAPSED_HEIGHT = 56 const QUICK_ENTRY_COLLAPSED_HEIGHT = 56
const ZOOM_STEP = 0.5
const ZOOM_CONFIG_FILE = 'zoom.json'
const BASE_WEB_PREFERENCES = { const BASE_WEB_PREFERENCES = {
nodeIntegration: false, nodeIntegration: false,
contextIsolation: true, contextIsolation: true,
@ -56,7 +52,6 @@ let isQuitting = false
let pendingDeepLinkUrl = null let pendingDeepLinkUrl = null
let pendingApiUrl = null let pendingApiUrl = null
let currentShortcut = null let currentShortcut = null
let zoomLevel = 0
const DEFAULT_QUICK_ENTRY_SHORTCUT = 'CmdOrCtrl+Shift+A' const DEFAULT_QUICK_ENTRY_SHORTCUT = 'CmdOrCtrl+Shift+A'
const launchedWithQuickEntry = process.argv.includes('--quick-entry') const launchedWithQuickEntry = process.argv.includes('--quick-entry')
@ -100,15 +95,10 @@ app.on('second-instance', (_event, argv) => {
return return
} }
// Reveal the main window. It may be hidden in the tray (not just minimized), // Focus the main window
// 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.
if (mainWindow) { if (mainWindow) {
if (mainWindow.isMinimized()) mainWindow.restore() if (mainWindow.isMinimized()) mainWindow.restore()
mainWindow.show()
mainWindow.focus() mainWindow.focus()
} else if (serverPort) {
createMainWindow()
} }
// Find the deep link URL in argv // 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 ───────────────────────────────────────────────────── // ─── Main window ─────────────────────────────────────────────────────
function createMainWindow() { function createMainWindow() {
mainWindow = new BrowserWindow({ mainWindow = new BrowserWindow({
width: 1680, width: 1680,
height: 960, 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: { webPreferences: {
...BASE_WEB_PREFERENCES, ...BASE_WEB_PREFERENCES,
preload: path.join(__dirname, 'preload.js'), preload: path.join(__dirname, 'preload.js'),
@ -282,11 +213,6 @@ function createMainWindow() {
mainWindow.loadURL(`http://127.0.0.1:${serverPort}`) 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, // Process any deep link that arrived before the page was ready,
// either buffered from open-url or passed via process.argv on first launch // either buffered from open-url or passed via process.argv on first launch
mainWindow.webContents.once('did-finish-load', () => { mainWindow.webContents.once('did-finish-load', () => {
@ -407,11 +333,7 @@ function toggleQuickEntry() {
// ─── System tray ───────────────────────────────────────────────────── // ─── System tray ─────────────────────────────────────────────────────
function setupTray() { function setupTray() {
if (!tray) { if (!tray) {
// NOTE: load the icon from the app root, not build/. The build/ directory is const iconPath = path.join(__dirname, 'build', 'icon.png')
// 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}) const icon = nativeImage.createFromPath(iconPath).resize({width: 16, height: 16})
tray = new Tray(icon) tray = new Tray(icon)
tray.setToolTip('Vikunja') tray.setToolTip('Vikunja')
@ -512,8 +434,6 @@ ipcMain.on('desktop:update-quick-entry-shortcut', (_event, shortcut) => {
// ─── App lifecycle ─────────────────────────────────────────────────── // ─── App lifecycle ───────────────────────────────────────────────────
app.whenReady().then(() => { app.whenReady().then(() => {
zoomLevel = loadZoomLevel()
startServer(() => { startServer(() => {
createMainWindow() createMainWindow()
createQuickEntryWindow() createQuickEntryWindow()
@ -553,14 +473,3 @@ app.on('window-all-closed', () => {
app.quit() 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()
})
}

View File

@ -5,7 +5,7 @@
"main": "main.js", "main": "main.js",
"repository": "https://code.vikunja.io/desktop", "repository": "https://code.vikunja.io/desktop",
"license": "GPL-3.0-or-later", "license": "GPL-3.0-or-later",
"packageManager": "pnpm@10.34.4", "packageManager": "pnpm@10.28.1",
"author": { "author": {
"email": "maintainers@vikunja.io", "email": "maintainers@vikunja.io",
"name": "Vikunja Team" "name": "Vikunja Team"
@ -61,9 +61,9 @@
} }
}, },
"devDependencies": { "devDependencies": {
"electron": "40.10.5", "electron": "40.9.1",
"electron-builder": "26.15.3", "electron-builder": "26.8.1",
"unzipper": "0.12.5" "unzipper": "0.12.3"
}, },
"dependencies": { "dependencies": {
"express": "5.2.1" "express": "5.2.1"
@ -73,16 +73,10 @@
"electron" "electron"
], ],
"overrides": { "overrides": {
"minimatch": "10.2.5", "minimatch": "^10.2.3",
"tar": "7.5.17", "tar": "^7.5.11",
"@tootallnate/once": "3.0.1", "@tootallnate/once": "^3.0.1",
"picomatch": "4.0.4", "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"
} }
} }
} }

File diff suppressed because it is too large Load Diff

View File

@ -3,11 +3,10 @@
"devenv": { "devenv": {
"locked": { "locked": {
"dir": "src/modules", "dir": "src/modules",
"lastModified": 1782492839, "lastModified": 1773012232,
"narHash": "sha256-j9wrcB4al5QhMelEghJ0Qs+RQPT+wyCcI4070NEgPLQ=",
"owner": "cachix", "owner": "cachix",
"repo": "devenv", "repo": "devenv",
"rev": "3d39d0817d62069f7b18821c34a617b5141cb278", "rev": "46a4bd0299a26ad948b71d3053174ba7b90522f7",
"type": "github" "type": "github"
}, },
"original": { "original": {
@ -17,16 +16,71 @@
"type": "github" "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": { "nixpkgs": {
"inputs": { "inputs": {
"nixpkgs-src": "nixpkgs-src" "nixpkgs-src": "nixpkgs-src"
}, },
"locked": { "locked": {
"lastModified": 1782132010, "lastModified": 1772749504,
"narHash": "sha256-ZnAVHdVrotp80iIMm5CSR1fdxPlw7Uwmwxb+O/wsgZ8=",
"owner": "cachix", "owner": "cachix",
"repo": "devenv-nixpkgs", "repo": "devenv-nixpkgs",
"rev": "12866ae2dddbc0ab8b329915f8072bb9c75bde89", "rev": "08543693199362c1fddb8f52126030d0d374ba2e",
"type": "github" "type": "github"
}, },
"original": { "original": {
@ -39,11 +93,11 @@
"nixpkgs-src": { "nixpkgs-src": {
"flake": false, "flake": false,
"locked": { "locked": {
"lastModified": 1781607440, "lastModified": 1769922788,
"narHash": "sha256-rxO+uc/KFbSJp+pgyXRuAX6QlG9hJdnt0BXpEQRXY+U=", "narHash": "sha256-H3AfG4ObMDTkTJYkd8cz1/RbY9LatN5Mk4UF48VuSXc=",
"owner": "NixOS", "owner": "NixOS",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "3e41b24abd260e8f71dbe2f5737d24122f972158", "rev": "207d15f1a6603226e1e223dc79ac29c7846da32e",
"type": "github" "type": "github"
}, },
"original": { "original": {
@ -55,11 +109,10 @@
}, },
"nixpkgs-unstable": { "nixpkgs-unstable": {
"locked": { "locked": {
"lastModified": 1782467914, "lastModified": 1772773019,
"narHash": "sha256-pGvFkM8N0xEkIIXDe5YYfbEAvHrk4IxBrjB/x8OomhE=",
"owner": "NixOS", "owner": "NixOS",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "e73de5be04e0eff4190a1432b946d469c794e7b4", "rev": "aca4d95fce4914b3892661bcb80b8087293536c6",
"type": "github" "type": "github"
}, },
"original": { "original": {
@ -72,8 +125,12 @@
"root": { "root": {
"inputs": { "inputs": {
"devenv": "devenv", "devenv": "devenv",
"git-hooks": "git-hooks",
"nixpkgs": "nixpkgs", "nixpkgs": "nixpkgs",
"nixpkgs-unstable": "nixpkgs-unstable" "nixpkgs-unstable": "nixpkgs-unstable",
"pre-commit-hooks": [
"git-hooks"
]
} }
} }
}, },

View File

@ -1 +1 @@
24.18.0 24.13.0

View File

@ -23,6 +23,7 @@
// It has to be the full url, including the last /api/v1 part and port. // 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. // You can change this if your api is not reachable on the same port as the frontend.
window.API_URL = '/api/v1' window.API_URL = '/api/v1'
window.ALLOW_ICON_CHANGES = true
</script> </script>
</body> </body>
</html> </html>

View File

@ -13,7 +13,7 @@
}, },
"homepage": "https://vikunja.io/", "homepage": "https://vikunja.io/",
"funding": "https://opencollective.com/vikunja", "funding": "https://opencollective.com/vikunja",
"packageManager": "pnpm@10.34.4", "packageManager": "pnpm@10.28.1",
"engines": { "engines": {
"node": ">=24.0.0" "node": ">=24.0.0"
}, },
@ -51,114 +51,111 @@
"story:preview": "histoire preview" "story:preview": "histoire preview"
}, },
"dependencies": { "dependencies": {
"@floating-ui/dom": "1.7.6", "@floating-ui/dom": "1.7.4",
"@fortawesome/fontawesome-svg-core": "7.3.0", "@fortawesome/fontawesome-svg-core": "7.1.0",
"@fortawesome/free-regular-svg-icons": "7.3.0", "@fortawesome/free-regular-svg-icons": "7.1.0",
"@fortawesome/free-solid-svg-icons": "7.3.0", "@fortawesome/free-solid-svg-icons": "7.1.0",
"@fortawesome/vue-fontawesome": "3.3.0", "@fortawesome/vue-fontawesome": "3.1.3",
"@intlify/unplugin-vue-i18n": "11.2.4", "@intlify/unplugin-vue-i18n": "11.0.3",
"@kyvg/vue3-notification": "3.4.2", "@kyvg/vue3-notification": "3.4.2",
"@sentry/vue": "10.62.0", "@sentry/vue": "10.36.0",
"@tiptap/core": "3.27.1", "@tiptap/core": "3.17.0",
"@tiptap/extension-blockquote": "3.27.1", "@tiptap/extension-code-block-lowlight": "3.17.0",
"@tiptap/extension-code-block-lowlight": "3.27.1", "@tiptap/extension-hard-break": "3.17.0",
"@tiptap/extension-hard-break": "3.27.1", "@tiptap/extension-image": "3.17.0",
"@tiptap/extension-image": "3.27.1", "@tiptap/extension-link": "3.17.0",
"@tiptap/extension-link": "3.27.1", "@tiptap/extension-list": "3.17.0",
"@tiptap/extension-list": "3.27.1", "@tiptap/extension-mention": "3.17.0",
"@tiptap/extension-mention": "3.27.1", "@tiptap/extension-table": "3.17.0",
"@tiptap/extension-table": "3.27.1", "@tiptap/extension-typography": "3.17.0",
"@tiptap/extension-typography": "3.27.1", "@tiptap/extension-underline": "3.17.0",
"@tiptap/extension-underline": "3.27.1", "@tiptap/extensions": "3.17.0",
"@tiptap/extensions": "3.27.1", "@tiptap/pm": "3.17.0",
"@tiptap/pm": "3.27.1", "@tiptap/starter-kit": "3.17.0",
"@tiptap/starter-kit": "3.27.1", "@tiptap/suggestion": "3.17.0",
"@tiptap/suggestion": "3.27.1", "@tiptap/vue-3": "3.17.0",
"@tiptap/vue-3": "3.27.1", "@vueuse/core": "14.1.0",
"@vueuse/core": "14.3.0", "@vueuse/router": "14.1.0",
"@vueuse/router": "14.3.0", "axios": "1.15.0",
"axios": "1.18.1",
"blurhash": "2.0.5", "blurhash": "2.0.5",
"bulma-css-variables": "0.9.33", "bulma-css-variables": "0.9.33",
"change-case": "5.4.4", "change-case": "5.4.4",
"dayjs": "1.11.21", "dayjs": "1.11.19",
"dompurify": "3.4.11", "dompurify": "3.4.0",
"fast-deep-equal": "3.1.3", "fast-deep-equal": "3.1.3",
"flatpickr": "4.6.13", "flatpickr": "4.6.13",
"floating-vue": "5.2.2", "floating-vue": "5.2.2",
"is-touch-device": "1.0.1", "is-touch-device": "1.0.1",
"klona": "2.0.6", "klona": "2.0.6",
"lowlight": "3.3.0", "lowlight": "3.3.0",
"marked": "17.0.6", "marked": "17.0.1",
"nanoid": "5.1.16", "nanoid": "5.1.6",
"pinia": "3.0.4", "pinia": "3.0.4",
"register-service-worker": "1.7.2", "register-service-worker": "1.7.2",
"sortablejs": "1.15.7", "sortablejs": "1.15.6",
"ufo": "1.6.4", "ufo": "1.6.3",
"vue": "3.5.39", "vue": "3.5.27",
"vue-advanced-cropper": "2.8.9", "vue-advanced-cropper": "2.8.9",
"vue-flatpickr-component": "11.0.5", "vue-flatpickr-component": "11.0.5",
"vue-i18n": "11.4.6", "vue-i18n": "11.2.8",
"vue-router": "4.6.4", "vue-router": "4.6.4",
"vuemoji-picker": "0.3.2", "vuemoji-picker": "0.3.2",
"workbox-precaching": "7.4.1", "workbox-precaching": "7.4.0",
"zhyswan-vuedraggable": "4.1.3" "zhyswan-vuedraggable": "4.1.3"
}, },
"devDependencies": { "devDependencies": {
"@faker-js/faker": "10.5.0", "@faker-js/faker": "10.4.0",
"@histoire/plugin-screenshot": "1.0.0-beta.1", "@histoire/plugin-screenshot": "1.0.0-beta.1",
"@histoire/plugin-vue": "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", "@sentry/vite-plugin": "3.6.1",
"@tailwindcss/vite": "4.3.1", "@tailwindcss/vite": "4.2.2",
"@tsconfig/node24": "24.0.4", "@tsconfig/node24": "24.0.4",
"@types/codemirror": "5.60.17", "@types/codemirror": "5.60.17",
"@types/is-touch-device": "1.0.3", "@types/is-touch-device": "1.0.3",
"@types/node": "24.13.2", "@types/node": "24.12.2",
"@types/sortablejs": "1.15.9", "@types/sortablejs": "1.15.9",
"@types/ws": "8.18.1", "@types/ws": "8.18.1",
"@typescript-eslint/eslint-plugin": "8.62.0", "@typescript-eslint/eslint-plugin": "8.58.2",
"@typescript-eslint/parser": "8.62.0", "@typescript-eslint/parser": "8.58.2",
"@vitejs/plugin-vue": "6.0.7", "@vitejs/plugin-vue": "6.0.6",
"@vue/eslint-config-typescript": "14.9.0", "@vue/eslint-config-typescript": "14.7.0",
"@vue/test-utils": "2.4.11", "@vue/test-utils": "2.4.6",
"@vue/tsconfig": "0.9.1", "@vue/tsconfig": "0.9.1",
"@vueuse/shared": "14.3.0", "@vueuse/shared": "14.2.1",
"autoprefixer": "10.5.2", "autoprefixer": "10.5.0",
"browserslist": "4.28.4", "browserslist": "4.28.2",
"caniuse-lite": "1.0.30001799", "caniuse-lite": "1.0.30001788",
"csstype": "3.2.3", "csstype": "3.2.3",
"esbuild": "0.28.1", "esbuild": "0.28.0",
"eslint": "9.39.4", "eslint": "9.39.4",
"eslint-plugin-depend": "1.5.0", "eslint-plugin-depend": "1.5.0",
"eslint-plugin-vue": "10.9.2", "eslint-plugin-vue": "10.8.0",
"happy-dom": "20.10.6", "happy-dom": "20.9.0",
"histoire": "1.0.0-beta.1", "histoire": "1.0.0-beta.1",
"otplib": "12.0.1", "postcss": "8.5.10",
"postcss": "8.5.15",
"postcss-easing-gradients": "3.0.1", "postcss-easing-gradients": "3.0.1",
"postcss-html": "1.8.1", "postcss-preset-env": "11.2.1",
"postcss-preset-env": "11.3.1", "rollup": "4.60.2",
"rollup": "4.62.2",
"rollup-plugin-visualizer": "6.0.11", "rollup-plugin-visualizer": "6.0.11",
"sass-embedded": "1.100.0", "sass-embedded": "1.99.0",
"stylelint": "17.13.0", "stylelint": "17.8.0",
"stylelint-config-property-sort-order-smacss": "10.0.0", "stylelint-config-property-sort-order-smacss": "10.0.0",
"stylelint-config-recommended-vue": "1.6.1", "stylelint-config-recommended-vue": "1.6.1",
"stylelint-config-standard-scss": "17.0.0", "stylelint-config-standard-scss": "17.0.0",
"stylelint-use-logical": "2.1.3", "stylelint-use-logical": "2.1.3",
"tailwindcss": "4.3.1", "tailwindcss": "4.2.2",
"typescript": "5.9.3", "typescript": "5.9.3",
"unplugin-inject-preload": "3.0.0", "unplugin-inject-preload": "3.0.0",
"vite": "7.3.6", "vite": "7.3.2",
"vite-plugin-pwa": "1.3.0", "vite-plugin-pwa": "1.2.0",
"vite-plugin-vue-devtools": "8.1.4", "vite-plugin-vue-devtools": "8.1.1",
"vite-svg-loader": "5.1.1", "vite-svg-loader": "5.1.1",
"vitest": "4.1.9", "vitest": "4.1.4",
"vue-tsc": "3.3.5", "vue-tsc": "3.2.7",
"wait-on": "9.0.10", "wait-on": "9.0.5",
"workbox-cli": "7.4.1", "workbox-cli": "7.4.0",
"ws": "8.21.0" "ws": "8.20.0"
}, },
"pnpm": { "pnpm": {
"onlyBuiltDependencies": [ "onlyBuiltDependencies": [
@ -169,20 +166,11 @@
"vue-demi" "vue-demi"
], ],
"overrides": { "overrides": {
"minimatch": "10.2.5", "minimatch": "^10.2.3",
"rollup": "$rollup", "rollup": "$rollup",
"basic-ftp": "6.0.1", "basic-ftp": ">=5.2.2",
"serialize-javascript": "7.0.6", "serialize-javascript": "^7.0.5",
"flatted": "3.4.2", "flatted": "^3.4.1"
"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"
} }
} }
} }

File diff suppressed because it is too large Load Diff

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

View File

@ -9,12 +9,6 @@
</div> </div>
</template> </template>
<template v-else> <template v-else>
<a
href="#main-content"
class="skip-to-content"
>
{{ $t('misc.skipToContent') }}
</a>
<template v-if="showAuthLayout"> <template v-if="showAuthLayout">
<AppHeader /> <AppHeader />
<ContentAuth /> <ContentAuth />
@ -61,7 +55,6 @@ import {useAuthStore} from '@/stores/auth'
import {useBaseStore} from '@/stores/base' import {useBaseStore} from '@/stores/base'
import {useColorScheme} from '@/composables/useColorScheme' import {useColorScheme} from '@/composables/useColorScheme'
import {useTimeTrackingFavicon} from '@/composables/useTimeTrackingFavicon'
import {useBodyClass} from '@/composables/useBodyClass' import {useBodyClass} from '@/composables/useBodyClass'
import QuickAddOverlay from '@/components/quick-actions/QuickAddOverlay.vue' import QuickAddOverlay from '@/components/quick-actions/QuickAddOverlay.vue'
import AddToHomeScreen from '@/components/home/AddToHomeScreen.vue' import AddToHomeScreen from '@/components/home/AddToHomeScreen.vue'
@ -108,7 +101,6 @@ watch(accountDeletionConfirm, async (accountDeletionConfirm) => {
setLanguage(authStore.settings.language ?? DEFAULT_LANGUAGE) setLanguage(authStore.settings.language ?? DEFAULT_LANGUAGE)
useColorScheme() useColorScheme()
useTimeTrackingFavicon()
</script> </script>
<style src="@/styles/tailwind.css" /> <style src="@/styles/tailwind.css" />

View File

@ -11,7 +11,6 @@
class="is-sr-only" class="is-sr-only"
:checked="modelValue" :checked="modelValue"
:disabled="disabled || undefined" :disabled="disabled || undefined"
:aria-label="ariaLabel"
@change="(event) => emit('update:modelValue', (event.target as HTMLInputElement).checked)" @change="(event) => emit('update:modelValue', (event.target as HTMLInputElement).checked)"
> >
<slot /> <slot />
@ -23,10 +22,8 @@
withDefaults(defineProps<{ withDefaults(defineProps<{
modelValue?: boolean, modelValue?: boolean,
disabled: boolean, disabled: boolean,
ariaLabel?: string,
}>(), { }>(), {
modelValue: false, modelValue: false,
ariaLabel: undefined,
}) })
const emit = defineEmits<{ const emit = defineEmits<{

View File

@ -82,92 +82,22 @@ const pages = computed(() => createPagination(props.totalPages, props.currentPag
</script> </script>
<style lang="scss" scoped> <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 { .pagination {
align-items: center;
display: flex;
font-size: $size-normal;
justify-content: center;
margin: -0.25rem;
padding-block-end: 1rem; padding-block-end: 1rem;
text-align: center; }
.pagination-previous,
.pagination-next {
&:not(:disabled):hover {
background: $scheme-main;
cursor: pointer;
}
} }
.pagination-list { .pagination-list {
align-items: center;
display: flex;
flex-wrap: wrap;
justify-content: center;
text-align: center;
&, & li { &, & li {
margin-block-start: 0; 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> </style>

View File

@ -36,18 +36,4 @@ describe('DatepickerWithRange predefined ranges', () => {
const last = wrapper.emitted('update:modelValue')?.pop()?.[0] const last = wrapper.emitted('update:modelValue')?.pop()?.[0]
expect(last).toEqual({dateFrom: 'now/M-1M', dateTo: 'now/M'}) 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('')
})
}) })

View File

@ -114,17 +114,16 @@ import DatemathHelp from '@/components/date/DatemathHelp.vue'
import {useFlatpickrLanguage} from '@/helpers/useFlatpickrLanguage' import {useFlatpickrLanguage} from '@/helpers/useFlatpickrLanguage'
const props = defineProps<{ const props = defineProps<{
// null for a side that's been cleared (the Custom option) emitted, so accepted too.
modelValue: { modelValue: {
dateFrom: Date | string | null, dateFrom: Date | string,
dateTo: Date | string | null, dateTo: Date | string,
}, },
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
'update:modelValue': [value: { 'update:modelValue': [value: {
dateFrom: Date | string | null, dateFrom: Date | string,
dateTo: Date | string | null dateTo: Date | string
}] }]
}>() }>()
@ -150,8 +149,8 @@ const to = ref('')
watch( watch(
() => props.modelValue, () => props.modelValue,
newValue => { newValue => {
from.value = typeof newValue.dateFrom === 'string' ? newValue.dateFrom : (newValue.dateFrom?.toISOString() ?? '') from.value = typeof newValue.dateFrom === 'string' ? newValue.dateFrom : newValue.dateFrom.toISOString()
to.value = typeof newValue.dateTo === 'string' ? newValue.dateTo : (newValue.dateTo?.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. // 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. // Otherwise flatpickr runs in an endless loop and slows down the browser.
const dateFrom = parseDateOrString(from.value, false) const dateFrom = parseDateOrString(from.value, false)
@ -209,22 +208,14 @@ const customRangeActive = computed<boolean>(() => {
}) })
const buttonText = computed<string>(() => { const buttonText = computed<string>(() => {
if (from.value === '' || to.value === '') { if (from.value !== '' && to.value !== '') {
return t('task.show.select') 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. return t('task.show.select')
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,
})
}) })
</script> </script>

View File

@ -3,7 +3,6 @@ export const DATE_RANGES = {
// Key is the title, as a translation string, the first entry of the value array // 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. // is the "from" date, the second one is the "to" date.
'today': ['now/d', 'now/d+1d'], 'today': ['now/d', 'now/d+1d'],
'tomorrow': ['now/d+1d', 'now/d+2d'],
'lastWeek': ['now/w-1w', 'now/w'], 'lastWeek': ['now/w-1w', 'now/w'],
'thisWeek': ['now/w', 'now/w+1w'], 'thisWeek': ['now/w', 'now/w+1w'],

View File

@ -13,14 +13,14 @@
<div class="gantt-chart-wrapper"> <div class="gantt-chart-wrapper">
<GanttTimelineHeader <GanttTimelineHeader
:timeline-data="timelineData" :timeline-data="timelineData"
:day-width-pixels="dayWidthPixels" :day-width-pixels="DAY_WIDTH_PIXELS"
/> />
<GanttVerticalGridLines <GanttVerticalGridLines
:timeline-data="timelineData" :timeline-data="timelineData"
:total-width="totalWidth" :total-width="totalWidth"
:height="ganttRows.length * 40" :height="ganttRows.length * 40"
:day-width-pixels="dayWidthPixels" :day-width-pixels="DAY_WIDTH_PIXELS"
/> />
<GanttChartBody <GanttChartBody
@ -57,7 +57,7 @@
:total-width="totalWidth" :total-width="totalWidth"
:date-from-date="dateFromDate" :date-from-date="dateFromDate"
:date-to-date="dateToDate" :date-to-date="dateToDate"
:day-width-pixels="dayWidthPixels" :day-width-pixels="DAY_WIDTH_PIXELS"
:is-dragging="isDragging" :is-dragging="isDragging"
:is-resizing="isResizing" :is-resizing="isResizing"
:drag-state="dragState" :drag-state="dragState"
@ -89,7 +89,7 @@
</template> </template>
<script setup lang="ts"> <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 {useRouter} from 'vue-router'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import {useDayjsLanguageSync} from '@/i18n/useDayjsLanguageSync' import {useDayjsLanguageSync} from '@/i18n/useDayjsLanguageSync'
@ -126,9 +126,7 @@ const emit = defineEmits<{
(e: 'update:task', task: ITaskPartialWithId): void (e: 'update:task', task: ITaskPartialWithId): void
}>() }>()
const DAY_WIDTH_PIXELS_MIN = 30 const DAY_WIDTH_PIXELS = 30
const dayWidthPixels = ref(0)
let resizeObserver: ResizeObserver
const {tasks, filters} = toRefs(props) const {tasks, filters} = toRefs(props)
@ -160,7 +158,7 @@ const dateToDate = computed(() => dayjs(filters.value.dateTo).endOf('day').toDat
const totalWidth = computed(() => { const totalWidth = computed(() => {
const dateDiff = Math.ceil((dateToDate.value.valueOf() - dateFromDate.value.valueOf()) / MILLISECONDS_A_DAY) 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(() => { 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 // Build the task tree when tasks change
watch( watch(
[tasks, filters], [tasks, filters],
@ -402,7 +351,7 @@ const ROW_HEIGHT = 40
const barPositions = computed(() => { const barPositions = computed(() => {
const positions = new Map<number, GanttBarPosition>() const positions = new Map<number, GanttBarPosition>()
const ds = dragState.value 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) => { ganttBars.value.forEach((rowBars, rowIndex) => {
for (const bar of rowBars) { for (const bar of rowBars) {
@ -437,7 +386,7 @@ function computeBarX(date: Date): number {
(roundToNaturalDayBoundary(date, true).getTime() - dateFromDate.value.getTime()) / (roundToNaturalDayBoundary(date, true).getTime() - dateFromDate.value.getTime()) /
MILLISECONDS_A_DAY, MILLISECONDS_A_DAY,
) )
return diff * dayWidthPixels.value return diff * DAY_WIDTH_PIXELS
} }
function computeBarWidth(bar: GanttBarModel): number { function computeBarWidth(bar: GanttBarModel): number {
@ -445,7 +394,7 @@ function computeBarWidth(bar: GanttBarModel): number {
(roundToNaturalDayBoundary(bar.end).getTime() - roundToNaturalDayBoundary(bar.start, true).getTime()) / (roundToNaturalDayBoundary(bar.end).getTime() - roundToNaturalDayBoundary(bar.start, true).getTime()) /
MILLISECONDS_A_DAY, MILLISECONDS_A_DAY,
) )
return diff * dayWidthPixels.value return diff * DAY_WIDTH_PIXELS
} }
// Compute relation arrows // Compute relation arrows
@ -641,7 +590,7 @@ function startDrag(bar: GanttBarModel, event: PointerEvent) {
if (!dragState.value || !isDragging.value) return if (!dragState.value || !isDragging.value) return
const diff = e.clientX - dragState.value.startX 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) { if (days !== dragState.value.currentDays) {
dragState.value.currentDays = days dragState.value.currentDays = days
@ -703,7 +652,7 @@ function startResize(bar: GanttBarModel, edge: 'start' | 'end', event: PointerEv
if (!dragState.value || !isResizing.value) return if (!dragState.value || !isResizing.value) return
const diff = e.clientX - dragState.value.startX 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') { if (edge === 'start') {
const newStart = new Date(dragState.value.originalStart) const newStart = new Date(dragState.value.originalStart)
@ -781,7 +730,7 @@ function focusTaskBar(rowId: string) {
setTimeout(() => { setTimeout(() => {
const taskBarElement = document.querySelector(`[data-row-id="${rowId}"] [role="slider"]`) as HTMLElement const taskBarElement = document.querySelector(`[data-row-id="${rowId}"] [role="slider"]`) as HTMLElement
if (taskBarElement) { if (taskBarElement) {
taskBarElement.focus({preventScroll: true}) taskBarElement.focus()
} }
}, 0) }, 0)
} }

View File

@ -12,7 +12,6 @@
{{ $t('home.addToHomeScreen') }} {{ $t('home.addToHomeScreen') }}
</p> </p>
<BaseButton <BaseButton
:aria-label="$t('misc.closeBanner')"
class="hide-button" class="hide-button"
@click="() => hideMessage = true" @click="() => hideMessage = true"
> >

View File

@ -7,7 +7,7 @@
<RouterLink <RouterLink
:to="{ name: 'home' }" :to="{ name: 'home' }"
class="logo-link" class="logo-link"
:aria-label="$t('navigation.home')" :aria-label="$t('navigation.overview')"
> >
<Logo <Logo
width="164" width="164"
@ -21,9 +21,9 @@
v-if="currentProject?.id" v-if="currentProject?.id"
class="project-title-wrapper" class="project-title-wrapper"
> >
<span class="project-title"> <h1 class="project-title">
{{ currentProject.title === '' ? $t('misc.loading') : getProjectTitle(currentProject) }} {{ currentProject.title === '' ? $t('misc.loading') : getProjectTitle(currentProject) }}
</span> </h1>
<BaseButton <BaseButton
v-if="!isEditorContentEmpty(currentProject.description)" v-if="!isEditorContentEmpty(currentProject.description)"
@ -54,15 +54,7 @@
</ProjectSettingsDropdown> </ProjectSettingsDropdown>
</div> </div>
<div
v-else-if="pageTitle"
class="project-title-wrapper"
>
<span class="project-title">{{ pageTitle }}</span>
</div>
<div class="navbar-end"> <div class="navbar-end">
<TimerBadge />
<OpenQuickActions /> <OpenQuickActions />
<Notifications /> <Notifications />
<Dropdown> <Dropdown>
@ -95,12 +87,6 @@
<DropdownItem :to="{ name: 'user.settings' }"> <DropdownItem :to="{ name: 'user.settings' }">
{{ $t('user.settings.title') }} {{ $t('user.settings.title') }}
</DropdownItem> </DropdownItem>
<DropdownItem
v-if="adminPanelEnabled && authStore.info?.isAdmin"
:to="{ name: 'admin.overview' }"
>
{{ $t('admin.title') }}
</DropdownItem>
<DropdownItem <DropdownItem
v-if="imprintUrl" v-if="imprintUrl"
:href="imprintUrl" :href="imprintUrl"
@ -129,17 +115,13 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed } from 'vue'
import { useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { PERMISSIONS as Permissions } from '@/constants/permissions' import { PERMISSIONS as Permissions } from '@/constants/permissions'
import { PRO_FEATURE } from '@/constants/proFeatures'
import ProjectSettingsDropdown from '@/components/project/ProjectSettingsDropdown.vue' import ProjectSettingsDropdown from '@/components/project/ProjectSettingsDropdown.vue'
import Dropdown from '@/components/misc/Dropdown.vue' import Dropdown from '@/components/misc/Dropdown.vue'
import DropdownItem from '@/components/misc/DropdownItem.vue' import DropdownItem from '@/components/misc/DropdownItem.vue'
import Notifications from '@/components/notifications/Notifications.vue' import Notifications from '@/components/notifications/Notifications.vue'
import TimerBadge from '@/components/time-tracking/TimerBadge.vue'
import Logo from '@/components/home/Logo.vue' import Logo from '@/components/home/Logo.vue'
import BaseButton from '@/components/base/BaseButton.vue' import BaseButton from '@/components/base/BaseButton.vue'
import MenuButton from '@/components/home/MenuButton.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 canWriteCurrentProject = computed(() => baseStore.currentProject?.maxPermission !== null && baseStore.currentProject?.maxPermission !== undefined && baseStore.currentProject.maxPermission > Permissions.READ)
const menuActive = computed(() => baseStore.menuActive) 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 authStore = useAuthStore()
const configStore = useConfigStore() const configStore = useConfigStore()
const imprintUrl = computed(() => configStore.legal.imprintUrl) const imprintUrl = computed(() => configStore.legal.imprintUrl)
const privacyPolicyUrl = computed(() => configStore.legal.privacyPolicyUrl) const privacyPolicyUrl = computed(() => configStore.legal.privacyPolicyUrl)
const adminPanelEnabled = computed(() => configStore.isProFeatureEnabled(PRO_FEATURE.ADMIN_PANEL))
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>

View File

@ -2,7 +2,6 @@
<div class="content-auth"> <div class="content-auth">
<BaseButton <BaseButton
v-show="menuActive" v-show="menuActive"
:aria-label="$t('navigation.closeSidebar')"
class="menu-hide-button d-print-none" class="menu-hide-button d-print-none"
@click="baseStore.setMenuActive(false)" @click="baseStore.setMenuActive(false)"
> >
@ -23,7 +22,6 @@
/> />
<Navigation class="d-print-none" /> <Navigation class="d-print-none" />
<main <main
id="main-content"
class="app-content" class="app-content"
:class="[ :class="[
{ 'is-menu-enabled': menuActive }, { 'is-menu-enabled': menuActive },
@ -33,7 +31,6 @@
> >
<BaseButton <BaseButton
v-show="menuActive" v-show="menuActive"
:aria-label="$t('navigation.closeSidebar')"
class="mobile-overlay d-print-none" class="mobile-overlay d-print-none"
@click="baseStore.setMenuActive(false)" @click="baseStore.setMenuActive(false)"
/> />
@ -53,7 +50,6 @@
:enabled="typeof currentModal !== 'undefined'" :enabled="typeof currentModal !== 'undefined'"
variant="scrolling" variant="scrolling"
class="task-detail-view-modal" class="task-detail-view-modal"
:aria-label="$t('task.detail.title')"
@close="closeModal()" @close="closeModal()"
> >
<component <component

View File

@ -18,7 +18,6 @@ const enabled = computed(() => configStore.demoModeEnabled && !hide.value)
<strong class="is-uppercase">{{ $t('demo.everythingWillBeDeleted') }}</strong> <strong class="is-uppercase">{{ $t('demo.everythingWillBeDeleted') }}</strong>
</p> </p>
<BaseButton <BaseButton
:aria-label="$t('misc.closeBanner')"
class="hide-button" class="hide-button"
@click="() => hide = true" @click="() => hide = true"
> >

View File

@ -2,7 +2,6 @@
import { computed } from 'vue' import { computed } from 'vue'
import { useNow } from '@vueuse/core' import { useNow } from '@vueuse/core'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { useConfigStore } from '@/stores/config'
import { useColorScheme } from '@/composables/useColorScheme' import { useColorScheme } from '@/composables/useColorScheme'
import LogoFull from '@/assets/logo-full.svg?component' import LogoFull from '@/assets/logo-full.svg?component'
@ -14,10 +13,9 @@ const now = useNow({
}) })
const authStore = useAuthStore() const authStore = useAuthStore()
const configStore = useConfigStore()
const { isDark } = useColorScheme() const { isDark } = useColorScheme()
const Logo = computed(() => configStore.allowIconChanges const Logo = computed(() => window.ALLOW_ICON_CHANGES
&& authStore.settings.frontendSettings.allowIconChanges && authStore.settings.frontendSettings.allowIconChanges
&& now.value.getMonth() === 5 && now.value.getMonth() === 5
? LogoFullPride ? LogoFullPride

View File

@ -8,7 +8,7 @@
<RouterLink <RouterLink
:to="{name: 'home'}" :to="{name: 'home'}"
class="logo" class="logo"
:aria-label="$t('navigation.home')" :aria-label="$t('navigation.overview')"
> >
<Logo <Logo
width="164" width="164"
@ -71,14 +71,6 @@
{{ $t('team.title') }} {{ $t('team.title') }}
</RouterLink> </RouterLink>
</li> </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> </menu>
</nav> </nav>
@ -141,17 +133,12 @@ import Loading from '@/components/misc/Loading.vue'
import {useBaseStore} from '@/stores/base' import {useBaseStore} from '@/stores/base'
import {useProjectStore} from '@/stores/projects' import {useProjectStore} from '@/stores/projects'
import {useConfigStore} from '@/stores/config'
import {PRO_FEATURE} from '@/constants/proFeatures'
import ProjectsNavigation from '@/components/home/ProjectsNavigation.vue' import ProjectsNavigation from '@/components/home/ProjectsNavigation.vue'
import type {IProject} from '@/modelTypes/IProject' import type {IProject} from '@/modelTypes/IProject'
import {useSidebarResize} from '@/composables/useSidebarResize' import {useSidebarResize} from '@/composables/useSidebarResize'
const baseStore = useBaseStore() const baseStore = useBaseStore()
const projectStore = useProjectStore() const projectStore = useProjectStore()
const configStore = useConfigStore()
const timeTrackingEnabled = computed(() => configStore.isProFeatureEnabled(PRO_FEATURE.TIME_TRACKING))
const {sidebarWidth, isResizing, startResize, isMobile} = useSidebarResize() const {sidebarWidth, isResizing, startResize, isMobile} = useSidebarResize()

View File

@ -15,7 +15,6 @@
type="color" type="color"
:list="colorListID" :list="colorListID"
:class="{'is-empty': isEmpty}" :class="{'is-empty': isEmpty}"
:aria-label="$t('input.projectColor')"
> >
<svg <svg
v-show="isEmpty" v-show="isEmpty"

View File

@ -5,10 +5,7 @@
:disabled="disabled || undefined" :disabled="disabled || undefined"
@click.stop="toggleDatePopup" @click.stop="toggleDatePopup"
> >
<i v-if="date === null && emptyLabel !== ''">{{ emptyLabel }}</i> {{ date === null ? chooseDateLabel : formatDisplayDate(date) }}
<template v-else>
{{ date === null ? chooseDateLabel : formatDisplayDate(date) }}
</template>
</SimpleButton> </SimpleButton>
<CustomTransition name="fade"> <CustomTransition name="fade">
@ -19,7 +16,6 @@
> >
<DatepickerInline <DatepickerInline
v-model="date" v-model="date"
:show-shortcuts="showShortcuts"
@update:modelValue="updateData" @update:modelValue="updateData"
/> />
@ -52,17 +48,12 @@ const props = withDefaults(defineProps<{
modelValue: Date | null | string, modelValue: Date | null | string,
chooseDateLabel?: string, chooseDateLabel?: string,
disabled?: boolean, disabled?: boolean,
showShortcuts?: boolean,
// When the value is null, show this (italic) instead of chooseDateLabel.
emptyLabel?: string,
}>(), { }>(), {
chooseDateLabel: () => { chooseDateLabel: () => {
const {t} = useI18n({useScope: 'global'}) const {t} = useI18n({useScope: 'global'})
return t('input.datepicker.chooseDate') return t('input.datepicker.chooseDate')
}, },
disabled: false, disabled: false,
showShortcuts: true,
emptyLabel: '',
}) })
const emit = defineEmits<{ const emit = defineEmits<{

View File

@ -1,68 +1,66 @@
<template> <template>
<template v-if="showShortcuts"> <BaseButton
<BaseButton v-if="(new Date()).getHours() < 21"
v-if="(new Date()).getHours() < 21" class="datepicker__quick-select-date"
class="datepicker__quick-select-date" @click.stop="setDate('today')"
@click.stop="setDate('today')" >
> <span class="icon"><Icon :icon="['far', 'calendar-alt']" /></span>
<span class="icon"><Icon :icon="['far', 'calendar-alt']" /></span> <span class="text">
<span class="text"> <span>{{ $t('input.datepicker.today') }}</span>
<span>{{ $t('input.datepicker.today') }}</span> <span class="weekday">{{ getWeekdayFromStringInterval('today') }}</span>
<span class="weekday">{{ getWeekdayFromStringInterval('today') }}</span> </span>
</span> </BaseButton>
</BaseButton> <BaseButton
<BaseButton class="datepicker__quick-select-date"
class="datepicker__quick-select-date" @click.stop="setDate('tomorrow')"
@click.stop="setDate('tomorrow')" >
> <span class="icon"><Icon :icon="['far', 'sun']" /></span>
<span class="icon"><Icon :icon="['far', 'sun']" /></span> <span class="text">
<span class="text"> <span>{{ $t('input.datepicker.tomorrow') }}</span>
<span>{{ $t('input.datepicker.tomorrow') }}</span> <span class="weekday">{{ getWeekdayFromStringInterval('tomorrow') }}</span>
<span class="weekday">{{ getWeekdayFromStringInterval('tomorrow') }}</span> </span>
</span> </BaseButton>
</BaseButton> <BaseButton
<BaseButton class="datepicker__quick-select-date"
class="datepicker__quick-select-date" @click.stop="setDate('nextMonday')"
@click.stop="setDate('nextMonday')" >
> <span class="icon"><Icon icon="coffee" /></span>
<span class="icon"><Icon icon="coffee" /></span> <span class="text">
<span class="text"> <span>{{ $t('input.datepicker.nextMonday') }}</span>
<span>{{ $t('input.datepicker.nextMonday') }}</span> <span class="weekday">{{ getWeekdayFromStringInterval('nextMonday') }}</span>
<span class="weekday">{{ getWeekdayFromStringInterval('nextMonday') }}</span> </span>
</span> </BaseButton>
</BaseButton> <BaseButton
<BaseButton v-if="!((new Date()).getDay() === 0 && (new Date()).getHours() >= 21)"
v-if="!((new Date()).getDay() === 0 && (new Date()).getHours() >= 21)" class="datepicker__quick-select-date"
class="datepicker__quick-select-date" @click.stop="setDate('thisWeekend')"
@click.stop="setDate('thisWeekend')" >
> <span class="icon"><Icon icon="cocktail" /></span>
<span class="icon"><Icon icon="cocktail" /></span> <span class="text">
<span class="text"> <span>{{ $t('input.datepicker.thisWeekend') }}</span>
<span>{{ $t('input.datepicker.thisWeekend') }}</span> <span class="weekday">{{ getWeekdayFromStringInterval('thisWeekend') }}</span>
<span class="weekday">{{ getWeekdayFromStringInterval('thisWeekend') }}</span> </span>
</span> </BaseButton>
</BaseButton> <BaseButton
<BaseButton class="datepicker__quick-select-date"
class="datepicker__quick-select-date" @click.stop="setDate('laterThisWeek')"
@click.stop="setDate('laterThisWeek')" >
> <span class="icon"><Icon icon="chess-knight" /></span>
<span class="icon"><Icon icon="chess-knight" /></span> <span class="text">
<span class="text"> <span>{{ $t('input.datepicker.laterThisWeek') }}</span>
<span>{{ $t('input.datepicker.laterThisWeek') }}</span> <span class="weekday">{{ getWeekdayFromStringInterval('laterThisWeek') }}</span>
<span class="weekday">{{ getWeekdayFromStringInterval('laterThisWeek') }}</span> </span>
</span> </BaseButton>
</BaseButton> <BaseButton
<BaseButton class="datepicker__quick-select-date"
class="datepicker__quick-select-date" @click.stop="setDate('nextWeek')"
@click.stop="setDate('nextWeek')" >
> <span class="icon"><Icon icon="forward" /></span>
<span class="icon"><Icon icon="forward" /></span> <span class="text">
<span class="text"> <span>{{ $t('input.datepicker.nextWeek') }}</span>
<span>{{ $t('input.datepicker.nextWeek') }}</span> <span class="weekday">{{ getWeekdayFromStringInterval('nextWeek') }}</span>
<span class="weekday">{{ getWeekdayFromStringInterval('nextWeek') }}</span> </span>
</span> </BaseButton>
</BaseButton>
</template>
<div class="flatpickr-container"> <div class="flatpickr-container">
<flat-pickr <flat-pickr
@ -86,22 +84,16 @@ import {calculateNearestHours} from '@/helpers/time/calculateNearestHours'
import {createDateFromString} from '@/helpers/time/createDateFromString' import {createDateFromString} from '@/helpers/time/createDateFromString'
import {useI18n} from 'vue-i18n' import {useI18n} from 'vue-i18n'
import {useFlatpickrLanguage} from '@/helpers/useFlatpickrLanguage' 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 modelValue: Date | null | string
showShortcuts?: boolean }>()
}>(), {
showShortcuts: true,
})
const emit = defineEmits<{ const emit = defineEmits<{
'update:modelValue': [Date | null], 'update:modelValue': [Date | null],
}>() }>()
const {t} = useI18n({useScope: 'global'}) const {t} = useI18n({useScope: 'global'})
const {store: timeFormat} = useTimeFormat()
const date = ref<Date | null>(null) const date = ref<Date | null>(null)
const changed = ref(false) const changed = ref(false)
@ -119,7 +111,7 @@ const flatPickerConfig = computed(() => ({
altInput: true, altInput: true,
dateFormat: 'Y-m-d H:i', dateFormat: 'Y-m-d H:i',
enableTime: true, enableTime: true,
time_24hr: timeFormat.value === TIME_FORMAT.HOURS_24, time_24hr: true,
inline: true, inline: true,
locale: useFlatpickrLanguage().value, locale: useFlatpickrLanguage().value,
})) }))

View File

@ -7,7 +7,6 @@
}" }"
:disabled="disabled" :disabled="disabled"
:model-value="modelValue" :model-value="modelValue"
:aria-label="ariaLabel"
@update:modelValue="value => emit('update:modelValue', value)" @update:modelValue="value => emit('update:modelValue', value)"
> >
<CheckboxIcon class="fancy-checkbox__icon" /> <CheckboxIcon class="fancy-checkbox__icon" />
@ -27,12 +26,10 @@ import BaseCheckbox from '@/components/base/BaseCheckbox.vue'
withDefaults(defineProps<{ withDefaults(defineProps<{
modelValue: boolean, modelValue: boolean,
disabled?: boolean, disabled?: boolean,
isBlock?: boolean, isBlock?: boolean
ariaLabel?: string,
}>(), { }>(), {
disabled: false, disabled: false,
isBlock: false, isBlock: false,
ariaLabel: undefined,
}) })
const emit = defineEmits<{ const emit = defineEmits<{

View File

@ -28,33 +28,12 @@ function handleChange(event: Event) {
</template> </template>
<style lang="scss" scoped> <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 { label.checkbox {
cursor: pointer;
line-height: 1.25;
position: relative;
display: flex; display: flex;
align-items: center; align-items: center;
gap: .5rem; gap: .5rem;
inline-size: fit-content; 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) { &:not(:last-child) {
margin-block-end: .75rem; margin-block-end: .75rem;
} }

View File

@ -233,7 +233,8 @@ describe('FormField', () => {
}, },
}) })
const label = wrapper.find('label.two-col') const label = wrapper.find('label.two-col')
// for="" would mismatch the slotted control's id; rely on the label wrapping instead. // for would point to a different id than the slotted control generates,
// so omit it entirely and rely on the label wrapping the control.
expect(label.attributes('for')).toBeUndefined() expect(label.attributes('for')).toBeUndefined()
expect(label.find('input').exists()).toBe(true) expect(label.find('input').exists()).toBe(true)
}) })

View File

@ -35,7 +35,6 @@ const slots = useSlots()
const generatedId = useId() const generatedId = useId()
const inputId = computed(() => props.id ?? generatedId) const inputId = computed(() => props.id ?? generatedId)
const errorId = computed(() => props.error ? `${inputId.value}-error` : undefined)
const hasAddon = computed(() => !!slots.addon) const hasAddon = computed(() => !!slots.addon)
const fieldClasses = computed(() => [ const fieldClasses = computed(() => [
@ -83,18 +82,13 @@ defineExpose({
class="two-col" class="two-col"
> >
<span>{{ label }}</span> <span>{{ label }}</span>
<slot <slot :id="inputId">
:id="inputId"
:error-id="errorId"
>
<input <input
:id="inputId" :id="inputId"
ref="inputRef" ref="inputRef"
v-bind="{ ...$attrs, ...inputBindings }" v-bind="{ ...$attrs, ...inputBindings }"
:class="inputClasses" :class="inputClasses"
:disabled="disabled || undefined" :disabled="disabled || undefined"
:aria-invalid="error ? true : undefined"
:aria-describedby="errorId"
@input="handleInput" @input="handleInput"
> >
</slot> </slot>
@ -115,18 +109,13 @@ defineExpose({
{{ label }} {{ label }}
</label> </label>
<div :class="controlClasses"> <div :class="controlClasses">
<slot <slot :id="inputId">
:id="inputId"
:error-id="errorId"
>
<input <input
:id="inputId" :id="inputId"
ref="inputRef" ref="inputRef"
v-bind="{ ...$attrs, ...inputBindings }" v-bind="{ ...$attrs, ...inputBindings }"
:class="inputClasses" :class="inputClasses"
:disabled="disabled || undefined" :disabled="disabled || undefined"
:aria-invalid="error ? true : undefined"
:aria-describedby="errorId"
@input="handleInput" @input="handleInput"
> >
</slot> </slot>
@ -140,9 +129,7 @@ defineExpose({
</template> </template>
<p <p
v-if="error" v-if="error"
:id="errorId"
class="help is-danger" class="help is-danger"
role="alert"
> >
{{ error }} {{ error }}
</p> </p>

View File

@ -22,7 +22,6 @@ defineOptions({inheritAttrs: false})
const fallbackId = useId() const fallbackId = useId()
const inputId = computed(() => props.id ?? fallbackId) const inputId = computed(() => props.id ?? fallbackId)
const errorId = computed(() => props.error ? `${inputId.value}-error` : undefined)
const inputClasses = computed(() => [ const inputClasses = computed(() => [
'input', 'input',
@ -68,15 +67,11 @@ defineExpose({
v-bind="{ ...$attrs, ...inputBindings }" v-bind="{ ...$attrs, ...inputBindings }"
:class="inputClasses" :class="inputClasses"
:disabled="disabled || undefined" :disabled="disabled || undefined"
:aria-invalid="error ? true : undefined"
:aria-describedby="errorId"
@input="handleInput" @input="handleInput"
> >
<p <p
v-if="error" v-if="error"
:id="errorId"
class="help is-danger" class="help is-danger"
role="alert"
> >
{{ error }} {{ error }}
</p> </p>

View File

@ -155,7 +155,9 @@ describe('FormSelect', () => {
}, },
}) })
const select = wrapper.find('select') const select = wrapper.find('select')
// Forcing :value="undefined" would break the native default-to-first-option behavior. // Without an explicit value binding, the native select defaults to the
// first option. If the component forced :value="undefined" that default
// would be broken.
expect((select.element as HTMLSelectElement).value).toBe('') expect((select.element as HTMLSelectElement).value).toBe('')
}) })

View File

@ -27,7 +27,6 @@ defineOptions({inheritAttrs: false})
const fallbackId = useId() const fallbackId = useId()
const selectId = computed(() => props.id ?? fallbackId) const selectId = computed(() => props.id ?? fallbackId)
const errorId = computed(() => props.error ? `${selectId.value}-error` : undefined)
const wrapperClasses = computed(() => [ const wrapperClasses = computed(() => [
'select', 'select',
@ -71,8 +70,6 @@ function handleChange(event: Event) {
:id="selectId" :id="selectId"
v-bind="{ ...$attrs, ...selectBindings }" v-bind="{ ...$attrs, ...selectBindings }"
:disabled="disabled || undefined" :disabled="disabled || undefined"
:aria-invalid="error ? true : undefined"
:aria-describedby="errorId"
@change="handleChange" @change="handleChange"
> >
<template v-if="normalizedOptions"> <template v-if="normalizedOptions">
@ -90,9 +87,7 @@ function handleChange(event: Event) {
</div> </div>
<p <p
v-if="error" v-if="error"
:id="errorId"
class="help is-danger" class="help is-danger"
role="alert"
> >
{{ error }} {{ error }}
</p> </p>

View File

@ -7,10 +7,8 @@
:placeholder="$t('user.auth.passwordPlaceholder')" :placeholder="$t('user.auth.passwordPlaceholder')"
required required
:type="passwordFieldType" :type="passwordFieldType"
:autocomplete="autocomplete" autocomplete="current-password"
:tabindex="tabindex" :tabindex="tabindex"
:aria-invalid="isValid !== true ? true : undefined"
:aria-describedby="errorId"
@keyup.enter="e => $emit('submit', e)" @keyup.enter="e => $emit('submit', e)"
@focusout="() => {validate(); validateAfterFirst = true}" @focusout="() => {validate(); validateAfterFirst = true}"
@keyup="() => {validateAfterFirst ? validate() : null}" @keyup="() => {validateAfterFirst ? validate() : null}"
@ -27,16 +25,14 @@
</div> </div>
<p <p
v-if="isValid !== true" v-if="isValid !== true"
:id="errorId"
class="help is-danger" class="help is-danger"
role="alert"
> >
{{ isValid }} {{ isValid }}
</p> </p>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import {computed, ref, watchEffect} from 'vue' import {ref, watchEffect} from 'vue'
import {useDebounceFn} from '@vueuse/core' import {useDebounceFn} from '@vueuse/core'
import {useI18n} from 'vue-i18n' import {useI18n} from 'vue-i18n'
import BaseButton from '@/components/base/BaseButton.vue' 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. // This prop is a workaround to trigger validation from the outside when the user never had focus in the input.
validateInitially?: boolean, validateInitially?: boolean,
validateMinLength?: boolean, validateMinLength?: boolean,
autocomplete?: string,
}>(), { }>(), {
tabindex: undefined, tabindex: undefined,
validateMinLength: true, validateMinLength: true,
autocomplete: 'current-password',
}) })
const emit = defineEmits<{ const emit = defineEmits<{
@ -65,7 +59,6 @@ const password = ref('')
// eslint-disable-next-line vue/no-setup-props-reactivity-loss // eslint-disable-next-line vue/no-setup-props-reactivity-loss
const isValid = ref<true | string>(props.validateInitially === true ? true : '') const isValid = ref<true | string>(props.validateInitially === true ? true : '')
const validateAfterFirst = ref(false) const validateAfterFirst = ref(false)
const errorId = computed(() => isValid.value !== true ? 'password-error' : undefined)
const validate = useDebounceFn(() => { const validate = useDebounceFn(() => {
const valid = validatePassword(password.value, props.validateMinLength) const valid = validatePassword(password.value, props.validateMinLength)

View File

@ -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>

View File

@ -166,7 +166,6 @@ import Mention from '@tiptap/extension-mention'
import {TaskList} from '@tiptap/extension-list' import {TaskList} from '@tiptap/extension-list'
import {TaskItemWithId} from './taskItemWithId' import {TaskItemWithId} from './taskItemWithId'
import {BlockquoteWithCommentId} from './blockquoteWithCommentId'
import HardBreak from '@tiptap/extension-hard-break' import HardBreak from '@tiptap/extension-hard-break'
import Commands from './commands' import Commands from './commands'
@ -418,9 +417,7 @@ const extensions : Extensions = [
StarterKit.configure({ StarterKit.configure({
codeBlock: false, codeBlock: false,
hardBreak: false, hardBreak: false,
blockquote: false,
}), }),
BlockquoteWithCommentId,
CodeBlockLowlight.configure({ CodeBlockLowlight.configure({
lowlight: createLowlight(common), lowlight: createLowlight(common),
@ -722,7 +719,7 @@ async function addImage(event: Event) {
return return
} }
const url = await inputPrompt(event.target.getBoundingClientRect(), '', editor.value) const url = await inputPrompt(event.target.getBoundingClientRect())
if (url) { if (url) {
editor.value?.chain().focus().setImage({src: url}).run() editor.value?.chain().focus().setImage({src: url}).run()
@ -778,24 +775,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 // See https://github.com/github/hotkey/discussions/85#discussioncomment-5214660
function setFocusToEditor(event: KeyboardEvent) { function setFocusToEditor(event: KeyboardEvent) {

View File

@ -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()
})
})

View File

@ -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)
},
})

View File

@ -5,7 +5,6 @@ import {PluginKey, type EditorState} from '@tiptap/pm/state'
import EmojiList from './EmojiList.vue' import EmojiList from './EmojiList.vue'
import {loadEmojis, filterEmojis, type EmojiEntry} from './emojiData' import {loadEmojis, filterEmojis, type EmojiEntry} from './emojiData'
import {getPopupContainer} from '../popupContainer'
export const EmojiSuggestionPluginKey = new PluginKey('emojiSuggestion') export const EmojiSuggestionPluginKey = new PluginKey('emojiSuggestion')
@ -79,7 +78,7 @@ export default function emojiSuggestionSetup() {
popupElement.style.left = '0' popupElement.style.left = '0'
popupElement.style.zIndex = '4700' popupElement.style.zIndex = '4700'
popupElement.appendChild(component.element!) popupElement.appendChild(component.element!)
getPopupContainer(props.editor).appendChild(popupElement) document.body.appendChild(popupElement)
const rect = props.clientRect() const rect = props.clientRect()
if (!rect) { if (!rect) {
@ -109,7 +108,7 @@ export default function emojiSuggestionSetup() {
cleanupFloating = null cleanupFloating = null
} }
if (popupElement) { if (popupElement) {
popupElement.remove() document.body.removeChild(popupElement)
popupElement = null popupElement = null
} }
component?.destroy() component?.destroy()

View File

@ -3,7 +3,7 @@ import inputPrompt from '@/helpers/inputPrompt'
export async function setLinkInEditor(pos: DOMRect, editor: Editor | null | undefined) { export async function setLinkInEditor(pos: DOMRect, editor: Editor | null | undefined) {
const previousUrl = editor?.getAttributes('link').href || '' const previousUrl = editor?.getAttributes('link').href || ''
const url = await inputPrompt(pos, previousUrl, editor ?? undefined) const url = await inputPrompt(pos, previousUrl)
// empty // empty
if (url === '') { if (url === '') {

View File

@ -135,7 +135,6 @@ defineExpose({
inline-size: 100%; inline-size: 100%;
text-align: start; text-align: start;
background: transparent; background: transparent;
color: inherit;
border-radius: $radius; border-radius: $radius;
border: 0; border: 0;
padding: 0.375rem 0.5rem; padding: 0.375rem 0.5rem;

View File

@ -2,7 +2,6 @@
<Modal <Modal
:overflow="true" :overflow="true"
:wide="wide" :wide="wide"
:aria-label="title"
@close="$router.back()" @close="$router.back()"
> >
<Card <Card

View File

@ -13,7 +13,6 @@
> >
<BaseButton <BaseButton
class="dropdown-trigger is-flex" class="dropdown-trigger is-flex"
:aria-label="triggerLabel"
@click="toggleOpen" @click="toggleOpen"
> >
<Icon <Icon
@ -50,10 +49,8 @@ import BaseButton from '@/components/base/BaseButton.vue'
withDefaults(defineProps<{ withDefaults(defineProps<{
triggerIcon?: IconProp triggerIcon?: IconProp
triggerLabel?: string
}>(), { }>(), {
triggerIcon: 'ellipsis-h', triggerIcon: 'ellipsis-h',
triggerLabel: undefined,
}) })
const emit = defineEmits<{ const emit = defineEmits<{

View File

@ -1,7 +1,6 @@
import {library} from '@fortawesome/fontawesome-svg-core' import {library} from '@fortawesome/fontawesome-svg-core'
import { import {
faAlignLeft, faAlignLeft,
faAngleLeft,
faAngleRight, faAngleRight,
faAnglesUp, faAnglesUp,
faArchive, faArchive,
@ -59,7 +58,6 @@ import {
faPlay, faPlay,
faPlus, faPlus,
faPowerOff, faPowerOff,
faRss,
faSearch, faSearch,
faShareAlt, faShareAlt,
faSignOutAlt, faSignOutAlt,
@ -75,7 +73,6 @@ import {
faTimes, faTimes,
faTrashAlt, faTrashAlt,
faUser, faUser,
faUserEdit,
faUsers, faUsers,
faQuoteRight, faQuoteRight,
faListUl, faListUl,
@ -122,7 +119,6 @@ library.add(faCode)
library.add(faQuoteRight) library.add(faQuoteRight)
library.add(faListUl) library.add(faListUl)
library.add(faAlignLeft) library.add(faAlignLeft)
library.add(faAngleLeft)
library.add(faAngleRight) library.add(faAngleRight)
library.add(faArchive) library.add(faArchive)
library.add(faArrowLeft) library.add(faArrowLeft)
@ -171,7 +167,6 @@ library.add(faPercent)
library.add(faPlay) library.add(faPlay)
library.add(faPlus) library.add(faPlus)
library.add(faPowerOff) library.add(faPowerOff)
library.add(faRss)
library.add(faSave) library.add(faSave)
library.add(faSearch) library.add(faSearch)
library.add(faShareAlt) library.add(faShareAlt)
@ -191,7 +186,6 @@ library.add(faTimes)
library.add(faTimesCircle) library.add(faTimesCircle)
library.add(faTrashAlt) library.add(faTrashAlt)
library.add(faUser) library.add(faUser)
library.add(faUserEdit)
library.add(faUsers) library.add(faUsers)
library.add(faArrowDownShortWide) library.add(faArrowDownShortWide)
library.add(faArrowUpFromBracket) library.add(faArrowUpFromBracket)

View File

@ -3,14 +3,6 @@ import {mount, flushPromises} from '@vue/test-utils'
import {nextTick} from 'vue' import {nextTick} from 'vue'
import Modal from './Modal.vue' import Modal from './Modal.vue'
const globalMocks = {
global: {
mocks: {
$t: (key: string) => key,
},
},
}
// jsdom does not implement HTMLDialogElement.showModal/close. // jsdom does not implement HTMLDialogElement.showModal/close.
// Provide stubs so that the [open] attribute — which CSS and our tests // Provide stubs so that the [open] attribute — which CSS and our tests
// check — is flipped the same way the real browser would. // check — is flipped the same way the real browser would.
@ -58,7 +50,6 @@ afterEach(() => {
describe('Modal.vue — open race condition (#2590)', () => { describe('Modal.vue — open race condition (#2590)', () => {
it('opens the dialog when enabled flips false → true', async () => { it('opens the dialog when enabled flips false → true', async () => {
const wrapper = mount(Modal, { const wrapper = mount(Modal, {
...globalMocks,
attachTo: document.body, attachTo: document.body,
props: {enabled: false}, props: {enabled: false},
slots: {default: '<p class="test-body">hi</p>'}, slots: {default: '<p class="test-body">hi</p>'},
@ -93,7 +84,6 @@ describe('Modal.vue — open race condition (#2590)', () => {
// resolves after the first state change, the dialog must already have // resolves after the first state change, the dialog must already have
// [open] set — no additional flushPromises or extra ticks required. // [open] set — no additional flushPromises or extra ticks required.
const wrapper = mount(Modal, { const wrapper = mount(Modal, {
...globalMocks,
attachTo: document.body, attachTo: document.body,
props: {enabled: false}, props: {enabled: false},
slots: {default: '<p class="test-body">hi</p>'}, slots: {default: '<p class="test-body">hi</p>'},
@ -121,7 +111,6 @@ describe('Modal.vue — open race condition (#2590)', () => {
// nextTick callback whose timing could fire before the dialog mounted, // nextTick callback whose timing could fire before the dialog mounted,
// skipping the showModal() call entirely and leaving .open === false. // skipping the showModal() call entirely and leaving .open === false.
const wrapper = mount(Modal, { const wrapper = mount(Modal, {
...globalMocks,
attachTo: document.body, attachTo: document.body,
props: {enabled: true}, props: {enabled: true},
slots: {default: '<p class="test-body">hi</p>'}, slots: {default: '<p class="test-body">hi</p>'},
@ -143,7 +132,6 @@ describe('Modal.vue — open race condition (#2590)', () => {
it('closes the dialog when enabled flips true → false', async () => { it('closes the dialog when enabled flips true → false', async () => {
const wrapper = mount(Modal, { const wrapper = mount(Modal, {
...globalMocks,
attachTo: document.body, attachTo: document.body,
props: {enabled: true}, props: {enabled: true},
slots: {default: '<p class="test-body">hi</p>'}, slots: {default: '<p class="test-body">hi</p>'},
@ -171,7 +159,6 @@ describe('Modal.vue — open race condition (#2590)', () => {
// element mounts. If props.enabled has flipped back to false by the // element mounts. If props.enabled has flipped back to false by the
// time the mount happens, the watcher must not call showModal(). // time the mount happens, the watcher must not call showModal().
const wrapper = mount(Modal, { const wrapper = mount(Modal, {
...globalMocks,
attachTo: document.body, attachTo: document.body,
props: {enabled: false}, props: {enabled: false},
slots: {default: '<p class="test-body">hi</p>'}, slots: {default: '<p class="test-body">hi</p>'},
@ -202,7 +189,6 @@ describe('Modal.vue — open race condition (#2590)', () => {
// sure openDialog() clears the leftover data-closing flag itself; // sure openDialog() clears the leftover data-closing flag itself;
// otherwise the dialog stays stuck at opacity 0. // otherwise the dialog stays stuck at opacity 0.
const wrapper = mount(Modal, { const wrapper = mount(Modal, {
...globalMocks,
attachTo: document.body, attachTo: document.body,
props: {enabled: true}, props: {enabled: true},
slots: {default: '<p class="test-body">hi</p>'}, slots: {default: '<p class="test-body">hi</p>'},

View File

@ -16,8 +16,7 @@
@mousedown.self.prevent.stop="$emit('close')" @mousedown.self.prevent.stop="$emit('close')"
> >
<BaseButton <BaseButton
:aria-label="$t('misc.closeDialog')" class="close"
class="close d-print-none"
@click="$emit('close')" @click="$emit('close')"
> >
<Icon icon="times" /> <Icon icon="times" />
@ -62,13 +61,13 @@
<script lang="ts" setup> <script lang="ts" setup>
import BaseButton from '@/components/base/BaseButton.vue' import BaseButton from '@/components/base/BaseButton.vue'
import {ref, useAttrs, watch, onBeforeUnmount, onMounted} from 'vue' import {ref, useAttrs, watch, onBeforeUnmount} from 'vue'
const props = withDefaults(defineProps<{ const props = withDefaults(defineProps<{
enabled?: boolean, enabled?: boolean,
overflow?: boolean, overflow?: boolean,
wide?: boolean, wide?: boolean,
variant?: 'default' | 'hint-modal' | 'scrolling' | 'top', variant?: 'default' | 'hint-modal' | 'scrolling',
}>(), { }>(), {
enabled: true, enabled: true,
overflow: false, overflow: false,
@ -158,37 +157,6 @@ watch(dialogRef, (dialog) => {
dialog.showModal() 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)
})
onBeforeUnmount(() => { onBeforeUnmount(() => {
if (closeTimer) { if (closeTimer) {
clearTimeout(closeTimer) clearTimeout(closeTimer)
@ -198,8 +166,6 @@ onBeforeUnmount(() => {
if (previouslyFocused.value instanceof HTMLElement) { if (previouslyFocused.value instanceof HTMLElement) {
previouslyFocused.value.focus() previouslyFocused.value.focus()
} }
window.removeEventListener('beforeprint', handleBeforePrint)
window.removeEventListener('afterprint', handleAfterPrint)
}) })
</script> </script>
@ -211,13 +177,7 @@ $modal-width: 1024px;
// Reset UA dialog styles // Reset UA dialog styles
padding: 0; padding: 0;
border: none; border: none;
// The scrim lives on the dialog element, not on ::backdrop: Chromium background: transparent;
// 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);
color: #ffffff; color: #ffffff;
// Fill viewport // Fill viewport
position: fixed; position: fixed;
@ -227,12 +187,10 @@ $modal-width: 1024px;
max-inline-size: 100%; max-inline-size: 100%;
max-block-size: 100%; max-block-size: 100%;
// Transitions. No display/allow-discrete transition needed: the close // Transitions
// fade runs while the dialog is still [open] (data-closing + timer in
// closeDialog), and transitioning display triggers the Chromium paint
// bug above.
opacity: 0; opacity: 0;
transition: opacity 150ms ease; transition: opacity 150ms ease,
display 150ms ease allow-discrete;
&[open]:not([data-closing]) { &[open]:not([data-closing]) {
opacity: 1; opacity: 1;
@ -244,11 +202,16 @@ $modal-width: 1024px;
&::backdrop { &::backdrop {
background-color: rgba(0, 0, 0, 0); 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 &[open]:not([data-closing])::backdrop {
&:has(.is-quick-add-mode) { background-color: rgba(0, 0, 0, .8);
background: transparent;
@starting-style {
background-color: rgba(0, 0, 0, 0);
}
} }
} }
@ -264,20 +227,13 @@ $modal-width: 1024px;
} }
.default .modal-content, .default .modal-content,
.hint-modal .modal-content, .hint-modal .modal-content {
.top .modal-content {
text-align: center; text-align: center;
position: absolute; position: absolute;
// fine to use top/left since we're only using this to position it centered // fine to use top/left since we're only using this to position it centered
inset-block-start: 50%; inset-block-start: 50%;
inset-inline-start: 50%; inset-inline-start: 50%;
transform: translate(-50%, -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"] & { [dir="rtl"] & {
transform: translate(50%, -50%); transform: translate(50%, -50%);
@ -287,9 +243,6 @@ $modal-width: 1024px;
margin: 0; margin: 0;
position: static; position: static;
transform: none; transform: none;
// the fullscreen mobile layout flows and scrolls in .modal-container
max-block-size: none;
overflow: visible;
} }
.modal-header { .modal-header {
@ -302,40 +255,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 // scrolling-content
// used e.g. for <TaskDetailViewModal> // used e.g. for <TaskDetailViewModal>
.scrolling .modal-content { .scrolling .modal-content {
@ -427,32 +346,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) { .modal-content:has(.modal-header) {
display: flex; display: flex;
flex-direction: column; flex-direction: column;

View File

@ -17,10 +17,7 @@
{{ $t("misc.welcomeBack") }} {{ $t("misc.welcomeBack") }}
</h2> </h2>
</section> </section>
<main <section class="content">
id="main-content"
class="content"
>
<div> <div>
<h2 <h2
v-if="title" v-if="title"
@ -38,7 +35,7 @@
<slot /> <slot />
</div> </div>
<Legal /> <Legal />
</main> </section>
</div> </div>
</div> </div>
</template> </template>

View File

@ -1,96 +1,64 @@
<template> <template>
<Teleport :to="teleportTarget"> <Notifications
<Notifications position="bottom left"
position="bottom left" :max="2"
:max="2" :ignore-duplicates="true"
:ignore-duplicates="true" class="global-notification"
class="global-notification" >
role="status" <template #body="{ item, close }">
aria-live="polite" <!-- FIXME: overlay whole notification with button and add event listener on that button instead -->
> <div
<template #body="{ item, close }"> class="vue-notification-template vue-notification"
<!-- FIXME: overlay whole notification with button and add event listener on that button instead --> :class="[
item.type,
]"
@click="close()"
>
<div <div
class="vue-notification-template vue-notification" v-if="item.title"
:class="[ class="notification-title"
item.type,
]"
@click="close()"
> >
<div {{ item.title }}
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>
</div> </div>
</template> <div class="notification-content">
</Notifications> <template v-if="Array.isArray(item.text)">
</Teleport> <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> </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> <style scoped>
.vue-notification { .vue-notification {
z-index: 9999; z-index: 9999;

View File

@ -4,39 +4,38 @@
:current-page="currentPage" :current-page="currentPage"
> >
<template #previous="{ disabled }"> <template #previous="{ disabled }">
<PaginationItem <RouterLink
variant="previous" :disabled="disabled || undefined"
:to="getRouteForPagination(currentPage - 1)" :to="getRouteForPagination(currentPage - 1)"
:disabled="disabled" class="pagination-previous"
> >
{{ $t('misc.previous') }} {{ $t('misc.previous') }}
</PaginationItem> </RouterLink>
</template> </template>
<template #next="{ disabled }"> <template #next="{ disabled }">
<PaginationItem <RouterLink
variant="next" :disabled="disabled || undefined"
:to="getRouteForPagination(currentPage + 1)" :to="getRouteForPagination(currentPage + 1)"
:disabled="disabled" class="pagination-next"
> >
{{ $t('misc.next') }} {{ $t('misc.next') }}
</PaginationItem> </RouterLink>
</template> </template>
<template #page-link="{ page, isCurrent }"> <template #page-link="{ page, isCurrent }">
<PaginationItem <RouterLink
variant="link" class="pagination-link"
:to="getRouteForPagination(page.number)"
:is-current="isCurrent"
:aria-label="'Goto page ' + page.number" :aria-label="'Goto page ' + page.number"
:class="{ 'is-current': isCurrent }"
:to="getRouteForPagination(page.number)"
> >
{{ page.number }} {{ page.number }}
</PaginationItem> </RouterLink>
</template> </template>
</BasePagination> </BasePagination>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import BasePagination from '@/components/base/BasePagination.vue' import BasePagination from '@/components/base/BasePagination.vue'
import PaginationItem from '@/components/misc/PaginationItem.vue'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
withDefaults(defineProps<{ withDefaults(defineProps<{

View File

@ -4,39 +4,39 @@
:current-page="currentPage" :current-page="currentPage"
> >
<template #previous="{ disabled }"> <template #previous="{ disabled }">
<PaginationItem <BaseButton
variant="previous"
:disabled="disabled" :disabled="disabled"
class="pagination-previous"
@click="changePage(currentPage - 1)" @click="changePage(currentPage - 1)"
> >
{{ $t('misc.previous') }} {{ $t('misc.previous') }}
</PaginationItem> </BaseButton>
</template> </template>
<template #next="{ disabled }"> <template #next="{ disabled }">
<PaginationItem <BaseButton
variant="next"
:disabled="disabled" :disabled="disabled"
class="pagination-next"
@click="changePage(currentPage + 1)" @click="changePage(currentPage + 1)"
> >
{{ $t('misc.next') }} {{ $t('misc.next') }}
</PaginationItem> </BaseButton>
</template> </template>
<template #page-link="{ page, isCurrent }"> <template #page-link="{ page, isCurrent }">
<PaginationItem <BaseButton
variant="link" class="pagination-link"
:is-current="isCurrent"
:aria-label="'Goto page ' + page.number" :aria-label="'Goto page ' + page.number"
:class="{ 'is-current': isCurrent }"
@click="changePage(page.number)" @click="changePage(page.number)"
> >
{{ page.number }} {{ page.number }}
</PaginationItem> </BaseButton>
</template> </template>
</BasePagination> </BasePagination>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import BasePagination from '@/components/base/BasePagination.vue' 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<{ const props = withDefaults(defineProps<{
totalPages: number, totalPages: number,

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -2,24 +2,15 @@
<div <div
class="user" class="user"
:class="{'is-inline': isInline}" :class="{'is-inline': isInline}"
:style="{'--avatar-size': `${avatarSize}px`}"
> >
<span class="avatar-wrapper"> <img
<img v-tooltip="displayName"
v-tooltip="displayName" :height="avatarSize"
:height="avatarSize" :src="avatarSrc"
:src="avatarSrc" :width="avatarSize"
:width="avatarSize" :alt="'Avatar of ' + displayName"
:alt="'Avatar of ' + displayName" class="avatar"
class="avatar" >
>
<span
v-if="isBot"
v-tooltip="t('user.settings.bots.badge')"
class="bot-badge"
aria-label="Bot"
>B</span>
</span>
<span <span
v-if="showUsername" v-if="showUsername"
class="username" class="username"
@ -29,7 +20,6 @@
<script lang="ts" setup> <script lang="ts" setup>
import {computed, ref, watch} from 'vue' import {computed, ref, watch} from 'vue'
import {useI18n} from 'vue-i18n'
import {fetchAvatarBlobUrl, getDisplayName} from '@/models/user' import {fetchAvatarBlobUrl, getDisplayName} from '@/models/user'
import type {IUser} from '@/modelTypes/IUser' import type {IUser} from '@/modelTypes/IUser'
@ -45,10 +35,7 @@ const props = withDefaults(defineProps<{
isInline: false, isInline: false,
}) })
const {t} = useI18n({useScope: 'global'})
const displayName = computed(() => getDisplayName(props.user)) const displayName = computed(() => getDisplayName(props.user))
const isBot = computed(() => ((props.user as IUser & {botOwnerId?: number}).botOwnerId ?? 0) > 0)
const avatarSrc = ref('') const avatarSrc = ref('')
async function loadAvatar() { 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 { .avatar {
inline-size: var(--avatar-size);
block-size: var(--avatar-size);
border-radius: 100%; border-radius: 100%;
vertical-align: middle; vertical-align: middle;
} margin-inline-end: .5rem;
.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;
} }
</style> </style>

View File

@ -145,7 +145,7 @@ function doDelete() {
<template #default="{id}"> <template #default="{id}">
<FormInput <FormInput
:id="id" :id="id"
v-model="newWebhook.basicAuthUser" v-model="newWebhook.basicauthuser"
/> />
</template> </template>
</FormField> </FormField>
@ -153,7 +153,7 @@ function doDelete() {
<template #default="{id}"> <template #default="{id}">
<FormInput <FormInput
:id="id" :id="id"
v-model="newWebhook.basicAuthPassword" v-model="newWebhook.basicauthpassword"
/> />
</template> </template>
</FormField> </FormField>

View File

@ -24,18 +24,7 @@
ref="popup" ref="popup"
class="notifications-list" class="notifications-list"
> >
<div class="head"> <span class="head">{{ $t('notification.title') }}</span>
<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>
<div <div
v-for="(n, index) in notifications" v-for="(n, index) in notifications"
:key="n.id" :key="n.id"
@ -295,19 +284,6 @@ async function markAllRead() {
font-family: $vikunja-font; font-family: $vikunja-font;
font-size: 1rem; font-size: 1rem;
padding: .5rem; 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 { .single-notification {

View File

@ -31,7 +31,6 @@
> >
{{ $t('menu.views') }} {{ $t('menu.views') }}
</DropdownItem> </DropdownItem>
<slot name="before-delete" />
<DropdownItem <DropdownItem
:to="{ name: 'filter.settings.delete', params: { projectId: project.id } }" :to="{ name: 'filter.settings.delete', params: { projectId: project.id } }"
icon="trash-alt" icon="trash-alt"
@ -110,9 +109,8 @@
> >
{{ $t('menu.createProject') }} {{ $t('menu.createProject') }}
</DropdownItem> </DropdownItem>
<slot name="before-delete" />
<DropdownItem <DropdownItem
v-if="forceAllActions || project.maxPermission === PERMISSIONS.ADMIN" v-if="project.maxPermission === PERMISSIONS.ADMIN"
v-tooltip="isDefaultProject ? $t('menu.cantDeleteIsDefault') : ''" v-tooltip="isDefaultProject ? $t('menu.cantDeleteIsDefault') : ''"
:to="{ name: 'project.settings.delete', params: { projectId: project.id } }" :to="{ name: 'project.settings.delete', params: { projectId: project.id } }"
icon="trash-alt" icon="trash-alt"
@ -141,12 +139,9 @@ import {useProjectStore} from '@/stores/projects'
import {useAuthStore} from '@/stores/auth' import {useAuthStore} from '@/stores/auth'
import {PERMISSIONS} from '@/constants/permissions' import {PERMISSIONS} from '@/constants/permissions'
const props = withDefaults(defineProps<{ const props = defineProps<{
project: IProject project: IProject
forceAllActions?: boolean }>()
}>(), {
forceAllActions: false,
})
const projectStore = useProjectStore() const projectStore = useProjectStore()
const subscription = ref<ISubscription | null>(null) const subscription = ref<ISubscription | null>(null)

View File

@ -16,10 +16,7 @@
</p> </p>
<div class="field"> <div class="field">
<div class="select is-fullwidth"> <div class="select is-fullwidth">
<select <select v-model="selected">
v-model="selected"
:aria-label="$t('misc.sortBy')"
>
<option <option
v-for="o in options" v-for="o in options"
:key="o.value" :key="o.value"

View File

@ -8,14 +8,14 @@
<template #default> <template #default>
<Card :has-content="false"> <Card :has-content="false">
<div class="gantt-options"> <div class="gantt-options">
<FormField :label="$t('misc.dateRange')"> <FormField :label="$t('project.gantt.range')">
<Foo <Foo
id="range" id="range"
ref="flatPickerEl" ref="flatPickerEl"
v-model="flatPickerDateRange" v-model="flatPickerDateRange"
:config="flatPickerConfig" :config="flatPickerConfig"
class="input" class="input"
:placeholder="$t('misc.dateRange')" :placeholder="$t('project.gantt.range')"
/> />
</FormField> </FormField>
<div <div

View File

@ -74,7 +74,6 @@
v-if="canWrite && !collapsedBuckets[bucket.id]" v-if="canWrite && !collapsedBuckets[bucket.id]"
class="is-right options" class="is-right options"
trigger-icon="ellipsis-v" trigger-icon="ellipsis-v"
:trigger-label="$t('project.kanban.bucketOptions')"
@close="() => showSetLimitInput = false" @close="() => showSetLimitInput = false"
> >
<div <div
@ -109,7 +108,7 @@
@click.stop="showSetLimitInput = true" @click.stop="showSetLimitInput = true"
> >
{{ {{
$t('project.kanban.limit', {limit: bucket.limit > 0 ? bucket.limit : $t('misc.notSet')}) $t('project.kanban.limit', {limit: bucket.limit > 0 ? bucket.limit : $t('project.kanban.noLimit')})
}} }}
</DropdownItem> </DropdownItem>
<DropdownItem <DropdownItem
@ -1094,7 +1093,6 @@ $filter-container-height: '1rem - #{$switch-view-height}';
.bucket-footer { .bucket-footer {
position: sticky; position: sticky;
inset-block-end: 0; inset-block-end: 0;
z-index: 2;
block-size: min-content; block-size: min-content;
padding: .5rem; padding: .5rem;
background-color: var(--grey-100); background-color: var(--grey-100);

View File

@ -9,7 +9,6 @@ import {useLabelStore} from '@/stores/labels'
import {useProjectStore} from '@/stores/projects' import {useProjectStore} from '@/stores/projects'
import XButton from '@/components/input/Button.vue' import XButton from '@/components/input/Button.vue'
import FancyCheckbox from '@/components/input/FancyCheckbox.vue'
import FilterInputDocs from '@/components/input/filter/FilterInputDocs.vue' import FilterInputDocs from '@/components/input/filter/FilterInputDocs.vue'
import FilterInput from '@/components/input/filter/FilterInput.vue' import FilterInput from '@/components/input/filter/FilterInput.vue'
import FormField from '@/components/input/FormField.vue' import FormField from '@/components/input/FormField.vue'
@ -59,16 +58,6 @@ onBeforeMount(() => {
filter.filter = filter.s filter.filter = filter.s
} }
// AbstractModel.assignData() runs objectToCamelCase recursively on all
// nested objects, which converts filter_include_nulls to filterIncludeNulls
// inside the filter object. IFilters intentionally uses snake_case keys to
// match the API query param format. We check both key forms here to handle
// data coming from either the API response (camelCased by assignData) or
// from a freshly constructed filter object (snake_case).
filter.filter_include_nulls = filterInput.filter_include_nulls
?? (filterInput as Record<string, unknown>).filterIncludeNulls as boolean
?? false
return filter return filter
} }
@ -87,18 +76,16 @@ onBeforeMount(() => {
}) })
function save() { function save() {
const transformFilterForApi = (filterInput: IFilters): IFilters => { const transformFilterForApi = (filterQuery: string): IFilters => {
const filterString = transformFilterStringForApi( const filterString = transformFilterStringForApi(
filterInput?.filter || '', filterQuery,
labelTitle => labelStore.getLabelByExactTitle(labelTitle)?.id || null, labelTitle => labelStore.getLabelByExactTitle(labelTitle)?.id || null,
projectTitle => { projectTitle => {
const found = projectStore.findProjectByExactname(projectTitle) const found = projectStore.findProjectByExactname(projectTitle)
return found?.id || null return found?.id || null
}, },
) )
const filter: IFilters = { const filter: IFilters = {}
filter_include_nulls: filterInput?.filter_include_nulls ?? false,
}
if (hasFilterQuery(filterString)) { if (hasFilterQuery(filterString)) {
filter.filter = filterString filter.filter = filterString
} else { } else {
@ -110,10 +97,10 @@ function save() {
emit('update:modelValue', { emit('update:modelValue', {
...view.value, ...view.value,
filter: transformFilterForApi(view.value?.filter), filter: transformFilterForApi(view.value?.filter?.filter || ''),
bucketConfiguration: view.value?.bucketConfiguration.map(bc => ({ bucketConfiguration: view.value?.bucketConfiguration.map(bc => ({
title: bc.title, title: bc.title,
filter: transformFilterForApi(bc.filter), filter: transformFilterForApi(bc.filter?.filter || ''),
})), })),
}) })
} }
@ -185,18 +172,10 @@ function handleBubbleSave() {
class="mbe-1" class="mbe-1"
/> />
<div class="is-size-7 mbe-2"> <div class="is-size-7 mbe-3">
<FilterInputDocs /> <FilterInputDocs />
</div> </div>
<div class="field mbe-3">
<FancyCheckbox
v-model="view.filter.filter_include_nulls"
>
{{ $t('filters.attributes.includeNulls') }}
</FancyCheckbox>
</div>
<div <div
v-if="view.viewKind === 'kanban'" v-if="view.viewKind === 'kanban'"
class="field" class="field"
@ -266,24 +245,16 @@ function handleBubbleSave() {
class="mbe-2" class="mbe-2"
/> />
<div class="is-size-7 mbe-2"> <div class="is-size-7">
<FilterInputDocs /> <FilterInputDocs />
</div> </div>
<div class="field mbe-3">
<FancyCheckbox
v-model="view.bucketConfiguration[index].filter.filter_include_nulls"
>
{{ $t('filters.attributes.includeNulls') }}
</FancyCheckbox>
</div>
</div> </div>
</div> </div>
<div class="is-flex is-justify-content-end"> <div class="is-flex is-justify-content-end">
<XButton <XButton
variant="secondary" variant="secondary"
icon="plus" icon="plus"
@click="() => view.bucketConfiguration.push({title: '', filter: {filter: '', filter_include_nulls: false}})" @click="() => view.bucketConfiguration.push({title: '', filter: {filter: ''}})"
> >
{{ $t('project.kanban.addBucket') }} {{ $t('project.kanban.addBucket') }}
</XButton> </XButton>
@ -331,32 +302,4 @@ function handleBubbleSave() {
inline-size: 100%; inline-size: 100%;
} }
} }
// Ported from bulma-css-variables/sass/form/checkbox-radio.sass
// (the %checkbox-radio placeholder plus the .radio + .radio sibling rule),
// scoped to this component so we can drop the global Bulma import.
label.radio {
cursor: pointer;
display: inline-block;
line-height: 1.25;
position: relative;
input {
cursor: pointer;
}
&:hover {
color: var(--input-hover-color);
}
&[disabled],
input[disabled] {
color: var(--input-disabled-color);
cursor: not-allowed;
}
& + .radio {
margin-inline-start: .5em;
}
}
</style> </style>

View File

@ -2,7 +2,6 @@
<Modal <Modal
:enabled="active" :enabled="active"
:overflow="isNewTaskCommand" :overflow="isNewTaskCommand"
variant="top"
@close="closeQuickActions" @close="closeQuickActions"
> >
<div <div
@ -38,7 +37,6 @@
v-if="isNewTaskCommand" v-if="isNewTaskCommand"
/> />
<BaseButton <BaseButton
:aria-label="$t('misc.closeQuickActions')"
class="close" class="close"
@click="closeQuickActions" @click="closeQuickActions"
> >
@ -191,17 +189,12 @@ watchEffect(() => {
let focusRafId: number | null = null let focusRafId: number | null = null
watchEffect(() => { watchEffect(() => {
if (active.value) { if (active.value && isQuickAddMode) {
if (isQuickAddMode) { selectedCmd.value = commands.value.newTask
selectedCmd.value = commands.value.newTask
}
// The input may not be focusable yet due to: // The input may not be focusable yet due to:
// 1. Modal mounts the <dialog> via v-if and then calls showModal() in a // 1. Modal transition (v-if + <Transition appear>) delaying DOM readiness
// follow-up flush, so v-focus fires while the dialog is still closed // 2. Electron window not yet visible (shown after did-finish-load)
// and the focus() call is dropped.
// 2. In quick-add mode the Electron window isn't visible until
// did-finish-load.
// Retry with rAF until focus actually lands on the input. // Retry with rAF until focus actually lands on the input.
const tryFocus = () => { const tryFocus = () => {
if (!active.value) { if (!active.value) {
@ -705,16 +698,15 @@ function reset() {
<style lang="scss" scoped> <style lang="scss" scoped>
.quick-actions { .quick-actions {
// global Bulma .card styles are gone (ported into Card.vue, scoped),
// so this bare .card div needs its own card visuals
background-color: var(--white);
border-radius: $radius;
border: 1px solid var(--card-border-color);
box-shadow: var(--shadow-sm);
color: var(--text);
overflow: hidden; overflow: hidden;
justify-content: flex-start !important; justify-content: flex-start !important;
// FIXME: changed position should be an option of the modal
:deep(.modal-content) {
inset-block-start: 3rem;
transform: translate(-50%, 0);
}
&.is-quick-add-mode { &.is-quick-add-mode {
padding: 0; padding: 0;
margin: 0; margin: 0;

View File

@ -25,7 +25,6 @@
rows="1" rows="1"
@keydown="resetEmptyTitleError" @keydown="resetEmptyTitleError"
@keydown.enter="handleEnter" @keydown.enter="handleEnter"
@keydown.esc="blurTaskInput"
/> />
<QuickAddMagic <QuickAddMagic
:highlight-hint-icon="taskAddHovered" :highlight-hint-icon="taskAddHovered"
@ -283,10 +282,6 @@ function focusTaskInput() {
newTaskInput.value?.focus() newTaskInput.value?.focus()
} }
function blurTaskInput() {
newTaskInput.value?.blur()
}
defineExpose({ defineExpose({
focusTaskInput, focusTaskInput,
}) })

View File

@ -123,7 +123,7 @@
</XButton> </XButton>
<!-- Dropzone --> <!-- Dropzone -->
<Teleport :to="dropzoneTeleportTarget"> <Teleport to="body">
<div <div
v-if="editEnabled" v-if="editEnabled"
:class="{hidden: !showDropzone}" :class="{hidden: !showDropzone}"
@ -185,7 +185,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import {ref, shallowReactive, computed, watch, onMounted, onBeforeUnmount} from 'vue' import {ref, shallowReactive, computed, watch} from 'vue'
import {useDropZone} from '@vueuse/core' import {useDropZone} from '@vueuse/core'
import User from '@/components/misc/User.vue' import User from '@/components/misc/User.vue'
@ -322,34 +322,6 @@ const showDropzone = computed(() =>
props.editEnabled && isDraggingFiles.value && !isDragOverEditor.value, props.editEnabled && isDraggingFiles.value && !isDragOverEditor.value,
) )
// A <dialog> opened with showModal() (e.g. the Kanban task detail) renders in
// the browser's top layer, so the full-screen dropzone overlay teleported to
// <body> would paint behind it regardless of z-index. Teleport it into the
// topmost open dialog instead, mirroring Notification.vue.
const dropzoneTeleportTarget = ref<string | HTMLElement>('body')
let dialogObserver: MutationObserver | null = null
function syncDropzoneTeleportTarget() {
const dialogs = document.querySelectorAll<HTMLDialogElement>('dialog.modal-dialog[open]')
dropzoneTeleportTarget.value = dialogs.item(dialogs.length - 1) ?? 'body'
}
onMounted(() => {
syncDropzoneTeleportTarget()
dialogObserver = new MutationObserver(syncDropzoneTeleportTarget)
dialogObserver.observe(document.body, {
attributes: true,
attributeFilter: ['open'],
childList: true,
subtree: true,
})
})
onBeforeUnmount(() => {
dialogObserver?.disconnect()
dialogObserver = null
})
watch(() => props.editEnabled, enabled => { watch(() => props.editEnabled, enabled => {
if (!enabled) { if (!enabled) {
resetDragState() resetDragState()
@ -506,7 +478,7 @@ defineExpose({
inset-inline-start: 0; inset-inline-start: 0;
inset-block-end: 0; inset-block-end: 0;
inset-inline-end: 0; inset-inline-end: 0;
z-index: 4001; // above app chrome when teleported to body (no modal open) z-index: 4001; // modal z-index is 4000
text-align: center; text-align: center;
&.hidden { &.hidden {

View File

@ -11,7 +11,7 @@
{{ currentBucketTitle }} {{ currentBucketTitle }}
<Icon <Icon
icon="pencil-alt" icon="pencil-alt"
class="change-indicator d-print-none" class="change-indicator"
/> />
</BaseButton> </BaseButton>
</template> </template>

View File

@ -128,7 +128,7 @@
/> />
<Reactions <Reactions
v-model="c.reactions" v-model="c.reactions"
class="mbs-2 d-print-none" class="mbs-2"
entity-kind="comments" entity-kind="comments"
:entity-id="c.id" :entity-id="c.id"
:disabled="!canWrite" :disabled="!canWrite"
@ -173,7 +173,6 @@
<div class="field"> <div class="field">
<Editor <Editor
v-if="editorActive" v-if="editorActive"
ref="newCommentEditor"
v-model="newCommentText" v-model="newCommentText"
:class="{ :class="{
'is-loading': 'is-loading':
@ -223,7 +222,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import {ref, reactive, computed, nextTick, provide, shallowReactive, watch} from 'vue' import {ref, reactive, computed, shallowReactive, watch} from 'vue'
import {useI18n} from 'vue-i18n' import {useI18n} from 'vue-i18n'
import BaseButton from '@/components/base/BaseButton.vue' import BaseButton from '@/components/base/BaseButton.vue'
@ -247,7 +246,6 @@ import {useConfigStore} from '@/stores/config'
import {useAuthStore} from '@/stores/auth' import {useAuthStore} from '@/stores/auth'
import Reactions from '@/components/input/Reactions.vue' import Reactions from '@/components/input/Reactions.vue'
import {useCopyToClipboard} from '@/composables/useCopyToClipboard' import {useCopyToClipboard} from '@/composables/useCopyToClipboard'
import {commentReplyContextKey, scrollAndHighlightComment} from '@/components/tasks/partials/commentReplyContext'
const props = withDefaults(defineProps<{ const props = withDefaults(defineProps<{
taskId: number, taskId: number,
@ -306,19 +304,15 @@ const actions = computed(() => {
if (!props.canWrite) { if (!props.canWrite) {
return {} return {}
} }
return Object.fromEntries(comments.value.map((comment) => { return Object.fromEntries(comments.value.map((comment) => ([
const list: {action: () => void, title: string}[] = [{ comment.id,
action: () => startReplyTo(comment), comment.author.id === currentUserId.value
title: t('task.comment.reply'), ? [{
}]
if (comment.author.id === currentUserId.value) {
list.push({
action: () => toggleDelete(comment.id), action: () => toggleDelete(comment.id),
title: t('misc.delete'), title: t('misc.delete'),
}) }]
} : [],
return [comment.id, list] ])))
}))
}) })
const frontendUrl = computed(() => configStore.frontendUrl) const frontendUrl = computed(() => configStore.frontendUrl)
@ -327,55 +321,6 @@ const commentStorageKey = computed(() => `task-comment-${props.taskId}`)
const currentPage = ref(1) const currentPage = ref(1)
const commentsRef = ref<HTMLElement | null>(null) const commentsRef = ref<HTMLElement | null>(null)
const newCommentEditor = ref<{setReplyContent: (html: string) => Promise<void>} | null>(null)
provide(commentReplyContextKey, {
findComment: (id: number) => comments.value.find(c => c.id === id),
scrollToComment: scrollAndHighlightComment,
})
// Strip <mention-user> elements from a reply quote so reposting the parent
// body doesn't trigger fresh notifications for users mentioned in the
// original. The inner text is kept so the quote still reads correctly.
function stripMentionsForQuote(html: string): string {
if (!html) {
return ''
}
const doc = new DOMParser().parseFromString(`<div>${html}</div>`, 'text/html')
doc.querySelectorAll('mention-user').forEach((el) => {
const label = (el.getAttribute('data-label') ?? el.textContent ?? '').trim()
el.replaceWith(label ? `@${label.replace(/^@+/, '')}` : '')
})
return doc.body.firstElementChild?.innerHTML ?? ''
}
async function startReplyTo(parent: ITaskComment) {
const body = stripMentionsForQuote(parent.comment ?? '')
const draft = `<blockquote data-comment-id="${parent.id}">${body}</blockquote><p></p>`
if (!editorActive.value) {
editorActive.value = true
}
// Editor mounts asynchronously through defineAsyncComponent; wait until
// the ref is populated before pushing content in. Bail with a warning
// rather than fall back to `newCommentText = draft` the modelValue
// watcher in TipTap.vue would land the editor in preview mode, leaving
// the user unable to type without clicking the editor first.
const editor = await waitForEditorRef()
if (!editor) {
console.warn('Reply editor did not mount in time; aborting reply prefill.')
return
}
await editor.setReplyContent(draft)
}
async function waitForEditorRef() {
const start = performance.now()
while (!newCommentEditor.value && performance.now() - start < 2000) {
await nextTick()
}
return newCommentEditor.value
}
async function attachmentUpload(files: File[] | FileList): (Promise<string[]>) { async function attachmentUpload(files: File[] | FileList): (Promise<string[]>) {
@ -561,9 +506,7 @@ async function deleteComment(commentToDelete: ITaskComment) {
function getCommentUrl(commentId: string) { function getCommentUrl(commentId: string) {
const baseUrl = frontendUrl.value.endsWith('/') ? frontendUrl.value.slice(0, -1) : frontendUrl.value const baseUrl = frontendUrl.value.endsWith('/') ? frontendUrl.value.slice(0, -1) : frontendUrl.value
const url = new URL(location.pathname + location.search, baseUrl) return `${baseUrl}${location.pathname}${location.search}#comment-${commentId}`
url.hash = `comment-${commentId}`
return url.toString()
} }
</script> </script>
@ -572,10 +515,11 @@ function getCommentUrl(commentId: string) {
align-items: flex-start; align-items: flex-start;
display: flex; display: flex;
text-align: inherit; text-align: inherit;
padding-block-start: .5rem;
& + .media { & + .media {
margin-block-start: .5rem; border-block-start: 1px solid rgba(var(--border-rgb), 0.5);
margin-block-start: 1rem;
padding-block-start: 1rem;
} }
} }
@ -583,7 +527,7 @@ function getCommentUrl(commentId: string) {
flex-basis: auto; flex-basis: auto;
flex-grow: 0; flex-grow: 0;
flex-shrink: 0; flex-shrink: 0;
margin: 0 .5rem !important; margin: 0 1rem !important;
} }
.comment-info { .comment-info {
@ -659,15 +603,4 @@ function getCommentUrl(commentId: string) {
.comments-container { .comments-container {
scroll-margin-block-start: 4rem; scroll-margin-block-start: 4rem;
} }
.media.comment {
scroll-margin-block-start: 4rem;
transition: background-color .3s ease-out;
border-radius: $radius;
}
.media.comment.comment-highlight {
background-color: hsla(var(--primary-hsl), 0.18);
transition: background-color .15s ease-in;
}
</style> </style>

View File

@ -49,8 +49,6 @@ import flatPickr from 'vue-flatpickr-component'
import TaskService from '@/services/task' import TaskService from '@/services/task'
import type {ITask} from '@/modelTypes/ITask' import type {ITask} from '@/modelTypes/ITask'
import {useFlatpickrLanguage} from '@/helpers/useFlatpickrLanguage' import {useFlatpickrLanguage} from '@/helpers/useFlatpickrLanguage'
import {useTimeFormat} from '@/composables/useTimeFormat'
import {TIME_FORMAT} from '@/constants/timeFormat'
const props = defineProps<{ const props = defineProps<{
modelValue: ITask, modelValue: ITask,
@ -61,7 +59,6 @@ const emit = defineEmits<{
}>() }>()
const {t} = useI18n({useScope: 'global'}) const {t} = useI18n({useScope: 'global'})
const {store: timeFormat} = useTimeFormat()
const taskService = shallowReactive(new TaskService()) const taskService = shallowReactive(new TaskService())
const task = ref<ITask>() const task = ref<ITask>()
@ -106,7 +103,7 @@ const flatPickerConfig = computed(() => ({
altInput: true, altInput: true,
dateFormat: 'Y-m-d H:i', dateFormat: 'Y-m-d H:i',
enableTime: true, enableTime: true,
time_24hr: timeFormat.value === TIME_FORMAT.HOURS_24, time_24hr: true,
inline: true, inline: true,
locale: useFlatpickrLanguage().value, locale: useFlatpickrLanguage().value,
})) }))

View File

@ -1,7 +1,5 @@
<template> <template>
<div <div>
:class="{'d-print-none': isEmpty}"
>
<h3> <h3>
<span class="icon is-grey"> <span class="icon is-grey">
<Icon icon="align-left" /> <Icon icon="align-left" />
@ -50,7 +48,6 @@ import CustomTransition from '@/components/misc/CustomTransition.vue'
import Editor from '@/components/input/AsyncEditor' import Editor from '@/components/input/AsyncEditor'
import { clearEditorDraft } from '@/helpers/editorDraftStorage' import { clearEditorDraft } from '@/helpers/editorDraftStorage'
import { isEditorContentEmpty } from '@/helpers/editorContentEmpty'
import type { ITask } from '@/modelTypes/ITask' import type { ITask } from '@/modelTypes/ITask'
import { useTaskStore } from '@/stores/tasks' import { useTaskStore } from '@/stores/tasks'
@ -85,8 +82,6 @@ const changeTimeout = ref<ReturnType<typeof setTimeout> | null>(null)
const descriptionStorageKey = computed(() => `task-description-${props.modelValue.id}`) const descriptionStorageKey = computed(() => `task-description-${props.modelValue.id}`)
const isEmpty = computed(() => isEditorContentEmpty(description.value))
async function saveWithDelay() { async function saveWithDelay() {
if (description.value === props.modelValue.description) { if (description.value === props.modelValue.description) {
hasChanges.value = false hasChanges.value = false

View File

@ -7,9 +7,9 @@
:color="getHexColor(task.hexColor)" :color="getHexColor(task.hexColor)"
/> />
<BaseButton @click="copyUrl"> <BaseButton @click="copyUrl">
<span class="title task-id"> <h1 class="title task-id">
{{ textIdentifier }} {{ textIdentifier }}
</span> </h1>
</BaseButton> </BaseButton>
</div> </div>
<Done <Done
@ -17,8 +17,7 @@
/> />
<BaseButton <BaseButton
v-if="hasClose" v-if="hasClose"
:aria-label="$t('task.detail.closeTaskDetail')" class="close"
class="close d-print-none"
@click="$emit('close')" @click="$emit('close')"
> >
<Icon icon="times" /> <Icon icon="times" />
@ -38,8 +37,7 @@
</h1> </h1>
<BaseButton <BaseButton
v-if="hasClose" v-if="hasClose"
:aria-label="$t('task.detail.closeTaskDetail')" class="close"
class="close d-print-none"
@click="$emit('close')" @click="$emit('close')"
> >
<Icon icon="times" /> <Icon icon="times" />

View File

@ -4,7 +4,7 @@
v-if="editEnabled && Object.keys(relatedTasks).length > 0" v-if="editEnabled && Object.keys(relatedTasks).length > 0"
id="showRelatedTasksFormButton" id="showRelatedTasksFormButton"
v-tooltip="$t('task.relation.add')" v-tooltip="$t('task.relation.add')"
class="is-pulled-end add-task-relation-button d-print-none" class="is-pulled-right add-task-relation-button d-print-none"
:class="{'is-active': showNewRelationForm}" :class="{'is-active': showNewRelationForm}"
variant="secondary" variant="secondary"
icon="plus" icon="plus"
@ -36,7 +36,7 @@
</label> </label>
<div <div
key="field-search" key="field-search"
class="field task-relation-search-field" class="field"
> >
<Multiselect <Multiselect
v-model="newTaskRelation.task" v-model="newTaskRelation.task"
@ -77,7 +77,6 @@
</span> </span>
</template> </template>
</Multiselect> </Multiselect>
<QuickAddMagic />
</div> </div>
<div <div
key="field-kind" key="field-kind"
@ -201,7 +200,6 @@ import CustomTransition from '@/components/misc/CustomTransition.vue'
import BaseButton from '@/components/base/BaseButton.vue' import BaseButton from '@/components/base/BaseButton.vue'
import Multiselect from '@/components/input/Multiselect.vue' import Multiselect from '@/components/input/Multiselect.vue'
import FancyCheckbox from '@/components/input/FancyCheckbox.vue' import FancyCheckbox from '@/components/input/FancyCheckbox.vue'
import QuickAddMagic from '@/components/tasks/partials/QuickAddMagic.vue'
import {error, success} from '@/message' import {error, success} from '@/message'
import {useTaskStore} from '@/stores/tasks' import {useTaskStore} from '@/stores/tasks'
@ -364,7 +362,7 @@ async function removeTaskRelation() {
} }
async function createAndRelateTask(title: string) { async function createAndRelateTask(title: string) {
const newTask = await taskStore.createNewTask({title, projectId: props.projectId}) const newTask = await taskService.create(new TaskModel({title, projectId: props.projectId}))
newTaskRelation.task = newTask newTaskRelation.task = newTask
await addTaskRelation() await addTaskRelation()
} }
@ -461,17 +459,6 @@ async function toggleTaskDone(task: ITask) {
padding: 0.5rem; padding: 0.5rem;
} }
.task-relation-search-field {
position: relative;
:deep(.quick-add-magic-trigger-btn) {
position: absolute;
inset-block-start: .75rem;
inset-inline-end: .75rem;
z-index: 4;
}
}
// FIXME: The height of the actual checkbox in the <FancyCheckbox/> component is too much resulting in a // FIXME: The height of the actual checkbox in the <FancyCheckbox/> component is too much resulting in a
// weired positioning of the checkbox. Setting the height here is a workaround until we fix the styling // weired positioning of the checkbox. Setting the height here is a workaround until we fix the styling
// of the component. // of the component.

View File

@ -19,7 +19,6 @@
<FancyCheckbox <FancyCheckbox
v-model="task.done" v-model="task.done"
:disabled="isArchived || disabled || !canMarkAsDone" :disabled="isArchived || disabled || !canMarkAsDone"
:aria-label="$t('task.detail.markAsDone', {task: task.title})"
@update:modelValue="markAsDone" @update:modelValue="markAsDone"
@click.stop @click.stop
/> />
@ -326,17 +325,9 @@ const isOverdue = computed(() => (
let oldTask let oldTask
async function markAsDone(checked: boolean, wasReverted: boolean = false) { async function markAsDone(checked: boolean, wasReverted: boolean = false) {
oldTask = {...task.value} const updateFunc = async () => {
oldTask = {...task.value}
// Fire the request immediately and with the intended done value snapshotted, so a re-render or const newTask = await taskStore.update(task.value)
// teardown during the animation delay can neither drop the save nor make it send a stale state.
const updatePromise = taskStore.update({
...task.value,
done: checked,
})
const finish = async () => {
const newTask = await updatePromise
task.value = newTask task.value = newTask
updateDueDate() updateDueDate()
@ -362,9 +353,9 @@ async function markAsDone(checked: boolean, wasReverted: boolean = false) {
} }
if (checked) { if (checked) {
setTimeout(finish, 300) // Delay only the follow-up to show the animation when marking a task as done setTimeout(updateFunc, 300) // Delay it to show the animation when marking a task as done
} else { } else {
await finish() // Don't delay it when un-marking it as it doesn't have an animation the other way around await updateFunc() // Don't delay it when un-marking it as it doesn't have an animation the other way around
} }
} }
@ -391,7 +382,7 @@ function hasTextSelected() {
function openTaskDetail(event: MouseEvent | KeyboardEvent) { function openTaskDetail(event: MouseEvent | KeyboardEvent) {
if (event.target instanceof HTMLElement) { if (event.target instanceof HTMLElement) {
const isInteractiveElement = event.target.closest('a, button, label, input[type="checkbox"], .favorite, [role="button"]') const isInteractiveElement = event.target.closest('a, button, .favorite, [role="button"]')
if (isInteractiveElement || hasTextSelected()) { if (isInteractiveElement || hasTextSelected()) {
return return
} }
@ -544,23 +535,6 @@ defineExpose({
span { span {
display: none; display: none;
} }
// Extend the hit target to >=44x44 without affecting layout (WCAG 2.5.5).
.base-checkbox__label {
position: relative;
&::before {
content: '';
position: absolute;
inset-block-start: 50%;
inset-inline-start: 50%;
min-block-size: 44px;
min-inline-size: 44px;
block-size: 100%;
inline-size: 100%;
transform: translate(-50%, -50%);
}
}
} }
.tasktext.done { .tasktext.done {

View File

@ -8,10 +8,7 @@
<slot /> <slot />
</span> </span>
<Teleport <Teleport to="body">
v-if="canHover"
to="body"
>
<CustomTransition name="fade"> <CustomTransition name="fade">
<div <div
v-if="showTooltip" v-if="showTooltip"
@ -85,7 +82,6 @@
<script setup lang="ts"> <script setup lang="ts">
import {ref, computed, onUnmounted, nextTick} from 'vue' import {ref, computed, onUnmounted, nextTick} from 'vue'
import {computePosition, flip, offset, shift} from '@floating-ui/dom' import {computePosition, flip, offset, shift} from '@floating-ui/dom'
import {useMediaQuery} from '@vueuse/core'
import type {ITask} from '@/modelTypes/ITask' import type {ITask} from '@/modelTypes/ITask'
import {getTaskIdentifier} from '@/models/task' import {getTaskIdentifier} from '@/models/task'
@ -105,9 +101,6 @@ const props = defineProps<{
const HOVER_DELAY = 1000 // 1 second const HOVER_DELAY = 1000 // 1 second
const MAX_DESCRIPTION_LENGTH = 150 const MAX_DESCRIPTION_LENGTH = 150
// Taps on touch devices emulate mouseenter, which would show the tooltip unexpectedly.
const canHover = useMediaQuery('(hover: hover) and (pointer: fine)')
const triggerRef = ref<HTMLElement | null>(null) const triggerRef = ref<HTMLElement | null>(null)
const tooltipRef = ref<HTMLElement | null>(null) const tooltipRef = ref<HTMLElement | null>(null)
const showTooltip = ref(false) const showTooltip = ref(false)
@ -159,10 +152,6 @@ async function updatePosition() {
} }
function handleMouseEnter() { function handleMouseEnter() {
if (!canHover.value) {
return
}
// Clear any existing timeout // Clear any existing timeout
if (hoverTimeout) { if (hoverTimeout) {
clearTimeout(hoverTimeout) clearTimeout(hoverTimeout)

Some files were not shown because too many files have changed in this diff Show More