Merge pull request 'Created an initial sketch using AI' (#2) from dev into main

This commit was merged in pull request #2.
This commit is contained in:
2026-08-21 17:59:02 +02:00
41 changed files with 13681 additions and 459 deletions
+5
View File
@@ -1,5 +1,10 @@
# dttmr-fe # dttmr-fe
A offline-first progressive web app written with Vue
Including user authorization (using JWT), shared lists and a sandbox
for myself to experiment with Go and TypeScript/Vue.
## Project Setup ## Project Setup
```sh ```sh
+10 -3
View File
@@ -1,10 +1,17 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang=""> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<link rel="icon" href="/favicon.ico"> <link rel="icon" href="/favicon.ico">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <link rel="apple-touch-icon" href="/apple-touch-icon.png">
<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">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="dttmr">
<title>dttmr</title>
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>
+10488
View File
File diff suppressed because it is too large Load Diff
+10 -74
View File
@@ -1,85 +1,21 @@
<script setup lang="ts"> <script setup lang="ts">
import { RouterLink, RouterView } from 'vue-router' import { RouterView } from 'vue-router'
import HelloWorld from './components/HelloWorld.vue' import AppHeader from '@/components/AppHeader.vue'
import BottomNav from '@/components/BottomNav.vue'
</script> </script>
<template> <template>
<header> <div class="app-shell">
<img alt="Vue logo" class="logo" src="@/assets/logo.svg" width="125" height="125" /> <AppHeader />
<div class="wrapper">
<HelloWorld msg="You did it!" />
<nav>
<RouterLink to="/">Home</RouterLink>
<RouterLink to="/about">About</RouterLink>
</nav>
</div>
</header>
<RouterView /> <RouterView />
<BottomNav />
</div>
</template> </template>
<style scoped> <style scoped>
header { .app-shell {
line-height: 1.5; min-height: 100vh;
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; display: flex;
place-items: center; flex-direction: column;
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;
}
} }
</style> </style>
+151
View File
@@ -0,0 +1,151 @@
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')
})
})
})
+152
View File
@@ -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' }),
)
})
})
+182
View File
@@ -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' }),
)
})
})
+56
View File
@@ -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)
}
+84
View File
@@ -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' }),
}
+85
View File
@@ -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'))
}
}
+73 -47
View File
@@ -1,53 +1,39 @@
/* color palette from <https://github.com/vuejs/theme> */ /* dttmr color palette - dark, modern, blueish */
:root { :root {
--vt-c-white: #ffffff; --c-bg: #0a0e1a;
--vt-c-white-soft: #f8f8f8; --c-bg-soft: #101627;
--vt-c-white-mute: #f2f2f2; --c-bg-mute: #161d33;
--c-bg-elevated: #1a2338;
--vt-c-black: #181818; --c-border: rgba(120, 150, 220, 0.16);
--vt-c-black-soft: #222222; --c-border-hover: rgba(120, 150, 220, 0.32);
--vt-c-black-mute: #282828;
--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); --c-accent: #4f7dfa;
--vt-c-divider-light-2: rgba(60, 60, 60, 0.12); --c-accent-soft: #3a5fd9;
--vt-c-divider-dark-1: rgba(84, 84, 84, 0.65); --c-accent-strong: #6c93ff;
--vt-c-divider-dark-2: rgba(84, 84, 84, 0.48); --c-accent-bg: rgba(79, 125, 250, 0.14);
--vt-c-text-light-1: var(--vt-c-indigo); --c-success: #34d399;
--vt-c-text-light-2: rgba(60, 60, 60, 0.66); --c-danger: #f87171;
--vt-c-text-dark-1: var(--vt-c-white); --c-danger-bg: rgba(248, 113, 113, 0.12);
--vt-c-text-dark-2: rgba(235, 235, 235, 0.64); --c-warning: #fbbf24;
}
/* semantic color variables for this project */ --radius-sm: 8px;
:root { --radius-md: 12px;
--color-background: var(--vt-c-white); --radius-lg: 18px;
--color-background-soft: var(--vt-c-white-soft);
--color-background-mute: var(--vt-c-white-mute);
--color-border: var(--vt-c-divider-light-2); --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.4);
--color-border-hover: var(--vt-c-divider-light-1); --shadow-md: 0 8px 24px rgba(0, 0, 0, 0.45);
--color-heading: var(--vt-c-text-light-1); --nav-height: 60px;
--color-text: var(--vt-c-text-light-1); --safe-bottom: env(safe-area-inset-bottom, 0px);
--safe-top: env(safe-area-inset-top, 0px);
--section-gap: 160px; color-scheme: dark;
}
@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);
}
} }
*, *,
@@ -58,14 +44,20 @@
font-weight: normal; font-weight: normal;
} }
ul,
ol {
padding: 0;
}
html {
-webkit-tap-highlight-color: transparent;
}
body { body {
min-height: 100vh; min-height: 100vh;
color: var(--color-text); color: var(--c-text);
background: var(--color-background); background: radial-gradient(circle at top, #101a33 0%, var(--c-bg) 55%);
transition: line-height: 1.55;
color 0.5s,
background-color 0.5s;
line-height: 1.6;
font-family: font-family:
Inter, Inter,
-apple-system, -apple-system,
@@ -84,3 +76,37 @@ body {
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale; -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;
}
+118 -20
View File
@@ -1,35 +1,133 @@
@import './base.css'; @import './base.css';
#app { #app {
max-width: 1280px; min-height: 100vh;
margin: 0 auto;
padding: 2rem;
font-weight: normal; font-weight: normal;
} }
a, .btn {
.green { display: inline-flex;
text-decoration: none; align-items: center;
color: hsla(160, 100%, 37%, 1); justify-content: center;
transition: 0.4s; gap: 0.5rem;
padding: 3px; 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) { .btn:disabled {
a:hover { opacity: 0.55;
background-color: hsla(160, 100%, 37%, 0.2); cursor: not-allowed;
}
} }
@media (min-width: 1024px) { .btn-primary {
body { background: linear-gradient(135deg, var(--c-accent) 0%, var(--c-accent-soft) 100%);
color: #ffffff;
box-shadow: var(--shadow-sm);
}
.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; display: flex;
place-items: center; flex-direction: column;
gap: 0.375rem;
} }
#app { .field label {
display: grid; font-size: 0.8rem;
grid-template-columns: 1fr 1fr; font-weight: 500;
padding: 0 2rem; 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;
} }
} }
+102
View File
@@ -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>
+91
View File
@@ -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>
-41
View File
@@ -1,41 +0,0 @@
<script setup lang="ts">
defineProps<{
msg: string
}>()
</script>
<template>
<div class="greetings">
<h1 class="green">{{ msg }}</h1>
<h3>
Youve 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>
+262
View File
@@ -0,0 +1,262 @@
<script setup lang="ts">
import { computed, ref, onMounted, onUnmounted } from 'vue'
import { RouterLink } from 'vue-router'
import type { LocalList } from '@/database/db'
import { useListsStore } from '@/stores/lists'
const props = defineProps<{ list: LocalList }>()
const emit = defineEmits<{
(e: 'share', list: LocalList): void
}>()
const listsStore = useListsStore()
const isMenuOpen = ref(false)
const menuContainerRef = ref<HTMLElement | null>(null)
const items = computed(() => listsStore.itemsForList(props.list.id))
const completedCount = computed(() => items.value.filter((item) => item.is_completed).length)
function toggleMenu(event: Event) {
event.preventDefault()
event.stopPropagation()
isMenuOpen.value = !isMenuOpen.value
}
function handleShare(event: Event) {
event.preventDefault()
event.stopPropagation()
isMenuOpen.value = false
emit('share', props.list)
}
function handleClickOutside(event: MouseEvent) {
if (menuContainerRef.value && !menuContainerRef.value.contains(event.target as Node)) {
isMenuOpen.value = false
}
}
function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Escape' && isMenuOpen.value) {
isMenuOpen.value = false
}
}
onMounted(() => {
document.addEventListener('click', handleClickOutside)
document.addEventListener('keydown', handleKeydown)
})
onUnmounted(() => {
document.removeEventListener('click', handleClickOutside)
document.removeEventListener('keydown', handleKeydown)
})
</script>
<template>
<div class="list-card card">
<RouterLink :to="`/lists/${list.id}`" class="list-card-link">
<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>
<div ref="menuContainerRef" class="menu-container">
<button
type="button"
class="menu-trigger-btn"
aria-label="List options"
aria-haspopup="true"
:aria-expanded="isMenuOpen"
title="More options"
@click="toggleMenu"
>
<svg class="dots-icon" viewBox="0 0 24 24" width="18" height="18" fill="currentColor">
<circle cx="5" cy="12" r="2" />
<circle cx="12" cy="12" r="2" />
<circle cx="19" cy="12" r="2" />
</svg>
</button>
<div v-if="isMenuOpen" class="submenu-dropdown card" role="menu">
<button type="button" class="submenu-item" role="menuitem" @click="handleShare">
<svg
class="submenu-icon"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle cx="18" cy="5" r="3" />
<circle cx="6" cy="12" r="3" />
<circle cx="18" cy="19" r="3" />
<line x1="8.59" y1="13.51" x2="15.42" y2="17.49" />
<line x1="15.41" y1="6.51" x2="8.59" y2="10.49" />
</svg>
<span>Share list</span>
</button>
</div>
</div>
</div>
</template>
<style scoped>
.list-card {
position: relative;
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
padding: 0.85rem 1rem;
color: inherit;
transition:
border-color 0.15s ease-in-out,
transform 0.1s ease-in-out;
}
.list-card:hover {
border-color: var(--c-border-hover);
}
.list-card-link {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
flex: 1;
min-width: 0;
color: inherit;
text-decoration: none;
}
.list-card-link:active {
transform: scale(0.995);
}
.list-card-main {
flex: 1;
min-width: 0;
}
.list-card-main h3 {
font-size: 1.02rem;
margin-bottom: 0.2rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.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;
line-height: 1;
padding-right: 0.25rem;
}
.menu-container {
position: relative;
display: flex;
align-items: center;
}
.menu-trigger-btn {
background: transparent;
border: 1px solid transparent;
border-radius: var(--radius-sm);
color: var(--c-text-soft);
cursor: pointer;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
transition:
background-color 0.15s ease-in-out,
color 0.15s ease-in-out,
border-color 0.15s ease-in-out;
}
.menu-trigger-btn:hover,
.menu-trigger-btn[aria-expanded='true'] {
background-color: var(--c-bg-mute);
color: var(--c-heading);
border-color: var(--c-border);
}
.dots-icon {
display: block;
}
.submenu-dropdown {
position: absolute;
top: calc(100% + 6px);
right: 0;
z-index: 30;
min-width: 140px;
background-color: var(--c-bg-elevated);
border: 1px solid var(--c-border-hover);
border-radius: var(--radius-md);
box-shadow: var(--shadow-md);
padding: 0.35rem;
animation: dropdownIn 0.12s ease-out;
}
.submenu-item {
display: flex;
align-items: center;
gap: 0.6rem;
width: 100%;
padding: 0.5rem 0.65rem;
background: transparent;
border: none;
border-radius: var(--radius-sm);
color: var(--c-heading);
font-size: 0.85rem;
cursor: pointer;
text-align: left;
transition:
background-color 0.15s ease-in-out,
color 0.15s ease-in-out;
}
.submenu-item:hover {
background-color: var(--c-bg-mute);
color: var(--c-accent-strong);
}
.submenu-icon {
width: 15px;
height: 15px;
flex-shrink: 0;
}
@keyframes dropdownIn {
from {
opacity: 0;
transform: translateY(-4px) scale(0.96);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
</style>
+119
View File
@@ -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>
-95
View File
@@ -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>
Vues
<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>
-87
View File
@@ -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')
})
})
-7
View File
@@ -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>
-7
View File
@@ -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>
-7
View File
@@ -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>
-19
View File
@@ -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>
+41 -6
View File
@@ -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({ export interface LocalListItem extends ListItem {
lists: '++id, uuid, title', 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()
+9
View File
@@ -5,6 +5,7 @@ import { createPinia } from 'pinia'
import App from './App.vue' import App from './App.vue'
import router from './router' import router from './router'
import { useListsStore } from './stores/lists'
const app = createApp(App) const app = createApp(App)
@@ -12,3 +13,11 @@ app.use(createPinia())
app.use(router) app.use(router)
app.mount('#app') 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
View File
@@ -1,13 +1,27 @@
import { createRouter, createWebHistory } from 'vue-router' 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({ const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL), history: createWebHistory(import.meta.env.BASE_URL),
routes: [ routes: [
{ {
path: '/', path: '/',
name: 'home', name: 'lists',
component: HomeView, 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', 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 export default router
+191
View File
@@ -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()
})
})
+265
View File
@@ -0,0 +1,265 @@
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<() => Promise<unknown>>(),
createListApi: vi.fn<() => Promise<unknown>>(),
getListItemsApi: vi.fn<() => Promise<unknown>>(),
createListItemApi: vi.fn<() => Promise<unknown>>(),
updateListItemApi: vi.fn<() => Promise<unknown>>(),
setListItemCompletedApi: vi.fn<() => Promise<unknown>>(),
addUserToListApi: vi.fn<() => Promise<unknown>>(),
removeUserFromListApi: vi.fn<() => Promise<unknown>>(),
}))
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)
})
})
+99
View File
@@ -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,
}
})
+374
View File
@@ -0,0 +1,374 @@
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,
}
})
+13
View File
@@ -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
}
+45
View File
@@ -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
}
+60 -9
View File
@@ -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> <template>
<div class="about"> <main class="page about">
<h1>This is an about page</h1> <h1>About dttmr</h1>
</div> <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> </template>
<style> <style scoped>
@media (min-width: 1024px) { .about h1 {
.about { font-size: 1.35rem;
min-height: 100vh; margin-bottom: 0.75rem;
display: flex;
align-items: center;
} }
.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> </style>
-9
View File
@@ -1,9 +0,0 @@
<script setup lang="ts">
import TheWelcome from '../components/TheWelcome.vue'
</script>
<template>
<main>
<TheWelcome />
</main>
</template>
+152
View File
@@ -0,0 +1,152 @@
<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 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
}
}
</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>
</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;
padding: 0;
margin: 0;
}
.empty-hint {
font-size: 0.85rem;
color: var(--c-text-soft);
text-align: center;
padding: 1.5rem 0;
}
</style>
+136
View File
@@ -0,0 +1,136 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useListsStore } from '@/stores/lists'
import type { LocalList } from '@/database/db'
import ListCard from '@/components/ListCard.vue'
import ShareListModal from '@/components/ShareListModal.vue'
const listsStore = useListsStore()
const newListName = ref('')
const isCreating = ref(false)
const createError = ref('')
const sharingList = ref<LocalList | null>(null)
onMounted(() => {
listsStore.loadLists()
})
function handleOpenShare(list: LocalList) {
sharingList.value = list
}
function handleCloseShare() {
sharingList.value = null
}
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>
<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" @share="handleOpenShare(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>
<ShareListModal v-if="sharingList" :list="sharingList" @close="handleCloseShare" />
</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;
list-style: none;
padding: 0;
margin: 0;
}
.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>
+175
View File
@@ -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>
+80
View File
@@ -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.')
})
})
+20 -1
View File
@@ -12,14 +12,19 @@ export default defineConfig({
vueDevTools(), vueDevTools(),
VitePWA({ VitePWA({
registerType: 'autoUpdate', registerType: 'autoUpdate',
includeAssets: ['favicon.ico', 'apple-touch-icon.png'],
workbox: { workbox: {
globPatterns: ['**/*.{js,css,html,ico,png,svg}'] globPatterns: ['**/*.{js,css,html,ico,png,svg}']
}, },
manifest: { manifest: {
id: '/',
name: 'dittmar.dev', name: 'dittmar.dev',
short_name: 'dttmr', short_name: 'dttmr',
description: 'dittmar.dev frontend app', description: 'dittmar.dev frontend app',
theme_color: '#ffffff', start_url: '/',
scope: '/',
theme_color: '#0a0e1a',
background_color: '#0a0e1a',
display: 'standalone', display: 'standalone',
icons: [ icons: [
{ {
@@ -31,6 +36,12 @@ export default defineConfig({
src: 'pwa-512x512.png', src: 'pwa-512x512.png',
sizes: '512x512', sizes: '512x512',
type: 'image/png', type: 'image/png',
},
{
src: 'maskable-icon-512x512.png',
sizes: '512x512',
type: 'image/png',
purpose: 'maskable',
} }
] ]
} }
@@ -41,4 +52,12 @@ export default defineConfig({
'@': fileURLToPath(new URL('./src', import.meta.url)), '@': fileURLToPath(new URL('./src', import.meta.url)),
}, },
}, },
server: {
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true,
},
},
},
}) })