feat: initial sketch with AI

This commit is contained in:
2026-08-21 17:09:32 +02:00
parent d3fe34cb22
commit 10aefdd655
40 changed files with 13491 additions and 459 deletions
+61 -10
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>
<div class="about">
<h1>This is an about page</h1>
</div>
<main class="page about">
<h1>About dttmr</h1>
<p>
An offline-first progressive web app for shared lists. Every change you make is saved
instantly on this device and pushed to the server as soon as you're back online.
</p>
<section class="card info-card">
<h4>Sync status</h4>
<p class="row">
<span>Pending changes</span>
<strong>{{ listsStore.pendingCount }}</strong>
</p>
<p class="row">
<span>Syncing</span>
<strong>{{ listsStore.isSyncing ? 'Yes' : 'No' }}</strong>
</p>
</section>
</main>
</template>
<style>
@media (min-width: 1024px) {
.about {
min-height: 100vh;
display: flex;
align-items: center;
}
<style scoped>
.about h1 {
font-size: 1.35rem;
margin-bottom: 0.75rem;
}
.about p {
color: var(--c-text-soft);
font-size: 0.9rem;
margin-bottom: 1.25rem;
}
.info-card {
padding: 1rem 1.1rem;
}
.info-card h4 {
font-size: 0.85rem;
margin-bottom: 0.75rem;
}
.row {
display: flex;
justify-content: space-between;
font-size: 0.85rem;
margin-bottom: 0.4rem;
color: var(--c-text);
}
.row strong {
color: var(--c-heading);
}
</style>
-9
View File
@@ -1,9 +0,0 @@
<script setup lang="ts">
import TheWelcome from '../components/TheWelcome.vue'
</script>
<template>
<main>
<TheWelcome />
</main>
</template>
+192
View File
@@ -0,0 +1,192 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useListsStore } from '@/stores/lists'
import ListItemRow from '@/components/ListItemRow.vue'
const props = defineProps<{ id: string }>()
const router = useRouter()
const listsStore = useListsStore()
const newItemTitle = ref('')
const newUserId = ref('')
const isAddingItem = ref(false)
const itemError = ref('')
onMounted(() => {
listsStore.loadLists()
})
const list = computed(() => listsStore.lists.find((entry) => entry.id === props.id))
const items = computed(() => listsStore.itemsForList(props.id))
const pendingItems = computed(() => items.value.filter((item) => !item.is_completed))
const completedItems = computed(() => items.value.filter((item) => item.is_completed))
async function handleAddItem() {
const title = newItemTitle.value.trim()
if (!title) return
itemError.value = ''
isAddingItem.value = true
try {
await listsStore.createListItem(props.id, title)
newItemTitle.value = ''
} catch (err) {
itemError.value = err instanceof Error ? err.message : 'Failed to add item'
} finally {
isAddingItem.value = false
}
}
async function handleAddUser() {
const userId = newUserId.value.trim()
if (!userId) return
await listsStore.addUserToList(props.id, userId)
newUserId.value = ''
}
</script>
<template>
<main class="page">
<button type="button" class="back-link" @click="router.push('/')"> Lists</button>
<template v-if="list">
<h1>{{ list.name }}</h1>
<p v-if="list.pendingSync" class="pending-note">This list hasn't synced to the server yet.</p>
<form class="new-item-form" @submit.prevent="handleAddItem">
<div class="field">
<input
v-model="newItemTitle"
type="text"
placeholder="Add an item…"
:disabled="isAddingItem"
/>
</div>
<button
type="submit"
class="btn btn-primary add-btn"
:disabled="isAddingItem || !newItemTitle.trim()"
>
+
</button>
</form>
<p v-if="itemError" class="banner banner-error">{{ itemError }}</p>
<section v-if="pendingItems.length > 0" class="card items-card">
<ul class="items-list">
<ListItemRow v-for="item in pendingItems" :key="item.id" :item="item" />
</ul>
</section>
<section v-if="completedItems.length > 0" class="card items-card completed-card">
<h4>Completed</h4>
<ul class="items-list">
<ListItemRow v-for="item in completedItems" :key="item.id" :item="item" />
</ul>
</section>
<p v-if="items.length === 0" class="empty-hint">No items yet add your first one above.</p>
<section class="card share-card">
<h4>Share this list</h4>
<form class="add-user-form" @submit.prevent="handleAddUser">
<div class="field">
<input v-model="newUserId" type="text" placeholder="User ID" />
</div>
<button type="submit" class="btn btn-secondary">Add</button>
</form>
</section>
</template>
<p v-else class="empty-hint">List not found on this device.</p>
</main>
</template>
<style scoped>
.back-link {
background: none;
border: none;
color: var(--c-accent-strong);
font-size: 0.9rem;
padding: 0;
margin-bottom: 0.75rem;
cursor: pointer;
}
h1 {
font-size: 1.35rem;
margin-bottom: 0.5rem;
}
.pending-note {
font-size: 0.8rem;
color: var(--c-warning);
margin-bottom: 1rem;
}
.new-item-form {
display: flex;
gap: 0.6rem;
margin: 1rem 0;
}
.new-item-form .field {
flex: 1;
}
.add-btn {
width: 46px;
flex-shrink: 0;
font-size: 1.3rem;
line-height: 1;
}
.items-card {
padding: 0.2rem 0.9rem;
margin-bottom: 1rem;
}
.completed-card h4 {
padding: 0.7rem 0.2rem 0;
font-size: 0.8rem;
color: var(--c-text-soft);
}
.items-list {
list-style: none;
}
.empty-hint {
font-size: 0.85rem;
color: var(--c-text-soft);
text-align: center;
padding: 1.5rem 0;
}
.share-card {
padding: 1rem 1.1rem;
margin-top: 1.5rem;
}
.share-card h4 {
font-size: 0.85rem;
margin-bottom: 0.75rem;
}
.add-user-form {
display: flex;
gap: 0.6rem;
}
.add-user-form .field {
flex: 1;
}
.add-user-form .btn {
width: auto;
flex-shrink: 0;
}
</style>
+117
View File
@@ -0,0 +1,117 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useListsStore } from '@/stores/lists'
import ListCard from '@/components/ListCard.vue'
const listsStore = useListsStore()
const newListName = ref('')
const isCreating = ref(false)
const createError = ref('')
onMounted(() => {
listsStore.loadLists()
})
async function handleCreateList() {
const name = newListName.value.trim()
if (!name) return
createError.value = ''
isCreating.value = true
try {
await listsStore.createList(name)
newListName.value = ''
} catch (err) {
createError.value = err instanceof Error ? err.message : 'Failed to create list'
} finally {
isCreating.value = false
}
}
</script>
<template>
<main class="page">
<h1>Your Lists</h1>
<p class="subtitle">Everything is saved on this device and synced when you're online.</p>
<form class="new-list-form" @submit.prevent="handleCreateList">
<div class="field">
<input
v-model="newListName"
type="text"
placeholder="New list name…"
:disabled="isCreating"
/>
</div>
<button type="submit" class="btn btn-primary add-btn" :disabled="isCreating || !newListName.trim()">
+
</button>
</form>
<p v-if="createError" class="banner banner-error">{{ createError }}</p>
<ul v-if="listsStore.sortedLists.length > 0" class="lists">
<li v-for="list in listsStore.sortedLists" :key="list.id">
<ListCard :list="list" />
</li>
</ul>
<div v-else class="empty-state">
<p>No lists yet</p>
<p class="empty-hint">Create your first list above to get started.</p>
</div>
</main>
</template>
<style scoped>
h1 {
font-size: 1.4rem;
margin-bottom: 0.25rem;
}
.subtitle {
font-size: 0.85rem;
color: var(--c-text-soft);
margin-bottom: 1.25rem;
}
.new-list-form {
display: flex;
gap: 0.6rem;
margin-bottom: 1rem;
}
.new-list-form .field {
flex: 1;
}
.add-btn {
width: 46px;
flex-shrink: 0;
font-size: 1.3rem;
line-height: 1;
}
.lists {
display: flex;
flex-direction: column;
gap: 0.6rem;
}
.empty-state {
text-align: center;
padding: 3rem 1rem;
color: var(--c-text-soft);
}
.empty-state p:first-child {
color: var(--c-heading);
font-weight: 500;
margin-bottom: 0.35rem;
}
.empty-hint {
font-size: 0.85rem;
}
</style>
+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.')
})
})