Compare commits
50
Commits
2cc38bbc17
..
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b90fc232b7 | ||
|
|
7f66903d7d | ||
|
|
50aa249cf8 | ||
|
|
c212173692 | ||
|
|
a67189a493 | ||
|
|
7fcf761f4f | ||
|
|
c2e656443f | ||
|
|
81c58d5ca0 | ||
|
|
227dc06b58 | ||
|
|
59cf4f1e2d | ||
|
|
39b96c409e | ||
|
|
1f4b4a578e | ||
|
|
edfaf98e18 | ||
|
|
ff1e078be9 | ||
|
|
7974cfafc7 | ||
|
|
cc010e79ca | ||
|
|
23029c8793 | ||
|
|
deb0bac072 | ||
|
|
17585dc2b6 | ||
|
|
7185730a87 | ||
|
|
47034e111c | ||
|
|
73663543fd | ||
|
|
c0dc4a2fe0 | ||
|
|
20a000319d | ||
|
|
8df92a470e | ||
|
|
16405ec5f7 | ||
|
|
6a1624c616 | ||
|
|
f35009822f | ||
|
|
6196556012 | ||
|
|
3998679810 | ||
|
|
14563e5204 | ||
|
|
a30101f841 | ||
|
|
9abd66fdba | ||
|
|
936dbe4e3c | ||
|
|
1bacc58648 | ||
|
|
125a7bd6c5 | ||
|
|
0b6a9231f9 | ||
|
|
4edfb64723 | ||
|
|
474f11a8ca | ||
|
|
182704b9cd | ||
|
|
564c7c6338 | ||
|
|
5d0a2cfded | ||
|
|
327cfc1c5d | ||
|
|
2c45478478 | ||
|
|
68991a3722 | ||
|
|
73ef2b625d | ||
|
|
6b416eeb02 | ||
|
|
69fe3651df | ||
|
|
10bf01046c | ||
|
|
946399c16c |
@@ -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
|
||||||
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
User-agent: *
|
||||||
|
Disallow: /cv
|
||||||
@@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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' }),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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
@@ -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
@@ -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()
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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));
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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 "${props.item.title}"?`"
|
||||||
|
description="Are you sure you want to delete this item? This action cannot be undone."
|
||||||
|
confirm-label="Delete item"
|
||||||
|
@close="handleClose"
|
||||||
|
@confirm="handleConfirm"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
@@ -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 "${list.name}"?`"
|
:title="`Delete "${props.list.name}"?`"
|
||||||
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>
|
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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))
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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 } }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
@@ -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[]
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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
@@ -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
@@ -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 {
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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
@@ -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: [
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user