Merge remote-tracking branch 'upstream/main' into auto-redirect-oidc-login
This commit is contained in:
commit
066791ec00
|
|
@ -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.
|
||||
|
|
@ -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`).
|
||||
|
|
@ -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. 2–4 labels is typical, occasionally up to 6.
|
||||
3. `concern/*` is additive — it describes a cross-cutting quality (UX polish, performance, a11y, regression) on top of the feature area.
|
||||
4. `integration/*` applies only when the item is about connecting to a *specific third-party system* (Slack, Gotify, Apprise, external webhooks, WeKan import, Todoist import, add-task-from-email, MCP, etc.).
|
||||
- CalDAV is its own `area/caldav` — do NOT also tag `integration/*`.
|
||||
- Generic webhook infrastructure is `area/webhooks`; a PR adding Slack delivery is `area/webhooks` + `integration/outbound`.
|
||||
5. `db/mysql`, `db/postgres`, `db/sqlite` ONLY when the item is explicitly engine-specific (e.g. "fails on MySQL 8"). General DB issues get `area/database` with no engine tag.
|
||||
6. `concern/regression` ONLY if the body explicitly says it worked in a prior version and is broken now.
|
||||
7. Do NOT invent labels. Only use names from the taxonomy below — anything else will be discarded.
|
||||
|
||||
# Taxonomy
|
||||
|
||||
The following labels are available. Each line is `label-name — description`. Pick only from this list.
|
||||
|
||||
{{TAXONOMY}}
|
||||
|
||||
# Examples
|
||||
|
||||
Input:
|
||||
TITLE: SQL syntax error on MySQL due to CAST in is_archived computation
|
||||
BODY: After upgrading to 2.3.0 I get SQL syntax errors on MySQL 8. Worked fine on 2.2.x.
|
||||
Output:
|
||||
["area/database", "db/mysql", "concern/regression"]
|
||||
|
||||
Input:
|
||||
TITLE: feat: add Slack webhook support
|
||||
BODY: Adds outbound Slack notifications when tasks change.
|
||||
Output:
|
||||
["area/webhooks", "area/notifications", "integration/outbound"]
|
||||
|
||||
Input:
|
||||
TITLE: Mobile: "Mark task done" should be easier to find
|
||||
BODY: The checkbox is too small on phones.
|
||||
Output:
|
||||
["area/mobile", "area/task-editor", "concern/ux"]
|
||||
|
|
@ -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 2–4 is typical, 6 is the ceiling.
|
||||
const toApply = valid.slice(0, 6);
|
||||
|
||||
if (toApply.length === 0) {
|
||||
core.info('No valid labels selected — leaving item unlabeled for human triage.');
|
||||
return;
|
||||
}
|
||||
|
||||
const number =
|
||||
context.payload.issue?.number ?? context.payload.pull_request.number;
|
||||
|
||||
await github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: number,
|
||||
labels: toApply,
|
||||
});
|
||||
|
||||
core.info(`Applied labels to #${number}: ${toApply.join(', ')}`);
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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/..*
|
||||
|
|
|
|||
15
AGENTS.md
15
AGENTS.md
|
|
@ -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
|
||||
|
||||
|
|
|
|||
287
CHANGELOG.md
287
CHANGELOG.md
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
[](https://github.com/go-vikunja/vikunja/actions/workflows/ci.yml)
|
||||
[](LICENSE)
|
||||
[](https://vikunja.io/docs/installing)
|
||||
[](https://vikunja.io/docs/installing)
|
||||
[](https://hub.docker.com/r/vikunja/vikunja/)
|
||||
[](https://try.vikunja.io/api/v1/docs)
|
||||
[](https://goreportcard.com/report/code.vikunja.io/api)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 |
576
desktop/main.js
576
desktop/main.js
|
|
@ -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()
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
})
|
||||
|
|
@ -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,
|
||||
})
|
||||
|
|
@ -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{}
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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<{
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -86,7 +86,6 @@
|
|||
|
||||
<Modal
|
||||
:enabled="showHowItWorks"
|
||||
transition-name="fade"
|
||||
:overflow="true"
|
||||
variant="hint-modal"
|
||||
@close="() => showHowItWorks = false"
|
||||
|
|
|
|||
|
|
@ -64,7 +64,6 @@
|
|||
|
||||
<Modal
|
||||
:enabled="showHowItWorks"
|
||||
transition-name="fade"
|
||||
:overflow="true"
|
||||
variant="hint-modal"
|
||||
@close="() => showHowItWorks = false"
|
||||
|
|
|
|||
|
|
@ -751,6 +751,7 @@ onUnmounted(() => {
|
|||
<style scoped lang="scss">
|
||||
.gantt-container {
|
||||
overflow-x: auto;
|
||||
min-inline-size: 100%;
|
||||
}
|
||||
|
||||
.gantt-chart-wrapper {
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@
|
|||
{{ $t('home.addToHomeScreen') }}
|
||||
</p>
|
||||
<BaseButton
|
||||
:aria-label="$t('misc.closeBanner')"
|
||||
class="hide-button"
|
||||
@click="() => hideMessage = true"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
<RouterLink
|
||||
:to="{ name: 'home' }"
|
||||
class="logo-link"
|
||||
:aria-label="$t('navigation.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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@
|
|||
<RouterLink
|
||||
:to="{name: 'home'}"
|
||||
class="logo"
|
||||
:aria-label="$t('navigation.overview')"
|
||||
:aria-label="$t('navigation.home')"
|
||||
>
|
||||
<Logo
|
||||
width="164"
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@
|
|||
type="color"
|
||||
:list="colorListID"
|
||||
:class="{'is-empty': isEmpty}"
|
||||
:aria-label="$t('input.projectColor')"
|
||||
>
|
||||
<svg
|
||||
v-show="isEmpty"
|
||||
|
|
|
|||
|
|
@ -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<{
|
||||
|
|
|
|||
|
|
@ -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>')
|
||||
})
|
||||
})
|
||||
|
|
@ -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>
|
||||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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>
|
||||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
@ -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>
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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,
|
||||
}),
|
||||
]
|
||||
},
|
||||
})
|
||||
|
|
@ -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,
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
<Modal
|
||||
:overflow="true"
|
||||
:wide="wide"
|
||||
:aria-label="title"
|
||||
@close="$router.back()"
|
||||
>
|
||||
<Card
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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<{
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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 -->
|
||||
|
|
|
|||
|
|
@ -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<{
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@
|
|||
</XButton>
|
||||
<Modal
|
||||
:enabled="modalOpen"
|
||||
transition-name="fade"
|
||||
:overflow="true"
|
||||
variant="hint-modal"
|
||||
@close="() => modalOpen = false"
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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})})
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,201 @@
|
|||
<template>
|
||||
<template v-if="kanbanView">
|
||||
<span class="has-text-grey-light"> > </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>
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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')})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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')})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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')"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Reference in New Issue