Compare commits

..
50 Commits
Author SHA1 Message Date
robin b90fc232b7 Merge pull request 'Re-order lists' (#27) from dev into main
Build and Deploy / build-and-deploy (push) Successful in 1m17s
Build and Deploy / sync-dev (push) Successful in 14s
2026-09-11 20:08:56 +02:00
robin 7f66903d7d feat: re-order lists
PR Checks / lint-and-test (pull_request) Successful in 1m29s
2026-09-11 19:14:25 +02:00
robin 50aa249cf8 Merge pull request 'CV: spiced up interests section' (#26) from dev into main
Build and Deploy / build-and-deploy (push) Successful in 1m15s
Build and Deploy / sync-dev (push) Successful in 14s
2026-09-09 17:30:40 +02:00
robin c212173692 feat: cv; spiced up interests section
PR Checks / lint-and-test (pull_request) Successful in 1m28s
2026-09-09 17:25:51 +02:00
robin a67189a493 Merge pull request 'CI now runs when opening a PR' (#25) from dev into main
Build and Deploy / build-and-deploy (push) Successful in 1m16s
Build and Deploy / sync-dev (push) Successful in 14s
2026-09-09 17:11:02 +02:00
robin 7fcf761f4f feat: added PR checks (lint & test) and removed them from the deploy step
PR Checks / lint-and-test (pull_request) Successful in 1m28s
2026-09-09 17:00:54 +02:00
robin c2e656443f fix: missing '-' in linkedin link 2026-09-09 09:59:51 +02:00
robin 81c58d5ca0 Merge pull request 'CI now syncs dev to main' (#24) from dev into main
Build and Deploy / build-and-deploy (push) Successful in 2m8s
Build and Deploy / sync-dev (push) Successful in 14s
2026-09-06 17:36:14 +02:00
robin 227dc06b58 fix: ci now syncs dev to main 2026-09-06 17:34:10 +02:00
robin 59cf4f1e2d Merge pull request 'Added deploy.yml for automatically building and deploying after merging to main' (#23) from dev into main
Build and Deploy / build-and-deploy (push) Successful in 2m6s
2026-09-06 16:35:15 +02:00
robin 39b96c409e feat: added deploy.yml for automatically building and deploying after merging to main 2026-09-06 16:34:03 +02:00
robin 1f4b4a578e Merge pull request 'CV page improvements' (#22) from dev into main 2026-09-05 20:38:54 +02:00
robin edfaf98e18 fix: cv change, language rating bar width 2026-09-05 20:38:12 +02:00
robin ff1e078be9 fix cv page improvements 2026-09-05 20:30:52 +02:00
robin 7974cfafc7 feat: cv page now features "check out my work" section and small icons 2026-09-05 20:08:37 +02:00
robin cc010e79ca fix: linkedin link 2026-09-05 19:48:15 +02:00
robin 23029c8793 Merge pull request 'Added cv view (not indexed)' (#21) from dev into main 2026-09-05 10:46:11 +02:00
robin deb0bac072 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
51 changed files with 4373 additions and 213 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="icon" href="/favicon.ico">
<link rel="apple-touch-icon" href="/apple-touch-icon.png"> <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="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="color-scheme" content="dark">
<meta name="mobile-web-app-capable" content="yes"> <meta name="mobile-web-app-capable" content="yes">
<meta name="apple-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-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="dttmr"> <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> <title>dttmr</title>
</head> </head>
<body> <body>
+2
View File
@@ -0,0 +1,2 @@
User-agent: *
Disallow: /cv
+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, createListApi,
getListItemsApi, getListItemsApi,
createListItemApi, createListItemApi,
updateListItemApi, updateListItemTitleApi,
setListItemCompletedApi, setListItemCompletedApi,
addUserToListApi, addUserToListApi,
removeUserFromListApi, removeUserFromListApi,
@@ -77,7 +77,7 @@ describe('lists API', () => {
expect(result).toEqual(mockItems) 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({ const fetchMock = vi.fn<typeof fetch>().mockResolvedValueOnce({
ok: true, ok: true,
status: 204, status: 204,
@@ -87,7 +87,7 @@ describe('lists API', () => {
await setListItemCompletedApi('item-1', { is_completed: true }) await setListItemCompletedApi('item-1', { is_completed: true })
expect(fetchMock).toHaveBeenCalledWith( expect(fetchMock).toHaveBeenCalledWith(
`${API_BASE_URL}/lists/items/item-1`, `${API_BASE_URL}/lists/items/item-1/complete`,
expect.objectContaining({ method: 'POST' }), expect.objectContaining({ method: 'POST' }),
) )
}) })
@@ -101,7 +101,7 @@ describe('lists API', () => {
} as unknown as Response) } as unknown as Response)
global.fetch = fetchMock global.fetch = fetchMock
const result = await createListApi({ name: 'Groceries', user_ids: [] }) const result = await createListApi({ name: 'Groceries' })
expect(fetchMock).toHaveBeenCalledWith( expect(fetchMock).toHaveBeenCalledWith(
`${API_BASE_URL}/lists`, `${API_BASE_URL}/lists`,
@@ -119,7 +119,7 @@ describe('lists API', () => {
await expect(createListApi({ name: 'x' })).rejects.toThrow('Failed') 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 mockItem = { id: 'item-1', list_id: '', title: 'Milk', is_completed: false }
const fetchMock = vi.fn<typeof fetch>().mockResolvedValueOnce({ const fetchMock = vi.fn<typeof fetch>().mockResolvedValueOnce({
ok: true, ok: true,
@@ -131,24 +131,24 @@ describe('lists API', () => {
const result = await createListItemApi({ list_id: 'list-1', title: 'Milk' }) const result = await createListItemApi({ list_id: 'list-1', title: 'Milk' })
expect(fetchMock).toHaveBeenCalledWith( expect(fetchMock).toHaveBeenCalledWith(
`${API_BASE_URL}/lists/item`, `${API_BASE_URL}/lists/items`,
expect.objectContaining({ method: 'POST' }), expect.objectContaining({ method: 'POST' }),
) )
expect(result).toEqual(mockItem) 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({ const fetchMock = vi.fn<typeof fetch>().mockResolvedValueOnce({
ok: true, ok: true,
status: 204, status: 204,
} as unknown as Response) } as unknown as Response)
global.fetch = fetchMock global.fetch = fetchMock
await updateListItemApi({ list_item_id: 'item-1', is_completed: true }) await updateListItemTitleApi('item-1', { title: 'Free-range eggs' })
expect(fetchMock).toHaveBeenCalledWith( expect(fetchMock).toHaveBeenCalledWith(
`${API_BASE_URL}/lists/item`, `${API_BASE_URL}/lists/items/item-1/title`,
expect.objectContaining({ method: 'PUT' }), expect.objectContaining({ method: 'POST' }),
) )
}) })
@@ -206,7 +206,7 @@ describe('lists API', () => {
await expect(deleteListApi('list-1')).rejects.toThrow('List not found') 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({ const fetchMock = vi.fn<typeof fetch>().mockResolvedValueOnce({
ok: true, ok: true,
status: 204, status: 204,
@@ -216,7 +216,7 @@ describe('lists API', () => {
await deleteListItemApi('item-1') await deleteListItemApi('item-1')
expect(fetchMock).toHaveBeenCalledWith( expect(fetchMock).toHaveBeenCalledWith(
`${API_BASE_URL}/lists/item/item-1`, `${API_BASE_URL}/lists/items/item-1`,
expect.objectContaining({ method: 'DELETE' }), 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, ListItem,
CreateListPayload, CreateListPayload,
CreateListItemPayload, CreateListItemPayload,
UpdateListItemPayload,
SetListItemCompletedPayload, SetListItemCompletedPayload,
SetListItemTitlePayload,
AddUserToListPayload, AddUserToListPayload,
RemoveUserFromListPayload, RemoveUserFromListPayload,
OrderListsPayload,
} from '@/types/list' } from '@/types/list'
export async function getListsApi(): Promise<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> { 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) { if (!response.ok) {
throw new Error(await extractErrorMessage(response, 'Failed to create list item')) throw new Error(await extractErrorMessage(response, 'Failed to create list item'))
} }
return response.json() 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( export async function setListItemCompletedApi(
itemId: string, itemId: string,
payload: SetListItemCompletedPayload, payload: SetListItemCompletedPayload,
): Promise<void> { ): Promise<void> {
const response = await apiClient.post(`/lists/items/${itemId}`, payload) const response = await apiClient.post(`/lists/items/${itemId}/complete`, payload)
if (!response.ok) { if (!response.ok) {
throw new Error(await extractErrorMessage(response, 'Failed to update list item status')) 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> { export async function addUserToListApi(payload: AddUserToListPayload): Promise<void> {
const response = await apiClient.post('/lists/user', payload) const response = await apiClient.post('/lists/user', payload)
if (!response.ok) { 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> { export async function deleteListApi(listId: string): Promise<void> {
const response = await apiClient.delete(`/lists/${listId}`) const response = await apiClient.delete(`/lists/${listId}`)
if (!response.ok) { if (!response.ok) {
@@ -82,7 +93,7 @@ export async function deleteListApi(listId: string): Promise<void> {
} }
export async function deleteListItemApi(itemId: 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) { if (!response.ok) {
throw new Error(await extractErrorMessage(response, 'Failed to delete list item')) throw new Error(await extractErrorMessage(response, 'Failed to delete list item'))
} }
+10 -1
View File
@@ -1,10 +1,19 @@
import { apiClient } from '@/api/client' import { apiClient } from '@/api/client'
import { extractErrorMessage } from '@/api/http' import { extractErrorMessage } from '@/api/http'
import type { ChangePasswordPayload } from '@/types/auth' import type { ChangePasswordPayload } from '@/types/auth'
import type { CreateUserPayload, User } from '@/types/user'
export async function changePasswordApi(payload: ChangePasswordPayload): Promise<void> { export async function changePasswordApi(payload: ChangePasswordPayload): Promise<void> {
const response = await apiClient.post('/users/password', payload) const response = await apiClient.post('/user/password', payload)
if (!response.ok) { if (!response.ok) {
throw new Error(await extractErrorMessage(response, 'Failed to change password')) 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 { :root {
--c-bg: #0a0e1a; --c-bg: #12161d;
--c-bg-soft: #101627; --c-bg-soft: #181e27;
--c-bg-mute: #161d33; --c-bg-mute: #202632;
--c-bg-elevated: #1a2338; --c-bg-elevated: #262e3c;
--c-border: rgba(120, 150, 220, 0.16); --c-border: rgba(74, 130, 196, 0.16);
--c-border-hover: rgba(120, 150, 220, 0.32); --c-border-hover: rgba(74, 130, 196, 0.36);
--c-text: #c7d0e6; --c-text: #aab2c0;
--c-text-soft: #8892b0; --c-text-soft: #707a8c;
--c-heading: #f1f4fc; --c-heading: #eef1f6;
--c-accent: #4f7dfa; --c-accent: #3d72b4;
--c-accent-soft: #3a5fd9; --c-accent-soft: #2f5c92;
--c-accent-strong: #6c93ff; --c-accent-strong: #62a0e0;
--c-accent-bg: rgba(79, 125, 250, 0.14); --c-accent-bg: rgba(61, 114, 180, 0.16);
--c-success: #34d399; --c-success: #6ba178;
--c-danger: #f87171; --c-success-bg: rgba(107, 161, 120, 0.14);
--c-danger-bg: rgba(248, 113, 113, 0.12); --c-danger: #c1614a;
--c-warning: #fbbf24; --c-danger-bg: rgba(193, 97, 74, 0.14);
--c-warning: #cf9d3f;
--radius-sm: 8px; --font-body:
--radius-md: 12px; 'IBM Plex Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
--radius-lg: 18px; --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); --radius-sm: 3px;
--shadow-md: 0 8px 24px rgba(0, 0, 0, 0.45); --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; --nav-height: 60px;
--safe-bottom: env(safe-area-inset-bottom, 0px); --safe-bottom: env(safe-area-inset-bottom, 0px);
@@ -56,21 +63,18 @@ html {
body { body {
min-height: 100vh; min-height: 100vh;
color: var(--c-text); 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; line-height: 1.55;
font-family: font-family: var(--font-body);
Inter,
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
Roboto,
Oxygen,
Ubuntu,
Cantarell,
'Fira Sans',
'Droid Sans',
'Helvetica Neue',
sans-serif;
font-size: 15px; font-size: 15px;
text-rendering: optimizeLegibility; text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
@@ -85,6 +89,12 @@ h4 {
font-weight: 600; font-weight: 600;
} }
h1 {
font-family: var(--font-display);
font-weight: 600;
letter-spacing: -0.01em;
}
a { a {
color: var(--c-accent-strong); color: var(--c-accent-strong);
text-decoration: none; text-decoration: none;
Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

+18 -5
View File
@@ -29,13 +29,14 @@
} }
.btn-primary { .btn-primary {
background: linear-gradient(135deg, var(--c-accent) 0%, var(--c-accent-soft) 100%); background-color: var(--c-accent);
color: #ffffff; color: var(--c-bg);
font-weight: 600;
box-shadow: var(--shadow-sm); box-shadow: var(--shadow-sm);
} }
.btn-primary:hover:not(:disabled) { .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 { .btn-secondary {
@@ -52,7 +53,7 @@
.btn-danger { .btn-danger {
background-color: transparent; background-color: transparent;
color: var(--c-danger); 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) { .btn-danger:hover:not(:disabled) {
@@ -113,11 +114,23 @@
.banner-error { .banner-error {
background-color: var(--c-danger-bg); 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); 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 { .page {
width: 100%;
min-height: 100vh; min-height: 100vh;
padding: 1rem 1rem calc(var(--nav-height) + var(--safe-bottom) + 1.5rem); padding: 1rem 1rem calc(var(--nav-height) + var(--safe-bottom) + 1.5rem);
padding-top: calc(1rem + var(--safe-top)); padding-top: calc(1rem + var(--safe-top));
+60 -21
View File
@@ -1,4 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { useListsStore } from '@/stores/lists' import { useListsStore } from '@/stores/lists'
@@ -11,25 +12,44 @@ async function handleLogout() {
await authStore.logout() await authStore.logout()
router.push('/login') 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> </script>
<template> <template>
<header class="app-header"> <header class="app-header">
<div class="brand"> <div class="brand">
<span class="brand-dot"></span> <span class="brand-seal"></span>
<span class="brand-name">dttmr</span> <span class="brand-name">dttmr</span>
</div> </div>
<div class="status"> <div class="status">
<span <span
class="sync-dot" v-if="listsStore.error"
:class="{ offline: !authStore.isAuthenticated }" class="stamp stamp-danger"
:title="listsStore.isSyncing ? 'Syncing…' : 'Up to date'" :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>
<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"> <button v-if="authStore.isAuthenticated" type="button" class="logout" @click="handleLogout">
Log out Log out
</button> </button>
@@ -46,7 +66,7 @@ async function handleLogout() {
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
padding: calc(0.75rem + var(--safe-top)) 1rem 0.75rem; 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); backdrop-filter: blur(8px);
} }
@@ -54,17 +74,23 @@ async function handleLogout() {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
font-weight: 600;
color: var(--c-heading); color: var(--c-heading);
}
.brand-name {
font-family: var(--font-display);
font-weight: 600;
font-size: 1rem;
letter-spacing: 0.02em; letter-spacing: 0.02em;
} }
.brand-dot { .brand-seal {
width: 10px; width: 16px;
height: 10px; height: 16px;
border-radius: 50%; border-radius: 3px;
background: linear-gradient(135deg, var(--c-accent-strong), var(--c-accent-soft)); background: var(--c-accent);
box-shadow: 0 0 12px var(--c-accent); transform: rotate(-8deg);
box-shadow: 0 0 0 2px var(--c-bg) inset;
} }
.status { .status {
@@ -75,18 +101,31 @@ async function handleLogout() {
color: var(--c-text-soft); 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; width: 8px;
height: 8px; height: 8px;
border-radius: 50%; border-radius: 50%;
background-color: var(--c-success); background-color: currentColor;
flex-shrink: 0;
} }
.sync-dot.offline { .stamp-pending {
background-color: var(--c-text-soft); color: var(--c-warning);
} }
.sync-error { .stamp-synced {
color: var(--c-success);
}
.stamp-danger {
color: var(--c-danger); color: var(--c-danger);
cursor: help; cursor: help;
} }
+42 -9
View File
@@ -10,14 +10,41 @@ const listsStore = useListsStore()
<template> <template>
<nav v-if="authStore.isAuthenticated" class="bottom-nav"> <nav v-if="authStore.isAuthenticated" class="bottom-nav">
<RouterLink to="/" class="nav-item" active-class="is-active"> <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 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 listsStore.pendingCount
}}</span> }}</span>
</RouterLink> </RouterLink>
<RouterLink to="/about" class="nav-item" active-class="is-active"> <RouterLink to="/exercises" 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"
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> <span class="nav-label">Account</span>
</RouterLink> </RouterLink>
</nav> </nav>
@@ -45,14 +72,19 @@ const listsStore = useListsStore()
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: 0.15rem; gap: 0.2rem;
color: var(--c-text-soft); color: var(--c-text-soft);
font-size: 0.7rem; font-size: 0.68rem;
transition: color 0.15s ease-in-out;
}
.nav-item.is-active {
color: var(--c-accent-strong);
} }
.nav-icon { .nav-icon {
font-size: 1.25rem; width: 21px;
line-height: 1; height: 21px;
} }
.nav-badge { .nav-badge {
@@ -64,8 +96,9 @@ const listsStore = useListsStore()
padding: 0 4px; padding: 0 4px;
border-radius: 999px; border-radius: 999px;
background-color: var(--c-accent); background-color: var(--c-accent);
color: #fff; color: var(--c-bg);
font-size: 0.6rem; font-size: 0.6rem;
font-weight: 600;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
-3
View File
@@ -126,9 +126,6 @@ async function handleSubmit() {
} }
.banner-success { .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; margin-top: 0.75rem;
} }
+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>
+97
View File
@@ -0,0 +1,97 @@
<script setup lang="ts">
import { computed } from 'vue'
type IconName =
| 'phone'
| 'email'
| 'location'
| 'linkedin'
| 'github'
| 'code'
| 'verified'
| 'summary'
| 'experience'
| 'education'
| 'achievements'
| 'languages'
| 'skills'
| 'additional'
| 'interests'
| 'terminal'
| 'server'
| 'git-branch'
| 'cpu'
| 'mountain'
| 'dumbbell'
const props = defineProps<{ name: IconName }>()
// Sourced from lucide-static (ISC license): phone, mail, map-pin, linkedin,
// github, code, badge-check, file-text, briefcase, graduation-cap, award,
// languages, chart-column, layers, heart, terminal, server, git-branch, cpu,
// mountain, dumbbell.
const MARKUP: Record<IconName, string> = {
phone:
'<path d="M13.832 16.568a1 1 0 0 0 1.213-.303l.355-.465A2 2 0 0 1 17 15h3a2 2 0 0 1 2 2v3a2 2 0 0 1-2 2A18 18 0 0 1 2 4a2 2 0 0 1 2-2h3a2 2 0 0 1 2 2v3a2 2 0 0 1-.8 1.6l-.468.351a1 1 0 0 0-.292 1.233 14 14 0 0 0 6.392 6.384" />',
email:
'<path d="m22 7-8.991 5.727a2 2 0 0 1-2.009 0L2 7" /><rect x="2" y="4" width="20" height="16" rx="2" />',
location:
'<path d="M20 10c0 4.993-5.539 10.193-7.399 11.799a1 1 0 0 1-1.202 0C9.539 20.193 4 14.993 4 10a8 8 0 0 1 16 0" /><circle cx="12" cy="10" r="3" />',
linkedin:
'<path d="M16 8a6 6 0 0 1 6 6v7h-4v-7a2 2 0 0 0-2-2 2 2 0 0 0-2 2v7h-4v-7a6 6 0 0 1 6-6z" /><rect width="4" height="12" x="2" y="9" /><circle cx="4" cy="4" r="2" />',
github:
'<path d="M15 22v-4a4.8 4.8 0 0 0-1-3.5c3 0 6-2 6-5.5.08-1.25-.27-2.48-1-3.5.28-1.15.28-2.35 0-3.5 0 0-1 0-3 1.5-2.64-.5-5.36-.5-8 0C6 2 5 2 5 2c-.3 1.15-.3 2.35 0 3.5A5.403 5.403 0 0 0 4 9c0 3.5 3 5.5 6 5.5-.39.49-.68 1.05-.85 1.65-.17.6-.22 1.23-.15 1.85v4" /><path d="M9 18c-4.51 2-5-2-7-2" />',
code: '<path d="m16 18 6-6-6-6" /><path d="m8 6-6 6 6 6" />',
verified:
'<path d="M3.85 8.62a4 4 0 0 1 4.78-4.77 4 4 0 0 1 6.74 0 4 4 0 0 1 4.78 4.78 4 4 0 0 1 0 6.74 4 4 0 0 1-4.77 4.78 4 4 0 0 1-6.75 0 4 4 0 0 1-4.78-4.77 4 4 0 0 1 0-6.76Z" /><path d="m16 9-5.5 5.5L8 12" />',
summary:
'<path d="M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z" /><path d="M14 2v5a1 1 0 0 0 1 1h5" /><path d="M10 9H8" /><path d="M16 13H8" /><path d="M16 17H8" />',
experience:
'<path d="M16 20V4a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16" /><rect width="20" height="14" x="2" y="6" rx="2" />',
education:
'<path d="M21.42 10.922a1 1 0 0 0-.019-1.838L12.83 5.18a2 2 0 0 0-1.66 0L2.6 9.08a1 1 0 0 0 0 1.832l8.57 3.908a2 2 0 0 0 1.66 0z" /><path d="M22 10v6" /><path d="M6 12.5V16a6 3 0 0 0 12 0v-3.5" />',
achievements:
'<path d="m15.477 12.89 1.515 8.526a.5.5 0 0 1-.81.47l-3.58-2.687a1 1 0 0 0-1.197 0l-3.586 2.686a.5.5 0 0 1-.81-.469l1.514-8.526" /><circle cx="12" cy="8" r="6" />',
languages:
'<path d="m5 8 6 6" /><path d="m4 14 6-6 2-3" /><path d="M2 5h12" /><path d="M7 2h1" /><path d="m22 22-5-10-5 10" /><path d="M14 18h6" />',
skills:
'<path d="M3 3v16a2 2 0 0 0 2 2h16" /><path d="M18 17V9" /><path d="M13 17V5" /><path d="M8 17v-3" />',
additional:
'<path d="M12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.58 3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.83z" /><path d="M2 12a1 1 0 0 0 .58.91l8.6 3.91a2 2 0 0 0 1.65 0l8.58-3.9A1 1 0 0 0 22 12" /><path d="M2 17a1 1 0 0 0 .58.91l8.6 3.91a2 2 0 0 0 1.65 0l8.58-3.9A1 1 0 0 0 22 17" />',
interests:
'<path d="M2 9.5a5.5 5.5 0 0 1 9.591-3.676.56.56 0 0 0 .818 0A5.49 5.49 0 0 1 22 9.5c0 2.29-1.5 4-3 5.5l-5.492 5.313a2 2 0 0 1-3 .019L5 15c-1.5-1.5-3-3.2-3-5.5" />',
terminal: '<path d="M12 19h8" /><path d="m4 17 6-6-6-6" />',
server:
'<rect width="20" height="8" x="2" y="2" rx="2" ry="2" /><rect width="20" height="8" x="2" y="14" rx="2" ry="2" /><line x1="6" x2="6.01" y1="6" y2="6" /><line x1="6" x2="6.01" y1="18" y2="18" />',
'git-branch':
'<path d="M15 6a9 9 0 0 0-9 9V3" /><circle cx="18" cy="6" r="3" /><circle cx="6" cy="18" r="3" />',
cpu: '<path d="M12 20v2" /><path d="M12 2v2" /><path d="M17 20v2" /><path d="M17 2v2" /><path d="M2 12h2" /><path d="M2 17h2" /><path d="M2 7h2" /><path d="M20 12h2" /><path d="M20 17h2" /><path d="M20 7h2" /><path d="M7 20v2" /><path d="M7 2v2" /><rect x="4" y="4" width="16" height="16" rx="2" /><rect x="8" y="8" width="8" height="8" rx="1" />',
mountain: '<path d="m8 3 4 8 5-5 5 15H2L8 3z" />',
dumbbell:
'<path d="M17.596 12.768a2 2 0 1 0 2.829-2.829l-1.768-1.767a2 2 0 0 0 2.828-2.829l-2.828-2.828a2 2 0 0 0-2.829 2.828l-1.767-1.768a2 2 0 1 0-2.829 2.829z" /><path d="m2.5 21.5 1.4-1.4" /><path d="m20.1 3.9 1.4-1.4" /><path d="M5.343 21.485a2 2 0 1 0 2.829-2.828l1.767 1.768a2 2 0 1 0 2.829-2.829l-6.364-6.364a2 2 0 1 0-2.829 2.829l1.768 1.767a2 2 0 0 0-2.828 2.829z" /><path d="m9.6 14.4 4.8-4.8" />',
}
const markup = computed(() => MARKUP[props.name])
</script>
<template>
<svg
class="cv-icon"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
v-html="markup"
></svg>
</template>
<style scoped>
.cv-icon {
width: 1em;
height: 1em;
flex-shrink: 0;
}
</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>
+8 -29
View File
@@ -1,8 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import type { LocalList } from '@/database/db' import type { LocalList } from '@/database/db'
import BaseModal from '@/components/BaseModal.vue' import ConfirmDeleteModal from '@/components/ConfirmDeleteModal.vue'
defineProps<{ const props = defineProps<{
list: LocalList list: LocalList
}>() }>()
@@ -21,32 +21,11 @@ function handleConfirm() {
</script> </script>
<template> <template>
<BaseModal <ConfirmDeleteModal
:title="`Delete &quot;${list.name}&quot;?`" :title="`Delete &quot;${props.list.name}&quot;?`"
title-id="delete-modal-title" 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" @close="handleClose"
> @confirm="handleConfirm"
<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>
<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">
Delete list
</button>
</template>
</BaseModal>
</template> </template>
<style scoped>
.modal-description {
font-size: 0.85rem;
color: var(--c-text-soft);
margin-bottom: 1.25rem;
line-height: 1.4;
}
</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 { useListsStore } from '@/stores/lists'
import { useDismissableMenu } from '@/composables/useDismissableMenu' import { useDismissableMenu } from '@/composables/useDismissableMenu'
const props = defineProps<{ list: LocalList }>() const props = defineProps<{ list: LocalList; dragging?: boolean }>()
const emit = defineEmits<{ const emit = defineEmits<{
share: [list: LocalList] share: [list: LocalList]
delete: [list: LocalList] delete: [list: LocalList]
'handle-pointerdown': [event: PointerEvent]
}>() }>()
const listsStore = useListsStore() const listsStore = useListsStore()
@@ -49,12 +50,29 @@ function handleDelete(event: Event) {
</script> </script>
<template> <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"> <RouterLink :to="`/lists/${list.id}`" class="list-card-link">
<div class="list-card-main"> <div class="list-card-main">
<h3>{{ list.name }}</h3> <h3>{{ list.name }}</h3>
<p class="meta"> <p class="meta">
{{ completedCount }}/{{ totalCount }} done <span class="mono-num">{{ completedCount }}/{{ totalCount }}</span> done
<span v-if="list.pendingSync" class="pending-tag">syncing</span> <span v-if="list.pendingSync" class="pending-tag">syncing</span>
</p> </p>
</div> </div>
@@ -144,6 +162,48 @@ function handleDelete(event: Event) {
border-color: var(--c-border-hover); 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 { .list-card-link {
display: flex; display: flex;
align-items: center; align-items: center;
+46 -14
View File
@@ -3,6 +3,7 @@ import { ref } from 'vue'
import type { LocalListItem } from '@/database/db' import type { LocalListItem } from '@/database/db'
import { useListsStore } from '@/stores/lists' import { useListsStore } from '@/stores/lists'
import { useDismissableMenu } from '@/composables/useDismissableMenu' import { useDismissableMenu } from '@/composables/useDismissableMenu'
import DeleteListItemModal from '@/components/DeleteListItemModal.vue'
const props = defineProps<{ item: LocalListItem }>() 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 // diffing against the live (possibly just-changed) prop and overwriting the
// remote edit with the untouched original text. // remote edit with the untouched original text.
const originalTitle = ref(props.item.title) const originalTitle = ref(props.item.title)
const showDeleteModal = ref(false)
const { const {
isOpen: isMenuOpen, isOpen: isMenuOpen,
containerRef: menuContainerRef, containerRef: menuContainerRef,
@@ -27,6 +29,7 @@ function toggleCompleted() {
} }
function startEditing() { function startEditing() {
isMenuOpen.value = false
editedTitle.value = props.item.title editedTitle.value = props.item.title
originalTitle.value = props.item.title originalTitle.value = props.item.title
isEditing.value = true isEditing.value = true
@@ -35,15 +38,22 @@ function startEditing() {
function saveTitle() { function saveTitle() {
const title = editedTitle.value.trim() const title = editedTitle.value.trim()
if (title && title !== originalTitle.value) { if (title && title !== originalTitle.value) {
listsStore.updateListItem(props.item.id, { title }) listsStore.updateListItemTitle(props.item.id, title)
} }
isEditing.value = false isEditing.value = false
} }
function handleDelete(event: Event) { function handleOpenDelete() {
event.preventDefault()
event.stopPropagation()
isMenuOpen.value = false isMenuOpen.value = false
showDeleteModal.value = true
}
function handleCloseDelete() {
showDeleteModal.value = false
}
function handleConfirmDelete() {
showDeleteModal.value = false
listsStore.deleteListItem(props.item.id) listsStore.deleteListItem(props.item.id)
} }
</script> </script>
@@ -68,7 +78,7 @@ function handleDelete(event: Event) {
@keyup.escape="isEditing = false" @keyup.escape="isEditing = false"
@blur="saveTitle" @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> <span v-if="item.pendingSync" class="pending-dot" title="Not yet synced"></span>
@@ -90,11 +100,25 @@ function handleDelete(event: Event) {
</button> </button>
<div v-if="isMenuOpen" class="submenu-dropdown card" role="menu"> <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 <button
type="button" type="button"
class="submenu-item submenu-item-danger" class="submenu-item submenu-item-danger"
role="menuitem" role="menuitem"
@click="handleDelete" @click="handleOpenDelete"
> >
<svg <svg
class="submenu-icon" class="submenu-icon"
@@ -116,6 +140,13 @@ function handleDelete(event: Event) {
</button> </button>
</div> </div>
</div> </div>
<DeleteListItemModal
v-if="showDeleteModal"
:item="item"
@close="handleCloseDelete"
@confirm="handleConfirmDelete"
/>
</li> </li>
</template> </template>
@@ -134,22 +165,23 @@ function handleDelete(event: Event) {
.checkbox { .checkbox {
flex-shrink: 0; flex-shrink: 0;
width: 22px; width: 21px;
height: 22px; height: 21px;
border-radius: 6px; border-radius: var(--radius-sm);
border: 1px solid var(--c-border-hover); border: 1.5px solid var(--c-border-hover);
background: transparent; background: transparent;
color: #fff; color: var(--c-bg);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
cursor: pointer; cursor: pointer;
font-size: 0.75rem; font-size: 0.8rem;
font-weight: 700;
} }
.item-row.completed .checkbox { .item-row.completed .checkbox {
background: linear-gradient(135deg, var(--c-accent-strong), var(--c-accent-soft)); background: var(--c-accent);
border-color: transparent; border-color: var(--c-accent);
} }
.title { .title {
-3
View File
@@ -110,9 +110,6 @@ async function handleAddUser() {
} }
.banner-success { .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; margin-top: 0.75rem;
} }
@@ -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) 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, { const wrapper = mount(ListItemRow, {
props: { props: {
item: sampleItem, item: sampleItem,
@@ -61,10 +93,42 @@ describe('ListItemRow', () => {
await wrapper.find('.menu-trigger-btn').trigger('click') await wrapper.find('.menu-trigger-btn').trigger('click')
expect(wrapper.find('.submenu-dropdown').exists()).toBe(true) 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') await wrapper.find('.submenu-item-danger').trigger('click')
expect(deleteSpy).toHaveBeenCalledWith('item-1')
expect(wrapper.find('.submenu-dropdown').exists()).toBe(false) 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, ListItem,
CreateListPayload, CreateListPayload,
CreateListItemPayload, CreateListItemPayload,
UpdateListItemPayload,
SetListItemCompletedPayload, SetListItemCompletedPayload,
SetListItemTitlePayload,
OrderListsPayload,
} from '@/types/list' } from '@/types/list'
export interface LocalList extends List { export interface LocalList extends List {
@@ -36,10 +37,11 @@ interface SyncQueueEntryBase {
type SyncOperationPayloads = { type SyncOperationPayloads = {
createList: CreateListPayload createList: CreateListPayload
createListItem: CreateListItemPayload createListItem: CreateListItemPayload
updateListItem: UpdateListItemPayload updateListItemTitle: SetListItemTitlePayload
setListItemCompleted: SetListItemCompletedPayload setListItemCompleted: SetListItemCompletedPayload
deleteList: DeleteListPayload deleteList: DeleteListPayload
deleteListItem: DeleteListItemPayload deleteListItem: DeleteListItemPayload
orderLists: OrderListsPayload
} }
export type SyncOperationType = keyof SyncOperationPayloads 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 }, meta: { requiresAuth: true },
props: true, props: true,
}, },
{
path: '/exercises',
name: 'exercises',
component: () => import('../views/ExercisesView.vue'),
meta: { requiresAuth: true },
},
{ {
path: '/login', path: '/login',
name: 'login', name: 'login',
component: () => import('../views/LoginView.vue'), component: () => import('../views/LoginView.vue'),
}, },
{ {
path: '/about', path: '/cv',
name: 'about', name: 'cv',
component: () => import('../views/AboutView.vue'), 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) => { router.beforeEach((to) => {
const authStore = useAuthStore() 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) { if (to.meta.requiresAuth && !authStore.isAuthenticated) {
return { name: 'login', query: { redirect: to.fullPath } } return { name: 'login', query: { redirect: to.fullPath } }
} }
+121 -10
View File
@@ -109,12 +109,13 @@ const listsApiMocks = vi.hoisted(() => ({
createListApi: vi.fn<() => Promise<unknown>>(), createListApi: vi.fn<() => Promise<unknown>>(),
getListItemsApi: vi.fn<() => Promise<unknown>>(), getListItemsApi: vi.fn<() => Promise<unknown>>(),
createListItemApi: vi.fn<() => Promise<unknown>>(), createListItemApi: vi.fn<() => Promise<unknown>>(),
updateListItemApi: vi.fn<() => Promise<unknown>>(), updateListItemTitleApi: vi.fn<() => Promise<unknown>>(),
setListItemCompletedApi: vi.fn<() => Promise<unknown>>(), setListItemCompletedApi: vi.fn<() => Promise<unknown>>(),
addUserToListApi: vi.fn<() => Promise<unknown>>(), addUserToListApi: vi.fn<() => Promise<unknown>>(),
removeUserFromListApi: vi.fn<() => Promise<unknown>>(), removeUserFromListApi: vi.fn<() => Promise<unknown>>(),
deleteListApi: vi.fn<() => Promise<unknown>>(), deleteListApi: vi.fn<() => Promise<unknown>>(),
deleteListItemApi: vi.fn<() => Promise<unknown>>(), deleteListItemApi: vi.fn<() => Promise<unknown>>(),
orderListsApi: vi.fn<() => Promise<unknown>>(),
})) }))
vi.mock('@/api/lists', () => listsApiMocks) vi.mock('@/api/lists', () => listsApiMocks)
@@ -140,6 +141,11 @@ describe('useListsStore', () => {
Object.defineProperty(navigator, 'onLine', { value: true, configurable: true }) 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() // Default the read endpoints to an empty result so that the pullFromServer()
// step chained onto every sync() call doesn't interfere with unrelated tests. // step chained onto every sync() call doesn't interfere with unrelated tests.
listsApiMocks.getListsApi.mockResolvedValue([]) listsApiMocks.getListsApi.mockResolvedValue([])
@@ -157,14 +163,13 @@ describe('useListsStore', () => {
listsApiMocks.getListsApi.mockResolvedValueOnce([{ id: 'server-id-1', name: 'Groceries' }]) listsApiMocks.getListsApi.mockResolvedValueOnce([{ id: 'server-id-1', name: 'Groceries' }])
const store = useListsStore() 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 // wait for the fire-and-forget sync triggered by createList to settle
await store.sync() await store.sync()
expect(listsApiMocks.createListApi).toHaveBeenCalledWith({ expect(listsApiMocks.createListApi).toHaveBeenCalledWith({
name: 'Groceries', name: 'Groceries',
user_ids: [],
}) })
expect(store.lists.find((list) => list.id === localList.id)).toBeUndefined() expect(store.lists.find((list) => list.id === localList.id)).toBeUndefined()
const synced = store.lists.find((list) => list.id === 'server-id-1') const synced = store.lists.find((list) => list.id === 'server-id-1')
@@ -207,29 +212,28 @@ describe('useListsStore', () => {
expect(store.error).toBe('Network error') 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({ listsApiMocks.createListItemApi.mockResolvedValueOnce({
id: 'server-item-2', id: 'server-item-2',
list_id: '', list_id: '',
title: 'Eggs', title: 'Eggs',
is_completed: false, is_completed: false,
}) })
listsApiMocks.updateListItemApi.mockResolvedValueOnce(undefined) listsApiMocks.updateListItemTitleApi.mockResolvedValueOnce(undefined)
const store = useListsStore() const store = useListsStore()
await store.createListItem('list-1', 'Eggs') await store.createListItem('list-1', 'Eggs')
await store.sync() await store.sync()
const created = store.listItems.find((entry) => entry.title === 'Eggs')! 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() await store.sync()
expect(listsApiMocks.updateListItemApi).toHaveBeenCalledWith({ expect(listsApiMocks.updateListItemTitleApi).toHaveBeenCalledWith(created.id, {
list_item_id: created.id, title: 'Free-range eggs',
is_completed: true,
}) })
const updated = store.listItems.find((entry) => entry.id === created.id) 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) expect(updated?.pendingSync).toBe(false)
}) })
@@ -244,6 +248,16 @@ describe('useListsStore', () => {
expect(store.pendingCount).toBe(1) 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 () => { it('sets a list item completed locally and pushes it via the dedicated endpoint', async () => {
listsApiMocks.createListItemApi.mockResolvedValueOnce({ listsApiMocks.createListItemApi.mockResolvedValueOnce({
id: 'server-item-3', id: 'server-item-3',
@@ -518,6 +532,103 @@ describe('useListsStore', () => {
expect(store.pendingCount).toBe(0) 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 () => { it('serializes pullListItems() behind an in-flight sync() so they never race on the same rows', async () => {
let resolveGetLists!: (value: unknown[]) => void let resolveGetLists!: (value: unknown[]) => void
listsApiMocks.getListsApi.mockImplementationOnce( listsApiMocks.getListsApi.mockImplementationOnce(
+91 -18
View File
@@ -1,5 +1,6 @@
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { useAuthStore } from '@/stores/auth'
import { import {
db, db,
type LocalList, type LocalList,
@@ -12,12 +13,13 @@ import {
createListApi, createListApi,
getListItemsApi, getListItemsApi,
createListItemApi, createListItemApi,
updateListItemApi, updateListItemTitleApi,
setListItemCompletedApi, setListItemCompletedApi,
addUserToListApi, addUserToListApi,
removeUserFromListApi, removeUserFromListApi,
deleteListApi, deleteListApi,
deleteListItemApi, deleteListItemApi,
orderListsApi,
} from '@/api/lists' } from '@/api/lists'
// A burst of rapid edits (ticking off several items, typing then blurring a // 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 error = ref<string | null>(null)
const pendingCount = ref(0) 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(() => 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) { function itemsForList(listId: string) {
@@ -133,7 +145,7 @@ export const useListsStore = defineStore('lists', () => {
}, SYNC_DEBOUNCE_MS) }, 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 now = new Date().toISOString()
const localList: LocalList = { const localList: LocalList = {
id: generateId(), id: generateId(),
@@ -147,7 +159,7 @@ export const useListsStore = defineStore('lists', () => {
upsertList(localList) upsertList(localList)
await enqueue({ await enqueue({
type: 'createList', type: 'createList',
payload: { name, user_ids: userIds }, payload: { name },
localListId: localList.id, localListId: localList.id,
}) })
scheduleSync() scheduleSync()
@@ -179,12 +191,9 @@ export const useListsStore = defineStore('lists', () => {
return localItem return localItem
} }
async function updateListItem( async function updateListItemTitle(itemId: string, title: string) {
itemId: string,
changes: { title?: string; is_completed?: boolean },
) {
const patch = { const patch = {
...changes, title,
modified_at: new Date().toISOString(), modified_at: new Date().toISOString(),
pendingSync: true, pendingSync: true,
} }
@@ -192,8 +201,8 @@ export const useListsStore = defineStore('lists', () => {
const existingItem = listItems.value.find((entry) => entry.id === itemId) const existingItem = listItems.value.find((entry) => entry.id === itemId)
if (existingItem) Object.assign(existingItem, patch) if (existingItem) Object.assign(existingItem, patch)
await enqueue({ await enqueue({
type: 'updateListItem', type: 'updateListItemTitle',
payload: { list_item_id: itemId, ...changes }, payload: { title },
localListItemId: itemId, localListItemId: itemId,
}) })
scheduleSync() scheduleSync()
@@ -247,6 +256,37 @@ export const useListsStore = defineStore('lists', () => {
scheduleSync() 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) { async function deleteListItem(itemId: string) {
await db.listItems.delete(itemId) await db.listItems.delete(itemId)
removeLocalListItem(itemId) removeLocalListItem(itemId)
@@ -294,6 +334,18 @@ export const useListsStore = defineStore('lists', () => {
payload: updatedPayload, 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 // Remaps a client-generated temporary list item id to the id assigned by
@@ -356,11 +408,10 @@ export const useListsStore = defineStore('lists', () => {
} }
break break
} }
case 'updateListItem': { case 'updateListItemTitle': {
await updateListItemApi(entry.payload) if (!entry.localListItemId) break
if (entry.localListItemId) { await updateListItemTitleApi(entry.localListItemId, entry.payload)
await markListItemSynced(entry.localListItemId) await markListItemSynced(entry.localListItemId)
}
break break
} }
case 'setListItemCompleted': { case 'setListItemCompleted': {
@@ -377,6 +428,17 @@ export const useListsStore = defineStore('lists', () => {
await deleteListItemApi(entry.payload.id) await deleteListItemApi(entry.payload.id)
break 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 { try {
const queue = await db.syncQueue.orderBy('createdAt').toArray() 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 { try {
await processSyncEntry(entry) await processSyncEntry(entry)
if (entry.id !== undefined) { if (entry.id !== undefined) {
@@ -438,6 +508,7 @@ export const useListsStore = defineStore('lists', () => {
return ongoingSync return ongoingSync
} }
if (typeof navigator !== 'undefined' && !navigator.onLine) return if (typeof navigator !== 'undefined' && !navigator.onLine) return
if (!useAuthStore().isAuthenticated) return
ongoingSync = enqueueOperation(() => runSync().then(() => pullFromServer())) ongoingSync = enqueueOperation(() => runSync().then(() => pullFromServer()))
try { try {
@@ -562,17 +633,19 @@ export const useListsStore = defineStore('lists', () => {
pendingCount, pendingCount,
error, error,
itemsForList, itemsForList,
ensureLoaded,
loadLists, loadLists,
loadListItems, loadListItems,
refresh, refresh,
createList, createList,
createListItem, createListItem,
updateListItem, updateListItemTitle,
setListItemCompleted, setListItemCompleted,
addUserToList, addUserToList,
removeUserFromList, removeUserFromList,
deleteList, deleteList,
deleteListItem, deleteListItem,
reorderLists,
sync, sync,
pullFromServer, pullFromServer,
pullListItems, pullListItems,
+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 modified_at?: string
total_items?: number total_items?: number
completed_items?: number completed_items?: number
position?: number
} }
export interface ListItem { export interface ListItem {
@@ -18,7 +19,6 @@ export interface ListItem {
export interface CreateListPayload { export interface CreateListPayload {
name: string name: string
user_ids?: string[]
} }
export interface CreateListItemPayload { export interface CreateListItemPayload {
@@ -26,16 +26,14 @@ export interface CreateListItemPayload {
title: string title: string
} }
export interface UpdateListItemPayload {
list_item_id: string
title?: string
is_completed?: boolean
}
export interface SetListItemCompletedPayload { export interface SetListItemCompletedPayload {
is_completed: boolean is_completed: boolean
} }
export interface SetListItemTitlePayload {
title: string
}
export interface AddUserToListPayload { export interface AddUserToListPayload {
list_id: string list_id: string
email: string email: string
@@ -45,3 +43,7 @@ export interface RemoveUserFromListPayload {
list_id: string list_id: string
email: 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
}
@@ -2,16 +2,36 @@
import { onMounted, ref } from 'vue' import { onMounted, ref } from 'vue'
import { useListsStore } from '@/stores/lists' import { useListsStore } from '@/stores/lists'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { getVersionApi } from '@/api/version'
import type { VersionInfo } from '@/types/version'
import ChangePasswordModal from '@/components/ChangePasswordModal.vue' import ChangePasswordModal from '@/components/ChangePasswordModal.vue'
import InvitesPanel from '@/components/InvitesPanel.vue'
const listsStore = useListsStore() const listsStore = useListsStore()
const authStore = useAuthStore() const authStore = useAuthStore()
const showChangePassword = ref(false) const showChangePassword = ref(false)
const versionInfo = ref<VersionInfo | null>(null)
const versionError = ref('')
const isLoadingVersion = ref(false)
onMounted(() => { onMounted(() => {
listsStore.loadLists() 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> </script>
<template> <template>
@@ -22,7 +42,7 @@ onMounted(() => {
<section class="card info-card"> <section class="card info-card">
<h4>Users</h4> <h4>Users</h4>
<p class="row"> <p class="row">
<span>Signed in as</span> <span>Signed in as </span>
<strong>{{ authStore.email ?? 'Unknown' }}</strong> <strong>{{ authStore.email ?? 'Unknown' }}</strong>
</p> </p>
<button <button
@@ -34,13 +54,15 @@ onMounted(() => {
</button> </button>
</section> </section>
<h1>Pending changes</h1> <InvitesPanel />
<h1 class="section-heading">Pending changes</h1>
<section class="card info-card"> <section class="card info-card">
<h4>Sync status</h4> <h4>Sync status</h4>
<p class="row"> <p class="row">
<span>Pending changes</span> <span>Pending changes</span>
<strong>{{ listsStore.pendingCount }}</strong> <strong class="mono-num">{{ listsStore.pendingCount }}</strong>
</p> </p>
<p class="row"> <p class="row">
<span>Syncing</span> <span>Syncing</span>
@@ -48,16 +70,47 @@ onMounted(() => {
</p> </p>
</section> </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" /> <ChangePasswordModal v-if="showChangePassword" @close="showChangePassword = false" />
</main> </main>
</template> </template>
<style scoped> <style scoped>
@media (min-width: 768px) {
.page.about {
max-width: 780px;
}
}
.about h1 { .about h1 {
font-size: 1.35rem; font-size: 1.35rem;
margin-bottom: 0.75rem; margin-bottom: 0.75rem;
} }
.section-heading {
margin-top: 1.5rem;
}
.about p { .about p {
color: var(--c-text-soft); color: var(--c-text-soft);
font-size: 0.9rem; font-size: 0.9rem;
@@ -94,4 +147,10 @@ onMounted(() => {
margin-top: 0.6rem; margin-top: 0.6rem;
padding: 0.5rem 1rem; padding: 0.5rem 1rem;
} }
.loading-text {
font-size: 0.85rem;
color: var(--c-text-soft);
margin: 0;
}
</style> </style>
+876
View File
@@ -0,0 +1,876 @@
<script setup lang="ts">
import { onBeforeUnmount, onMounted } from 'vue'
import cvPhoto from '@/assets/cv-photo.jpg'
import CvIcon from '@/components/CvIcon.vue'
// This is a public sub-page with no link from the rest of the app, and it
// shouldn't show up in search results. index.html is shared across every
// route, so the noindex meta tag has to be added/removed here rather than
// living statically in the HTML head.
let robotsMeta: HTMLMetaElement | null = null
onMounted(() => {
robotsMeta = document.createElement('meta')
robotsMeta.name = 'robots'
robotsMeta.content = 'noindex, nofollow'
document.head.appendChild(robotsMeta)
})
onBeforeUnmount(() => {
robotsMeta?.remove()
})
interface Project {
title: string
repo: string
desc: string
url: string
}
interface ExperienceEntry {
company: string
location: string
title: string
dates: string
subtitle: string
bullets: string[]
}
interface EducationEntry {
school: string
dates: string
degree: string
}
interface Achievement {
title: string
desc: string
}
interface Language {
name: string
level: string
rating: number
}
interface Skill {
name: string
rating: number
}
interface AdditionalExperience {
title: string
desc: string
}
interface Interest {
name: string
icon: 'terminal' | 'server' | 'git-branch' | 'cpu' | 'mountain' | 'dumbbell'
}
const SKILL_MAX = 8
const LANGUAGE_MAX = 5
const projects: Project[] = [
{
title: 'Frontend',
repo: 'dttmr-fe',
desc: 'Offline-first PWA for shared, syncable to-do lists and tracking workouts. Vue 3 + TypeScript.',
url: 'https://git.dittmar.dev/robin/dttmr-fe',
},
{
title: 'API',
repo: 'dttmr-api',
desc: 'Go backend powering auth and sync for the to-do list/workout tracking app.',
url: 'https://git.dittmar.dev/robin/dttmr-api',
},
]
const experience: ExperienceEntry[] = [
{
company: 'Greenbone AG',
location: 'Remote, Germany',
title: 'Senior C Software Engineer',
dates: '08/2025 - Present',
subtitle: 'Open-Source Vulnerability Management',
bullets: [
"Improved vulnerability management for customers and community users by working on open- and closed-source projects like 'gvmd', 'gsad', and 'gsa'.",
'Maintain old legacy systems in C, as well as building new components in Rust, which align with the ecosystem.',
'Improve GitHub Actions CI/CD pipelines.',
],
},
{
company: 'Aspen Technology',
location: 'Cologne, Germany',
title: 'Sr Software Developer',
dates: '04/2025 - 07/2025',
subtitle: 'Asset Optimization Software',
bullets: [
'Maintenance work and feature development for "inmation" Backend, a scalable solution for asset optimization.',
],
},
{
company: 'Ubisoft',
location: 'Düsseldorf, Germany',
title: 'Programmer',
dates: '10/2020 - 07/2024',
subtitle: 'AAA Game Developer/Publisher',
bullets: [
'Designed and implemented new backend services, resulting in less development time for business features.',
'Overseen the architecture and deployment of software solutions handling over 600k concurrent users.',
'Reduced processing time by 30% by optimizing database queries and scripts.',
'Improved system stability by 10%, resulting in increased user engagement and satisfaction.',
'Enhanced C++ Backend functionality, resulting in a 5% decrease in response times.',
],
},
{
company: '11880 Internet Services AG',
location: 'Essen, Germany',
title: 'C++ Developer',
dates: '06/2018 - 09/2020',
subtitle: '11880.com Information Portal',
bullets: [
'Maintained and developed new features of an in-memory search engine written in C++.',
'Lifting on-premises services to the cloud, resulting in 30% more uptime and reduced costs.',
],
},
{
company: 'Fluxatron Softec GmbH',
location: 'Oberhausen, Germany',
title: 'Software Developer Working Student',
dates: '03/2016 - 09/2017',
subtitle: 'Software Startup',
bullets: [
'Implement and design add-ons for Dynamics AX 365 for Business and Operations using X++.',
'Enabled operational efficiency by reducing feature deployment time by 20% through end-to-end feature implementation collaboration.',
],
},
]
const education: EducationEntry[] = [
{
school: 'Hochschule Ruhr West University of Applied Sciences',
dates: '10/2015 - 05/2018',
degree: 'Applied Informatics (no degree)',
},
{
school: 'Hans-Sachs Berufskolleg',
dates: '08/2012 - 06/2015',
degree: 'Fachabitur + Informationstechnischer Assistent',
},
]
const achievements: Achievement[] = [
{
title: 'Design New Services',
desc: 'Successfully implemented two new services within three months.',
},
{
title: 'Server Optimization Success',
desc: 'Reduced server downtime by 10% by optimizing code.',
},
{
title: 'Performance Enhancement Achievement',
desc: 'Improved application performance by 50% through code refactoring.',
},
]
const languages: Language[] = [
{ name: 'German', level: 'Native', rating: 5 },
{ name: 'English', level: 'Proficient', rating: 4 },
{ name: 'Spanish', level: 'Beginner', rating: 1 },
]
const interests: Interest[] = [
{ name: 'Software Development', icon: 'terminal' },
{ name: 'Linux', icon: 'server' },
{ name: 'Open Source', icon: 'git-branch' },
{ name: 'Raspberry Pi', icon: 'cpu' },
{ name: 'Rock climbing', icon: 'mountain' },
{ name: 'Calisthenics', icon: 'dumbbell' },
]
const skills: Skill[] = [
{ name: 'Golang', rating: 5 },
{ name: 'C++17', rating: 8 },
{ name: 'C++20', rating: 5 },
{ name: 'C++ STL', rating: 7 },
{ name: 'C++ boost', rating: 4 },
{ name: 'C++ POCO', rating: 4 },
{ name: 'C (C89 to C11)', rating: 7 },
{ name: 'SQL', rating: 5 },
{ name: 'TypeScript', rating: 5 },
{ name: 'Docker', rating: 6 },
{ name: 'Linux', rating: 7 },
{ name: 'git', rating: 7 },
{ name: 'HTTP/REST', rating: 7 },
{ name: 'Amazon Web Services (AWS)', rating: 4 },
{ name: 'Node.js', rating: 4 },
{ name: 'Vue.js', rating: 5 },
{ name: 'CMake', rating: 5 },
{ name: 'MongoDB', rating: 4 },
{ name: 'Python', rating: 6 },
{ name: 'Rust', rating: 4 },
{ name: 'C#', rating: 3 },
{ name: 'ELK stack', rating: 3 },
{ name: 'DevOps', rating: 5 },
{ name: 'CI/CD with gitlab/jenkins', rating: 6 },
{ name: 'GitHub Actions', rating: 6 },
{ name: 'Jetbrains IDEs', rating: 7 },
{ name: 'VSCode', rating: 5 },
{ name: 'Visual Studio', rating: 5 },
{ name: 'vim/neovim', rating: 5 },
{ name: 'MS Windows', rating: 5 },
{ name: 'perf', rating: 5 },
{ name: 'valgrind', rating: 4 },
{ name: 'C++ SFML', rating: 6 },
{ name: 'OpenGL', rating: 4 },
{ name: 'Vulkan', rating: 2 },
{ name: 'DirectX', rating: 2 },
{ name: 'HTML5/CSS3', rating: 4 },
{ name: 'Claude Code', rating: 4 },
]
const additionalExperiences: AdditionalExperience[] = [
{
title: 'Minecraft Mod',
desc: 'Around 2012 I have developed and published the "Blood Mod" for Minecraft. The mod was pretty popular at the time, being downloaded thousands of times.',
},
{
title: 'Buy script generator',
desc: 'Around 2013, I have developed a tool to generate so-called "buy scripts" for Counter-Strike. This was my first experience creating C++ GUI applications, using the Win32 API.',
},
{
title: 'Small 2D games',
desc: 'Around 2014-2015 I became interested in game development and have created a few simple 2D games using C++ and SFML.',
},
{
title: 'Self-hosted services',
desc: 'For years, I have been hosting my own git and password manager at home, utilizing a Raspberry Pi with Docker. In addition, I have a VPS with nginx and WireGuard, for access from anywhere around the world.',
},
]
</script>
<template>
<main class="page cv-page">
<div class="profile-top">
<img class="avatar" :src="cvPhoto" alt="Portrait of Robin Dittmar" />
<div>
<h1>Robin Dittmar</h1>
<span class="role-badge">Software Developer</span>
</div>
</div>
<div class="ledger-rule" aria-hidden="true"></div>
<section class="card info-card contact-card">
<h4>Contacts</h4>
<p class="row">
<span class="row-label"><CvIcon name="phone" />Phone</span>
<a class="mono-num" href="tel:+4915901668799">+49 1590 1668799</a>
</p>
<p class="row">
<span class="row-label"><CvIcon name="email" />Email</span>
<a href="mailto:robindittmar@gmail.com">robindittmar@gmail.com</a>
</p>
<p class="row">
<span class="row-label"><CvIcon name="location" />Location</span>
<strong>Düsseldorf</strong>
</p>
<p class="row">
<span class="row-label"><CvIcon name="linkedin" />LinkedIn</span>
<a
href="https://www.linkedin.com/in/robin-dittmar-948424310/"
target="_blank"
rel="noopener"
>robin-dittmar</a
>
</p>
<p class="row">
<span class="row-label"><CvIcon name="github" />GitHub</span>
<a href="https://github.com/rdttmr" target="_blank" rel="noopener">rdttmr</a>
</p>
</section>
<h1 class="section-heading"><CvIcon name="code" />Check Out My Work</h1>
<ul class="cards">
<li v-for="project in projects" :key="project.repo">
<a class="card project-card" :href="project.url" target="_blank" rel="noopener">
<span class="project-icon"><CvIcon name="code" /></span>
<span class="project-body">
<span class="project-title">
{{ project.title }}
<span class="mono-num project-repo">{{ project.repo }}</span>
</span>
<span class="project-desc">{{ project.desc }}</span>
</span>
</a>
</li>
</ul>
<h1 class="section-heading"><CvIcon name="summary" />Summary</h1>
<section class="card info-card">
<p class="summary-text">
Passionate Software Developer professional with over 8 years of experience in backend
development and software architecture. Expert in C++, strong TypeScript skills, seasoned
Python experience and aspiring Go professional. My key achievements include designing and
implementing new backend services resulting in reduced development time, and optimizing
systems to handle over 600k concurrent users.
</p>
</section>
<h1 class="section-heading"><CvIcon name="experience" />Experience</h1>
<ul class="cards timeline timeline-current">
<li v-for="job in experience" :key="job.company + job.dates">
<article class="card job-card">
<div class="job-card-head">
<h3>{{ job.company }}</h3>
<span class="mono-num job-dates">{{ job.dates }}</span>
</div>
<p class="job-title">{{ job.title }} · {{ job.location }}</p>
<p class="job-subtitle">{{ job.subtitle }}</p>
<ul class="job-bullets">
<li v-for="(bullet, idx) in job.bullets" :key="idx">{{ bullet }}</li>
</ul>
</article>
</li>
</ul>
<h1 class="section-heading"><CvIcon name="education" />Education</h1>
<ul class="cards timeline">
<li v-for="edu in education" :key="edu.school">
<article class="card job-card">
<div class="job-card-head">
<h3>{{ edu.school }}</h3>
<span class="mono-num job-dates">{{ edu.dates }}</span>
</div>
<p class="job-subtitle">{{ edu.degree }}</p>
</article>
</li>
</ul>
<h1 class="section-heading"><CvIcon name="achievements" />Key Achievements</h1>
<section class="card info-card">
<div v-for="item in achievements" :key="item.title" class="achievement-row">
<span class="achv-icon"><CvIcon name="verified" /></span>
<div>
<h4>{{ item.title }}</h4>
<p>{{ item.desc }}</p>
</div>
</div>
</section>
<h1 class="section-heading"><CvIcon name="languages" />Languages</h1>
<section class="card info-card">
<div v-for="lang in languages" :key="lang.name" class="rating-row">
<p class="row">
<span>{{ lang.name }}</span>
<span class="tag">{{ lang.level }}</span>
</p>
<div class="tally tally-lg">
<span
v-for="n in LANGUAGE_MAX"
:key="n"
class="tally-tick"
:class="{ filled: n <= lang.rating }"
></span>
</div>
</div>
</section>
<h1 class="section-heading"><CvIcon name="skills" />Industry Experience</h1>
<section class="card info-card">
<div class="skill-grid">
<div v-for="skill in skills" :key="skill.name" class="skill-row">
<span class="skill-name">{{ skill.name }}</span>
<div class="tally">
<span
v-for="n in SKILL_MAX"
:key="n"
class="tally-tick"
:class="{ filled: n <= skill.rating }"
></span>
</div>
</div>
</div>
</section>
<h1 class="section-heading"><CvIcon name="additional" />Additional Experience</h1>
<ul class="cards">
<li v-for="item in additionalExperiences" :key="item.title">
<article class="card job-card">
<h3>{{ item.title }}</h3>
<p class="job-subtitle">{{ item.desc }}</p>
</article>
</li>
</ul>
<h1 class="section-heading"><CvIcon name="interests" />Interests</h1>
<section class="card info-card">
<div class="interests">
<div v-for="interest in interests" :key="interest.name" class="interest-chip">
<span class="interest-icon"><CvIcon :name="interest.icon" /></span>
<span>{{ interest.name }}</span>
</div>
</div>
</section>
</main>
</template>
<style scoped>
.cv-page {
padding-bottom: 2.5rem;
}
@media (min-width: 768px) {
.cv-page {
max-width: 780px;
}
}
.cv-page > h1:first-child {
font-size: 1.35rem;
}
.profile-top {
display: flex;
align-items: center;
gap: 1.1rem;
margin-bottom: 1rem;
}
.profile-top h1 {
font-size: 1.4rem;
margin-bottom: 0.45rem;
letter-spacing: 0.01em;
}
.avatar {
display: block;
width: 76px;
height: 76px;
border-radius: 50%;
object-fit: cover;
border: 1px solid var(--c-border);
box-shadow: var(--shadow-sm);
flex-shrink: 0;
}
.role-badge {
display: inline-flex;
padding: 0.22rem 0.65rem;
border-radius: 3px;
background: var(--c-accent-bg);
color: var(--c-accent-strong);
font-size: 0.78rem;
font-weight: 500;
}
.ledger-rule {
height: 4px;
margin-bottom: 1.5rem;
border-top: 1.5px solid var(--c-border-hover);
border-bottom: 1px dashed var(--c-border);
}
.section-heading {
display: flex;
align-items: center;
gap: 0.55rem;
font-size: 1.08rem;
font-weight: 600;
margin: 2rem 0 0.85rem;
}
.section-heading::after {
content: '';
flex: 1;
height: 1px;
background: var(--c-border);
margin-left: 0.35rem;
}
.section-heading .cv-icon {
font-size: 0.85rem;
color: var(--c-accent-strong);
}
.card {
border-radius: 4px;
box-shadow: none;
}
.info-card {
padding: 1rem 1.1rem;
}
.info-card h4 {
font-size: 0.85rem;
margin-bottom: 0.75rem;
}
.contact-card {
border-color: var(--c-accent-soft);
box-shadow: 0 10px 28px rgba(61, 114, 180, 0.22);
}
.row {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.75rem;
font-size: 0.85rem;
margin-bottom: 0.4rem;
color: var(--c-text);
}
.row:last-child {
margin-bottom: 0;
}
.info-card > .row {
padding-bottom: 0.5rem;
margin-bottom: 0.5rem;
border-bottom: 1px dashed var(--c-border);
}
.info-card > .row:last-child {
padding-bottom: 0;
margin-bottom: 0;
border-bottom: none;
}
.row strong {
color: var(--c-heading);
}
.row-label {
display: inline-flex;
align-items: center;
gap: 0.4rem;
}
.row-label .cv-icon {
color: var(--c-text-soft);
}
.row a {
color: var(--c-accent-strong);
}
.summary-text {
font-size: 0.88rem;
color: var(--c-text);
}
.achievement-row {
display: flex;
gap: 0.7rem;
align-items: flex-start;
}
.achievement-row + .achievement-row {
margin-top: 0.85rem;
padding-top: 0.85rem;
border-top: 1px dashed var(--c-border);
}
.achv-icon {
display: flex;
align-items: center;
justify-content: center;
width: 1.6rem;
height: 1.6rem;
flex-shrink: 0;
margin-top: 0.05rem;
border-radius: 50%;
background: var(--c-accent-bg);
color: var(--c-accent-strong);
font-size: 0.9rem;
}
.achievement-row h4 {
font-size: 0.85rem;
color: var(--c-heading);
margin-bottom: 0.15rem;
}
.achievement-row p {
font-size: 0.82rem;
color: var(--c-text-soft);
}
.rating-row + .rating-row {
margin-top: 0.85rem;
}
/* rating tallies */
.tally {
display: flex;
gap: 3px;
}
.tally-tick {
flex: 1;
height: 6px;
border-radius: 1px;
background: var(--c-bg-mute);
}
.tally-tick.filled {
background: var(--c-accent);
}
.tally-lg {
max-width: 82%;
}
.tally-lg .tally-tick {
height: 7px;
}
/* cards list (experience / education / additional experience) */
.cards {
display: flex;
flex-direction: column;
gap: 0.6rem;
list-style: none;
padding: 0;
margin: 0;
}
/* timeline (experience / education): a real chronological record, so the
rail + nodes carry meaning rather than decorate */
.timeline {
position: relative;
gap: 0.9rem;
padding-left: 1.1rem;
}
.timeline::before {
content: '';
position: absolute;
left: 3px;
top: 0.6rem;
bottom: 0.6rem;
width: 1px;
background: var(--c-border-hover);
}
.timeline > li {
position: relative;
}
.timeline > li::before {
content: '';
position: absolute;
left: -1.1rem;
top: 1.1rem;
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--c-bg);
border: 1.5px solid var(--c-text-soft);
}
.timeline-current > li:first-child::before {
border-color: var(--c-accent-strong);
background: var(--c-accent-strong);
box-shadow: 0 0 0 3px var(--c-accent-bg);
}
.job-card {
padding: 0.9rem 1.1rem;
}
.job-card-head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 0.75rem;
flex-wrap: wrap;
}
.job-card-head h3 {
font-size: 1.02rem;
}
.job-dates {
font-size: 0.75rem;
color: var(--c-text-soft);
white-space: nowrap;
}
.job-title {
font-size: 0.85rem;
color: var(--c-text);
margin-top: 0.15rem;
}
.job-subtitle {
font-size: 0.82rem;
font-style: italic;
color: var(--c-text-soft);
margin-top: 0.3rem;
}
.job-bullets {
list-style: none;
display: flex;
flex-direction: column;
gap: 0.3rem;
margin-top: 0.6rem;
font-size: 0.85rem;
color: var(--c-text);
}
.job-bullets li {
position: relative;
padding-left: 0.9rem;
}
.job-bullets li::before {
content: '';
position: absolute;
left: 0;
top: 0.55em;
width: 5px;
height: 5px;
border-radius: 50%;
background: var(--c-accent);
}
/* project links */
.project-card {
display: flex;
align-items: center;
gap: 0.9rem;
padding: 0.9rem 1.1rem;
border: 1px solid var(--c-accent-soft);
transition:
transform 0.15s ease-in-out,
border-color 0.15s ease-in-out,
box-shadow 0.15s ease-in-out;
}
.project-card:hover,
.project-card:focus-visible {
border-color: var(--c-accent-strong);
box-shadow: var(--shadow-md);
transform: translateY(-1px);
}
.project-icon {
display: flex;
align-items: center;
justify-content: center;
width: 2.2rem;
height: 2.2rem;
flex-shrink: 0;
border-radius: var(--radius-md);
background: var(--c-accent-bg);
color: var(--c-accent-strong);
font-size: 1.1rem;
}
.project-body {
display: flex;
flex-direction: column;
gap: 0.2rem;
min-width: 0;
}
.project-title {
display: flex;
align-items: baseline;
gap: 0.5rem;
font-weight: 600;
color: var(--c-heading);
font-size: 0.95rem;
}
.project-repo {
font-size: 0.75rem;
color: var(--c-accent-strong);
}
.project-desc {
font-size: 0.82rem;
color: var(--c-text-soft);
}
/* skills */
.skill-grid {
display: grid;
grid-template-columns: 1fr;
gap: 0.7rem;
}
@media (min-width: 560px) {
.skill-grid {
grid-template-columns: 1fr 1fr;
gap: 0.7rem 1.5rem;
}
}
.skill-row {
display: flex;
flex-direction: column;
gap: 0.3rem;
}
.skill-name {
font-size: 0.82rem;
color: var(--c-text);
}
/* interests */
.interests {
display: flex;
flex-wrap: wrap;
gap: 0.55rem;
}
.interest-chip {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.3rem 0.8rem 0.3rem 0.35rem;
border-radius: 999px;
border: 1px solid var(--c-border);
background: var(--c-bg-soft);
font-size: 0.8rem;
color: var(--c-text);
transition:
border-color 0.15s ease-in-out,
transform 0.15s ease-in-out;
}
.interest-chip:hover {
border-color: var(--c-accent-soft);
transform: translateY(-1px);
}
.interest-icon {
display: flex;
align-items: center;
justify-content: center;
width: 1.5rem;
height: 1.5rem;
flex-shrink: 0;
border-radius: 50%;
background: var(--c-accent-bg);
color: var(--c-accent-strong);
font-size: 0.75rem;
}
/* tags */
.tag {
display: inline-flex;
font-size: 0.72rem;
font-weight: 500;
padding: 0.2rem 0.6rem;
border-radius: 999px;
background-color: var(--c-bg-mute);
color: var(--c-text-soft);
}
</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>
+55 -5
View File
@@ -1,10 +1,11 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted } from 'vue' import { ref, watch, onMounted } from 'vue'
import { useListsStore } from '@/stores/lists' import { useListsStore } from '@/stores/lists'
import type { LocalList } from '@/database/db' import type { LocalList } from '@/database/db'
import ListCard from '@/components/ListCard.vue' import ListCard from '@/components/ListCard.vue'
import ShareListModal from '@/components/ShareListModal.vue' import ShareListModal from '@/components/ShareListModal.vue'
import DeleteListModal from '@/components/DeleteListModal.vue' import DeleteListModal from '@/components/DeleteListModal.vue'
import { useListDragReorder } from '@/composables/useListDragReorder'
const listsStore = useListsStore() const listsStore = useListsStore()
@@ -14,6 +15,26 @@ const createError = ref('')
const sharingList = ref<LocalList | null>(null) const sharingList = ref<LocalList | null>(null)
const deletingList = 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(() => { onMounted(() => {
listsStore.loadLists() listsStore.loadLists()
}) })
@@ -86,11 +107,24 @@ async function handleCreateList() {
<p v-if="createError" class="banner banner-error">{{ createError }}</p> <p v-if="createError" class="banner banner-error">{{ createError }}</p>
<ul v-if="listsStore.sortedLists.length > 0" class="lists"> <TransitionGroup v-if="displayedLists.length > 0" tag="ul" name="list-reorder" class="lists">
<li v-for="list in listsStore.sortedLists" :key="list.id"> <li
<ListCard :list="list" @share="handleOpenShare(list)" @delete="handleOpenDelete(list)" /> 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> </li>
</ul> </TransitionGroup>
<div v-else class="empty-state"> <div v-else class="empty-state">
<p>No lists yet</p> <p>No lists yet</p>
@@ -145,6 +179,22 @@ h1 {
margin: 0; 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 { .empty-state {
text-align: center; text-align: center;
padding: 3rem 1rem; padding: 3rem 1rem;
+19 -8
View File
@@ -46,7 +46,8 @@ async function handleSubmit() {
<div class="login-container"> <div class="login-container">
<div class="login-card card"> <div class="login-card card">
<div class="brand-mark"> <div class="brand-mark">
<span class="brand-dot"></span> <span class="brand-seal"></span>
<span class="brand-word">dttmr</span>
</div> </div>
<h2>Login</h2> <h2>Login</h2>
<p class="subtitle">Enter your credentials to access your account</p> <p class="subtitle">Enter your credentials to access your account</p>
@@ -119,16 +120,26 @@ async function handleSubmit() {
.brand-mark { .brand-mark {
display: flex; display: flex;
align-items: center;
justify-content: center; justify-content: center;
margin-bottom: 1rem; gap: 0.55rem;
margin-bottom: 1.5rem;
} }
.brand-dot { .brand-seal {
width: 40px; width: 30px;
height: 40px; height: 30px;
border-radius: 12px; border-radius: 6px;
background: linear-gradient(135deg, var(--c-accent-strong), var(--c-accent-soft)); background: var(--c-accent);
box-shadow: 0 0 24px var(--c-accent-bg); 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 { 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) 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 listsStore = useListsStore()
const deleteItemSpy = vi.spyOn(listsStore, 'deleteListItem').mockResolvedValue() const deleteItemSpy = vi.spyOn(listsStore, 'deleteListItem').mockResolvedValue()
const sampleList: LocalList = { const sampleList: LocalList = {
@@ -156,9 +156,16 @@ describe('ListDetailView', () => {
const deleteItemBtn = itemRow.find('.submenu-item-danger') const deleteItemBtn = itemRow.find('.submenu-item-danger')
expect(deleteItemBtn.exists()).toBe(true) expect(deleteItemBtn.exists()).toBe(true)
// 2nd click: delete item // 2nd click: opens confirmation modal
await deleteItemBtn.trigger('click') 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') 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', description: 'dittmar.dev frontend app',
start_url: '/', start_url: '/',
scope: '/', scope: '/',
theme_color: '#0a0e1a', theme_color: '#12161d',
background_color: '#0a0e1a', background_color: '#12161d',
display: 'standalone', display: 'standalone',
icons: [ icons: [
{ {