Compare commits

...
48 Commits
Author SHA1 Message Date
robin 702bf9b8b8 Merge pull request 'Re-order lists' (#27) from dev into main
Build and Deploy / sync-dev (push) Skipped
Build and Deploy / build-and-deploy (push) Failing after 1m4s
2026-09-11 20:08:56 +02:00
robin df0bcee42b feat: re-order lists 2026-09-11 19:14:25 +02:00
robin 0dea32191b Merge pull request 'CI now runs when opening a PR' (#25) from dev into main 2026-09-09 17:11:02 +02:00
robin 06583f1a5a feat: added PR checks (lint & test) and removed them from the deploy step 2026-09-09 17:00:54 +02:00
robin 66f99e3a4a Merge pull request 'CI now syncs dev to main' (#24) from dev into main 2026-09-06 17:36:14 +02:00
robin 608199b8b8 fix: ci now syncs dev to main 2026-09-06 17:34:10 +02:00
robin 2144cb4bf1 Merge pull request 'Added deploy.yml for automatically building and deploying after merging to main' (#23) from dev into main 2026-09-06 16:35:15 +02:00
robin e976baa8fb feat: added deploy.yml for automatically building and deploying after merging to main 2026-09-06 16:34:03 +02:00
robin b7e273afd1 Merge pull request 'Added cv view (not indexed)' (#21) from dev into main 2026-09-05 10:46:11 +02:00
robin 9136381936 feat: Added cv view (not indexed) 2026-09-05 10:45:26 +02:00
robin 17585dc2b6 Merge pull request 'Added exercises; Lists improvements' (#20) from dev into main 2026-09-04 12:31:29 +02:00
robin 7185730a87 feat: Added first iteration of exercises view 2026-09-04 12:29:25 +02:00
robin 47034e111c fix: List item UI improvements 2026-09-04 10:35:06 +02:00
robin 73663543fd Merge pull request 'Visiting account view no longer fetches /lists' (#19) from dev into main 2026-09-01 17:26:20 +02:00
robin c0dc4a2fe0 fix: visiting account view no longer fetches /lists 2026-09-01 17:25:39 +02:00
robin 20a000319d Merge pull request 'Always display invites status and new API information' (#18) from dev into main 2026-09-01 17:06:25 +02:00
robin 8df92a470e feat: use new version endpoint to display api information 2026-09-01 17:03:23 +02:00
robin 16405ec5f7 feat: use new invite status endpoint to display different invite type counts 2026-09-01 16:34:13 +02:00
robin 6a1624c616 Merge pull request 'List improvements' (#17) from dev into main 2026-09-01 16:00:12 +02:00
robin f35009822f fix: use new list item routes 2026-09-01 15:57:09 +02:00
robin 6196556012 fix: removed user_ids from create list payload; improved share button on invites 2026-09-01 15:29:31 +02:00
robin 3998679810 Merge pull request 'Added pagination to invites' (#16) from dev into main 2026-09-01 11:14:51 +02:00
robin 14563e5204 feat: pagination for invites 2026-09-01 11:13:33 +02:00
robin a30101f841 fix: styling for AccountsView on desktop and mobile 2026-09-01 08:58:38 +02:00
robin 9abd66fdba fix: viewport will no longer overflow in width 2026-09-01 08:44:39 +02:00
robin 936dbe4e3c Merge pull request 'Invite page will now no longer redirect to login' (#15) from dev into main 2026-08-31 22:42:23 +02:00
robin 1bacc58648 fix: invite links now will not redirect to login 2026-08-31 22:41:50 +02:00
robin 125a7bd6c5 Merge pull request 'Redesign fixes' (#14) from dev into main 2026-08-31 22:27:12 +02:00
robin 0b6a9231f9 fix: invite panel width 2026-08-31 22:26:14 +02:00
robin 4edfb64723 fix: dot color for sync status 2026-08-31 22:07:29 +02:00
robin 474f11a8ca Merge pull request 'Redesign' (#13) from dev into main 2026-08-31 22:01:48 +02:00
robin 182704b9cd feat: reverted "sync" stamps back to colored dots 2026-08-31 22:00:49 +02:00
robin 564c7c6338 fix: ellipse-trim invite codes 2026-08-31 21:56:13 +02:00
robin 5d0a2cfded feat: re-design 2026-08-31 21:53:59 +02:00
robin 327cfc1c5d Merge pull request 'Invite system and user registration' (#12) from dev into main 2026-08-31 21:37:37 +02:00
robin 2c45478478 feat: registration with invite codes 2026-08-31 21:35:37 +02:00
robin 68991a3722 feat: user invites in account panel 2026-08-31 21:08:47 +02:00
robin 73ef2b625d fix: updated /user/password route 2026-08-31 20:08:23 +02:00
robin 6b416eeb02 fix: added missing space 2026-08-31 19:36:00 +02:00
robin 69fe3651df Merge pull request 'Abstracted modal's common code to BaseModal' (#11) from dev into main 2026-08-31 18:11:42 +02:00
robin 10bf01046c fix: abstracted modal's common code to BaseModal 2026-08-31 18:10:57 +02:00
robin 946399c16c Merge pull request 'Add item input now doubles as search bar for the list' (#10) from dev into main 2026-08-31 18:03:23 +02:00
robin ca0317a01a feat: add item input now doubles as search bar for the list 2026-08-31 18:02:52 +02:00
robin 3369b49eb6 Merge pull request 'Added change password feature' (#9) from dev into main 2026-08-31 17:48:54 +02:00
robin 4188f4afee fix: added missing files for change password 2026-08-31 17:48:01 +02:00
robin f82dc34723 feat: added change password to about/account view 2026-08-31 17:46:37 +02:00
robin d981cde758 Merge pull request 'New icon with necessary pwa icon files' (#8) from dev into main 2026-08-31 17:46:08 +02:00
robin 46de339918 feat: new icon with necessary pwa icon files 2026-08-25 10:54:47 +02:00
63 changed files with 3957 additions and 514 deletions
+73
View File
@@ -0,0 +1,73 @@
name: Build and Deploy
# Required secrets
# DEPLOY_SSH_KEY - private key for the deploy user
# DEPLOY_HOST - production host, e.g. YOUR_PROD_HOST
# DEPLOY_USER - ssh user, e.g. deploy
# DEPLOY_PATH - destination dir, e.g. /var/www/dttmr-fe
on:
push:
branches:
- main
jobs:
build-and-deploy:
runs-on: ubuntu-latest
container:
image: node:24-bookworm
steps:
- name: Install system dependencies
run: apt-get update && apt-get install -y --no-install-recommends git openssh-client rsync ca-certificates
- name: Checkout
uses: actions/checkout@v4
- name: Install dependencies
run: npm ci
- name: Build
run: npm run build
- name: Configure SSH
env:
DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
run: |
mkdir -p ~/.ssh
printf '%s\n' "$DEPLOY_SSH_KEY" > ~/.ssh/deploy_key
chmod 600 ~/.ssh/deploy_key
ssh-keyscan -H "$DEPLOY_HOST" >> ~/.ssh/known_hosts
- name: Deploy via rsync
env:
DEPLOY_USER: ${{ secrets.DEPLOY_USER }}
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
DEPLOY_PATH: ${{ secrets.DEPLOY_PATH }}
run: |
rsync -avz --delete \
-e "ssh -i ~/.ssh/deploy_key -o UserKnownHostsFile=~/.ssh/known_hosts" \
dist/ "$DEPLOY_USER@$DEPLOY_HOST:$DEPLOY_PATH"
sync-dev:
needs: build-and-deploy
runs-on: ubuntu-latest
container:
image: node:24-bookworm
permissions:
contents: write
steps:
- name: Install system dependencies
run: apt-get update && apt-get install -y --no-install-recommends git
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Fast-forward dev to main
run: |
git fetch origin dev:dev
git checkout dev
git merge --ff-only origin/main
git push origin dev
+24
View File
@@ -0,0 +1,24 @@
name: PR Checks
on:
pull_request:
branches:
- main
jobs:
lint-and-test:
runs-on: ubuntu-latest
container:
image: node:24-bookworm
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install dependencies
run: npm ci
- name: Lint
run: npm run lint
- name: Run unit tests
run: npm run test:unit
+7 -1
View File
@@ -5,12 +5,18 @@
<link rel="icon" href="/favicon.ico">
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<meta name="theme-color" content="#0a0e1a">
<meta name="theme-color" content="#12161d">
<meta name="color-scheme" content="dark">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="dttmr">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link
href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=IBM+Plex+Sans:wght@400;500;600;700&family=IBM+Plex+Serif:wght@500;600;700&family=Special+Elite&display=swap"
rel="stylesheet"
>
<title>dttmr</title>
</head>
<body>
+9 -9
View File
@@ -33,7 +33,7 @@
"prettier": "3.9.5",
"typescript": "~6.0.0",
"vite": "^8.1.5",
"vite-plugin-pwa": "^1.2.0",
"vite-plugin-pwa": "^1.3.0",
"vite-plugin-vue-devtools": "^8.1.5",
"vitest": "^4.1.10",
"vue-eslint-parser": "^10.4.1",
@@ -9563,17 +9563,17 @@
}
},
"node_modules/vite-plugin-pwa": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/vite-plugin-pwa/-/vite-plugin-pwa-1.2.0.tgz",
"integrity": "sha512-a2xld+SJshT9Lgcv8Ji4+srFJL4k/1bVbd1x06JIkvecpQkwkvCncD1+gSzcdm3s+owWLpMJerG3aN5jupJEVw==",
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/vite-plugin-pwa/-/vite-plugin-pwa-1.3.0.tgz",
"integrity": "sha512-c5kMgN+ITrOtHXp8PAtk2uOIEea6XjP/unCGxOWWBzQ6qa65qj/awHg0wf+QF9E/2u9vh86LqxPwzEPNbM2r5A==",
"dev": true,
"license": "MIT",
"dependencies": {
"debug": "^4.3.6",
"pretty-bytes": "^6.1.1",
"tinyglobby": "^0.2.10",
"workbox-build": "^7.4.0",
"workbox-window": "^7.4.0"
"workbox-build": "^7.4.1",
"workbox-window": "^7.4.1"
},
"engines": {
"node": ">=16.0.0"
@@ -9583,9 +9583,9 @@
},
"peerDependencies": {
"@vite-pwa/assets-generator": "^1.0.0",
"vite": "^3.1.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0",
"workbox-build": "^7.4.0",
"workbox-window": "^7.4.0"
"vite": "^3.1.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0",
"workbox-build": "^7.4.1",
"workbox-window": "^7.4.1"
},
"peerDependenciesMeta": {
"@vite-pwa/assets-generator": {
+1 -1
View File
@@ -41,7 +41,7 @@
"prettier": "3.9.5",
"typescript": "~6.0.0",
"vite": "^8.1.5",
"vite-plugin-pwa": "^1.2.0",
"vite-plugin-pwa": "^1.3.0",
"vite-plugin-vue-devtools": "^8.1.5",
"vitest": "^4.1.10",
"vue-eslint-parser": "^10.4.1",
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 KiB

After

Width:  |  Height:  |  Size: 23 KiB

+29
View File
@@ -0,0 +1,29 @@
<svg width="100%" viewBox="0 0 680 490" role="img" xmlns="http://www.w3.org/2000/svg" style=""><title style="fill:rgb(0, 0, 0);stroke:none;color:rgb(11, 11, 11);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:&quot;anthropic-sans&quot;, -apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;, sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto">App icon concept in deep ink blue: a checkmark that doubles as a dumbbell</title><desc style="fill:rgb(0, 0, 0);stroke:none;color:rgb(11, 11, 11);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:&quot;anthropic-sans&quot;, -apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;, sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto">A rounded deep navy square containing a white checkmark whose upward stroke carries two barbell plates, shown at large size and at 96, 64 and 40 pixels.</desc>
<g transform="translate(210 40)" style="fill:rgb(0, 0, 0);stroke:none;color:rgb(11, 11, 11);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:&quot;anthropic-sans&quot;, -apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;, sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto">
<rect x="0" y="0" width="260" height="260" rx="60" fill="#13294B" style="fill:rgb(19, 41, 75);stroke:none;color:rgb(11, 11, 11);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:&quot;anthropic-sans&quot;, -apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;, sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto"/>
<path d="M60 132 L98 170 L196 66" fill="none" stroke="#FFFFFF" stroke-width="26" stroke-linecap="round" stroke-linejoin="round" style="fill:none;stroke:rgb(255, 255, 255);color:rgb(11, 11, 11);stroke-width:26px;stroke-linecap:round;stroke-linejoin:round;opacity:1;font-family:&quot;anthropic-sans&quot;, -apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;, sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto"/>
<rect x="120" y="110" width="14" height="58" rx="7" fill="#FFFFFF" transform="rotate(-45 127 139)" style="fill:rgb(255, 255, 255);stroke:none;color:rgb(11, 11, 11);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:&quot;anthropic-sans&quot;, -apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;, sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto"/>
<rect x="189" y="34" width="14" height="64" rx="7" fill="#FFFFFF" transform="rotate(-45 196 66)" style="fill:rgb(255, 255, 255);stroke:none;color:rgb(11, 11, 11);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:&quot;anthropic-sans&quot;, -apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;, sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto"/>
</g>
<g transform="translate(200 340) scale(0.369)" style="fill:rgb(0, 0, 0);stroke:none;color:rgb(11, 11, 11);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:&quot;anthropic-sans&quot;, -apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;, sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto">
<rect x="0" y="0" width="260" height="260" rx="60" fill="#13294B" style="fill:rgb(19, 41, 75);stroke:none;color:rgb(11, 11, 11);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:&quot;anthropic-sans&quot;, -apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;, sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto"/>
<path d="M60 132 L98 170 L196 66" fill="none" stroke="#FFFFFF" stroke-width="26" stroke-linecap="round" stroke-linejoin="round" style="fill:none;stroke:rgb(255, 255, 255);color:rgb(11, 11, 11);stroke-width:26px;stroke-linecap:round;stroke-linejoin:round;opacity:1;font-family:&quot;anthropic-sans&quot;, -apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;, sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto"/>
<rect x="120" y="110" width="14" height="58" rx="7" fill="#FFFFFF" transform="rotate(-45 127 139)" style="fill:rgb(255, 255, 255);stroke:none;color:rgb(11, 11, 11);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:&quot;anthropic-sans&quot;, -apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;, sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto"/>
<rect x="189" y="34" width="14" height="64" rx="7" fill="#FFFFFF" transform="rotate(-45 196 66)" style="fill:rgb(255, 255, 255);stroke:none;color:rgb(11, 11, 11);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:&quot;anthropic-sans&quot;, -apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;, sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto"/>
</g>
<g transform="translate(336 372) scale(0.246)" style="fill:rgb(0, 0, 0);stroke:none;color:rgb(11, 11, 11);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:&quot;anthropic-sans&quot;, -apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;, sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto">
<rect x="0" y="0" width="260" height="260" rx="60" fill="#13294B" style="fill:rgb(19, 41, 75);stroke:none;color:rgb(11, 11, 11);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:&quot;anthropic-sans&quot;, -apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;, sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto"/>
<path d="M60 132 L98 170 L196 66" fill="none" stroke="#FFFFFF" stroke-width="26" stroke-linecap="round" stroke-linejoin="round" style="fill:none;stroke:rgb(255, 255, 255);color:rgb(11, 11, 11);stroke-width:26px;stroke-linecap:round;stroke-linejoin:round;opacity:1;font-family:&quot;anthropic-sans&quot;, -apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;, sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto"/>
<rect x="120" y="110" width="14" height="58" rx="7" fill="#FFFFFF" transform="rotate(-45 127 139)" style="fill:rgb(255, 255, 255);stroke:none;color:rgb(11, 11, 11);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:&quot;anthropic-sans&quot;, -apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;, sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto"/>
<rect x="189" y="34" width="14" height="64" rx="7" fill="#FFFFFF" transform="rotate(-45 196 66)" style="fill:rgb(255, 255, 255);stroke:none;color:rgb(11, 11, 11);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:&quot;anthropic-sans&quot;, -apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;, sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto"/>
</g>
<g transform="translate(440 396) scale(0.154)" style="fill:rgb(0, 0, 0);stroke:none;color:rgb(11, 11, 11);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:&quot;anthropic-sans&quot;, -apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;, sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto">
<rect x="0" y="0" width="260" height="260" rx="60" fill="#13294B" style="fill:rgb(19, 41, 75);stroke:none;color:rgb(11, 11, 11);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:&quot;anthropic-sans&quot;, -apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;, sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto"/>
<path d="M60 132 L98 170 L196 66" fill="none" stroke="#FFFFFF" stroke-width="26" stroke-linecap="round" stroke-linejoin="round" style="fill:none;stroke:rgb(255, 255, 255);color:rgb(11, 11, 11);stroke-width:26px;stroke-linecap:round;stroke-linejoin:round;opacity:1;font-family:&quot;anthropic-sans&quot;, -apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;, sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto"/>
<rect x="120" y="110" width="14" height="58" rx="7" fill="#FFFFFF" transform="rotate(-45 127 139)" style="fill:rgb(255, 255, 255);stroke:none;color:rgb(11, 11, 11);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:&quot;anthropic-sans&quot;, -apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;, sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto"/>
<rect x="189" y="34" width="14" height="64" rx="7" fill="#FFFFFF" transform="rotate(-45 196 66)" style="fill:rgb(255, 255, 255);stroke:none;color:rgb(11, 11, 11);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:&quot;anthropic-sans&quot;, -apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;, sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto"/>
</g>
<text x="248" y="460" text-anchor="middle" style="fill:rgb(82, 81, 78);stroke:none;color:rgb(11, 11, 11);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:&quot;anthropic-sans&quot;, -apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;, sans-serif;font-size:12px;font-weight:400;text-anchor:middle;dominant-baseline:auto">96 px</text>
<text x="368" y="460" text-anchor="middle" style="fill:rgb(82, 81, 78);stroke:none;color:rgb(11, 11, 11);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:&quot;anthropic-sans&quot;, -apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;, sans-serif;font-size:12px;font-weight:400;text-anchor:middle;dominant-baseline:auto">64 px</text>
<text x="460" y="460" text-anchor="middle" style="fill:rgb(82, 81, 78);stroke:none;color:rgb(11, 11, 11);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:&quot;anthropic-sans&quot;, -apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;, sans-serif;font-size:12px;font-weight:400;text-anchor:middle;dominant-baseline:auto">40 px</text>
</svg>

After

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

+145
View File
@@ -0,0 +1,145 @@
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { getInvitesApi, getInviteStatusApi, createInviteApi, deleteInviteApi } from '../invites'
import { useAuthStore } from '@/stores/auth'
import { API_BASE_URL } from '@/api/http'
describe('invites API', () => {
const originalFetch = global.fetch
beforeEach(() => {
setActivePinia(createPinia())
localStorage.clear()
vi.restoreAllMocks()
const authStore = useAuthStore()
authStore.setTokens({ access_token: 'token-123', refresh_token: 'refresh-123' })
})
afterEach(() => {
global.fetch = originalFetch
localStorage.clear()
})
it('getInvitesApi sends GET to /user/invites and returns the paginated result', async () => {
const mockResponse = { data: [{ id: 'invite-1', code: 'ABC123' }], total: 1, count: 1 }
const fetchMock = vi.fn<typeof fetch>().mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => mockResponse,
} as unknown as Response)
global.fetch = fetchMock
const result = await getInvitesApi()
expect(fetchMock).toHaveBeenCalledWith(
`${API_BASE_URL}/user/invites`,
expect.objectContaining({ method: 'GET' }),
)
expect(result).toEqual(mockResponse)
})
it('getInvitesApi sends page and count as query params when given', async () => {
const mockResponse = { data: [], total: 0, count: 10 }
const fetchMock = vi.fn<typeof fetch>().mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => mockResponse,
} as unknown as Response)
global.fetch = fetchMock
await getInvitesApi({ page: 2, count: 10 })
expect(fetchMock).toHaveBeenCalledWith(
`${API_BASE_URL}/user/invites?page=2&count=10`,
expect.objectContaining({ method: 'GET' }),
)
})
it('getInvitesApi throws on failure', async () => {
global.fetch = vi.fn<typeof fetch>().mockResolvedValueOnce({
ok: false,
json: async () => ({ message: 'Failed' }),
} as unknown as Response)
await expect(getInvitesApi()).rejects.toThrow('Failed')
})
it('getInviteStatusApi sends GET to /user/invites/status and returns the counts', async () => {
const mockCounts = { active: 12, expired: 2, used: 8 }
const fetchMock = vi.fn<typeof fetch>().mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => mockCounts,
} as unknown as Response)
global.fetch = fetchMock
const result = await getInviteStatusApi()
expect(fetchMock).toHaveBeenCalledWith(
`${API_BASE_URL}/user/invites/status`,
expect.objectContaining({ method: 'GET' }),
)
expect(result).toEqual(mockCounts)
})
it('getInviteStatusApi throws on failure', async () => {
global.fetch = vi.fn<typeof fetch>().mockResolvedValueOnce({
ok: false,
json: async () => ({ message: 'Failed to load invite counts' }),
} as unknown as Response)
await expect(getInviteStatusApi()).rejects.toThrow('Failed to load invite counts')
})
it('createInviteApi sends POST to /user/invites and returns the created invite', async () => {
const mockInvite = { id: 'invite-1', code: 'ABC123' }
const fetchMock = vi.fn<typeof fetch>().mockResolvedValueOnce({
ok: true,
status: 201,
json: async () => mockInvite,
} as unknown as Response)
global.fetch = fetchMock
const result = await createInviteApi()
expect(fetchMock).toHaveBeenCalledWith(
`${API_BASE_URL}/user/invites`,
expect.objectContaining({ method: 'POST' }),
)
expect(result).toEqual(mockInvite)
})
it('createInviteApi throws on failure', async () => {
global.fetch = vi.fn<typeof fetch>().mockResolvedValueOnce({
ok: false,
json: async () => ({ message: 'Failed to create invite' }),
} as unknown as Response)
await expect(createInviteApi()).rejects.toThrow('Failed to create invite')
})
it('deleteInviteApi sends DELETE to /user/invites/:id', async () => {
const fetchMock = vi.fn<typeof fetch>().mockResolvedValueOnce({
ok: true,
status: 204,
} as unknown as Response)
global.fetch = fetchMock
await deleteInviteApi('invite-1')
expect(fetchMock).toHaveBeenCalledWith(
`${API_BASE_URL}/user/invites/invite-1`,
expect.objectContaining({ method: 'DELETE' }),
)
})
it('deleteInviteApi throws on failure', async () => {
global.fetch = vi.fn<typeof fetch>().mockResolvedValueOnce({
ok: false,
json: async () => ({ message: 'Failed to delete invite' }),
} as unknown as Response)
await expect(deleteInviteApi('invite-1')).rejects.toThrow('Failed to delete invite')
})
})
+12 -12
View File
@@ -5,7 +5,7 @@ import {
createListApi,
getListItemsApi,
createListItemApi,
updateListItemApi,
updateListItemTitleApi,
setListItemCompletedApi,
addUserToListApi,
removeUserFromListApi,
@@ -77,7 +77,7 @@ describe('lists API', () => {
expect(result).toEqual(mockItems)
})
it('setListItemCompletedApi sends POST to /lists/items/{id}', async () => {
it('setListItemCompletedApi sends POST to /lists/items/{id}/complete', async () => {
const fetchMock = vi.fn<typeof fetch>().mockResolvedValueOnce({
ok: true,
status: 204,
@@ -87,7 +87,7 @@ describe('lists API', () => {
await setListItemCompletedApi('item-1', { is_completed: true })
expect(fetchMock).toHaveBeenCalledWith(
`${API_BASE_URL}/lists/items/item-1`,
`${API_BASE_URL}/lists/items/item-1/complete`,
expect.objectContaining({ method: 'POST' }),
)
})
@@ -101,7 +101,7 @@ describe('lists API', () => {
} as unknown as Response)
global.fetch = fetchMock
const result = await createListApi({ name: 'Groceries', user_ids: [] })
const result = await createListApi({ name: 'Groceries' })
expect(fetchMock).toHaveBeenCalledWith(
`${API_BASE_URL}/lists`,
@@ -119,7 +119,7 @@ describe('lists API', () => {
await expect(createListApi({ name: 'x' })).rejects.toThrow('Failed')
})
it('createListItemApi sends POST to /lists/item and returns the created item with its server id', async () => {
it('createListItemApi sends POST to /lists/items and returns the created item with its server id', async () => {
const mockItem = { id: 'item-1', list_id: '', title: 'Milk', is_completed: false }
const fetchMock = vi.fn<typeof fetch>().mockResolvedValueOnce({
ok: true,
@@ -131,24 +131,24 @@ describe('lists API', () => {
const result = await createListItemApi({ list_id: 'list-1', title: 'Milk' })
expect(fetchMock).toHaveBeenCalledWith(
`${API_BASE_URL}/lists/item`,
`${API_BASE_URL}/lists/items`,
expect.objectContaining({ method: 'POST' }),
)
expect(result).toEqual(mockItem)
})
it('updateListItemApi sends PUT to /lists/item', async () => {
it('updateListItemTitleApi sends POST to /lists/items/{id}/title', async () => {
const fetchMock = vi.fn<typeof fetch>().mockResolvedValueOnce({
ok: true,
status: 204,
} as unknown as Response)
global.fetch = fetchMock
await updateListItemApi({ list_item_id: 'item-1', is_completed: true })
await updateListItemTitleApi('item-1', { title: 'Free-range eggs' })
expect(fetchMock).toHaveBeenCalledWith(
`${API_BASE_URL}/lists/item`,
expect.objectContaining({ method: 'PUT' }),
`${API_BASE_URL}/lists/items/item-1/title`,
expect.objectContaining({ method: 'POST' }),
)
})
@@ -206,7 +206,7 @@ describe('lists API', () => {
await expect(deleteListApi('list-1')).rejects.toThrow('List not found')
})
it('deleteListItemApi sends DELETE to /lists/item/{id}', async () => {
it('deleteListItemApi sends DELETE to /lists/items/{id}', async () => {
const fetchMock = vi.fn<typeof fetch>().mockResolvedValueOnce({
ok: true,
status: 204,
@@ -216,7 +216,7 @@ describe('lists API', () => {
await deleteListItemApi('item-1')
expect(fetchMock).toHaveBeenCalledWith(
`${API_BASE_URL}/lists/item/item-1`,
`${API_BASE_URL}/lists/items/item-1`,
expect.objectContaining({ method: 'DELETE' }),
)
})
+96
View File
@@ -0,0 +1,96 @@
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { changePasswordApi, createUserApi } from '../users'
import { useAuthStore } from '@/stores/auth'
import { API_BASE_URL } from '@/api/http'
describe('users API', () => {
const originalFetch = global.fetch
beforeEach(() => {
setActivePinia(createPinia())
localStorage.clear()
vi.restoreAllMocks()
})
afterEach(() => {
global.fetch = originalFetch
localStorage.clear()
})
describe('changePasswordApi', () => {
it('sends POST to /user/password with the old and new password', async () => {
const authStore = useAuthStore()
authStore.setTokens({ access_token: 'token-123', refresh_token: 'refresh-123' })
const fetchMock = vi.fn<typeof fetch>().mockResolvedValueOnce({
ok: true,
status: 204,
} as unknown as Response)
global.fetch = fetchMock
await changePasswordApi({ old_password: 'old', new_password: 'new12345' })
expect(fetchMock).toHaveBeenCalledWith(
`${API_BASE_URL}/user/password`,
expect.objectContaining({
method: 'POST',
body: JSON.stringify({ old_password: 'old', new_password: 'new12345' }),
}),
)
})
it('throws on failure', async () => {
global.fetch = vi.fn<typeof fetch>().mockResolvedValueOnce({
ok: false,
json: async () => ({ message: 'Failed' }),
} as unknown as Response)
await expect(
changePasswordApi({ old_password: 'old', new_password: 'new12345' }),
).rejects.toThrow('Failed')
})
})
describe('createUserApi', () => {
it('sends POST to /users with the registration payload and returns the created user', async () => {
const mockUser = { id: 'user-1', email: 'new@example.com', name: 'New User' }
const fetchMock = vi.fn<typeof fetch>().mockResolvedValueOnce({
ok: true,
status: 201,
json: async () => mockUser,
} as unknown as Response)
global.fetch = fetchMock
const payload = {
email: 'new@example.com',
password: 'password123',
name: 'New User',
invite_code: 'invite-abc',
}
const result = await createUserApi(payload)
expect(fetchMock).toHaveBeenCalledWith(
`${API_BASE_URL}/users`,
expect.objectContaining({ method: 'POST', body: JSON.stringify(payload) }),
)
expect(result).toEqual(mockUser)
})
it('throws on failure', async () => {
global.fetch = vi.fn<typeof fetch>().mockResolvedValueOnce({
ok: false,
json: async () => ({ message: 'Invalid invite code' }),
} as unknown as Response)
await expect(
createUserApi({
email: 'new@example.com',
password: 'password123',
name: 'New User',
invite_code: 'bad-code',
}),
).rejects.toThrow('Invalid invite code')
})
})
})
+23
View File
@@ -0,0 +1,23 @@
import { apiClient } from '@/api/client'
import { extractErrorMessage } from '@/api/http'
import type { PaginatedExercises } from '@/types/exercise'
export interface GetExercisesParams {
page?: number
count?: number
}
export async function getExercisesApi(
params: GetExercisesParams = {},
): Promise<PaginatedExercises> {
const query = new URLSearchParams()
if (params.page !== undefined) query.set('page', String(params.page))
if (params.count !== undefined) query.set('count', String(params.count))
const queryString = query.toString()
const response = await apiClient.get(`/exercises${queryString ? `?${queryString}` : ''}`)
if (!response.ok) {
throw new Error(await extractErrorMessage(response, 'Failed to load exercises'))
}
return response.json()
}
+44
View File
@@ -0,0 +1,44 @@
import { apiClient } from '@/api/client'
import { extractErrorMessage } from '@/api/http'
import type { Invite, InviteStatusCounts, PaginatedInvites } from '@/types/invite'
export interface GetInvitesParams {
page?: number
count?: number
}
export async function getInvitesApi(params: GetInvitesParams = {}): Promise<PaginatedInvites> {
const query = new URLSearchParams()
if (params.page !== undefined) query.set('page', String(params.page))
if (params.count !== undefined) query.set('count', String(params.count))
const queryString = query.toString()
const response = await apiClient.get(`/user/invites${queryString ? `?${queryString}` : ''}`)
if (!response.ok) {
throw new Error(await extractErrorMessage(response, 'Failed to load invites'))
}
return response.json()
}
export async function getInviteStatusApi(): Promise<InviteStatusCounts> {
const response = await apiClient.get('/user/invites/status')
if (!response.ok) {
throw new Error(await extractErrorMessage(response, 'Failed to load invite counts'))
}
return response.json()
}
export async function createInviteApi(): Promise<Invite> {
const response = await apiClient.post('/user/invites')
if (!response.ok) {
throw new Error(await extractErrorMessage(response, 'Failed to create invite'))
}
return response.json()
}
export async function deleteInviteApi(id: string): Promise<void> {
const response = await apiClient.delete(`/user/invites/${id}`)
if (!response.ok) {
throw new Error(await extractErrorMessage(response, 'Failed to delete invite'))
}
}
+22 -11
View File
@@ -5,10 +5,11 @@ import type {
ListItem,
CreateListPayload,
CreateListItemPayload,
UpdateListItemPayload,
SetListItemCompletedPayload,
SetListItemTitlePayload,
AddUserToListPayload,
RemoveUserFromListPayload,
OrderListsPayload,
} from '@/types/list'
export async function getListsApi(): Promise<List[]> {
@@ -36,30 +37,33 @@ export async function getListItemsApi(listId: string): Promise<ListItem[]> {
}
export async function createListItemApi(payload: CreateListItemPayload): Promise<ListItem> {
const response = await apiClient.post('/lists/item', payload)
const response = await apiClient.post('/lists/items', payload)
if (!response.ok) {
throw new Error(await extractErrorMessage(response, 'Failed to create list item'))
}
return response.json()
}
export async function updateListItemApi(payload: UpdateListItemPayload): Promise<void> {
const response = await apiClient.put('/lists/item', payload)
if (!response.ok) {
throw new Error(await extractErrorMessage(response, 'Failed to update list item'))
}
}
export async function setListItemCompletedApi(
itemId: string,
payload: SetListItemCompletedPayload,
): Promise<void> {
const response = await apiClient.post(`/lists/items/${itemId}`, payload)
const response = await apiClient.post(`/lists/items/${itemId}/complete`, payload)
if (!response.ok) {
throw new Error(await extractErrorMessage(response, 'Failed to update list item status'))
}
}
export async function updateListItemTitleApi(
itemId: string,
payload: SetListItemTitlePayload,
): Promise<void> {
const response = await apiClient.post(`/lists/items/${itemId}/title`, payload)
if (!response.ok) {
throw new Error(await extractErrorMessage(response, 'Failed to update list item title'))
}
}
export async function addUserToListApi(payload: AddUserToListPayload): Promise<void> {
const response = await apiClient.post('/lists/user', payload)
if (!response.ok) {
@@ -74,6 +78,13 @@ export async function removeUserFromListApi(payload: RemoveUserFromListPayload):
}
}
export async function orderListsApi(payload: OrderListsPayload): Promise<void> {
const response = await apiClient.post('/lists/order', payload)
if (!response.ok) {
throw new Error(await extractErrorMessage(response, 'Failed to reorder lists'))
}
}
export async function deleteListApi(listId: string): Promise<void> {
const response = await apiClient.delete(`/lists/${listId}`)
if (!response.ok) {
@@ -82,7 +93,7 @@ export async function deleteListApi(listId: string): Promise<void> {
}
export async function deleteListItemApi(itemId: string): Promise<void> {
const response = await apiClient.delete(`/lists/item/${itemId}`)
const response = await apiClient.delete(`/lists/items/${itemId}`)
if (!response.ok) {
throw new Error(await extractErrorMessage(response, 'Failed to delete list item'))
}
+19
View File
@@ -0,0 +1,19 @@
import { apiClient } from '@/api/client'
import { extractErrorMessage } from '@/api/http'
import type { ChangePasswordPayload } from '@/types/auth'
import type { CreateUserPayload, User } from '@/types/user'
export async function changePasswordApi(payload: ChangePasswordPayload): Promise<void> {
const response = await apiClient.post('/user/password', payload)
if (!response.ok) {
throw new Error(await extractErrorMessage(response, 'Failed to change password'))
}
}
export async function createUserApi(payload: CreateUserPayload): Promise<User> {
const response = await apiClient.post('/users', payload)
if (!response.ok) {
throw new Error(await extractErrorMessage(response, 'Failed to create account'))
}
return response.json()
}
+10
View File
@@ -0,0 +1,10 @@
import { API_BASE_URL, extractErrorMessage } from '@/api/http'
import type { VersionInfo } from '@/types/version'
export async function getVersionApi(): Promise<VersionInfo> {
const response = await fetch(`${API_BASE_URL}/version`)
if (!response.ok) {
throw new Error(await extractErrorMessage(response, 'Failed to load API version'))
}
return response.json()
}
+47 -37
View File
@@ -1,33 +1,40 @@
/* dttmr color palette - dark, modern, blueish */
/* dttmr palette - field-ledger / stamped ink, cool slate ground, blue ink accent */
:root {
--c-bg: #0a0e1a;
--c-bg-soft: #101627;
--c-bg-mute: #161d33;
--c-bg-elevated: #1a2338;
--c-bg: #12161d;
--c-bg-soft: #181e27;
--c-bg-mute: #202632;
--c-bg-elevated: #262e3c;
--c-border: rgba(120, 150, 220, 0.16);
--c-border-hover: rgba(120, 150, 220, 0.32);
--c-border: rgba(74, 130, 196, 0.16);
--c-border-hover: rgba(74, 130, 196, 0.36);
--c-text: #c7d0e6;
--c-text-soft: #8892b0;
--c-heading: #f1f4fc;
--c-text: #aab2c0;
--c-text-soft: #707a8c;
--c-heading: #eef1f6;
--c-accent: #4f7dfa;
--c-accent-soft: #3a5fd9;
--c-accent-strong: #6c93ff;
--c-accent-bg: rgba(79, 125, 250, 0.14);
--c-accent: #3d72b4;
--c-accent-soft: #2f5c92;
--c-accent-strong: #62a0e0;
--c-accent-bg: rgba(61, 114, 180, 0.16);
--c-success: #34d399;
--c-danger: #f87171;
--c-danger-bg: rgba(248, 113, 113, 0.12);
--c-warning: #fbbf24;
--c-success: #6ba178;
--c-success-bg: rgba(107, 161, 120, 0.14);
--c-danger: #c1614a;
--c-danger-bg: rgba(193, 97, 74, 0.14);
--c-warning: #cf9d3f;
--radius-sm: 8px;
--radius-md: 12px;
--radius-lg: 18px;
--font-body:
'IBM Plex Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
--font-display: 'IBM Plex Serif', Georgia, 'Times New Roman', serif;
--font-mono: 'IBM Plex Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
--font-stamp: 'Special Elite', var(--font-mono), monospace;
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.4);
--shadow-md: 0 8px 24px rgba(0, 0, 0, 0.45);
--radius-sm: 3px;
--radius-md: 5px;
--radius-lg: 8px;
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.45);
--shadow-md: 0 10px 28px rgba(0, 0, 0, 0.5);
--nav-height: 60px;
--safe-bottom: env(safe-area-inset-bottom, 0px);
@@ -56,21 +63,18 @@ html {
body {
min-height: 100vh;
color: var(--c-text);
background: radial-gradient(circle at top, #101a33 0%, var(--c-bg) 55%);
background-color: var(--c-bg);
background-image:
repeating-linear-gradient(
to bottom,
rgba(74, 130, 196, 0.06) 0,
rgba(74, 130, 196, 0.06) 1px,
transparent 1px,
transparent 28px
),
radial-gradient(circle at 50% -10%, rgba(74, 130, 196, 0.08), transparent 55%);
line-height: 1.55;
font-family:
Inter,
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
Roboto,
Oxygen,
Ubuntu,
Cantarell,
'Fira Sans',
'Droid Sans',
'Helvetica Neue',
sans-serif;
font-family: var(--font-body);
font-size: 15px;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
@@ -85,6 +89,12 @@ h4 {
font-weight: 600;
}
h1 {
font-family: var(--font-display);
font-weight: 600;
letter-spacing: -0.01em;
}
a {
color: var(--c-accent-strong);
text-decoration: none;
+18 -5
View File
@@ -29,13 +29,14 @@
}
.btn-primary {
background: linear-gradient(135deg, var(--c-accent) 0%, var(--c-accent-soft) 100%);
color: #ffffff;
background-color: var(--c-accent);
color: var(--c-bg);
font-weight: 600;
box-shadow: var(--shadow-sm);
}
.btn-primary:hover:not(:disabled) {
background: linear-gradient(135deg, var(--c-accent-strong) 0%, var(--c-accent) 100%);
background-color: var(--c-accent-strong);
}
.btn-secondary {
@@ -52,7 +53,7 @@
.btn-danger {
background-color: transparent;
color: var(--c-danger);
border-color: rgba(248, 113, 113, 0.35);
border-color: rgba(193, 97, 74, 0.4);
}
.btn-danger:hover:not(:disabled) {
@@ -113,11 +114,23 @@
.banner-error {
background-color: var(--c-danger-bg);
border: 1px solid rgba(248, 113, 113, 0.4);
border: 1px solid rgba(193, 97, 74, 0.4);
color: var(--c-danger);
}
.banner-success {
background-color: var(--c-success-bg);
border: 1px solid rgba(127, 174, 120, 0.4);
color: var(--c-success);
}
.mono-num {
font-family: var(--font-mono);
font-variant-numeric: tabular-nums;
}
.page {
width: 100%;
min-height: 100vh;
padding: 1rem 1rem calc(var(--nav-height) + var(--safe-bottom) + 1.5rem);
padding-top: calc(1rem + var(--safe-top));
+60 -21
View File
@@ -1,4 +1,5 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { useListsStore } from '@/stores/lists'
@@ -11,25 +12,44 @@ async function handleLogout() {
await authStore.logout()
router.push('/login')
}
const stampClass = computed(() => {
if (!authStore.isAuthenticated) return ''
if (listsStore.pendingCount > 0 || listsStore.isSyncing) return 'stamp-pending'
return 'stamp-synced'
})
const stampTitle = computed(() => {
if (!authStore.isAuthenticated) return 'Not signed in'
if (listsStore.isSyncing) return 'Syncing…'
if (listsStore.pendingCount > 0) return `${listsStore.pendingCount} change(s) waiting to sync`
return 'Up to date'
})
</script>
<template>
<header class="app-header">
<div class="brand">
<span class="brand-dot"></span>
<span class="brand-seal"></span>
<span class="brand-name">dttmr</span>
</div>
<div class="status">
<span
class="sync-dot"
:class="{ offline: !authStore.isAuthenticated }"
:title="listsStore.isSyncing ? 'Syncing…' : 'Up to date'"
v-if="listsStore.error"
class="stamp stamp-danger"
:title="listsStore.error"
:aria-label="listsStore.error"
role="img"
></span>
<span
v-else
class="stamp"
:class="stampClass"
:title="stampTitle"
:aria-label="stampTitle"
role="img"
></span>
<span v-if="listsStore.pendingCount > 0" class="pending">
{{ listsStore.pendingCount }} pending
</span>
<span v-if="listsStore.error" class="sync-error" :title="listsStore.error"> sync error</span>
<button v-if="authStore.isAuthenticated" type="button" class="logout" @click="handleLogout">
Log out
</button>
@@ -46,7 +66,7 @@ async function handleLogout() {
align-items: center;
justify-content: space-between;
padding: calc(0.75rem + var(--safe-top)) 1rem 0.75rem;
background: linear-gradient(180deg, rgba(10, 14, 26, 0.92) 60%, rgba(10, 14, 26, 0));
background: linear-gradient(180deg, rgba(20, 24, 26, 0.92) 60%, rgba(20, 24, 26, 0));
backdrop-filter: blur(8px);
}
@@ -54,17 +74,23 @@ async function handleLogout() {
display: flex;
align-items: center;
gap: 0.5rem;
font-weight: 600;
color: var(--c-heading);
}
.brand-name {
font-family: var(--font-display);
font-weight: 600;
font-size: 1rem;
letter-spacing: 0.02em;
}
.brand-dot {
width: 10px;
height: 10px;
border-radius: 50%;
background: linear-gradient(135deg, var(--c-accent-strong), var(--c-accent-soft));
box-shadow: 0 0 12px var(--c-accent);
.brand-seal {
width: 16px;
height: 16px;
border-radius: 3px;
background: var(--c-accent);
transform: rotate(-8deg);
box-shadow: 0 0 0 2px var(--c-bg) inset;
}
.status {
@@ -75,18 +101,31 @@ async function handleLogout() {
color: var(--c-text-soft);
}
.sync-dot {
.stamp {
display: inline-flex;
align-items: center;
gap: 0.4rem;
color: var(--c-text-soft);
}
.stamp::before {
content: '';
width: 8px;
height: 8px;
border-radius: 50%;
background-color: var(--c-success);
background-color: currentColor;
flex-shrink: 0;
}
.sync-dot.offline {
background-color: var(--c-text-soft);
.stamp-pending {
color: var(--c-warning);
}
.sync-error {
.stamp-synced {
color: var(--c-success);
}
.stamp-danger {
color: var(--c-danger);
cursor: help;
}
+122
View File
@@ -0,0 +1,122 @@
<script setup lang="ts">
import { useEscapeKey } from '@/composables/useEscapeKey'
defineProps<{
title: string
titleId: string
}>()
const emit = defineEmits<{
close: []
}>()
useEscapeKey(handleClose)
function handleClose() {
emit('close')
}
</script>
<template>
<div class="modal-overlay" @click.self="handleClose">
<div class="modal-card card" role="dialog" aria-modal="true" :aria-labelledby="titleId">
<div class="modal-header">
<h3 :id="titleId">{{ title }}</h3>
<button type="button" class="close-btn" aria-label="Close modal" @click="handleClose">
</button>
</div>
<slot />
<div class="modal-footer">
<slot name="footer" />
</div>
</div>
</div>
</template>
<style scoped>
.modal-overlay {
position: fixed;
inset: 0;
background-color: rgba(0, 0, 0, 0.65);
backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
z-index: 100;
animation: fadeIn 0.15s ease-out;
}
.modal-card {
width: 100%;
max-width: 440px;
background-color: var(--c-bg-soft);
border: 1px solid var(--c-border-hover);
border-radius: var(--radius-lg);
padding: 1.25rem 1.4rem;
box-shadow: var(--shadow-md);
animation: slideUp 0.15s ease-out;
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.5rem;
}
.modal-header h3 {
font-size: 1.1rem;
margin: 0;
}
.close-btn {
background: none;
border: none;
color: var(--c-text-soft);
font-size: 1.1rem;
cursor: pointer;
padding: 0.2rem 0.4rem;
border-radius: var(--radius-sm);
line-height: 1;
}
.close-btn:hover {
color: var(--c-heading);
}
.modal-footer {
display: flex;
justify-content: flex-end;
gap: 0.6rem;
margin-top: 1rem;
}
.modal-footer .btn {
width: auto;
padding: 0.5rem 1.2rem;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(8px) scale(0.98);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
</style>
+42 -13
View File
@@ -10,15 +10,42 @@ const listsStore = useListsStore()
<template>
<nav v-if="authStore.isAuthenticated" class="bottom-nav">
<RouterLink to="/" class="nav-item" active-class="is-active">
<span class="nav-icon"></span>
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M4 6h1.5M4 12h1.5M4 18h1.5" stroke-linecap="round" />
<path d="M9 6h11M9 12h11M9 18h11" stroke-linecap="round" />
</svg>
<span class="nav-label">Lists</span>
<span v-if="listsStore.pendingCount > 0" class="nav-badge">{{
<span v-if="listsStore.pendingCount > 0" class="nav-badge mono-num">{{
listsStore.pendingCount
}}</span>
</RouterLink>
<RouterLink to="/about" class="nav-item" active-class="is-active">
<span class="nav-icon"></span>
<span class="nav-label">About</span>
<RouterLink to="/exercises" class="nav-item" active-class="is-active">
<svg
class="nav-icon"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect x="1" y="9" width="3" height="6" rx="1" />
<rect x="4.5" y="7" width="2" height="10" rx="1" />
<line x1="6.5" y1="12" x2="17.5" y2="12" />
<rect x="17.5" y="7" width="2" height="10" rx="1" />
<rect x="20" y="9" width="3" height="6" rx="1" />
</svg>
<span class="nav-label">Exercises</span>
</RouterLink>
<RouterLink to="/account" class="nav-item" active-class="is-active">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="3.2" />
<path
d="M12 3.5v2M12 18.5v2M20.5 12h-2M5.5 12h-2M17.7 6.3l-1.4 1.4M7.7 16.3l-1.4 1.4M17.7 17.7l-1.4-1.4M7.7 7.7 6.3 6.3"
stroke-linecap="round"
/>
</svg>
<span class="nav-label">Account</span>
</RouterLink>
</nav>
</template>
@@ -45,20 +72,21 @@ const listsStore = useListsStore()
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.15rem;
gap: 0.2rem;
color: var(--c-text-soft);
font-size: 0.7rem;
}
.nav-icon {
font-size: 1.25rem;
line-height: 1;
font-size: 0.68rem;
transition: color 0.15s ease-in-out;
}
.nav-item.is-active {
color: var(--c-accent-strong);
}
.nav-icon {
width: 21px;
height: 21px;
}
.nav-badge {
position: absolute;
top: 0.35rem;
@@ -68,8 +96,9 @@ const listsStore = useListsStore()
padding: 0 4px;
border-radius: 999px;
background-color: var(--c-accent);
color: #fff;
color: var(--c-bg);
font-size: 0.6rem;
font-weight: 600;
display: flex;
align-items: center;
justify-content: center;
+135
View File
@@ -0,0 +1,135 @@
<script setup lang="ts">
import { ref, onMounted, nextTick } from 'vue'
import { changePasswordApi } from '@/api/users'
import BaseModal from '@/components/BaseModal.vue'
const emit = defineEmits<{
close: []
}>()
const passwordInput = ref<HTMLInputElement | null>(null)
const currentPassword = ref('')
const newPassword = ref('')
const confirmPassword = ref('')
const isSubmitting = ref(false)
const error = ref('')
const successMessage = ref('')
onMounted(() => {
nextTick(() => {
passwordInput.value?.focus()
})
})
function handleClose() {
emit('close')
}
async function handleSubmit() {
error.value = ''
successMessage.value = ''
if (newPassword.value.length < 8) {
error.value = 'New password must be at least 8 characters.'
return
}
if (newPassword.value !== confirmPassword.value) {
error.value = 'New passwords do not match.'
return
}
isSubmitting.value = true
try {
await changePasswordApi({
old_password: currentPassword.value,
new_password: newPassword.value,
})
currentPassword.value = ''
newPassword.value = ''
confirmPassword.value = ''
successMessage.value = 'Password changed successfully.'
} catch (err) {
error.value = err instanceof Error ? err.message : 'Failed to change password'
} finally {
isSubmitting.value = false
}
}
</script>
<template>
<BaseModal title="Change password" title-id="change-password-modal-title" @close="handleClose">
<p class="modal-description">Enter your current password and choose a new one.</p>
<form class="password-form" @submit.prevent="handleSubmit">
<div class="field">
<input
ref="passwordInput"
v-model="currentPassword"
type="password"
placeholder="Current password"
autocomplete="current-password"
:disabled="isSubmitting"
/>
</div>
<div class="field">
<input
v-model="newPassword"
type="password"
placeholder="New password"
autocomplete="new-password"
:disabled="isSubmitting"
/>
</div>
<div class="field">
<input
v-model="confirmPassword"
type="password"
placeholder="Confirm new password"
autocomplete="new-password"
:disabled="isSubmitting"
/>
</div>
<button
type="submit"
class="btn btn-primary"
:disabled="isSubmitting || !currentPassword || !newPassword || !confirmPassword"
>
Change password
</button>
</form>
<p v-if="error" class="banner banner-error">{{ error }}</p>
<p v-if="successMessage" class="banner banner-success">{{ successMessage }}</p>
<template #footer>
<button type="button" class="btn btn-secondary" @click="handleClose">Done</button>
</template>
</BaseModal>
</template>
<style scoped>
.modal-description {
font-size: 0.85rem;
color: var(--c-text-soft);
margin-bottom: 1rem;
}
.password-form {
display: flex;
flex-direction: column;
gap: 0.6rem;
margin-bottom: 1rem;
}
.password-form .btn {
padding: 0.65rem 1.2rem;
}
.banner-success {
margin-top: 0.75rem;
}
.banner-error {
margin-top: 0.75rem;
}
</style>
+51
View File
@@ -0,0 +1,51 @@
<script setup lang="ts">
import BaseModal from '@/components/BaseModal.vue'
withDefaults(
defineProps<{
title: string
description: string
confirmLabel?: string
}>(),
{
confirmLabel: 'Delete',
},
)
const emit = defineEmits<{
close: []
confirm: []
}>()
function handleClose() {
emit('close')
}
function handleConfirm() {
emit('confirm')
}
</script>
<template>
<BaseModal :title="title" title-id="confirm-delete-modal-title" @close="handleClose">
<p class="modal-description">{{ description }}</p>
<template #footer>
<button type="button" class="btn btn-secondary cancel-btn" @click="handleClose">
Cancel
</button>
<button type="button" class="btn btn-danger confirm-delete-btn" @click="handleConfirm">
{{ confirmLabel }}
</button>
</template>
</BaseModal>
</template>
<style scoped>
.modal-description {
font-size: 0.85rem;
color: var(--c-text-soft);
margin-bottom: 1.25rem;
line-height: 1.4;
}
</style>
+31
View File
@@ -0,0 +1,31 @@
<script setup lang="ts">
import type { LocalListItem } from '@/database/db'
import ConfirmDeleteModal from '@/components/ConfirmDeleteModal.vue'
const props = defineProps<{
item: LocalListItem
}>()
const emit = defineEmits<{
close: []
confirm: []
}>()
function handleClose() {
emit('close')
}
function handleConfirm() {
emit('confirm')
}
</script>
<template>
<ConfirmDeleteModal
:title="`Delete &quot;${props.item.title}&quot;?`"
description="Are you sure you want to delete this item? This action cannot be undone."
confirm-label="Delete item"
@close="handleClose"
@confirm="handleConfirm"
/>
</template>
+9 -125
View File
@@ -1,8 +1,8 @@
<script setup lang="ts">
import type { LocalList } from '@/database/db'
import { useEscapeKey } from '@/composables/useEscapeKey'
import ConfirmDeleteModal from '@/components/ConfirmDeleteModal.vue'
defineProps<{
const props = defineProps<{
list: LocalList
}>()
@@ -11,8 +11,6 @@ const emit = defineEmits<{
confirm: []
}>()
useEscapeKey(handleClose)
function handleClose() {
emit('close')
}
@@ -23,125 +21,11 @@ function handleConfirm() {
</script>
<template>
<div class="modal-overlay" @click.self="handleClose">
<div
class="modal-card card"
role="dialog"
aria-modal="true"
aria-labelledby="delete-modal-title"
>
<div class="modal-header">
<h3 id="delete-modal-title">Delete "{{ list.name }}"?</h3>
<button type="button" class="close-btn" aria-label="Close modal" @click="handleClose">
</button>
</div>
<p class="modal-description">
Are you sure you want to delete this list? This action cannot be undone and all items in
this list will be deleted.
</p>
<div class="modal-footer">
<button type="button" class="btn btn-secondary cancel-btn" @click="handleClose">
Cancel
</button>
<button type="button" class="btn btn-danger confirm-delete-btn" @click="handleConfirm">
Delete list
</button>
</div>
</div>
</div>
<ConfirmDeleteModal
:title="`Delete &quot;${props.list.name}&quot;?`"
description="Are you sure you want to delete this list? This action cannot be undone and all items in this list will be deleted."
confirm-label="Delete list"
@close="handleClose"
@confirm="handleConfirm"
/>
</template>
<style scoped>
.modal-overlay {
position: fixed;
inset: 0;
background-color: rgba(0, 0, 0, 0.65);
backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
z-index: 100;
animation: fadeIn 0.15s ease-out;
}
.modal-card {
width: 100%;
max-width: 440px;
background-color: var(--c-bg-soft);
border: 1px solid var(--c-border-hover);
border-radius: var(--radius-lg);
padding: 1.25rem 1.4rem;
box-shadow: var(--shadow-md);
animation: slideUp 0.15s ease-out;
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.5rem;
}
.modal-header h3 {
font-size: 1.1rem;
margin: 0;
}
.close-btn {
background: none;
border: none;
color: var(--c-text-soft);
font-size: 1.1rem;
cursor: pointer;
padding: 0.2rem 0.4rem;
border-radius: var(--radius-sm);
line-height: 1;
}
.close-btn:hover {
color: var(--c-heading);
}
.modal-description {
font-size: 0.85rem;
color: var(--c-text-soft);
margin-bottom: 1.25rem;
line-height: 1.4;
}
.modal-footer {
display: flex;
justify-content: flex-end;
gap: 0.6rem;
margin-top: 1rem;
}
.modal-footer .btn {
width: auto;
padding: 0.5rem 1.2rem;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(8px) scale(0.98);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
</style>
+112
View File
@@ -0,0 +1,112 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { Exercise } from '@/types/exercise'
import { EQUIPMENT_LABELS, LOAD_LABELS, METRIC_LABELS } from '@/types/exercise'
const props = defineProps<{ exercise: Exercise }>()
const emit = defineEmits<{
select: [id: string]
}>()
const equipmentLabels = computed(
() => props.exercise.equipment?.map((equipment) => EQUIPMENT_LABELS[equipment]) ?? [],
)
const loadLabel = computed(() =>
props.exercise.load !== undefined ? LOAD_LABELS[props.exercise.load] : null,
)
const metricLabel = computed(() =>
props.exercise.metric !== undefined ? METRIC_LABELS[props.exercise.metric] : null,
)
function handleClick() {
emit('select', props.exercise.id)
}
</script>
<template>
<button type="button" class="exercise-card card" @click="handleClick">
<div class="exercise-card-main">
<h3>{{ exercise.name }}</h3>
<p v-if="exercise.notes" class="notes">{{ exercise.notes }}</p>
<div v-if="loadLabel || metricLabel || equipmentLabels.length > 0" class="tags">
<span v-if="loadLabel" class="tag tag-accent">{{ loadLabel }}</span>
<span v-if="metricLabel" class="tag tag-accent">{{ metricLabel }}</span>
<span v-for="equipment in equipmentLabels" :key="equipment" class="tag">{{
equipment
}}</span>
</div>
</div>
<span class="chevron"></span>
</button>
</template>
<style scoped>
.exercise-card {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: 0.85rem 1rem;
color: inherit;
background-color: var(--c-bg-soft);
font: inherit;
text-align: left;
cursor: pointer;
transition: border-color 0.15s ease-in-out;
}
.exercise-card:hover {
border-color: var(--c-border-hover);
}
.exercise-card-main {
flex: 1;
min-width: 0;
}
.exercise-card-main h3 {
font-size: 1.02rem;
margin-bottom: 0.2rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.notes {
font-size: 0.8rem;
color: var(--c-text-soft);
margin-bottom: 0.4rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tags {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
}
.tag {
font-size: 0.68rem;
font-weight: 500;
padding: 0.15rem 0.5rem;
border-radius: 999px;
background-color: var(--c-bg-mute);
color: var(--c-text-soft);
}
.tag-accent {
background-color: var(--c-accent-bg);
color: var(--c-accent-strong);
}
.chevron {
flex-shrink: 0;
color: var(--c-text-soft);
font-size: 1.3rem;
line-height: 1;
padding-right: 0.25rem;
}
</style>
+622
View File
@@ -0,0 +1,622 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { getInvitesApi, getInviteStatusApi, createInviteApi, deleteInviteApi } from '@/api/invites'
import type { Invite, InviteStatusCounts } from '@/types/invite'
type InviteStatus = 'active' | 'used' | 'expired'
const PAGE_SIZE = 10
const expanded = ref(false)
const invites = ref<Invite[]>([])
const page = ref(1)
const total = ref(0)
const isLoading = ref(false)
const isCreating = ref(false)
const error = ref('')
const pendingDeleteId = ref<string | null>(null)
const deletingId = ref<string | null>(null)
const sharedId = ref<string | null>(null)
// Counts across ALL invites (not just the current page)
const statusCounts = ref<InviteStatusCounts | null>(null)
let hasLoaded = false
let sharedTimeout: ReturnType<typeof setTimeout> | undefined
const totalInvites = computed(() =>
statusCounts.value
? statusCounts.value.active + statusCounts.value.expired + statusCounts.value.used
: null,
)
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / PAGE_SIZE)))
const showPagination = computed(() => totalPages.value > 1)
onMounted(() => {
void loadStatusCounts()
})
onBeforeUnmount(() => {
clearTimeout(sharedTimeout)
})
function isOffline(): boolean {
return typeof navigator !== 'undefined' && !navigator.onLine
}
async function toggleExpanded() {
expanded.value = !expanded.value
if (expanded.value && !hasLoaded) {
await loadInvites()
}
}
async function loadInvites() {
error.value = ''
if (isOffline()) {
error.value = 'You must be online to manage invites.'
return
}
isLoading.value = true
try {
const response = await getInvitesApi({ page: page.value, count: PAGE_SIZE })
invites.value = response.data
total.value = response.total
hasLoaded = true
} catch (err) {
error.value = err instanceof Error ? err.message : 'Failed to load invites'
} finally {
isLoading.value = false
}
}
async function loadStatusCounts() {
if (isOffline()) return
try {
statusCounts.value = await getInviteStatusApi()
} catch {
// Non-critical: the header badges just stay hidden until the next
// successful fetch instead of blocking the rest of the panel.
}
}
async function goToPage(target: number) {
if (target < 1 || target > totalPages.value || target === page.value || isLoading.value) {
return
}
page.value = target
await loadInvites()
}
async function handleCreate() {
error.value = ''
if (isOffline()) {
error.value = 'You must be online to create an invite.'
return
}
isCreating.value = true
try {
const invite = await createInviteApi()
page.value = 1
await loadInvites()
await loadStatusCounts()
// Sharing is the whole point of an invite, so offer it immediately
// instead of making the user hunt for the Share button afterwards.
await shareInvite(invite)
} catch (err) {
error.value = err instanceof Error ? err.message : 'Failed to create invite'
} finally {
isCreating.value = false
}
}
function requestDelete(id: string) {
error.value = ''
pendingDeleteId.value = id
}
function cancelDelete() {
pendingDeleteId.value = null
}
async function confirmDelete(id: string) {
error.value = ''
if (isOffline()) {
error.value = 'You must be online to delete an invite.'
pendingDeleteId.value = null
return
}
deletingId.value = id
try {
await deleteInviteApi(id)
// Deleted the only item on a page past the first: step back a page
// instead of reloading into a stranded, empty page.
if (invites.value.length === 1 && page.value > 1) {
page.value -= 1
}
await loadInvites()
await loadStatusCounts()
} catch (err) {
error.value = err instanceof Error ? err.message : 'Failed to delete invite'
} finally {
deletingId.value = null
pendingDeleteId.value = null
}
}
// The link that lands someone on the (otherwise unlinked) register page —
// see router.beforeEach, which redirects any URL carrying ?invite=... there.
function getInviteUrl(code: string): string {
const base = `${window.location.origin}${import.meta.env.BASE_URL}`
return `${base}?invite=${encodeURIComponent(code)}`
}
async function shareInvite(invite: Invite) {
const url = getInviteUrl(invite.code)
if (typeof navigator.share === 'function') {
try {
await navigator.share({
title: 'Join dttmr',
text: 'Use this link to create your account',
url,
})
return
} catch (err) {
if (err instanceof Error && err.name === 'AbortError') {
return
}
// Web Share unsupported in this context; fall through to clipboard copy.
}
}
try {
await navigator.clipboard.writeText(url)
sharedId.value = invite.id
clearTimeout(sharedTimeout)
sharedTimeout = setTimeout(() => {
sharedId.value = null
}, 1500)
} catch {
// Clipboard access denied or unavailable; nothing sensible to do.
}
}
function inviteStatus(invite: Invite): InviteStatus {
if (invite.consumed_at) return 'used'
if (invite.expires_at && new Date(invite.expires_at).getTime() < Date.now()) return 'expired'
return 'active'
}
function statusLabel(invite: Invite): string {
const status = inviteStatus(invite)
return status === 'used' ? 'Used' : status === 'expired' ? 'Expired' : 'Active'
}
// Standard "time ago"/"time until" formatter: walk unit divisions until the
// duration fits in one, so both past and future dates read naturally.
function formatRelative(dateStr: string): string {
const divisions: [number, Intl.RelativeTimeFormatUnit][] = [
[60, 'seconds'],
[60, 'minutes'],
[24, 'hours'],
[7, 'days'],
[4.34524, 'weeks'],
[12, 'months'],
[Number.POSITIVE_INFINITY, 'years'],
]
const rtf = new Intl.RelativeTimeFormat('en', { numeric: 'auto' })
let duration = (new Date(dateStr).getTime() - Date.now()) / 1000
for (const [amount, unit] of divisions) {
if (Math.abs(duration) < amount) {
return rtf.format(Math.round(duration), unit)
}
duration /= amount
}
return rtf.format(Math.round(duration), 'years')
}
function inviteDetail(invite: Invite): string {
const status = inviteStatus(invite)
if (status === 'used') {
return invite.consumed_at ? `Used ${formatRelative(invite.consumed_at)}` : 'Used'
}
if (!invite.expires_at) {
return 'No expiry'
}
return `${status === 'expired' ? 'Expired' : 'Expires'} ${formatRelative(invite.expires_at)}`
}
</script>
<template>
<section class="card info-card invites-card">
<button
type="button"
class="invites-toggle"
:aria-expanded="expanded"
aria-controls="invites-panel"
@click="toggleExpanded"
>
<span class="invites-toggle-label">
<h4>Invites</h4>
<span v-if="statusCounts" class="invites-stats">
<span class="invites-count-badge">{{ statusCounts.active }} active</span>
<span class="invites-count-badge badge-expired">{{ statusCounts.expired }} expired</span>
<span class="invites-count-badge badge-used">{{ statusCounts.used }} used</span>
<span class="invites-total-label">{{ totalInvites }} total</span>
</span>
</span>
<span class="chevron" :class="{ 'is-open': expanded }" aria-hidden="true"></span>
</button>
<div v-if="expanded" id="invites-panel" class="invites-body">
<p class="invites-hint">
Invite codes let someone new create an account. Creating, listing, and deleting invites
requires an internet connection.
</p>
<p v-if="isLoading" class="invites-loading">Loading invites</p>
<ul v-else-if="invites.length > 0" class="invite-list">
<li
v-for="invite in invites"
:key="invite.id"
class="invite-ticket"
:class="`is-${inviteStatus(invite)}`"
>
<div class="invite-ticket-main">
<code class="invite-code" :title="invite.code">{{ invite.code }}</code>
<span class="invite-status-pill" :class="`pill-${inviteStatus(invite)}`">{{
statusLabel(invite)
}}</span>
</div>
<div class="invite-ticket-meta">
<span class="invite-detail">{{ inviteDetail(invite) }}</span>
<div v-if="pendingDeleteId !== invite.id" class="invite-actions">
<button
type="button"
class="ticket-btn"
:disabled="inviteStatus(invite) !== 'active'"
:title="
inviteStatus(invite) !== 'active' ? 'Only active invites can be shared' : ''
"
@click="shareInvite(invite)"
>
{{ sharedId === invite.id ? 'Copied!' : 'Share' }}
</button>
<button
type="button"
class="ticket-btn ticket-btn-danger"
:disabled="inviteStatus(invite) === 'used'"
:title="inviteStatus(invite) === 'used' ? 'Used invites cannot be deleted' : ''"
@click="requestDelete(invite.id)"
>
Delete
</button>
</div>
<div v-else class="invite-actions">
<span class="confirm-label">Delete this invite?</span>
<button type="button" class="ticket-btn" @click="cancelDelete">No</button>
<button
type="button"
class="ticket-btn ticket-btn-danger"
:disabled="deletingId === invite.id"
@click="confirmDelete(invite.id)"
>
{{ deletingId === invite.id ? 'Deleting…' : 'Yes' }}
</button>
</div>
</div>
</li>
</ul>
<p v-else class="invites-empty">No invites yet. Generate one to invite someone.</p>
<div v-if="showPagination" class="invites-pagination">
<button
type="button"
class="page-btn"
:disabled="page <= 1 || isLoading"
aria-label="Previous page"
@click="goToPage(page - 1)"
>
</button>
<span class="pagination-info">{{ total }} invites total</span>
<button
type="button"
class="page-btn"
:disabled="page >= totalPages || isLoading"
aria-label="Next page"
@click="goToPage(page + 1)"
>
</button>
</div>
<p v-if="error" class="banner banner-error">{{ error }}</p>
<button
type="button"
class="btn btn-primary generate-btn"
:disabled="isCreating"
@click="handleCreate"
>
{{ isCreating ? 'Generating…' : '+ Generate invite' }}
</button>
</div>
</section>
</template>
<style scoped>
.invites-toggle {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
background: none;
border: none;
padding: 0;
color: inherit;
cursor: pointer;
text-align: left;
}
.invites-toggle-label {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
}
.invites-toggle-label h4 {
font-size: 0.85rem;
margin: 0;
}
.invites-stats {
display: flex;
align-items: center;
gap: 0.35rem;
flex-wrap: wrap;
}
.invites-count-badge {
font-size: 0.65rem;
font-weight: 600;
padding: 0.15rem 0.5rem;
border-radius: 999px;
background-color: var(--c-accent-bg);
color: var(--c-accent-strong);
}
.invites-count-badge.badge-expired {
background-color: var(--c-danger-bg);
color: var(--c-danger);
}
.invites-count-badge.badge-used {
background-color: var(--c-bg-elevated);
color: var(--c-text-soft);
}
.invites-total-label {
font-size: 0.65rem;
color: var(--c-text-soft);
}
.chevron {
color: var(--c-text-soft);
transition: transform 0.15s ease-in-out;
}
.chevron.is-open {
transform: rotate(180deg);
}
.invites-body {
display: flex;
flex-direction: column;
gap: 0.75rem;
margin-top: 0.85rem;
}
.invites-hint {
font-size: 0.8rem;
color: var(--c-text-soft);
margin: 0;
}
.invites-loading,
.invites-empty {
font-size: 0.85rem;
color: var(--c-text-soft);
margin: 0;
}
.invite-list {
list-style: none;
display: flex;
flex-direction: column;
gap: 0.6rem;
}
.invite-ticket {
border: 1px solid var(--c-border);
border-left: 3px solid var(--c-text-soft);
border-radius: var(--radius-md);
background-color: var(--c-bg-mute);
padding: 0.65rem 0.85rem;
}
.invite-ticket.is-active {
border-left-color: var(--c-success);
}
.invite-ticket.is-expired {
border-left-color: var(--c-danger);
}
.invite-ticket.is-used {
border-left-color: var(--c-text-soft);
opacity: 0.75;
}
.invite-ticket-main {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
flex-wrap: wrap;
}
.invite-code {
font-family: var(--font-mono);
font-size: 0.95rem;
letter-spacing: 0.06em;
color: var(--c-heading);
flex: 1 1 auto;
min-width: 0;
max-width: 24ch;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
@media (min-width: 768px) {
.invite-code {
max-width: 80ch;
}
}
.invite-status-pill {
flex-shrink: 0;
font-size: 0.65rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
padding: 0.15rem 0.5rem;
border-radius: 999px;
}
.pill-active {
background-color: var(--c-success-bg);
color: var(--c-success);
}
.pill-expired {
background-color: var(--c-danger-bg);
color: var(--c-danger);
}
.pill-used {
background-color: var(--c-bg-elevated);
color: var(--c-text-soft);
}
.invite-ticket-meta {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.4rem 0.5rem;
flex-wrap: wrap;
border-top: 1px dashed var(--c-border);
margin-top: 0.55rem;
padding-top: 0.5rem;
}
.invite-detail {
font-size: 0.75rem;
color: var(--c-text-soft);
min-width: 0;
}
.invite-actions {
display: flex;
align-items: center;
gap: 0.4rem;
margin-left: auto;
}
.confirm-label {
font-size: 0.75rem;
color: var(--c-text-soft);
}
.ticket-btn {
background: none;
border: 1px solid var(--c-border);
color: var(--c-text);
font-size: 0.72rem;
padding: 0.25rem 0.55rem;
border-radius: var(--radius-sm);
cursor: pointer;
}
.ticket-btn:hover:not(:disabled) {
border-color: var(--c-border-hover);
color: var(--c-heading);
}
.ticket-btn:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.ticket-btn-danger {
color: var(--c-danger);
border-color: rgba(193, 97, 74, 0.4);
}
.ticket-btn-danger:hover:not(:disabled) {
background-color: var(--c-danger-bg);
}
.generate-btn {
width: auto;
align-self: flex-start;
padding: 0.55rem 1.1rem;
}
.invites-pagination {
display: flex;
align-items: center;
justify-content: center;
gap: 0.6rem;
}
.pagination-info {
font-size: 0.72rem;
color: var(--c-text-soft);
}
.page-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.6rem;
height: 1.6rem;
padding: 0;
background: none;
border: 1px solid var(--c-border);
border-radius: var(--radius-sm);
color: var(--c-text);
font-size: 0.85rem;
line-height: 1;
cursor: pointer;
}
.page-btn:hover:not(:disabled) {
border-color: var(--c-border-hover);
color: var(--c-heading);
}
.page-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
</style>
+63 -3
View File
@@ -5,10 +5,11 @@ import type { LocalList } from '@/database/db'
import { useListsStore } from '@/stores/lists'
import { useDismissableMenu } from '@/composables/useDismissableMenu'
const props = defineProps<{ list: LocalList }>()
const props = defineProps<{ list: LocalList; dragging?: boolean }>()
const emit = defineEmits<{
share: [list: LocalList]
delete: [list: LocalList]
'handle-pointerdown': [event: PointerEvent]
}>()
const listsStore = useListsStore()
@@ -49,12 +50,29 @@ function handleDelete(event: Event) {
</script>
<template>
<div class="list-card card">
<div class="list-card card" :class="{ 'is-dragging': dragging }">
<button
type="button"
class="grab-handle"
aria-label="Reorder list"
title="Drag to reorder"
@pointerdown="emit('handle-pointerdown', $event)"
>
<svg class="grab-icon" viewBox="0 0 24 24" width="16" height="16" fill="currentColor">
<circle cx="9" cy="6" r="1.6" />
<circle cx="15" cy="6" r="1.6" />
<circle cx="9" cy="12" r="1.6" />
<circle cx="15" cy="12" r="1.6" />
<circle cx="9" cy="18" r="1.6" />
<circle cx="15" cy="18" r="1.6" />
</svg>
</button>
<RouterLink :to="`/lists/${list.id}`" class="list-card-link">
<div class="list-card-main">
<h3>{{ list.name }}</h3>
<p class="meta">
{{ completedCount }}/{{ totalCount }} done
<span class="mono-num">{{ completedCount }}/{{ totalCount }}</span> done
<span v-if="list.pendingSync" class="pending-tag">syncing</span>
</p>
</div>
@@ -144,6 +162,48 @@ function handleDelete(event: Event) {
border-color: var(--c-border-hover);
}
.list-card.is-dragging {
border-color: var(--c-accent-strong);
box-shadow: var(--shadow-md);
}
.grab-handle {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 44px;
margin: -0.5rem -0.2rem -0.5rem -0.5rem;
padding: 0;
background: transparent;
border: none;
border-radius: var(--radius-sm);
color: var(--c-text-soft);
cursor: grab;
touch-action: none;
-webkit-user-select: none;
user-select: none;
transition:
background-color 0.15s ease-in-out,
color 0.15s ease-in-out;
}
.grab-handle:hover {
background-color: var(--c-bg-mute);
color: var(--c-heading);
}
.list-card.is-dragging .grab-handle {
cursor: grabbing;
color: var(--c-accent-strong);
}
.grab-icon {
display: block;
pointer-events: none;
}
.list-card-link {
display: flex;
align-items: center;
+46 -14
View File
@@ -3,6 +3,7 @@ import { ref } from 'vue'
import type { LocalListItem } from '@/database/db'
import { useListsStore } from '@/stores/lists'
import { useDismissableMenu } from '@/composables/useDismissableMenu'
import DeleteListItemModal from '@/components/DeleteListItemModal.vue'
const props = defineProps<{ item: LocalListItem }>()
@@ -16,6 +17,7 @@ const editedTitle = ref(props.item.title)
// diffing against the live (possibly just-changed) prop and overwriting the
// remote edit with the untouched original text.
const originalTitle = ref(props.item.title)
const showDeleteModal = ref(false)
const {
isOpen: isMenuOpen,
containerRef: menuContainerRef,
@@ -27,6 +29,7 @@ function toggleCompleted() {
}
function startEditing() {
isMenuOpen.value = false
editedTitle.value = props.item.title
originalTitle.value = props.item.title
isEditing.value = true
@@ -35,15 +38,22 @@ function startEditing() {
function saveTitle() {
const title = editedTitle.value.trim()
if (title && title !== originalTitle.value) {
listsStore.updateListItem(props.item.id, { title })
listsStore.updateListItemTitle(props.item.id, title)
}
isEditing.value = false
}
function handleDelete(event: Event) {
event.preventDefault()
event.stopPropagation()
function handleOpenDelete() {
isMenuOpen.value = false
showDeleteModal.value = true
}
function handleCloseDelete() {
showDeleteModal.value = false
}
function handleConfirmDelete() {
showDeleteModal.value = false
listsStore.deleteListItem(props.item.id)
}
</script>
@@ -68,7 +78,7 @@ function handleDelete(event: Event) {
@keyup.escape="isEditing = false"
@blur="saveTitle"
/>
<span v-else class="title" @click="startEditing">{{ item.title }}</span>
<span v-else class="title" @click="toggleCompleted">{{ item.title }}</span>
<span v-if="item.pendingSync" class="pending-dot" title="Not yet synced"></span>
@@ -90,11 +100,25 @@ function handleDelete(event: Event) {
</button>
<div v-if="isMenuOpen" class="submenu-dropdown card" role="menu">
<button type="button" class="submenu-item" role="menuitem" @click="startEditing">
<svg
class="submenu-icon"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M17 3a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L17 3z" />
</svg>
<span>Edit title</span>
</button>
<button
type="button"
class="submenu-item submenu-item-danger"
role="menuitem"
@click="handleDelete"
@click="handleOpenDelete"
>
<svg
class="submenu-icon"
@@ -116,6 +140,13 @@ function handleDelete(event: Event) {
</button>
</div>
</div>
<DeleteListItemModal
v-if="showDeleteModal"
:item="item"
@close="handleCloseDelete"
@confirm="handleConfirmDelete"
/>
</li>
</template>
@@ -134,22 +165,23 @@ function handleDelete(event: Event) {
.checkbox {
flex-shrink: 0;
width: 22px;
height: 22px;
border-radius: 6px;
border: 1px solid var(--c-border-hover);
width: 21px;
height: 21px;
border-radius: var(--radius-sm);
border: 1.5px solid var(--c-border-hover);
background: transparent;
color: #fff;
color: var(--c-bg);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
font-size: 0.75rem;
font-size: 0.8rem;
font-weight: 700;
}
.item-row.completed .checkbox {
background: linear-gradient(135deg, var(--c-accent-strong), var(--c-accent-soft));
border-color: transparent;
background: var(--c-accent);
border-color: var(--c-accent);
}
.title {
+8 -105
View File
@@ -2,7 +2,7 @@
import { ref, onMounted, nextTick } from 'vue'
import type { LocalList } from '@/database/db'
import { useListsStore } from '@/stores/lists'
import { useEscapeKey } from '@/composables/useEscapeKey'
import BaseModal from '@/components/BaseModal.vue'
const props = defineProps<{
list: LocalList
@@ -20,8 +20,6 @@ const isSubmitting = ref(false)
const error = ref('')
const successMessage = ref('')
useEscapeKey(handleClose)
onMounted(() => {
nextTick(() => {
emailInput.value?.focus()
@@ -53,20 +51,11 @@ async function handleAddUser() {
</script>
<template>
<div class="modal-overlay" @click.self="handleClose">
<div
class="modal-card card"
role="dialog"
aria-modal="true"
aria-labelledby="share-modal-title"
<BaseModal
:title="`Share &quot;${list.name}&quot;`"
title-id="share-modal-title"
@close="handleClose"
>
<div class="modal-header">
<h3 id="share-modal-title">Share "{{ list.name }}"</h3>
<button type="button" class="close-btn" aria-label="Close modal" @click="handleClose">
</button>
</div>
<p class="modal-description">Enter an email to invite them to collaborate on this list.</p>
<form class="share-form" @submit.prevent="handleAddUser">
@@ -91,65 +80,13 @@ async function handleAddUser() {
<p v-if="error" class="banner banner-error">{{ error }}</p>
<p v-if="successMessage" class="banner banner-success">{{ successMessage }}</p>
<div class="modal-footer">
<template #footer>
<button type="button" class="btn btn-secondary" @click="handleClose">Done</button>
</div>
</div>
</div>
</template>
</BaseModal>
</template>
<style scoped>
.modal-overlay {
position: fixed;
inset: 0;
background-color: rgba(0, 0, 0, 0.65);
backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
z-index: 100;
animation: fadeIn 0.15s ease-out;
}
.modal-card {
width: 100%;
max-width: 440px;
background-color: var(--c-bg-soft);
border: 1px solid var(--c-border-hover);
border-radius: var(--radius-lg);
padding: 1.25rem 1.4rem;
box-shadow: var(--shadow-md);
animation: slideUp 0.15s ease-out;
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.5rem;
}
.modal-header h3 {
font-size: 1.1rem;
margin: 0;
}
.close-btn {
background: none;
border: none;
color: var(--c-text-soft);
font-size: 1.1rem;
cursor: pointer;
padding: 0.2rem 0.4rem;
border-radius: var(--radius-sm);
line-height: 1;
}
.close-btn:hover {
color: var(--c-heading);
}
.modal-description {
font-size: 0.85rem;
color: var(--c-text-soft);
@@ -172,45 +109,11 @@ async function handleAddUser() {
padding: 0.65rem 1.2rem;
}
.modal-footer {
display: flex;
justify-content: flex-end;
margin-top: 1rem;
}
.modal-footer .btn {
width: auto;
padding: 0.5rem 1.2rem;
}
.banner-success {
background-color: rgba(52, 211, 153, 0.12);
border: 1px solid rgba(52, 211, 153, 0.4);
color: var(--c-success);
margin-top: 0.75rem;
}
.banner-error {
margin-top: 0.75rem;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(8px) scale(0.98);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
</style>
@@ -0,0 +1,79 @@
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import ConfirmDeleteModal from '../ConfirmDeleteModal.vue'
describe('ConfirmDeleteModal', () => {
it('renders title, description, and default confirm label', () => {
const wrapper = mount(ConfirmDeleteModal, {
props: {
title: 'Delete "Groceries"?',
description: 'Are you sure?',
},
})
expect(wrapper.text()).toContain('Delete "Groceries"?')
expect(wrapper.text()).toContain('Are you sure?')
expect(wrapper.find('.confirm-delete-btn').text()).toBe('Delete')
expect(wrapper.find('.cancel-btn').text()).toBe('Cancel')
})
it('renders a custom confirm label', () => {
const wrapper = mount(ConfirmDeleteModal, {
props: {
title: 'Delete "Apples"?',
description: 'Are you sure?',
confirmLabel: 'Delete item',
},
})
expect(wrapper.find('.confirm-delete-btn').text()).toBe('Delete item')
})
it('emits confirm event when Delete button is clicked', async () => {
const wrapper = mount(ConfirmDeleteModal, {
props: {
title: 'Delete "Groceries"?',
description: 'Are you sure?',
},
})
await wrapper.find('.confirm-delete-btn').trigger('click')
expect(wrapper.emitted('confirm')).toBeTruthy()
})
it('emits close event when Cancel button is clicked', async () => {
const wrapper = mount(ConfirmDeleteModal, {
props: {
title: 'Delete "Groceries"?',
description: 'Are you sure?',
},
})
await wrapper.find('.cancel-btn').trigger('click')
expect(wrapper.emitted('close')).toBeTruthy()
})
it('emits close event when close icon button is clicked', async () => {
const wrapper = mount(ConfirmDeleteModal, {
props: {
title: 'Delete "Groceries"?',
description: 'Are you sure?',
},
})
await wrapper.find('.close-btn').trigger('click')
expect(wrapper.emitted('close')).toBeTruthy()
})
it('emits close event when clicking overlay background', async () => {
const wrapper = mount(ConfirmDeleteModal, {
props: {
title: 'Delete "Groceries"?',
description: 'Are you sure?',
},
})
await wrapper.find('.modal-overlay').trigger('click')
expect(wrapper.emitted('close')).toBeTruthy()
})
})
@@ -0,0 +1,50 @@
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import DeleteListItemModal from '../DeleteListItemModal.vue'
import type { LocalListItem } from '@/database/db'
describe('DeleteListItemModal', () => {
const sampleItem: LocalListItem = {
id: 'item-1',
list_id: 'list-1',
title: 'Apples',
is_completed: false,
created_at: '2026-08-21T00:00:00.000Z',
modified_at: '2026-08-21T00:00:00.000Z',
}
it('renders modal with item title and confirmation prompt', () => {
const wrapper = mount(DeleteListItemModal, {
props: {
item: sampleItem,
},
})
expect(wrapper.text()).toContain('Delete "Apples"?')
expect(wrapper.text()).toContain('Are you sure you want to delete this item?')
expect(wrapper.find('.confirm-delete-btn').text()).toBe('Delete item')
expect(wrapper.find('.cancel-btn').text()).toBe('Cancel')
})
it('emits confirm event when Delete button is clicked', async () => {
const wrapper = mount(DeleteListItemModal, {
props: {
item: sampleItem,
},
})
await wrapper.find('.confirm-delete-btn').trigger('click')
expect(wrapper.emitted('confirm')).toBeTruthy()
})
it('emits close event when Cancel button is clicked', async () => {
const wrapper = mount(DeleteListItemModal, {
props: {
item: sampleItem,
},
})
await wrapper.find('.cancel-btn').trigger('click')
expect(wrapper.emitted('close')).toBeTruthy()
})
})
@@ -0,0 +1,422 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import InvitesPanel from '../InvitesPanel.vue'
import * as invitesApi from '@/api/invites'
import type { Invite, PaginatedInvites } from '@/types/invite'
function paginated(data: Invite[], total = data.length): PaginatedInvites {
return { data, total, count: data.length }
}
describe('InvitesPanel', () => {
beforeEach(() => {
vi.restoreAllMocks()
Object.defineProperty(navigator, 'onLine', { value: true, configurable: true })
Object.defineProperty(navigator, 'clipboard', {
value: { writeText: vi.fn<(text: string) => Promise<void>>().mockResolvedValue(undefined) },
configurable: true,
})
// jsdom has no Web Share API; tests that want it define it explicitly.
Reflect.deleteProperty(navigator, 'share')
// Every mount fetches status counts once on mount; give it a harmless
// default so tests that don't care about counts don't hit real fetch.
vi.spyOn(invitesApi, 'getInviteStatusApi').mockResolvedValue({
active: 0,
expired: 0,
used: 0,
})
})
it('renders collapsed by default without loading invites', () => {
const getSpy = vi.spyOn(invitesApi, 'getInvitesApi')
const wrapper = mount(InvitesPanel)
expect(wrapper.text()).toContain('Invites')
expect(wrapper.find('#invites-panel').exists()).toBe(false)
expect(getSpy).not.toHaveBeenCalled()
})
it('fetches invite status counts on mount and shows them in the header while collapsed', async () => {
const statusSpy = vi.spyOn(invitesApi, 'getInviteStatusApi').mockResolvedValueOnce({
active: 12,
expired: 2,
used: 8,
})
const wrapper = mount(InvitesPanel)
await flushPromises()
expect(statusSpy).toHaveBeenCalledTimes(1)
expect(wrapper.find('#invites-panel').exists()).toBe(false)
expect(wrapper.text()).toContain('12 active')
expect(wrapper.text()).toContain('2 expired')
expect(wrapper.text()).toContain('8 used')
expect(wrapper.text()).toContain('22 total')
})
it('does not fetch status counts on mount when offline', () => {
Object.defineProperty(navigator, 'onLine', { value: false, configurable: true })
const statusSpy = vi.spyOn(invitesApi, 'getInviteStatusApi')
mount(InvitesPanel)
expect(statusSpy).not.toHaveBeenCalled()
})
it('hides status badges without an error banner when the status endpoint fails', async () => {
vi.spyOn(invitesApi, 'getInviteStatusApi').mockRejectedValueOnce(new Error('boom'))
const wrapper = mount(InvitesPanel)
await flushPromises()
expect(wrapper.find('.invites-stats').exists()).toBe(false)
expect(wrapper.find('.banner-error').exists()).toBe(false)
})
it('does not refetch status counts when paginating', async () => {
const statusSpy = vi.spyOn(invitesApi, 'getInviteStatusApi').mockResolvedValue({
active: 11,
expired: 0,
used: 0,
})
const page1 = Array.from({ length: 10 }, (_, i) => ({ id: `invite-${i}`, code: `CODE${i}` }))
const page2 = [{ id: 'invite-10', code: 'CODE10' }]
vi.spyOn(invitesApi, 'getInvitesApi')
.mockResolvedValueOnce(paginated(page1, 11))
.mockResolvedValueOnce(paginated(page2, 11))
const wrapper = mount(InvitesPanel)
await wrapper.find('.invites-toggle').trigger('click')
await flushPromises()
expect(statusSpy).toHaveBeenCalledTimes(1)
const [, nextBtn] = wrapper.findAll('.page-btn')
await nextBtn?.trigger('click')
await flushPromises()
expect(statusSpy).toHaveBeenCalledTimes(1)
})
it('refetches status counts after creating an invite', async () => {
const statusSpy = vi.spyOn(invitesApi, 'getInviteStatusApi').mockResolvedValue({
active: 1,
expired: 0,
used: 0,
})
const newInvite: Invite = { id: 'invite-new', code: 'NEWCODE1' }
vi.spyOn(invitesApi, 'getInvitesApi')
.mockResolvedValueOnce(paginated([]))
.mockResolvedValueOnce(paginated([newInvite]))
vi.spyOn(invitesApi, 'createInviteApi').mockResolvedValueOnce(newInvite)
const wrapper = mount(InvitesPanel)
await wrapper.find('.invites-toggle').trigger('click')
await flushPromises()
expect(statusSpy).toHaveBeenCalledTimes(1)
await wrapper.find('.generate-btn').trigger('click')
await flushPromises()
expect(statusSpy).toHaveBeenCalledTimes(2)
})
it('refetches status counts after deleting an invite', async () => {
const statusSpy = vi.spyOn(invitesApi, 'getInviteStatusApi').mockResolvedValue({
active: 0,
expired: 0,
used: 0,
})
const mockInvite: Invite = { id: 'invite-1', code: 'ABC123' }
vi.spyOn(invitesApi, 'getInvitesApi')
.mockResolvedValueOnce(paginated([mockInvite]))
.mockResolvedValueOnce(paginated([]))
vi.spyOn(invitesApi, 'deleteInviteApi').mockResolvedValueOnce()
const wrapper = mount(InvitesPanel)
await wrapper.find('.invites-toggle').trigger('click')
await flushPromises()
expect(statusSpy).toHaveBeenCalledTimes(1)
await wrapper.find('.ticket-btn-danger').trigger('click')
const confirmButtons = wrapper.findAll('.ticket-btn-danger')
await confirmButtons[confirmButtons.length - 1]?.trigger('click')
await flushPromises()
expect(statusSpy).toHaveBeenCalledTimes(2)
})
it('loads and displays invites on expand', async () => {
const mockInvites: Invite[] = [
{ id: 'invite-1', code: 'ABC123', expires_at: '2099-01-01T00:00:00.000Z' },
{
id: 'invite-2',
code: 'USEDCODE',
expires_at: '2099-01-01T00:00:00.000Z',
consumed_at: '2026-01-01T00:00:00.000Z',
},
]
const getSpy = vi
.spyOn(invitesApi, 'getInvitesApi')
.mockResolvedValueOnce(paginated(mockInvites))
const wrapper = mount(InvitesPanel)
await wrapper.find('.invites-toggle').trigger('click')
await flushPromises()
expect(getSpy).toHaveBeenCalledWith({ page: 1, count: 10 })
expect(wrapper.text()).toContain('ABC123')
expect(wrapper.text()).toContain('USEDCODE')
expect(wrapper.text()).toContain('Active')
expect(wrapper.text()).toContain('Used')
})
it('shows an offline message instead of fetching when expanded offline', async () => {
Object.defineProperty(navigator, 'onLine', { value: false, configurable: true })
const getSpy = vi.spyOn(invitesApi, 'getInvitesApi')
const wrapper = mount(InvitesPanel)
await wrapper.find('.invites-toggle').trigger('click')
expect(getSpy).not.toHaveBeenCalled()
expect(wrapper.find('.banner-error').text()).toContain('must be online')
})
it('shows empty state when there are no invites', async () => {
vi.spyOn(invitesApi, 'getInvitesApi').mockResolvedValueOnce(paginated([]))
const wrapper = mount(InvitesPanel)
await wrapper.find('.invites-toggle').trigger('click')
await flushPromises()
expect(wrapper.text()).toContain('No invites yet')
})
it('does not show pagination controls when everything fits on one page', async () => {
vi.spyOn(invitesApi, 'getInvitesApi').mockResolvedValueOnce(
paginated([{ id: 'invite-1', code: 'ABC123' }], 1),
)
const wrapper = mount(InvitesPanel)
await wrapper.find('.invites-toggle').trigger('click')
await flushPromises()
expect(wrapper.find('.invites-pagination').exists()).toBe(false)
})
it('shows pagination controls and total count when there is more than one page', async () => {
const page1 = Array.from({ length: 10 }, (_, i) => ({ id: `invite-${i}`, code: `CODE${i}` }))
vi.spyOn(invitesApi, 'getInvitesApi').mockResolvedValueOnce(paginated(page1, 15))
const wrapper = mount(InvitesPanel)
await wrapper.find('.invites-toggle').trigger('click')
await flushPromises()
expect(wrapper.find('.invites-pagination').text()).toContain('15 invites total')
const [prevBtn, nextBtn] = wrapper.findAll('.page-btn')
expect((prevBtn!.element as HTMLButtonElement).disabled).toBe(true)
expect((nextBtn!.element as HTMLButtonElement).disabled).toBe(false)
})
it('navigates to the next page when the forward button is clicked', async () => {
const page1 = Array.from({ length: 10 }, (_, i) => ({ id: `invite-${i}`, code: `CODE${i}` }))
const page2 = [{ id: 'invite-10', code: 'CODE10' }]
const getSpy = vi
.spyOn(invitesApi, 'getInvitesApi')
.mockResolvedValueOnce(paginated(page1, 11))
.mockResolvedValueOnce(paginated(page2, 11))
const wrapper = mount(InvitesPanel)
await wrapper.find('.invites-toggle').trigger('click')
await flushPromises()
const [, nextBtn] = wrapper.findAll('.page-btn')
await nextBtn?.trigger('click')
await flushPromises()
expect(getSpy).toHaveBeenLastCalledWith({ page: 2, count: 10 })
expect(wrapper.text()).toContain('CODE10')
const [prevBtn, nextBtnAfter] = wrapper.findAll('.page-btn')
expect((prevBtn!.element as HTMLButtonElement).disabled).toBe(false)
expect((nextBtnAfter!.element as HTMLButtonElement).disabled).toBe(true)
})
it('generates a new invite, reloads page one, and prepends it to the list', async () => {
const newInvite: Invite = { id: 'invite-new', code: 'NEWCODE1' }
vi.spyOn(invitesApi, 'getInvitesApi')
.mockResolvedValueOnce(paginated([]))
.mockResolvedValueOnce(paginated([newInvite]))
vi.spyOn(invitesApi, 'createInviteApi').mockResolvedValueOnce(newInvite)
const wrapper = mount(InvitesPanel)
await wrapper.find('.invites-toggle').trigger('click')
await flushPromises()
await wrapper.find('.generate-btn').trigger('click')
await flushPromises()
expect(wrapper.text()).toContain('NEWCODE1')
})
it('shares the newly created invite automatically', async () => {
const newInvite: Invite = { id: 'invite-new', code: 'NEWCODE1' }
vi.spyOn(invitesApi, 'getInvitesApi')
.mockResolvedValueOnce(paginated([]))
.mockResolvedValueOnce(paginated([newInvite]))
vi.spyOn(invitesApi, 'createInviteApi').mockResolvedValueOnce(newInvite)
const wrapper = mount(InvitesPanel)
await wrapper.find('.invites-toggle').trigger('click')
await flushPromises()
await wrapper.find('.generate-btn').trigger('click')
await flushPromises()
expect(navigator.clipboard.writeText).toHaveBeenCalledWith(
expect.stringContaining('?invite=NEWCODE1'),
)
})
it('shares an existing invite link via the clipboard when Web Share is unavailable', async () => {
const mockInvite: Invite = { id: 'invite-1', code: 'ABC123' }
vi.spyOn(invitesApi, 'getInvitesApi').mockResolvedValueOnce(paginated([mockInvite]))
const wrapper = mount(InvitesPanel)
await wrapper.find('.invites-toggle').trigger('click')
await flushPromises()
await wrapper.find('.ticket-btn').trigger('click')
await flushPromises()
expect(navigator.clipboard.writeText).toHaveBeenCalledWith(
expect.stringContaining('?invite=ABC123'),
)
expect(wrapper.text()).toContain('Copied!')
})
it('uses the Web Share API instead of the clipboard when available', async () => {
const shareMock = vi.fn<(data: ShareData) => Promise<void>>().mockResolvedValue(undefined)
Object.defineProperty(navigator, 'share', { value: shareMock, configurable: true })
const mockInvite: Invite = { id: 'invite-1', code: 'ABC123' }
vi.spyOn(invitesApi, 'getInvitesApi').mockResolvedValueOnce(paginated([mockInvite]))
const wrapper = mount(InvitesPanel)
await wrapper.find('.invites-toggle').trigger('click')
await flushPromises()
await wrapper.find('.ticket-btn').trigger('click')
await flushPromises()
expect(shareMock).toHaveBeenCalledWith(
expect.objectContaining({ url: expect.stringContaining('?invite=ABC123') }),
)
expect(navigator.clipboard.writeText).not.toHaveBeenCalled()
})
it('deletes an invite after confirming', async () => {
const mockInvite: Invite = { id: 'invite-1', code: 'ABC123' }
vi.spyOn(invitesApi, 'getInvitesApi')
.mockResolvedValueOnce(paginated([mockInvite]))
.mockResolvedValueOnce(paginated([]))
const deleteSpy = vi.spyOn(invitesApi, 'deleteInviteApi').mockResolvedValueOnce()
const wrapper = mount(InvitesPanel)
await wrapper.find('.invites-toggle').trigger('click')
await flushPromises()
await wrapper.find('.ticket-btn-danger').trigger('click')
expect(wrapper.text()).toContain('Delete this invite?')
const confirmButtons = wrapper.findAll('.ticket-btn-danger')
await confirmButtons[confirmButtons.length - 1]?.trigger('click')
await flushPromises()
expect(deleteSpy).toHaveBeenCalledWith('invite-1')
expect(wrapper.text()).toContain('No invites yet')
})
it('steps back a page when deleting the last item on a page past the first', async () => {
const page1 = Array.from({ length: 10 }, (_, i) => ({ id: `invite-${i}`, code: `CODE${i}` }))
const page2 = [{ id: 'invite-10', code: 'CODE10' }]
const getSpy = vi
.spyOn(invitesApi, 'getInvitesApi')
.mockResolvedValueOnce(paginated(page1, 11))
.mockResolvedValueOnce(paginated(page2, 11))
.mockResolvedValueOnce(paginated(page1, 10))
vi.spyOn(invitesApi, 'deleteInviteApi').mockResolvedValueOnce()
const wrapper = mount(InvitesPanel)
await wrapper.find('.invites-toggle').trigger('click')
await flushPromises()
const [, nextBtn] = wrapper.findAll('.page-btn')
await nextBtn?.trigger('click')
await flushPromises()
await wrapper.find('.ticket-btn-danger').trigger('click')
const confirmButtons = wrapper.findAll('.ticket-btn-danger')
await confirmButtons[confirmButtons.length - 1]?.trigger('click')
await flushPromises()
expect(getSpy).toHaveBeenLastCalledWith({ page: 1, count: 10 })
})
it('disables delete for already-used invites', async () => {
const usedInvite: Invite = {
id: 'invite-1',
code: 'USEDCODE',
consumed_at: '2026-01-01T00:00:00.000Z',
}
vi.spyOn(invitesApi, 'getInvitesApi').mockResolvedValueOnce(paginated([usedInvite]))
const wrapper = mount(InvitesPanel)
await wrapper.find('.invites-toggle').trigger('click')
await flushPromises()
const deleteBtn = wrapper.find('.ticket-btn-danger')
expect((deleteBtn.element as HTMLButtonElement).disabled).toBe(true)
})
it('disables share for used and expired invites, but leaves expired invites deletable', async () => {
const usedInvite: Invite = {
id: 'invite-1',
code: 'USEDCODE',
consumed_at: '2026-01-01T00:00:00.000Z',
}
const expiredInvite: Invite = {
id: 'invite-2',
code: 'EXPCODE',
expires_at: '2020-01-01T00:00:00.000Z',
}
vi.spyOn(invitesApi, 'getInvitesApi').mockResolvedValueOnce(
paginated([usedInvite, expiredInvite]),
)
const wrapper = mount(InvitesPanel)
await wrapper.find('.invites-toggle').trigger('click')
await flushPromises()
const shareButtons = wrapper.findAll('.ticket-btn:not(.ticket-btn-danger)')
expect((shareButtons[0]!.element as HTMLButtonElement).disabled).toBe(true)
expect((shareButtons[1]!.element as HTMLButtonElement).disabled).toBe(true)
const deleteButtons = wrapper.findAll('.ticket-btn-danger')
expect((deleteButtons[0]!.element as HTMLButtonElement).disabled).toBe(true)
expect((deleteButtons[1]!.element as HTMLButtonElement).disabled).toBe(false)
})
it('shows an error banner when loading invites fails', async () => {
vi.spyOn(invitesApi, 'getInvitesApi').mockRejectedValueOnce(new Error('Network error'))
const wrapper = mount(InvitesPanel)
await wrapper.find('.invites-toggle').trigger('click')
await flushPromises()
expect(wrapper.find('.banner-error').text()).toContain('Network error')
})
})
function flushPromises() {
return new Promise((resolve) => setTimeout(resolve))
}
+67 -3
View File
@@ -47,7 +47,39 @@ describe('ListItemRow', () => {
expect(wrapper.find('.submenu-dropdown').exists()).toBe(false)
})
it('deletes item when Delete item is clicked in submenu', async () => {
it('toggles completed state when the title text is clicked', async () => {
const wrapper = mount(ListItemRow, {
props: {
item: sampleItem,
},
})
const listsStore = useListsStore()
const toggleSpy = vi.spyOn(listsStore, 'setListItemCompleted').mockResolvedValue()
await wrapper.find('.title').trigger('click')
expect(toggleSpy).toHaveBeenCalledWith('item-1', true)
})
it('opens the title editor via the Edit title submenu item', async () => {
const wrapper = mount(ListItemRow, {
props: {
item: sampleItem,
},
})
await wrapper.find('.menu-trigger-btn').trigger('click')
const editBtn = wrapper.find('.submenu-item:not(.submenu-item-danger)')
expect(editBtn.text()).toContain('Edit title')
await editBtn.trigger('click')
expect(wrapper.find('.title-input').exists()).toBe(true)
expect(wrapper.find('.submenu-dropdown').exists()).toBe(false)
})
it('opens a confirmation modal when Delete item is clicked in submenu', async () => {
const wrapper = mount(ListItemRow, {
props: {
item: sampleItem,
@@ -61,10 +93,42 @@ describe('ListItemRow', () => {
await wrapper.find('.menu-trigger-btn').trigger('click')
expect(wrapper.find('.submenu-dropdown').exists()).toBe(true)
// 2nd click: delete item
// 2nd click: opens confirmation modal, doesn't delete yet
await wrapper.find('.submenu-item-danger').trigger('click')
expect(deleteSpy).toHaveBeenCalledWith('item-1')
expect(wrapper.find('.submenu-dropdown').exists()).toBe(false)
expect(deleteSpy).not.toHaveBeenCalled()
const modal = wrapper.findComponent({ name: 'DeleteListItemModal' })
expect(modal.exists()).toBe(true)
expect(modal.text()).toContain('Delete "Apples"?')
// Confirm deletion in modal
await modal.find('.confirm-delete-btn').trigger('click')
expect(deleteSpy).toHaveBeenCalledWith('item-1')
expect(wrapper.findComponent({ name: 'DeleteListItemModal' }).exists()).toBe(false)
})
it('cancels item deletion when cancel is clicked in confirmation modal', async () => {
const wrapper = mount(ListItemRow, {
props: {
item: sampleItem,
},
})
const listsStore = useListsStore()
const deleteSpy = vi.spyOn(listsStore, 'deleteListItem').mockResolvedValue()
await wrapper.find('.menu-trigger-btn').trigger('click')
await wrapper.find('.submenu-item-danger').trigger('click')
const modal = wrapper.findComponent({ name: 'DeleteListItemModal' })
expect(modal.exists()).toBe(true)
await modal.find('.cancel-btn').trigger('click')
expect(deleteSpy).not.toHaveBeenCalled()
expect(wrapper.findComponent({ name: 'DeleteListItemModal' }).exists()).toBe(false)
})
})
+129
View File
@@ -0,0 +1,129 @@
import { ref, type Ref } from 'vue'
import type { LocalList } from '@/database/db'
// Matches the CSS transition duration on `.list-row` (see ListsView.vue) -
// how long the dragged row's "snap into place" animation takes after drop,
// before handing the final order back to the caller.
const SETTLE_MS = 220
interface SlotRect {
top: number
height: number
}
// Drag-to-reorder for the lists overview page. Deliberately built on raw
// Pointer Events rather than HTML5 drag-and-drop, which has no usable touch
// story - this needs to work as a one-finger drag on a phone first.
//
// The dragged row stays in the v-for (rather than becoming a floating
// clone): its transform is computed each pointermove as the delta between
// where the pointer says it should be and where it currently sits in the
// (already reordered) array. Because `items` gets live-spliced as the
// pointer crosses sibling midpoints, that natural position jumps in whole
// slot-steps while the transform absorbs the remainder - the row tracks the
// finger with no lag and no double-counted offset. Every other row's shift
// is left entirely to <TransitionGroup>'s built-in FLIP move animation.
export function useListDragReorder(
items: Ref<LocalList[]>,
onReorder: (orderedIds: string[]) => void,
) {
const draggingId = ref<string | null>(null)
const isPointerActive = ref(false)
const dragOffsetPx = ref(0)
const itemEls = new Map<string, HTMLElement>()
let slots: SlotRect[] = []
let startClientY = 0
let startTop = 0
let startHeight = 0
let activePointerId: number | null = null
let settleTimeout: ReturnType<typeof setTimeout> | null = null
function setItemRef(id: string, el: Element | null) {
if (el) itemEls.set(id, el as HTMLElement)
else itemEls.delete(id)
}
function onPointerDown(id: string, event: PointerEvent) {
if (event.pointerType === 'mouse' && event.button !== 0) return
const el = itemEls.get(id)
if (!el) return
if (settleTimeout !== null) {
clearTimeout(settleTimeout)
settleTimeout = null
}
event.preventDefault()
activePointerId = event.pointerId
// Fixed physical slot positions for this drag, captured once up front.
// Reordering `items` mid-drag doesn't shift these - slot i always means
// "the i-th row's on-screen position", independent of which id currently
// occupies it - so the target-slot math below never has to re-measure a
// layout our own reordering just changed.
slots = items.value.map((item) => {
const itemEl = itemEls.get(item.id)
const rect = itemEl?.getBoundingClientRect()
return { top: rect?.top ?? 0, height: rect?.height ?? 0 }
})
const rect = el.getBoundingClientRect()
startTop = rect.top
startHeight = rect.height
startClientY = event.clientY
draggingId.value = id
isPointerActive.value = true
dragOffsetPx.value = 0
window.addEventListener('pointermove', onPointerMove)
window.addEventListener('pointerup', onPointerUp)
window.addEventListener('pointercancel', onPointerUp)
}
function onPointerMove(event: PointerEvent) {
if (!isPointerActive.value || event.pointerId !== activePointerId) return
event.preventDefault()
const desiredTop = startTop + (event.clientY - startClientY)
const draggedCenter = desiredTop + startHeight / 2
let targetIndex = slots.findIndex((slot) => draggedCenter < slot.top + slot.height / 2)
if (targetIndex === -1) targetIndex = slots.length - 1
const currentIndex = items.value.findIndex((item) => item.id === draggingId.value)
if (currentIndex !== -1 && targetIndex !== currentIndex) {
const reordered = [...items.value]
const moved = reordered.splice(currentIndex, 1)[0]
if (moved) {
reordered.splice(targetIndex, 0, moved)
items.value = reordered
}
}
const settledIndex = items.value.findIndex((item) => item.id === draggingId.value)
dragOffsetPx.value = desiredTop - (slots[settledIndex]?.top ?? startTop)
}
function onPointerUp(event: PointerEvent) {
if (!isPointerActive.value || event.pointerId !== activePointerId) return
window.removeEventListener('pointermove', onPointerMove)
window.removeEventListener('pointerup', onPointerUp)
window.removeEventListener('pointercancel', onPointerUp)
activePointerId = null
isPointerActive.value = false
// Let the row's CSS transition animate the residual offset back to 0
// (see ListsView.vue: transitions are suppressed only while
// isPointerActive), then hand back the final order.
dragOffsetPx.value = 0
const finalIds = items.value.map((item) => item.id)
settleTimeout = setTimeout(() => {
settleTimeout = null
draggingId.value = null
onReorder(finalIds)
}, SETTLE_MS)
}
return { draggingId, isPointerActive, dragOffsetPx, setItemRef, onPointerDown }
}
+4 -2
View File
@@ -4,8 +4,9 @@ import type {
ListItem,
CreateListPayload,
CreateListItemPayload,
UpdateListItemPayload,
SetListItemCompletedPayload,
SetListItemTitlePayload,
OrderListsPayload,
} from '@/types/list'
export interface LocalList extends List {
@@ -36,10 +37,11 @@ interface SyncQueueEntryBase {
type SyncOperationPayloads = {
createList: CreateListPayload
createListItem: CreateListItemPayload
updateListItem: UpdateListItemPayload
updateListItemTitle: SetListItemTitlePayload
setListItemCompleted: SetListItemCompletedPayload
deleteList: DeleteListPayload
deleteListItem: DeleteListItemPayload
orderLists: OrderListsPayload
}
export type SyncOperationType = keyof SyncOperationPayloads
+55
View File
@@ -0,0 +1,55 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
import router from '../index'
import { useAuthStore } from '@/stores/auth'
describe('router', () => {
beforeEach(() => {
setActivePinia(createPinia())
localStorage.clear()
})
it('redirects unauthenticated access to protected routes to login', async () => {
await router.push('/')
expect(router.currentRoute.value.name).toBe('login')
})
it('allows authenticated access to protected routes', async () => {
const authStore = useAuthStore()
authStore.setTokens({ access_token: 'token', refresh_token: 'refresh' })
await router.push('/')
expect(router.currentRoute.value.name).toBe('lists')
})
it('redirects any route carrying ?invite= to the (unlisted) register route', async () => {
await router.push('/?invite=abc123')
expect(router.currentRoute.value.name).toBe('register')
expect(router.currentRoute.value.query.invite).toBe('abc123')
})
it('redirects an authenticated route with an invite code to register too', async () => {
const authStore = useAuthStore()
authStore.setTokens({ access_token: 'token', refresh_token: 'refresh' })
await router.push('/account?invite=abc123')
expect(router.currentRoute.value.name).toBe('register')
expect(router.currentRoute.value.query.invite).toBe('abc123')
})
it('does not redirect when already navigating to register with an invite code', async () => {
await router.push('/register?invite=xyz')
expect(router.currentRoute.value.name).toBe('register')
})
it('does not redirect to register when there is no invite code', async () => {
await router.push('/login')
expect(router.currentRoute.value.name).toBe('login')
})
})
+27 -3
View File
@@ -18,15 +18,33 @@ const router = createRouter({
meta: { requiresAuth: true },
props: true,
},
{
path: '/exercises',
name: 'exercises',
component: () => import('../views/ExercisesView.vue'),
meta: { requiresAuth: true },
},
{
path: '/login',
name: 'login',
component: () => import('../views/LoginView.vue'),
},
{
path: '/about',
name: 'about',
component: () => import('../views/AboutView.vue'),
path: '/cv',
name: 'cv',
component: () => import('../views/CvView.vue'),
},
{
path: '/account',
name: 'account',
component: () => import('../views/AccountView.vue'),
},
// Intentionally not linked from anywhere in the UI — reached only via an
// invite link (see router.beforeEach below) or by typing the URL directly.
{
path: '/register',
name: 'register',
component: () => import('../views/RegisterView.vue'),
},
],
})
@@ -34,6 +52,12 @@ const router = createRouter({
router.beforeEach((to) => {
const authStore = useAuthStore()
// An invite link may point anywhere (e.g. the app root) so it still works
// if the recipient doesn't have the app installed/bookmarked at /register.
if (to.name !== 'register' && typeof to.query.invite === 'string' && to.query.invite) {
return { name: 'register', query: { invite: to.query.invite } }
}
if (to.meta.requiresAuth && !authStore.isAuthenticated) {
return { name: 'login', query: { redirect: to.fullPath } }
}
+121 -10
View File
@@ -109,12 +109,13 @@ const listsApiMocks = vi.hoisted(() => ({
createListApi: vi.fn<() => Promise<unknown>>(),
getListItemsApi: vi.fn<() => Promise<unknown>>(),
createListItemApi: vi.fn<() => Promise<unknown>>(),
updateListItemApi: vi.fn<() => Promise<unknown>>(),
updateListItemTitleApi: vi.fn<() => Promise<unknown>>(),
setListItemCompletedApi: vi.fn<() => Promise<unknown>>(),
addUserToListApi: vi.fn<() => Promise<unknown>>(),
removeUserFromListApi: vi.fn<() => Promise<unknown>>(),
deleteListApi: vi.fn<() => Promise<unknown>>(),
deleteListItemApi: vi.fn<() => Promise<unknown>>(),
orderListsApi: vi.fn<() => Promise<unknown>>(),
}))
vi.mock('@/api/lists', () => listsApiMocks)
@@ -140,6 +141,11 @@ describe('useListsStore', () => {
Object.defineProperty(navigator, 'onLine', { value: true, configurable: true })
// sync() is a no-op for anonymous visitors (see lists.ts); these tests
// exercise the authenticated sync path, so seed a logged-in session.
localStorage.setItem('access_token', 'test-access-token')
localStorage.setItem('refresh_token', 'test-refresh-token')
// Default the read endpoints to an empty result so that the pullFromServer()
// step chained onto every sync() call doesn't interfere with unrelated tests.
listsApiMocks.getListsApi.mockResolvedValue([])
@@ -157,14 +163,13 @@ describe('useListsStore', () => {
listsApiMocks.getListsApi.mockResolvedValueOnce([{ id: 'server-id-1', name: 'Groceries' }])
const store = useListsStore()
const localList = await store.createList('Groceries', [])
const localList = await store.createList('Groceries')
// wait for the fire-and-forget sync triggered by createList to settle
await store.sync()
expect(listsApiMocks.createListApi).toHaveBeenCalledWith({
name: 'Groceries',
user_ids: [],
})
expect(store.lists.find((list) => list.id === localList.id)).toBeUndefined()
const synced = store.lists.find((list) => list.id === 'server-id-1')
@@ -207,29 +212,28 @@ describe('useListsStore', () => {
expect(store.error).toBe('Network error')
})
it('updates a list item locally and pushes the change to the server', async () => {
it('updates a list item title locally and pushes the change via the dedicated endpoint', async () => {
listsApiMocks.createListItemApi.mockResolvedValueOnce({
id: 'server-item-2',
list_id: '',
title: 'Eggs',
is_completed: false,
})
listsApiMocks.updateListItemApi.mockResolvedValueOnce(undefined)
listsApiMocks.updateListItemTitleApi.mockResolvedValueOnce(undefined)
const store = useListsStore()
await store.createListItem('list-1', 'Eggs')
await store.sync()
const created = store.listItems.find((entry) => entry.title === 'Eggs')!
await store.updateListItem(created.id, { is_completed: true })
await store.updateListItemTitle(created.id, 'Free-range eggs')
await store.sync()
expect(listsApiMocks.updateListItemApi).toHaveBeenCalledWith({
list_item_id: created.id,
is_completed: true,
expect(listsApiMocks.updateListItemTitleApi).toHaveBeenCalledWith(created.id, {
title: 'Free-range eggs',
})
const updated = store.listItems.find((entry) => entry.id === created.id)
expect(updated?.is_completed).toBe(true)
expect(updated?.title).toBe('Free-range eggs')
expect(updated?.pendingSync).toBe(false)
})
@@ -244,6 +248,16 @@ describe('useListsStore', () => {
expect(store.pendingCount).toBe(1)
})
it('does not attempt to sync when logged out, e.g. an anonymous visitor on a public invite link', async () => {
localStorage.removeItem('access_token')
localStorage.removeItem('refresh_token')
const store = useListsStore()
await store.sync()
expect(listsApiMocks.getListsApi).not.toHaveBeenCalled()
})
it('sets a list item completed locally and pushes it via the dedicated endpoint', async () => {
listsApiMocks.createListItemApi.mockResolvedValueOnce({
id: 'server-item-3',
@@ -518,6 +532,103 @@ describe('useListsStore', () => {
expect(store.pendingCount).toBe(0)
})
it('sorts lists by position, breaking ties (e.g. two lists both at position 0) by newest first', async () => {
const store = useListsStore()
await fakeDb.lists.put({
id: 'list-b',
name: 'B',
position: 1,
created_at: '2024-01-01T00:00:00.000Z',
pendingSync: false,
})
await fakeDb.lists.put({
id: 'list-a',
name: 'A',
position: 0,
created_at: '2024-01-02T00:00:00.000Z',
pendingSync: false,
})
// Newly created lists always come back from the server at position 0 (so
// a new list is always on top), so a not-yet-synced local list (no
// position of its own yet, defaulting to 0) must also win the tiebreak
// against any existing position-0 list by virtue of being newest.
await fakeDb.lists.put({
id: 'list-new',
name: 'New',
created_at: '2024-01-03T00:00:00.000Z',
pendingSync: true,
})
await store.refresh()
expect(store.sortedLists.map((list) => list.id)).toEqual(['list-new', 'list-a', 'list-b'])
})
it('reorders lists optimistically and pushes the new order via the dedicated endpoint', async () => {
listsApiMocks.orderListsApi.mockResolvedValueOnce(undefined)
listsApiMocks.getListsApi.mockResolvedValueOnce([
{ id: 'list-a', name: 'A', position: 1 },
{ id: 'list-b', name: 'B', position: 0 },
])
const store = useListsStore()
await fakeDb.lists.put({ id: 'list-a', name: 'A', position: 0, pendingSync: false })
await fakeDb.lists.put({ id: 'list-b', name: 'B', position: 1, pendingSync: false })
await store.refresh()
await store.reorderLists(['list-b', 'list-a'])
expect(store.sortedLists.map((list) => list.id)).toEqual(['list-b', 'list-a'])
expect(store.lists.every((list) => list.pendingSync)).toBe(true)
await store.sync()
expect(listsApiMocks.orderListsApi).toHaveBeenCalledWith({ list_ids: ['list-b', 'list-a'] })
expect(store.lists.find((list) => list.id === 'list-a')?.pendingSync).toBe(false)
expect(store.lists.find((list) => list.id === 'list-b')?.pendingSync).toBe(false)
expect(store.pendingCount).toBe(0)
})
it('supersedes a stale queued reorder instead of replaying both', async () => {
listsApiMocks.orderListsApi.mockResolvedValue(undefined)
const store = useListsStore()
await fakeDb.lists.put({ id: 'list-a', name: 'A', position: 0, pendingSync: false })
await fakeDb.lists.put({ id: 'list-b', name: 'B', position: 1, pendingSync: false })
await store.refresh()
await store.reorderLists(['list-b', 'list-a'])
await store.reorderLists(['list-a', 'list-b'])
expect(store.pendingCount).toBe(1)
await store.sync()
expect(listsApiMocks.orderListsApi).toHaveBeenCalledTimes(1)
expect(listsApiMocks.orderListsApi).toHaveBeenCalledWith({ list_ids: ['list-a', 'list-b'] })
})
it('remaps a queued reorder entry when one of its lists gets its server id assigned mid-flight', async () => {
listsApiMocks.createListApi.mockResolvedValueOnce({ id: 'server-list-1', name: 'New list' })
listsApiMocks.orderListsApi.mockResolvedValueOnce(undefined)
listsApiMocks.getListsApi.mockResolvedValueOnce([
{ id: 'server-list-1', name: 'New list', position: 0 },
{ id: 'list-a', name: 'A', position: 1 },
])
const store = useListsStore()
const localList = await store.createList('New list')
await fakeDb.lists.put({ id: 'list-a', name: 'A', position: 0, pendingSync: false })
await store.refresh()
// Queues an "orderLists" entry referencing the not-yet-synced localList.id,
// created after the still-pending "createList" entry.
await store.reorderLists([localList.id, 'list-a'])
await store.sync()
expect(listsApiMocks.orderListsApi).toHaveBeenCalledWith({
list_ids: ['server-list-1', 'list-a'],
})
})
it('serializes pullListItems() behind an in-flight sync() so they never race on the same rows', async () => {
let resolveGetLists!: (value: unknown[]) => void
listsApiMocks.getListsApi.mockImplementationOnce(
+11 -1
View File
@@ -1,7 +1,8 @@
import { ref, computed } from 'vue'
import { defineStore } from 'pinia'
import { loginApi, refreshApi, logoutApi, logoutAllApi } from '@/api/auth'
import type { LoginPayload, TokenPair } from '@/types/auth'
import type { AccessTokenClaims, LoginPayload, TokenPair } from '@/types/auth'
import { decodeJwtPayload } from '@/utils/jwt'
export const useAuthStore = defineStore('auth', () => {
const accessToken = ref<string | null>(localStorage.getItem('access_token'))
@@ -11,6 +12,12 @@ export const useAuthStore = defineStore('auth', () => {
const isAuthenticated = computed(() => !!accessToken.value)
const currentUser = computed(() =>
accessToken.value ? decodeJwtPayload<AccessTokenClaims>(accessToken.value) : null,
)
const email = computed(() => currentUser.value?.email ?? null)
const username = computed(() => currentUser.value?.name ?? null)
function setTokens(tokens: TokenPair) {
accessToken.value = tokens.access_token
refreshToken.value = tokens.refresh_token
@@ -89,6 +96,9 @@ export const useAuthStore = defineStore('auth', () => {
isLoading,
error,
isAuthenticated,
currentUser,
email,
username,
setTokens,
clearTokens,
login,
+90 -17
View File
@@ -1,5 +1,6 @@
import { ref, computed } from 'vue'
import { defineStore } from 'pinia'
import { useAuthStore } from '@/stores/auth'
import {
db,
type LocalList,
@@ -12,12 +13,13 @@ import {
createListApi,
getListItemsApi,
createListItemApi,
updateListItemApi,
updateListItemTitleApi,
setListItemCompletedApi,
addUserToListApi,
removeUserFromListApi,
deleteListApi,
deleteListItemApi,
orderListsApi,
} from '@/api/lists'
// A burst of rapid edits (ticking off several items, typing then blurring a
@@ -41,8 +43,18 @@ export const useListsStore = defineStore('lists', () => {
const error = ref<string | null>(null)
const pendingCount = ref(0)
// Lists carry a server-assigned `position`, rearranged via reorderLists().
// New lists (local-only or freshly synced) always come back as position 0
// - by design, so a new list always lands at the top - which means several
// lists can share a position. created_at desc breaks that tie, newest
// first, and also covers a local list created but not yet synced (no
// position of its own yet: defaults to 0 below).
const sortedLists = computed(() =>
[...lists.value].sort((a, b) => (b.modified_at ?? '').localeCompare(a.modified_at ?? '')),
[...lists.value].sort((a, b) => {
const positionDiff = (a.position ?? 0) - (b.position ?? 0)
if (positionDiff !== 0) return positionDiff
return (b.created_at ?? '').localeCompare(a.created_at ?? '')
}),
)
function itemsForList(listId: string) {
@@ -133,7 +145,7 @@ export const useListsStore = defineStore('lists', () => {
}, SYNC_DEBOUNCE_MS)
}
async function createList(name: string, userIds: string[] = []): Promise<LocalList> {
async function createList(name: string): Promise<LocalList> {
const now = new Date().toISOString()
const localList: LocalList = {
id: generateId(),
@@ -147,7 +159,7 @@ export const useListsStore = defineStore('lists', () => {
upsertList(localList)
await enqueue({
type: 'createList',
payload: { name, user_ids: userIds },
payload: { name },
localListId: localList.id,
})
scheduleSync()
@@ -179,12 +191,9 @@ export const useListsStore = defineStore('lists', () => {
return localItem
}
async function updateListItem(
itemId: string,
changes: { title?: string; is_completed?: boolean },
) {
async function updateListItemTitle(itemId: string, title: string) {
const patch = {
...changes,
title,
modified_at: new Date().toISOString(),
pendingSync: true,
}
@@ -192,8 +201,8 @@ export const useListsStore = defineStore('lists', () => {
const existingItem = listItems.value.find((entry) => entry.id === itemId)
if (existingItem) Object.assign(existingItem, patch)
await enqueue({
type: 'updateListItem',
payload: { list_item_id: itemId, ...changes },
type: 'updateListItemTitle',
payload: { title },
localListItemId: itemId,
})
scheduleSync()
@@ -247,6 +256,37 @@ export const useListsStore = defineStore('lists', () => {
scheduleSync()
}
// Applies a full reordering of the user's lists (e.g. from a drag-and-drop
// gesture). Positions are reassigned optimistically to every list in
// `orderedIds`, and marked pendingSync so a pull racing the queued
// "orderLists" entry can't clobber the optimistic order before it syncs.
async function reorderLists(orderedIds: string[]) {
await Promise.all(
orderedIds.map((id, index) => db.lists.update(id, { position: index, pendingSync: true })),
)
for (const [index, id] of orderedIds.entries()) {
const existing = lists.value.find((entry) => entry.id === id)
if (existing) {
existing.position = index
existing.pendingSync = true
}
}
// Only the latest requested order matters, so any not-yet-synced
// "orderLists" entry is superseded rather than left to also replay.
const staleEntries = await db.syncQueue.where('type').equals('orderLists').toArray()
if (staleEntries.length > 0) {
await db.syncQueue.bulkDelete(staleEntries.map((entry) => entry.id!))
pendingCount.value = Math.max(0, pendingCount.value - staleEntries.length)
}
await enqueue({
type: 'orderLists',
payload: { list_ids: orderedIds },
})
scheduleSync()
}
async function deleteListItem(itemId: string) {
await db.listItems.delete(itemId)
removeLocalListItem(itemId)
@@ -294,6 +334,18 @@ export const useListsStore = defineStore('lists', () => {
payload: updatedPayload,
})
}
// "orderLists" entries aren't tied to a single localListId (they carry
// every list's id in payload.list_ids), so they need their own remap pass.
const affectedOrderEntries = await db.syncQueue.where('type').equals('orderLists').toArray()
for (const orderEntry of affectedOrderEntries) {
if (orderEntry.type !== 'orderLists' || !orderEntry.payload.list_ids.includes(oldId)) continue
await db.syncQueue.update(orderEntry.id!, {
payload: {
list_ids: orderEntry.payload.list_ids.map((id) => (id === oldId ? newId : id)),
},
})
}
}
// Remaps a client-generated temporary list item id to the id assigned by
@@ -356,11 +408,10 @@ export const useListsStore = defineStore('lists', () => {
}
break
}
case 'updateListItem': {
await updateListItemApi(entry.payload)
if (entry.localListItemId) {
case 'updateListItemTitle': {
if (!entry.localListItemId) break
await updateListItemTitleApi(entry.localListItemId, entry.payload)
await markListItemSynced(entry.localListItemId)
}
break
}
case 'setListItemCompleted': {
@@ -377,6 +428,17 @@ export const useListsStore = defineStore('lists', () => {
await deleteListItemApi(entry.payload.id)
break
}
case 'orderLists': {
await orderListsApi(entry.payload)
await Promise.all(
entry.payload.list_ids.map((id) => db.lists.update(id, { pendingSync: false })),
)
const affectedIds = new Set(entry.payload.list_ids)
for (const list of lists.value) {
if (affectedIds.has(list.id)) list.pendingSync = false
}
break
}
}
}
@@ -387,7 +449,15 @@ export const useListsStore = defineStore('lists', () => {
try {
const queue = await db.syncQueue.orderBy('createdAt').toArray()
for (const entry of queue) {
for (const snapshotEntry of queue) {
// Re-read the entry rather than trusting the queue snapshot: an
// earlier iteration this same pass may have remapped a temporary id
// referenced in this entry's payload (e.g. remapListId rewriting a
// queued "orderLists" entry after its "createList" entry synced).
const entry =
snapshotEntry.id !== undefined
? ((await db.syncQueue.get(snapshotEntry.id)) ?? snapshotEntry)
: snapshotEntry
try {
await processSyncEntry(entry)
if (entry.id !== undefined) {
@@ -438,6 +508,7 @@ export const useListsStore = defineStore('lists', () => {
return ongoingSync
}
if (typeof navigator !== 'undefined' && !navigator.onLine) return
if (!useAuthStore().isAuthenticated) return
ongoingSync = enqueueOperation(() => runSync().then(() => pullFromServer()))
try {
@@ -562,17 +633,19 @@ export const useListsStore = defineStore('lists', () => {
pendingCount,
error,
itemsForList,
ensureLoaded,
loadLists,
loadListItems,
refresh,
createList,
createListItem,
updateListItem,
updateListItemTitle,
setListItemCompleted,
addUserToList,
removeUserFromList,
deleteList,
deleteListItem,
reorderLists,
sync,
pullFromServer,
pullListItems,
+12
View File
@@ -11,3 +11,15 @@ export interface TokenPair {
access_token: string
refresh_token: string
}
export interface AccessTokenClaims {
user_id: string
email: string
name: string
exp: number
}
export interface ChangePasswordPayload {
old_password: string
new_password: string
}
+62
View File
@@ -0,0 +1,62 @@
export enum Equipment {
Unknown = 0,
Floor = 1,
Rings = 2,
PullUpBar = 3,
ParallelBars = 4,
LowBar = 5,
Parallettes = 6,
ResistanceBand = 7,
}
export enum Load {
Unknown = 0,
Bodyweight = 1,
External = 2,
}
export enum Metric {
Unknown = 0,
Reps = 1,
Seconds = 2,
}
export const EQUIPMENT_LABELS: Record<Equipment, string> = {
[Equipment.Unknown]: 'Unknown',
[Equipment.Floor]: 'Floor',
[Equipment.Rings]: 'Rings',
[Equipment.PullUpBar]: 'Pull-up bar',
[Equipment.ParallelBars]: 'Parallel bars',
[Equipment.LowBar]: 'Low bar',
[Equipment.Parallettes]: 'Parallettes',
[Equipment.ResistanceBand]: 'Resistance band',
}
export const LOAD_LABELS: Record<Load, string> = {
[Load.Unknown]: 'Unknown',
[Load.Bodyweight]: 'Bodyweight',
[Load.External]: 'External',
}
export const METRIC_LABELS: Record<Metric, string> = {
[Metric.Unknown]: 'Unknown',
[Metric.Reps]: 'Reps',
[Metric.Seconds]: 'Seconds',
}
export interface Exercise {
id: string
name: string
notes?: string
equipment?: Equipment[]
load?: Load
metric?: Metric
tags?: string[]
modified_at?: string
}
export interface PaginatedExercises {
data: Exercise[]
total: number
count: number
}
+18
View File
@@ -0,0 +1,18 @@
export interface Invite {
id: string
code: string
expires_at?: string
consumed_at?: string
}
export interface PaginatedInvites {
data: Invite[]
total: number
count: number
}
export interface InviteStatusCounts {
active: number
expired: number
used: number
}
+9 -7
View File
@@ -5,6 +5,7 @@ export interface List {
modified_at?: string
total_items?: number
completed_items?: number
position?: number
}
export interface ListItem {
@@ -18,7 +19,6 @@ export interface ListItem {
export interface CreateListPayload {
name: string
user_ids?: string[]
}
export interface CreateListItemPayload {
@@ -26,16 +26,14 @@ export interface CreateListItemPayload {
title: string
}
export interface UpdateListItemPayload {
list_item_id: string
title?: string
is_completed?: boolean
}
export interface SetListItemCompletedPayload {
is_completed: boolean
}
export interface SetListItemTitlePayload {
title: string
}
export interface AddUserToListPayload {
list_id: string
email: string
@@ -45,3 +43,7 @@ export interface RemoveUserFromListPayload {
list_id: string
email: string
}
export interface OrderListsPayload {
list_ids: string[]
}
+13
View File
@@ -0,0 +1,13 @@
export interface User {
id: string
email: string
name: string
created_at?: string
}
export interface CreateUserPayload {
email: string
password: string
name: string
invite_code: string
}
+5
View File
@@ -0,0 +1,5 @@
export interface VersionInfo {
version: string
commit: string
buildTime: string
}
+24
View File
@@ -0,0 +1,24 @@
import { describe, it, expect } from 'vitest'
import { fuzzyMatch } from '../fuzzyMatch'
describe('fuzzyMatch', () => {
it('matches an empty query against anything', () => {
expect(fuzzyMatch('', 'Groceries')).toBe(true)
expect(fuzzyMatch(' ', 'Groceries')).toBe(true)
})
it('matches an exact substring, case-insensitively', () => {
expect(fuzzyMatch('milk', 'Buy milk and eggs')).toBe(true)
expect(fuzzyMatch('MILK', 'buy milk and eggs')).toBe(true)
})
it('tolerates a couple of off characters', () => {
expect(fuzzyMatch('mikl', 'Buy milk and eggs')).toBe(true)
expect(fuzzyMatch('grocries', 'Groceries')).toBe(true)
})
it('does not match unrelated text', () => {
expect(fuzzyMatch('xyz', 'Groceries')).toBe(false)
expect(fuzzyMatch('banana', 'Groceries')).toBe(false)
})
})
+45
View File
@@ -0,0 +1,45 @@
function levenshtein(a: string, b: string): number {
const rows = a.length + 1
const cols = b.length + 1
let prev: number[] = Array.from({ length: cols }, (_, j) => j)
for (let i = 1; i < rows; i++) {
const curr: number[] = [i]
for (let j = 1; j < cols; j++) {
const cost = a[i - 1] === b[j - 1] ? 0 : 1
curr.push(Math.min((curr[j - 1] ?? 0) + 1, (prev[j] ?? 0) + 1, (prev[j - 1] ?? 0) + cost))
}
prev = curr
}
return prev[cols - 1] ?? 0
}
function maxDistanceFor(queryLength: number): number {
if (queryLength <= 4) return 1
return 2
}
/**
* Cheap, local fuzzy substring match: true if `text` contains a run of
* characters within edit-distance of `query` (tolerating ~1-2 typos).
*/
export function fuzzyMatch(query: string, text: string): boolean {
const q = query.trim().toLowerCase()
if (!q) return true
const t = text.toLowerCase()
if (t.includes(q)) return true
const maxDistance = maxDistanceFor(q.length)
const minLen = Math.max(1, q.length - maxDistance)
const maxLen = q.length + maxDistance
for (let len = minLen; len <= maxLen; len++) {
for (let start = 0; start + len <= t.length; start++) {
if (levenshtein(q, t.substring(start, start + len)) <= maxDistance) return true
}
}
return false
}
+17
View File
@@ -0,0 +1,17 @@
export function decodeJwtPayload<T>(token: string): T | null {
const parts = token.split('.')
if (parts.length !== 3) return null
try {
const base64 = parts[1]!.replace(/-/g, '+').replace(/_/g, '/')
const json = decodeURIComponent(
atob(base64)
.split('')
.map((c) => '%' + c.charCodeAt(0).toString(16).padStart(2, '0'))
.join(''),
)
return JSON.parse(json) as T
} catch {
return null
}
}
-66
View File
@@ -1,66 +0,0 @@
<script setup lang="ts">
import { onMounted } from 'vue'
import { useListsStore } from '@/stores/lists'
const listsStore = useListsStore()
onMounted(() => {
listsStore.loadLists()
})
</script>
<template>
<main class="page about">
<h1>About dttmr</h1>
<p>
An offline-first progressive web app for shared lists. Every change you make is saved
instantly on this device and pushed to the server as soon as you're back online.
</p>
<section class="card info-card">
<h4>Sync status</h4>
<p class="row">
<span>Pending changes</span>
<strong>{{ listsStore.pendingCount }}</strong>
</p>
<p class="row">
<span>Syncing</span>
<strong>{{ listsStore.isSyncing ? 'Yes' : 'No' }}</strong>
</p>
</section>
</main>
</template>
<style scoped>
.about h1 {
font-size: 1.35rem;
margin-bottom: 0.75rem;
}
.about p {
color: var(--c-text-soft);
font-size: 0.9rem;
margin-bottom: 1.25rem;
}
.info-card {
padding: 1rem 1.1rem;
}
.info-card h4 {
font-size: 0.85rem;
margin-bottom: 0.75rem;
}
.row {
display: flex;
justify-content: space-between;
font-size: 0.85rem;
margin-bottom: 0.4rem;
color: var(--c-text);
}
.row strong {
color: var(--c-heading);
}
</style>
+156
View File
@@ -0,0 +1,156 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useListsStore } from '@/stores/lists'
import { useAuthStore } from '@/stores/auth'
import { getVersionApi } from '@/api/version'
import type { VersionInfo } from '@/types/version'
import ChangePasswordModal from '@/components/ChangePasswordModal.vue'
import InvitesPanel from '@/components/InvitesPanel.vue'
const listsStore = useListsStore()
const authStore = useAuthStore()
const showChangePassword = ref(false)
const versionInfo = ref<VersionInfo | null>(null)
const versionError = ref('')
const isLoadingVersion = ref(false)
onMounted(() => {
listsStore.ensureLoaded()
loadVersion()
})
async function loadVersion() {
isLoadingVersion.value = true
versionError.value = ''
try {
versionInfo.value = await getVersionApi()
} catch (err) {
versionError.value = err instanceof Error ? err.message : 'Failed to load API information'
} finally {
isLoadingVersion.value = false
}
}
</script>
<template>
<main class="page about">
<h1 v-if="authStore.username">Hi, {{ authStore.username }}</h1>
<h1 v-else>Account</h1>
<section class="card info-card">
<h4>Users</h4>
<p class="row">
<span>Signed in as </span>
<strong>{{ authStore.email ?? 'Unknown' }}</strong>
</p>
<button
type="button"
class="btn btn-secondary change-password-btn"
@click="showChangePassword = true"
>
Change password
</button>
</section>
<InvitesPanel />
<h1 class="section-heading">Pending changes</h1>
<section class="card info-card">
<h4>Sync status</h4>
<p class="row">
<span>Pending changes</span>
<strong class="mono-num">{{ listsStore.pendingCount }}</strong>
</p>
<p class="row">
<span>Syncing</span>
<strong>{{ listsStore.isSyncing ? 'Yes' : 'No' }}</strong>
</p>
</section>
<h1 class="section-heading">API information</h1>
<section class="card info-card">
<p v-if="isLoadingVersion" class="loading-text">Loading</p>
<template v-else-if="versionInfo">
<p class="row">
<span>Version</span>
<strong class="mono-num">{{ versionInfo.version }}</strong>
</p>
<p class="row">
<span>Commit</span>
<strong class="mono-num">{{ versionInfo.commit }}</strong>
</p>
<p class="row">
<span>Build time</span>
<strong>{{ versionInfo.buildTime }}</strong>
</p>
</template>
<p v-else class="banner banner-error">{{ versionError }}</p>
</section>
<ChangePasswordModal v-if="showChangePassword" @close="showChangePassword = false" />
</main>
</template>
<style scoped>
@media (min-width: 768px) {
.page.about {
max-width: 780px;
}
}
.about h1 {
font-size: 1.35rem;
margin-bottom: 0.75rem;
}
.section-heading {
margin-top: 1.5rem;
}
.about p {
color: var(--c-text-soft);
font-size: 0.9rem;
margin-bottom: 1.25rem;
}
.info-card {
padding: 1rem 1.1rem;
}
.info-card + .info-card {
margin-top: 1rem;
}
.info-card h4 {
font-size: 0.85rem;
margin-bottom: 0.75rem;
}
.row {
display: flex;
justify-content: space-between;
font-size: 0.85rem;
margin-bottom: 0.4rem;
color: var(--c-text);
}
.row strong {
color: var(--c-heading);
}
.change-password-btn {
width: auto;
margin-top: 0.6rem;
padding: 0.5rem 1rem;
}
.loading-text {
font-size: 0.85rem;
color: var(--c-text-soft);
margin: 0;
}
</style>
+157
View File
@@ -0,0 +1,157 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { getExercisesApi } from '@/api/exercises'
import type { Exercise } from '@/types/exercise'
import ExerciseCard from '@/components/ExerciseCard.vue'
const PAGE_SIZE = 20
const exercises = ref<Exercise[]>([])
const page = ref(1)
const total = ref(0)
const isLoading = ref(false)
const error = ref('')
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / PAGE_SIZE)))
const showPagination = computed(() => totalPages.value > 1)
onMounted(() => {
void loadExercises()
})
async function loadExercises() {
error.value = ''
isLoading.value = true
try {
const response = await getExercisesApi({ page: page.value, count: PAGE_SIZE })
exercises.value = response.data
total.value = response.total
} catch (err) {
error.value = err instanceof Error ? err.message : 'Failed to load exercises'
} finally {
isLoading.value = false
}
}
async function goToPage(target: number) {
if (target < 1 || target > totalPages.value || target === page.value || isLoading.value) {
return
}
page.value = target
await loadExercises()
}
// No workout/workout-template flow exists yet to receive a selection; this
// is the attachment point future "add exercise to workout" UI will use.
function handleSelectExercise() {}
</script>
<template>
<main class="page">
<h1>Exercises</h1>
<p v-if="error" class="banner banner-error">{{ error }}</p>
<p v-if="isLoading && exercises.length === 0" class="loading-text">Loading exercises</p>
<ul v-else-if="exercises.length > 0" class="exercises">
<li v-for="exercise in exercises" :key="exercise.id">
<ExerciseCard :exercise="exercise" @select="handleSelectExercise" />
</li>
</ul>
<div v-else class="empty-state">
<p>No exercises yet</p>
</div>
<div v-if="showPagination" class="pagination">
<button
type="button"
class="page-btn"
:disabled="page <= 1 || isLoading"
aria-label="Previous page"
@click="goToPage(page - 1)"
>
</button>
<span class="pagination-info mono-num"
>Page {{ page }} of {{ totalPages }} · {{ total }} total</span
>
<button
type="button"
class="page-btn"
:disabled="page >= totalPages || isLoading"
aria-label="Next page"
@click="goToPage(page + 1)"
>
</button>
</div>
</main>
</template>
<style scoped>
h1 {
font-size: 1.4rem;
margin-bottom: 1rem;
}
.loading-text,
.empty-state {
color: var(--c-text-soft);
font-size: 0.9rem;
}
.empty-state {
text-align: center;
padding: 3rem 1rem;
}
.exercises {
display: flex;
flex-direction: column;
gap: 0.6rem;
list-style: none;
padding: 0;
margin: 0;
}
.pagination {
display: flex;
align-items: center;
justify-content: center;
gap: 0.75rem;
margin-top: 1.25rem;
}
.pagination-info {
font-size: 0.78rem;
color: var(--c-text-soft);
}
.page-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
padding: 0;
background: none;
border: 1px solid var(--c-border);
border-radius: var(--radius-sm);
color: var(--c-text);
font-size: 1rem;
line-height: 1;
cursor: pointer;
}
.page-btn:hover:not(:disabled) {
border-color: var(--c-border-hover);
color: var(--c-heading);
}
.page-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
</style>
+20 -7
View File
@@ -3,6 +3,7 @@ import { ref, computed, onMounted, watch } from 'vue'
import { useRouter } from 'vue-router'
import { useListsStore } from '@/stores/lists'
import type { LocalListItem } from '@/database/db'
import { fuzzyMatch } from '@/utils/fuzzyMatch'
import ListItemRow from '@/components/ListItemRow.vue'
import DeleteListModal from '@/components/DeleteListModal.vue'
import { useDismissableMenu } from '@/composables/useDismissableMenu'
@@ -12,7 +13,8 @@ const props = defineProps<{ id: string }>()
const router = useRouter()
const listsStore = useListsStore()
const newItemTitle = ref('')
// Doubles as the "add item" text box and the live filter query on the list below.
const itemInput = ref('')
const isAddingItem = ref(false)
const itemError = ref('')
const showDeleteModal = ref(false)
@@ -44,11 +46,21 @@ function byModifiedDesc(a: LocalListItem, b: LocalListItem) {
return (b.modified_at ?? '').localeCompare(a.modified_at ?? '')
}
// Filtering is local and fuzzy only, for display purposes — it never touches
// the server or the sync queue.
const filterQuery = computed(() => itemInput.value.trim())
const filteredItems = computed(() => {
const query = filterQuery.value
if (!query) return items.value
return items.value.filter((item) => fuzzyMatch(query, item.title))
})
const pendingItems = computed(() =>
items.value.filter((item) => !item.is_completed).sort(byModifiedDesc),
filteredItems.value.filter((item) => !item.is_completed).sort(byModifiedDesc),
)
const completedItems = computed(() =>
items.value.filter((item) => item.is_completed).sort(byModifiedDesc),
filteredItems.value.filter((item) => item.is_completed).sort(byModifiedDesc),
)
function handleOpenDelete() {
@@ -72,14 +84,14 @@ async function handleConfirmDelete() {
}
async function handleAddItem() {
const title = newItemTitle.value.trim()
const title = filterQuery.value
if (!title) return
itemError.value = ''
isAddingItem.value = true
try {
await listsStore.createListItem(props.id, title)
newItemTitle.value = ''
itemInput.value = ''
} catch (err) {
itemError.value = err instanceof Error ? err.message : 'Failed to add item'
} finally {
@@ -146,7 +158,7 @@ async function handleAddItem() {
<form class="new-item-form" @submit.prevent="handleAddItem">
<div class="field">
<input
v-model="newItemTitle"
v-model="itemInput"
type="text"
placeholder="Add an item…"
:disabled="isAddingItem"
@@ -155,7 +167,7 @@ async function handleAddItem() {
<button
type="submit"
class="btn btn-primary add-btn"
:disabled="isAddingItem || !newItemTitle.trim()"
:disabled="isAddingItem || !filterQuery"
>
+
</button>
@@ -177,6 +189,7 @@ async function handleAddItem() {
</section>
<p v-if="items.length === 0" class="empty-hint">No items yet add your first one above.</p>
<p v-else-if="filteredItems.length === 0" class="empty-hint">No items match "{{ filterQuery }}".</p>
<DeleteListModal
v-if="showDeleteModal && list"
+55 -5
View File
@@ -1,10 +1,11 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ref, watch, onMounted } from 'vue'
import { useListsStore } from '@/stores/lists'
import type { LocalList } from '@/database/db'
import ListCard from '@/components/ListCard.vue'
import ShareListModal from '@/components/ShareListModal.vue'
import DeleteListModal from '@/components/DeleteListModal.vue'
import { useListDragReorder } from '@/composables/useListDragReorder'
const listsStore = useListsStore()
@@ -14,6 +15,26 @@ const createError = ref('')
const sharingList = ref<LocalList | null>(null)
const deletingList = ref<LocalList | null>(null)
// Local, reorderable copy of the store's list order. Kept in sync with
// listsStore.sortedLists except while a drag is in progress, so a
// mid-sync-pass update (e.g. total_items ticking over) can't yank a row out
// from under the user's finger.
const displayedLists = ref<LocalList[]>([])
const { draggingId, isPointerActive, dragOffsetPx, setItemRef, onPointerDown } = useListDragReorder(
displayedLists,
(orderedIds) => {
void listsStore.reorderLists(orderedIds)
},
)
watch(
() => listsStore.sortedLists,
(next) => {
if (draggingId.value === null) displayedLists.value = [...next]
},
{ immediate: true },
)
onMounted(() => {
listsStore.loadLists()
})
@@ -86,11 +107,24 @@ async function handleCreateList() {
<p v-if="createError" class="banner banner-error">{{ createError }}</p>
<ul v-if="listsStore.sortedLists.length > 0" class="lists">
<li v-for="list in listsStore.sortedLists" :key="list.id">
<ListCard :list="list" @share="handleOpenShare(list)" @delete="handleOpenDelete(list)" />
<TransitionGroup v-if="displayedLists.length > 0" tag="ul" name="list-reorder" class="lists">
<li
v-for="list in displayedLists"
:key="list.id"
:ref="(el) => setItemRef(list.id, el as Element | null)"
class="list-row"
:class="{ 'no-transition': isPointerActive && draggingId === list.id }"
:style="draggingId === list.id ? { transform: `translateY(${dragOffsetPx}px)` } : undefined"
>
<ListCard
:list="list"
:dragging="draggingId === list.id"
@share="handleOpenShare(list)"
@delete="handleOpenDelete(list)"
@handle-pointerdown="onPointerDown(list.id, $event)"
/>
</li>
</ul>
</TransitionGroup>
<div v-else class="empty-state">
<p>No lists yet</p>
@@ -145,6 +179,22 @@ h1 {
margin: 0;
}
.list-row {
transition:
transform 0.22s cubic-bezier(0.22, 1, 0.36, 1),
z-index 0s;
}
.list-row.no-transition {
transition: none;
z-index: 2;
position: relative;
}
.list-reorder-move {
transition: transform 0.28s cubic-bezier(0.22, 1, 0.36, 1);
}
.empty-state {
text-align: center;
padding: 3rem 1rem;
+19 -8
View File
@@ -46,7 +46,8 @@ async function handleSubmit() {
<div class="login-container">
<div class="login-card card">
<div class="brand-mark">
<span class="brand-dot"></span>
<span class="brand-seal"></span>
<span class="brand-word">dttmr</span>
</div>
<h2>Login</h2>
<p class="subtitle">Enter your credentials to access your account</p>
@@ -119,16 +120,26 @@ async function handleSubmit() {
.brand-mark {
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 1rem;
gap: 0.55rem;
margin-bottom: 1.5rem;
}
.brand-dot {
width: 40px;
height: 40px;
border-radius: 12px;
background: linear-gradient(135deg, var(--c-accent-strong), var(--c-accent-soft));
box-shadow: 0 0 24px var(--c-accent-bg);
.brand-seal {
width: 30px;
height: 30px;
border-radius: 6px;
background: var(--c-accent);
transform: rotate(-8deg);
box-shadow: 0 0 0 3px var(--c-bg-soft) inset;
}
.brand-word {
font-family: var(--font-stamp);
font-size: 1.3rem;
letter-spacing: 0.04em;
color: var(--c-heading);
}
h2 {
+229
View File
@@ -0,0 +1,229 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { createUserApi } from '@/api/users'
import { useAuthStore } from '@/stores/auth'
import { useListsStore } from '@/stores/lists'
const router = useRouter()
const route = useRoute()
const authStore = useAuthStore()
const listsStore = useListsStore()
const inviteCode = computed(() => {
const invite = route.query.invite
return typeof invite === 'string' ? invite : ''
})
const name = ref('')
const email = ref('')
const password = ref('')
const confirmPassword = ref('')
const isSubmitting = ref(false)
const error = ref('')
async function handleSubmit() {
error.value = ''
if (password.value.length < 8) {
error.value = 'Password must be at least 8 characters.'
return
}
if (password.value !== confirmPassword.value) {
error.value = 'Passwords do not match.'
return
}
isSubmitting.value = true
try {
await createUserApi({
name: name.value,
email: email.value,
password: password.value,
invite_code: inviteCode.value,
})
await authStore.login({ email: email.value, password: password.value })
listsStore.sync().catch(() => {})
router.push('/')
} catch (err) {
error.value = err instanceof Error ? err.message : 'Failed to create account'
} finally {
isSubmitting.value = false
}
}
</script>
<template>
<div class="register-container">
<div class="register-card card">
<div class="brand-mark">
<span class="brand-seal"></span>
<span class="brand-word">dttmr</span>
</div>
<h2>Create your account</h2>
<div v-if="authStore.isAuthenticated" class="already-logged-in">
<p>You're already logged in as {{ authStore.email }}.</p>
<div class="actions">
<button type="button" class="btn btn-secondary" @click="router.push('/')">
Go to Home
</button>
<button type="button" class="btn btn-danger" @click="authStore.logout()">Log Out</button>
</div>
</div>
<div v-else-if="!inviteCode" class="invalid-invite">
<p>This page requires a valid invite link. Ask whoever invited you for a new one.</p>
</div>
<form v-else @submit.prevent="handleSubmit">
<p class="subtitle">You've been invited to join dttmr. Set up your account below.</p>
<div v-if="error" class="error-banner banner banner-error">{{ error }}</div>
<div class="field">
<label for="name">Name</label>
<input
id="name"
v-model="name"
type="text"
placeholder="Jane Doe"
autocomplete="name"
required
:disabled="isSubmitting"
/>
</div>
<div class="field">
<label for="email">Email</label>
<input
id="email"
v-model="email"
type="email"
placeholder="name@example.com"
autocomplete="email"
required
:disabled="isSubmitting"
/>
</div>
<div class="field">
<label for="password">Password</label>
<input
id="password"
v-model="password"
type="password"
placeholder="At least 8 characters"
autocomplete="new-password"
required
:disabled="isSubmitting"
/>
</div>
<div class="field">
<label for="confirm-password">Confirm password</label>
<input
id="confirm-password"
v-model="confirmPassword"
type="password"
placeholder="Repeat your password"
autocomplete="new-password"
required
:disabled="isSubmitting"
/>
</div>
<button type="submit" class="btn btn-primary" :disabled="isSubmitting">
<span v-if="isSubmitting">Creating account...</span>
<span v-else>Create account</span>
</button>
</form>
</div>
</div>
</template>
<style scoped>
.register-container {
flex: 1;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
padding: 1.5rem;
}
.register-card {
width: 100%;
max-width: 400px;
padding: 2rem 1.75rem;
}
.brand-mark {
display: flex;
align-items: center;
justify-content: center;
gap: 0.55rem;
margin-bottom: 1.5rem;
}
.brand-seal {
width: 30px;
height: 30px;
border-radius: 6px;
background: var(--c-accent);
transform: rotate(-8deg);
box-shadow: 0 0 0 3px var(--c-bg-soft) inset;
}
.brand-word {
font-family: var(--font-stamp);
font-size: 1.3rem;
letter-spacing: 0.04em;
color: var(--c-heading);
}
h2 {
margin: 0 0 0.4rem;
font-size: 1.4rem;
text-align: center;
}
.subtitle {
margin: 0 0 1.5rem;
font-size: 0.85rem;
color: var(--c-text-soft);
text-align: center;
}
.error-banner {
margin-bottom: 1.1rem;
}
.already-logged-in,
.invalid-invite {
text-align: center;
}
.already-logged-in p,
.invalid-invite p {
margin-bottom: 1.25rem;
color: var(--c-text);
}
.actions {
display: flex;
gap: 0.75rem;
justify-content: center;
}
form .field {
margin-bottom: 1.1rem;
}
@media (min-width: 768px) {
.register-card {
padding: 2.5rem 2.25rem;
}
}
</style>
+9 -2
View File
@@ -121,7 +121,7 @@ describe('ListDetailView', () => {
expect(wrapper.findComponent({ name: 'DeleteListModal' }).exists()).toBe(false)
})
it('allows deleting list items via two clicks on item row menu', async () => {
it('allows deleting list items via menu and confirmation modal', async () => {
const listsStore = useListsStore()
const deleteItemSpy = vi.spyOn(listsStore, 'deleteListItem').mockResolvedValue()
const sampleList: LocalList = {
@@ -156,9 +156,16 @@ describe('ListDetailView', () => {
const deleteItemBtn = itemRow.find('.submenu-item-danger')
expect(deleteItemBtn.exists()).toBe(true)
// 2nd click: delete item
// 2nd click: opens confirmation modal
await deleteItemBtn.trigger('click')
const modal = itemRow.findComponent({ name: 'DeleteListItemModal' })
expect(modal.exists()).toBe(true)
expect(deleteItemSpy).not.toHaveBeenCalled()
// Confirm deletion in modal
await modal.find('.confirm-delete-btn').trigger('click')
expect(deleteItemSpy).toHaveBeenCalledWith('item-1')
})
})
+146
View File
@@ -0,0 +1,146 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import RegisterView from '../RegisterView.vue'
import { useAuthStore } from '@/stores/auth'
import { useListsStore } from '@/stores/lists'
import * as usersApi from '@/api/users'
const mockPush = vi.fn<(to: string) => void>()
let routeQuery: Record<string, string> = { invite: 'invite-code-123' }
vi.mock('vue-router', async (importOriginal) => {
const actual = await importOriginal<typeof import('vue-router')>()
return {
...actual,
useRouter: () => ({
push: mockPush,
}),
useRoute: () => ({
query: routeQuery,
}),
}
})
describe('RegisterView', () => {
beforeEach(() => {
setActivePinia(createPinia())
localStorage.clear()
vi.restoreAllMocks()
mockPush.mockClear()
routeQuery = { invite: 'invite-code-123' }
})
it('shows an invalid-invite message when there is no invite code in the URL', () => {
routeQuery = {}
const wrapper = mount(RegisterView)
expect(wrapper.find('.invalid-invite').exists()).toBe(true)
expect(wrapper.find('form').exists()).toBe(false)
})
it('renders the registration form when an invite code is present', () => {
const wrapper = mount(RegisterView)
expect(wrapper.find('h2').text()).toBe('Create your account')
expect(wrapper.find('input#name').exists()).toBe(true)
expect(wrapper.find('input#email').exists()).toBe(true)
expect(wrapper.find('input#password').exists()).toBe(true)
expect(wrapper.find('input#confirm-password').exists()).toBe(true)
})
it('shows logged in status and hides the form if already authenticated', () => {
const authStore = useAuthStore()
authStore.setTokens({ access_token: 'active-token', refresh_token: 'active-refresh' })
const wrapper = mount(RegisterView)
expect(wrapper.find('.already-logged-in').exists()).toBe(true)
expect(wrapper.find('form').exists()).toBe(false)
})
it('rejects passwords that are too short', async () => {
const createSpy = vi.spyOn(usersApi, 'createUserApi')
const wrapper = mount(RegisterView)
await wrapper.find('#name').setValue('Jane Doe')
await wrapper.find('#email').setValue('jane@example.com')
await wrapper.find('#password').setValue('short')
await wrapper.find('#confirm-password').setValue('short')
await wrapper.find('form').trigger('submit.prevent')
expect(wrapper.find('.error-banner').text()).toContain('at least 8 characters')
expect(createSpy).not.toHaveBeenCalled()
})
it('rejects mismatched passwords', async () => {
const createSpy = vi.spyOn(usersApi, 'createUserApi')
const wrapper = mount(RegisterView)
await wrapper.find('#name').setValue('Jane Doe')
await wrapper.find('#email').setValue('jane@example.com')
await wrapper.find('#password').setValue('password123')
await wrapper.find('#confirm-password').setValue('password456')
await wrapper.find('form').trigger('submit.prevent')
expect(wrapper.find('.error-banner').text()).toContain('do not match')
expect(createSpy).not.toHaveBeenCalled()
})
it('creates the account, logs in, syncs, and redirects home on success', async () => {
const createSpy = vi.spyOn(usersApi, 'createUserApi').mockResolvedValueOnce({
id: 'user-1',
email: 'jane@example.com',
name: 'Jane Doe',
})
const authStore = useAuthStore()
const loginSpy = vi.spyOn(authStore, 'login').mockResolvedValueOnce({
access_token: 'access-123',
refresh_token: 'refresh-456',
})
const listsStore = useListsStore()
const syncSpy = vi.spyOn(listsStore, 'sync').mockResolvedValueOnce()
const wrapper = mount(RegisterView)
await wrapper.find('#name').setValue('Jane Doe')
await wrapper.find('#email').setValue('jane@example.com')
await wrapper.find('#password').setValue('password123')
await wrapper.find('#confirm-password').setValue('password123')
await wrapper.find('form').trigger('submit.prevent')
await flushPromises()
expect(createSpy).toHaveBeenCalledWith({
name: 'Jane Doe',
email: 'jane@example.com',
password: 'password123',
invite_code: 'invite-code-123',
})
expect(loginSpy).toHaveBeenCalledWith({
email: 'jane@example.com',
password: 'password123',
})
expect(syncSpy).toHaveBeenCalled()
expect(mockPush).toHaveBeenCalledWith('/')
})
it('shows an error banner when account creation fails', async () => {
vi.spyOn(usersApi, 'createUserApi').mockRejectedValueOnce(new Error('Invalid invite code'))
const wrapper = mount(RegisterView)
await wrapper.find('#name').setValue('Jane Doe')
await wrapper.find('#email').setValue('jane@example.com')
await wrapper.find('#password').setValue('password123')
await wrapper.find('#confirm-password').setValue('password123')
await wrapper.find('form').trigger('submit.prevent')
await flushPromises()
expect(wrapper.find('.error-banner').text()).toContain('Invalid invite code')
})
})
function flushPromises() {
return new Promise((resolve) => setTimeout(resolve))
}
+2 -2
View File
@@ -23,8 +23,8 @@ export default defineConfig({
description: 'dittmar.dev frontend app',
start_url: '/',
scope: '/',
theme_color: '#0a0e1a',
background_color: '#0a0e1a',
theme_color: '#12161d',
background_color: '#12161d',
display: 'standalone',
icons: [
{