feat: initial sketch with AI
This commit is contained in:
+5
-3
@@ -1,10 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="">
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<link rel="icon" href="/favicon.ico">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Vite App</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
||||
<meta name="theme-color" content="#0a0e1a">
|
||||
<meta name="color-scheme" content="dark">
|
||||
<title>dttmr</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
Generated
+10488
File diff suppressed because it is too large
Load Diff
+12
-76
@@ -1,85 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
import { RouterLink, RouterView } from 'vue-router'
|
||||
import HelloWorld from './components/HelloWorld.vue'
|
||||
import { RouterView } from 'vue-router'
|
||||
import AppHeader from '@/components/AppHeader.vue'
|
||||
import BottomNav from '@/components/BottomNav.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header>
|
||||
<img alt="Vue logo" class="logo" src="@/assets/logo.svg" width="125" height="125" />
|
||||
|
||||
<div class="wrapper">
|
||||
<HelloWorld msg="You did it!" />
|
||||
|
||||
<nav>
|
||||
<RouterLink to="/">Home</RouterLink>
|
||||
<RouterLink to="/about">About</RouterLink>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<RouterView />
|
||||
<div class="app-shell">
|
||||
<AppHeader />
|
||||
<RouterView />
|
||||
<BottomNav />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
header {
|
||||
line-height: 1.5;
|
||||
max-height: 100vh;
|
||||
}
|
||||
|
||||
.logo {
|
||||
display: block;
|
||||
margin: 0 auto 2rem;
|
||||
}
|
||||
|
||||
nav {
|
||||
width: 100%;
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
nav a.router-link-exact-active {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
nav a.router-link-exact-active:hover {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
nav a {
|
||||
display: inline-block;
|
||||
padding: 0 1rem;
|
||||
border-left: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
nav a:first-of-type {
|
||||
border: 0;
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
header {
|
||||
display: flex;
|
||||
place-items: center;
|
||||
padding-right: calc(var(--section-gap) / 2);
|
||||
}
|
||||
|
||||
.logo {
|
||||
margin: 0 2rem 0 0;
|
||||
}
|
||||
|
||||
header .wrapper {
|
||||
display: flex;
|
||||
place-items: flex-start;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
nav {
|
||||
text-align: left;
|
||||
margin-left: -1rem;
|
||||
font-size: 1rem;
|
||||
|
||||
padding: 1rem 0;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
.app-shell {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'
|
||||
import { loginApi, refreshApi, logoutApi, logoutAllApi, API_BASE_URL } from '../auth'
|
||||
|
||||
describe('auth API', () => {
|
||||
const originalFetch = global.fetch
|
||||
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
global.fetch = originalFetch
|
||||
})
|
||||
|
||||
describe('loginApi', () => {
|
||||
it('sends POST request to /login and returns token pair on success', async () => {
|
||||
const mockResponse = {
|
||||
access_token: 'access-123',
|
||||
refresh_token: 'refresh-456',
|
||||
}
|
||||
|
||||
global.fetch = vi.fn<typeof fetch>().mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => mockResponse,
|
||||
} as unknown as Response)
|
||||
|
||||
const payload = { email: 'user@example.com', password: 'secretpassword' }
|
||||
const result = await loginApi(payload)
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledWith(`${API_BASE_URL}/login`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
expect(result).toEqual(mockResponse)
|
||||
})
|
||||
|
||||
it('throws error with message on failure', async () => {
|
||||
global.fetch = vi.fn<typeof fetch>().mockResolvedValueOnce({
|
||||
ok: false,
|
||||
json: async () => ({ message: 'Invalid email or password' }),
|
||||
} as unknown as Response)
|
||||
|
||||
await expect(
|
||||
loginApi({ email: 'user@example.com', password: 'wrong' }),
|
||||
).rejects.toThrow('Invalid email or password')
|
||||
})
|
||||
})
|
||||
|
||||
describe('refreshApi', () => {
|
||||
it('sends POST request to /login/refresh and returns token pair on success', async () => {
|
||||
const mockResponse = {
|
||||
access_token: 'new-access-123',
|
||||
refresh_token: 'new-refresh-456',
|
||||
}
|
||||
|
||||
global.fetch = vi.fn<typeof fetch>().mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => mockResponse,
|
||||
} as unknown as Response)
|
||||
|
||||
const payload = { refresh_token: 'refresh-456' }
|
||||
const result = await refreshApi(payload)
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledWith(`${API_BASE_URL}/login/refresh`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
expect(result).toEqual(mockResponse)
|
||||
})
|
||||
|
||||
it('throws error on failure', async () => {
|
||||
global.fetch = vi.fn<typeof fetch>().mockResolvedValueOnce({
|
||||
ok: false,
|
||||
statusText: 'Unauthorized',
|
||||
json: async () => {
|
||||
throw new Error('Not JSON')
|
||||
},
|
||||
} as unknown as Response)
|
||||
|
||||
await expect(
|
||||
refreshApi({ refresh_token: 'invalid-token' }),
|
||||
).rejects.toThrow('Unauthorized')
|
||||
})
|
||||
})
|
||||
|
||||
describe('logoutApi', () => {
|
||||
it('sends POST request to /logout on success', async () => {
|
||||
global.fetch = vi.fn<typeof fetch>().mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => null,
|
||||
} as unknown as Response)
|
||||
|
||||
const payload = { refresh_token: 'refresh-456' }
|
||||
await logoutApi(payload)
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledWith(`${API_BASE_URL}/logout`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
})
|
||||
|
||||
it('throws error on failure', async () => {
|
||||
global.fetch = vi.fn<typeof fetch>().mockResolvedValueOnce({
|
||||
ok: false,
|
||||
statusText: 'Bad Request',
|
||||
json: async () => ({ error: 'failed to logout' }),
|
||||
} as unknown as Response)
|
||||
|
||||
await expect(
|
||||
logoutApi({ refresh_token: 'invalid-token' }),
|
||||
).rejects.toThrow('failed to logout')
|
||||
})
|
||||
})
|
||||
|
||||
describe('logoutAllApi', () => {
|
||||
it('sends POST request to /logout/all with token header on success', async () => {
|
||||
global.fetch = vi.fn<typeof fetch>().mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => null,
|
||||
} as unknown as Response)
|
||||
|
||||
await logoutAllApi('access-123')
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledWith(`${API_BASE_URL}/logout/all`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': 'Bearer access-123',
|
||||
},
|
||||
body: JSON.stringify({}),
|
||||
})
|
||||
})
|
||||
|
||||
it('throws error on failure', async () => {
|
||||
global.fetch = vi.fn<typeof fetch>().mockResolvedValueOnce({
|
||||
ok: false,
|
||||
statusText: 'Unauthorized',
|
||||
json: async () => ({ message: 'Unauthorized' }),
|
||||
} as unknown as Response)
|
||||
|
||||
await expect(logoutAllApi('bad-token')).rejects.toThrow('Unauthorized')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,152 @@
|
||||
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'
|
||||
import { setActivePinia, createPinia } from 'pinia'
|
||||
import { fetchWithAuth, apiClient } from '../client'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import * as authApi from '@/api/auth'
|
||||
|
||||
describe('api client (fetchWithAuth)', () => {
|
||||
const originalFetch = global.fetch
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
localStorage.clear()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
global.fetch = originalFetch
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
it('adds Authorization header if accessToken is available in store', async () => {
|
||||
const authStore = useAuthStore()
|
||||
authStore.setTokens({
|
||||
access_token: 'test-token',
|
||||
refresh_token: 'refresh-token',
|
||||
})
|
||||
|
||||
const fetchMock = vi.fn<typeof fetch>().mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ success: true }),
|
||||
} as unknown as Response)
|
||||
global.fetch = fetchMock
|
||||
|
||||
await fetchWithAuth('/lists')
|
||||
|
||||
expect(fetchMock).toHaveBeenCalled()
|
||||
const callArgs = fetchMock.mock.calls[0]
|
||||
expect(callArgs).toBeDefined()
|
||||
const headers = callArgs?.[1]?.headers as Headers
|
||||
expect(headers.get('Authorization')).toBe('Bearer test-token')
|
||||
})
|
||||
|
||||
it('omits Authorization header when skipAuth is true', async () => {
|
||||
const authStore = useAuthStore()
|
||||
authStore.setTokens({
|
||||
access_token: 'test-token',
|
||||
refresh_token: 'refresh-token',
|
||||
})
|
||||
|
||||
const fetchMock = vi.fn<typeof fetch>().mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
} as unknown as Response)
|
||||
global.fetch = fetchMock
|
||||
|
||||
await fetchWithAuth('/health', { skipAuth: true })
|
||||
|
||||
const callArgs = fetchMock.mock.calls[0]
|
||||
expect(callArgs).toBeDefined()
|
||||
const headers = callArgs?.[1]?.headers as Headers
|
||||
expect(headers.has('Authorization')).toBe(false)
|
||||
})
|
||||
|
||||
it('refreshes token and retries request on 401 response', async () => {
|
||||
const authStore = useAuthStore()
|
||||
authStore.setTokens({
|
||||
access_token: 'expired-token',
|
||||
refresh_token: 'valid-refresh-token',
|
||||
})
|
||||
|
||||
const refreshSpy = vi.spyOn(authApi, 'refreshApi').mockResolvedValueOnce({
|
||||
access_token: 'new-access-token',
|
||||
refresh_token: 'new-refresh-token',
|
||||
})
|
||||
|
||||
const firstResponse = {
|
||||
ok: false,
|
||||
status: 401,
|
||||
statusText: 'Unauthorized',
|
||||
}
|
||||
const secondResponse = {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ data: 'protected data' }),
|
||||
}
|
||||
|
||||
const fetchMock = vi
|
||||
.fn<typeof fetch>()
|
||||
.mockResolvedValueOnce(firstResponse as unknown as Response)
|
||||
.mockResolvedValueOnce(secondResponse as unknown as Response)
|
||||
global.fetch = fetchMock
|
||||
|
||||
const res = await fetchWithAuth('/lists')
|
||||
|
||||
expect(refreshSpy).toHaveBeenCalledWith({ refresh_token: 'valid-refresh-token' })
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2)
|
||||
expect(authStore.accessToken).toBe('new-access-token')
|
||||
|
||||
// Second call should have new access token
|
||||
const secondCall = fetchMock.mock.calls[1]
|
||||
expect(secondCall).toBeDefined()
|
||||
const secondCallHeaders = secondCall?.[1]?.headers as Headers
|
||||
expect(secondCallHeaders.get('Authorization')).toBe('Bearer new-access-token')
|
||||
expect(res).toBe(secondResponse)
|
||||
})
|
||||
|
||||
it('calls apiClient helper methods correctly', async () => {
|
||||
const authStore = useAuthStore()
|
||||
authStore.setTokens({
|
||||
access_token: 'my-token',
|
||||
refresh_token: 'my-refresh',
|
||||
})
|
||||
|
||||
const mockResponse = {
|
||||
ok: true,
|
||||
status: 200,
|
||||
}
|
||||
const fetchMock = vi.fn<typeof fetch>().mockResolvedValue(mockResponse as unknown as Response)
|
||||
global.fetch = fetchMock
|
||||
|
||||
await apiClient.get('/lists')
|
||||
expect(fetchMock).toHaveBeenLastCalledWith(
|
||||
expect.stringContaining('/lists'),
|
||||
expect.objectContaining({ method: 'GET' }),
|
||||
)
|
||||
|
||||
await apiClient.post('/lists', { name: 'My List' })
|
||||
expect(fetchMock).toHaveBeenLastCalledWith(
|
||||
expect.stringContaining('/lists'),
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ name: 'My List' }),
|
||||
}),
|
||||
)
|
||||
|
||||
await apiClient.put('/lists/item', { title: 'Updated' })
|
||||
expect(fetchMock).toHaveBeenLastCalledWith(
|
||||
expect.stringContaining('/lists/item'),
|
||||
expect.objectContaining({
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ title: 'Updated' }),
|
||||
}),
|
||||
)
|
||||
|
||||
await apiClient.delete('/lists/user')
|
||||
expect(fetchMock).toHaveBeenLastCalledWith(
|
||||
expect.stringContaining('/lists/user'),
|
||||
expect.objectContaining({ method: 'DELETE' }),
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,182 @@
|
||||
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'
|
||||
import { setActivePinia, createPinia } from 'pinia'
|
||||
import {
|
||||
getListsApi,
|
||||
createListApi,
|
||||
getListItemsApi,
|
||||
createListItemApi,
|
||||
updateListItemApi,
|
||||
setListItemCompletedApi,
|
||||
addUserToListApi,
|
||||
removeUserFromListApi,
|
||||
} from '../lists'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { API_BASE_URL } from '@/api/auth'
|
||||
|
||||
describe('lists 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('getListsApi sends GET to /lists and returns the lists', async () => {
|
||||
const mockLists = [{ id: 'list-1', name: 'Groceries' }]
|
||||
const fetchMock = vi.fn<typeof fetch>().mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => mockLists,
|
||||
} as unknown as Response)
|
||||
global.fetch = fetchMock
|
||||
|
||||
const result = await getListsApi()
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
`${API_BASE_URL}/lists`,
|
||||
expect.objectContaining({ method: 'GET' }),
|
||||
)
|
||||
expect(result).toEqual(mockLists)
|
||||
})
|
||||
|
||||
it('getListsApi throws on failure', async () => {
|
||||
global.fetch = vi.fn<typeof fetch>().mockResolvedValueOnce({
|
||||
ok: false,
|
||||
json: async () => ({ message: 'Failed' }),
|
||||
} as unknown as Response)
|
||||
|
||||
await expect(getListsApi()).rejects.toThrow('Failed')
|
||||
})
|
||||
|
||||
it('getListItemsApi sends GET to /lists/{id} and returns the items', async () => {
|
||||
const mockItems = [{ id: 'item-1', list_id: 'list-1', title: 'Milk', is_completed: false }]
|
||||
const fetchMock = vi.fn<typeof fetch>().mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => mockItems,
|
||||
} as unknown as Response)
|
||||
global.fetch = fetchMock
|
||||
|
||||
const result = await getListItemsApi('list-1')
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
`${API_BASE_URL}/lists/list-1`,
|
||||
expect.objectContaining({ method: 'GET' }),
|
||||
)
|
||||
expect(result).toEqual(mockItems)
|
||||
})
|
||||
|
||||
it('setListItemCompletedApi sends POST to /lists/items/{id}', async () => {
|
||||
const fetchMock = vi.fn<typeof fetch>().mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 204,
|
||||
} as unknown as Response)
|
||||
global.fetch = fetchMock
|
||||
|
||||
await setListItemCompletedApi('item-1', { is_completed: true })
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
`${API_BASE_URL}/lists/items/item-1`,
|
||||
expect.objectContaining({ method: 'POST' }),
|
||||
)
|
||||
})
|
||||
|
||||
it('createListApi sends POST to /lists and returns the created list', async () => {
|
||||
const mockList = { id: 'list-1', name: 'Groceries' }
|
||||
const fetchMock = vi.fn<typeof fetch>().mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 201,
|
||||
json: async () => mockList,
|
||||
} as unknown as Response)
|
||||
global.fetch = fetchMock
|
||||
|
||||
const result = await createListApi({ name: 'Groceries', user_ids: [] })
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
`${API_BASE_URL}/lists`,
|
||||
expect.objectContaining({ method: 'POST' }),
|
||||
)
|
||||
expect(result).toEqual(mockList)
|
||||
})
|
||||
|
||||
it('createListApi throws on failure', async () => {
|
||||
global.fetch = vi.fn<typeof fetch>().mockResolvedValueOnce({
|
||||
ok: false,
|
||||
json: async () => ({ message: 'Failed' }),
|
||||
} as unknown as Response)
|
||||
|
||||
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 () => {
|
||||
const mockItem = { id: 'item-1', list_id: '', title: 'Milk', is_completed: false }
|
||||
const fetchMock = vi.fn<typeof fetch>().mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 201,
|
||||
json: async () => mockItem,
|
||||
} as unknown as Response)
|
||||
global.fetch = fetchMock
|
||||
|
||||
const result = await createListItemApi({ list_id: 'list-1', title: 'Milk' })
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
`${API_BASE_URL}/lists/item`,
|
||||
expect.objectContaining({ method: 'POST' }),
|
||||
)
|
||||
expect(result).toEqual(mockItem)
|
||||
})
|
||||
|
||||
it('updateListItemApi sends PUT to /lists/item', async () => {
|
||||
const fetchMock = vi.fn<typeof fetch>().mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 204,
|
||||
} as unknown as Response)
|
||||
global.fetch = fetchMock
|
||||
|
||||
await updateListItemApi({ list_item_id: 'item-1', is_completed: true })
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
`${API_BASE_URL}/lists/item`,
|
||||
expect.objectContaining({ method: 'PUT' }),
|
||||
)
|
||||
})
|
||||
|
||||
it('addUserToListApi sends POST to /lists/user', async () => {
|
||||
const fetchMock = vi.fn<typeof fetch>().mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 204,
|
||||
} as unknown as Response)
|
||||
global.fetch = fetchMock
|
||||
|
||||
await addUserToListApi({ list_id: 'list-1', user_id: 'user-1' })
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
`${API_BASE_URL}/lists/user`,
|
||||
expect.objectContaining({ method: 'POST' }),
|
||||
)
|
||||
})
|
||||
|
||||
it('removeUserFromListApi sends DELETE to /lists/user', async () => {
|
||||
const fetchMock = vi.fn<typeof fetch>().mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 204,
|
||||
} as unknown as Response)
|
||||
global.fetch = fetchMock
|
||||
|
||||
await removeUserFromListApi({ list_id: 'list-1', user_id: 'user-1' })
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
`${API_BASE_URL}/lists/user`,
|
||||
expect.objectContaining({ method: 'DELETE' }),
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { LoginPayload, RefreshPayload, TokenPair } from '@/types/auth'
|
||||
|
||||
export const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? '/api/v1'
|
||||
|
||||
async function extractErrorMessage(response: Response, fallback: string): Promise<string> {
|
||||
try {
|
||||
const errorData = await response.json()
|
||||
return errorData.message || errorData.error || fallback
|
||||
} catch {
|
||||
return response.statusText || fallback
|
||||
}
|
||||
}
|
||||
|
||||
async function postJson(
|
||||
path: string,
|
||||
body: unknown,
|
||||
errorFallback: string,
|
||||
token?: string,
|
||||
): Promise<Response> {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_BASE_URL}${path}`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(await extractErrorMessage(response, errorFallback))
|
||||
}
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
export async function loginApi(payload: LoginPayload): Promise<TokenPair> {
|
||||
const response = await postJson('/login', payload, 'Login failed')
|
||||
return response.json()
|
||||
}
|
||||
|
||||
export async function refreshApi(payload: RefreshPayload): Promise<TokenPair> {
|
||||
const response = await postJson('/login/refresh', payload, 'Token refresh failed')
|
||||
return response.json()
|
||||
}
|
||||
|
||||
export async function logoutApi(payload?: RefreshPayload): Promise<void> {
|
||||
await postJson('/logout', payload ?? {}, 'Logout failed')
|
||||
}
|
||||
|
||||
export async function logoutAllApi(token?: string): Promise<void> {
|
||||
await postJson('/logout/all', {}, 'Logout all failed', token)
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { API_BASE_URL } from '@/api/auth'
|
||||
|
||||
let refreshPromise: Promise<unknown> | null = null
|
||||
|
||||
export interface FetchOptions extends RequestInit {
|
||||
skipAuth?: boolean
|
||||
skipRefresh?: boolean
|
||||
}
|
||||
|
||||
function buildHeaders(options: RequestInit, accessToken: string | null): Headers {
|
||||
const headers = new Headers(options.headers || {})
|
||||
|
||||
if (accessToken) {
|
||||
headers.set('Authorization', `Bearer ${accessToken}`)
|
||||
}
|
||||
|
||||
if (!headers.has('Content-Type') && !(options.body instanceof FormData)) {
|
||||
headers.set('Content-Type', 'application/json')
|
||||
}
|
||||
|
||||
return headers
|
||||
}
|
||||
|
||||
function buildUrl(endpoint: string): string {
|
||||
if (endpoint.startsWith('http://') || endpoint.startsWith('https://')) {
|
||||
return endpoint
|
||||
}
|
||||
return `${API_BASE_URL}${endpoint.startsWith('/') ? '' : '/'}${endpoint}`
|
||||
}
|
||||
|
||||
export async function fetchWithAuth(
|
||||
endpoint: string,
|
||||
options: FetchOptions = {},
|
||||
): Promise<Response> {
|
||||
const authStore = useAuthStore()
|
||||
const { skipAuth = false, skipRefresh = false, ...customOptions } = options
|
||||
|
||||
const url = buildUrl(endpoint)
|
||||
|
||||
let response = await fetch(url, {
|
||||
...customOptions,
|
||||
headers: buildHeaders(customOptions, skipAuth ? null : authStore.accessToken),
|
||||
})
|
||||
|
||||
if (response.status === 401 && !skipRefresh && authStore.refreshToken) {
|
||||
try {
|
||||
if (!refreshPromise) {
|
||||
refreshPromise = authStore.refreshTokens().finally(() => {
|
||||
refreshPromise = null
|
||||
})
|
||||
}
|
||||
await refreshPromise
|
||||
|
||||
response = await fetch(url, {
|
||||
...customOptions,
|
||||
headers: buildHeaders(customOptions, authStore.accessToken),
|
||||
})
|
||||
} catch {
|
||||
return response
|
||||
}
|
||||
}
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
export const apiClient = {
|
||||
get: (endpoint: string, options?: FetchOptions) =>
|
||||
fetchWithAuth(endpoint, { ...options, method: 'GET' }),
|
||||
post: (endpoint: string, body?: unknown, options?: FetchOptions) =>
|
||||
fetchWithAuth(endpoint, {
|
||||
...options,
|
||||
method: 'POST',
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
}),
|
||||
put: (endpoint: string, body?: unknown, options?: FetchOptions) =>
|
||||
fetchWithAuth(endpoint, {
|
||||
...options,
|
||||
method: 'PUT',
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
}),
|
||||
delete: (endpoint: string, options?: FetchOptions) =>
|
||||
fetchWithAuth(endpoint, { ...options, method: 'DELETE' }),
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { apiClient } from '@/api/client'
|
||||
import type {
|
||||
List,
|
||||
ListItem,
|
||||
CreateListPayload,
|
||||
CreateListItemPayload,
|
||||
UpdateListItemPayload,
|
||||
SetListItemCompletedPayload,
|
||||
AddUserToListPayload,
|
||||
RemoveUserFromListPayload,
|
||||
} from '@/types/list'
|
||||
|
||||
async function extractErrorMessage(response: Response, fallback: string): Promise<string> {
|
||||
try {
|
||||
const errorData = await response.json()
|
||||
return errorData.message || errorData.error || fallback
|
||||
} catch {
|
||||
return response.statusText || fallback
|
||||
}
|
||||
}
|
||||
|
||||
export async function getListsApi(): Promise<List[]> {
|
||||
const response = await apiClient.get('/lists')
|
||||
if (!response.ok) {
|
||||
throw new Error(await extractErrorMessage(response, 'Failed to load lists'))
|
||||
}
|
||||
return response.json()
|
||||
}
|
||||
|
||||
export async function createListApi(payload: CreateListPayload): Promise<List> {
|
||||
const response = await apiClient.post('/lists', payload)
|
||||
if (!response.ok) {
|
||||
throw new Error(await extractErrorMessage(response, 'Failed to create list'))
|
||||
}
|
||||
return response.json()
|
||||
}
|
||||
|
||||
export async function getListItemsApi(listId: string): Promise<ListItem[]> {
|
||||
const response = await apiClient.get(`/lists/${listId}`)
|
||||
if (!response.ok) {
|
||||
throw new Error(await extractErrorMessage(response, 'Failed to load list items'))
|
||||
}
|
||||
return response.json()
|
||||
}
|
||||
|
||||
export async function createListItemApi(payload: CreateListItemPayload): Promise<ListItem> {
|
||||
const response = await apiClient.post('/lists/item', payload)
|
||||
if (!response.ok) {
|
||||
throw new Error(await extractErrorMessage(response, 'Failed to create list item'))
|
||||
}
|
||||
return response.json()
|
||||
}
|
||||
|
||||
export async function updateListItemApi(payload: UpdateListItemPayload): Promise<void> {
|
||||
const response = await apiClient.put('/lists/item', payload)
|
||||
if (!response.ok) {
|
||||
throw new Error(await extractErrorMessage(response, 'Failed to update list item'))
|
||||
}
|
||||
}
|
||||
|
||||
export async function setListItemCompletedApi(
|
||||
itemId: string,
|
||||
payload: SetListItemCompletedPayload,
|
||||
): Promise<void> {
|
||||
const response = await apiClient.post(`/lists/items/${itemId}`, payload)
|
||||
if (!response.ok) {
|
||||
throw new Error(await extractErrorMessage(response, 'Failed to update list item status'))
|
||||
}
|
||||
}
|
||||
|
||||
export async function addUserToListApi(payload: AddUserToListPayload): Promise<void> {
|
||||
const response = await apiClient.post('/lists/user', payload)
|
||||
if (!response.ok) {
|
||||
throw new Error(await extractErrorMessage(response, 'Failed to add user to list'))
|
||||
}
|
||||
}
|
||||
|
||||
export async function removeUserFromListApi(payload: RemoveUserFromListPayload): Promise<void> {
|
||||
const response = await apiClient.delete('/lists/user', {
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
if (!response.ok) {
|
||||
throw new Error(await extractErrorMessage(response, 'Failed to remove user from list'))
|
||||
}
|
||||
}
|
||||
+68
-47
@@ -1,53 +1,39 @@
|
||||
/* color palette from <https://github.com/vuejs/theme> */
|
||||
/* dttmr color palette - dark, modern, blueish */
|
||||
:root {
|
||||
--vt-c-white: #ffffff;
|
||||
--vt-c-white-soft: #f8f8f8;
|
||||
--vt-c-white-mute: #f2f2f2;
|
||||
--c-bg: #0a0e1a;
|
||||
--c-bg-soft: #101627;
|
||||
--c-bg-mute: #161d33;
|
||||
--c-bg-elevated: #1a2338;
|
||||
|
||||
--vt-c-black: #181818;
|
||||
--vt-c-black-soft: #222222;
|
||||
--vt-c-black-mute: #282828;
|
||||
--c-border: rgba(120, 150, 220, 0.16);
|
||||
--c-border-hover: rgba(120, 150, 220, 0.32);
|
||||
|
||||
--vt-c-indigo: #2c3e50;
|
||||
--c-text: #c7d0e6;
|
||||
--c-text-soft: #8892b0;
|
||||
--c-heading: #f1f4fc;
|
||||
|
||||
--vt-c-divider-light-1: rgba(60, 60, 60, 0.29);
|
||||
--vt-c-divider-light-2: rgba(60, 60, 60, 0.12);
|
||||
--vt-c-divider-dark-1: rgba(84, 84, 84, 0.65);
|
||||
--vt-c-divider-dark-2: rgba(84, 84, 84, 0.48);
|
||||
--c-accent: #4f7dfa;
|
||||
--c-accent-soft: #3a5fd9;
|
||||
--c-accent-strong: #6c93ff;
|
||||
--c-accent-bg: rgba(79, 125, 250, 0.14);
|
||||
|
||||
--vt-c-text-light-1: var(--vt-c-indigo);
|
||||
--vt-c-text-light-2: rgba(60, 60, 60, 0.66);
|
||||
--vt-c-text-dark-1: var(--vt-c-white);
|
||||
--vt-c-text-dark-2: rgba(235, 235, 235, 0.64);
|
||||
}
|
||||
--c-success: #34d399;
|
||||
--c-danger: #f87171;
|
||||
--c-danger-bg: rgba(248, 113, 113, 0.12);
|
||||
--c-warning: #fbbf24;
|
||||
|
||||
/* semantic color variables for this project */
|
||||
:root {
|
||||
--color-background: var(--vt-c-white);
|
||||
--color-background-soft: var(--vt-c-white-soft);
|
||||
--color-background-mute: var(--vt-c-white-mute);
|
||||
--radius-sm: 8px;
|
||||
--radius-md: 12px;
|
||||
--radius-lg: 18px;
|
||||
|
||||
--color-border: var(--vt-c-divider-light-2);
|
||||
--color-border-hover: var(--vt-c-divider-light-1);
|
||||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.4);
|
||||
--shadow-md: 0 8px 24px rgba(0, 0, 0, 0.45);
|
||||
|
||||
--color-heading: var(--vt-c-text-light-1);
|
||||
--color-text: var(--vt-c-text-light-1);
|
||||
--nav-height: 60px;
|
||||
--safe-bottom: env(safe-area-inset-bottom, 0px);
|
||||
--safe-top: env(safe-area-inset-top, 0px);
|
||||
|
||||
--section-gap: 160px;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--color-background: var(--vt-c-black);
|
||||
--color-background-soft: var(--vt-c-black-soft);
|
||||
--color-background-mute: var(--vt-c-black-mute);
|
||||
|
||||
--color-border: var(--vt-c-divider-dark-2);
|
||||
--color-border-hover: var(--vt-c-divider-dark-1);
|
||||
|
||||
--color-heading: var(--vt-c-text-dark-1);
|
||||
--color-text: var(--vt-c-text-dark-2);
|
||||
}
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
*,
|
||||
@@ -58,14 +44,15 @@
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
html {
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
body {
|
||||
min-height: 100vh;
|
||||
color: var(--color-text);
|
||||
background: var(--color-background);
|
||||
transition:
|
||||
color 0.5s,
|
||||
background-color 0.5s;
|
||||
line-height: 1.6;
|
||||
color: var(--c-text);
|
||||
background: radial-gradient(circle at top, #101a33 0%, var(--c-bg) 55%);
|
||||
line-height: 1.55;
|
||||
font-family:
|
||||
Inter,
|
||||
-apple-system,
|
||||
@@ -84,3 +71,37 @@ body {
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4 {
|
||||
color: var(--c-heading);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--c-accent-strong);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
button {
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
input,
|
||||
textarea,
|
||||
select {
|
||||
font-family: inherit;
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: var(--c-accent-bg);
|
||||
}
|
||||
|
||||
/* scrollbars */
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--c-border-hover) transparent;
|
||||
}
|
||||
|
||||
+120
-22
@@ -1,35 +1,133 @@
|
||||
@import './base.css';
|
||||
|
||||
#app {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
min-height: 100vh;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
a,
|
||||
.green {
|
||||
text-decoration: none;
|
||||
color: hsla(160, 100%, 37%, 1);
|
||||
transition: 0.4s;
|
||||
padding: 3px;
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
padding: 0.7rem 1rem;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 500;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background-color 0.15s ease-in-out,
|
||||
border-color 0.15s ease-in-out,
|
||||
opacity 0.15s;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
a:hover {
|
||||
background-color: hsla(160, 100%, 37%, 0.2);
|
||||
}
|
||||
.btn:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
body {
|
||||
display: flex;
|
||||
place-items: center;
|
||||
}
|
||||
.btn-primary {
|
||||
background: linear-gradient(135deg, var(--c-accent) 0%, var(--c-accent-soft) 100%);
|
||||
color: #ffffff;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
#app {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
padding: 0 2rem;
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background: linear-gradient(135deg, var(--c-accent-strong) 0%, var(--c-accent) 100%);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background-color: var(--c-bg-mute);
|
||||
color: var(--c-text);
|
||||
border-color: var(--c-border);
|
||||
}
|
||||
|
||||
.btn-secondary:hover:not(:disabled) {
|
||||
border-color: var(--c-border-hover);
|
||||
background-color: var(--c-bg-elevated);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background-color: transparent;
|
||||
color: var(--c-danger);
|
||||
border-color: rgba(248, 113, 113, 0.35);
|
||||
}
|
||||
|
||||
.btn-danger:hover:not(:disabled) {
|
||||
background-color: var(--c-danger-bg);
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
width: auto;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.card {
|
||||
background-color: var(--c-bg-soft);
|
||||
border: 1px solid var(--c-border);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.field label {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
color: var(--c-text-soft);
|
||||
}
|
||||
|
||||
.field input,
|
||||
.field textarea {
|
||||
padding: 0.65rem 0.8rem;
|
||||
border: 1px solid var(--c-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background-color: var(--c-bg-mute);
|
||||
color: var(--c-heading);
|
||||
outline: none;
|
||||
font-size: 0.95rem;
|
||||
transition: border-color 0.15s ease-in-out;
|
||||
}
|
||||
|
||||
.field input::placeholder,
|
||||
.field textarea::placeholder {
|
||||
color: var(--c-text-soft);
|
||||
}
|
||||
|
||||
.field input:focus,
|
||||
.field textarea:focus {
|
||||
border-color: var(--c-accent);
|
||||
}
|
||||
|
||||
.banner {
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.banner-error {
|
||||
background-color: var(--c-danger-bg);
|
||||
border: 1px solid rgba(248, 113, 113, 0.4);
|
||||
color: var(--c-danger);
|
||||
}
|
||||
|
||||
.page {
|
||||
min-height: 100vh;
|
||||
padding: 1rem 1rem calc(var(--nav-height) + var(--safe-bottom) + 1.5rem);
|
||||
padding-top: calc(1rem + var(--safe-top));
|
||||
max-width: 640px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.page {
|
||||
padding-left: 2rem;
|
||||
padding-right: 2rem;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
<script setup lang="ts">
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useListsStore } from '@/stores/lists'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const listsStore = useListsStore()
|
||||
const router = useRouter()
|
||||
|
||||
async function handleLogout() {
|
||||
await authStore.logout()
|
||||
router.push('/login')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header class="app-header">
|
||||
<div class="brand">
|
||||
<span class="brand-dot"></span>
|
||||
<span class="brand-name">dttmr</span>
|
||||
</div>
|
||||
|
||||
<div class="status">
|
||||
<span
|
||||
class="sync-dot"
|
||||
:class="{ offline: !authStore.isAuthenticated }"
|
||||
:title="listsStore.isSyncing ? 'Syncing…' : 'Up to date'"
|
||||
></span>
|
||||
<span v-if="listsStore.pendingCount > 0" class="pending">
|
||||
{{ listsStore.pendingCount }} pending
|
||||
</span>
|
||||
<button v-if="authStore.isAuthenticated" type="button" class="logout" @click="handleLogout">
|
||||
Log out
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.app-header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: calc(0.75rem + var(--safe-top)) 1rem 0.75rem;
|
||||
background: linear-gradient(180deg, rgba(10, 14, 26, 0.92) 60%, rgba(10, 14, 26, 0));
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-weight: 600;
|
||||
color: var(--c-heading);
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.brand-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, var(--c-accent-strong), var(--c-accent-soft));
|
||||
box-shadow: 0 0 12px var(--c-accent);
|
||||
}
|
||||
|
||||
.status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--c-text-soft);
|
||||
}
|
||||
|
||||
.sync-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background-color: var(--c-success);
|
||||
}
|
||||
|
||||
.sync-dot.offline {
|
||||
background-color: var(--c-text-soft);
|
||||
}
|
||||
|
||||
.logout {
|
||||
background: none;
|
||||
border: 1px solid var(--c-border);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--c-text);
|
||||
padding: 0.3rem 0.6rem;
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.logout:hover {
|
||||
border-color: var(--c-border-hover);
|
||||
color: var(--c-heading);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,91 @@
|
||||
<script setup lang="ts">
|
||||
import { RouterLink } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useListsStore } from '@/stores/lists'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const listsStore = useListsStore()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<nav v-if="authStore.isAuthenticated" class="bottom-nav">
|
||||
<RouterLink to="/" class="nav-item" active-class="is-active">
|
||||
<span class="nav-icon">☰</span>
|
||||
<span class="nav-label">Lists</span>
|
||||
<span v-if="listsStore.pendingCount > 0" class="nav-badge">{{
|
||||
listsStore.pendingCount
|
||||
}}</span>
|
||||
</RouterLink>
|
||||
<RouterLink to="/about" class="nav-item" active-class="is-active">
|
||||
<span class="nav-icon">ⓘ</span>
|
||||
<span class="nav-label">About</span>
|
||||
</RouterLink>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.bottom-nav {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
height: calc(var(--nav-height) + var(--safe-bottom));
|
||||
padding-bottom: var(--safe-bottom);
|
||||
background-color: var(--c-bg-soft);
|
||||
border-top: 1px solid var(--c-border);
|
||||
backdrop-filter: blur(12px);
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.15rem;
|
||||
color: var(--c-text-soft);
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.nav-icon {
|
||||
font-size: 1.25rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.nav-item.is-active {
|
||||
color: var(--c-accent-strong);
|
||||
}
|
||||
|
||||
.nav-badge {
|
||||
position: absolute;
|
||||
top: 0.35rem;
|
||||
right: calc(50% - 1.35rem);
|
||||
min-width: 16px;
|
||||
height: 16px;
|
||||
padding: 0 4px;
|
||||
border-radius: 999px;
|
||||
background-color: var(--c-accent);
|
||||
color: #fff;
|
||||
font-size: 0.6rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.bottom-nav {
|
||||
left: 50%;
|
||||
right: auto;
|
||||
bottom: 1.25rem;
|
||||
transform: translateX(-50%);
|
||||
width: min(420px, calc(100% - 2rem));
|
||||
height: 56px;
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--c-border);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,41 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
msg: string
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="greetings">
|
||||
<h1 class="green">{{ msg }}</h1>
|
||||
<h3>
|
||||
You’ve successfully created a project with
|
||||
<a href="https://vite.dev/" target="_blank" rel="noopener">Vite</a> +
|
||||
<a href="https://vuejs.org/" target="_blank" rel="noopener">Vue 3</a>. What's next?
|
||||
</h3>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
h1 {
|
||||
font-weight: 500;
|
||||
font-size: 2.6rem;
|
||||
position: relative;
|
||||
top: -10px;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.greetings h1,
|
||||
.greetings h3 {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.greetings h1,
|
||||
.greetings h3 {
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,70 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import type { LocalList } from '@/database/db'
|
||||
import { useListsStore } from '@/stores/lists'
|
||||
|
||||
const props = defineProps<{ list: LocalList }>()
|
||||
|
||||
const listsStore = useListsStore()
|
||||
|
||||
const items = computed(() => listsStore.itemsForList(props.list.id))
|
||||
const completedCount = computed(() => items.value.filter((item) => item.is_completed).length)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<RouterLink :to="`/lists/${list.id}`" class="list-card card">
|
||||
<div class="list-card-main">
|
||||
<h3>{{ list.name }}</h3>
|
||||
<p class="meta">
|
||||
{{ completedCount }}/{{ items.length }} done
|
||||
<span v-if="list.pendingSync" class="pending-tag">syncing…</span>
|
||||
</p>
|
||||
</div>
|
||||
<span class="chevron">›</span>
|
||||
</RouterLink>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.list-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem 1.1rem;
|
||||
color: inherit;
|
||||
transition:
|
||||
border-color 0.15s ease-in-out,
|
||||
transform 0.1s ease-in-out;
|
||||
}
|
||||
|
||||
.list-card:active {
|
||||
transform: scale(0.995);
|
||||
}
|
||||
|
||||
.list-card:hover {
|
||||
border-color: var(--c-border-hover);
|
||||
}
|
||||
|
||||
.list-card h3 {
|
||||
font-size: 1.02rem;
|
||||
margin-bottom: 0.2rem;
|
||||
}
|
||||
|
||||
.meta {
|
||||
font-size: 0.78rem;
|
||||
color: var(--c-text-soft);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.pending-tag {
|
||||
color: var(--c-accent-strong);
|
||||
}
|
||||
|
||||
.chevron {
|
||||
color: var(--c-text-soft);
|
||||
font-size: 1.3rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,119 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import type { LocalListItem } from '@/database/db'
|
||||
import { useListsStore } from '@/stores/lists'
|
||||
|
||||
const props = defineProps<{ item: LocalListItem }>()
|
||||
|
||||
const listsStore = useListsStore()
|
||||
const isEditing = ref(false)
|
||||
const editedTitle = ref(props.item.title)
|
||||
|
||||
function toggleCompleted() {
|
||||
listsStore.setListItemCompleted(props.item.id, !props.item.is_completed)
|
||||
}
|
||||
|
||||
function startEditing() {
|
||||
editedTitle.value = props.item.title
|
||||
isEditing.value = true
|
||||
}
|
||||
|
||||
function saveTitle() {
|
||||
const title = editedTitle.value.trim()
|
||||
if (title && title !== props.item.title) {
|
||||
listsStore.updateListItem(props.item.id, { title })
|
||||
}
|
||||
isEditing.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<li class="item-row" :class="{ completed: item.is_completed }">
|
||||
<button
|
||||
type="button"
|
||||
class="checkbox"
|
||||
:aria-pressed="item.is_completed"
|
||||
@click="toggleCompleted"
|
||||
>
|
||||
<span v-if="item.is_completed">✓</span>
|
||||
</button>
|
||||
|
||||
<input
|
||||
v-if="isEditing"
|
||||
v-model="editedTitle"
|
||||
class="title-input"
|
||||
type="text"
|
||||
@keyup.enter="saveTitle"
|
||||
@keyup.escape="isEditing = false"
|
||||
@blur="saveTitle"
|
||||
/>
|
||||
<span v-else class="title" @click="startEditing">{{ item.title }}</span>
|
||||
|
||||
<span v-if="item.pendingSync" class="pending-dot" title="Not yet synced"></span>
|
||||
</li>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.item-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.7rem 0.2rem;
|
||||
border-bottom: 1px solid var(--c-border);
|
||||
}
|
||||
|
||||
.item-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.checkbox {
|
||||
flex-shrink: 0;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--c-border-hover);
|
||||
background: transparent;
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.item-row.completed .checkbox {
|
||||
background: linear-gradient(135deg, var(--c-accent-strong), var(--c-accent-soft));
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.title {
|
||||
flex: 1;
|
||||
font-size: 0.95rem;
|
||||
cursor: text;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.item-row.completed .title {
|
||||
color: var(--c-text-soft);
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.title-input {
|
||||
flex: 1;
|
||||
padding: 0.3rem 0.5rem;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--c-accent);
|
||||
background-color: var(--c-bg-mute);
|
||||
color: var(--c-heading);
|
||||
font-size: 0.95rem;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.pending-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background-color: var(--c-warning);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -1,95 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import WelcomeItem from './WelcomeItem.vue'
|
||||
import DocumentationIcon from './icons/IconDocumentation.vue'
|
||||
import ToolingIcon from './icons/IconTooling.vue'
|
||||
import EcosystemIcon from './icons/IconEcosystem.vue'
|
||||
import CommunityIcon from './icons/IconCommunity.vue'
|
||||
import SupportIcon from './icons/IconSupport.vue'
|
||||
|
||||
const openReadmeInEditor = () => fetch('/__open-in-editor?file=README.md')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<WelcomeItem>
|
||||
<template #icon>
|
||||
<DocumentationIcon />
|
||||
</template>
|
||||
<template #heading>Documentation</template>
|
||||
|
||||
Vue’s
|
||||
<a href="https://vuejs.org/" target="_blank" rel="noopener">official documentation</a>
|
||||
provides you with all information you need to get started.
|
||||
</WelcomeItem>
|
||||
|
||||
<WelcomeItem>
|
||||
<template #icon>
|
||||
<ToolingIcon />
|
||||
</template>
|
||||
<template #heading>Tooling</template>
|
||||
|
||||
This project is served and bundled with
|
||||
<a href="https://vite.dev/guide/features.html" target="_blank" rel="noopener">Vite</a>. The
|
||||
recommended IDE setup is
|
||||
<a href="https://code.visualstudio.com/" target="_blank" rel="noopener">VSCode</a>
|
||||
+
|
||||
<a href="https://github.com/vuejs/language-tools" target="_blank" rel="noopener"
|
||||
>Vue - Official</a
|
||||
>. If you need to test your components and web pages, check out
|
||||
<a href="https://vitest.dev/" target="_blank" rel="noopener">Vitest</a>
|
||||
and
|
||||
<a href="https://www.cypress.io/" target="_blank" rel="noopener">Cypress</a>
|
||||
/
|
||||
<a href="https://playwright.dev/" target="_blank" rel="noopener">Playwright</a>.
|
||||
|
||||
<br />
|
||||
|
||||
More instructions are available in
|
||||
<a href="javascript:void(0)" @click="openReadmeInEditor"><code>README.md</code></a
|
||||
>.
|
||||
</WelcomeItem>
|
||||
|
||||
<WelcomeItem>
|
||||
<template #icon>
|
||||
<EcosystemIcon />
|
||||
</template>
|
||||
<template #heading>Ecosystem</template>
|
||||
|
||||
Get official tools and libraries for your project:
|
||||
<a href="https://pinia.vuejs.org/" target="_blank" rel="noopener">Pinia</a>,
|
||||
<a href="https://router.vuejs.org/" target="_blank" rel="noopener">Vue Router</a>,
|
||||
<a href="https://test-utils.vuejs.org/" target="_blank" rel="noopener">Vue Test Utils</a>, and
|
||||
<a href="https://github.com/vuejs/devtools" target="_blank" rel="noopener">Vue Dev Tools</a>. If
|
||||
you need more resources, we suggest paying
|
||||
<a href="https://github.com/vuejs/awesome-vue" target="_blank" rel="noopener">Awesome Vue</a>
|
||||
a visit.
|
||||
</WelcomeItem>
|
||||
|
||||
<WelcomeItem>
|
||||
<template #icon>
|
||||
<CommunityIcon />
|
||||
</template>
|
||||
<template #heading>Community</template>
|
||||
|
||||
Got stuck? Ask your question on
|
||||
<a href="https://chat.vuejs.org" target="_blank" rel="noopener">Vue Land</a>
|
||||
(our official Discord server), or
|
||||
<a href="https://stackoverflow.com/questions/tagged/vue.js" target="_blank" rel="noopener"
|
||||
>StackOverflow</a
|
||||
>. You should also follow the official
|
||||
<a href="https://bsky.app/profile/vuejs.org" target="_blank" rel="noopener">@vuejs.org</a>
|
||||
Bluesky account or the
|
||||
<a href="https://x.com/vuejs" target="_blank" rel="noopener">@vuejs</a>
|
||||
X account for latest news in the Vue world.
|
||||
</WelcomeItem>
|
||||
|
||||
<WelcomeItem>
|
||||
<template #icon>
|
||||
<SupportIcon />
|
||||
</template>
|
||||
<template #heading>Support Vue</template>
|
||||
|
||||
As an independent project, Vue relies on community backing for its sustainability. You can help
|
||||
us by
|
||||
<a href="https://vuejs.org/sponsor/" target="_blank" rel="noopener">becoming a sponsor</a>.
|
||||
</WelcomeItem>
|
||||
</template>
|
||||
@@ -1,87 +0,0 @@
|
||||
<template>
|
||||
<div class="item">
|
||||
<i>
|
||||
<slot name="icon"></slot>
|
||||
</i>
|
||||
<div class="details">
|
||||
<h3>
|
||||
<slot name="heading"></slot>
|
||||
</h3>
|
||||
<slot></slot>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.item {
|
||||
margin-top: 2rem;
|
||||
display: flex;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.details {
|
||||
flex: 1;
|
||||
margin-left: 1rem;
|
||||
}
|
||||
|
||||
i {
|
||||
display: flex;
|
||||
place-items: center;
|
||||
place-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 1.2rem;
|
||||
font-weight: 500;
|
||||
margin-bottom: 0.4rem;
|
||||
color: var(--color-heading);
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.item {
|
||||
margin-top: 0;
|
||||
padding: 0.4rem 0 1rem calc(var(--section-gap) / 2);
|
||||
}
|
||||
|
||||
i {
|
||||
top: calc(50% - 25px);
|
||||
left: -26px;
|
||||
position: absolute;
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-background);
|
||||
border-radius: 8px;
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
}
|
||||
|
||||
.item:before {
|
||||
content: ' ';
|
||||
border-left: 1px solid var(--color-border);
|
||||
position: absolute;
|
||||
left: 0;
|
||||
bottom: calc(50% + 25px);
|
||||
height: calc(50% - 25px);
|
||||
}
|
||||
|
||||
.item:after {
|
||||
content: ' ';
|
||||
border-left: 1px solid var(--color-border);
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: calc(50% + 25px);
|
||||
height: calc(50% - 25px);
|
||||
}
|
||||
|
||||
.item:first-of-type:before {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.item:last-of-type:after {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,11 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
|
||||
import { mount } from '@vue/test-utils'
|
||||
import HelloWorld from '../HelloWorld.vue'
|
||||
|
||||
describe('HelloWorld', () => {
|
||||
it('renders properly', () => {
|
||||
const wrapper = mount(HelloWorld, { props: { msg: 'Hello Vitest' } })
|
||||
expect(wrapper.text()).toContain('Hello Vitest')
|
||||
})
|
||||
})
|
||||
@@ -1,7 +0,0 @@
|
||||
<template>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor">
|
||||
<path
|
||||
d="M15 4a1 1 0 1 0 0 2V4zm0 11v-1a1 1 0 0 0-1 1h1zm0 4l-.707.707A1 1 0 0 0 16 19h-1zm-4-4l.707-.707A1 1 0 0 0 11 14v1zm-4.707-1.293a1 1 0 0 0-1.414 1.414l1.414-1.414zm-.707.707l-.707-.707.707.707zM9 11v-1a1 1 0 0 0-.707.293L9 11zm-4 0h1a1 1 0 0 0-1-1v1zm0 4H4a1 1 0 0 0 1.707.707L5 15zm10-9h2V4h-2v2zm2 0a1 1 0 0 1 1 1h2a3 3 0 0 0-3-3v2zm1 1v6h2V7h-2zm0 6a1 1 0 0 1-1 1v2a3 3 0 0 0 3-3h-2zm-1 1h-2v2h2v-2zm-3 1v4h2v-4h-2zm1.707 3.293l-4-4-1.414 1.414 4 4 1.414-1.414zM11 14H7v2h4v-2zm-4 0c-.276 0-.525-.111-.707-.293l-1.414 1.414C5.42 15.663 6.172 16 7 16v-2zm-.707 1.121l3.414-3.414-1.414-1.414-3.414 3.414 1.414 1.414zM9 12h4v-2H9v2zm4 0a3 3 0 0 0 3-3h-2a1 1 0 0 1-1 1v2zm3-3V3h-2v6h2zm0-6a3 3 0 0 0-3-3v2a1 1 0 0 1 1 1h2zm-3-3H3v2h10V0zM3 0a3 3 0 0 0-3 3h2a1 1 0 0 1 1-1V0zM0 3v6h2V3H0zm0 6a3 3 0 0 0 3 3v-2a1 1 0 0 1-1-1H0zm3 3h2v-2H3v2zm1-1v4h2v-4H4zm1.707 4.707l.586-.586-1.414-1.414-.586.586 1.414 1.414z"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
@@ -1,7 +0,0 @@
|
||||
<template>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="17" fill="currentColor">
|
||||
<path
|
||||
d="M11 2.253a1 1 0 1 0-2 0h2zm-2 13a1 1 0 1 0 2 0H9zm.447-12.167a1 1 0 1 0 1.107-1.666L9.447 3.086zM1 2.253L.447 1.42A1 1 0 0 0 0 2.253h1zm0 13H0a1 1 0 0 0 1.553.833L1 15.253zm8.447.833a1 1 0 1 0 1.107-1.666l-1.107 1.666zm0-14.666a1 1 0 1 0 1.107 1.666L9.447 1.42zM19 2.253h1a1 1 0 0 0-.447-.833L19 2.253zm0 13l-.553.833A1 1 0 0 0 20 15.253h-1zm-9.553-.833a1 1 0 1 0 1.107 1.666L9.447 14.42zM9 2.253v13h2v-13H9zm1.553-.833C9.203.523 7.42 0 5.5 0v2c1.572 0 2.961.431 3.947 1.086l1.107-1.666zM5.5 0C3.58 0 1.797.523.447 1.42l1.107 1.666C2.539 2.431 3.928 2 5.5 2V0zM0 2.253v13h2v-13H0zm1.553 13.833C2.539 15.431 3.928 15 5.5 15v-2c-1.92 0-3.703.523-5.053 1.42l1.107 1.666zM5.5 15c1.572 0 2.961.431 3.947 1.086l1.107-1.666C9.203 13.523 7.42 13 5.5 13v2zm5.053-11.914C11.539 2.431 12.928 2 14.5 2V0c-1.92 0-3.703.523-5.053 1.42l1.107 1.666zM14.5 2c1.573 0 2.961.431 3.947 1.086l1.107-1.666C18.203.523 16.421 0 14.5 0v2zm3.5.253v13h2v-13h-2zm1.553 12.167C18.203 13.523 16.421 13 14.5 13v2c1.573 0 2.961.431 3.947 1.086l1.107-1.666zM14.5 13c-1.92 0-3.703.523-5.053 1.42l1.107 1.666C11.539 15.431 12.928 15 14.5 15v-2z"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
@@ -1,7 +0,0 @@
|
||||
<template>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="20" fill="currentColor">
|
||||
<path
|
||||
d="M11.447 8.894a1 1 0 1 0-.894-1.789l.894 1.789zm-2.894-.789a1 1 0 1 0 .894 1.789l-.894-1.789zm0 1.789a1 1 0 1 0 .894-1.789l-.894 1.789zM7.447 7.106a1 1 0 1 0-.894 1.789l.894-1.789zM10 9a1 1 0 1 0-2 0h2zm-2 2.5a1 1 0 1 0 2 0H8zm9.447-5.606a1 1 0 1 0-.894-1.789l.894 1.789zm-2.894-.789a1 1 0 1 0 .894 1.789l-.894-1.789zm2 .789a1 1 0 1 0 .894-1.789l-.894 1.789zm-1.106-2.789a1 1 0 1 0-.894 1.789l.894-1.789zM18 5a1 1 0 1 0-2 0h2zm-2 2.5a1 1 0 1 0 2 0h-2zm-5.447-4.606a1 1 0 1 0 .894-1.789l-.894 1.789zM9 1l.447-.894a1 1 0 0 0-.894 0L9 1zm-2.447.106a1 1 0 1 0 .894 1.789l-.894-1.789zm-6 3a1 1 0 1 0 .894 1.789L.553 4.106zm2.894.789a1 1 0 1 0-.894-1.789l.894 1.789zm-2-.789a1 1 0 1 0-.894 1.789l.894-1.789zm1.106 2.789a1 1 0 1 0 .894-1.789l-.894 1.789zM2 5a1 1 0 1 0-2 0h2zM0 7.5a1 1 0 1 0 2 0H0zm8.553 12.394a1 1 0 1 0 .894-1.789l-.894 1.789zm-1.106-2.789a1 1 0 1 0-.894 1.789l.894-1.789zm1.106 1a1 1 0 1 0 .894 1.789l-.894-1.789zm2.894.789a1 1 0 1 0-.894-1.789l.894 1.789zM8 19a1 1 0 1 0 2 0H8zm2-2.5a1 1 0 1 0-2 0h2zm-7.447.394a1 1 0 1 0 .894-1.789l-.894 1.789zM1 15H0a1 1 0 0 0 .553.894L1 15zm1-2.5a1 1 0 1 0-2 0h2zm12.553 2.606a1 1 0 1 0 .894 1.789l-.894-1.789zM17 15l.447.894A1 1 0 0 0 18 15h-1zm1-2.5a1 1 0 1 0-2 0h2zm-7.447-5.394l-2 1 .894 1.789 2-1-.894-1.789zm-1.106 1l-2-1-.894 1.789 2 1 .894-1.789zM8 9v2.5h2V9H8zm8.553-4.894l-2 1 .894 1.789 2-1-.894-1.789zm.894 0l-2-1-.894 1.789 2 1 .894-1.789zM16 5v2.5h2V5h-2zm-4.553-3.894l-2-1-.894 1.789 2 1 .894-1.789zm-2.894-1l-2 1 .894 1.789 2-1L8.553.106zM1.447 5.894l2-1-.894-1.789-2 1 .894 1.789zm-.894 0l2 1 .894-1.789-2-1-.894 1.789zM0 5v2.5h2V5H0zm9.447 13.106l-2-1-.894 1.789 2 1 .894-1.789zm0 1.789l2-1-.894-1.789-2 1 .894 1.789zM10 19v-2.5H8V19h2zm-6.553-3.894l-2-1-.894 1.789 2 1 .894-1.789zM2 15v-2.5H0V15h2zm13.447 1.894l2-1-.894-1.789-2 1 .894 1.789zM18 15v-2.5h-2V15h2z"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
@@ -1,7 +0,0 @@
|
||||
<template>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor">
|
||||
<path
|
||||
d="M10 3.22l-.61-.6a5.5 5.5 0 0 0-7.666.105 5.5 5.5 0 0 0-.114 7.665L10 18.78l8.39-8.4a5.5 5.5 0 0 0-.114-7.665 5.5 5.5 0 0 0-7.666-.105l-.61.61z"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
@@ -1,19 +0,0 @@
|
||||
<!-- This icon is from <https://github.com/Templarian/MaterialDesign>, distributed under Apache 2.0 (https://www.apache.org/licenses/LICENSE-2.0) license-->
|
||||
<template>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
aria-hidden="true"
|
||||
role="img"
|
||||
class="iconify iconify--mdi"
|
||||
width="24"
|
||||
height="24"
|
||||
preserveAspectRatio="xMidYMid meet"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
d="M20 18v-4h-3v1h-2v-1H9v1H7v-1H4v4h16M6.33 8l-1.74 4H7v-1h2v1h6v-1h2v1h2.41l-1.74-4H6.33M9 5v1h6V5H9m12.84 7.61c.1.22.16.48.16.8V18c0 .53-.21 1-.6 1.41c-.4.4-.85.59-1.4.59H4c-.55 0-1-.19-1.4-.59C2.21 19 2 18.53 2 18v-4.59c0-.32.06-.58.16-.8L4.5 7.22C4.84 6.41 5.45 6 6.33 6H7V5c0-.55.18-1 .57-1.41C7.96 3.2 8.44 3 9 3h6c.56 0 1.04.2 1.43.59c.39.41.57.86.57 1.41v1h.67c.88 0 1.49.41 1.83 1.22l2.34 5.39z"
|
||||
fill="currentColor"
|
||||
></path>
|
||||
</svg>
|
||||
</template>
|
||||
+42
-7
@@ -1,12 +1,47 @@
|
||||
import Dexie from 'dexie';
|
||||
import Dexie, { type Table } from 'dexie'
|
||||
import type { List, ListItem } from '@/types/list'
|
||||
|
||||
export const db = new Dexie('dttmrdb')
|
||||
export interface LocalList extends List {
|
||||
pendingSync?: boolean
|
||||
}
|
||||
|
||||
db.version(1).stores({
|
||||
lists: '++id, uuid, title',
|
||||
export interface LocalListItem extends ListItem {
|
||||
pendingSync?: boolean
|
||||
}
|
||||
|
||||
listItems: '++id, uuid, title, isChecked',
|
||||
export type SyncOperationType =
|
||||
| 'createList'
|
||||
| 'createListItem'
|
||||
| 'updateListItem'
|
||||
| 'setListItemCompleted'
|
||||
| 'addUserToList'
|
||||
| 'removeUserFromList'
|
||||
|
||||
syncQueue: '++id, action, endpoint, createdAt',
|
||||
})
|
||||
export interface SyncQueueEntry {
|
||||
id?: number
|
||||
type: SyncOperationType
|
||||
payload: unknown
|
||||
localListId?: string
|
||||
localListItemId?: string
|
||||
createdAt: number
|
||||
attempts: number
|
||||
lastError?: string
|
||||
}
|
||||
|
||||
class AppDatabase extends Dexie {
|
||||
lists!: Table<LocalList, string>
|
||||
listItems!: Table<LocalListItem, string>
|
||||
syncQueue!: Table<SyncQueueEntry, number>
|
||||
|
||||
constructor() {
|
||||
super('dttmrdb')
|
||||
|
||||
this.version(1).stores({
|
||||
lists: 'id, name, pendingSync',
|
||||
listItems: 'id, list_id, title, is_completed, pendingSync',
|
||||
syncQueue: '++id, type, createdAt',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export const db = new AppDatabase()
|
||||
|
||||
@@ -5,6 +5,7 @@ import { createPinia } from 'pinia'
|
||||
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import { useListsStore } from './stores/lists'
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
@@ -12,3 +13,11 @@ app.use(createPinia())
|
||||
app.use(router)
|
||||
|
||||
app.mount('#app')
|
||||
|
||||
// Offline-first sync: attempt to flush the pending sync queue whenever the
|
||||
// app becomes online again, and once eagerly on startup.
|
||||
const listsStore = useListsStore()
|
||||
window.addEventListener('online', () => {
|
||||
void listsStore.sync()
|
||||
})
|
||||
void listsStore.sync()
|
||||
|
||||
+27
-3
@@ -1,13 +1,27 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import HomeView from '../views/HomeView.vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import ListsView from '../views/ListsView.vue'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(import.meta.env.BASE_URL),
|
||||
routes: [
|
||||
{
|
||||
path: '/',
|
||||
name: 'home',
|
||||
component: HomeView,
|
||||
name: 'lists',
|
||||
component: ListsView,
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/lists/:id',
|
||||
name: 'list-detail',
|
||||
component: () => import('../views/ListDetailView.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
props: true,
|
||||
},
|
||||
{
|
||||
path: '/login',
|
||||
name: 'login',
|
||||
component: () => import('../views/LoginView.vue'),
|
||||
},
|
||||
{
|
||||
path: '/about',
|
||||
@@ -20,4 +34,14 @@ const router = createRouter({
|
||||
],
|
||||
})
|
||||
|
||||
router.beforeEach((to) => {
|
||||
const authStore = useAuthStore()
|
||||
|
||||
if (to.meta.requiresAuth && !authStore.isAuthenticated) {
|
||||
return { name: 'login', query: { redirect: to.fullPath } }
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
|
||||
export default router
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'
|
||||
import { setActivePinia, createPinia } from 'pinia'
|
||||
import { useAuthStore } from '../auth'
|
||||
import * as authApi from '@/api/auth'
|
||||
|
||||
describe('useAuthStore', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
localStorage.clear()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
it('initializes with tokens from localStorage if available', () => {
|
||||
localStorage.setItem('access_token', 'initial-access-token')
|
||||
localStorage.setItem('refresh_token', 'initial-refresh-token')
|
||||
|
||||
const authStore = useAuthStore()
|
||||
|
||||
expect(authStore.accessToken).toBe('initial-access-token')
|
||||
expect(authStore.refreshToken).toBe('initial-refresh-token')
|
||||
expect(authStore.isAuthenticated).toBe(true)
|
||||
})
|
||||
|
||||
it('initializes with null tokens if localStorage is empty', () => {
|
||||
const authStore = useAuthStore()
|
||||
|
||||
expect(authStore.accessToken).toBeNull()
|
||||
expect(authStore.refreshToken).toBeNull()
|
||||
expect(authStore.isAuthenticated).toBe(false)
|
||||
})
|
||||
|
||||
it('successfully logs in and stores tokens in state and localStorage', async () => {
|
||||
const mockTokenPair = {
|
||||
access_token: 'new-access-token',
|
||||
refresh_token: 'new-refresh-token',
|
||||
}
|
||||
|
||||
vi.spyOn(authApi, 'loginApi').mockResolvedValueOnce(mockTokenPair)
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const result = await authStore.login({
|
||||
email: 'test@example.com',
|
||||
password: 'password123',
|
||||
})
|
||||
|
||||
expect(result).toEqual(mockTokenPair)
|
||||
expect(authStore.accessToken).toBe('new-access-token')
|
||||
expect(authStore.refreshToken).toBe('new-refresh-token')
|
||||
expect(authStore.isAuthenticated).toBe(true)
|
||||
expect(localStorage.getItem('access_token')).toBe('new-access-token')
|
||||
expect(localStorage.getItem('refresh_token')).toBe('new-refresh-token')
|
||||
expect(authStore.error).toBeNull()
|
||||
})
|
||||
|
||||
it('handles login failure and sets error message', async () => {
|
||||
vi.spyOn(authApi, 'loginApi').mockRejectedValueOnce(new Error('Invalid credentials'))
|
||||
|
||||
const authStore = useAuthStore()
|
||||
|
||||
await expect(
|
||||
authStore.login({
|
||||
email: 'test@example.com',
|
||||
password: 'wrong-password',
|
||||
}),
|
||||
).rejects.toThrow('Invalid credentials')
|
||||
|
||||
expect(authStore.accessToken).toBeNull()
|
||||
expect(authStore.refreshToken).toBeNull()
|
||||
expect(authStore.isAuthenticated).toBe(false)
|
||||
expect(authStore.error).toBe('Invalid credentials')
|
||||
})
|
||||
|
||||
it('refreshes tokens successfully and updates state and localStorage', async () => {
|
||||
localStorage.setItem('access_token', 'old-access-token')
|
||||
localStorage.setItem('refresh_token', 'old-refresh-token')
|
||||
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const refreshedTokenPair = {
|
||||
access_token: 'refreshed-access-token',
|
||||
refresh_token: 'refreshed-refresh-token',
|
||||
}
|
||||
|
||||
const refreshSpy = vi.spyOn(authApi, 'refreshApi').mockResolvedValueOnce(refreshedTokenPair)
|
||||
|
||||
const result = await authStore.refreshTokens()
|
||||
|
||||
expect(refreshSpy).toHaveBeenCalledWith({ refresh_token: 'old-refresh-token' })
|
||||
expect(result).toEqual(refreshedTokenPair)
|
||||
expect(authStore.accessToken).toBe('refreshed-access-token')
|
||||
expect(authStore.refreshToken).toBe('refreshed-refresh-token')
|
||||
expect(localStorage.getItem('access_token')).toBe('refreshed-access-token')
|
||||
expect(localStorage.getItem('refresh_token')).toBe('refreshed-refresh-token')
|
||||
})
|
||||
|
||||
it('clears tokens and throws if refreshTokens is called without a refresh token', async () => {
|
||||
const authStore = useAuthStore()
|
||||
|
||||
await expect(authStore.refreshTokens()).rejects.toThrow('No refresh token available')
|
||||
expect(authStore.accessToken).toBeNull()
|
||||
expect(authStore.refreshToken).toBeNull()
|
||||
})
|
||||
|
||||
it('clears tokens and sets error when refresh API call fails', async () => {
|
||||
localStorage.setItem('access_token', 'old-access-token')
|
||||
localStorage.setItem('refresh_token', 'expired-refresh-token')
|
||||
|
||||
const authStore = useAuthStore()
|
||||
|
||||
vi.spyOn(authApi, 'refreshApi').mockRejectedValueOnce(new Error('Refresh token expired'))
|
||||
|
||||
await expect(authStore.refreshTokens()).rejects.toThrow('Refresh token expired')
|
||||
expect(authStore.accessToken).toBeNull()
|
||||
expect(authStore.refreshToken).toBeNull()
|
||||
expect(authStore.error).toBe('Refresh token expired')
|
||||
expect(localStorage.getItem('access_token')).toBeNull()
|
||||
expect(localStorage.getItem('refresh_token')).toBeNull()
|
||||
})
|
||||
|
||||
it('clears tokens and calls logout API on logout', async () => {
|
||||
localStorage.setItem('access_token', 'sample-access')
|
||||
localStorage.setItem('refresh_token', 'sample-refresh')
|
||||
|
||||
const authStore = useAuthStore()
|
||||
expect(authStore.isAuthenticated).toBe(true)
|
||||
|
||||
const logoutSpy = vi.spyOn(authApi, 'logoutApi').mockResolvedValueOnce()
|
||||
|
||||
await authStore.logout()
|
||||
|
||||
expect(logoutSpy).toHaveBeenCalledWith({ refresh_token: 'sample-refresh' })
|
||||
expect(authStore.accessToken).toBeNull()
|
||||
expect(authStore.refreshToken).toBeNull()
|
||||
expect(authStore.isAuthenticated).toBe(false)
|
||||
expect(localStorage.getItem('access_token')).toBeNull()
|
||||
expect(localStorage.getItem('refresh_token')).toBeNull()
|
||||
})
|
||||
|
||||
it('still clears tokens if logout API fails', async () => {
|
||||
localStorage.setItem('access_token', 'sample-access')
|
||||
localStorage.setItem('refresh_token', 'sample-refresh')
|
||||
|
||||
const authStore = useAuthStore()
|
||||
vi.spyOn(authApi, 'logoutApi').mockRejectedValueOnce(new Error('Network error'))
|
||||
|
||||
await authStore.logout()
|
||||
|
||||
expect(authStore.accessToken).toBeNull()
|
||||
expect(authStore.refreshToken).toBeNull()
|
||||
expect(authStore.isAuthenticated).toBe(false)
|
||||
expect(localStorage.getItem('access_token')).toBeNull()
|
||||
expect(localStorage.getItem('refresh_token')).toBeNull()
|
||||
})
|
||||
|
||||
it('clears tokens and calls logoutAll API on logoutAll', async () => {
|
||||
localStorage.setItem('access_token', 'sample-access')
|
||||
localStorage.setItem('refresh_token', 'sample-refresh')
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const logoutAllSpy = vi.spyOn(authApi, 'logoutAllApi').mockResolvedValueOnce()
|
||||
|
||||
await authStore.logoutAll()
|
||||
|
||||
expect(logoutAllSpy).toHaveBeenCalledWith('sample-access')
|
||||
expect(authStore.accessToken).toBeNull()
|
||||
expect(authStore.refreshToken).toBeNull()
|
||||
expect(authStore.isAuthenticated).toBe(false)
|
||||
expect(localStorage.getItem('access_token')).toBeNull()
|
||||
expect(localStorage.getItem('refresh_token')).toBeNull()
|
||||
})
|
||||
|
||||
it('still clears tokens if logoutAll API fails', async () => {
|
||||
localStorage.setItem('access_token', 'sample-access')
|
||||
localStorage.setItem('refresh_token', 'sample-refresh')
|
||||
|
||||
const authStore = useAuthStore()
|
||||
vi.spyOn(authApi, 'logoutAllApi').mockRejectedValueOnce(new Error('Network error'))
|
||||
|
||||
await authStore.logoutAll()
|
||||
|
||||
expect(authStore.accessToken).toBeNull()
|
||||
expect(authStore.refreshToken).toBeNull()
|
||||
expect(authStore.isAuthenticated).toBe(false)
|
||||
expect(localStorage.getItem('access_token')).toBeNull()
|
||||
expect(localStorage.getItem('refresh_token')).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,267 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { setActivePinia, createPinia } from 'pinia'
|
||||
|
||||
type Record = { id?: unknown; [key: string]: unknown }
|
||||
|
||||
function createFakeTable(autoIncrement = false) {
|
||||
const store = new Map<unknown, Record>()
|
||||
let nextId = 1
|
||||
|
||||
const table = {
|
||||
async toArray() {
|
||||
return Array.from(store.values()).map((v) => ({ ...v }))
|
||||
},
|
||||
async add(record: Record) {
|
||||
const id = autoIncrement ? nextId++ : record.id
|
||||
const toStore = autoIncrement ? { ...record, id } : record
|
||||
store.set(id, { ...toStore })
|
||||
return id
|
||||
},
|
||||
async get(id: unknown) {
|
||||
const found = store.get(id)
|
||||
return found ? { ...found } : undefined
|
||||
},
|
||||
async put(record: Record) {
|
||||
store.set(record.id, { ...record })
|
||||
return record.id
|
||||
},
|
||||
async delete(id: unknown) {
|
||||
store.delete(id)
|
||||
},
|
||||
async update(id: unknown, changes: Record) {
|
||||
const existing = store.get(id)
|
||||
if (!existing) return 0
|
||||
store.set(id, { ...existing, ...changes })
|
||||
return 1
|
||||
},
|
||||
async count() {
|
||||
return store.size
|
||||
},
|
||||
where(field: string) {
|
||||
return {
|
||||
equals(value: unknown) {
|
||||
return {
|
||||
async toArray() {
|
||||
return Array.from(store.values())
|
||||
.filter((v) => v[field] === value)
|
||||
.map((v) => ({ ...v }))
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
orderBy(field: string) {
|
||||
return {
|
||||
async toArray() {
|
||||
return Array.from(store.values())
|
||||
.sort((a, b) => Number(a[field]) - Number(b[field]))
|
||||
.map((v) => ({ ...v }))
|
||||
},
|
||||
}
|
||||
},
|
||||
filter(predicate: (record: Record) => boolean) {
|
||||
return {
|
||||
async toArray() {
|
||||
return Array.from(store.values())
|
||||
.filter(predicate)
|
||||
.map((v) => ({ ...v }))
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
return table
|
||||
}
|
||||
|
||||
const fakeDb = {
|
||||
lists: createFakeTable(),
|
||||
listItems: createFakeTable(),
|
||||
syncQueue: createFakeTable(true),
|
||||
}
|
||||
|
||||
vi.mock('@/database/db', () => ({
|
||||
db: fakeDb,
|
||||
}))
|
||||
|
||||
const listsApiMocks = vi.hoisted(() => ({
|
||||
getListsApi: vi.fn(),
|
||||
createListApi: vi.fn(),
|
||||
getListItemsApi: vi.fn(),
|
||||
createListItemApi: vi.fn(),
|
||||
updateListItemApi: vi.fn(),
|
||||
setListItemCompletedApi: vi.fn(),
|
||||
addUserToListApi: vi.fn(),
|
||||
removeUserFromListApi: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/api/lists', () => listsApiMocks)
|
||||
|
||||
const { useListsStore } = await import('../lists')
|
||||
|
||||
describe('useListsStore', () => {
|
||||
beforeEach(async () => {
|
||||
setActivePinia(createPinia())
|
||||
vi.restoreAllMocks()
|
||||
Object.values(listsApiMocks).forEach((mock) => mock.mockReset())
|
||||
|
||||
for (const table of Object.values(fakeDb)) {
|
||||
const all = await table.toArray()
|
||||
for (const record of all) {
|
||||
await table.delete(record.id)
|
||||
}
|
||||
}
|
||||
|
||||
Object.defineProperty(navigator, 'onLine', { value: true, configurable: true })
|
||||
|
||||
// Default the read endpoints to an empty result so that the pullFromServer()
|
||||
// step chained onto every sync() call doesn't interfere with unrelated tests.
|
||||
listsApiMocks.getListsApi.mockResolvedValue([])
|
||||
listsApiMocks.getListItemsApi.mockResolvedValue([])
|
||||
})
|
||||
|
||||
it('creates a list locally, queues a sync entry, and remaps the id after a successful sync', async () => {
|
||||
listsApiMocks.createListApi.mockResolvedValueOnce({ id: 'server-id-1', name: 'Groceries' })
|
||||
|
||||
const store = useListsStore()
|
||||
const localList = await store.createList('Groceries', [])
|
||||
|
||||
// wait for the fire-and-forget sync triggered by createList to settle
|
||||
await store.sync()
|
||||
|
||||
expect(listsApiMocks.createListApi).toHaveBeenCalledWith({
|
||||
name: 'Groceries',
|
||||
user_ids: [],
|
||||
})
|
||||
expect(store.lists.find((list) => list.id === localList.id)).toBeUndefined()
|
||||
const synced = store.lists.find((list) => list.id === 'server-id-1')
|
||||
expect(synced).toBeDefined()
|
||||
expect(synced?.pendingSync).toBe(false)
|
||||
expect(store.pendingCount).toBe(0)
|
||||
})
|
||||
|
||||
it('creates a list item locally and remaps it to the server-assigned id once synced', async () => {
|
||||
listsApiMocks.createListItemApi.mockResolvedValueOnce({
|
||||
id: 'server-item-1',
|
||||
list_id: '',
|
||||
title: 'Milk',
|
||||
is_completed: false,
|
||||
})
|
||||
|
||||
const store = useListsStore()
|
||||
const item = await store.createListItem('list-1', 'Milk')
|
||||
await store.sync()
|
||||
|
||||
expect(listsApiMocks.createListItemApi).toHaveBeenCalledWith({
|
||||
list_id: 'list-1',
|
||||
title: 'Milk',
|
||||
})
|
||||
expect(store.listItems.find((entry) => entry.id === item.id)).toBeUndefined()
|
||||
const synced = store.listItems.find((entry) => entry.id === 'server-item-1')
|
||||
expect(synced).toBeDefined()
|
||||
expect(synced?.pendingSync).toBe(false)
|
||||
expect(store.pendingCount).toBe(0)
|
||||
})
|
||||
|
||||
it('keeps the entry in the sync queue and records the error when the API call fails', async () => {
|
||||
listsApiMocks.createListItemApi.mockRejectedValueOnce(new Error('Network error'))
|
||||
|
||||
const store = useListsStore()
|
||||
await store.createListItem('list-1', 'Bread')
|
||||
await store.sync()
|
||||
|
||||
expect(store.pendingCount).toBe(1)
|
||||
expect(store.error).toBe('Network error')
|
||||
})
|
||||
|
||||
it('updates a list item locally and pushes the change to the server', async () => {
|
||||
listsApiMocks.createListItemApi.mockResolvedValueOnce({
|
||||
id: 'server-item-2',
|
||||
list_id: '',
|
||||
title: 'Eggs',
|
||||
is_completed: false,
|
||||
})
|
||||
listsApiMocks.updateListItemApi.mockResolvedValueOnce(undefined)
|
||||
|
||||
const store = useListsStore()
|
||||
await store.createListItem('list-1', 'Eggs')
|
||||
await store.sync()
|
||||
const created = store.listItems.find((entry) => entry.title === 'Eggs')!
|
||||
|
||||
await store.updateListItem(created.id, { is_completed: true })
|
||||
await store.sync()
|
||||
|
||||
expect(listsApiMocks.updateListItemApi).toHaveBeenCalledWith({
|
||||
list_item_id: created.id,
|
||||
is_completed: true,
|
||||
})
|
||||
const updated = store.listItems.find((entry) => entry.id === created.id)
|
||||
expect(updated?.is_completed).toBe(true)
|
||||
expect(updated?.pendingSync).toBe(false)
|
||||
})
|
||||
|
||||
it('does not attempt to sync while offline', async () => {
|
||||
Object.defineProperty(navigator, 'onLine', { value: false, configurable: true })
|
||||
|
||||
const store = useListsStore()
|
||||
await store.createList('Offline list')
|
||||
await store.sync()
|
||||
|
||||
expect(listsApiMocks.createListApi).not.toHaveBeenCalled()
|
||||
expect(store.pendingCount).toBe(1)
|
||||
})
|
||||
|
||||
it('sets a list item completed locally and pushes it via the dedicated endpoint', async () => {
|
||||
listsApiMocks.createListItemApi.mockResolvedValueOnce({
|
||||
id: 'server-item-3',
|
||||
list_id: '',
|
||||
title: 'Eggs',
|
||||
is_completed: false,
|
||||
})
|
||||
listsApiMocks.setListItemCompletedApi.mockResolvedValueOnce(undefined)
|
||||
|
||||
const store = useListsStore()
|
||||
await store.createListItem('list-1', 'Eggs')
|
||||
await store.sync()
|
||||
const created = store.listItems.find((entry) => entry.title === 'Eggs')!
|
||||
|
||||
await store.setListItemCompleted(created.id, true)
|
||||
await store.sync()
|
||||
|
||||
expect(listsApiMocks.setListItemCompletedApi).toHaveBeenCalledWith(created.id, {
|
||||
is_completed: true,
|
||||
})
|
||||
const updated = store.listItems.find((entry) => entry.id === created.id)
|
||||
expect(updated?.is_completed).toBe(true)
|
||||
expect(updated?.pendingSync).toBe(false)
|
||||
})
|
||||
|
||||
it('pulls lists and items from the server and merges them locally', async () => {
|
||||
listsApiMocks.getListsApi.mockResolvedValueOnce([{ id: 'server-list-1', name: 'Groceries' }])
|
||||
listsApiMocks.getListItemsApi.mockResolvedValueOnce([
|
||||
{ id: 'server-item-1', list_id: 'server-list-1', title: 'Milk', is_completed: false },
|
||||
])
|
||||
|
||||
const store = useListsStore()
|
||||
await store.pullFromServer()
|
||||
|
||||
expect(store.lists.find((list) => list.id === 'server-list-1')).toBeDefined()
|
||||
expect(store.listItems.find((item) => item.id === 'server-item-1')).toBeDefined()
|
||||
})
|
||||
|
||||
it('does not overwrite a locally pending list with stale server data', async () => {
|
||||
listsApiMocks.createListApi.mockImplementation(() => new Promise(() => {}))
|
||||
|
||||
const store = useListsStore()
|
||||
const localList = await store.createList('Local only')
|
||||
|
||||
listsApiMocks.getListsApi.mockResolvedValueOnce([
|
||||
{ id: localList.id, name: 'Server version' },
|
||||
])
|
||||
|
||||
await store.pullFromServer()
|
||||
|
||||
const stillLocal = store.lists.find((list) => list.id === localList.id)
|
||||
expect(stillLocal?.name).toBe('Local only')
|
||||
expect(stillLocal?.pendingSync).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,99 @@
|
||||
import { ref, computed } from 'vue'
|
||||
import { defineStore } from 'pinia'
|
||||
import { loginApi, refreshApi, logoutApi, logoutAllApi } from '@/api/auth'
|
||||
import type { LoginPayload, TokenPair } from '@/types/auth'
|
||||
|
||||
export const useAuthStore = defineStore('auth', () => {
|
||||
const accessToken = ref<string | null>(localStorage.getItem('access_token'))
|
||||
const refreshToken = ref<string | null>(localStorage.getItem('refresh_token'))
|
||||
const isLoading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
const isAuthenticated = computed(() => !!accessToken.value)
|
||||
|
||||
function setTokens(tokens: TokenPair) {
|
||||
accessToken.value = tokens.access_token
|
||||
refreshToken.value = tokens.refresh_token
|
||||
localStorage.setItem('access_token', tokens.access_token)
|
||||
localStorage.setItem('refresh_token', tokens.refresh_token)
|
||||
}
|
||||
|
||||
function clearTokens() {
|
||||
accessToken.value = null
|
||||
refreshToken.value = null
|
||||
localStorage.removeItem('access_token')
|
||||
localStorage.removeItem('refresh_token')
|
||||
}
|
||||
|
||||
async function login(payload: LoginPayload): Promise<TokenPair> {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const tokens = await loginApi(payload)
|
||||
setTokens(tokens)
|
||||
return tokens
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'Login failed'
|
||||
throw err
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshTokens(): Promise<TokenPair> {
|
||||
if (!refreshToken.value) {
|
||||
clearTokens()
|
||||
throw new Error('No refresh token available')
|
||||
}
|
||||
|
||||
try {
|
||||
const tokens = await refreshApi({ refresh_token: refreshToken.value })
|
||||
setTokens(tokens)
|
||||
return tokens
|
||||
} catch (err) {
|
||||
clearTokens()
|
||||
error.value = err instanceof Error ? err.message : 'Token refresh failed'
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
const currentRefreshToken = refreshToken.value
|
||||
clearTokens()
|
||||
error.value = null
|
||||
if (currentRefreshToken) {
|
||||
try {
|
||||
await logoutApi({ refresh_token: currentRefreshToken })
|
||||
} catch {
|
||||
// Backend logout failure should not prevent local token clearing
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function logoutAll() {
|
||||
const currentAccessToken = accessToken.value
|
||||
clearTokens()
|
||||
error.value = null
|
||||
if (currentAccessToken) {
|
||||
try {
|
||||
await logoutAllApi(currentAccessToken)
|
||||
} catch {
|
||||
// Backend logout failure should not prevent local token clearing
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
accessToken,
|
||||
refreshToken,
|
||||
isLoading,
|
||||
error,
|
||||
isAuthenticated,
|
||||
setTokens,
|
||||
clearTokens,
|
||||
login,
|
||||
refreshTokens,
|
||||
logout,
|
||||
logoutAll,
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,376 @@
|
||||
import { ref, computed } from 'vue'
|
||||
import { defineStore } from 'pinia'
|
||||
import { db, type LocalList, type LocalListItem, type SyncQueueEntry } from '@/database/db'
|
||||
import {
|
||||
getListsApi,
|
||||
createListApi,
|
||||
getListItemsApi,
|
||||
createListItemApi,
|
||||
updateListItemApi,
|
||||
setListItemCompletedApi,
|
||||
addUserToListApi,
|
||||
removeUserFromListApi,
|
||||
} from '@/api/lists'
|
||||
|
||||
function generateId(): string {
|
||||
if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) {
|
||||
return crypto.randomUUID()
|
||||
}
|
||||
return `${Date.now()}-${Math.random().toString(16).slice(2)}`
|
||||
}
|
||||
|
||||
export const useListsStore = defineStore('lists', () => {
|
||||
const lists = ref<LocalList[]>([])
|
||||
const listItems = ref<LocalListItem[]>([])
|
||||
const isLoaded = ref(false)
|
||||
const isSyncing = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const pendingCount = ref(0)
|
||||
|
||||
const sortedLists = computed(() =>
|
||||
[...lists.value].sort((a, b) => (b.modified_at ?? '').localeCompare(a.modified_at ?? '')),
|
||||
)
|
||||
|
||||
function itemsForList(listId: string) {
|
||||
return listItems.value
|
||||
.filter((item) => item.list_id === listId)
|
||||
.sort((a, b) => (a.created_at ?? '').localeCompare(b.created_at ?? ''))
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
lists.value = await db.lists.toArray()
|
||||
listItems.value = await db.listItems.toArray()
|
||||
pendingCount.value = await db.syncQueue.count()
|
||||
isLoaded.value = true
|
||||
}
|
||||
|
||||
async function loadLists() {
|
||||
if (!isLoaded.value) {
|
||||
await refresh()
|
||||
}
|
||||
// The server is the source of truth: even though we already have a
|
||||
// local snapshot to render instantly (including while offline), always
|
||||
// kick off a background sync/pull so views reflect the latest server
|
||||
// state on every visit, not just on the first load of the session.
|
||||
void sync()
|
||||
}
|
||||
|
||||
async function enqueue(entry: Omit<SyncQueueEntry, 'id' | 'createdAt' | 'attempts'>) {
|
||||
await db.syncQueue.add({
|
||||
...entry,
|
||||
createdAt: Date.now(),
|
||||
attempts: 0,
|
||||
})
|
||||
}
|
||||
|
||||
async function createList(name: string, userIds: string[] = []): Promise<LocalList> {
|
||||
const now = new Date().toISOString()
|
||||
const localList: LocalList = {
|
||||
id: generateId(),
|
||||
name,
|
||||
created_at: now,
|
||||
modified_at: now,
|
||||
pendingSync: true,
|
||||
}
|
||||
|
||||
await db.lists.add(localList)
|
||||
await enqueue({
|
||||
type: 'createList',
|
||||
payload: { name, user_ids: userIds },
|
||||
localListId: localList.id,
|
||||
})
|
||||
await refresh()
|
||||
void sync()
|
||||
|
||||
return localList
|
||||
}
|
||||
|
||||
async function createListItem(listId: string, title: string): Promise<LocalListItem> {
|
||||
const now = new Date().toISOString()
|
||||
const localItem: LocalListItem = {
|
||||
id: generateId(),
|
||||
list_id: listId,
|
||||
title,
|
||||
is_completed: false,
|
||||
created_at: now,
|
||||
modified_at: now,
|
||||
pendingSync: true,
|
||||
}
|
||||
|
||||
await db.listItems.add(localItem)
|
||||
await enqueue({
|
||||
type: 'createListItem',
|
||||
payload: { list_id: listId, title },
|
||||
localListItemId: localItem.id,
|
||||
})
|
||||
await refresh()
|
||||
void sync()
|
||||
|
||||
return localItem
|
||||
}
|
||||
|
||||
async function updateListItem(
|
||||
itemId: string,
|
||||
changes: { title?: string; is_completed?: boolean },
|
||||
) {
|
||||
await db.listItems.update(itemId, {
|
||||
...changes,
|
||||
modified_at: new Date().toISOString(),
|
||||
pendingSync: true,
|
||||
})
|
||||
await enqueue({
|
||||
type: 'updateListItem',
|
||||
payload: { list_item_id: itemId, ...changes },
|
||||
localListItemId: itemId,
|
||||
})
|
||||
await refresh()
|
||||
void sync()
|
||||
}
|
||||
|
||||
async function setListItemCompleted(itemId: string, isCompleted: boolean) {
|
||||
await db.listItems.update(itemId, {
|
||||
is_completed: isCompleted,
|
||||
modified_at: new Date().toISOString(),
|
||||
pendingSync: true,
|
||||
})
|
||||
await enqueue({
|
||||
type: 'setListItemCompleted',
|
||||
payload: { is_completed: isCompleted },
|
||||
localListItemId: itemId,
|
||||
})
|
||||
await refresh()
|
||||
void sync()
|
||||
}
|
||||
|
||||
async function addUserToList(listId: string, userId: string) {
|
||||
await enqueue({
|
||||
type: 'addUserToList',
|
||||
payload: { list_id: listId, user_id: userId },
|
||||
localListId: listId,
|
||||
})
|
||||
await refresh()
|
||||
void sync()
|
||||
}
|
||||
|
||||
async function removeUserFromList(listId: string, userId: string) {
|
||||
await enqueue({
|
||||
type: 'removeUserFromList',
|
||||
payload: { list_id: listId, user_id: userId },
|
||||
localListId: listId,
|
||||
})
|
||||
await refresh()
|
||||
void sync()
|
||||
}
|
||||
|
||||
// Remaps a client-generated temporary list id to the id assigned by the
|
||||
// server once the "createList" sync operation succeeds. This keeps any
|
||||
// items or queued operations referencing the temporary id consistent.
|
||||
async function remapListId(oldId: string, newId: string) {
|
||||
if (oldId === newId) return
|
||||
|
||||
const existing = await db.lists.get(oldId)
|
||||
if (existing) {
|
||||
await db.lists.delete(oldId)
|
||||
await db.lists.put({ ...existing, id: newId, pendingSync: false })
|
||||
}
|
||||
|
||||
const affectedItems = await db.listItems.where('list_id').equals(oldId).toArray()
|
||||
for (const item of affectedItems) {
|
||||
await db.listItems.update(item.id, { list_id: newId })
|
||||
}
|
||||
|
||||
const affectedQueueEntries = await db.syncQueue
|
||||
.filter((entry) => entry.localListId === oldId)
|
||||
.toArray()
|
||||
for (const entry of affectedQueueEntries) {
|
||||
const payload = entry.payload as { list_id?: string }
|
||||
await db.syncQueue.update(entry.id!, {
|
||||
localListId: newId,
|
||||
payload: payload?.list_id ? { ...payload, list_id: newId } : entry.payload,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Remaps a client-generated temporary list item id to the id assigned by
|
||||
// the server, keeping any queued operations referencing the temporary id
|
||||
// consistent. This is what lets the server remain the source of truth for
|
||||
// item ids instead of the client-generated placeholder living on forever.
|
||||
async function remapListItemId(oldId: string, newId: string) {
|
||||
if (oldId === newId) return
|
||||
|
||||
const existing = await db.listItems.get(oldId)
|
||||
if (existing) {
|
||||
await db.listItems.delete(oldId)
|
||||
await db.listItems.put({ ...existing, id: newId, pendingSync: false })
|
||||
}
|
||||
|
||||
const affectedQueueEntries = await db.syncQueue
|
||||
.filter((queueEntry) => queueEntry.localListItemId === oldId)
|
||||
.toArray()
|
||||
for (const queueEntry of affectedQueueEntries) {
|
||||
const payload = queueEntry.payload as { list_item_id?: string }
|
||||
await db.syncQueue.update(queueEntry.id!, {
|
||||
localListItemId: newId,
|
||||
payload: payload?.list_item_id
|
||||
? { ...payload, list_item_id: newId }
|
||||
: queueEntry.payload,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function processSyncEntry(entry: SyncQueueEntry) {
|
||||
switch (entry.type) {
|
||||
case 'createList': {
|
||||
const created = await createListApi(entry.payload as { name: string; user_ids?: string[] })
|
||||
if (entry.localListId) {
|
||||
await remapListId(entry.localListId, created.id)
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'createListItem': {
|
||||
// The server is the source of truth for item ids: it returns the
|
||||
// created item (with its own real id) in the response body, so we
|
||||
// remap our client-generated placeholder id to it instead of keeping
|
||||
// the made-up one around.
|
||||
const created = await createListItemApi(entry.payload as { list_id: string; title: string })
|
||||
if (entry.localListItemId) {
|
||||
await remapListItemId(entry.localListItemId, created.id)
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'updateListItem': {
|
||||
await updateListItemApi(
|
||||
entry.payload as { list_item_id: string; title?: string; is_completed?: boolean },
|
||||
)
|
||||
if (entry.localListItemId) {
|
||||
await db.listItems.update(entry.localListItemId, { pendingSync: false })
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'setListItemCompleted': {
|
||||
if (!entry.localListItemId) break
|
||||
await setListItemCompletedApi(
|
||||
entry.localListItemId,
|
||||
entry.payload as { is_completed: boolean },
|
||||
)
|
||||
await db.listItems.update(entry.localListItemId, { pendingSync: false })
|
||||
break
|
||||
}
|
||||
case 'addUserToList': {
|
||||
await addUserToListApi(entry.payload as { list_id: string; user_id: string })
|
||||
break
|
||||
}
|
||||
case 'removeUserFromList': {
|
||||
await removeUserFromListApi(entry.payload as { list_id: string; user_id: string })
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let ongoingSync: Promise<void> | null = null
|
||||
|
||||
async function runSync() {
|
||||
isSyncing.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const queue = await db.syncQueue.orderBy('createdAt').toArray()
|
||||
|
||||
for (const entry of queue) {
|
||||
try {
|
||||
await processSyncEntry(entry)
|
||||
if (entry.id !== undefined) {
|
||||
await db.syncQueue.delete(entry.id)
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Sync failed'
|
||||
if (entry.id !== undefined) {
|
||||
await db.syncQueue.update(entry.id, {
|
||||
attempts: entry.attempts + 1,
|
||||
lastError: message,
|
||||
})
|
||||
}
|
||||
error.value = message
|
||||
// Stop processing further entries to preserve ordering; the next
|
||||
// sync attempt (e.g. triggered by the "online" event) will retry.
|
||||
break
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
isSyncing.value = false
|
||||
await refresh()
|
||||
}
|
||||
}
|
||||
|
||||
// Ensures overlapping calls to sync() (e.g. one triggered automatically by
|
||||
// a mutation while another is triggered by the "online" event) share the
|
||||
// same in-flight run instead of silently no-oping.
|
||||
async function sync(): Promise<void> {
|
||||
if (ongoingSync) {
|
||||
return ongoingSync
|
||||
}
|
||||
if (typeof navigator !== 'undefined' && !navigator.onLine) return
|
||||
|
||||
ongoingSync = runSync().then(() => pullFromServer())
|
||||
try {
|
||||
await ongoingSync
|
||||
} finally {
|
||||
ongoingSync = null
|
||||
}
|
||||
}
|
||||
|
||||
// Pulls the authoritative lists/items from the server and merges them into
|
||||
// local storage. Entries that still have local unsynced changes
|
||||
// (pendingSync) are left untouched so we never clobber pending edits.
|
||||
async function pullFromServer(): Promise<void> {
|
||||
if (typeof navigator !== 'undefined' && !navigator.onLine) return
|
||||
|
||||
try {
|
||||
const serverLists = await getListsApi()
|
||||
|
||||
for (const serverList of serverLists) {
|
||||
const existingList = await db.lists.get(serverList.id)
|
||||
if (!existingList || !existingList.pendingSync) {
|
||||
await db.lists.put({ ...serverList, pendingSync: false })
|
||||
}
|
||||
|
||||
try {
|
||||
const serverItems = await getListItemsApi(serverList.id)
|
||||
for (const serverItem of serverItems) {
|
||||
const existingItem = await db.listItems.get(serverItem.id)
|
||||
if (!existingItem || !existingItem.pendingSync) {
|
||||
await db.listItems.put({ ...serverItem, pendingSync: false })
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore per-list failures so one broken list doesn't block the rest.
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'Failed to load lists from server'
|
||||
} finally {
|
||||
await refresh()
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
lists,
|
||||
listItems,
|
||||
sortedLists,
|
||||
isLoaded,
|
||||
isSyncing,
|
||||
pendingCount,
|
||||
error,
|
||||
itemsForList,
|
||||
loadLists,
|
||||
refresh,
|
||||
createList,
|
||||
createListItem,
|
||||
updateListItem,
|
||||
setListItemCompleted,
|
||||
addUserToList,
|
||||
removeUserFromList,
|
||||
sync,
|
||||
pullFromServer,
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,13 @@
|
||||
export interface LoginPayload {
|
||||
email: string
|
||||
password: string
|
||||
}
|
||||
|
||||
export interface RefreshPayload {
|
||||
refresh_token: string
|
||||
}
|
||||
|
||||
export interface TokenPair {
|
||||
access_token: string
|
||||
refresh_token: string
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
export interface List {
|
||||
id: string
|
||||
name: string
|
||||
created_at?: string
|
||||
modified_at?: string
|
||||
}
|
||||
|
||||
export interface ListItem {
|
||||
id: string
|
||||
list_id: string
|
||||
title: string
|
||||
is_completed: boolean
|
||||
created_at?: string
|
||||
modified_at?: string
|
||||
}
|
||||
|
||||
export interface CreateListPayload {
|
||||
name: string
|
||||
user_ids?: string[]
|
||||
}
|
||||
|
||||
export interface CreateListItemPayload {
|
||||
list_id: string
|
||||
title: string
|
||||
}
|
||||
|
||||
export interface UpdateListItemPayload {
|
||||
list_item_id: string
|
||||
title?: string
|
||||
is_completed?: boolean
|
||||
}
|
||||
|
||||
export interface SetListItemCompletedPayload {
|
||||
is_completed: boolean
|
||||
}
|
||||
|
||||
export interface AddUserToListPayload {
|
||||
list_id: string
|
||||
user_id: string
|
||||
}
|
||||
|
||||
export interface RemoveUserFromListPayload {
|
||||
list_id: string
|
||||
user_id: string
|
||||
}
|
||||
+61
-10
@@ -1,15 +1,66 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from 'vue'
|
||||
import { useListsStore } from '@/stores/lists'
|
||||
|
||||
const listsStore = useListsStore()
|
||||
|
||||
onMounted(() => {
|
||||
listsStore.loadLists()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="about">
|
||||
<h1>This is an about page</h1>
|
||||
</div>
|
||||
<main class="page about">
|
||||
<h1>About dttmr</h1>
|
||||
<p>
|
||||
An offline-first progressive web app for shared lists. Every change you make is saved
|
||||
instantly on this device and pushed to the server as soon as you're back online.
|
||||
</p>
|
||||
|
||||
<section class="card info-card">
|
||||
<h4>Sync status</h4>
|
||||
<p class="row">
|
||||
<span>Pending changes</span>
|
||||
<strong>{{ listsStore.pendingCount }}</strong>
|
||||
</p>
|
||||
<p class="row">
|
||||
<span>Syncing</span>
|
||||
<strong>{{ listsStore.isSyncing ? 'Yes' : 'No' }}</strong>
|
||||
</p>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
@media (min-width: 1024px) {
|
||||
.about {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
<style scoped>
|
||||
.about h1 {
|
||||
font-size: 1.35rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.about p {
|
||||
color: var(--c-text-soft);
|
||||
font-size: 0.9rem;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.info-card {
|
||||
padding: 1rem 1.1rem;
|
||||
}
|
||||
|
||||
.info-card h4 {
|
||||
font-size: 0.85rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 0.85rem;
|
||||
margin-bottom: 0.4rem;
|
||||
color: var(--c-text);
|
||||
}
|
||||
|
||||
.row strong {
|
||||
color: var(--c-heading);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import TheWelcome from '../components/TheWelcome.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main>
|
||||
<TheWelcome />
|
||||
</main>
|
||||
</template>
|
||||
@@ -0,0 +1,192 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useListsStore } from '@/stores/lists'
|
||||
import ListItemRow from '@/components/ListItemRow.vue'
|
||||
|
||||
const props = defineProps<{ id: string }>()
|
||||
|
||||
const router = useRouter()
|
||||
const listsStore = useListsStore()
|
||||
|
||||
const newItemTitle = ref('')
|
||||
const newUserId = ref('')
|
||||
const isAddingItem = ref(false)
|
||||
const itemError = ref('')
|
||||
|
||||
onMounted(() => {
|
||||
listsStore.loadLists()
|
||||
})
|
||||
|
||||
const list = computed(() => listsStore.lists.find((entry) => entry.id === props.id))
|
||||
const items = computed(() => listsStore.itemsForList(props.id))
|
||||
const pendingItems = computed(() => items.value.filter((item) => !item.is_completed))
|
||||
const completedItems = computed(() => items.value.filter((item) => item.is_completed))
|
||||
|
||||
async function handleAddItem() {
|
||||
const title = newItemTitle.value.trim()
|
||||
if (!title) return
|
||||
|
||||
itemError.value = ''
|
||||
isAddingItem.value = true
|
||||
try {
|
||||
await listsStore.createListItem(props.id, title)
|
||||
newItemTitle.value = ''
|
||||
} catch (err) {
|
||||
itemError.value = err instanceof Error ? err.message : 'Failed to add item'
|
||||
} finally {
|
||||
isAddingItem.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAddUser() {
|
||||
const userId = newUserId.value.trim()
|
||||
if (!userId) return
|
||||
await listsStore.addUserToList(props.id, userId)
|
||||
newUserId.value = ''
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="page">
|
||||
<button type="button" class="back-link" @click="router.push('/')">‹ Lists</button>
|
||||
|
||||
<template v-if="list">
|
||||
<h1>{{ list.name }}</h1>
|
||||
<p v-if="list.pendingSync" class="pending-note">This list hasn't synced to the server yet.</p>
|
||||
|
||||
<form class="new-item-form" @submit.prevent="handleAddItem">
|
||||
<div class="field">
|
||||
<input
|
||||
v-model="newItemTitle"
|
||||
type="text"
|
||||
placeholder="Add an item…"
|
||||
:disabled="isAddingItem"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-primary add-btn"
|
||||
:disabled="isAddingItem || !newItemTitle.trim()"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p v-if="itemError" class="banner banner-error">{{ itemError }}</p>
|
||||
|
||||
<section v-if="pendingItems.length > 0" class="card items-card">
|
||||
<ul class="items-list">
|
||||
<ListItemRow v-for="item in pendingItems" :key="item.id" :item="item" />
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section v-if="completedItems.length > 0" class="card items-card completed-card">
|
||||
<h4>Completed</h4>
|
||||
<ul class="items-list">
|
||||
<ListItemRow v-for="item in completedItems" :key="item.id" :item="item" />
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<p v-if="items.length === 0" class="empty-hint">No items yet — add your first one above.</p>
|
||||
|
||||
<section class="card share-card">
|
||||
<h4>Share this list</h4>
|
||||
<form class="add-user-form" @submit.prevent="handleAddUser">
|
||||
<div class="field">
|
||||
<input v-model="newUserId" type="text" placeholder="User ID" />
|
||||
</div>
|
||||
<button type="submit" class="btn btn-secondary">Add</button>
|
||||
</form>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<p v-else class="empty-hint">List not found on this device.</p>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.back-link {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--c-accent-strong);
|
||||
font-size: 0.9rem;
|
||||
padding: 0;
|
||||
margin-bottom: 0.75rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.35rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.pending-note {
|
||||
font-size: 0.8rem;
|
||||
color: var(--c-warning);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.new-item-form {
|
||||
display: flex;
|
||||
gap: 0.6rem;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.new-item-form .field {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.add-btn {
|
||||
width: 46px;
|
||||
flex-shrink: 0;
|
||||
font-size: 1.3rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.items-card {
|
||||
padding: 0.2rem 0.9rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.completed-card h4 {
|
||||
padding: 0.7rem 0.2rem 0;
|
||||
font-size: 0.8rem;
|
||||
color: var(--c-text-soft);
|
||||
}
|
||||
|
||||
.items-list {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.empty-hint {
|
||||
font-size: 0.85rem;
|
||||
color: var(--c-text-soft);
|
||||
text-align: center;
|
||||
padding: 1.5rem 0;
|
||||
}
|
||||
|
||||
.share-card {
|
||||
padding: 1rem 1.1rem;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.share-card h4 {
|
||||
font-size: 0.85rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.add-user-form {
|
||||
display: flex;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.add-user-form .field {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.add-user-form .btn {
|
||||
width: auto;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,117 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useListsStore } from '@/stores/lists'
|
||||
import ListCard from '@/components/ListCard.vue'
|
||||
|
||||
const listsStore = useListsStore()
|
||||
|
||||
const newListName = ref('')
|
||||
const isCreating = ref(false)
|
||||
const createError = ref('')
|
||||
|
||||
onMounted(() => {
|
||||
listsStore.loadLists()
|
||||
})
|
||||
|
||||
async function handleCreateList() {
|
||||
const name = newListName.value.trim()
|
||||
if (!name) return
|
||||
|
||||
createError.value = ''
|
||||
isCreating.value = true
|
||||
try {
|
||||
await listsStore.createList(name)
|
||||
newListName.value = ''
|
||||
} catch (err) {
|
||||
createError.value = err instanceof Error ? err.message : 'Failed to create list'
|
||||
} finally {
|
||||
isCreating.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="page">
|
||||
<h1>Your Lists</h1>
|
||||
<p class="subtitle">Everything is saved on this device and synced when you're online.</p>
|
||||
|
||||
<form class="new-list-form" @submit.prevent="handleCreateList">
|
||||
<div class="field">
|
||||
<input
|
||||
v-model="newListName"
|
||||
type="text"
|
||||
placeholder="New list name…"
|
||||
:disabled="isCreating"
|
||||
/>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary add-btn" :disabled="isCreating || !newListName.trim()">
|
||||
+
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p v-if="createError" class="banner banner-error">{{ createError }}</p>
|
||||
|
||||
<ul v-if="listsStore.sortedLists.length > 0" class="lists">
|
||||
<li v-for="list in listsStore.sortedLists" :key="list.id">
|
||||
<ListCard :list="list" />
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div v-else class="empty-state">
|
||||
<p>No lists yet</p>
|
||||
<p class="empty-hint">Create your first list above to get started.</p>
|
||||
</div>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
h1 {
|
||||
font-size: 1.4rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 0.85rem;
|
||||
color: var(--c-text-soft);
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.new-list-form {
|
||||
display: flex;
|
||||
gap: 0.6rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.new-list-form .field {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.add-btn {
|
||||
width: 46px;
|
||||
flex-shrink: 0;
|
||||
font-size: 1.3rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.lists {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 3rem 1rem;
|
||||
color: var(--c-text-soft);
|
||||
}
|
||||
|
||||
.empty-state p:first-child {
|
||||
color: var(--c-heading);
|
||||
font-weight: 500;
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
|
||||
.empty-hint {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,175 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useListsStore } from '@/stores/lists'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const authStore = useAuthStore()
|
||||
const listsStore = useListsStore()
|
||||
|
||||
const email = ref('')
|
||||
const password = ref('')
|
||||
const localError = ref('')
|
||||
|
||||
async function handleSubmit() {
|
||||
localError.value = ''
|
||||
|
||||
if (!email.value || !password.value) {
|
||||
localError.value = 'Please enter both email and password.'
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await authStore.login({
|
||||
email: email.value,
|
||||
password: password.value,
|
||||
})
|
||||
|
||||
// The initial sync at app boot may have run before the user was
|
||||
// authenticated (e.g. no valid session yet), leaving the lists store
|
||||
// "loaded" with empty/stale data and no further automatic retry. Kick
|
||||
// off a fresh, now-authenticated sync so lists and items actually show
|
||||
// up after logging in.
|
||||
listsStore.sync().catch(() => {})
|
||||
|
||||
const redirect = route.query.redirect
|
||||
router.push(typeof redirect === 'string' && redirect ? redirect : '/')
|
||||
} catch (err) {
|
||||
localError.value = err instanceof Error ? err.message : 'Failed to log in'
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="login-container">
|
||||
<div class="login-card card">
|
||||
<div class="brand-mark">
|
||||
<span class="brand-dot"></span>
|
||||
</div>
|
||||
<h2>Login</h2>
|
||||
<p class="subtitle">Enter your credentials to access your account</p>
|
||||
|
||||
<div v-if="authStore.isAuthenticated" class="already-logged-in">
|
||||
<p>You are already logged in.</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>
|
||||
|
||||
<form v-else @submit.prevent="handleSubmit">
|
||||
<div v-if="localError || authStore.error" class="error-banner banner banner-error">
|
||||
{{ localError || authStore.error }}
|
||||
</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="authStore.isLoading"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="password">Password</label>
|
||||
<input
|
||||
id="password"
|
||||
v-model="password"
|
||||
type="password"
|
||||
placeholder="Enter password"
|
||||
autocomplete="current-password"
|
||||
required
|
||||
:disabled="authStore.isLoading"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary" :disabled="authStore.isLoading">
|
||||
<span v-if="authStore.isLoading">Logging in...</span>
|
||||
<span v-else>Log In</span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.login-container {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
padding: 2rem 1.75rem;
|
||||
}
|
||||
|
||||
.brand-mark {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.brand-dot {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(135deg, var(--c-accent-strong), var(--c-accent-soft));
|
||||
box-shadow: 0 0 24px var(--c-accent-bg);
|
||||
}
|
||||
|
||||
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 {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.already-logged-in 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) {
|
||||
.login-card {
|
||||
padding: 2.5rem 2.25rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,80 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import LoginView from '../LoginView.vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const mockPush = vi.fn<(to: string) => void>()
|
||||
vi.mock('vue-router', () => ({
|
||||
useRouter: () => ({
|
||||
push: mockPush,
|
||||
}),
|
||||
useRoute: () => ({
|
||||
query: {},
|
||||
}),
|
||||
}))
|
||||
|
||||
describe('LoginView', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
localStorage.clear()
|
||||
vi.restoreAllMocks()
|
||||
mockPush.mockClear()
|
||||
})
|
||||
|
||||
it('renders login form with email and password inputs', () => {
|
||||
const wrapper = mount(LoginView)
|
||||
|
||||
expect(wrapper.find('h2').text()).toBe('Login')
|
||||
expect(wrapper.find('input[type="email"]').exists()).toBe(true)
|
||||
expect(wrapper.find('input[type="password"]').exists()).toBe(true)
|
||||
expect(wrapper.find('button[type="submit"]').text()).toBe('Log In')
|
||||
})
|
||||
|
||||
it('submits login form and navigates to default route on success', async () => {
|
||||
const authStore = useAuthStore()
|
||||
const loginSpy = vi.spyOn(authStore, 'login').mockResolvedValueOnce({
|
||||
access_token: 'access-123',
|
||||
refresh_token: 'refresh-456',
|
||||
})
|
||||
|
||||
const wrapper = mount(LoginView)
|
||||
|
||||
await wrapper.find('input[type="email"]').setValue('user@example.com')
|
||||
await wrapper.find('input[type="password"]').setValue('secret123')
|
||||
await wrapper.find('form').trigger('submit.prevent')
|
||||
|
||||
expect(loginSpy).toHaveBeenCalledWith({
|
||||
email: 'user@example.com',
|
||||
password: 'secret123',
|
||||
})
|
||||
expect(mockPush).toHaveBeenCalledWith('/')
|
||||
})
|
||||
|
||||
it('displays error banner when login fails', async () => {
|
||||
const authStore = useAuthStore()
|
||||
vi.spyOn(authStore, 'login').mockRejectedValueOnce(new Error('Invalid email or password'))
|
||||
|
||||
const wrapper = mount(LoginView)
|
||||
|
||||
await wrapper.find('input[type="email"]').setValue('user@example.com')
|
||||
await wrapper.find('input[type="password"]').setValue('wrong-password')
|
||||
await wrapper.find('form').trigger('submit.prevent')
|
||||
|
||||
expect(wrapper.find('.error-banner').exists()).toBe(true)
|
||||
expect(wrapper.find('.error-banner').text()).toContain('Invalid email or password')
|
||||
})
|
||||
|
||||
it('shows logged in status and logout button if already authenticated', () => {
|
||||
const authStore = useAuthStore()
|
||||
authStore.setTokens({
|
||||
access_token: 'active-token',
|
||||
refresh_token: 'active-refresh',
|
||||
})
|
||||
|
||||
const wrapper = mount(LoginView)
|
||||
|
||||
expect(wrapper.find('.already-logged-in').exists()).toBe(true)
|
||||
expect(wrapper.text()).toContain('You are already logged in.')
|
||||
})
|
||||
})
|
||||
+10
-1
@@ -19,7 +19,8 @@ export default defineConfig({
|
||||
name: 'dittmar.dev',
|
||||
short_name: 'dttmr',
|
||||
description: 'dittmar.dev frontend app',
|
||||
theme_color: '#ffffff',
|
||||
theme_color: '#0a0e1a',
|
||||
background_color: '#0a0e1a',
|
||||
display: 'standalone',
|
||||
icons: [
|
||||
{
|
||||
@@ -41,4 +42,12 @@ export default defineConfig({
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8080',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user