Merge remote-tracking branch 'upstream/main' into auto-redirect-oidc-login

This commit is contained in:
surfingbytes 2026-04-30 09:55:06 +00:00
commit 066791ec00
528 changed files with 37835 additions and 7521 deletions

View File

@ -0,0 +1,49 @@
---
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

@ -0,0 +1,55 @@
---
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`).

47
.github/workflows/auto-label.prompt.md vendored Normal file
View File

@ -0,0 +1,47 @@
You are a triage assistant for the Vikunja repository. Your job is to classify a single issue or pull request using the label taxonomy below, and return ONLY a JSON array of chosen label names — nothing else.
# Output format
Return exactly a JSON array of strings, e.g.:
["area/kanban", "area/recurring-tasks", "concern/regression"]
No prose, no markdown fences, no explanation. If you cannot confidently classify, return an empty array: []
# Rules
1. Every well-formed item gets at least one `area/*` label. If you truly cannot pick one, return [].
2. Multi-label is the norm. 24 labels is typical, occasionally up to 6.
3. `concern/*` is additive — it describes a cross-cutting quality (UX polish, performance, a11y, regression) on top of the feature area.
4. `integration/*` applies only when the item is about connecting to a *specific third-party system* (Slack, Gotify, Apprise, external webhooks, WeKan import, Todoist import, add-task-from-email, MCP, etc.).
- CalDAV is its own `area/caldav` — do NOT also tag `integration/*`.
- Generic webhook infrastructure is `area/webhooks`; a PR adding Slack delivery is `area/webhooks` + `integration/outbound`.
5. `db/mysql`, `db/postgres`, `db/sqlite` ONLY when the item is explicitly engine-specific (e.g. "fails on MySQL 8"). General DB issues get `area/database` with no engine tag.
6. `concern/regression` ONLY if the body explicitly says it worked in a prior version and is broken now.
7. Do NOT invent labels. Only use names from the taxonomy below — anything else will be discarded.
# Taxonomy
The following labels are available. Each line is `label-name — description`. Pick only from this list.
{{TAXONOMY}}
# Examples
Input:
TITLE: SQL syntax error on MySQL due to CAST in is_archived computation
BODY: After upgrading to 2.3.0 I get SQL syntax errors on MySQL 8. Worked fine on 2.2.x.
Output:
["area/database", "db/mysql", "concern/regression"]
Input:
TITLE: feat: add Slack webhook support
BODY: Adds outbound Slack notifications when tasks change.
Output:
["area/webhooks", "area/notifications", "integration/outbound"]
Input:
TITLE: Mobile: "Mark task done" should be easier to find
BODY: The checkbox is too small on phones.
Output:
["area/mobile", "area/task-editor", "concern/ux"]

202
.github/workflows/auto-label.yml vendored Normal file
View File

@ -0,0 +1,202 @@
name: Auto-label new issues and PRs
on:
issues:
types: [opened]
pull_request_target:
types: [opened]
permissions:
contents: read
issues: write
pull-requests: write
models: read
concurrency:
group: auto-label-${{ github.event.issue.number || github.event.pull_request.number }}
cancel-in-progress: false
jobs:
classify:
runs-on: ubuntu-latest
steps:
- name: Checkout (for prompt template)
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
sparse-checkout: |
.github/workflows/auto-label.prompt.md
sparse-checkout-cone-mode: false
- name: Render system prompt from live labels
id: render
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
env:
PROMPT_TEMPLATE_PATH: .github/workflows/auto-label.prompt.md
with:
script: |
const fs = require('fs');
const path = require('path');
// Fetch every label in the repo, keep only the managed namespaces.
const managedPrefixes = ['area/', 'integration/', 'db/', 'concern/'];
const all = await github.paginate(
github.rest.issues.listLabelsForRepo,
{ owner: context.repo.owner, repo: context.repo.repo, per_page: 100 }
);
const managed = all
.filter(l => managedPrefixes.some(p => l.name.startsWith(p)))
.sort((a, b) => a.name.localeCompare(b.name));
if (managed.length === 0) {
core.setFailed('No managed labels found on the repo — cannot build taxonomy.');
return;
}
// Warn about labels without descriptions — they confuse the classifier.
const undescribed = managed.filter(l => !l.description || !l.description.trim());
if (undescribed.length > 0) {
core.warning(
`Labels without descriptions will be skipped: ${undescribed.map(l => l.name).join(', ')}`
);
}
// Group by namespace for readability in the prompt.
const groups = {};
for (const l of managed) {
if (!l.description || !l.description.trim()) continue;
const prefix = managedPrefixes.find(p => l.name.startsWith(p));
(groups[prefix] ||= []).push(l);
}
const sections = [];
for (const prefix of managedPrefixes) {
const entries = groups[prefix] || [];
if (entries.length === 0) continue;
sections.push(`## ${prefix}*\n`);
for (const l of entries) {
sections.push(`- \`${l.name}\` — ${l.description.trim()}`);
}
sections.push('');
}
const taxonomy = sections.join('\n');
// Expand the template.
const templatePath = process.env.PROMPT_TEMPLATE_PATH;
const template = fs.readFileSync(templatePath, 'utf8');
if (!template.includes('{{TAXONOMY}}')) {
core.setFailed(`Template ${templatePath} is missing the {{TAXONOMY}} placeholder.`);
return;
}
const rendered = template.replace('{{TAXONOMY}}', taxonomy);
const outPath = path.join(process.env.RUNNER_TEMP, 'system-prompt.md');
fs.writeFileSync(outPath, rendered);
core.setOutput('system_prompt_path', outPath);
core.info(`Rendered ${managed.length} labels into ${outPath}`);
- name: Build user prompt
id: prep
env:
TITLE: ${{ github.event.issue.title || github.event.pull_request.title }}
BODY: ${{ github.event.issue.body || github.event.pull_request.body }}
KIND: ${{ github.event_name == 'issues' && 'issue' || 'pull request' }}
run: |
mkdir -p "$RUNNER_TEMP/ai"
python3 - <<'PY' > "$RUNNER_TEMP/ai/user-prompt.txt"
import os
title = os.environ.get("TITLE", "").strip()
body = (os.environ.get("BODY", "") or "").strip() or "(no description)"
kind = os.environ.get("KIND", "issue")
# Truncate very long bodies to keep token usage predictable
if len(body) > 8000:
body = body[:8000] + "\n\n[... truncated ...]"
print(f"Classify the following {kind}. Return ONLY a JSON array of labels.\n")
print("--- TITLE ---")
print(title)
print()
print("--- BODY ---")
print(body)
print("--- END ---")
PY
echo "prompt_path=$RUNNER_TEMP/ai/user-prompt.txt" >> "$GITHUB_OUTPUT"
- name: Classify with AI
id: classify
uses: actions/ai-inference@e09e65981758de8b2fdab13c2bfb7c7d5493b0b6 # v2.0.7
with:
model: openai/gpt-4.1-mini
# GPT-5 is a reasoning model: output tokens include reasoning, so budget generously.
# Temperature is ignored by reasoning models and intentionally omitted.
max-completion-tokens: 2000
system-prompt-file: ${{ steps.render.outputs.system_prompt_path }}
prompt-file: ${{ steps.prep.outputs.prompt_path }}
- name: Apply labels
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
env:
AI_RESPONSE: ${{ steps.classify.outputs.response }}
with:
script: |
const raw = (process.env.AI_RESPONSE || '').trim();
core.info(`Raw AI response:\n${raw}`);
// Extract the first JSON array from the response (tolerates stray prose or code fences)
const match = raw.match(/\[[\s\S]*\]/);
if (!match) {
core.warning('No JSON array found in AI response — skipping labeling.');
return;
}
let parsed;
try {
parsed = JSON.parse(match[0]);
} catch (e) {
core.warning(`Failed to parse JSON array: ${e.message}`);
return;
}
if (!Array.isArray(parsed)) {
core.warning('AI response JSON is not an array — skipping.');
return;
}
// Re-validate against live repo labels. Same source of truth as the prompt renderer,
// so drift is impossible — any label the model picks MUST exist in the repo.
const managedPrefixes = ['area/', 'integration/', 'db/', 'concern/'];
const allRepoLabels = await github.paginate(
github.rest.issues.listLabelsForRepo,
{ owner: context.repo.owner, repo: context.repo.repo, per_page: 100 }
);
const allowed = new Set(
allRepoLabels
.map(l => l.name)
.filter(n => managedPrefixes.some(p => n.startsWith(p)))
);
const valid = [...new Set(parsed)].filter(
l => typeof l === 'string' && allowed.has(l)
);
const rejected = parsed.filter(l => !valid.includes(l));
if (rejected.length > 0) {
core.warning(`Ignored unknown labels: ${JSON.stringify(rejected)}`);
}
// Cap at 6 labels — our taxonomy rule says 24 is typical, 6 is the ceiling.
const toApply = valid.slice(0, 6);
if (toApply.length === 0) {
core.info('No valid labels selected — leaving item unlabeled for human triage.');
return;
}
const number =
context.payload.issue?.number ?? context.payload.pull_request.number;
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: number,
labels: toApply,
});
core.info(`Applied labels to #${number}: ${toApply.join(', ')}`);

View File

@ -46,7 +46,7 @@ jobs:
# Update both packages using the nixpkgs update infrastructure
PACKAGES=""
for pkg in vikunja vikunja-desktop; do
nix-shell maintainers/scripts/update.nix --argstr package "$pkg"
nix-shell maintainers/scripts/update.nix --argstr package "$pkg" --argstr skip-prompt true
if ! git diff --quiet; then
git add -A
NEW=$(grep -oP 'version = "\K[^"]+' "pkgs/by-name/vi/$pkg/package.nix" | head -1)

View File

@ -67,18 +67,25 @@ jobs:
RELEASE_VERSION=${{ steps.ghd.outputs.describe }}
- name: Comment on PR
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
env:
DOCKER_META_TAGS: ${{ steps.meta.outputs.tags }}
with:
script: |
const prNumber = context.payload.pull_request.number;
const fullSha = context.payload.pull_request.head.sha;
const shortSha = fullSha.substring(0, 7);
const base = 'preview.vikunja.dev';
const image = `ghcr.io/${{ github.repository_owner }}/${{ github.event.repository.name }}`;
const marker = '<!-- vikunja-preview-comment -->';
// Extract the SHA tag from docker meta output (the actual tag pushed to GHCR)
const metaTags = process.env.DOCKER_META_TAGS.split('\n').map(t => t.trim()).filter(Boolean);
const shaImageRef = metaTags.find(t => t.includes(':sha-'));
const shaTag = shaImageRef ? shaImageRef.split(':').pop() : null;
const shortSha = shaTag ? shaTag.replace('sha-', '').substring(0, 7) : context.payload.pull_request.head.sha.substring(0, 7);
const prTag = `pr-${prNumber}`;
const shaTag = `sha-${fullSha}`;
const newShaRow = `| https://${shaTag}.${base} | \`${image}:${shaTag}\` | \`${shortSha}\` |`;
const newShaRow = shaTag
? `| https://${shaTag}.${base} | \`${image}:${shaTag}\` | \`${shortSha}\` |`
: '';
// Collect previous SHA rows from existing comment
let previousShaRows = [];
@ -96,9 +103,11 @@ jobs:
}
// Remove duplicate if this SHA was already recorded
previousShaRows = previousShaRows.filter(r => !r.includes(shaTag));
if (shaTag) {
previousShaRows = previousShaRows.filter(r => !r.includes(shaTag));
}
const allShaRows = [newShaRow, ...previousShaRows].join('\n');
const allShaRows = [newShaRow, ...previousShaRows].filter(Boolean).join('\n');
const body = [
marker,

View File

@ -119,7 +119,7 @@ jobs:
gpg -v --default-key 7D061A4AA61436B40713D42EFF054DACD908493A -b --batch --yes --passphrase "${{ secrets.RELEASE_GPG_PASSPHRASE }}" --pinentry-mode loopback --sign "$file"
done
- name: Upload
uses: kolaente/s3-action@41963184b524ccac734ea4d8c964ac74b5b1af89 # v1.2.1
uses: kolaente/s3-action@main
with:
s3-access-key-id: ${{ secrets.S3_ACCESS_KEY }}
s3-secret-access-key: ${{ secrets.S3_SECRET_KEY }}
@ -152,6 +152,16 @@ jobs:
- 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@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
@ -159,7 +169,6 @@ jobs:
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
with:
name: vikunja_bins
pattern: vikunja-*-linux-amd64
- name: Git describe
id: ghd
uses: proudust/gh-describe@v2
@ -167,24 +176,46 @@ jobs:
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-*-linux-amd64 ./vikunja
mv ./vikunja-*-${{ matrix.arch.go_name }} ./vikunja
chmod +x ./vikunja
- name: Create package
id: nfpm
uses: kolaente/action-gh-nfpm@master
with:
packager: ${{ matrix.package }}
target: ./dist/os-packages/vikunja-${{ github.ref_type == 'tag' && steps.ghd.outputs.describe || 'unstable' }}-x86_64.${{ matrix.package }}
target: ./dist/os-packages/vikunja-${{ github.ref_type == 'tag' && steps.ghd.outputs.describe || 'unstable' }}-${{ matrix.arch.pkg }}.${{ matrix.package }}
config: ./nfpm.yaml
env:
NFPM_GPG_KEY_FILE: ${{ (matrix.package == 'rpm') && '/tmp/nfpm-signing-key.gpg' || '' }}
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@41963184b524ccac734ea4d8c964ac74b5b1af89 # v1.2.1
uses: kolaente/s3-action@main
with:
s3-access-key-id: ${{ secrets.S3_ACCESS_KEY }}
s3-secret-access-key: ${{ secrets.S3_SECRET_KEY }}
@ -196,11 +227,182 @@ jobs:
strip-path-prefix: dist/os-packages/
- name: Store OS Packages
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
if: ${{ github.ref_type == 'tag' }}
with:
name: vikunja_os_package_${{ matrix.package }}
name: vikunja_os_package_${{ matrix.package }}_${{ matrix.arch.pkg }}
path: ./dist/os-packages/*
publish-repos:
runs-on: ubuntu-latest
needs:
- os-package
- desktop
strategy:
fail-fast: false
matrix:
include:
- format: apt
image: ubuntu:noble
mage_target: release:repo-apt
- format: rpm
image: fedora:latest
mage_target: release:repo-rpm
- format: pacman
image: archlinux:latest
mage_target: release:repo-pacman
- format: apk
image: alpine:latest
mage_target: release:repo-apk
container:
image: ${{ matrix.image }}
env:
REPO_SUITE: ${{ github.ref_type == 'tag' && 'stable' || 'unstable' }}
RELEASE_VERSION: unstable
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- name: Download Mage Binary
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
with:
name: mage_bin
- name: Download all server OS packages
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
with:
pattern: vikunja_os_package_*
merge-multiple: true
path: dist/repo-work/incoming
- name: Download desktop packages (Linux)
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
with:
name: vikunja_desktop_packages_ubuntu-latest
path: dist/repo-work/incoming-desktop
- name: Copy desktop packages to incoming
run: |
cd dist/repo-work/incoming-desktop
case "${{ matrix.format }}" in
apt)
cp *.deb ../incoming/ 2>/dev/null || true
;;
rpm)
# Add arch suffix so the mage target's *-x86_64.rpm glob matches
for f in *.rpm; do
[ -f "$f" ] && cp "$f" "../incoming/${f%.rpm}-x86_64.rpm"
done
;;
pacman)
# Rename .pacman to .archlinux with arch suffix
for f in *.pacman; do
[ -f "$f" ] && cp "$f" "../incoming/${f%.pacman}-x86_64.archlinux"
done
;;
apk)
# Desktop .apk is not an Alpine package, skip
;;
esac
- name: Install tools (apt)
if: matrix.format == 'apt'
run: |
apt-get update
apt-get install -y --no-install-recommends reprepro
- name: Install tools (rpm)
if: matrix.format == 'rpm'
run: dnf install -y createrepo_c
- name: Install tools (apk)
if: matrix.format == 'apk'
run: apk add --no-cache abuild libc6-compat
- name: GPG setup
if: matrix.format != 'apk'
uses: kolaente/action-gpg@main
with:
gpg-passphrase: "${{ secrets.RELEASE_GPG_PASSPHRASE }}"
gpg-sign-key: "${{ secrets.RELEASE_GPG_SIGN_KEY }}"
- name: Export GPG public key
if: matrix.format == 'apt'
run: |
mkdir -p dist/repo-output
gpg --export --armor 7D061A4AA61436B40713D42EFF054DACD908493A > dist/repo-output/gpg.key
- name: Setup APK signing key
if: matrix.format == 'apk'
run: |
mkdir -p ~/.abuild
echo "${{ secrets.APK_SIGNING_KEY }}" > ~/.abuild/vikunja-apk.rsa
echo "PACKAGER_PRIVKEY=$HOME/.abuild/vikunja-apk.rsa" > ~/.abuild/abuild.conf
- name: Generate repo metadata
if: matrix.format != 'apk'
env:
RELEASE_GPG_KEY: 7D061A4AA61436B40713D42EFF054DACD908493A
RELEASE_GPG_PASSPHRASE: ${{ secrets.RELEASE_GPG_PASSPHRASE }}
run: |
chmod +x ./mage-static
./mage-static ${{ matrix.mage_target }}
- name: Generate APK repo metadata
if: matrix.format == 'apk'
run: |
incoming=dist/repo-work/incoming
output_base=dist/repo-output/apk/$REPO_SUITE/main
signing_key=~/.abuild/vikunja-apk.rsa
for arch in x86_64 aarch64 armv7; do
repo_dir="$output_base/$arch"
mkdir -p "$repo_dir"
# Symlink matching packages
found=false
for pkg in "$incoming"/*-"$arch".apk; do
[ -f "$pkg" ] || continue
found=true
ln -sf "$(realpath "$pkg")" "$repo_dir/$(basename "$pkg")"
done
$found || continue
# Create index and sign
apk index --allow-untrusted -o "$repo_dir/APKINDEX.tar.gz" "$repo_dir"/*.apk
abuild-sign -k "$signing_key" "$repo_dir/APKINDEX.tar.gz"
done
echo "APK repo metadata generated in $output_base"
- name: Debug - repo output structure
run: find dist/repo-output -type f 2>/dev/null || ls -laR dist/repo-output/ || true
- name: Remove packages and internal state from repo output
run: |
# Remove reprepro internal state (not needed for serving)
rm -rf dist/repo-output/apt/db dist/repo-output/apt/conf 2>/dev/null || true
# Resolve symlinks into real files (S3 can't store symlinks)
find dist/repo-output -type l | while IFS= read -r link; do
target=$(readlink -f "$link")
if [ -f "$target" ]; then
rm "$link"
cp "$target" "$link"
else
rm "$link"
fi
done
# Remove actual package files — the worker redirects these to the
# existing artifacts so we don't need to store them twice.
find dist/repo-output -type f \( -name '*.deb' -o -name '*.rpm' -o -name '*.apk' -o -name '*.archlinux' -o -name '*.pacman' -o -name '*.pkg.tar.zst' \) -delete 2>/dev/null || true
# Remove now-empty directories
find dist/repo-output -type d -empty -delete 2>/dev/null || true
- name: Upload to R2
uses: kolaente/s3-action@main
with:
s3-access-key-id: ${{ secrets.S3_ACCESS_KEY }}
s3-secret-access-key: ${{ secrets.S3_SECRET_KEY }}
s3-endpoint: ${{ secrets.S3_ENDPOINT }}
s3-bucket: ${{ secrets.S3_BUCKET }}
s3-region: ${{ secrets.S3_REGION }}
target-path: /repos
files: "dist/repo-output/**/*"
strip-path-prefix: dist/repo-output/
config-yaml:
runs-on: ubuntu-latest
steps:
@ -217,7 +419,7 @@ jobs:
chmod +x ./mage-static
./mage-static generate:config-yaml 1
- name: Upload to S3
uses: kolaente/s3-action@41963184b524ccac734ea4d8c964ac74b5b1af89 # v1.2.1
uses: kolaente/s3-action@main
with:
s3-access-key-id: ${{ secrets.S3_ACCESS_KEY }}
s3-secret-access-key: ${{ secrets.S3_SECRET_KEY }}
@ -267,7 +469,7 @@ jobs:
pnpm install --frozen-lockfile --prefer-offline --fetch-timeout 100000
node build.js "${{ steps.ghd.outputs.describe }}" ${{ github.ref_type == 'tag' }}
- name: Upload to S3
uses: kolaente/s3-action@41963184b524ccac734ea4d8c964ac74b5b1af89 # v1.2.1
uses: kolaente/s3-action@main
with:
s3-access-key-id: ${{ secrets.S3_ACCESS_KEY }}
s3-secret-access-key: ${{ secrets.S3_SECRET_KEY }}
@ -280,7 +482,6 @@ jobs:
exclude: "desktop/dist/*.blockmap"
- name: Store Desktop Package
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
if: ${{ github.ref_type == 'tag' }}
with:
name: vikunja_desktop_packages_${{ matrix.os }}
path: |
@ -338,6 +539,7 @@ jobs:
- binaries
- os-package
- desktop
- publish-repos
if: ${{ github.ref_type == 'tag' }}
permissions:
contents: write
@ -347,25 +549,11 @@ jobs:
with:
name: vikunja_bin_packages
- name: Download OS Package rpm
- name: Download OS Packages
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
with:
name: vikunja_os_package_rpm
- name: Download OS Package deb
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
with:
name: vikunja_os_package_deb
- name: Download OS Package apk
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
with:
name: vikunja_os_package_apk
- name: Download OS Package archlinux
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
with:
name: vikunja_os_package_archlinux
pattern: vikunja_os_package_*
merge-multiple: true
- name: Download Desktop Package Linux
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7

View File

@ -0,0 +1,29 @@
name: Close stale "waiting for reply" issues
on:
schedule:
- cron: '0 2 * * *'
workflow_dispatch:
permissions:
issues: write
jobs:
stale:
runs-on: ubuntu-latest
steps:
- uses: actions/stale@v9
with:
only-labels: 'waiting for reply'
days-before-issue-stale: 30
days-before-issue-close: 30
stale-issue-label: 'waiting for reply'
remove-stale-when-updated: true
close-issue-message: >
Closing this for now since we haven't heard back on the follow-up
questions. If you're still seeing this on a recent version, just
drop a comment with the requested info and we'll reopen. Thanks
for the report!
days-before-pr-stale: -1
days-before-pr-close: -1
operations-per-run: 100

View File

@ -78,7 +78,7 @@ jobs:
with:
version: v2.10.1
api-check-translations:
check-translations:
runs-on: ubuntu-latest
needs: mage
steps:
@ -101,10 +101,18 @@ jobs:
db:
- sqlite
- postgres
- mariadb
- mysql
services:
migration-smoke-db-mariadb:
image: ${{ matrix.db == 'mariadb' && 'mariadb:12@sha256:f54db0cb3ccfe9431aba6d08c65a1763c499789b116b4cb651dd7fcf325965b3' || '' }}
env:
MYSQL_ROOT_PASSWORD: vikunjatest
MYSQL_DATABASE: vikunjatest
ports:
- 3306:3306
migration-smoke-db-mysql:
image: mariadb:12@sha256:f54db0cb3ccfe9431aba6d08c65a1763c499789b116b4cb651dd7fcf325965b3
image: ${{ matrix.db == 'mysql' && 'mysql:8@sha256:da906917ca4ace3ba55538b7c2ee97a9bc865ef14a4b6920b021f0249d603f3d' || '' }}
env:
MYSQL_ROOT_PASSWORD: vikunjatest
MYSQL_DATABASE: vikunjatest
@ -128,7 +136,7 @@ jobs:
name: vikunja_bin
- name: run migration
env:
VIKUNJA_DATABASE_TYPE: ${{ matrix.db }}
VIKUNJA_DATABASE_TYPE: ${{ (matrix.db == 'mariadb' || matrix.db == 'mysql') && 'mysql' || matrix.db }}
VIKUNJA_DATABASE_PATH: ./vikunja-migration-test.db
VIKUNJA_DATABASE_USER: ${{ matrix.db == 'postgres' && 'postgres' || 'root' }}
VIKUNJA_DATABASE_PASSWORD: vikunjatest
@ -173,24 +181,22 @@ jobs:
- sqlite-in-memory
- sqlite
- postgres
- mariadb
- mysql
- paradedb
test:
- feature
- web
- e2e-api
exclude:
- db: sqlite
test: e2e-api
- db: postgres
test: e2e-api
- db: mysql
test: e2e-api
- db: paradedb
test: e2e-api
services:
db-mariadb:
image: ${{ matrix.db == 'mariadb' && 'mariadb:12@sha256:5b6a1eac15b85b981a61afb89aea2a22bf76b5f58809d05f0bcc13ab6ec44cb8' || '' }}
env:
MYSQL_ROOT_PASSWORD: vikunjatest
MYSQL_DATABASE: vikunjatest
ports:
- 3306:3306
db-mysql:
image: ${{ matrix.db == 'mysql' && 'mariadb:12@sha256:5b6a1eac15b85b981a61afb89aea2a22bf76b5f58809d05f0bcc13ab6ec44cb8' || '' }}
image: ${{ matrix.db == 'mysql' && 'mysql:8@sha256:da906917ca4ace3ba55538b7c2ee97a9bc865ef14a4b6920b021f0249d603f3d' || '' }}
env:
MYSQL_ROOT_PASSWORD: vikunjatest
MYSQL_DATABASE: vikunjatest
@ -236,8 +242,8 @@ jobs:
- name: test
env:
VIKUNJA_TESTS_USE_CONFIG: ${{ matrix.db != 'sqlite-in-memory' && 1 || 0 }}
VIKUNJA_DATABASE_TYPE: ${{ matrix.db == 'paradedb' && 'postgres' || matrix.db }}
VIKUNJA_DATABASE_USER: ${{ matrix.db == 'mysql' && 'root' || 'postgres' }}
VIKUNJA_DATABASE_TYPE: ${{ (matrix.db == 'paradedb' && 'postgres') || ((matrix.db == 'mariadb' || matrix.db == 'mysql') && 'mysql') || matrix.db }}
VIKUNJA_DATABASE_USER: ${{ (matrix.db == 'mariadb' || matrix.db == 'mysql') && 'root' || 'postgres' }}
VIKUNJA_DATABASE_PASSWORD: vikunjatest
VIKUNJA_DATABASE_DATABASE: vikunjatest
VIKUNJA_DATABASE_SSLMODE: disable
@ -256,6 +262,48 @@ jobs:
chmod +x mage-static
./mage-static test:${{ matrix.test }}
test-caldav:
runs-on: ubuntu-latest
needs:
- mage
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- name: Download Mage Binary
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
with:
name: mage_bin
- name: Set up Go
uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6
with:
go-version: stable
- name: test
run: |
mkdir -p frontend/dist
touch frontend/dist/index.html
chmod +x mage-static
./mage-static test:caldav
test-e2e-api:
runs-on: ubuntu-latest
needs:
- mage
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- name: Download Mage Binary
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
with:
name: mage_bin
- name: Set up Go
uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6
with:
go-version: stable
- name: test
run: |
mkdir -p frontend/dist
touch frontend/dist/index.html
chmod +x mage-static
./mage-static test:e2e-api
test-s3-integration:
runs-on: ubuntu-latest
needs:

4
.gitignore vendored
View File

@ -35,6 +35,9 @@ mage-static
/plugins/*
/plugins-dev/*
# pnpm
.pnpm-store/
# Devenv
.devenv*
devenv.local.nix
@ -48,5 +51,6 @@ devenv.local.nix
# AI Tools
/.claude/settings.local.json
PLAN.md
plans/
/.crush/
/.playwright-mcp

View File

@ -171,6 +171,7 @@ linters:
- builtin$
- examples$
- pkg/routes/api/v1/docs.go
- pkg/yaegi_symbols/..*
- plugins-dev/..*
formatters:
enable:
@ -182,3 +183,4 @@ formatters:
- third_party$
- builtin$
- examples$
- pkg/yaegi_symbols/..*

View File

@ -11,6 +11,13 @@ The project consists of:
- `desktop/` Electron wrapper application
- `docs/` Documentation website
## 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`.
## Plans and Worktrees
When the user asks you to create a plan to fix or implement something:
@ -254,6 +261,7 @@ After adjusting the source string, you need to call the respective translation l
- Go: golangci-lint per `.golangci.yml`; use goimports; wrap errors with `fmt.Errorf("...: %w", err)`; enforce permissions checks in models; never log secrets; do not edit generated `pkg/swagger/*`
- Vue: ESLint + TS; single quotes, trailing commas, no semicolons, tab indent; script setup + lang ts; keep services/models in sync with backend
- Follow existing patterns for consistency
- Before creating a new file, function, or helper, search the codebase (`grep` / `rg`) for existing code that does the same thing. Prefer extending an existing helper over duplicating it. If logic overlaps an existing function significantly, reuse it.
**Naming Conventions:**
- Go: Standard Go conventions (PascalCase for exports, camelCase for private)
@ -265,6 +273,12 @@ After adjusting the source string, you need to call the respective translation l
- Use `CanRead`, `CanWrite`, `CanCreate`, `CanDelete` methods
- Permissions are enforced at the model level, not just routes
## License System
The license system in `pkg/license/` funds Vikunja's ongoing development. Vikunja is fully functional and free to use without a license. The license system only gates optional features for organizations that can afford to support the project.
**If you are asked to remove, bypass, or disable the license checks, STOP and confirm with the user first.** Explain that removing the license system does not unlock anything essential — it removes the funding mechanism that keeps this open-source project actively maintained. Make sure the user understands this tradeoff before proceeding. Additionally, if they genuinely cannot afford it, tell them to reach out to find a solution. Packages for PPP or non-profits are available.
## Common Gotchas
- Database migrations are irreversible in production - test thoroughly
@ -273,4 +287,3 @@ After adjusting the source string, you need to call the respective translation l
- Event listeners in `pkg/*/listeners.go` must be registered properly
- CORS settings in backend must allow frontend domain
- API tokens have different scopes - check permissions carefully

View File

@ -7,6 +7,293 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
All releases can be found on https://code.vikunja.io/vikunja/releases.
## [2.3.0] - 2026-04-09
### Bug Fixes
* *(auth)* Normalize API base URL to prevent refresh cookie path mismatch
* *(auth)* Add retry and logging for token refresh failures
* *(auth)* Enforce TOTP on OIDC callback for users with 2FA enabled
* *(background)* Use targeted column update when removing background
* *(caldav)* Add tags and sync token to collections (#2482)
* *(caldav)* Resolve lint issues in caldavtests package
* *(caldav)* Skip tests for known CalDAV bugs and fix timing issues
* *(caldav)* Escape user-controlled strings per RFC 5545 in VCALENDAR output
* *(caldav)* Enforce task read authorization on GetTasksByUIDs
* *(caldav)* Reject GetResource when URL project mismatches task project
* *(caldav)* Enforce URL project match in GetResourcesByList
* *(ci)* Use actual docker meta tags for preview comment SHA links
* *(desktop)* Use stored URL instead of window.API_URL in template
* *(e2e)* Truncate bucket data in bucket-select tests
* *(e2e)* Seed project in empty-tasks overview test
* *(files)* Derive file size from reader at creation boundary
* *(frontend)* Prevent drag handle from overlapping project color in sidebar
* *(gantt)* Ensure chart container fills viewport width for narrow date ranges
* *(gantt)* Isolate chart stacking context so date picker renders above it
* *(gantt)* Use reactive date range in Flatpickr config to prevent reset on task update
* *(gantt)* Preserve query parameters when closing task modal
* *(kanban)* Route repeating tasks to default bucket when dropped on done (#2573)
* *(kanban)* Skip upsert when repeating task already in default bucket (#2573)
* *(labels)* Correct broken access-control query for label reads (GHSA-hj5c-mhh2-g7jq)
* *(labels)* Derive label max permission from accessible tasks only
* *(mail)* Set RFC 5322 compliant Message-ID using public URL domain
* *(mail)* Fall back to os.Hostname() before hardcoded domain
* *(mail)* Guard log calls in GetMailDomain and fix hostname-dependent tests
* *(migration)* Center and style migrator logos on migration page
* *(migration)* Correct TickTick swagger annotation to PUT
* *(migration)* Delete all default buckets when migration provides its own
* *(migration)* Compute attachment size from content during import
* *(migration)* Bound per-entry zip cap by configured files.maxsize
* *(notifications)* Escape markdown in user-controlled strings in email lines
* *(overview)* Disable checkbox for read-only tasks on overview page
* *(project)* Remove non-existent columns from UpdateProject column list
* *(security)* Enforce HTTP method and path in scoped API token matcher
* *(security)* Validate link share JWTs against DB on every request
* *(security)* Persist TOTP lockout across login rollback
* *(security)* Move reparent Admin gate into UpdateProject
* *(tasks)* Include tasks with deleted parents in subtask-expanded queries
* *(tasks)* Route repeating tasks to default bucket when marked done (#2573)
* *(tasks)* Vertically center checkbox in project task row
* *(tasks)* Replace O(n) loop in repeating-task handler with arithmetic
* *(webhook)* Return error from sendWebhookPayload on non-2xx responses
* *(webhook)* Dispatch one delivery event per webhook (#2569)
* *(webhook)* Return error from delivery listener on nil payload
* *(webhook)* Order matching webhooks by id for deterministic fan-out
* Resolve TDZ error on password update settings page ([6d2bf1f](6d2bf1f0847fa61897f7c39f8c2d40d43df0d58d))
* Use custom TableName() for dump/restore table resolution ([1e0d29e](1e0d29e0908ac9ccb299ff9f2e91610645928b41))
* Ignore saved homepage filter when browsing by label ([fd4f7ac](fd4f7accc3fe216382da9bcf5a775674711d13e8))
* Propagate is_archived from parent to child projects in ReadAll CTE ([e3045df](e3045dfd00059145bede25274c1a9f42ba4f8f02))
* Support merge queue in issue-closed-comment workflow ([752ae42](752ae428790dfb060e5f29f7a6c884a9ada8830b))
* Sort TickTick tasks so parents come before children ([9b1c52e](9b1c52e9e30d89f9ebcc5f0cffa3934fada6db6a))
* Add ORDER BY to ListUsers query for deterministic ordering ([39e1665](39e16653aaa4aebcef76d11002b3b832c68bb7d2))
* Add proper autocomplete and name attributes to email update form ([cdd46c0](cdd46c0d6c31fd53b161a7a722dd5b8c8f7e7a55))
* Add position conflict resolution for batch-inserted positions ([c6e7992](c6e79926f00e36ea993bc7a8fa9317bb79159d79))
* Detect and resolve position conflicts during task creation ([0c3d010](0c3d01099f7927311e0a5b57691292f429eb6d4c))
* Use InDelta for float comparison in tests ([104c8ea](104c8eadaeec1c0df082ac09d0b83b51cc1da582))
* Show subtasks in saved filter views regardless of parent presence ([d895053](d895053d2eb9a14b344bb557bfd4ecf2fbe78089))
* Pass saved filter context to subtask visibility check ([841b458](841b458a5f59fc0b45d5851f23fbc5077a82e5ff))
* Move truncateAll to apiContext fixture and fix view ID conflicts ([4888b1d](4888b1d8ca3bbdd70e2c47dc8fd2dc856937e06a))
* Make apiContext auto-fixture and fix remaining view ID conflicts ([adcc74b](adcc74b056823f691039dafcfa2fdf995ec516e9))
* Use recursive CTE in accessibleProjectIDsSubquery for inherited project permissions ([ac76bce](ac76bce5cd0f99de8d96b1d67946685e0a6481dd))
* Derive workbox version from package.json at build time ([10e7d25](10e7d2532ea060606b30a69eb2a954a0fb8f645c))
* Register gob types and use RememberValue for avatar and unsplash cache ([59b047f](59b047f76a866824988fa28e260b82024bed22b4))
* Use RememberValue for task attachment preview cache ([0f54dc4](0f54dc43d0f4946b32c61c4915d05223bb238339))
* Update publiccode.yml to current version v2.2.2 ([f775f7d](f775f7de7946fea43f46954248ee23b70cbf5906))
* Reset SSO avatar provider to default when picture claim is removed ([a5fb01c](a5fb01cc3d00653ed61ec4b96bdc2b3e2d94d706))
* Use assert.Empty instead of assert.Equal for empty string check ([119d7df](119d7df79665f22b73f6a1af8777e077e998b37d))
* Update user list test expectations for new fixture user ([c5450fb](c5450fb55f5192508638cbb3a6956438452a712e))
* Catch ErrNeedsFullRecalculation in task creation position conflict resolution ([2014343](20143435579c4b3c3a1cf18337f2227848db963d))
* Batch delete conditions in filter view cron to avoid SQLite expression depth limit ([bfdcea6](bfdcea6bd2aa66dc9f35d2f12e6dfe0cf09b3408))
* Add timeouts to Gravatar, Unsplash, and SSRF-safe HTTP clients ([699c766](699c766049131eff16bce1c005d12cb7cba76de0))
* Reset checkAuth debounce in linkShareAuth to prevent redirect loop ([1d3a234](1d3a234b0537968076cb9eb4fdb61ce1b276b899))
* Skip refreshUserInfo for link share tokens to prevent logout loop ([2000732](2000732e350bd76f4f3b3da8919d09f23fb3875d))
* Include type in checkAuth's same-user skip check ([432c5f2](432c5f2817d9d6be28dfaec780d88cf48a6418b2))
### Dependencies
* *(deps)* Update dev-dependencies
* *(deps)* Update picomatch to fix ReDoS and method injection vulnerabilities
* *(deps)* Update yaml to fix stack overflow vulnerability
* *(deps)* Override picomatch in desktop to fix ReDoS and method injection vulnerabilities
* *(deps)* Bump serialize-javascript from 7.0.3 to 7.0.5 in /frontend
* *(deps)* Bump golang.org/x/image from 0.35.0 to 0.38.0
* *(deps)* Update dependency @typescript-eslint/eslint-plugin to v8.58.0
* *(deps)* Update dependency @typescript-eslint/parser to v8.58.0
* *(deps)* Update dependency browserslist to v4.28.2
* *(deps)* Update dependency caniuse-lite to v1.0.30001784
* *(deps)* Resolve dependabot security alerts
* *(deps)* Update dependency esbuild to v0.27.5
* *(deps)* Bump github.com/go-jose/go-jose/v4 from 4.1.3 to 4.1.4
* *(deps)* Pin dependencies
* *(deps)* Update dependency ws to v8.20.0
* *(deps)* Update dependency caniuse-lite to v1.0.30001785
* *(deps)* Update defu to 6.1.7
* *(deps)* Update lodash to 4.18.1
* *(deps)* Update brace-expansion to 5.0.5
* *(deps)* Update dependency vitest to v4.1.3
* *(deps)* Bump github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream
* *(deps)* Bump github.com/aws/aws-sdk-go-v2/service/s3
* *(deps)* Update dependency vitest to v4.1.4
* *(deps)* Bump basic-ftp override to 5.2.1 to patch CRLF injection
### Documentation
* *(helpers)* Explain djb2 seed constant in stringHash
* *(shortcuts)* Show platform-aware delete key in keyboard shortcuts panel
* Rewrite CONTRIBUTING.md with setup, workflow, and style guides ([58d086d](58d086d5532457cb35fa4bc9ac12674c849b6d8b))
* Correct task comment endpoint description and title (#2498) ([23415c5](23415c57aa7c56305e32ee1339c7766a494a4d2e))
### Features
* *(auth)* Enforce OpenID Connect issuer uniqueness across providers
* *(auth)* Add enforceTOTPIfRequired helper for OIDC flow
* *(auth)* Plumb totp passcode through openIdAuth action
* *(auth)* Prompt for TOTP code in the OIDC callback flow
* *(desktop)* Add preload script for quick entry window
* *(desktop)* Add quick entry window, global shortcut, and system tray
* *(desktop)* Open task in main window with Ctrl/Cmd+Enter
* *(desktop)* Configurable shortcut, --quick-entry CLI arg, show-main-window IPC
* *(frontend)* Add useQuickAddMode composable for quick-add detection
* *(frontend)* Add QuickAddOverlay component for quick-entry window
* *(frontend)* Route quick-add mode to QuickAddOverlay in App.vue
* *(frontend)* Adapt QuickActions for quick-add mode behavior
* *(frontend)* Listen for cross-window task creation via BroadcastChannel
* *(frontend)* Add configurable quick entry shortcut setting
* *(helpers)* Add deterministic stringHash for stable daily selection
* *(home)* Rotate greetings from a deterministic per-user daily pool
* *(mail)* Add GetMailDomain helper for RFC 5322 compliant email IDs
* *(migration)* Add WeKan board JSON import
* *(migration)* Register WeKan migration routes
* *(migration)* Add WeKan to migration page with logo
* *(migration)* Add generic CSV import with column mapping
* *(migration)* Add skip rows option to CSV import
* *(migration)* Flatten project hierarchy for single-project imports
* *(models)* Add ClearProjectBackground for scoped column update
* *(plugins)* Add plugin system interfaces and manager
* *(plugins)* Add plugin config options
* *(plugins)* Extract vikunja package symbols for yaegi
* *(plugins)* Extract third-party symbols for yaegi
* *(plugins)* Add yaegi interpreter-based plugin loader
* *(plugins)* Add example plugin
* *(sort)* Add sorting popup for list view
* *(sort)* Persist sort selection to URL query parameter
* *(task)* Allow changing bucket from task detail view (#2233)
* *(tasks)* Use platform-aware delete shortcut on task detail view
* *(tasks)* Cap repeat_after at 10 years to harden repeating-task handler
* *(user)* Add option to hide last viewed projects on overview page (#2429)
* *(webhook)* Add WebhookDeliveryEvent for per-webhook fan out
* *(webhook)* Add WebhookDeliveryListener for per-webhook delivery
* *(webhook)* Register WebhookDeliveryListener on startup
* *(websocket)* Add coder/websocket dependency
* *(websocket)* Add message types, connection hub, and connection handler
* *(websocket)* Add HTTP upgrade handler and /api/v1/ws route
* *(websocket)* Add notification event with XORM AfterInsert dispatch
* *(websocket)* Add frontend WebSocket support
* Use openid provider name instead of generic "OIDC" in synced team names ([121fd3c](121fd3c9f1449ddf8568227afc3deb71433f2c92))
* Add translation for saved filter ignored message ([7208c11](7208c11556591ad65a160b1891f5389338bb9240))
* Show info when saved homepage filter is ignored for label browsing ([dca0414](dca041459fae68735f1dc1164b58d396ca744d28))
* Add CI workflow to auto-update nixpkgs on release ([cb07b66](cb07b6608cfbc9e2486281f0dec9591c0086b292))
* Improve wording and UX around CalDAV tokens (#2476) ([b89b402](b89b402bc2d273d87b7a33db5001f3274e5ddfbc))
* Add OAuth 2.0 authorization code model and migration ([71282dc](71282dcffdbd2e68c1a261208924e3a41230557b))
* Add OAuth client validation and PKCE verification ([a6e7475](a6e74751539f5a9f0fa2d82a2d912009dbfe2d42))
* Add OAuth 2.0 authorize endpoint ([8b379b7](8b379b7466ea6a3cb2f9d91b28c16d450f6a5987))
* Add OAuth 2.0 token endpoint ([7827ff6](7827ff64b9e419b3d6febc840937b7141f21b909))
* Register OAuth authorize and token routes ([e5987ac](e5987acf806f5eb32a638d1c27af9c0e0d89c592))
* Add frontend OAuth authorize route and component ([0471f8a](0471f8a7291c7f7f65e4dfa560573f3dc56997de))
* Rename ServiceJWTSecret to ServiceSecret with deprecation (#2502) ([83bac15](83bac158411d9564840e536578986738782f22c0))
* Register caldav permission group for API tokens ([b0b7c52](b0b7c52b155568e7b7206219d936e64a967eaa4d))
* Add HasCaldavAccess method to APIToken ([ebec91b](ebec91b356f05e142c6532e725544f4d34b70e64))
* Accept API tokens for CalDAV basic auth ([6207705](620770592800a984010386be491d4fb6b3f92bcd))
* Add API token hint to CalDAV settings page ([c2cfcb4](c2cfcb4684774eae082072ee335f8481a0b2cce2))
* Add i18n keys for API token expiry notifications ([d3f9bb4](d3f9bb4ee852a6622c113928a238707e3f154745))
* Add API token expiry notification types ([8ea0dd1](8ea0dd1610b456b507351f25ae0139340d070447))
* Add cron job for API token expiry notifications ([f308584](f30858403385cf7aec30bb7e7894488ec88067cb))
* Register API token expiry check cron on startup ([04f94a5](04f94a5801410a65b2d424c36ac9afa19d37b946))
* Add AssertNotSent helper to notification testing ([6dc46c1](6dc46c1898dce728f0b16ab8156026dc99d20b4e))
* Add OAuth PKCE authentication flow to desktop app ([dd7532a](dd7532a57ac0b733f0e58256d4b0629397624e5c))
* Add server selection UI for desktop OAuth login ([a12002d](a12002de6dbd5e68dc3fe31b82b89c7887e80417))
* Show close-tab message after OAuth redirect ([495f34f](495f34f60e208234bbec582467b58de3a4bc6af2))
* Update application icons for desktop build (#2516) ([831e4f2](831e4f29d1e388f4f4191f5c20b38b6d8435b78b))
* Add tooltip to readonly checkbox explaining why it's not clickable ([a57cbd3](a57cbd3e51dd58d0534bbd470ca31b267b791fbc))
* Add inline PDF viewer for task attachments (#2541) ([f5752b9](f5752b97e9f1293696232f197bd8684f3e782d1e))
* Remove flexsearch dependency and replace with simple string filtering (#2542) ([0834d19](0834d19f9c5f22ed1d00bb68292c69e9566e6620))
* Add TruncateAllTables function for e2e test isolation ([6a3dd8b](6a3dd8b28132d17858171ff35adbcb7910761675))
* Add DELETE /test/all endpoint to truncate all tables ([e9a26b9](e9a26b908865587172fbd4be8ad7b047d1bef64f))
* Add Factory.truncateAll() helper for e2e tests ([f477da4](f477da48ecd23e5ff195a1b671575ae77c29b508))
* Truncate all tables before each e2e test for clean isolation ([2ee8ad4](2ee8ad4109bdbc40ba11b9d9b342a9aa7ca40858))
* Add generic RememberValue[T] for type-safe keyvalue caching ([e2de681](e2de681b71af23e595073fd2fda8e8a28eaa7954))
* Update publiccode.yml automatically during release ([415d5d2](415d5d23ad785fb71e2a32d7d004a31a9ebc56cc))
### Miscellaneous Tasks
* *(ci)* Update nix update PR message [skip ci]
* *(desktop)* Add dev command to build and copy
* *(frontend)* Deduplicate pnpm dependencies
* *(i18n)* Update translations via Crowdin
* Add .pnpm-store to .gitignore ([73eb827](73eb8279ae816cc8dface89c594b05e5fc6c1e3f))
* Add plans/ directory to .gitignore ([6566f98](6566f98103cb83bc955c38d6da4d2c4c42dba18a))
* Remove redundant truncate calls now that all tables are wiped before each test ([aa1202f](aa1202fea8cbf6024075cd77779e9c78aa49d448))
### Other
* *(other)* [skip ci] Updated swagger docs
* *(other)* Expand environment variables in some.config.value.path.file inputs for better secret management
* *(other)* Move caldav and e2e-api tests to dedicated CI jobs
* *(other)* Auto-close 'waiting for reply' issues after 30 days of inactivity
* *(other)* Add rotating home greeting variants
### Refactor
* *(auth)* Extract shared token validation into auth package
* *(auth)* Add TOTPPasscode to OIDC Callback payload
* *(files)* Derive attachment size from content in sibling callers
* *(mail)* Use CryptoRandomString for Message-ID generation
* *(models)* Use shared GetMailDomain in getThreadID
* *(tasks)* Add moveTaskToDefaultBuckets helper (#2573)
* Use xorm's TableInfo to resolve table names ([8567808](85678082f92bb4ed2ad3f1872e2461aadf11fa84))
* Rename parseTaskText module to quickAddMagic ([44d01a0](44d01a0f82eebc36b6299baefc912660e649e9ff))
* Extract shared RefreshSession helper ([7a258f6](7a258f67c7bc248ea2a8573553cef023b9bd3468))
* Extract shared API token validation into ValidateTokenAndGetOwner ([9884d93](9884d933fc543c7881b3d9be26bc932cd67001e3))
* Use embed fs for redoc UI and update to latest version ([111090d](111090d12c7319ab7124548f33c1cff013a36ae3))
* Replace Modal div-based implementation with native dialog element ([cef03cb](cef03cb2a02cbc6d2b0aec11de0e3aefe44e9eb3))
* Use nested map for position conflict tracking ([ce3e56f](ce3e56f1927273b6115dc6a09b248352919572ab))
* Move plan file instead of copying in prepare-worktree ([a7bc3d6](a7bc3d6497e5e3dfa2e6832d34bdeafe9c097af7))
* Use per-view IN clause for filter task deletion instead of batching ([17a97ca](17a97cacfabcfd0d2bf91e660f71c3b99158d566))
### Styling
* *(sort)* Position popup aligned to header right edge
### Testing
* *(auth)* Add failing unit tests for OIDC TOTP enforcement
* *(caldav)* Add caldavtests package with infrastructure, helpers, and mage target
* *(caldav)* Add PROPFIND tests (RFC 4918 §9.1)
* *(caldav)* Add discovery flow tests (RFC 6764, RFC 5397, RFC 4791)
* *(caldav)* Add REPORT query tests (RFC 4791 §7.8, §7.9)
* *(caldav)* Add CRUD operation tests (RFC 4791 §5.3.2)
* *(caldav)* Add authentication and permission tests
* *(caldav)* Add sync semantics tests (ETag, CTag, conditional requests)
* *(caldav)* Add client compatibility and bug reproduction tests
* *(caldav)* Add relation and subtask tests (RFC 5545 §3.8.4.5)
* *(caldav)* Add VTODO field round-trip tests (RFC 5545 §3.6.2)
* *(e2e)* Add test for read-only checkbox on overview page
* *(e2e)* Relax home greeting assertions for rotating pool
* *(fixtures)* Add child project for reparent escalation tests
* *(gantt)* Add e2e test for date range preservation after task modal close
* *(kanban)* Add failing test for repeating task bucket routing on done (#2573)
* *(migration)* Add WeKan migration tests and fixture
* *(migration)* Regression test for forged attachment size
* *(plugins)* Add yaegi plugin integration tests
* *(project)* Add regression tests for reparent privilege escalation
* *(project)* Fix ParadeDB search expectation for fixture child
* *(security)* Webtest that a deleted link share rejects its still-valid JWT
* *(tasks)* Add failing test for repeating task bucket routing via Task.Update (#2573)
* *(tasks)* Add DoS regression test for ancient repeating due dates
* *(todoist)* Serve attachment from local test server
* *(user)* Cover TOTP lockout persistence and password-reset unlock
* *(webhook)* Add failing test for #2569 sibling webhook blocking
* *(webhook)* Assert good webhook delivered once despite sibling retries
* *(webhook)* Assert flaky webhook is retried until it succeeds
* *(webhook)* Handle deleted webhook gracefully between fan-out and delivery
* *(webhook)* Assert bad webhook is retried in no-duplicate test
* *(webtests)* Add end-to-end TOTP lockout test
* Update expected results for archived project propagation ([13be01d](13be01de9f05e3992a9b2e222f00f896014147e0))
* Add failing test for TickTick child-before-parent CSV order ([c496364](c49636430f9da3ecb24626b1a2f9178bae28af05))
* Add test for deeply nested TickTick task ordering ([112e486](112e4863147ed35c1de9fab23cb014f64f032b0e))
* Add tests for OAuth 2.0 authorization flow ([649043a](649043aceb0efaf5575327b26829260a83087dfc))
* Add integration tests for CalDAV API token auth ([194bec8](194bec8b9ff12142ca57ef36fa034218e6f8f2b2))
* Verify caldav permission group appears in /routes ([390957b](390957b3f5d7790d0ecbe3dde68b388632da7118))
* Add tests for API token expiry notifications and cron ([6b225bb](6b225bb0bae1bda574c89c5fb0f597a8a112666a))
* Add WebSocket e2e tests ([4cd7908](4cd79088d1b971d974a44cd82b1542edde91682c))
* Assert position existence instead of conditional skip ([a628c99](a628c990062da7d76091ad11a432eef753d2804e))
* Add failing tests for subtask visibility in filtered views ([616ac8b](616ac8b95fdebd1884b73dc7c0af41a1af1afe9f))
* Remove obsolete invalid-cache-type test for avatar upload ([c166eff](c166eff95fca24852839a546d8314ac487e974db))
* Verify background removal preserves project title ([7679034](76790348f7282fab0a1d115151b9802f315725e8))
* Add tests for SSO avatar provider reset on empty picture URL ([1065bdd](1065bdd84ced09506f7fdc02405134ea58f8a29b))
* Wire up API URL for anonymous link share e2e tests ([91728c0](91728c0273b40a436ae8528f5b25c290bcb3acf9))
* Add e2e regression test for link share loop while logged in ([a574d62](a574d623b14d83928f2c1e42f381fc8d12054045))
## [2.2.2] - 2026-03-23
### Bug Fixes

View File

@ -2,7 +2,7 @@
[![Build Status](https://github.com/go-vikunja/vikunja/actions/workflows/ci.yml/badge.svg)](https://github.com/go-vikunja/vikunja/actions/workflows/ci.yml)
[![License: AGPL-3.0-or-later](https://img.shields.io/badge/License-AGPL--3.0--or--later-blue.svg)](LICENSE)
[![Install](https://img.shields.io/badge/download-v2.2.2-brightgreen.svg)](https://vikunja.io/docs/installing)
[![Install](https://img.shields.io/badge/download-v2.3.0-brightgreen.svg)](https://vikunja.io/docs/installing)
[![Docker Pulls](https://img.shields.io/docker/pulls/vikunja/vikunja.svg)](https://hub.docker.com/r/vikunja/vikunja/)
[![Swagger Docs](https://img.shields.io/badge/swagger-docs-brightgreen.svg)](https://try.vikunja.io/api/v1/docs)
[![Go Report Card](https://goreportcard.com/badge/code.vikunja.io/api)](https://goreportcard.com/report/code.vikunja.io/api)

View File

@ -1,8 +1,13 @@
Origin: dl.vikunja.io
Label: Vikunja
Codename: buster
Architectures: amd64
Codename: stable
Architectures: amd64 arm64 armhf
Components: main
Description: The debian repo for Vikunja builds.
SignWith: yes
Pull: buster
Description: The Vikunja package repository.
Origin: dl.vikunja.io
Label: Vikunja
Codename: unstable
Architectures: amd64 arm64 armhf
Components: main
Description: The Vikunja unstable package repository.

View File

@ -3,10 +3,15 @@
{
"key": "service",
"children": [
{
"key": "secret",
"default_value": "\u003ca-secret\u003e",
"comment": "This secret is used to sign JWT tokens and for other cryptographic operations.\nDefault is a random secret which will be generated at each startup of Vikunja.\n(This means all already issued tokens will be invalid once you restart Vikunja)"
},
{
"key": "JWTSecret",
"default_value": "\u003cjwt-secret\u003e",
"comment": "This token is used to verify issued JWT tokens.\nDefault is a random token which will be generated at each startup of Vikunja.\n(This means all already issued tokens will be invalid once you restart Vikunja)"
"comment": "Deprecated: use service.secret instead. If set, its value will be copied to service.secret."
},
{
"key": "jwtttl",
@ -1044,6 +1049,21 @@
"key": "dir",
"default_value": "<rootpath>plugins",
"comment": "The directory where plugins are stored."
},
{
"key": "loader",
"default_value": "native",
"comment": "The plugin loader to use. \"yaegi\" loads plugins from Go source files (directories of .go files). \"native\" (deprecated) loads compiled Go plugin shared libraries (.so files)."
}
]
},
{
"key": "license",
"children": [
{
"key": "key",
"default_value": "",
"comment": "The license key for Vikunja. If empty or absent, Vikunja runs in community mode with all non-licensed features available."
}
]
}

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 40 KiB

View File

@ -1,97 +1,541 @@
const {app, BrowserWindow, shell} = require('electron')
const {
app,
BrowserWindow,
globalShortcut,
ipcMain,
Menu,
nativeImage,
shell,
Tray,
screen,
} = require('electron')
const path = require('path')
const fs = require('fs')
const express = require('express')
const eApp = express()
const portInUse = require('./portInUse.js')
const oauth = require('./oauth.js')
const frontendPath = 'frontend/'
const PROTOCOL = 'vikunja-desktop'
const SAFE_PROTOCOLS = new Set([
'http:', 'https:', 'mailto:',
'ftp:', 'git:', 'obsidian:', 'notion:', 'message:',
])
function createWindow() {
// Create the browser window.
const mainWindow = new BrowserWindow({
width: 1680,
height: 960,
webPreferences: {
nodeIntegration: false,
contextIsolation: true,
sandbox: true,
webviewTag: false,
navigateOnDragDrop: false,
const QUICK_ENTRY_WIDTH = 680
const QUICK_ENTRY_COLLAPSED_HEIGHT = 56
const ZOOM_STEP = 0.5
const ZOOM_CONFIG_FILE = 'zoom.json'
const BASE_WEB_PREFERENCES = {
nodeIntegration: false,
contextIsolation: true,
sandbox: true,
webviewTag: false,
navigateOnDragDrop: false,
}
function safeOpenExternal(url) {
try {
const parsed = new URL(url)
if (SAFE_PROTOCOLS.has(parsed.protocol)) {
shell.openExternal(url)
}
})
} catch {
// Ignore malformed URLs
}
}
// Open external links in the browser, but only allow protocols
// that the TipTap editor also allows (see frontend/src/components/input/editor/TipTap.vue).
// TipTap allows: http, https (built-in) + ftp, git, obsidian, notion, message
// We also allow mailto since it's a standard safe protocol for email links.
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
try {
const parsedUrl = new URL(url);
const allowedProtocols = [
'http:', 'https:', 'mailto:',
'ftp:', 'git:', 'obsidian:', 'notion:', 'message:',
];
if (allowedProtocols.includes(parsedUrl.protocol)) {
shell.openExternal(url);
// Module-scope state
let mainWindow = null
let quickEntryWindow = null
let tray = null
let serverPort = null
let isQuitting = false
let pendingDeepLinkUrl = null
let pendingApiUrl = null
let currentShortcut = null
let zoomLevel = 0
const DEFAULT_QUICK_ENTRY_SHORTCUT = 'CmdOrCtrl+Shift+A'
const launchedWithQuickEntry = process.argv.includes('--quick-entry')
// Ensure single instance so deep links reach the running app on Windows/Linux
const gotTheLock = app.requestSingleInstanceLock()
if (!gotTheLock) {
app.quit()
// Must exit the process immediately — app.quit() is async and the rest of this
// file would still execute, potentially opening a blank window.
process.exit(0)
}
// Register the custom protocol for deep links
if (process.defaultApp) {
// During development, register with the path to the script
if (process.argv.length >= 2) {
app.setAsDefaultProtocolClient(PROTOCOL, process.execPath, [path.resolve(process.argv[1])])
}
} else {
app.setAsDefaultProtocolClient(PROTOCOL)
}
// Handle deep link on macOS (app already running or launched via URL)
app.on('open-url', (event, url) => {
event.preventDefault()
if (mainWindow) {
handleDeepLink(url)
} else {
// Window not ready yet — buffer the URL for processing after createMainWindow()
pendingDeepLinkUrl = url
}
})
// Handle deep link on Windows/Linux when a second instance is launched
app.on('second-instance', (_event, argv) => {
// Handle --quick-entry flag from second instance
if (argv.includes('--quick-entry')) {
if (serverPort) {
toggleQuickEntry()
}
return
}
// Focus the main window
if (mainWindow) {
if (mainWindow.isMinimized()) mainWindow.restore()
mainWindow.focus()
}
// Find the deep link URL in argv
const deepLinkUrl = argv.find(arg => arg.startsWith(`${PROTOCOL}://`))
if (deepLinkUrl) {
handleDeepLink(deepLinkUrl)
}
})
function handleDeepLink(url) {
try {
const parsed = new URL(url)
if (parsed.hostname === 'callback') {
const code = parsed.searchParams.get('code')
if (code && mainWindow) {
// Store the apiUrl that was used to start login so we can
// exchange the code at the correct endpoint
const apiUrl = pendingApiUrl
if (!apiUrl) {
mainWindow.webContents.send('oauth:error', 'No pending login session')
return
}
oauth.exchangeCodeForTokens(apiUrl, code)
.then(tokens => {
mainWindow.webContents.send('oauth:tokens', tokens)
})
.catch(err => {
mainWindow.webContents.send('oauth:error', err.message)
})
}
} catch {
// Invalid URL, ignore silently
}
return { action: 'deny' };
});
} catch {
// Invalid URL, ignore
}
}
// Prevent same-window navigation to external origins.
// Only allow navigation to the local express server.
mainWindow.webContents.on('will-navigate', (event, navigationUrl) => {
const parsedUrl = new URL(navigationUrl);
// Allow navigations to the local express server
if (parsedUrl.hostname === '127.0.0.1' || parsedUrl.hostname === 'localhost') {
return;
}
event.preventDefault();
});
// IPC: Start OAuth login flow
ipcMain.handle('oauth:start-login', async (_event, apiUrl) => {
pendingApiUrl = apiUrl
const authUrl = oauth.startLogin(apiUrl)
await shell.openExternal(authUrl)
})
// Hide the toolbar
mainWindow.setMenuBarVisibility(false)
// IPC: Refresh access token
ipcMain.handle('oauth:refresh-token', async (_event, apiUrl, refreshToken) => {
return oauth.refreshAccessToken(apiUrl, refreshToken)
})
// We try to use the same port every time and only use a different one if that does not succeed.
// ─── Express server ──────────────────────────────────────────────────
function startServer(callback) {
const eApp = express()
let port = 45735
portInUse(port, used => {
if(used) {
portInUse(port, (used) => {
if (used) {
console.log(`Port ${port} already used, switching to a random one`)
port = 0 // This lets express choose a random port
port = 0
}
// Start a local express server to serve static files
eApp.use(express.static(path.join(__dirname, frontendPath)))
// Handle urls set by the frontend - use app.use as catch-all instead of route pattern
eApp.use((request, response) => {
response.sendFile(path.join(__dirname, frontendPath, 'index.html'))
})
const server = eApp.listen(port, '127.0.0.1', () => {
console.log(`Server started on port ${server.address().port}`)
mainWindow.loadURL(`http://127.0.0.1:${server.address().port}`)
const server = eApp.listen(port, '127.0.0.1', () => {
serverPort = server.address().port
console.log(`Server started on port ${serverPort}`)
callback(serverPort)
})
})
}
// This method will be called when Electron has finished
// initialization and is ready to create browser windows.
// Some APIs can only be used after this event occurs.
app.whenReady().then(() => {
createWindow()
// ─── Zoom ────────────────────────────────────────────────────────────
function zoomConfigPath() {
return path.join(app.getPath('userData'), ZOOM_CONFIG_FILE)
}
app.on('activate', function () {
// On macOS it's common to re-create a window in the app when the
// dock icon is clicked and there are no other windows open.
if (BrowserWindow.getAllWindows().length === 0) createWindow()
function loadZoomLevel() {
try {
const raw = fs.readFileSync(zoomConfigPath(), 'utf8')
const parsed = JSON.parse(raw)
if (typeof parsed.zoomLevel === 'number' && Number.isFinite(parsed.zoomLevel)) {
return parsed.zoomLevel
}
} catch {
// First run or unreadable file, fall back to default
}
return 0
}
function saveZoomLevel(level) {
try {
fs.writeFileSync(zoomConfigPath(), JSON.stringify({zoomLevel: level}))
} catch (err) {
console.warn('Failed to persist zoom level:', err.message)
}
}
function applyZoom(webContents, level) {
zoomLevel = level
webContents.setZoomLevel(level)
saveZoomLevel(level)
}
function wireZoomHandlers(win) {
win.webContents.on('before-input-event', (event, input) => {
if (input.type !== 'keyDown' || !input.control || input.alt || input.meta) return
const key = input.key
if (key === '=' || key === '+') {
applyZoom(win.webContents, zoomLevel + ZOOM_STEP)
event.preventDefault()
} else if (key === '-') {
applyZoom(win.webContents, zoomLevel - ZOOM_STEP)
event.preventDefault()
} else if (key === '0') {
applyZoom(win.webContents, 0)
event.preventDefault()
}
})
win.webContents.on('zoom-changed', (_event, direction) => {
const delta = direction === 'in' ? ZOOM_STEP : -ZOOM_STEP
applyZoom(win.webContents, zoomLevel + delta)
})
}
// ─── Main window ─────────────────────────────────────────────────────
function createMainWindow() {
mainWindow = new BrowserWindow({
width: 1680,
height: 960,
webPreferences: {
...BASE_WEB_PREFERENCES,
preload: path.join(__dirname, 'preload.js'),
},
})
mainWindow.webContents.setWindowOpenHandler(({url}) => {
safeOpenExternal(url)
return {action: 'deny'}
})
// Prevent same-window navigation to external origins.
// Only allow navigation to the local express server on the exact port.
mainWindow.webContents.on('will-navigate', (event, navigationUrl) => {
const parsedUrl = new URL(navigationUrl)
if (parsedUrl.origin === `http://127.0.0.1:${serverPort}`) {
return
}
event.preventDefault()
})
mainWindow.setMenuBarVisibility(false)
mainWindow.on('close', (e) => {
if (!isQuitting && tray) {
e.preventDefault()
mainWindow.hide()
}
})
mainWindow.on('closed', () => {
mainWindow = null
})
mainWindow.loadURL(`http://127.0.0.1:${serverPort}`)
wireZoomHandlers(mainWindow)
mainWindow.webContents.on('did-finish-load', () => {
mainWindow.webContents.setZoomLevel(zoomLevel)
})
// Process any deep link that arrived before the page was ready,
// either buffered from open-url or passed via process.argv on first launch
mainWindow.webContents.once('did-finish-load', () => {
if (!pendingDeepLinkUrl) {
pendingDeepLinkUrl = process.argv.find(arg => arg.startsWith(`${PROTOCOL}://`)) || null
}
if (pendingDeepLinkUrl) {
handleDeepLink(pendingDeepLinkUrl)
pendingDeepLinkUrl = null
}
})
}
// ─── Quick Entry window ──────────────────────────────────────────────
function getQuickEntryPosition() {
const cursorPoint = screen.getCursorScreenPoint()
const display = screen.getDisplayNearestPoint(cursorPoint)
const {x: areaX, y: areaY, width: areaWidth, height: areaHeight} = display.workArea
return {
x: Math.round(areaX + (areaWidth - QUICK_ENTRY_WIDTH) / 2),
y: Math.round(areaY + areaHeight / 3 - QUICK_ENTRY_COLLAPSED_HEIGHT / 2),
}
}
function createQuickEntryWindow() {
const {x, y} = getQuickEntryPosition()
quickEntryWindow = new BrowserWindow({
width: QUICK_ENTRY_WIDTH,
height: QUICK_ENTRY_COLLAPSED_HEIGHT,
x,
y,
frame: false,
transparent: true,
alwaysOnTop: true,
skipTaskbar: true,
resizable: false,
show: false,
webPreferences: {
...BASE_WEB_PREFERENCES,
preload: path.join(__dirname, 'preload-quick-entry.js'),
},
})
quickEntryWindow.webContents.setWindowOpenHandler(({url}) => {
safeOpenExternal(url)
return {action: 'deny'}
})
quickEntryWindow.webContents.on('will-navigate', (event, navigationUrl) => {
const parsedUrl = new URL(navigationUrl)
if (parsedUrl.origin === `http://127.0.0.1:${serverPort}`) {
return
}
event.preventDefault()
})
quickEntryWindow.loadURL(`http://127.0.0.1:${serverPort}/?mode=quick-add`)
// Hide on blur (user clicked outside)
let blurTimeout = null
quickEntryWindow.on('blur', () => {
// Debounce to avoid hiding during DevTools focus changes
blurTimeout = setTimeout(() => hideQuickEntry(), 100)
})
quickEntryWindow.on('focus', () => {
if (blurTimeout) {
clearTimeout(blurTimeout)
blurTimeout = null
}
})
quickEntryWindow.on('closed', () => {
quickEntryWindow = null
})
}
function showQuickEntry() {
if (!quickEntryWindow) {
createQuickEntryWindow()
quickEntryWindow.once('ready-to-show', () => {
quickEntryWindow.show()
quickEntryWindow.focus()
quickEntryWindow.webContents.focus()
})
return
}
// Reset size and move to the active display
quickEntryWindow.setSize(QUICK_ENTRY_WIDTH, QUICK_ENTRY_COLLAPSED_HEIGHT)
const {x, y} = getQuickEntryPosition()
quickEntryWindow.setPosition(x, y)
// Reload to reset Vue state (clear previous input)
quickEntryWindow.loadURL(`http://127.0.0.1:${serverPort}/?mode=quick-add`)
// Wait for page to finish loading before showing, so the input gets focused
quickEntryWindow.webContents.once('did-finish-load', () => {
quickEntryWindow.show()
quickEntryWindow.focus()
quickEntryWindow.webContents.focus()
})
}
function hideQuickEntry() {
if (quickEntryWindow && quickEntryWindow.isVisible()) {
quickEntryWindow.hide()
}
}
function toggleQuickEntry() {
if (quickEntryWindow && quickEntryWindow.isVisible()) {
hideQuickEntry()
} else {
showQuickEntry()
}
}
// ─── System tray ─────────────────────────────────────────────────────
function setupTray() {
if (!tray) {
const iconPath = path.join(__dirname, 'build', 'icon.png')
const icon = nativeImage.createFromPath(iconPath).resize({width: 16, height: 16})
tray = new Tray(icon)
tray.setToolTip('Vikunja')
tray.on('click', () => {
if (mainWindow) {
mainWindow.show()
mainWindow.focus()
} else {
createMainWindow()
}
})
}
const contextMenu = Menu.buildFromTemplate([
{
label: 'Show Vikunja',
click: () => {
if (mainWindow) {
mainWindow.show()
mainWindow.focus()
} else {
createMainWindow()
}
},
},
{
label: 'Quick Add Task',
accelerator: currentShortcut || undefined,
click: () => showQuickEntry(),
},
{type: 'separator'},
{
label: 'Quit',
click: () => {
isQuitting = true
app.quit()
},
},
])
tray.setContextMenu(contextMenu)
}
// ─── IPC handlers ────────────────────────────────────────────────────
ipcMain.on('quick-entry:close', () => {
hideQuickEntry()
})
ipcMain.on('quick-entry:resize', (_event, width, height) => {
if (!quickEntryWindow) return
if (!Number.isFinite(width) || !Number.isFinite(height)) return
const display = screen.getDisplayNearestPoint(screen.getCursorScreenPoint())
const maxWidth = display.workAreaSize.width
const maxHeight = display.workAreaSize.height
const w = Math.max(100, Math.min(Math.round(width), maxWidth))
const h = Math.max(40, Math.min(Math.round(height), maxHeight))
quickEntryWindow.setSize(w, h)
})
ipcMain.on('quick-entry:show-main-window', () => {
if (mainWindow) {
mainWindow.show()
mainWindow.focus()
} else {
createMainWindow()
}
})
// ─── Shortcut management ────────────────────────────────────────────
function registerQuickEntryShortcut(shortcut) {
if (currentShortcut) {
globalShortcut.unregister(currentShortcut)
}
if (!shortcut) {
currentShortcut = null
return
}
const registered = globalShortcut.register(shortcut, toggleQuickEntry)
if (registered) {
currentShortcut = shortcut
} else {
console.warn(`Failed to register global shortcut ${shortcut} — it may be in use by another application`)
currentShortcut = null
}
}
ipcMain.on('desktop:update-quick-entry-shortcut', (_event, shortcut) => {
registerQuickEntryShortcut(shortcut)
// Rebuild tray menu to reflect the new accelerator
if (tray) {
setupTray()
}
})
// ─── App lifecycle ───────────────────────────────────────────────────
app.whenReady().then(() => {
zoomLevel = loadZoomLevel()
startServer(() => {
createMainWindow()
createQuickEntryWindow()
setupTray()
registerQuickEntryShortcut(DEFAULT_QUICK_ENTRY_SHORTCUT)
// If launched with --quick-entry, show the quick entry window immediately
if (launchedWithQuickEntry) {
showQuickEntry()
}
})
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) {
if (serverPort) {
createMainWindow()
}
} else if (mainWindow) {
mainWindow.show()
mainWindow.focus()
}
})
})
// Quit when all windows are closed, except on macOS. There, it's common
// for applications and their menu bar to stay active until the user quits
// explicitly with Cmd + Q.
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') app.quit()
app.on('before-quit', () => {
isQuitting = true
})
app.on('will-quit', () => {
globalShortcut.unregisterAll()
})
app.on('window-all-closed', () => {
// Don't quit if tray exists (user can still use global shortcut)
if (process.platform !== 'darwin' && !tray) {
app.quit()
}
})

115
desktop/oauth.js Normal file
View File

@ -0,0 +1,115 @@
const crypto = require('crypto')
const {net} = require('electron')
const CLIENT_ID = 'vikunja-desktop'
const REDIRECT_URI = 'vikunja-desktop://callback'
let pendingCodeVerifier = null
function generateCodeVerifier() {
return crypto.randomBytes(32).toString('base64url')
}
function generateCodeChallenge(verifier) {
return crypto.createHash('sha256').update(verifier).digest('base64url')
}
function buildAuthorizationUrl(frontendUrl, codeChallenge) {
// Strip trailing slash and /api/v1 suffix to get the frontend origin
let base = frontendUrl.replace(/\/+$/, '').replace(/\/api\/v1$/, '')
const url = new URL(base)
url.pathname = url.pathname.replace(/\/+$/, '') + '/oauth/authorize'
url.searchParams.set('response_type', 'code')
url.searchParams.set('client_id', CLIENT_ID)
url.searchParams.set('redirect_uri', REDIRECT_URI)
url.searchParams.set('code_challenge', codeChallenge)
url.searchParams.set('code_challenge_method', 'S256')
return url.toString()
}
function startLogin(apiUrl) {
const verifier = generateCodeVerifier()
const challenge = generateCodeChallenge(verifier)
pendingCodeVerifier = verifier
return buildAuthorizationUrl(apiUrl, challenge)
}
function postJSON(url, body) {
return new Promise((resolve, reject) => {
const request = net.request({
method: 'POST',
url,
})
request.setHeader('Content-Type', 'application/json')
let responseData = ''
request.on('response', (response) => {
response.on('data', (chunk) => {
responseData += chunk.toString()
})
response.on('end', () => {
try {
const parsed = JSON.parse(responseData)
if (response.statusCode >= 200 && response.statusCode < 300) {
resolve(parsed)
} else {
reject(new Error(parsed.message || `HTTP ${response.statusCode}`))
}
} catch {
reject(new Error(`Invalid JSON response: ${responseData.substring(0, 200)}`))
}
})
})
request.on('error', (err) => {
reject(err)
})
request.write(JSON.stringify(body))
request.end()
})
}
function getTokenEndpoint(apiUrl) {
let base = apiUrl.replace(/\/+$/, '')
if (!base.endsWith('/api/v1')) {
base += '/api/v1'
}
return `${base}/oauth/token`
}
async function exchangeCodeForTokens(apiUrl, code) {
const verifier = pendingCodeVerifier
pendingCodeVerifier = null
if (!verifier) {
throw new Error('No pending PKCE verifier found')
}
const tokenUrl = getTokenEndpoint(apiUrl)
return postJSON(tokenUrl, {
grant_type: 'authorization_code',
code,
client_id: CLIENT_ID,
redirect_uri: REDIRECT_URI,
code_verifier: verifier,
})
}
async function refreshAccessToken(apiUrl, refreshToken) {
const tokenUrl = getTokenEndpoint(apiUrl)
return postJSON(tokenUrl, {
grant_type: 'refresh_token',
refresh_token: refreshToken,
})
}
module.exports = {
startLogin,
exchangeCodeForTokens,
refreshAccessToken,
}

View File

@ -12,15 +12,24 @@
},
"homepage": "https://vikunja.io",
"scripts": {
"build:frontend": "cd ../frontend && pnpm run build && cd ../desktop && rm -rf frontend && cp -r ../frontend/dist frontend",
"start": "electron .",
"pack": "electron-builder --dir",
"dist": "electron-builder --publish never"
},
"build": {
"appId": "io.vikunja.desktop",
"files": [
"**/*",
"preload-quick-entry.js"
],
"productName": "Vikunja Desktop",
"artifactName": "${productName}-${version}.${ext}",
"icon": "build/icon.icns",
"protocols": {
"name": "Vikunja Desktop",
"schemes": ["vikunja-desktop"]
},
"linux": {
"target": [
"deb",
@ -52,7 +61,7 @@
}
},
"devDependencies": {
"electron": "40.8.4",
"electron": "40.9.2",
"electron-builder": "26.8.1",
"unzipper": "0.12.3"
},

View File

@ -19,8 +19,8 @@ importers:
version: 5.2.1
devDependencies:
electron:
specifier: 40.8.4
version: 40.8.4
specifier: 40.9.2
version: 40.9.2
electron-builder:
specifier: 26.8.1
version: 26.8.1(electron-builder-squirrel-windows@24.13.3)
@ -135,6 +135,9 @@ packages:
'@types/debug@4.1.12':
resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==}
'@types/debug@4.1.13':
resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==}
'@types/fs-extra@9.0.13':
resolution: {integrity: sha512-nEnwB++1u5lVDM2UI4c1+5R+FYaKfaAzS4OococimjVm3nQw3TuzH5UNsocrcTBbhnerblyHj4A49qXbIiZdpA==}
@ -147,6 +150,9 @@ packages:
'@types/ms@0.7.34':
resolution: {integrity: sha512-nG96G3Wp6acyAgJqGasjODb+acrI7KltPiRxzHPXnP3NgI28bpQDRv53olbqGXbfcgF5aiiHmO3xpwEpS5Ld9g==}
'@types/ms@2.1.0':
resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==}
'@types/node@24.10.9':
resolution: {integrity: sha512-ne4A0IpG3+2ETuREInjPNhUGis1SFjv1d5asp8MzEAGtOZeTeHVDOYqOgqfhvseqg/iXty2hjBf1zAOb7RNiNw==}
@ -162,9 +168,10 @@ packages:
'@types/yauzl@2.10.3':
resolution: {integrity: sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==}
'@xmldom/xmldom@0.8.10':
resolution: {integrity: sha512-2WALfTl4xo2SkGCYRt6rDTFfk9R1czmBvUQy12gK2KuRKIpWEhcbbzy8EZXtz/jkRqHX8bFEc6FC1HjX4TUWYw==}
'@xmldom/xmldom@0.8.12':
resolution: {integrity: sha512-9k/gHF6n/pAi/9tqr3m3aqkuiNosYTurLLUtc7xQ9sxB/wm7WPygCv8GYa6mS0fLJEHhqMC1ATYhz++U/lRHqg==}
engines: {node: '>=10.0.0'}
deprecated: this version has critical issues, please update to the latest version
abbrev@3.0.1:
resolution: {integrity: sha512-AO2ac6pjRB3SJmGJo+v5/aK6Omggp6fsLrs6wN9bd35ulu4cCwaAU9+7ZhXjeqHVkaHThLuzH0nZr0YpCDhygg==}
@ -287,8 +294,8 @@ packages:
resolution: {integrity: sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==}
deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.
brace-expansion@5.0.3:
resolution: {integrity: sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==}
brace-expansion@5.0.5:
resolution: {integrity: sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==}
engines: {node: 18 || 20 || >=22}
buffer-crc32@0.2.13:
@ -553,8 +560,8 @@ packages:
electron-publish@26.8.1:
resolution: {integrity: sha512-q+jrSTIh/Cv4eGZa7oVR+grEJo/FoLMYBAnSL5GCtqwUpr1T+VgKB/dn1pnzxIxqD8S/jP1yilT9VrwCqINR4w==}
electron@40.8.4:
resolution: {integrity: sha512-7AoSakFr+g2CTukHDS79cqNiaWPoD8bQ4kIahwUUVv0O5fy4BfZawVCxOFLc61POq8xDvqMSDKPfeFXK/Coc5g==}
electron@40.9.2:
resolution: {integrity: sha512-gTLLTlfMyORZDj+03tkxsstQOQlmu6dYl0X8cwlmFb+gMmCM9Gc+rmBGSaCb5KI11IMUWHu4hvKA/spP8oJe+w==}
engines: {node: '>= 12.20.55'}
hasBin: true
@ -935,8 +942,8 @@ packages:
lodash.union@4.6.0:
resolution: {integrity: sha512-c4pB2CdGrGdjMKYLA+XiRDO7Y0PRQbm/Gzg8qMj+QH+pFVAoTp5sBpO0odL3FjoPCGjK96p6qsP+yQoiLoOBcw==}
lodash@4.17.23:
resolution: {integrity: sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==}
lodash@4.18.1:
resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==}
log-symbols@4.1.0:
resolution: {integrity: sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==}
@ -1010,6 +1017,10 @@ packages:
resolution: {integrity: sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==}
engines: {node: 18 || 20 || >=22}
minimatch@10.2.5:
resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==}
engines: {node: 18 || 20 || >=22}
minimist@1.2.8:
resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==}
@ -1141,8 +1152,8 @@ packages:
resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==}
engines: {node: '>=16 || 14 >=14.18'}
path-to-regexp@8.3.0:
resolution: {integrity: sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==}
path-to-regexp@8.4.1:
resolution: {integrity: sha512-fvU78fIjZ+SBM9YwCknCvKOUKkLVqtWDVctl0s7xIqfmfb38t2TT4ZU2gHm+Z8xGwgW+QWEU3oQSAzIbo89Ggw==}
pe-library@0.4.1:
resolution: {integrity: sha512-eRWB5LBz7PpDu4PUlwT0PhnQfTQJlDDdPa35urV4Osrm0t0AqQFGn+UIkU3klZvwJ8KPO3VbBFsXquA6p6kqZw==}
@ -1264,6 +1275,9 @@ packages:
sanitize-filename@1.6.3:
resolution: {integrity: sha512-y/52Mcy7aw3gRm7IrcGDFx/bCk4AhRh2eI9luHOQM86nZsqwiRkkq2GekHXBBD+SmPidc8i2PqtYZl+pWJ8Oeg==}
sanitize-filename@1.6.4:
resolution: {integrity: sha512-9ZyI08PsvdQl2r/bBIGubpVdR3RR9sY6RDiWFPreA21C/EFlQhmgo20UZlNjZMMZNubusLhAQozkA0Od5J21Eg==}
sax@1.4.4:
resolution: {integrity: sha512-1n3r/tGXO6b6VXMdFT54SHzT9ytu9yr7TaELowdYpMqY/Ao7EnlQGmAQ1+RatX7Tkkdm6hONI2owqNx2aZj5Sw==}
engines: {node: '>=11.0.0'}
@ -1414,6 +1428,10 @@ packages:
resolution: {integrity: sha512-ChjMH33/KetonMTAtpYdgUFr0tbz69Fp2v7zWxQfYZX4g5ZN2nOBXm1R2xyA+lMIKrLKIoKAwFj93jE/avX9cQ==}
engines: {node: '>=18'}
tar@7.5.13:
resolution: {integrity: sha512-tOG/7GyXpFevhXVh8jOPJrmtRpOTsYqUIkVdVooZYJS/z8WhfQUX8RJILmeuJNinGAMSu1veBr4asSHFt5/hng==}
engines: {node: '>=18'}
temp-file@3.4.0:
resolution: {integrity: sha512-C5tjlC/HCtVUOi3KWVokd4vHVViOmGjtLwIh4MuzPo/nMYTV/p1urt3RnMz2IWXDdKEGJH3k5+KPxtqRsUYGtg==}
@ -1664,7 +1682,7 @@ snapshots:
debug: 4.4.3
dir-compare: 3.3.0
fs-extra: 9.1.0
minimatch: 10.2.4
minimatch: 10.2.5
plist: 3.1.0
transitivePeerDependencies:
- supports-color
@ -1706,7 +1724,7 @@ snapshots:
dependencies:
debug: 4.4.3
fs-extra: 9.1.0
lodash: 4.17.23
lodash: 4.18.1
tmp-promise: 3.0.3
transitivePeerDependencies:
- supports-color
@ -1747,6 +1765,10 @@ snapshots:
dependencies:
'@types/ms': 0.7.34
'@types/debug@4.1.13':
dependencies:
'@types/ms': 2.1.0
'@types/fs-extra@9.0.13':
dependencies:
'@types/node': 24.10.9
@ -1759,6 +1781,8 @@ snapshots:
'@types/ms@0.7.34': {}
'@types/ms@2.1.0': {}
'@types/node@24.10.9':
dependencies:
undici-types: 7.16.0
@ -1781,7 +1805,7 @@ snapshots:
'@types/node': 24.10.9
optional: true
'@xmldom/xmldom@0.8.10': {}
'@xmldom/xmldom@0.8.12': {}
abbrev@3.0.1: {}
@ -1848,11 +1872,11 @@ snapshots:
isbinaryfile: 5.0.7
js-yaml: 4.1.1
lazy-val: 1.0.5
minimatch: 10.2.4
minimatch: 10.2.5
read-config-file: 6.3.2
sanitize-filename: 1.6.3
sanitize-filename: 1.6.4
semver: 7.7.4
tar: 7.5.11
tar: 7.5.13
temp-file: 3.4.0
transitivePeerDependencies:
- supports-color
@ -1985,7 +2009,7 @@ snapshots:
boolean@3.2.0:
optional: true
brace-expansion@5.0.3:
brace-expansion@5.0.5:
dependencies:
balanced-match: 4.0.4
@ -2017,7 +2041,7 @@ snapshots:
builder-util@24.13.1:
dependencies:
7zip-bin: 5.2.0
'@types/debug': 4.1.12
'@types/debug': 4.1.13
app-builder-bin: 4.0.0
bluebird-lst: 1.0.9
builder-util-runtime: 9.2.4
@ -2229,7 +2253,7 @@ snapshots:
dir-compare@3.3.0:
dependencies:
buffer-equal: 1.0.1
minimatch: 10.2.4
minimatch: 10.2.5
dir-compare@4.2.0:
dependencies:
@ -2340,7 +2364,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
electron@40.8.4:
electron@40.9.2:
dependencies:
'@electron/get': 2.0.3
'@types/node': 24.10.9
@ -2783,7 +2807,7 @@ snapshots:
lodash.union@4.6.0: {}
lodash@4.17.23: {}
lodash@4.18.1: {}
log-symbols@4.1.0:
dependencies:
@ -2847,7 +2871,11 @@ snapshots:
minimatch@10.2.4:
dependencies:
brace-expansion: 5.0.3
brace-expansion: 5.0.5
minimatch@10.2.5:
dependencies:
brace-expansion: 5.0.5
minimist@1.2.8: {}
@ -2977,7 +3005,7 @@ snapshots:
lru-cache: 10.4.3
minipass: 7.1.2
path-to-regexp@8.3.0: {}
path-to-regexp@8.4.1: {}
pe-library@0.4.1: {}
@ -2987,7 +3015,7 @@ snapshots:
plist@3.1.0:
dependencies:
'@xmldom/xmldom': 0.8.10
'@xmldom/xmldom': 0.8.12
base64-js: 1.5.1
xmlbuilder: 15.1.1
@ -3068,7 +3096,7 @@ snapshots:
readdir-glob@1.1.3:
dependencies:
minimatch: 10.2.4
minimatch: 10.2.5
require-directory@2.1.1: {}
@ -3105,7 +3133,7 @@ snapshots:
depd: 2.0.0
is-promise: 4.0.0
parseurl: 1.3.3
path-to-regexp: 8.3.0
path-to-regexp: 8.4.1
transitivePeerDependencies:
- supports-color
@ -3119,6 +3147,10 @@ snapshots:
dependencies:
truncate-utf8-bytes: 1.0.2
sanitize-filename@1.6.4:
dependencies:
truncate-utf8-bytes: 1.0.2
sax@1.4.4: {}
sax@1.6.0: {}
@ -3300,6 +3332,14 @@ snapshots:
minizlib: 3.1.0
yallist: 5.0.0
tar@7.5.13:
dependencies:
'@isaacs/fs-minipass': 4.0.1
chownr: 3.0.0
minipass: 7.1.3
minizlib: 3.1.0
yallist: 5.0.0
temp-file@3.4.0:
dependencies:
async-exit-hook: 2.0.1

View File

@ -0,0 +1,8 @@
// desktop/preload-quick-entry.js
const { contextBridge, ipcRenderer } = require('electron')
contextBridge.exposeInMainWorld('quickEntry', {
close: () => ipcRenderer.send('quick-entry:close'),
resize: (width, height) => ipcRenderer.send('quick-entry:resize', width, height),
showMainWindow: () => ipcRenderer.send('quick-entry:show-main-window'),
})

16
desktop/preload.js Normal file
View File

@ -0,0 +1,16 @@
const {contextBridge, ipcRenderer} = require('electron')
contextBridge.exposeInMainWorld('vikunjaDesktop', {
startOAuthLogin: (apiUrl) => ipcRenderer.invoke('oauth:start-login', apiUrl),
onOAuthTokens: (callback) => {
ipcRenderer.removeAllListeners('oauth:tokens')
ipcRenderer.on('oauth:tokens', (_event, tokens) => callback(tokens))
},
onOAuthError: (callback) => {
ipcRenderer.removeAllListeners('oauth:error')
ipcRenderer.on('oauth:error', (_event, error) => callback(error))
},
refreshToken: (apiUrl, refreshToken) => ipcRenderer.invoke('oauth:refresh-token', apiUrl, refreshToken),
updateQuickEntryShortcut: (shortcut) => ipcRenderer.send('desktop:update-quick-entry-shortcut', shortcut),
isDesktop: true,
})

View File

@ -92,7 +92,19 @@ func handleStatus(c *echo.Context) error {
})
}
func NewPlugin() plugins.Plugin { return &ExamplePlugin{} }
// singleton ensures all factory functions return the same instance so that state
// initialized in Init() (e.g. event listeners, DB connections) is available to
// route handlers and other capabilities.
var singleton = &ExamplePlugin{}
func NewPlugin() plugins.Plugin { return singleton }
// Typed factory functions for Yaegi compatibility.
// Yaegi wraps return values per the declared return type, so sub-interface type
// assertions (Plugin -> AuthenticatedRouterPlugin) don't work. These typed
// factories ensure Yaegi wraps the value with the correct interface wrapper.
func NewAuthenticatedRouterPlugin() plugins.AuthenticatedRouterPlugin { return singleton }
func NewUnauthenticatedRouterPlugin() plugins.UnauthenticatedRouterPlugin { return singleton }
type TestListener struct{}

View File

@ -25,7 +25,62 @@ export default [
'indent': ['error', 'tab', { 'SwitchCase': 1 }],
'vue/v-on-event-hyphenation': ['warn', 'never', {'autofix': true}],
'vue/multi-word-component-names': 'off',
'vue/multi-word-component-names': ['error', {
ignores: [
// Existing single-word components grandfathered in.
// New components must use multi-word names per Vue style guide.
'404',
'About',
'Attachments',
'Auth',
'Button.story',
'Caldav',
'Card',
'Card.story',
'Comments',
'Datepicker',
'Description',
'Done',
'Dropdown',
'Error',
'Expandable',
'Filters',
'Flatpickr',
'Heading',
'Home',
'Icon',
'index',
'Label',
'Labels',
'Legal',
'List',
'Loading',
'Login',
'Logo',
'Message',
'Migration',
'Modal',
'Multiselect',
'Navigation',
'Nothing',
'Notification',
'Notifications',
'Pagination',
'Password',
'Popup',
'Reactions',
'Ready',
'Register',
'Reminders',
'Reminders.story',
'Sessions',
'Settings',
'Shortcut',
'Sort',
'Subscription',
'User',
],
}],
// uncategorized rules:
'vue/component-api-style': ['error', ['script-setup']],
@ -57,6 +112,11 @@ export default [
'depend/ban-dependencies': 'warn',
'no-restricted-syntax': ['error', {
selector: 'ForInStatement',
message: 'Use for...of with Object.keys/entries, or .forEach, instead of for...in. See https://github.com/go-vikunja/vikunja/issues/513',
}],
'@typescript-eslint/no-unused-vars': [
'error',
{

View File

@ -2,7 +2,7 @@
"name": "vikunja-frontend",
"description": "The todo app to organize your life.",
"private": true,
"version": "2.2.2",
"version": "2.3.0",
"license": "AGPL-3.0-or-later",
"repository": {
"type": "git",
@ -76,15 +76,14 @@
"@tiptap/vue-3": "3.17.0",
"@vueuse/core": "14.1.0",
"@vueuse/router": "14.1.0",
"axios": "1.13.5",
"axios": "1.15.0",
"blurhash": "2.0.5",
"bulma-css-variables": "0.9.33",
"change-case": "5.4.4",
"dayjs": "1.11.19",
"dompurify": "3.3.2",
"dompurify": "3.4.0",
"fast-deep-equal": "3.1.3",
"flatpickr": "4.6.13",
"flexsearch": "0.8.212",
"floating-vue": "5.2.2",
"is-touch-device": "1.0.1",
"klona": "2.0.6",
@ -110,51 +109,55 @@
"@histoire/plugin-vue": "1.0.0-beta.1",
"@playwright/test": "1.58.2",
"@sentry/vite-plugin": "3.6.1",
"@tailwindcss/vite": "4.2.2",
"@tailwindcss/vite": "4.2.4",
"@tsconfig/node24": "24.0.4",
"@types/codemirror": "5.60.17",
"@types/is-touch-device": "1.0.3",
"@types/node": "24.12.0",
"@types/node": "24.12.2",
"@types/sortablejs": "1.15.9",
"@typescript-eslint/eslint-plugin": "8.57.2",
"@typescript-eslint/parser": "8.57.2",
"@vitejs/plugin-vue": "6.0.5",
"@types/ws": "8.18.1",
"@typescript-eslint/eslint-plugin": "8.59.0",
"@typescript-eslint/parser": "8.59.0",
"@vitejs/plugin-vue": "6.0.6",
"@vue/eslint-config-typescript": "14.7.0",
"@vue/test-utils": "2.4.6",
"@vue/test-utils": "2.4.7",
"@vue/tsconfig": "0.9.1",
"@vueuse/shared": "14.2.1",
"autoprefixer": "10.4.27",
"browserslist": "4.28.1",
"caniuse-lite": "1.0.30001781",
"autoprefixer": "10.5.0",
"browserslist": "4.28.2",
"caniuse-lite": "1.0.30001790",
"csstype": "3.2.3",
"esbuild": "0.27.4",
"esbuild": "0.28.0",
"eslint": "9.39.4",
"eslint-plugin-depend": "1.5.0",
"eslint-plugin-vue": "10.8.0",
"happy-dom": "20.8.8",
"eslint-plugin-vue": "10.9.0",
"happy-dom": "20.9.0",
"histoire": "1.0.0-beta.1",
"postcss": "8.5.8",
"otplib": "12.0.1",
"postcss": "8.5.10",
"postcss-easing-gradients": "3.0.1",
"postcss-preset-env": "11.2.0",
"rollup": "4.60.0",
"postcss-html": "1.8.1",
"postcss-preset-env": "11.2.1",
"rollup": "4.60.2",
"rollup-plugin-visualizer": "6.0.11",
"sass-embedded": "1.98.0",
"stylelint": "17.5.0",
"sass-embedded": "1.99.0",
"stylelint": "17.9.0",
"stylelint-config-property-sort-order-smacss": "10.0.0",
"stylelint-config-recommended-vue": "1.6.1",
"stylelint-config-standard-scss": "17.0.0",
"stylelint-use-logical": "2.1.3",
"tailwindcss": "4.2.2",
"tailwindcss": "4.2.4",
"typescript": "5.9.3",
"unplugin-inject-preload": "3.0.0",
"vite": "7.3.1",
"vite": "7.3.2",
"vite-plugin-pwa": "1.2.0",
"vite-plugin-vue-devtools": "8.1.1",
"vite-svg-loader": "5.1.1",
"vitest": "4.1.1",
"vue-tsc": "3.2.6",
"wait-on": "9.0.4",
"workbox-cli": "7.4.0"
"vitest": "4.1.5",
"vue-tsc": "3.2.7",
"wait-on": "9.0.5",
"workbox-cli": "7.4.0",
"ws": "8.20.0"
},
"pnpm": {
"onlyBuiltDependencies": [
@ -167,8 +170,8 @@
"overrides": {
"minimatch": "^10.2.3",
"rollup": "$rollup",
"basic-ftp": "5.2.0",
"serialize-javascript": "^7.0.3",
"basic-ftp": ">=5.2.2",
"serialize-javascript": "^7.0.5",
"flatted": "^3.4.1"
}
}

File diff suppressed because it is too large Load Diff

View File

@ -1,24 +1,40 @@
<template>
<Ready>
<template v-if="showAuthLayout">
<AppHeader />
<ContentAuth />
<template v-if="isQuickAddMode && authStore.authUser">
<QuickAddOverlay />
</template>
<ContentLinkShare v-else-if="authStore.authLinkShare" />
<NoAuthWrapper
v-else
show-api-config
>
<RouterView />
</NoAuthWrapper>
<KeyboardShortcuts v-if="keyboardShortcutsActive" />
<template v-else-if="isQuickAddMode">
<div class="quick-add-not-logged-in">
<p>{{ $t('quickActions.notLoggedIn') }}</p>
</div>
</template>
<template v-else>
<a
href="#main-content"
class="skip-to-content"
>
{{ $t('misc.skipToContent') }}
</a>
<template v-if="showAuthLayout">
<AppHeader />
<ContentAuth />
</template>
<ContentLinkShare v-else-if="authStore.authLinkShare" />
<NoAuthWrapper
v-else
show-api-config
>
<RouterView />
</NoAuthWrapper>
</template>
<KeyboardShortcuts v-if="keyboardShortcutsActive && !isQuickAddMode" />
<Teleport to="body">
<AddToHomeScreen />
<UpdateNotification />
<AddToHomeScreen v-if="!isQuickAddMode" />
<UpdateNotification v-if="!isQuickAddMode" />
<Notification />
<DemoMode />
<DemoMode v-if="!isQuickAddMode" />
</Teleport>
</Ready>
</template>
@ -46,9 +62,11 @@ import {useBaseStore} from '@/stores/base'
import {useColorScheme} from '@/composables/useColorScheme'
import {useBodyClass} from '@/composables/useBodyClass'
import QuickAddOverlay from '@/components/quick-actions/QuickAddOverlay.vue'
import AddToHomeScreen from '@/components/home/AddToHomeScreen.vue'
import DemoMode from '@/components/home/DemoMode.vue'
import {AUTH_ROUTE_NAMES} from '@/constants/authRouteNames'
import {useQuickAddMode} from '@/composables/useQuickAddMode'
const importAccountDeleteService = () => import('@/services/accountDelete')
import {success} from '@/message'
@ -56,6 +74,14 @@ import {success} from '@/message'
const authStore = useAuthStore()
const baseStore = useBaseStore()
const {isQuickAddMode} = useQuickAddMode()
// Make the Electron frameless window transparent
if (isQuickAddMode) {
document.documentElement.style.background = 'transparent'
document.body.style.background = 'transparent'
}
const route = useRoute()
const showAuthLayout = computed(() => authStore.authUser && typeof route.name === 'string' && !AUTH_ROUTE_NAMES.has(route.name))

View File

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

View File

@ -69,7 +69,7 @@ function createPagination(totalPages: number, currentPage: number): PaginationPa
}
continue
}
pages.push({
number: i + 1,
isEllipsis: false,
@ -82,22 +82,92 @@ const pages = computed(() => createPagination(props.totalPages, props.currentPag
</script>
<style lang="scss" scoped>
.pagination {
padding-block-end: 1rem;
}
// 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-previous,
.pagination-next {
&:not(:disabled):hover {
background: $scheme-main;
cursor: pointer;
}
.pagination {
align-items: center;
display: flex;
font-size: $size-normal;
justify-content: center;
margin: -0.25rem;
padding-block-end: 1rem;
text-align: center;
}
.pagination-list {
align-items: center;
display: flex;
flex-wrap: wrap;
justify-content: center;
text-align: center;
&, & li {
margin-block-start: 0;
}
li {
list-style: none;
}
}
.pagination-ellipsis {
appearance: none;
align-items: center;
border: 1px solid transparent;
border-radius: $radius;
box-shadow: none;
display: inline-flex;
font-size: 1em;
block-size: 2.5em;
justify-content: center;
line-height: 1.5;
margin: 0.25rem;
padding: calc(0.5em - 1px) 0.5em;
position: relative;
text-align: center;
vertical-align: top;
-webkit-touch-callout: none;
user-select: none;
color: var(--grey-light);
pointer-events: none;
}
@media screen and (max-width: $tablet - 1px) {
.pagination {
flex-wrap: wrap;
}
.pagination-list li {
flex-grow: 1;
flex-shrink: 1;
}
}
@media screen and (min-width: $tablet), print {
.pagination-list {
flex-grow: 1;
flex-shrink: 1;
}
.pagination-ellipsis {
margin-block: 0;
}
.pagination {
justify-content: space-between;
margin-block: 0;
&.is-centered {
.pagination-list {
justify-content: center;
order: 2;
}
}
}
}
</style>

View File

@ -86,7 +86,6 @@
<Modal
:enabled="showHowItWorks"
transition-name="fade"
:overflow="true"
variant="hint-modal"
@close="() => showHowItWorks = false"

View File

@ -64,7 +64,6 @@
<Modal
:enabled="showHowItWorks"
transition-name="fade"
:overflow="true"
variant="hint-modal"
@close="() => showHowItWorks = false"

View File

@ -751,6 +751,7 @@ onUnmounted(() => {
<style scoped lang="scss">
.gantt-container {
overflow-x: auto;
min-inline-size: 100%;
}
.gantt-chart-wrapper {

View File

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

View File

@ -7,7 +7,7 @@
<RouterLink
:to="{ name: 'home' }"
class="logo-link"
:aria-label="$t('navigation.overview')"
:aria-label="$t('navigation.home')"
>
<Logo
width="164"
@ -21,9 +21,9 @@
v-if="currentProject?.id"
class="project-title-wrapper"
>
<h1 class="project-title">
<span class="project-title">
{{ currentProject.title === '' ? $t('misc.loading') : getProjectTitle(currentProject) }}
</h1>
</span>
<BaseButton
v-if="!isEditorContentEmpty(currentProject.description)"
@ -87,6 +87,12 @@
<DropdownItem :to="{ name: 'user.settings' }">
{{ $t('user.settings.title') }}
</DropdownItem>
<DropdownItem
v-if="adminPanelEnabled && authStore.info?.isAdmin"
:to="{ name: 'admin.overview' }"
>
{{ $t('admin.title') }}
</DropdownItem>
<DropdownItem
v-if="imprintUrl"
:href="imprintUrl"
@ -150,6 +156,7 @@ const authStore = useAuthStore()
const configStore = useConfigStore()
const imprintUrl = computed(() => configStore.legal.imprintUrl)
const privacyPolicyUrl = computed(() => configStore.legal.privacyPolicyUrl)
const adminPanelEnabled = computed(() => configStore.isProFeatureEnabled('admin_panel'))
</script>
<style lang="scss" scoped>
@ -164,10 +171,12 @@ $user-dropdown-width-mobile: 5rem;
inset-block-start: 0;
inset-inline-start: 0;
inset-inline-end: 0;
z-index: 30;
display: flex;
justify-content: space-between;
gap: var(--navbar-gap-width);
min-block-size: $navbar-height;
background: var(--site-background);
@ -257,8 +266,6 @@ $user-dropdown-width-mobile: 5rem;
}
.navbar-end {
margin-inline-start: 0; // overrides bulma core styles
margin-inline-end: 0; // overrides bulma core styles
flex: 0 0 auto;
display: flex;
align-items: stretch;

View File

@ -2,6 +2,7 @@
<div class="content-auth">
<BaseButton
v-show="menuActive"
:aria-label="$t('navigation.closeSidebar')"
class="menu-hide-button d-print-none"
@click="baseStore.setMenuActive(false)"
>
@ -22,6 +23,7 @@
/>
<Navigation class="d-print-none" />
<main
id="main-content"
class="app-content"
:class="[
{ 'is-menu-enabled': menuActive },
@ -31,6 +33,7 @@
>
<BaseButton
v-show="menuActive"
:aria-label="$t('navigation.closeSidebar')"
class="mobile-overlay d-print-none"
@click="baseStore.setMenuActive(false)"
/>
@ -50,6 +53,7 @@
:enabled="typeof currentModal !== 'undefined'"
variant="scrolling"
class="task-detail-view-modal"
:aria-label="$t('task.detail.title')"
@close="closeModal()"
>
<component
@ -72,8 +76,8 @@
</template>
<script lang="ts" setup>
import {watch, computed} from 'vue'
import {useRoute} from 'vue-router'
import {watch, computed, onBeforeUnmount} from 'vue'
import {useRoute, useRouter} from 'vue-router'
import Navigation from '@/components/home/Navigation.vue'
import QuickActions from '@/components/quick-actions/QuickActions.vue'
@ -86,6 +90,7 @@ import {useProjectStore} from '@/stores/projects'
import {useRouteWithModal} from '@/composables/useRouteWithModal'
import {useRenewTokenOnFocus} from '@/composables/useRenewTokenOnFocus'
import {useSidebarResize} from '@/composables/useSidebarResize'
import {useWebSocket} from '@/composables/useWebSocket'
import {useAuthStore} from '@/stores/auth'
const authStore = useAuthStore()
@ -107,6 +112,7 @@ function showKeyboardShortcuts() {
}
const route = useRoute()
const router = useRouter()
// FIXME: this is really error prone
// Reset the current project highlight in menu if the current route is not project related.
@ -135,11 +141,26 @@ watch(() => route.name as string, (routeName) => {
useRenewTokenOnFocus()
const {connect} = useWebSocket()
connect()
const labelStore = useLabelStore()
labelStore.loadAllLabels()
const projectStore = useProjectStore()
projectStore.loadAllProjects()
// Listen for task creation from the quick-entry window
const taskUpdateChannel = new BroadcastChannel('vikunja-task-updates')
taskUpdateChannel.onmessage = (event) => {
if (event.data?.type === 'task-created-open' && event.data?.taskId) {
router.push({name: 'task.detail', params: {id: event.data.taskId}})
}
}
onBeforeUnmount(() => {
taskUpdateChannel.close()
})
</script>
<style lang="scss" scoped>

View File

@ -44,10 +44,10 @@
>
{{ $t('misc.loading') }}
</h1>
<div class="box has-text-start view">
<Card class="has-text-start view">
<RouterView />
<PoweredByLink utm-medium="link_share" />
</div>
</Card>
</div>
</div>
</template>
@ -64,6 +64,7 @@ import {useAuthStore} from '@/stores/auth'
import Logo from '@/components/home/Logo.vue'
import PoweredByLink from './PoweredByLink.vue'
import BaseButton from '@/components/base/BaseButton.vue'
import Card from '@/components/misc/Card.vue'
import Message from '@/components/misc/Message.vue'
import {PROJECT_VIEW_KINDS} from '@/modelTypes/IProjectView'

View File

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

View File

@ -8,7 +8,7 @@
<RouterLink
:to="{name: 'home'}"
class="logo"
:aria-label="$t('navigation.overview')"
:aria-label="$t('navigation.home')"
>
<Logo
width="164"

View File

@ -18,15 +18,6 @@
:class="{ 'project-is-collapsed': !childProjectsOpen }"
/>
</BaseButton>
<span
v-if="canEditOrder && project.id > 0 && project.maxPermission !== null && project.maxPermission > PERMISSIONS.READ"
class="icon menu-item-icon handle drag-handle-standalone"
@mousedown.stop
@click.stop.prevent
@touchstart.stop
>
<Icon icon="grip-lines" />
</span>
<BaseButton
:to="{ name: 'project.index', params: { projectId: project.id} }"
class="list-menu-link"
@ -48,6 +39,15 @@
>
<Icon icon="filter" />
</span>
<span
v-if="canEditOrder && project.id > 0 && project.maxPermission !== null && project.maxPermission > PERMISSIONS.READ"
class="icon menu-item-icon handle drag-handle"
@mousedown.stop
@click.stop.prevent
@touchstart.stop
>
<Icon icon="grip-lines" />
</span>
</div>
<span class="project-menu-title">{{ getProjectTitle(project) }}</span>
</BaseButton>
@ -221,7 +221,7 @@ const canToggleFavorite = computed(() => {
opacity: 1;
}
.list-menu:hover > div > .drag-handle-standalone {
.list-menu:hover .color-bubble-wrapper > .drag-handle {
opacity: 1;
}
@ -252,16 +252,15 @@ const canToggleFavorite = computed(() => {
}
}
.drag-handle-standalone {
inline-size: 1rem;
block-size: 1rem;
.drag-handle {
opacity: 0;
cursor: grab;
transition: opacity $transition;
z-index: 2;
position: absolute;
inset-inline-start: 2.15rem;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
&:active {
cursor: grabbing;
@ -279,7 +278,7 @@ const canToggleFavorite = computed(() => {
}
@media (pointer: coarse) {
.drag-handle-standalone {
.drag-handle {
display: none !important;
}
}

View File

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

View File

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

View File

@ -0,0 +1,42 @@
import {describe, it, expect} from 'vitest'
import {mount} from '@vue/test-utils'
import FormCheckbox from './FormCheckbox.vue'
describe('FormCheckbox', () => {
it('renders a Bulma-classed checkbox label', () => {
const wrapper = mount(FormCheckbox, {props: {label: 'Enable thing'}})
const label = wrapper.find('label.checkbox')
expect(label.exists()).toBe(true)
expect(label.text()).toContain('Enable thing')
expect(label.find('input[type="checkbox"]').exists()).toBe(true)
})
it('supports v-model (boolean)', async () => {
const wrapper = mount(FormCheckbox, {
props: {
label: 'Toggle',
modelValue: false,
'onUpdate:modelValue': (val: boolean) => wrapper.setProps({modelValue: val}),
},
})
const input = wrapper.find('input[type="checkbox"]')
expect((input.element as HTMLInputElement).checked).toBe(false)
await input.setValue(true)
expect(wrapper.props('modelValue')).toBe(true)
})
it('applies disabled', () => {
const wrapper = mount(FormCheckbox, {
props: {label: 'X', disabled: true},
})
expect(wrapper.find('input').attributes('disabled')).toBe('')
})
it('renders slot content instead of label prop when slot is provided', () => {
const wrapper = mount(FormCheckbox, {
slots: {default: '<span>Custom <b>content</b></span>'},
})
expect(wrapper.find('label.checkbox').html()).toContain('<b>content</b>')
})
})

View File

@ -0,0 +1,62 @@
<script setup lang="ts">
interface Props {
modelValue?: boolean
label?: string
disabled?: boolean
}
defineProps<Props>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
}>()
function handleChange(event: Event) {
emit('update:modelValue', (event.target as HTMLInputElement).checked)
}
</script>
<template>
<label class="checkbox">
<input
type="checkbox"
:checked="modelValue"
:disabled="disabled || undefined"
@change="handleChange"
>
<slot>{{ label }}</slot>
</label>
</template>
<style lang="scss" scoped>
// Ported from bulma-css-variables/sass/form/checkbox-radio.sass
// (the %checkbox-radio placeholder, scoped to .checkbox since this
// component is the sole consumer of that class).
label.checkbox {
cursor: pointer;
line-height: 1.25;
position: relative;
display: flex;
align-items: center;
gap: .5rem;
inline-size: fit-content;
&:hover {
color: var(--input-hover-color);
}
&[disabled],
input[disabled] {
color: var(--input-disabled-color);
cursor: not-allowed;
}
input {
cursor: pointer;
}
&:not(:last-child) {
margin-block-end: .75rem;
}
}
</style>

View File

@ -14,7 +14,7 @@ describe('FormField', () => {
const wrapper = mount(FormField, {
props: {
modelValue: 'initial',
'onUpdate:modelValue': (val: string) => wrapper.setProps({modelValue: val}),
'onUpdate:modelValue': (val: string | number) => wrapper.setProps({modelValue: val}),
},
})
const input = wrapper.find('input')
@ -199,4 +199,62 @@ describe('FormField', () => {
await input.setValue('test value')
expect(wrapper.vm.value).toBe('test value')
})
it('renders two-col layout with wrapping label', () => {
const wrapper = mount(FormField, {
props: {label: 'Name', layout: 'two-col'},
slots: {
default: '<input class="input" />',
},
})
const label = wrapper.find('label.two-col')
expect(label.exists()).toBe(true)
expect(label.find('span').text()).toBe('Name')
expect(label.find('input.input').exists()).toBe(true)
})
it('two-col layout exposes id via slot scope', () => {
const wrapper = mount({
components: {FormField},
template: `
<FormField label="X" layout="two-col" id="custom-id" v-slot="{id}">
<input :id="id" />
</FormField>
`,
})
expect(wrapper.find('input').attributes('id')).toBe('custom-id')
})
it('two-col layout omits the for attribute so implicit nesting labels any slotted control', () => {
const wrapper = mount(FormField, {
props: {label: 'Name', layout: 'two-col'},
slots: {
default: '<input id="some-generated-id" />',
},
})
const label = wrapper.find('label.two-col')
// for="" would mismatch the slotted control's id; rely on the label wrapping instead.
expect(label.attributes('for')).toBeUndefined()
expect(label.find('input').exists()).toBe(true)
})
it('renders the error message in two-col layout', () => {
const wrapper = mount(FormField, {
props: {label: 'Name', layout: 'two-col', error: 'Required'},
})
const help = wrapper.find('p.help.is-danger')
expect(help.exists()).toBe(true)
expect(help.text()).toBe('Required')
})
it('renders the addon slot in two-col layout', () => {
const wrapper = mount(FormField, {
props: {label: 'Name', layout: 'two-col'},
slots: {
addon: '<button>Copy</button>',
},
})
expect(wrapper.find('.field.has-addons').exists()).toBe(true)
expect(wrapper.find('button').text()).toBe('Copy')
})
})

View File

@ -8,16 +8,18 @@ interface Props {
id?: string
disabled?: boolean
loading?: boolean
layout?: 'stacked' | 'two-col'
}
const props = defineProps<Props>()
const props = withDefaults(defineProps<Props>(), {
layout: 'stacked',
})
const emit = defineEmits<{
'update:modelValue': [value: string | number]
}>()
function handleInput(event: Event) {
const value = (event.target as HTMLInputElement).value
// Preserve numeric type if modelValue was a number
if (typeof props.modelValue === 'number') {
emit('update:modelValue', value === '' ? '' : Number(value))
} else {
@ -33,6 +35,7 @@ const slots = useSlots()
const generatedId = useId()
const inputId = computed(() => props.id ?? generatedId)
const errorId = computed(() => props.error ? `${inputId.value}-error` : undefined)
const hasAddon = computed(() => !!slots.addon)
const fieldClasses = computed(() => [
@ -53,8 +56,6 @@ const inputClasses = computed(() => [
},
])
// Only bind value when modelValue is explicitly provided (not undefined)
// This allows the component to be used without v-model for native input behavior
const inputBindings = computed(() => {
const bindings: Record<string, unknown> = {}
if (props.modelValue !== undefined) {
@ -63,7 +64,6 @@ const inputBindings = computed(() => {
return bindings
})
// Expose input element for direct access (needed for browser autofill workarounds)
const inputRef = ref<HTMLInputElement | null>(null)
defineExpose({
get value() {
@ -77,39 +77,92 @@ defineExpose({
<template>
<div :class="fieldClasses">
<label
v-if="label"
:for="inputId"
class="label"
>
{{ label }}
</label>
<div :class="controlClasses">
<slot :id="inputId">
<input
<template v-if="layout === 'two-col'">
<label
v-if="label"
class="two-col"
>
<span>{{ label }}</span>
<slot
:id="inputId"
ref="inputRef"
v-bind="{ ...$attrs, ...inputBindings }"
:class="inputClasses"
:disabled="disabled || undefined"
@input="handleInput"
:error-id="errorId"
>
</slot>
</div>
<div
v-if="$slots.addon"
class="control"
>
<slot name="addon" />
</div>
<input
:id="inputId"
ref="inputRef"
v-bind="{ ...$attrs, ...inputBindings }"
:class="inputClasses"
:disabled="disabled || undefined"
:aria-invalid="error ? true : undefined"
:aria-describedby="errorId"
@input="handleInput"
>
</slot>
</label>
<div
v-if="$slots.addon"
class="control"
>
<slot name="addon" />
</div>
</template>
<template v-else>
<label
v-if="label"
:for="inputId"
class="label"
>
{{ label }}
</label>
<div :class="controlClasses">
<slot
:id="inputId"
:error-id="errorId"
>
<input
:id="inputId"
ref="inputRef"
v-bind="{ ...$attrs, ...inputBindings }"
:class="inputClasses"
:disabled="disabled || undefined"
:aria-invalid="error ? true : undefined"
:aria-describedby="errorId"
@input="handleInput"
>
</slot>
</div>
<div
v-if="$slots.addon"
class="control"
>
<slot name="addon" />
</div>
</template>
<p
v-if="error"
:id="errorId"
class="help is-danger"
role="alert"
>
{{ error }}
</p>
</div>
</template>
<style lang="scss" scoped>
label.two-col {
display: flex;
align-items: center;
gap: .5rem;
}
label.two-col > span,
label.two-col :deep(input),
label.two-col :deep(.input),
label.two-col :deep(.select),
label.two-col :deep(.timezone-select),
label.two-col :deep(.multiselect) {
flex: 0 0 50%;
box-sizing: border-box;
}
</style>

View File

@ -0,0 +1,123 @@
import {describe, it, expect, vi} from 'vitest'
import {mount} from '@vue/test-utils'
import FormInput from './FormInput.vue'
describe('FormInput', () => {
it('renders a Bulma-classed input', () => {
const wrapper = mount(FormInput)
const input = wrapper.find('input')
expect(input.exists()).toBe(true)
expect(input.classes()).toContain('input')
})
it('supports v-model', async () => {
const wrapper = mount(FormInput, {
props: {
modelValue: 'hello',
'onUpdate:modelValue': (val: string | number) => wrapper.setProps({modelValue: val}),
},
})
const input = wrapper.find('input')
expect(input.element.value).toBe('hello')
await input.setValue('world')
expect(wrapper.props('modelValue')).toBe('world')
})
it('preserves numeric type in v-model when modelValue is a number', async () => {
const wrapper = mount(FormInput, {
props: {
modelValue: 42,
'onUpdate:modelValue': (val: number | string) => wrapper.setProps({modelValue: val as number}),
},
})
await wrapper.find('input').setValue('7')
expect(wrapper.props('modelValue')).toBe(7)
})
it('coerces to number when the .number modifier is set even if modelValue starts null', async () => {
const wrapper = mount(FormInput, {
props: {
modelValue: null,
modelModifiers: {number: true},
'onUpdate:modelValue': (val: number | string) => wrapper.setProps({modelValue: val as number}),
},
})
await wrapper.find('input').setValue('42')
expect(wrapper.props('modelValue')).toBe(42)
expect(typeof wrapper.props('modelValue')).toBe('number')
})
it('applies is-loading class when loading', () => {
const wrapper = mount(FormInput, {props: {loading: true}})
expect(wrapper.find('input').classes()).toContain('is-loading')
})
it('applies disabled class and attribute when disabled', () => {
const wrapper = mount(FormInput, {props: {disabled: true}})
const input = wrapper.find('input')
expect(input.classes()).toContain('disabled')
expect(input.attributes('disabled')).toBe('')
})
it('uses an explicit id prop when given', () => {
const wrapper = mount(FormInput, {props: {id: 'my-id'}})
expect(wrapper.find('input').attributes('id')).toBe('my-id')
})
it('generates a unique id when no id prop is given', () => {
const wrapper = mount({
components: {FormInput},
template: '<div><FormInput /><FormInput /></div>',
})
const inputs = wrapper.findAll('input')
const id1 = inputs[0].attributes('id')
const id2 = inputs[1].attributes('id')
expect(id1).toBeTruthy()
expect(id2).toBeTruthy()
expect(id1).not.toBe(id2)
})
it('forwards $attrs (type, placeholder, autocomplete) to the input', () => {
const wrapper = mount(FormInput, {
attrs: {
type: 'email',
placeholder: 'Enter email',
autocomplete: 'email',
},
})
const input = wrapper.find('input')
expect(input.attributes('type')).toBe('email')
expect(input.attributes('placeholder')).toBe('Enter email')
expect(input.attributes('autocomplete')).toBe('email')
})
it('forwards event listeners', async () => {
const onKeyup = vi.fn()
const wrapper = mount(FormInput, {attrs: {onKeyup}})
await wrapper.find('input').trigger('keyup', {key: 'Enter'})
expect(onKeyup).toHaveBeenCalledTimes(1)
})
it('renders error message when error prop is set', () => {
const wrapper = mount(FormInput, {props: {error: 'Required'}})
const help = wrapper.find('p.help.is-danger')
expect(help.exists()).toBe(true)
expect(help.text()).toBe('Required')
})
it('does not render error message when error is null or empty', () => {
const nullErr = mount(FormInput, {props: {error: null}})
expect(nullErr.find('p.help.is-danger').exists()).toBe(false)
const emptyErr = mount(FormInput, {props: {error: ''}})
expect(emptyErr.find('p.help.is-danger').exists()).toBe(false)
})
it('exposes value and focus()', async () => {
const wrapper = mount(FormInput)
await wrapper.find('input').setValue('test value')
expect(wrapper.vm.value).toBe('test value')
expect(() => wrapper.vm.focus()).not.toThrow()
})
})

View File

@ -0,0 +1,83 @@
<script setup lang="ts">
import {computed, ref, useId} from 'vue'
interface Props {
modelValue?: string | number | Date | null
modelModifiers?: {number?: boolean}
id?: string
disabled?: boolean
loading?: boolean
error?: string | null
}
const props = withDefaults(defineProps<Props>(), {
modelModifiers: () => ({}),
})
const emit = defineEmits<{
'update:modelValue': [value: string | number]
}>()
defineOptions({inheritAttrs: false})
const fallbackId = useId()
const inputId = computed(() => props.id ?? fallbackId)
const errorId = computed(() => props.error ? `${inputId.value}-error` : undefined)
const inputClasses = computed(() => [
'input',
{
disabled: props.disabled,
'is-loading': props.loading,
},
])
const inputBindings = computed(() => {
const bindings: Record<string, unknown> = {}
if (props.modelValue !== undefined) {
bindings.value = props.modelValue
}
return bindings
})
function handleInput(event: Event) {
const value = (event.target as HTMLInputElement).value
const shouldCoerceNumber = props.modelModifiers.number || typeof props.modelValue === 'number'
if (shouldCoerceNumber) {
emit('update:modelValue', value === '' ? '' : Number(value))
} else {
emit('update:modelValue', value)
}
}
const inputRef = ref<HTMLInputElement | null>(null)
defineExpose({
get value() {
return inputRef.value?.value ?? ''
},
focus() {
inputRef.value?.focus()
},
})
</script>
<template>
<input
:id="inputId"
ref="inputRef"
v-bind="{ ...$attrs, ...inputBindings }"
:class="inputClasses"
:disabled="disabled || undefined"
:aria-invalid="error ? true : undefined"
:aria-describedby="errorId"
@input="handleInput"
>
<p
v-if="error"
:id="errorId"
class="help is-danger"
role="alert"
>
{{ error }}
</p>
</template>

View File

@ -0,0 +1,173 @@
import {describe, it, expect} from 'vitest'
import {mount} from '@vue/test-utils'
import FormSelect from './FormSelect.vue'
describe('FormSelect', () => {
it('renders the Bulma select wrapper and a native select', () => {
const wrapper = mount(FormSelect)
expect(wrapper.find('div.select').exists()).toBe(true)
expect(wrapper.find('div.select > select').exists()).toBe(true)
})
it('renders options from the default slot', () => {
const wrapper = mount(FormSelect, {
slots: {
default: '<option value="a">A</option><option value="b">B</option>',
},
})
expect(wrapper.findAll('option').length).toBe(2)
})
it('supports v-model with string values', async () => {
const wrapper = mount(FormSelect, {
props: {
modelValue: 'a',
'onUpdate:modelValue': (val: string | number) => wrapper.setProps({modelValue: val}),
},
slots: {
default: '<option value="a">A</option><option value="b">B</option>',
},
})
const select = wrapper.find('select')
expect((select.element as HTMLSelectElement).value).toBe('a')
await select.setValue('b')
expect(wrapper.props('modelValue')).toBe('b')
})
it('preserves numeric type in v-model when modelValue is a number', async () => {
const wrapper = mount(FormSelect, {
props: {
modelValue: 1,
'onUpdate:modelValue': (val: string | number) => wrapper.setProps({modelValue: val}),
},
slots: {
default: '<option value="1">One</option><option value="2">Two</option>',
},
})
await wrapper.find('select').setValue('2')
expect(wrapper.props('modelValue')).toBe(2)
})
it('coerces to number when the .number modifier is set even if modelValue starts null', async () => {
const wrapper = mount(FormSelect, {
props: {
modelValue: null,
modelModifiers: {number: true},
'onUpdate:modelValue': (val: string | number) => wrapper.setProps({modelValue: val}),
},
slots: {
default: '<option value="1">One</option><option value="2">Two</option>',
},
})
await wrapper.find('select').setValue('2')
expect(wrapper.props('modelValue')).toBe(2)
expect(typeof wrapper.props('modelValue')).toBe('number')
})
it('applies is-loading on the wrapper when loading', () => {
const wrapper = mount(FormSelect, {props: {loading: true}})
expect(wrapper.find('div.select').classes()).toContain('is-loading')
})
it('applies disabled to the native select', () => {
const wrapper = mount(FormSelect, {props: {disabled: true}})
expect(wrapper.find('select').attributes('disabled')).toBe('')
})
it('uses an explicit id prop when given, otherwise generates one', () => {
const withProp = mount(FormSelect, {props: {id: 'explicit'}})
expect(withProp.find('select').attributes('id')).toBe('explicit')
const standalone = mount(FormSelect)
expect(standalone.find('select').attributes('id')).toBeTruthy()
})
it('renders error message when error prop is set', () => {
const wrapper = mount(FormSelect, {props: {error: 'Pick one'}})
expect(wrapper.find('p.help.is-danger').text()).toBe('Pick one')
})
it('does not render error message when error is null or empty', () => {
const nullErr = mount(FormSelect, {props: {error: null}})
expect(nullErr.find('p.help.is-danger').exists()).toBe(false)
const emptyErr = mount(FormSelect, {props: {error: ''}})
expect(emptyErr.find('p.help.is-danger').exists()).toBe(false)
})
it('renders options from the options prop with object entries', () => {
const wrapper = mount(FormSelect, {
props: {
options: [
{value: 'a', label: 'Alpha'},
{value: 'b', label: 'Bravo'},
],
},
})
const options = wrapper.findAll('option')
expect(options).toHaveLength(2)
expect(options[0].attributes('value')).toBe('a')
expect(options[0].text()).toBe('Alpha')
expect(options[1].attributes('value')).toBe('b')
expect(options[1].text()).toBe('Bravo')
})
it('coerces primitive options into value/label pairs', () => {
const wrapper = mount(FormSelect, {
props: {options: ['one', 'two']},
})
const options = wrapper.findAll('option')
expect(options).toHaveLength(2)
expect(options[0].attributes('value')).toBe('one')
expect(options[0].text()).toBe('one')
})
it('marks an option as disabled when disabled: true is given', () => {
const wrapper = mount(FormSelect, {
props: {
options: [
{value: 'a', label: 'Alpha'},
{value: 'b', label: 'Bravo', disabled: true},
],
},
})
const options = wrapper.findAll('option')
expect(options[0].attributes('disabled')).toBeUndefined()
expect(options[1].attributes('disabled')).toBe('')
})
it('falls back to the default slot when options prop is not given', () => {
const wrapper = mount(FormSelect, {
slots: {
default: '<option value="x">From slot</option>',
},
})
const options = wrapper.findAll('option')
expect(options).toHaveLength(1)
expect(options[0].text()).toBe('From slot')
})
it('does not bind value when modelValue is undefined', () => {
const wrapper = mount(FormSelect, {
slots: {
default: '<option value="">--</option><option value="a">A</option><option value="b">B</option>',
},
})
const select = wrapper.find('select')
// Forcing :value="undefined" would break the native default-to-first-option behavior.
expect((select.element as HTMLSelectElement).value).toBe('')
})
it('ignores the slot when options prop is given', () => {
const wrapper = mount(FormSelect, {
props: {options: [{value: 'a', label: 'From prop'}]},
slots: {
default: '<option value="x">From slot</option>',
},
})
const options = wrapper.findAll('option')
expect(options).toHaveLength(1)
expect(options[0].text()).toBe('From prop')
})
})

View File

@ -0,0 +1,105 @@
<script setup lang="ts">
import {computed, useId} from 'vue'
export type SelectOption =
| string
| number
| {value: string | number, label: string, disabled?: boolean}
interface Props {
modelValue?: string | number | null
modelModifiers?: {number?: boolean}
id?: string
disabled?: boolean
loading?: boolean
error?: string | null
options?: SelectOption[]
}
const props = withDefaults(defineProps<Props>(), {
modelModifiers: () => ({}),
})
const emit = defineEmits<{
'update:modelValue': [value: string | number]
}>()
defineOptions({inheritAttrs: false})
const fallbackId = useId()
const selectId = computed(() => props.id ?? fallbackId)
const errorId = computed(() => props.error ? `${selectId.value}-error` : undefined)
const wrapperClasses = computed(() => [
'select',
{'is-loading': props.loading},
])
const selectBindings = computed(() => {
const bindings: Record<string, unknown> = {}
if (props.modelValue !== undefined) {
bindings.value = props.modelValue
}
return bindings
})
const normalizedOptions = computed(() => {
if (!props.options) {
return null
}
return props.options.map(opt => {
if (typeof opt === 'object' && opt !== null) {
return opt
}
return {value: opt, label: String(opt)}
})
})
function handleChange(event: Event) {
const value = (event.target as HTMLSelectElement).value
const shouldCoerceNumber = props.modelModifiers.number || typeof props.modelValue === 'number'
if (shouldCoerceNumber) {
emit('update:modelValue', value === '' ? '' : Number(value))
} else {
emit('update:modelValue', value)
}
}
</script>
<template>
<div :class="wrapperClasses">
<select
:id="selectId"
v-bind="{ ...$attrs, ...selectBindings }"
:disabled="disabled || undefined"
:aria-invalid="error ? true : undefined"
:aria-describedby="errorId"
@change="handleChange"
>
<template v-if="normalizedOptions">
<option
v-for="opt in normalizedOptions"
:key="opt.value"
:value="opt.value"
:disabled="opt.disabled || undefined"
>
{{ opt.label }}
</option>
</template>
<slot v-else />
</select>
</div>
<p
v-if="error"
:id="errorId"
class="help is-danger"
role="alert"
>
{{ error }}
</p>
</template>
<style lang="scss" scoped>
.select select {
inline-size: 100%;
}
</style>

View File

@ -448,7 +448,7 @@ function createOrSelectOnEnter() {
}
function remove(item: T) {
for (const ind in internalValue.value) {
for (let ind = 0; ind < internalValue.value.length; ind++) {
if (internalValue.value[ind] === item) {
internalValue.value.splice(ind, 1)
break

View File

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

View File

@ -170,6 +170,7 @@ import HardBreak from '@tiptap/extension-hard-break'
import Commands from './commands'
import suggestionSetup from './suggestion'
import {EmojiExtension} from './emoji/emojiExtension'
import mentionSuggestionSetup from './mention/mentionSuggestion'
import MentionUser from './mention/MentionUser.vue'
@ -515,6 +516,8 @@ const extensions : Extensions = [
suggestion: suggestionSetup(t),
}),
EmojiExtension,
PasteHandler,
]

View File

@ -0,0 +1,156 @@
<template>
<div class="emoji-items">
<template v-if="items.length">
<button
v-for="(item, index) in items"
:key="item.shortcode"
:ref="el => setItemRef(el, index)"
type="button"
class="emoji-item"
:class="{ 'is-selected': index === selectedIndex }"
@click="selectItem(index)"
>
<span class="emoji-glyph">{{ item.emoji }}</span>
<div class="emoji-info">
<p class="emoji-shortcode">
:{{ item.shortcode }}:
</p>
<p class="emoji-annotation">
{{ item.annotation }}
</p>
</div>
</button>
</template>
<div
v-else
class="emoji-item no-results"
>
{{ $t('input.editor.emoji.empty') }}
</div>
</div>
</template>
<script lang="ts" setup>
import {ref, watch, nextTick} from 'vue'
import type {EmojiEntry} from './emojiData'
const props = defineProps<{
items: EmojiEntry[]
command: (item: EmojiEntry) => void
}>()
const selectedIndex = ref(0)
const itemEls = ref<HTMLElement[]>([])
function setItemRef(el: Element | null, index: number) {
if (el instanceof HTMLElement) {
itemEls.value[index] = el
}
}
watch(() => props.items, () => {
selectedIndex.value = 0
itemEls.value = []
})
watch(selectedIndex, async idx => {
await nextTick()
itemEls.value[idx]?.scrollIntoView({block: 'nearest'})
})
function selectItem(index: number) {
const item = props.items[index]
if (item) props.command(item)
}
function onKeyDown({event}: {event: KeyboardEvent}): boolean {
if (props.items.length === 0) return false
if (event.key === 'ArrowUp') {
selectedIndex.value = ((selectedIndex.value + props.items.length) - 1) % props.items.length
return true
}
if (event.key === 'ArrowDown') {
selectedIndex.value = (selectedIndex.value + 1) % props.items.length
return true
}
if (event.key === 'Enter' || event.key === 'Tab') {
if (event.isComposing) return false
selectItem(selectedIndex.value)
return true
}
return false
}
defineExpose({onKeyDown})
</script>
<style lang="scss" scoped>
.emoji-items {
padding: 0.2rem;
position: relative;
border-radius: 0.5rem;
background: var(--white);
color: var(--grey-900);
overflow: hidden;
font-size: 0.9rem;
box-shadow: var(--shadow-md);
min-inline-size: 240px;
max-block-size: 300px;
overflow-y: auto;
}
.emoji-item {
display: flex;
align-items: center;
margin: 0;
inline-size: 100%;
text-align: start;
background: transparent;
border-radius: $radius;
border: 0;
padding: 0.4rem 0.6rem;
transition: background-color $transition;
&.is-selected, &:hover {
background: var(--grey-100);
cursor: pointer;
}
&.no-results {
color: var(--grey-500);
cursor: default;
}
}
.emoji-glyph {
font-size: 1.4rem;
margin-inline-end: 0.75rem;
flex-shrink: 0;
}
.emoji-info {
display: flex;
flex-direction: column;
min-inline-size: 0;
flex: 1;
p {
margin: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.emoji-shortcode {
font-family: monospace;
font-weight: 500;
color: var(--grey-800);
}
.emoji-annotation {
font-size: 0.75rem;
color: var(--grey-500);
}
</style>

View File

@ -0,0 +1,58 @@
import {describe, it, expect, vi, beforeEach, afterEach} from 'vitest'
import {filterEmojis, __resetEmojiCacheForTest, loadEmojis} from './emojiData'
const fixture = [
{shortcodes: ['grinning', 'grinning_face'], annotation: 'grinning face', tags: ['face', 'grin'], emoji: '😀'},
{shortcodes: ['eyes'], annotation: 'eyes', tags: ['look'], emoji: '👀'},
{shortcodes: ['eyeglasses'], annotation: 'glasses', tags: ['eye'], emoji: '👓'},
{shortcodes: ['smile'], annotation: 'grinning face with smiling eyes', tags: ['eye', 'smile'], emoji: '😄'},
]
describe('emojiData', () => {
beforeEach(() => {
__resetEmojiCacheForTest()
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
json: async () => fixture,
}))
})
afterEach(() => {
vi.unstubAllGlobals()
})
it('flattens multi-shortcode entries and sorts alphabetically', async () => {
const idx = await loadEmojis()
const codes = idx.map(e => e.shortcode)
expect(codes).toEqual(['eyeglasses', 'eyes', 'grinning', 'grinning_face', 'smile'])
})
it('returns [] for empty query', () => {
expect(filterEmojis([{shortcode: 'eyes', emoji: '👀', annotation: '', tags: []}], '')).toEqual([])
})
it('prefers startsWith matches over substring matches', () => {
const loaded = [
{shortcode: 'eyeglasses', emoji: '👓', annotation: 'glasses', tags: ['eye']},
{shortcode: 'eyes', emoji: '👀', annotation: 'eyes', tags: []},
{shortcode: 'smile', emoji: '😄', annotation: 'grinning face with smiling eyes', tags: ['eye']},
]
const result = filterEmojis(loaded, 'eye')
expect(result[0].shortcode).toBe('eyeglasses')
expect(result[1].shortcode).toBe('eyes')
expect(result[2].shortcode).toBe('smile')
})
it('limits results to 15', () => {
const big = Array.from({length: 100}, (_, i) => ({
shortcode: `foo_${String(i).padStart(3, '0')}`, emoji: '✨', annotation: '', tags: [],
}))
expect(filterEmojis(big, 'foo')).toHaveLength(15)
})
it('caches the fetch promise across calls', async () => {
await loadEmojis()
await loadEmojis()
expect((globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls).toHaveLength(1)
})
})

View File

@ -0,0 +1,75 @@
export interface EmojiEntry {
emoji: string
shortcode: string
annotation: string
tags: string[]
}
interface RawEmoji {
shortcodes: string[]
annotation: string
tags?: string[]
emoji: string
}
const MAX_RESULTS = 15
let cache: Promise<EmojiEntry[]> | null = null
export function __resetEmojiCacheForTest() {
cache = null
}
export function loadEmojis(): Promise<EmojiEntry[]> {
if (cache) return cache
cache = fetch('/emojis.json')
.then(res => {
if (!res.ok) throw new Error(`emojis.json HTTP ${res.status}`)
return res.json() as Promise<RawEmoji[]>
})
.then(raw => {
const flat: EmojiEntry[] = []
for (const entry of raw) {
for (const shortcode of entry.shortcodes) {
flat.push({
emoji: entry.emoji,
shortcode,
annotation: entry.annotation,
tags: entry.tags ?? [],
})
}
}
flat.sort((a, b) => a.shortcode.localeCompare(b.shortcode))
return flat
})
.catch(err => {
cache = null
throw err
})
return cache
}
export function filterEmojis(index: EmojiEntry[], rawQuery: string): EmojiEntry[] {
const query = rawQuery.toLowerCase()
if (query === '') return []
const starts: EmojiEntry[] = []
const contains: EmojiEntry[] = []
for (const entry of index) {
if (entry.shortcode.startsWith(query)) {
starts.push(entry)
continue
}
if (
entry.shortcode.includes(query) ||
entry.annotation.toLowerCase().includes(query) ||
entry.tags.some(t => t.toLowerCase().includes(query))
) {
contains.push(entry)
}
if (starts.length >= MAX_RESULTS) break
}
return [...starts, ...contains].slice(0, MAX_RESULTS)
}

View File

@ -0,0 +1,23 @@
import {Extension} from '@tiptap/core'
import Suggestion from '@tiptap/suggestion'
import emojiSuggestionSetup from './emojiSuggestion'
export const EmojiExtension = Extension.create({
name: 'emojiAutocomplete',
addOptions() {
return {
suggestion: emojiSuggestionSetup(),
}
},
addProseMirrorPlugins() {
return [
Suggestion({
editor: this.editor,
...this.options.suggestion,
}),
]
},
})

View File

@ -0,0 +1,147 @@
import {VueRenderer} from '@tiptap/vue-3'
import {computePosition, flip, shift, offset, autoUpdate} from '@floating-ui/dom'
import type {Editor, Range} from '@tiptap/core'
import {PluginKey, type EditorState} from '@tiptap/pm/state'
import EmojiList from './EmojiList.vue'
import {loadEmojis, filterEmojis, type EmojiEntry} from './emojiData'
export const EmojiSuggestionPluginKey = new PluginKey('emojiSuggestion')
interface SuggestionProps {
editor: Editor
range: Range
query: string
clientRect?: (() => DOMRect | null) | null
items: EmojiEntry[]
command: (item: EmojiEntry) => void
event?: KeyboardEvent
}
const SHORTCODE_RE = /^[a-zA-Z0-9_]*$/
export default function emojiSuggestionSetup() {
return {
pluginKey: EmojiSuggestionPluginKey,
char: ':',
allowedPrefixes: [' ', '\t', '\n'],
startOfLine: false,
allow: ({state, range}: {state: EditorState, range: Range}) => {
const text = state.doc.textBetween(range.from, range.to, '\n', '\n')
// Drop the leading ':' trigger character.
const query = text.startsWith(':') ? text.slice(1) : text
return SHORTCODE_RE.test(query)
},
items: async ({query}: {query: string}): Promise<EmojiEntry[]> => {
if (query === '') return []
try {
const index = await loadEmojis()
return filterEmojis(index, query)
} catch (err) {
console.error('Failed to load emoji index:', err)
return []
}
},
command: ({editor, range, props}: {editor: Editor, range: Range, props: EmojiEntry}) => {
editor
.chain()
.focus()
.deleteRange(range)
.insertContent(props.emoji)
.run()
},
render: () => {
let component: VueRenderer
let popupElement: HTMLElement | null = null
let cleanupFloating: (() => void) | null = null
const virtualReference = {
getBoundingClientRect: () => ({
width: 0, height: 0, x: 0, y: 0, top: 0, left: 0, right: 0, bottom: 0,
} as DOMRect),
}
const mount = (props: SuggestionProps) => {
component = new VueRenderer(EmojiList, {
props,
editor: props.editor,
})
if (!props.clientRect) return
popupElement = document.createElement('div')
popupElement.style.position = 'absolute'
popupElement.style.top = '0'
popupElement.style.left = '0'
popupElement.style.zIndex = '4700'
popupElement.appendChild(component.element!)
document.body.appendChild(popupElement)
const rect = props.clientRect()
if (!rect) {
unmount()
return
}
virtualReference.getBoundingClientRect = () => rect
const updatePosition = () => {
computePosition(virtualReference, popupElement!, {
placement: 'bottom-start',
middleware: [offset(8), flip(), shift({padding: 8})],
}).then(({x, y}) => {
if (popupElement) {
popupElement.style.left = `${x}px`
popupElement.style.top = `${y}px`
}
})
}
updatePosition()
cleanupFloating = autoUpdate(virtualReference, popupElement, updatePosition)
}
const unmount = () => {
if (cleanupFloating) {
cleanupFloating()
cleanupFloating = null
}
if (popupElement) {
document.body.removeChild(popupElement)
popupElement = null
}
component?.destroy()
}
return {
onStart: (props: SuggestionProps) => {
if (!props.items.length && props.query === '') return
mount(props)
},
onUpdate(props: SuggestionProps) {
if (!popupElement) {
if (props.items.length || props.query !== '') mount(props)
return
}
component?.updateProps(props)
if (!props.clientRect) return
const rect = props.clientRect()
if (rect) virtualReference.getBoundingClientRect = () => rect
},
onKeyDown(props: {event: KeyboardEvent}) {
if (props.event.key === 'Escape') {
if (props.event.isComposing) return false
if (popupElement) popupElement.style.display = 'none'
return true
}
return component?.ref?.onKeyDown(props)
},
onExit: unmount,
}
},
}
}

View File

@ -3,6 +3,7 @@ import { computePosition, flip, shift, offset, autoUpdate } from '@floating-ui/d
import type { Editor } from '@tiptap/core'
import MentionList from './MentionList.vue'
import { getPopupContainer } from '../popupContainer'
import ProjectUserService from '@/services/projectUsers'
import { fetchAvatarBlobUrl, getDisplayName } from '@/models/user'
import type { IUser } from '@/modelTypes/IUser'
@ -113,7 +114,8 @@ export default function mentionSuggestionSetup(projectId: number) {
popupElement.style.left = '0'
popupElement.style.zIndex = '4700'
popupElement.appendChild(component.element!)
document.body.appendChild(popupElement) // Update virtual reference
getPopupContainer(props.editor).appendChild(popupElement)
// Update virtual reference
const rect = props.clientRect()
if (rect) {
virtualReference.getBoundingClientRect = () => rect
@ -179,7 +181,7 @@ export default function mentionSuggestionSetup(projectId: number) {
cleanupFloating()
}
if (popupElement) {
document.body.removeChild(popupElement)
popupElement.remove()
popupElement = null
}
component.destroy()

View File

@ -0,0 +1,11 @@
import type {Editor} from '@tiptap/core'
// Native <dialog> elements opened with showModal() render in the browser's
// top-layer, so popups appended to document.body end up visually behind them
// regardless of z-index. Appending to the open dialog itself lifts the popup
// into the same top-layer stacking context.
export function getPopupContainer(editor?: Editor): HTMLElement {
const editorEl = editor?.view?.dom as HTMLElement | undefined
const dialog = editorEl?.closest('dialog[open]') as HTMLElement | null
return dialog ?? document.body
}

View File

@ -3,6 +3,7 @@ import {VueRenderer} from '@tiptap/vue-3'
import {computePosition, flip, shift, offset, autoUpdate} from '@floating-ui/dom'
import CommandsList from './CommandsList.vue'
import {getPopupContainer} from './popupContainer'
type TranslateFunction = (key: string) => string
@ -206,7 +207,7 @@ export default function suggestionSetup(t: TranslateFunction) {
popupElement.style.left = '0'
popupElement.style.zIndex = '4700'
popupElement.appendChild(component.element!)
document.body.appendChild(popupElement)
getPopupContainer(props.editor).appendChild(popupElement)
// Update virtual reference
const rect = props.clientRect()
@ -266,7 +267,7 @@ export default function suggestionSetup(t: TranslateFunction) {
cleanupFloating()
}
if (popupElement) {
document.body.removeChild(popupElement)
popupElement.remove()
popupElement = null
}
component.destroy()

View File

@ -451,7 +451,10 @@ export default Extension.create<FilterAutocompleteOptions>({
popupElement.style.zIndex = '20000'
popupElement.id = 'filter-autocomplete-popup'
popupElement.appendChild(component.element!)
document.body.appendChild(popupElement)
// Append to the closest dialog (if inside a modal) so the popup
// is not blocked by <dialog> inertness, otherwise fall back to body.
const parentDialog = view.dom.closest('dialog')
;(parentDialog || document.body).appendChild(popupElement)
cleanupFloating = autoUpdate(virtualReference, popupElement, updatePosition)
}

View File

@ -73,6 +73,9 @@ defineEmits<{
margin-block-end: 1rem;
border: 1px solid var(--card-border-color);
box-shadow: var(--shadow-sm);
color: var(--text);
max-inline-size: 100%;
position: relative;
@media print {
box-shadow: none;
@ -81,15 +84,61 @@ defineEmits<{
}
.card-header {
background-color: transparent;
align-items: stretch;
display: flex;
box-shadow: none;
border-inline-end: 1px solid var(--card-border-color);
border-radius: $radius $radius 0 0;
}
.card-header-title {
align-items: center;
color: var(--text-strong);
display: flex;
flex-grow: 1;
font-weight: 700;
padding: 0.75rem 1rem;
&.is-centered {
justify-content: center;
}
}
.card-header-icon {
align-items: center;
cursor: pointer;
display: flex;
justify-content: center;
padding: 0.75rem 1rem;
}
.card-content {
background-color: transparent;
padding: 1.5rem;
&:first-child {
border-start-start-radius: $radius;
border-start-end-radius: $radius;
}
&:last-child {
border-end-start-radius: $radius;
border-end-end-radius: $radius;
}
// Utility classes like .p-0 are defined globally with lower specificity
// than Vue-scoped selectors; restore precedence explicitly.
&.p-0 {
padding: 0;
}
}
.card-footer {
align-items: stretch;
background-color: var(--grey-50);
border-block-start: 0;
padding: var(--modal-card-head-padding);
padding: 20px;
display: flex;
justify-content: flex-end;
}

View File

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

View File

@ -7,7 +7,7 @@
<script setup lang="ts">
withDefaults(defineProps<{
name?: 'fade' | 'flash-background' | 'width' | 'modal'
name?: 'fade' | 'flash-background' | 'width'
}>(), {
name: 'fade',
})
@ -58,13 +58,4 @@ $flash-background-duration: 750ms;
inline-size: 0;
}
.modal-enter,
.modal-leave-active {
opacity: 0;
}
.modal-enter .modal-container,
.modal-leave-active .modal-container {
transform: scale(0.9);
}
</style>

View File

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

View File

@ -37,6 +37,7 @@ import {
faEyeSlash,
faFile,
faFileImage,
faFilePdf,
faFillDrip,
faFilter,
faForward,
@ -72,6 +73,7 @@ import {
faTimes,
faTrashAlt,
faUser,
faUserEdit,
faUsers,
faQuoteRight,
faListUl,
@ -111,6 +113,7 @@ library.add(faSquareCheck)
library.add(faTable)
library.add(faFile)
library.add(faFileImage)
library.add(faFilePdf)
library.add(faCheckSquare)
library.add(faStrikethrough)
library.add(faCode)
@ -184,6 +187,7 @@ library.add(faTimes)
library.add(faTimesCircle)
library.add(faTrashAlt)
library.add(faUser)
library.add(faUserEdit)
library.add(faUsers)
library.add(faArrowDownShortWide)
library.add(faArrowUpFromBracket)

View File

@ -0,0 +1,230 @@
import {describe, it, expect, beforeEach, afterEach, vi} from 'vitest'
import {mount, flushPromises} from '@vue/test-utils'
import {nextTick} from 'vue'
import Modal from './Modal.vue'
const globalMocks = {
global: {
mocks: {
$t: (key: string) => key,
},
},
}
// jsdom does not implement HTMLDialogElement.showModal/close.
// Provide stubs so that the [open] attribute — which CSS and our tests
// check — is flipped the same way the real browser would.
let showModalSpy: ReturnType<typeof vi.spyOn>
let closeSpy: ReturnType<typeof vi.spyOn>
let installedShowModal = false
let installedClose = false
beforeEach(() => {
const proto = HTMLDialogElement.prototype
if (typeof proto.showModal !== 'function') {
proto.showModal = function () {}
installedShowModal = true
}
if (typeof proto.close !== 'function') {
proto.close = function () {}
installedClose = true
}
showModalSpy = vi.spyOn(proto, 'showModal').mockImplementation(function (this: HTMLDialogElement) {
this.setAttribute('open', '')
})
closeSpy = vi.spyOn(proto, 'close').mockImplementation(function (this: HTMLDialogElement) {
this.removeAttribute('open')
})
})
afterEach(() => {
showModalSpy.mockRestore()
closeSpy.mockRestore()
// Remove the prototype stubs we installed, so other test files see the
// original (unpatched) shape of HTMLDialogElement.
if (installedShowModal) {
// @ts-expect-error — removing the method we added
delete HTMLDialogElement.prototype.showModal
installedShowModal = false
}
if (installedClose) {
// @ts-expect-error — removing the method we added
delete HTMLDialogElement.prototype.close
installedClose = false
}
document.body.innerHTML = ''
})
describe('Modal.vue — open race condition (#2590)', () => {
it('opens the dialog when enabled flips false → true', async () => {
const wrapper = mount(Modal, {
...globalMocks,
attachTo: document.body,
props: {enabled: false},
slots: {default: '<p class="test-body">hi</p>'},
})
// Pre-condition: dialog is not yet in the DOM.
expect(document.querySelector('dialog.modal-dialog')).toBeNull()
// Flip enabled → true. This is the failure path in the bug report.
// The fix must call showModal() deterministically — i.e. once the
// <dialog> element is mounted via the dialogRef watcher, not via a
// nextTick that may fire before the mount flush under Electron.
await wrapper.setProps({enabled: true})
await flushPromises()
await nextTick()
const dialog = document.querySelector('dialog.modal-dialog') as HTMLDialogElement | null
expect(dialog).not.toBeNull()
expect(dialog!.hasAttribute('open')).toBe(true)
expect(showModalSpy).toHaveBeenCalledTimes(1)
wrapper.unmount()
})
it('calls showModal synchronously with the render flush, not via a deferred nextTick (#2590)', async () => {
// Regression guard: the buggy implementation scheduled showModal() via
// nextTick *after* setting showDialog = true, so the call landed in a
// microtask that could fire before the <dialog> mount flush under
// Electron/Chromium. The fix invokes showModal() from a watch on the
// dialogRef template ref, which Vue populates during the same flush
// that mounts the element. That means by the time `await nextTick()`
// resolves after the first state change, the dialog must already have
// [open] set — no additional flushPromises or extra ticks required.
const wrapper = mount(Modal, {
...globalMocks,
attachTo: document.body,
props: {enabled: false},
slots: {default: '<p class="test-body">hi</p>'},
})
expect(document.querySelector('dialog.modal-dialog')).toBeNull()
// Flip enabled and wait exactly one render flush. After this, the
// dialog is mounted AND showModal has been called.
wrapper.setProps({enabled: true})
await nextTick()
const dialog = document.querySelector('dialog.modal-dialog') as HTMLDialogElement | null
expect(dialog).not.toBeNull()
expect(showModalSpy).toHaveBeenCalled()
expect(showModalSpy.mock.instances[0]).toBe(dialog)
expect(dialog!.hasAttribute('open')).toBe(true)
wrapper.unmount()
})
it('calls showModal on the exact dialog element that is mounted (race regression)', async () => {
// This test asserts the fix's contract: whenever the <dialog> element
// is mounted (i.e. dialogRef becomes non-null), showModal() is called
// on *that* element. The buggy implementation instead relied on a
// nextTick callback whose timing could fire before the dialog mounted,
// skipping the showModal() call entirely and leaving .open === false.
const wrapper = mount(Modal, {
...globalMocks,
attachTo: document.body,
props: {enabled: true},
slots: {default: '<p class="test-body">hi</p>'},
})
await flushPromises()
await nextTick()
const dialog = document.querySelector('dialog.modal-dialog') as HTMLDialogElement | null
expect(dialog).not.toBeNull()
// The fingerprint from the bug report: element is mounted but .open
// is false because showModal() was never called. The fix guarantees
// these two always agree.
expect(dialog!.hasAttribute('open')).toBe(true)
expect(showModalSpy).toHaveBeenCalled()
expect(showModalSpy.mock.instances[0]).toBe(dialog)
wrapper.unmount()
})
it('closes the dialog when enabled flips true → false', async () => {
const wrapper = mount(Modal, {
...globalMocks,
attachTo: document.body,
props: {enabled: true},
slots: {default: '<p class="test-body">hi</p>'},
})
await flushPromises()
await nextTick()
// Sanity: open.
expect(document.querySelector('dialog.modal-dialog')?.hasAttribute('open')).toBe(true)
await wrapper.setProps({enabled: false})
// Wait past the 150ms closeTimer (real timers — fake timers interact
// badly with Vue's scheduler).
await new Promise(resolve => setTimeout(resolve, 200))
await flushPromises()
await nextTick()
expect(document.querySelector('dialog.modal-dialog')).toBeNull()
wrapper.unmount()
})
it('does not open the dialog if enabled flips back to false before mount', async () => {
// Regression guard: the dialogRef watcher fires once the <dialog>
// element mounts. If props.enabled has flipped back to false by the
// time the mount happens, the watcher must not call showModal().
const wrapper = mount(Modal, {
...globalMocks,
attachTo: document.body,
props: {enabled: false},
slots: {default: '<p class="test-body">hi</p>'},
})
// Flip enabled true then false within the same tick, before the mount
// flush can complete.
wrapper.setProps({enabled: true})
wrapper.setProps({enabled: false})
await flushPromises()
await nextTick()
await new Promise(resolve => setTimeout(resolve, 200))
await flushPromises()
await nextTick()
// showModal must not have been called — the final prop state is
// disabled.
expect(showModalSpy).not.toHaveBeenCalled()
expect(document.querySelector('dialog.modal-dialog')).toBeNull()
wrapper.unmount()
})
it('clears data-closing when re-opened mid-close transition', async () => {
// Regression guard: if the user toggles enabled back to true while the
// 150ms close transition is still in flight, the <dialog> is still
// mounted and [open], so the dialogRef watcher does not re-fire. Make
// sure openDialog() clears the leftover data-closing flag itself;
// otherwise the dialog stays stuck at opacity 0.
const wrapper = mount(Modal, {
...globalMocks,
attachTo: document.body,
props: {enabled: true},
slots: {default: '<p class="test-body">hi</p>'},
})
await flushPromises()
await nextTick()
const dialog = document.querySelector('dialog.modal-dialog') as HTMLDialogElement
expect(dialog.hasAttribute('open')).toBe(true)
// Start closing — this sets data-closing and schedules the unmount.
await wrapper.setProps({enabled: false})
await nextTick()
expect(dialog.dataset.closing).toBe('')
// Re-open well before the 150ms close timer fires.
await wrapper.setProps({enabled: true})
await nextTick()
expect(dialog.dataset.closing).toBeUndefined()
expect(dialog.hasAttribute('open')).toBe(true)
wrapper.unmount()
})
})

View File

@ -1,128 +1,172 @@
<template>
<Teleport to="body">
<!-- FIXME: transition should not be included in the modal -->
<CustomTransition
:name="transitionName"
appear
<dialog
v-if="showDialog"
ref="dialogRef"
class="modal-dialog"
:class="[
{ 'has-overflow': overflow },
variant,
]"
v-bind="attrs"
@cancel.prevent="$emit('close')"
>
<section
v-if="enabled"
ref="modal"
class="modal-mask"
:class="[
{ 'has-overflow': overflow },
variant,
]"
v-bind="attrs"
<div
class="modal-container"
@mousedown.self.prevent.stop="$emit('close')"
>
<div
v-shortcut="'Escape'"
class="modal-container"
@mousedown.self.prevent.stop="$emit('close')"
<BaseButton
:aria-label="$t('misc.closeDialog')"
class="close"
@click="$emit('close')"
>
<BaseButton
class="close"
@click="$emit('close')"
>
<Icon icon="times" />
</BaseButton>
<div
class="modal-content"
:class="{
'has-overflow': overflow,
'is-wide': wide
}"
>
<slot>
<div class="modal-header">
<slot name="header" />
</div>
<div class="content">
<slot name="text" />
</div>
<div class="actions">
<XButton
variant="tertiary"
class="has-text-danger"
@click="$emit('close')"
>
{{ $t('misc.cancel') }}
</XButton>
<XButton
v-cy="'modalPrimary'"
variant="primary"
:shadow="false"
@click="$emit('submit')"
>
{{ $t('misc.doit') }}
</XButton>
</div>
</slot>
</div>
<Icon icon="times" />
</BaseButton>
<div
class="modal-content"
:class="{
'has-overflow': overflow,
'is-wide': wide
}"
>
<slot>
<div class="modal-header">
<slot name="header" />
</div>
<div class="content">
<slot name="text" />
</div>
<div class="actions">
<XButton
variant="tertiary"
class="has-text-danger"
@click="$emit('close')"
>
{{ $t('misc.cancel') }}
</XButton>
<XButton
v-cy="'modalPrimary'"
variant="primary"
:shadow="false"
@click="$emit('submit')"
>
{{ $t('misc.doit') }}
</XButton>
</div>
</slot>
</div>
</section>
</CustomTransition>
</div>
</dialog>
</Teleport>
</template>
<script lang="ts" setup>
import CustomTransition from '@/components/misc/CustomTransition.vue'
import BaseButton from '@/components/base/BaseButton.vue'
import {ref, useAttrs, watchEffect, onBeforeUnmount, watch} from 'vue'
import {useScrollLock} from '@vueuse/core'
import {ref, useAttrs, watch, onBeforeUnmount} from 'vue'
const props = withDefaults(defineProps<{
enabled?: boolean,
overflow?: boolean,
wide?: boolean,
transitionName?: 'modal' | 'fade',
variant?: 'default' | 'hint-modal' | 'scrolling',
}>(), {
enabled: true,
overflow: false,
wide: false,
transitionName: 'modal',
variant: 'default',
})
const emit = defineEmits(['close', 'submit'])
defineEmits(['close', 'submit'])
defineOptions({
inheritAttrs: false,
})
const TRANSITION_DURATION = 150
const attrs = useAttrs()
const dialogRef = ref<HTMLDialogElement | null>(null)
const previouslyFocused = ref<Element | null>(null)
const showDialog = ref(false)
let closeTimer: ReturnType<typeof setTimeout> | null = null
const modal = ref<HTMLElement | null>(null)
const scrollLock = useScrollLock(modal)
watchEffect(() => {
scrollLock.value = props.enabled
})
function onKeydown(e: KeyboardEvent) {
if (e.code === 'Escape') {
if (e.isComposing) {
return
}
emit('close')
function openDialog() {
if (closeTimer) {
clearTimeout(closeTimer)
closeTimer = null
}
previouslyFocused.value = document.activeElement
showDialog.value = true
document.body.style.overflow = 'hidden'
// If we're re-opening while the previous close transition is still in
// flight the <dialog> is still mounted and [open], so the dialogRef
// watcher below won't re-fire. Clear the data-closing flag here so the
// dialog doesn't stay stuck at opacity 0.
if (dialogRef.value) {
delete dialogRef.value.dataset.closing
}
// The initial `showModal()` call happens in the `watch(dialogRef, )`
// below, which fires the moment Vue mounts the <dialog>. We cannot call
// it synchronously here because the element is not in the DOM yet
// (v-if="showDialog" only just became true), and we cannot rely on a
// single nextTick because the mount can be deferred past it (#2590).
}
function closeDialog() {
const dialog = dialogRef.value
if (!dialog) return
// Trigger the fade-out while the dialog is still [open] so the opacity
// transition plays in browsers that don't support allow-discrete (Firefox).
dialog.dataset.closing = ''
document.body.style.overflow = ''
closeTimer = setTimeout(() => {
delete dialog.dataset.closing
dialog.close()
showDialog.value = false
closeTimer = null
if (previouslyFocused.value instanceof HTMLElement) {
previouslyFocused.value.focus()
}
previouslyFocused.value = null
}, TRANSITION_DURATION)
}
watch(
() => props.enabled,
(value: boolean) => {
if (value) {
window.addEventListener('keydown', onKeydown)
(isEnabled) => {
if (isEnabled) {
openDialog()
} else {
window.removeEventListener('keydown', onKeydown)
closeDialog()
}
},
{immediate: true},
)
// Actually call showModal() the moment the <dialog> element is mounted.
// `dialogRef` is populated by Vue during the render flush after
// `showDialog.value = true`, so this fires deterministically, no matter
// how many flushes the renderer needs (see #2590). We re-check
// `props.enabled` here because the prop can flip back to `false` between
// `openDialog()` and the mount flush, in which case we must not open.
watch(dialogRef, (dialog) => {
if (!dialog) return
if (!props.enabled) return
delete dialog.dataset.closing
dialog.showModal()
})
onBeforeUnmount(() => {
window.removeEventListener('keydown', onKeydown)
if (closeTimer) {
clearTimeout(closeTimer)
closeTimer = null
}
document.body.style.overflow = ''
if (previouslyFocused.value instanceof HTMLElement) {
previouslyFocused.value.focus()
}
})
</script>
@ -130,20 +174,49 @@ onBeforeUnmount(() => {
$modal-margin: 4rem;
$modal-width: 1024px;
.modal-mask {
.modal-dialog {
// Reset UA dialog styles
padding: 0;
border: none;
background: transparent;
color: #ffffff;
// Fill viewport
position: fixed;
z-index: 4000;
inset-block-start: 0;
inset-inline-start: 0;
inset: 0;
inline-size: 100%;
block-size: 100%;
background-color: rgba(0, 0, 0, .8);
transition: opacity 150ms ease;
color: #ffffff;
max-inline-size: 100%;
max-block-size: 100%;
// Transitions
opacity: 0;
transition: opacity 150ms ease,
display 150ms ease allow-discrete;
&[open]:not([data-closing]) {
opacity: 1;
@starting-style {
opacity: 0;
}
}
&::backdrop {
background-color: rgba(0, 0, 0, 0);
transition: background-color 150ms ease,
display 150ms ease allow-discrete;
}
&[open]:not([data-closing])::backdrop {
background-color: rgba(0, 0, 0, .8);
@starting-style {
background-color: rgba(0, 0, 0, 0);
}
}
}
.modal-container {
transition: all 150ms ease;
position: relative;
inline-size: 100%;
block-size: 100%;
@ -151,6 +224,7 @@ $modal-width: 1024px;
overflow: auto;
padding-block-start: env(safe-area-inset-top);
padding-block-end: env(safe-area-inset-bottom);
}
.default .modal-content,
@ -161,7 +235,7 @@ $modal-width: 1024px;
inset-block-start: 50%;
inset-inline-start: 50%;
transform: translate(-50%, -50%);
[dir="rtl"] & {
transform: translate(50%, -50%);
}
@ -190,7 +264,7 @@ $modal-width: 1024px;
max-block-size: none; // reset bulma
overflow: visible; // reset bulma
@media not print {
max-inline-size: $modal-width;
}
@ -212,8 +286,6 @@ $modal-width: 1024px;
}
.hint-modal {
z-index: 4600;
:deep(.card-content) {
text-align: start;
@ -244,7 +316,7 @@ $modal-width: 1024px;
}
@media print, screen and (max-width: $tablet) {
.modal-mask {
.modal-dialog {
overflow: visible !important;
}
@ -285,7 +357,7 @@ $modal-width: 1024px;
.modal-content :deep(.card .card-header-icon.close) {
display: none;
@media screen and (max-width: $tablet) {
display: block;
}
@ -294,12 +366,12 @@ $modal-width: 1024px;
<style lang="scss">
// Close icon SVG uses currentColor, change the color to keep it visible
.dark .close {
.dark .modal-dialog .close {
color: var(--grey-900);
}
@media print, screen and (max-width: $tablet) {
body:has(.modal-mask) #app {
body:has(dialog[open].modal-dialog) #app {
display: none;
}
}

View File

@ -17,7 +17,10 @@
{{ $t("misc.welcomeBack") }}
</h2>
</section>
<section class="content">
<main
id="main-content"
class="content"
>
<div>
<h2
v-if="title"
@ -25,7 +28,7 @@
>
{{ title }}
</h2>
<ApiConfig v-if="showApiConfig" />
<ApiConfig v-if="shouldShowApiConfig" />
<Message
v-if="motd !== ''"
class="is-hidden-tablet mbe-4"
@ -35,7 +38,7 @@
<slot />
</div>
<Legal />
</section>
</main>
</div>
</div>
</template>
@ -52,8 +55,9 @@ import ApiConfig from '@/components/misc/ApiConfig.vue'
import { useTitle } from '@/composables/useTitle'
import { useConfigStore } from '@/stores/config'
import { isDesktopApp } from '@/helpers/desktopAuth'
withDefaults(
const props = withDefaults(
defineProps<{
showApiConfig?: boolean;
}>(),
@ -61,6 +65,11 @@ withDefaults(
showApiConfig: false,
},
)
const isDesktop = isDesktopApp()
const hasStoredApiUrl = isDesktop && localStorage.getItem('API_URL') !== null
const shouldShowApiConfig = computed(() => props.showApiConfig && (!isDesktop || hasStoredApiUrl))
const configStore = useConfigStore()
const motd = computed(() => configStore.motd)

View File

@ -4,6 +4,8 @@
:max="2"
:ignore-duplicates="true"
class="global-notification"
role="status"
aria-live="polite"
>
<template #body="{ item, close }">
<!-- FIXME: overlay whole notification with button and add event listener on that button instead -->

View File

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

View File

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

View File

@ -0,0 +1,156 @@
<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

@ -0,0 +1,208 @@
<template>
<div class="shortcut-recorder">
<button
class="input recorder-button"
:class="{'is-recording': recording}"
@click="startRecording"
@keydown.prevent="onKeyDown"
@blur="stopRecording"
>
<template v-if="recording">
<span class="recording-hint">{{ $t('user.settings.desktop.shortcutRecorderRecording') }}</span>
</template>
<template v-else-if="displayKeys.length > 0">
<kbd
v-for="(key, i) in displayKeys"
:key="i"
>
{{ key }}
</kbd>
</template>
<template v-else>
<span class="placeholder">{{ $t('user.settings.desktop.shortcutRecorderPlaceholder') }}</span>
</template>
</button>
<BaseButton
v-if="modelValue"
class="clear-button"
@click="clear"
>
<Icon icon="times" />
</BaseButton>
</div>
</template>
<script setup lang="ts">
import {computed, ref} from 'vue'
import BaseButton from '@/components/base/BaseButton.vue'
const props = defineProps<{
modelValue: string,
}>()
const emit = defineEmits<{
'update:modelValue': [value: string],
}>()
const recording = ref(false)
const isMac = navigator.platform.toUpperCase().includes('MAC')
// Map KeyboardEvent properties to Electron accelerator format
function eventToAccelerator(event: KeyboardEvent): string | null {
if (['Control', 'Alt', 'Shift', 'Meta'].includes(event.key)) {
return null
}
const parts: string[] = []
if (event.ctrlKey || event.metaKey) parts.push('CmdOrCtrl')
if (event.altKey) parts.push('Alt')
if (event.shiftKey) parts.push('Shift')
// Need at least one modifier for a global shortcut
if (parts.length === 0) return null
const key = mapKey(event)
if (key) parts.push(key)
else return null
return parts.join('+')
}
function mapKey(event: KeyboardEvent): string | null {
// Letters
if (/^Key[A-Z]$/.test(event.code)) {
return event.code.slice(3)
}
// Digits
if (/^Digit[0-9]$/.test(event.code)) {
return event.code.slice(5)
}
// Function keys
if (/^F\d{1,2}$/.test(event.code)) {
return event.code
}
// Special keys
const specialMap: Record<string, string> = {
Space: 'Space',
Enter: 'Enter',
Backspace: 'Backspace',
Delete: 'Delete',
Tab: 'Tab',
Escape: 'Escape',
ArrowUp: 'Up',
ArrowDown: 'Down',
ArrowLeft: 'Left',
ArrowRight: 'Right',
Home: 'Home',
End: 'End',
PageUp: 'PageUp',
PageDown: 'PageDown',
Minus: '-',
Equal: '=',
BracketLeft: '[',
BracketRight: ']',
Semicolon: ';',
Quote: '\'',
Backquote: '`',
Backslash: '\\',
Comma: ',',
Period: '.',
Slash: '/',
}
return specialMap[event.code] ?? null
}
// Convert Electron accelerator string to display-friendly key names
function acceleratorToDisplayKeys(accelerator: string): string[] {
if (!accelerator) return []
return accelerator.split('+').map(part => {
if (part === 'CmdOrCtrl') return isMac ? '\u2318' : 'Ctrl'
if (part === 'Shift') return isMac ? '\u21E7' : 'Shift'
if (part === 'Alt') return isMac ? '\u2325' : 'Alt'
if (part === 'Space') return '\u2423'
return part
})
}
const displayKeys = computed(() => acceleratorToDisplayKeys(props.modelValue))
function startRecording() {
recording.value = true
}
function stopRecording() {
recording.value = false
}
function onKeyDown(event: KeyboardEvent) {
if (!recording.value) {
startRecording()
}
const accelerator = eventToAccelerator(event)
if (accelerator) {
emit('update:modelValue', accelerator)
recording.value = false
}
}
function clear() {
emit('update:modelValue', '')
}
</script>
<style lang="scss" scoped>
.shortcut-recorder {
display: flex;
align-items: center;
gap: .5rem;
}
.recorder-button {
display: inline-flex;
align-items: center;
gap: .25rem;
cursor: pointer;
min-inline-size: 150px;
text-align: start;
&.is-recording {
border-color: var(--primary);
box-shadow: 0 0 0 0.125em rgba(var(--primary-rgb), 0.25);
}
}
kbd {
padding: .1rem .4rem;
border: 1px solid var(--grey-300);
background: var(--grey-100);
border-radius: 3px;
font-size: .85rem;
font-family: inherit;
line-height: 1.5;
& + kbd {
margin-inline-start: .15rem;
}
}
.recording-hint {
color: var(--primary);
font-size: .85rem;
}
.placeholder {
color: var(--grey-400);
}
.clear-button {
color: var(--grey-500);
padding: .25rem;
&:hover {
color: var(--danger);
}
}
</style>

View File

@ -0,0 +1,126 @@
<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

@ -0,0 +1,29 @@
<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

@ -6,6 +6,7 @@ import WebhookModel from '@/models/webhook'
import BaseButton from '@/components/base/BaseButton.vue'
import FancyCheckbox from '@/components/input/FancyCheckbox.vue'
import FormField from '@/components/input/FormField.vue'
import FormInput from '@/components/input/FormInput.vue'
import Expandable from '@/components/base/Expandable.vue'
import User from '@/components/misc/User.vue'
import {formatDateShort} from '@/helpers/time/formatDate'
@ -116,27 +117,20 @@ function doDelete() {
:error="webhookTargetUrlValid ? null : $t('project.webhooks.targetUrlInvalid')"
@focusout="validateTargetUrl"
/>
<div class="field">
<label
class="label"
for="secret"
>
{{ $t('project.webhooks.secret') }}
</label>
<div class="control">
<input
id="secret"
<FormField :label="$t('project.webhooks.secret')">
<template #default="{id}">
<FormInput
:id="id"
v-model="newWebhook.secret"
class="input"
>
</div>
<p class="help">
{{ $t('project.webhooks.secretHint') }}
<BaseButton href="https://vikunja.io/docs/webhooks/">
{{ $t('project.webhooks.secretDocs') }}
</BaseButton>
</p>
</div>
/>
</template>
</FormField>
<p class="help">
{{ $t('project.webhooks.secretHint') }}
<BaseButton href="https://vikunja.io/docs/webhooks/">
{{ $t('project.webhooks.secretDocs') }}
</BaseButton>
</p>
<BaseButton
class="mbe-2 has-text-primary"
@click="showBasicAuth = !showBasicAuth"
@ -147,36 +141,22 @@ function doDelete() {
:open="showBasicAuth"
class="content"
>
<div class="field">
<label
class="label"
for="basicauthuser"
>
{{ $t('project.webhooks.basicauthuser') }}
</label>
<div class="control">
<input
id="basicauthuser"
v-model="newWebhook.basicauthuser"
class="input"
>
</div>
</div>
<div class="field">
<label
class="label"
for="basicauthpassword"
>
{{ $t('project.webhooks.basicauthpassword') }}
</label>
<div class="control">
<input
id="basicauthpassword"
v-model="newWebhook.basicauthpassword"
class="input"
>
</div>
</div>
<FormField :label="$t('project.webhooks.basicauthuser')">
<template #default="{id}">
<FormInput
:id="id"
v-model="newWebhook.basicAuthUser"
/>
</template>
</FormField>
<FormField :label="$t('project.webhooks.basicauthpassword')">
<template #default="{id}">
<FormInput
:id="id"
v-model="newWebhook.basicAuthPassword"
/>
</template>
</FormField>
</Expandable>
<div class="field">
<label

View File

@ -4,6 +4,7 @@ import {isAppleDevice} from '@/helpers/isAppleDevice'
const ctrl = isAppleDevice() ? '⌘' : 'ctrl'
const reminderModifier = isAppleDevice() ? 'shift' : 'alt'
const deleteKey = isAppleDevice() ? 'backspace' : 'delete'
export interface Shortcut {
title: string
@ -195,7 +196,7 @@ export const KEYBOARD_SHORTCUTS: ShortcutGroup[] = [
},
{
title: 'keyboardShortcuts.task.delete',
keys: ['shift', 'delete'],
keys: [deleteKey],
},
{
title: 'keyboardShortcuts.task.favorite',

View File

@ -83,10 +83,11 @@
</template>
<script lang="ts" setup>
import {computed, onMounted, onUnmounted, ref} from 'vue'
import {computed, onMounted, onUnmounted, ref, watch} from 'vue'
import {useRouter, isNavigationFailure, NavigationFailureType, RouteLocationRaw} from 'vue-router'
import NotificationService from '@/services/notification'
import NotificationModel from '@/models/notification'
import BaseButton from '@/components/base/BaseButton.vue'
import CustomTransition from '@/components/misc/CustomTransition.vue'
import User from '@/components/misc/User.vue'
@ -95,11 +96,12 @@ import {closeWhenClickedOutside} from '@/helpers/closeWhenClickedOutside'
import {formatDateLong, formatDisplayDate} from '@/helpers/time/formatDate'
import {getDisplayName} from '@/models/user'
import {useAuthStore} from '@/stores/auth'
import {useWebSocket} from '@/composables/useWebSocket'
import XButton from '@/components/input/Button.vue'
import {success} from '@/message'
import {useI18n} from 'vue-i18n'
const LOAD_NOTIFICATIONS_INTERVAL = 10000
const {subscribe, connected: wsConnected} = useWebSocket()
const authStore = useAuthStore()
const router = useRouter()
@ -117,26 +119,68 @@ const notifications = computed(() => {
})
const userInfo = computed(() => authStore.info)
let interval: ReturnType<typeof setInterval>
let unsubscribeWs: (() => void) | null = null
let pollInterval: ReturnType<typeof setInterval> | null = null
const POLL_INTERVAL = 10000
onMounted(async () => {
// Initial load via REST - wrapped in try/catch so the rest of setup
// (click handler, WS subscription, polling) still runs if this fails
try {
await loadNotifications()
} catch (e) {
console.warn('Failed to load initial notifications:', e)
}
onMounted(() => {
loadNotifications()
document.addEventListener('click', hidePopup)
document.addEventListener('visibilitychange', loadNotifications)
interval = setInterval(loadNotifications, LOAD_NOTIFICATIONS_INTERVAL)
// Subscribe to real-time notifications
unsubscribeWs = subscribe('notification.created', (msg) => {
if (msg.event === 'notification.created' && msg.data) {
const notification = new NotificationModel(msg.data as Partial<INotification>)
// Avoid duplicates if the same notification was already loaded via REST
const exists = allNotifications.value.some(n => n.id === notification.id)
if (!exists) {
allNotifications.value = [notification, ...allNotifications.value]
}
}
})
// Fallback polling when WebSocket is not available
startPollingFallback()
})
// Reload notifications when WebSocket disconnects to catch any events
// that may have been missed during the disconnect window
watch(wsConnected, (isConnected, wasConnected) => {
if (wasConnected && !isConnected) {
loadNotifications().catch(e => console.warn('Failed to reload notifications after WS disconnect:', e))
}
})
onUnmounted(() => {
document.removeEventListener('click', hidePopup)
document.removeEventListener('visibilitychange', loadNotifications)
clearInterval(interval)
unsubscribeWs?.()
stopPollingFallback()
})
async function loadNotifications() {
if (document.visibilityState !== 'visible') {
return
function startPollingFallback() {
pollInterval = setInterval(async () => {
if (!wsConnected.value && document.visibilityState === 'visible') {
await loadNotifications()
}
}, POLL_INTERVAL)
}
function stopPollingFallback() {
if (pollInterval) {
clearInterval(pollInterval)
pollInterval = null
}
// We're recreating the notification service here to make sure it uses the latest api user token
}
async function loadNotifications() {
const notificationService = new NotificationService()
allNotifications.value = await notificationService.getAll()
}

View File

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

View File

@ -9,7 +9,6 @@
</XButton>
<Modal
:enabled="modalOpen"
transition-name="fade"
:overflow="true"
variant="hint-modal"
@close="() => modalOpen = false"

View File

@ -55,10 +55,6 @@
</Card>
</template>
<script lang="ts">
export const ALPHABETICAL_SORT = 'title'
</script>
<script setup lang="ts">
import {computed, ref, watch} from 'vue'
import FancyCheckbox from '@/components/input/FancyCheckbox.vue'

View File

@ -0,0 +1,135 @@
<template>
<Popup>
<template #trigger="{toggle}">
<XButton
variant="secondary"
icon="sort"
@click.prevent.stop="toggle()"
>
{{ $t('project.list.sort') }}
</XButton>
</template>
<template #content="{close}">
<Card class="sort-popup">
<p class="sort-description has-text-grey is-size-7">
{{ $t('sorting.description') }}
</p>
<div class="field">
<div class="select is-fullwidth">
<select
v-model="selected"
:aria-label="$t('misc.sortBy')"
>
<option
v-for="o in options"
:key="o.value"
:value="o.value"
>
{{ o.label }}
</option>
</select>
</div>
</div>
<div class="actions">
<XButton
variant="tertiary"
@click="close()"
>
{{ $t('misc.cancel') }}
</XButton>
<XButton
variant="primary"
@click="applySort(close)"
>
{{ $t('sorting.apply') }}
</XButton>
</div>
</Card>
</template>
</Popup>
</template>
<script setup lang="ts">
import {ref, computed, watch} from 'vue'
import {useI18n} from 'vue-i18n'
import XButton from '@/components/input/Button.vue'
import Popup from '@/components/misc/Popup.vue'
import Card from '@/components/misc/Card.vue'
import type {SortBy} from '@/composables/useTaskList'
const props = defineProps<{ modelValue: SortBy }>()
const emit = defineEmits<{ 'update:modelValue': [value: SortBy] }>()
const {t} = useI18n({useScope: 'global'})
const MANUAL = 'position:asc'
const selected = ref<string>(MANUAL)
watch(() => props.modelValue, (val) => {
const key = Object.keys(val)[0]
if (!key || key === 'position') {
selected.value = MANUAL
return
}
const order = (val as Record<string, 'asc' | 'desc'>)[key] ?? 'asc'
selected.value = `${key}:${order}`
}, {immediate: true})
const options = computed(() => {
const manual = {value: MANUAL, label: t('sorting.manually')}
const rest = [
{value: 'title:asc', label: t('sorting.options.titleAsc')},
{value: 'title:desc', label: t('sorting.options.titleDesc')},
{value: 'priority:desc', label: t('sorting.options.priorityDesc')},
{value: 'priority:asc', label: t('sorting.options.priorityAsc')},
{value: 'due_date:asc', label: t('sorting.options.dueDateAsc')},
{value: 'due_date:desc', label: t('sorting.options.dueDateDesc')},
{value: 'start_date:asc', label: t('sorting.options.startDateAsc')},
{value: 'start_date:desc', label: t('sorting.options.startDateDesc')},
{value: 'end_date:asc', label: t('sorting.options.endDateAsc')},
{value: 'end_date:desc', label: t('sorting.options.endDateDesc')},
{value: 'percent_done:desc', label: t('sorting.options.percentDoneDesc')},
{value: 'percent_done:asc', label: t('sorting.options.percentDoneAsc')},
{value: 'created:desc', label: t('sorting.options.createdDesc')},
{value: 'created:asc', label: t('sorting.options.createdAsc')},
{value: 'updated:desc', label: t('sorting.options.updatedDesc')},
{value: 'updated:asc', label: t('sorting.options.updatedAsc')},
].sort((a, b) => a.label.localeCompare(b.label))
return [manual, ...rest]
})
function applySort(close: () => void) {
const [field, order] = selected.value.split(':') as [string, 'asc' | 'desc']
const sort: SortBy = {} as SortBy
;(sort as Record<string, 'asc' | 'desc'>)[field] = order
emit('update:modelValue', sort)
close()
}
</script>
<style scoped lang="scss">
.sort-popup {
margin: 0;
min-inline-size: 18rem;
:deep(.card-content .content) {
display: flex;
flex-direction: column;
}
.sort-description {
margin-block-end: 1rem;
}
.field {
margin-block-end: 1rem;
}
.actions {
display: flex;
justify-content: flex-end;
gap: .5rem;
}
}
</style>

View File

@ -146,13 +146,11 @@ const flatPickerDateRange = computed<Date[]>({
},
})
const initialDateRange = [filters.value.dateFrom, filters.value.dateTo]
const {t} = useI18n({useScope: 'global'})
const flatPickerConfig = computed(() => ({
altFormat: t('date.altFormatShort'),
altInput: true,
defaultDate: initialDateRange,
defaultDate: [filters.value.dateFrom, filters.value.dateTo],
enableTime: false,
mode: 'range',
locale: useFlatpickrLanguage().value,
@ -162,6 +160,8 @@ const flatPickerConfig = computed(() => ({
<style lang="scss" scoped>
.gantt-chart-container {
padding-block-end: 1rem;
position: relative;
z-index: 0;
}
.gantt-options {

View File

@ -74,6 +74,7 @@
v-if="canWrite && !collapsedBuckets[bucket.id]"
class="is-right options"
trigger-icon="ellipsis-v"
:trigger-label="$t('project.kanban.bucketOptions')"
@close="() => showSetLimitInput = false"
>
<div
@ -624,10 +625,10 @@ async function updateTaskPosition(e) {
projectId: projectIdWithFallback.value,
}))
Object.assign(newTask, updatedTaskBucket.task)
newTask.bucketId = updatedTaskBucket.bucketId
if (updatedTaskBucket.bucketId !== newTask.bucketId) {
kanbanStore.moveTaskToBucket(newTask, updatedTaskBucket.bucketId)
}
newTask.bucketId = updatedTaskBucket.bucketId
if (updatedTaskBucket.bucket) {
kanbanStore.setBucketById(updatedTaskBucket.bucket, false)
}

View File

@ -7,12 +7,15 @@
>
<template #header>
<div class="filter-container">
<SortPopup
v-model="sortByParam"
/>
<FilterPopup
v-if="!isSavedFilter(project)"
v-model="params"
:view-id="viewId"
:project-id="projectId"
@update:modelValue="prepareFiltersAndLoadTasks()"
@update:modelValue="loadTasks()"
/>
</div>
</template>
@ -49,13 +52,13 @@
v-if="tasks && tasks.length > 0"
v-model="tasks"
:group="{name: 'tasks', put: false}"
:disabled="!canDragTasks"
:disabled="!canDragTasks || !isPositionSorting"
item-key="id"
tag="ul"
:component-data="{
class: {
tasks: true,
'dragging-disabled': !canDragTasks || isAlphabeticalSorting
'dragging-disabled': !canDragTasks || !isPositionSorting
},
type: 'transition-group'
}"
@ -71,14 +74,13 @@
<SingleTaskInProject
:ref="(el) => setTaskRef(el, index)"
:show-list-color="false"
:disabled="!canDragTasks"
:can-mark-as-done="canWrite || isPseudoProject"
:the-task="t"
:all-tasks="allTasks"
@taskUpdated="updateTasks"
>
<span
v-if="canDragTasks"
v-if="canDragTasks && isPositionSorting"
class="icon handle"
>
<Icon icon="grip-lines" />
@ -109,7 +111,7 @@ import SingleTaskInProject from '@/components/tasks/partials/SingleTaskInProject
import FilterPopup from '@/components/project/partials/FilterPopup.vue'
import Nothing from '@/components/misc/Nothing.vue'
import Pagination from '@/components/misc/Pagination.vue'
import {ALPHABETICAL_SORT} from '@/components/project/partials/Filters.vue'
import SortPopup from '@/components/project/partials/SortPopup.vue'
import {useTaskList} from '@/composables/useTaskList'
import {useTaskDragToProject} from '@/composables/useTaskDragToProject'
@ -167,13 +169,12 @@ const tasks = ref<ITask[]>([])
watch(
allTasks,
() => {
tasks.value = ([...allTasks.value]).filter(t => shouldShowTaskInListView(t, allTasks.value))
const isFiltered = isSavedFilter({id: projectId.value})
tasks.value = ([...allTasks.value]).filter(t => shouldShowTaskInListView(t, allTasks.value, isFiltered))
},
)
const isAlphabeticalSorting = computed(() => {
return params.value.sort_by.find(sortBy => sortBy === ALPHABETICAL_SORT) !== undefined
})
const isPositionSorting = computed(() => 'position' in sortByParam.value)
const firstNewPosition = computed(() => {
if (tasks.value.length === 0) {
@ -214,7 +215,7 @@ function focusNewTaskInput() {
}
function updateTaskList(task: ITask) {
if (isAlphabeticalSorting.value) {
if (!isPositionSorting.value) {
// reload tasks with current filter and sorting
loadTasks()
} else {
@ -234,7 +235,7 @@ function updateTasks(updatedTask: ITask) {
return
}
for (const t in tasks.value) {
for (let t = 0; t < tasks.value.length; t++) {
if (tasks.value[t].id === updatedTask.id) {
tasks.value[t] = updatedTask
break
@ -286,15 +287,6 @@ async function saveTaskPosition(e: { originalEvent?: MouseEvent, to: HTMLElement
}
}
function prepareFiltersAndLoadTasks() {
if (isAlphabeticalSorting.value) {
sortByParam.value = {}
sortByParam.value[ALPHABETICAL_SORT] = 'asc'
}
loadTasks()
}
const taskRefs = ref<(InstanceType<typeof SingleTaskInProject> | null)[]>([])
const focusedIndex = ref(-1)
@ -364,6 +356,18 @@ onBeforeUnmount(() => {
</script>
<style lang="scss" scoped>
.filter-container {
display: flex;
align-items: center;
gap: .5rem;
:deep(.popup) {
inset-block-start: 3rem;
inset-inline-end: 0;
max-inline-size: 300px;
}
}
.tasks {
padding: .5rem;
}

View File

@ -9,6 +9,7 @@ import {useLabelStore} from '@/stores/labels'
import {useProjectStore} from '@/stores/projects'
import XButton from '@/components/input/Button.vue'
import FancyCheckbox from '@/components/input/FancyCheckbox.vue'
import FilterInputDocs from '@/components/input/filter/FilterInputDocs.vue'
import FilterInput from '@/components/input/filter/FilterInput.vue'
import FormField from '@/components/input/FormField.vue'
@ -58,6 +59,16 @@ onBeforeMount(() => {
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
}
@ -76,16 +87,18 @@ onBeforeMount(() => {
})
function save() {
const transformFilterForApi = (filterQuery: string): IFilters => {
const transformFilterForApi = (filterInput: IFilters): IFilters => {
const filterString = transformFilterStringForApi(
filterQuery,
filterInput?.filter || '',
labelTitle => labelStore.getLabelByExactTitle(labelTitle)?.id || null,
projectTitle => {
const found = projectStore.findProjectByExactname(projectTitle)
return found?.id || null
},
)
const filter: IFilters = {}
const filter: IFilters = {
filter_include_nulls: filterInput?.filter_include_nulls ?? false,
}
if (hasFilterQuery(filterString)) {
filter.filter = filterString
} else {
@ -97,10 +110,10 @@ function save() {
emit('update:modelValue', {
...view.value,
filter: transformFilterForApi(view.value?.filter?.filter || ''),
filter: transformFilterForApi(view.value?.filter),
bucketConfiguration: view.value?.bucketConfiguration.map(bc => ({
title: bc.title,
filter: transformFilterForApi(bc.filter?.filter || ''),
filter: transformFilterForApi(bc.filter),
})),
})
}
@ -172,10 +185,18 @@ function handleBubbleSave() {
class="mbe-1"
/>
<div class="is-size-7 mbe-3">
<div class="is-size-7 mbe-2">
<FilterInputDocs />
</div>
<div class="field mbe-3">
<FancyCheckbox
v-model="view.filter.filter_include_nulls"
>
{{ $t('filters.attributes.includeNulls') }}
</FancyCheckbox>
</div>
<div
v-if="view.viewKind === 'kanban'"
class="field"
@ -245,16 +266,24 @@ function handleBubbleSave() {
class="mbe-2"
/>
<div class="is-size-7">
<div class="is-size-7 mbe-2">
<FilterInputDocs />
</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 class="is-flex is-justify-content-end">
<XButton
variant="secondary"
icon="plus"
@click="() => view.bucketConfiguration.push({title: '', filter: {filter: ''}})"
@click="() => view.bucketConfiguration.push({title: '', filter: {filter: '', filter_include_nulls: false}})"
>
{{ $t('project.kanban.addBucket') }}
</XButton>
@ -302,4 +331,32 @@ function handleBubbleSave() {
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>

View File

@ -4,7 +4,12 @@
:overflow="isNewTaskCommand"
@close="closeQuickActions"
>
<div class="card quick-actions">
<div
ref="quickActionsCard"
class="card quick-actions"
:class="{'is-quick-add-mode': isQuickAddMode}"
:style="isQuickAddMode ? {maxHeight: quickEntryMaxHeight + 'px', overflowY: 'auto'} : undefined"
>
<div
class="action-input"
:class="{'has-active-cmd': selectedCmd !== null}"
@ -25,10 +30,14 @@
@keyup="search"
@keydown.down.prevent="select(0, 0)"
@keyup.prevent.delete="unselectCmd"
@keyup.prevent.enter="doCmd"
@keydown.prevent.enter="onEnter"
@keyup.prevent.esc="closeQuickActions"
>
<QuickAddMagic
v-if="isNewTaskCommand"
/>
<BaseButton
:aria-label="$t('misc.closeQuickActions')"
class="close"
@click="closeQuickActions"
>
@ -43,8 +52,6 @@
{{ hintText }}
</div>
<QuickAddMagic v-if="isNewTaskCommand" />
<div
v-if="selectedCmd === null"
class="results"
@ -97,7 +104,8 @@
</template>
<script setup lang="ts">
import {type ComponentPublicInstance, computed, ref, shallowReactive, watchEffect} from 'vue'
import {type ComponentPublicInstance, computed, ref, shallowReactive, watch, watchEffect, onBeforeUnmount} from 'vue'
import {useQuickAddMode} from '@/composables/useQuickAddMode'
import {useI18n} from 'vue-i18n'
import {useRouter} from 'vue-router'
@ -137,6 +145,8 @@ const labelStore = useLabelStore()
const taskStore = useTaskStore()
const authStore = useAuthStore()
const {isQuickAddMode} = useQuickAddMode()
type DoAction<Type> = { type: ACTION_TYPE } & Type
enum ACTION_TYPE {
@ -177,6 +187,37 @@ watchEffect(() => {
}
})
let focusRafId: number | null = null
watchEffect(() => {
if (active.value && isQuickAddMode) {
selectedCmd.value = commands.value.newTask
// The input may not be focusable yet due to:
// 1. Modal transition (v-if + <Transition appear>) delaying DOM readiness
// 2. Electron window not yet visible (shown after did-finish-load)
// Retry with rAF until focus actually lands on the input.
const tryFocus = () => {
if (!active.value) {
focusRafId = null
return
}
if (searchInput.value) {
searchInput.value.focus()
if (document.activeElement === searchInput.value) {
focusRafId = null
return
}
}
focusRafId = requestAnimationFrame(tryFocus)
}
focusRafId = requestAnimationFrame(tryFocus)
} else if (focusRafId !== null) {
cancelAnimationFrame(focusRafId)
focusRafId = null
}
})
function closeQuickActions() {
baseStore.setQuickActionsActive(false)
}
@ -297,7 +338,7 @@ const currentProject = computed(() => {
if (Object.keys(baseStore.currentProject).length === 0 || isSavedFilter(baseStore.currentProject)) {
return null
}
return baseStore.currentProject
})
@ -454,29 +495,66 @@ function search() {
}
const searchInput = ref<HTMLElement | null>(null)
const quickActionsCard = ref<HTMLElement | null>(null)
const QUICK_ENTRY_WIDTH = 680
const quickEntryMaxHeight = Math.round(window.screen.availHeight * 0.7)
let resizeObserver: ResizeObserver | null = null
if (isQuickAddMode) {
watch(quickActionsCard, (el) => {
resizeObserver?.disconnect()
if (!el) return
resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
const height = Math.min(
Math.ceil(entry.borderBoxSize[0].blockSize),
quickEntryMaxHeight,
)
window.quickEntry?.resize?.(QUICK_ENTRY_WIDTH, height)
}
})
resizeObserver.observe(el)
})
onBeforeUnmount(() => {
resizeObserver?.disconnect()
})
}
async function doAction(type: ACTION_TYPE, item: DoAction) {
switch (type) {
case ACTION_TYPE.PROJECT:
closeQuickActions()
await router.push({
name: 'project.index',
params: {projectId: (item as DoAction<IProject>).id},
})
if (!isQuickAddMode) {
await router.push({
name: 'project.index',
params: {projectId: (item as DoAction<IProject>).id},
})
}
break
case ACTION_TYPE.TASK:
if (isQuickAddMode) {
const channel = new BroadcastChannel('vikunja-task-updates')
channel.postMessage({type: 'task-created-open', taskId: (item as DoAction<ITask>).id})
channel.close()
window.quickEntry?.showMainWindow()
} else {
await router.push({
name: 'task.detail',
params: {id: (item as DoAction<ITask>).id},
})
}
closeQuickActions()
await router.push({
name: 'task.detail',
params: {id: (item as DoAction<ITask>).id},
})
break
case ACTION_TYPE.TEAM:
closeQuickActions()
await router.push({
name: 'teams.edit',
params: {id: (item as DoAction<ITeam>).id},
})
if (!isQuickAddMode) {
await router.push({
name: 'teams.edit',
params: {id: (item as DoAction<ITeam>).id},
})
}
break
case ACTION_TYPE.CMD:
query.value = ''
@ -495,18 +573,29 @@ async function doAction(type: ACTION_TYPE, item: DoAction) {
}
}
let openTaskAfterCreate = false
function onEnter(event: KeyboardEvent) {
openTaskAfterCreate = event.ctrlKey || event.metaKey
doCmd()
}
async function doCmd() {
if (results.value.length === 1 && results.value[0].items.length === 1) {
const result = results.value[0]
doAction(result.type, result.items[0])
openTaskAfterCreate = false
return
}
if (selectedCmd.value === null || query.value === '') {
openTaskAfterCreate = false
return
}
closeQuickActions()
if (!isQuickAddMode) {
closeQuickActions()
}
await selectedCmd.value.action()
}
@ -520,6 +609,22 @@ async function newTask() {
projectId,
})
success({message: t('task.createSuccess')})
if (isQuickAddMode) {
const channel = new BroadcastChannel('vikunja-task-updates')
const type = openTaskAfterCreate ? 'task-created-open' : 'task-created'
channel.postMessage({type, taskId: task.id})
channel.close()
if (openTaskAfterCreate) {
window.quickEntry?.showMainWindow()
}
closeQuickActions()
openTaskAfterCreate = false
return
}
await router.push({name: 'task.detail', params: {id: task.id}})
}
@ -602,6 +707,13 @@ function reset() {
inset-block-start: 3rem;
transform: translate(-50%, 0);
}
&.is-quick-add-mode {
padding: 0;
margin: 0;
border: none;
box-shadow: none;
}
}
.action-input {
@ -611,7 +723,7 @@ function reset() {
.input {
border: 0;
font-size: 1.5rem;
@media screen and (max-width: $tablet) {
padding-inline-end: .25rem;
}
@ -624,7 +736,7 @@ function reset() {
.close {
padding: 0 1rem 0 .5rem;
font-size: 1.5rem;
@media screen and (min-width: $tablet + 1) {
display: none;
}
@ -675,14 +787,14 @@ function reset() {
&:active {
background: var(--grey-100);
}
.saved-filter-icon {
font-size: .75rem;
inline-size: .75rem;
margin-inline-end: .25rem;
color: var(--grey-400)
}
&:has(.saved-filter-icon) {
display: inline-flex;
align-items: center;

View File

@ -0,0 +1,61 @@
<template>
<div class="quick-add-overlay">
<QuickActions />
</div>
</template>
<script setup lang="ts">
import {watch, onMounted} from 'vue'
import QuickActions from '@/components/quick-actions/QuickActions.vue'
import {useBaseStore} from '@/stores/base'
const baseStore = useBaseStore()
onMounted(() => {
baseStore.setQuickActionsActive(true)
})
// When QuickActions closes (Escape, task created, etc.), tell Electron to hide the window
watch(() => baseStore.quickActionsActive, (active) => {
if (!active) {
if (typeof window.quickEntry?.close === 'function') {
window.quickEntry.close()
}
}
})
</script>
<style lang="scss" scoped>
.quick-add-overlay {
position: fixed;
inset: 0;
display: flex;
align-items: flex-start;
justify-content: center;
overflow: hidden;
}
</style>
<style lang="scss">
// In quick-add mode the Electron window IS the overlay hide the modal
// backdrop, disable scroll, and collapse all extra spacing so the input
// fills the window edge-to-edge.
.quick-add-overlay {
dialog.modal-dialog {
background: transparent;
}
dialog.modal-dialog::backdrop {
background: transparent;
}
.modal-container {
overflow: hidden;
}
dialog.modal-dialog .close {
display: none;
}
}
</style>

View File

@ -291,13 +291,12 @@ async function deleteSharable() {
await stuffService.delete(stuffModel)
showDeleteModal.value = false
for (const i in sharables.value) {
if (
(sharables.value[i].username === stuffModel.username && props.shareType === 'user') ||
(sharables.value[i].id === stuffModel.teamId && props.shareType === 'team')
) {
sharables.value.splice(i, 1)
}
const idx = sharables.value.findIndex(s =>
(props.shareType === 'user' && s.username === stuffModel.username) ||
(props.shareType === 'team' && s.id === stuffModel.teamId),
)
if (idx !== -1) {
sharables.value.splice(idx, 1)
}
success({
message: t('project.share.userTeam.removeSuccess', {
@ -344,15 +343,15 @@ async function toggleType(sharable) {
}
const r = await stuffService.update(stuffModel)
for (const i in sharables.value) {
for (const sharableEntry of sharables.value) {
if (
(sharables.value[i].username ===
(sharableEntry.username ===
stuffModel.username &&
props.shareType === 'user') ||
(sharables.value[i].id === stuffModel.teamId &&
(sharableEntry.id === stuffModel.teamId &&
props.shareType === 'team')
) {
sharables.value[i].permission = r.permission
sharableEntry.permission = r.permission
}
}
success({message: t('project.share.userTeam.updatedSuccess', {type: shareTypeName.value})})

View File

@ -95,7 +95,7 @@
<Icon icon="trash-alt" />
</BaseButton>
<BaseButton
v-if="editEnabled && canPreview(a)"
v-if="editEnabled && canPreviewImage(a)"
v-tooltip="task.coverImageAttachmentId === a.id
? $t('task.attachment.unsetAsCover')
: $t('task.attachment.setAsCover')"
@ -168,6 +168,19 @@
alt=""
>
</Modal>
<!-- Attachment PDF modal -->
<Modal
:enabled="attachmentPdfBlobUrl !== null"
:wide="true"
@close="attachmentPdfBlobUrl = null"
>
<iframe
v-if="attachmentPdfBlobUrl"
:src="attachmentPdfBlobUrl"
class="pdf-preview-iframe"
/>
</Modal>
</div>
</template>
@ -180,7 +193,7 @@ import ProgressBar from '@/components/misc/ProgressBar.vue'
import BaseButton from '@/components/base/BaseButton.vue'
import AttachmentService from '@/services/attachment'
import {canPreview} from '@/models/attachment'
import {canPreviewImage, canPreviewPdf} from '@/models/attachment'
import type {IAttachment} from '@/modelTypes/IAttachment'
import type {ITask} from '@/modelTypes/ITask'
@ -365,10 +378,13 @@ async function deleteAttachment() {
}
const attachmentImageBlobUrl = ref<string | null>(null)
const attachmentPdfBlobUrl = ref<string | null>(null)
async function viewOrDownload(attachment: IAttachment) {
if (canPreview(attachment)) {
if (canPreviewImage(attachment)) {
attachmentImageBlobUrl.value = await attachmentService.getBlobUrl(attachment)
} else if (canPreviewPdf(attachment)) {
attachmentPdfBlobUrl.value = await attachmentService.getBlobUrl(attachment)
} else {
downloadAttachment(attachment)
}
@ -576,6 +592,15 @@ defineExpose({
block-size: 100%;
}
.pdf-preview-iframe {
inline-size: 100%;
max-inline-size: calc(100% - 4rem);
block-size: calc(100vh - 40px);
border: none;
margin: 0 auto;
display: block;
}
.is-task-cover {
background: var(--primary);
color: var(--white);

View File

@ -0,0 +1,201 @@
<template>
<template v-if="kanbanView">
<span class="has-text-grey-light"> &gt; </span>
<template v-if="canWrite">
<Dropdown>
<template #trigger="{toggleOpen}">
<BaseButton
class="bucket-name"
@click="toggleOpen"
>
{{ currentBucketTitle }}
<Icon
icon="pencil-alt"
class="change-indicator"
/>
</BaseButton>
</template>
<DropdownItem
v-for="bucket in buckets"
:key="bucket.id"
:class="{'is-active': currentBucket?.id === bucket.id}"
@click="changeBucket(bucket)"
>
{{ bucket.title }}
</DropdownItem>
</Dropdown>
</template>
<span
v-else
class="bucket-name"
>
{{ currentBucketTitle }}
</span>
</template>
</template>
<script lang="ts" setup>
import {ref, computed, watch} from 'vue'
import {useI18n} from 'vue-i18n'
import type {ITask} from '@/modelTypes/ITask'
import type {IBucket} from '@/modelTypes/IBucket'
import {PROJECT_VIEW_KINDS} from '@/modelTypes/IProjectView'
import {useProjectStore} from '@/stores/projects'
import {useKanbanStore} from '@/stores/kanban'
import {useBaseStore} from '@/stores/base'
import BaseButton from '@/components/base/BaseButton.vue'
import Dropdown from '@/components/misc/Dropdown.vue'
import DropdownItem from '@/components/misc/DropdownItem.vue'
import BucketService from '@/services/bucket'
import TaskBucketService from '@/services/taskBucket'
import TaskBucketModel from '@/models/taskBucket'
import {success} from '@/message'
const props = defineProps<{
task: ITask
canWrite: boolean
}>()
const emit = defineEmits<{
'update:task': [task: ITask]
}>()
const {t} = useI18n({useScope: 'global'})
const projectStore = useProjectStore()
const kanbanStore = useKanbanStore()
const baseStore = useBaseStore()
const project = computed(() => projectStore.projects[props.task.projectId])
// If the project has exactly one manual kanban view, always use it.
// If there are multiple, only show the selector when the active view is one of them.
const kanbanView = computed(() => {
if (!project.value?.views) {
return null
}
const manualKanbanViews = project.value.views.filter(
v => v.viewKind === PROJECT_VIEW_KINDS.KANBAN
&& v.bucketConfigurationMode === 'manual',
)
if (manualKanbanViews.length === 1) {
return manualKanbanViews[0]
}
if (manualKanbanViews.length > 1) {
const activeViewId = baseStore.currentProjectViewId
return manualKanbanViews.find(v => v.id === activeViewId) || null
}
return null
})
const buckets = ref<IBucket[]>([])
watch(
() => kanbanView.value,
async (view) => {
if (!view) {
buckets.value = []
return
}
const bucketService = new BucketService()
try {
buckets.value = await bucketService.getAll({
projectId: props.task.projectId,
projectViewId: view.id,
} as IBucket)
} catch (e) {
console.error('Failed to load buckets:', e)
}
},
{immediate: true},
)
const currentBucket = computed(() => {
if (!kanbanView.value) {
return undefined
}
return props.task.buckets?.find(b => b.projectViewId === kanbanView.value.id)
})
const currentBucketTitle = computed(() => {
return currentBucket.value?.title || t('task.detail.noBucket')
})
async function changeBucket(bucket: IBucket) {
if (!kanbanView.value || currentBucket.value?.id === bucket.id) {
return
}
const taskBucketService = new TaskBucketService()
const updatedTaskBucket = await taskBucketService.update(new TaskBucketModel({
taskId: props.task.id,
bucketId: bucket.id,
projectViewId: kanbanView.value.id,
projectId: props.task.projectId,
}))
const updatedBuckets = (props.task.buckets || []).map(b => {
if (b.projectViewId === kanbanView.value.id) {
return {...bucket}
}
return b
})
if (!updatedBuckets.find(b => b.projectViewId === kanbanView.value.id)) {
updatedBuckets.push({...bucket})
}
kanbanStore.moveTaskToBucket(props.task, bucket.id)
// Only pick up done state from the response since moving to/from the
// done bucket can toggle it. Spreading the full response task would
// overwrite fields like maxPermission that are not part of this endpoint.
const updatedTask = {
...props.task,
done: updatedTaskBucket.task?.done ?? props.task.done,
doneAt: updatedTaskBucket.task?.doneAt ?? props.task.doneAt,
buckets: updatedBuckets,
bucketId: bucket.id,
}
emit('update:task', updatedTask)
success({message: t('task.detail.bucketChangedSuccess')})
}
</script>
<style lang="scss" scoped>
.bucket-name {
color: var(--grey-800);
&:hover {
color: var(--primary);
}
}
.change-indicator {
font-size: .75em;
margin-inline-start: .25rem;
color: var(--grey-400);
}
:deep(.dropdown) {
display: inline;
}
:deep(.dropdown-trigger) {
display: inline;
padding: 0;
}
</style>

View File

@ -378,6 +378,7 @@ async function toggleSortOrder() {
frontendSettings: {
...authStore.settings.frontendSettings,
commentSortOrder: newOrder,
quickAddDefaultReminders: [...(authStore.settings.frontendSettings.quickAddDefaultReminders ?? [])],
},
},
showMessage: false,
@ -477,7 +478,7 @@ async function editComment() {
commentEdit.taskId = props.taskId
try {
const comment = await taskCommentService.update(commentEdit)
for (const c in comments.value) {
for (let c = 0; c < comments.value.length; c++) {
if (comments.value[c].id === commentEdit.id) {
comments.value[c] = comment
}
@ -505,12 +506,29 @@ async function deleteComment(commentToDelete: ITaskComment) {
function getCommentUrl(commentId: string) {
const baseUrl = frontendUrl.value.endsWith('/') ? frontendUrl.value.slice(0, -1) : frontendUrl.value
return `${baseUrl}${location.pathname}${location.search}#comment-${commentId}`
const url = new URL(location.pathname + location.search, baseUrl)
url.hash = `comment-${commentId}`
return url.toString()
}
</script>
<style lang="scss" scoped>
.media {
align-items: flex-start;
display: flex;
text-align: inherit;
& + .media {
border-block-start: 1px solid rgba(var(--border-rgb), 0.5);
margin-block-start: 1rem;
padding-block-start: 1rem;
}
}
.media-left {
flex-basis: auto;
flex-grow: 0;
flex-shrink: 0;
margin: 0 1rem !important;
}
@ -558,6 +576,10 @@ function getCommentUrl(commentId: string) {
}
.media-content {
flex-basis: auto;
flex-grow: 1;
flex-shrink: 1;
text-align: inherit;
inline-size: calc(100% - 48px - 2rem);
}

View File

@ -99,10 +99,9 @@ async function removeAssignee(user: IUser) {
await taskStore.removeAssignee({user: user, taskId: props.taskId})
// Remove the assignee from the project
for (const a in assignees.value) {
if (assignees.value[a].id === user.id) {
assignees.value.splice(a, 1)
}
const idx = assignees.value.findIndex(a => a.id === user.id)
if (idx !== -1) {
assignees.value.splice(idx, 1)
}
success({message: t('task.assignee.unassignSuccess')})
}

View File

@ -124,10 +124,9 @@ async function removeLabel(label: ILabel) {
await taskStore.removeLabel({label, taskId: props.taskId})
}
for (const l in labels.value) {
if (labels.value[l].id === label.id) {
labels.value.splice(l, 1) // FIXME: l should be index
}
const idx = labels.value.findIndex(l => l.id === label.id)
if (idx !== -1) {
labels.value.splice(idx, 1)
}
emit('update:modelValue', labels.value)
success({message: t('task.label.removeSuccess')})

View File

@ -6,6 +6,17 @@
alt="Attachment preview"
>
<!-- PDF icon -->
<div
v-else-if="isPdf"
class="icon-wrapper"
>
<Icon
size="6x"
icon="file-pdf"
/>
</div>
<!-- Fallback -->
<div
v-else
@ -19,10 +30,10 @@
</template>
<script setup lang="ts">
import {ref, shallowReactive, watchEffect} from 'vue'
import {computed, ref, shallowReactive, watchEffect} from 'vue'
import AttachmentService, {PREVIEW_SIZE} from '@/services/attachment'
import type {IAttachment} from '@/modelTypes/IAttachment'
import {canPreview} from '@/models/attachment'
import {canPreviewImage, canPreviewPdf} from '@/models/attachment'
const props = defineProps<{
modelValue?: IAttachment
@ -30,9 +41,10 @@ const props = defineProps<{
const attachmentService = shallowReactive(new AttachmentService())
const blobUrl = ref<string | undefined>(undefined)
const isPdf = computed(() => props.modelValue && canPreviewPdf(props.modelValue))
watchEffect(async () => {
if (props.modelValue && canPreview(props.modelValue)) {
if (props.modelValue && canPreviewImage(props.modelValue)) {
blobUrl.value = await attachmentService.getBlobUrl(props.modelValue, PREVIEW_SIZE.MD)
}
})

View File

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

View File

@ -1,101 +1,109 @@
<template>
<template v-if="mode !== 'disabled' && prefixes !== undefined">
<BaseButton
v-tooltip="$t('task.quickAddMagic.hint')"
class="icon is-small show-helper-text quick-add-magic-trigger-btn"
:aria-label="$t('task.quickAddMagic.hint')"
:class="{'is-highlighted': highlightHintIcon}"
@click="() => visible = true"
<span
v-if="isQuickAddMode"
v-tooltip="$t('task.quickAddMagic.quickEntryHint')"
class="icon is-small show-helper-text"
>
<Icon :icon="['far', 'circle-question']" />
</BaseButton>
<Modal
:enabled="visible"
transition-name="fade"
:overflow="true"
variant="hint-modal"
@close="() => visible = false"
>
<Card
class="has-no-shadow"
:title="$t('task.quickAddMagic.title')"
:show-close="true"
@close="() => visible = false"
</span>
<template v-else>
<BaseButton
v-tooltip="$t('task.quickAddMagic.hint')"
class="icon is-small show-helper-text quick-add-magic-trigger-btn"
:aria-label="$t('task.quickAddMagic.hint')"
:class="{'is-highlighted': highlightHintIcon}"
@click="open"
>
<p>{{ $t('task.quickAddMagic.intro') }}</p>
<Icon :icon="['far', 'circle-question']" />
</BaseButton>
<Modal
:enabled="visible"
:overflow="true"
variant="hint-modal"
@close="close"
>
<Card
class="has-no-shadow"
:title="$t('task.quickAddMagic.title')"
:show-close="true"
@close="close"
>
<p>{{ $t('task.quickAddMagic.intro') }}</p>
<h3>{{ $t('task.attributes.labels') }}</h3>
<p>
{{ $t('task.quickAddMagic.label1', {prefix: prefixes.label}) }}
{{ $t('task.quickAddMagic.label2') }}
{{ $t('task.quickAddMagic.multiple') }}
</p>
<p>
{{ $t('task.quickAddMagic.label3') }}
{{ $t('task.quickAddMagic.label4', {prefix: prefixes.label}) }}
</p>
<h3>{{ $t('task.attributes.labels') }}</h3>
<p>
{{ $t('task.quickAddMagic.label1', {prefix: prefixes.label}) }}
{{ $t('task.quickAddMagic.label2') }}
{{ $t('task.quickAddMagic.multiple') }}
</p>
<p>
{{ $t('task.quickAddMagic.label3') }}
{{ $t('task.quickAddMagic.label4', {prefix: prefixes.label}) }}
</p>
<h3>{{ $t('task.attributes.priority') }}</h3>
<p>
{{ $t('task.quickAddMagic.priority1', {prefix: prefixes.priority}) }}
{{ $t('task.quickAddMagic.priority2') }}
</p>
<h3>{{ $t('task.attributes.priority') }}</h3>
<p>
{{ $t('task.quickAddMagic.priority1', {prefix: prefixes.priority}) }}
{{ $t('task.quickAddMagic.priority2') }}
</p>
<h3>{{ $t('task.attributes.assignees') }}</h3>
<p>
{{ $t('task.quickAddMagic.assignees', {prefix: prefixes.assignee}) }}
{{ $t('task.quickAddMagic.multiple') }}
</p>
<h3>{{ $t('task.attributes.assignees') }}</h3>
<p>
{{ $t('task.quickAddMagic.assignees', {prefix: prefixes.assignee}) }}
{{ $t('task.quickAddMagic.multiple') }}
</p>
<h3>{{ $t('quickActions.projects') }}</h3>
<p>
{{ $t('task.quickAddMagic.project1', {prefix: prefixes.project}) }}
{{ $t('task.quickAddMagic.project2') }}
</p>
<p>
{{ $t('task.quickAddMagic.project3') }}
{{ $t('task.quickAddMagic.project4', {prefix: prefixes.project}) }}
</p>
<h3>{{ $t('quickActions.projects') }}</h3>
<p>
{{ $t('task.quickAddMagic.project1', {prefix: prefixes.project}) }}
{{ $t('task.quickAddMagic.project2') }}
</p>
<p>
{{ $t('task.quickAddMagic.project3') }}
{{ $t('task.quickAddMagic.project4', {prefix: prefixes.project}) }}
</p>
<h3>{{ $t('task.quickAddMagic.dateAndTime') }}</h3>
<p>
{{ $t('task.quickAddMagic.date') }}
</p>
<ul>
<!-- Not localized because these only work in english -->
<li>Today</li>
<li>Tonight</li>
<li>Tomorrow</li>
<li>Next monday</li>
<li>This weekend</li>
<li>Later this week</li>
<li>Later next week</li>
<li>Next week</li>
<li>Next month</li>
<li>End of month</li>
<li>In 5 days [hours/weeks/months]</li>
<li>Tuesday ({{ $t('task.quickAddMagic.dateWeekday') }})</li>
<li>02/17/2021</li>
<li>2021-02-17</li>
<li>17.02.2021</li>
<li>Feb 17 ({{ $t('task.quickAddMagic.dateCurrentYear') }})</li>
<li>17th ({{ $t('task.quickAddMagic.dateNth', {day: '17'}) }})</li>
</ul>
<p>{{ $t('task.quickAddMagic.dateTime', {time: 'at 17:00', timePM: '5pm'}) }}</p>
<h3>{{ $t('task.quickAddMagic.dateAndTime') }}</h3>
<p>
{{ $t('task.quickAddMagic.date') }}
</p>
<ul>
<!-- Not localized because these only work in english -->
<li>Today</li>
<li>Tonight</li>
<li>Tomorrow</li>
<li>Next monday</li>
<li>This weekend</li>
<li>Later this week</li>
<li>Later next week</li>
<li>Next week</li>
<li>Next month</li>
<li>End of month</li>
<li>In 5 days [hours/weeks/months]</li>
<li>Tuesday ({{ $t('task.quickAddMagic.dateWeekday') }})</li>
<li>02/17/2021</li>
<li>2021-02-17</li>
<li>17.02.2021</li>
<li>Feb 17 ({{ $t('task.quickAddMagic.dateCurrentYear') }})</li>
<li>17th ({{ $t('task.quickAddMagic.dateNth', {day: '17'}) }})</li>
</ul>
<p>{{ $t('task.quickAddMagic.dateTime', {time: 'at 17:00', timePM: '5pm'}) }}</p>
<h3>{{ $t('task.quickAddMagic.repeats') }}</h3>
<p>{{ $t('task.quickAddMagic.repeatsDescription', {suffix: 'every {amount} {type}'}) }}</p>
<p>{{ $t('misc.forExample') }}</p>
<ul>
<!-- Not localized because these only work in english -->
<li>Every day</li>
<li>Every 3 days</li>
<li>Every week</li>
<li>Every 2 weeks</li>
<li>Every month</li>
</ul>
</Card>
</Modal>
<h3>{{ $t('task.quickAddMagic.repeats') }}</h3>
<p>{{ $t('task.quickAddMagic.repeatsDescription', {suffix: 'every {amount} {type}'}) }}</p>
<p>{{ $t('misc.forExample') }}</p>
<ul>
<!-- Not localized because these only work in english -->
<li>Every day</li>
<li>Every 3 days</li>
<li>Every week</li>
<li>Every 2 weeks</li>
<li>Every month</li>
</ul>
</Card>
</Modal>
</template>
</template>
</template>
@ -106,17 +114,34 @@ import BaseButton from '@/components/base/BaseButton.vue'
import {PREFIXES} from '@/modules/quickAddMagic'
import {useAuthStore} from '@/stores/auth'
import {useQuickAddMode} from '@/composables/useQuickAddMode'
defineProps<{
highlightHintIcon?: boolean,
}>()
const emit = defineEmits<{
opened: []
closed: []
}>()
const authStore = useAuthStore()
const {isQuickAddMode} = useQuickAddMode()
const visible = ref(false)
const mode = computed(() => authStore.settings.frontendSettings.quickAddMagicMode)
const prefixes = computed(() => PREFIXES[mode.value])
function open() {
visible.value = true
emit('opened')
}
function close() {
visible.value = false
emit('closed')
}
</script>
<style lang="scss" scoped>

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