APP als Hybrid Version - Anbindung an API

This commit is contained in:
Kevin Adametz 2026-06-05 09:54:12 +02:00
parent d054732bf5
commit c1514999be
46 changed files with 3418 additions and 196 deletions

View file

@ -183,7 +183,7 @@
:key="item.id"
class="event-panel__media-item"
>
<img :src="item.src" class="event-panel__media-img" alt="" />
<img :src="mediaDisplaySrc(item)" class="event-panel__media-img" alt="" />
<button
type="button"
class="event-panel__media-remove"
@ -221,7 +221,7 @@
flat
dense
no-caps
label="Event löschen"
label="Event entfernen"
icon="delete_outline"
color="negative"
size="sm"
@ -278,7 +278,7 @@
:name="index"
class="event-panel__gallery-slide"
>
<img :src="item.src" class="event-panel__gallery-img" alt="" />
<img :src="mediaDisplaySrc(item)" class="event-panel__gallery-img" alt="" />
</q-carousel-slide>
</q-carousel>
</div>
@ -365,6 +365,7 @@ const mediaInputRef = ref(null)
const removeKeyImageDialogOpen = ref(false)
const mediaGalleryOpen = ref(false)
const mediaGalleryIndex = ref(0)
const mediaSrcById = ref({})
const galleryItems = computed(() => {
const items = []
if (eventsStore.ghostImage) {
@ -379,6 +380,18 @@ const galleryItems = computed(() => {
return [...items, ...eventsStore.ghostMedia]
})
watch(
() => eventsStore.ghostMedia,
async (mediaItems) => {
const entries = await Promise.all((mediaItems || []).map(async (item) => [
item.id,
await resolveFullRes(item.previewUrl || item.thumbnailUrl || item.src)
]))
mediaSrcById.value = Object.fromEntries(entries.filter(([, src]) => Boolean(src)))
},
{ deep: true, immediate: true }
)
function openKeyImageUpload() {
keyImageInputRef.value?.click()
}
@ -427,6 +440,11 @@ async function onKeyImageSelected(event) {
if (!file) return
try {
const uploaded = await eventsStore.uploadGhostKeyImage(file)
if (uploaded) {
return
}
const sourceDataUrl = await readImageAsDataUrl(file)
eventsStore.ghostImage = await optimizeImageDataUrl(sourceDataUrl)
if (!eventsStore.ghostKeyImageTitle) {
@ -449,6 +467,11 @@ async function onMediaSelected(event) {
if (files.length === 0) return
try {
const uploaded = await eventsStore.uploadGhostMedia(files)
if (uploaded.length > 0) {
return
}
const images = await Promise.all(files.map(async (file) => ({
id: crypto.randomUUID(),
type: 'image',
@ -468,7 +491,12 @@ async function onMediaSelected(event) {
}
}
function removeMediaImage(id) {
function mediaDisplaySrc(item) {
return mediaSrcById.value[item.id] || item.previewUrl || item.thumbnailUrl || item.src
}
async function removeMediaImage(id) {
await eventsStore.deleteGhostMedia(id)
eventsStore.ghostMedia = eventsStore.ghostMedia.filter(item => item.id !== id)
eventsStore.saveGhostNow()
if (mediaGalleryIndex.value >= galleryItems.value.length) {
@ -496,7 +524,8 @@ function confirmRemoveKeyImage() {
removeKeyImageDialogOpen.value = true
}
function removeKeyImage() {
async function removeKeyImage() {
await eventsStore.deleteGhostKeyImage()
eventsStore.ghostImage = null
eventsStore.ghostKeyImageTitle = ''
keyImageSrc.value = null

View file

@ -262,7 +262,7 @@
<button
class="lw-settings__img-btn"
:class="{ 'lw-settings__img-btn--active': fl.backgroundImage === '' }"
@click="update({ backgroundImage: '' })"
@click="settingsStore.clearBackgroundImage()"
>
Keins
</button>
@ -485,7 +485,7 @@ const selectedPresetId = computed({
})
const isCustomBackground = computed(() => {
const bg = fl.value.backgroundImage ?? ''
return bg.startsWith('data:image/')
return bg.startsWith('data:image/') || bg.startsWith('/settings/media/')
})
const HORIZON_MODES = [
@ -576,6 +576,11 @@ async function onBackgroundUpload(event) {
if (!file) return
try {
if (settingsStore.uploadBackgroundImage && settingsStore.floatingLines.backgroundImage !== undefined) {
const uploaded = await settingsStore.uploadBackgroundImage(file)
if (uploaded) return
}
const sourceDataUrl = await readImageAsDataUrl(file)
const optimized = await optimizeImageDataUrl(sourceDataUrl)
update({ backgroundImage: optimized })

View file

@ -96,7 +96,7 @@
</div>
<div class="user-menu__info">
<div class="user-menu__name user-menu__name--sm">{{ authStore.currentUser?.name ?? 'Demo User' }}</div>
<div class="user-menu__plan">Demo Account</div>
<div class="user-menu__plan">Demo Account · v{{ APP_VERSION }}</div>
</div>
</div>
</div>
@ -106,6 +106,7 @@
<script setup>
import { ref } from 'vue'
import { useAuthStore } from 'stores/auth'
import { APP_VERSION } from 'src/config/appVersion'
defineProps({ open: { type: Boolean, default: false } })
defineEmits(['close', 'navigate'])

View file

@ -1,5 +1,6 @@
import { ref, unref, watch } from 'vue'
import { db } from 'src/db'
import { apiFetch } from 'src/services/apiClient'
const THUMB_SIZE = 200
@ -7,6 +8,10 @@ const THUMB_SIZE = 200
// Shared across all component instances
const memoryCache = new Map()
function isProtectedApiImageUrl(imageUrl) {
return imageUrl.startsWith('/event-media/') || imageUrl.startsWith('/settings/media/')
}
/**
* Create a thumbnail (THUMB_SIZE x THUMB_SIZE) from a source image blob.
* Returns a new Blob (JPEG, quality 0.8).
@ -51,7 +56,9 @@ function createThumbnail(blob) {
* Fetch an image from URL, cache thumbnail in IndexedDB, return blob URL.
*/
async function fetchAndCache(imageUrl, eventId) {
const response = await fetch(imageUrl)
const response = isProtectedApiImageUrl(imageUrl)
? await apiFetch(imageUrl)
: await fetch(imageUrl)
if (!response.ok) throw new Error(`Fetch failed: ${response.status}`)
const blob = await response.blob()
@ -159,6 +166,20 @@ export function useImageCache(imageUrl, eventId) {
export async function resolveFullRes(imageUrl) {
if (!imageUrl) return null
if (isProtectedApiImageUrl(imageUrl)) {
if (memoryCache.has(imageUrl)) return memoryCache.get(imageUrl)
try {
const response = await apiFetch(imageUrl)
if (!response.ok) throw new Error(`Fetch failed: ${response.status}`)
const blobUrl = URL.createObjectURL(await response.blob())
memoryCache.set(imageUrl, blobUrl)
return blobUrl
} catch (error) {
console.warn('Full-res image load failed:', error)
}
}
// If online, return original URL (browser caches via HTTP headers)
if (navigator.onLine) return imageUrl

View file

@ -0,0 +1,2 @@
export const APP_VERSION = '0.0.1'

View file

@ -25,7 +25,7 @@
:lines-gradient="parsedGradient"
:bg-color-center="fl.bgCenter"
:bg-color-edge="fl.bgEdge"
:background-image="fl.backgroundImage"
:background-image="resolvedBackgroundImage"
:mix-blend-mode="fl.lineMode === 'static' ? 'normal' : 'screen'"
:horizon-mode="fl.horizonMode ?? 'off'"
:horizon-opacity="fl.horizonOpacity ?? 0.5"
@ -147,7 +147,7 @@
</template>
<script setup>
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue'
import { useQuasar } from 'quasar'
import { useRouter } from 'vue-router'
import AddEventButton from 'components/AddEventButton.vue'
@ -161,6 +161,7 @@ import ZoomControl from 'components/ZoomControl.vue'
import { useAuthStore } from 'stores/auth'
import { useEventsStore } from 'stores/events'
import { useSettingsStore } from 'stores/settings'
import { resolveFullRes } from 'composables/useImageCache'
const $q = useQuasar()
const router = useRouter()
@ -173,6 +174,31 @@ const userMenuOpen = ref(false)
const appSettingsOpen = ref(false)
const floatingLinesRef = ref(null)
const fl = computed(() => settingsStore.floatingLines)
const resolvedBackgroundImage = ref('')
let backgroundRequestId = 0
watch(
() => fl.value.backgroundImage,
async (backgroundImage) => {
const requestId = ++backgroundRequestId
if (!backgroundImage) {
resolvedBackgroundImage.value = ''
return
}
if (!backgroundImage.startsWith('/settings/media/')) {
resolvedBackgroundImage.value = backgroundImage
return
}
const resolved = await resolveFullRes(backgroundImage)
if (requestId === backgroundRequestId) {
resolvedBackgroundImage.value = resolved || ''
}
},
{ immediate: true }
)
// Timeline view ref (for direct scroll access in render loop)
const timelineViewRef = ref(null)

View file

@ -6,15 +6,17 @@
<h1>Login</h1>
<p>Welcome back. Wähle einen Demo-User und arbeite mit eigenen Events und Settings.</p>
<label class="login-field">
<span>E-Mail</span>
<select v-model="email">
<option v-for="user in authStore.users" :key="user.id" :value="user.email">
{{ user.email }}
</option>
</select>
<q-icon name="person_outline" size="22px" />
</label>
<div class="login-user-row">
<label class="login-field login-field--user">
<span>E-Mail</span>
<select v-model="email">
<option v-for="user in authStore.users" :key="user.id" :value="user.email">
{{ user.mode === 'local' ? user.name : user.email }}
</option>
</select>
<q-icon name="person_outline" size="22px" />
</label>
</div>
<label class="login-field">
<span>Passwort</span>
@ -22,6 +24,8 @@
v-model="password"
:type="showPassword ? 'text' : 'password'"
autocomplete="current-password"
:disabled="isLocalDemo"
:placeholder="isLocalDemo ? 'Für Demo nicht nötig' : ''"
>
<button class="login-field__icon-btn" type="button" @click="showPassword = !showPassword">
<q-icon :name="showPassword ? 'visibility_off' : 'visibility'" size="22px" />
@ -35,17 +39,19 @@
<div v-if="authStore.lastError" class="login-error">{{ authStore.lastError }}</div>
<button class="login-submit" type="submit">Login</button>
<button class="login-submit" type="submit" :disabled="isSubmitting">
{{ submitLabel }}
</button>
<div class="login-card__hint">
Demo-Accounts: <strong>user1-user5@thats-me.app</strong>, Passwort <strong>pass</strong>
Demo bleibt lokal im Browser. API-Accounts: <strong>user1-user6@thats-me.app</strong>, Passwort <strong>pass</strong>.
</div>
</form>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { computed, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useAuthStore } from 'stores/auth'
@ -57,9 +63,27 @@ const email = ref(authStore.users[0]?.email ?? '')
const password = ref('pass')
const showPassword = ref(false)
const remember = ref(true)
const isSubmitting = ref(false)
const selectedUser = computed(() => authStore.users.find(user => user.email === email.value) ?? null)
const isLocalDemo = computed(() => selectedUser.value?.mode === 'local')
const submitLabel = computed(() => {
if (isSubmitting.value) return isLocalDemo.value ? 'Demo startet...' : 'Login läuft...'
return isLocalDemo.value ? 'Demo lokal starten' : 'Login'
})
watch(isLocalDemo, (localDemo) => {
password.value = localDemo ? '' : 'pass'
}, { immediate: true })
async function onSubmit() {
if (isSubmitting.value) return
isSubmitting.value = true
const isLoggedIn = await authStore.login(email.value, password.value)
isSubmitting.value = false
if (!isLoggedIn) return
function onSubmit() {
if (!authStore.login(email.value, password.value)) return
router.replace(typeof route.query.redirect === 'string' ? route.query.redirect : '/')
}
</script>
@ -135,6 +159,16 @@ function onSubmit() {
margin-bottom: 16px;
}
.login-user-row {
display: flex;
align-items: flex-end;
gap: 10px;
}
.login-field--user {
flex: 1;
}
.login-field span {
display: block;
margin: 0 0 6px 2px;

View file

@ -1,7 +1,7 @@
import { defineRouter } from '#q-app/wrappers'
import { createRouter, createMemoryHistory, createWebHistory, createWebHashHistory } from 'vue-router'
import routes from './routes'
import { AUTH_STORAGE_KEY, DEMO_USERS } from 'stores/auth'
import { hasStoredAuth } from 'stores/auth'
/*
* If not building with SSR mode, you can
@ -28,16 +28,7 @@ export default defineRouter(function (/* { store, ssrContext } */) {
})
Router.beforeEach((to) => {
const stored = localStorage.getItem(AUTH_STORAGE_KEY)
let userId = null
try {
userId = stored ? JSON.parse(stored)?.userId ?? null : null
} catch {
userId = null
}
const isAuthenticated = DEMO_USERS.some(user => user.id === userId)
const isAuthenticated = hasStoredAuth()
if (to.meta.requiresAuth && !isAuthenticated) {
return { path: '/login', query: { redirect: to.fullPath } }

View file

@ -0,0 +1,136 @@
import { db } from 'src/db'
function normalizeApiBase(base) {
const trimmedBase = String(base).replace(/\/+$/, '')
if (/^https?:\/\/api\.thats-me\.(app|test)$/i.test(trimmedBase)) {
return `${trimmedBase}/api`
}
return trimmedBase
}
function resolveApiBase() {
if (import.meta.env.VITE_API_BASE) {
return normalizeApiBase(import.meta.env.VITE_API_BASE)
}
if (typeof window !== 'undefined' && window.location.hostname === 'app.thats-me.test') {
return normalizeApiBase('https://api.thats-me.test')
}
if (typeof window !== 'undefined' && window.location.hostname.endsWith('thats-me.app')) {
return normalizeApiBase('https://api.thats-me.app')
}
if (import.meta.env.PROD) {
return normalizeApiBase('https://api.thats-me.app')
}
return normalizeApiBase('/api')
}
export const API_BASE = resolveApiBase()
export class ApiError extends Error {
constructor(message, { status = 0, errors = null } = {}) {
super(message)
this.name = 'ApiError'
this.status = status
this.errors = errors
}
}
export async function getToken() {
try {
const meta = await db.meta.get('accessToken')
return meta?.value || null
} catch {
return null
}
}
export async function setToken(token) {
if (!token) {
await clearToken()
return
}
await db.meta.put({ key: 'accessToken', value: token })
}
export async function clearToken() {
await db.meta.delete('accessToken')
}
async function readResponsePayload(response) {
if (response.status === 204) return null
const text = await response.text()
if (!text) return null
try {
return JSON.parse(text)
} catch {
return text
}
}
export async function apiFetch(path, options = {}) {
const { auth = true, body, headers = {}, ...fetchOptions } = options
const token = auth ? await getToken() : null
if (auth && !token) {
throw new ApiError('Nicht angemeldet.', { status: 401 })
}
const isFormData = typeof FormData !== 'undefined' && body instanceof FormData
const requestUrl = `${API_BASE}${path}`
if (import.meta.env.DEV) {
console.debug('API request:', requestUrl)
}
const response = await fetch(requestUrl, {
...fetchOptions,
body: body && !isFormData && typeof body !== 'string' ? JSON.stringify(body) : body,
headers: {
Accept: 'application/json',
...(!isFormData ? { 'Content-Type': 'application/json' } : {}),
...(token ? { Authorization: `Bearer ${token}` } : {}),
...headers
}
})
if (response.status === 401 && auth) {
await clearToken()
}
return response
}
export async function apiJson(path, options = {}) {
const response = await apiFetch(path, options)
const payload = await readResponsePayload(response)
if (!response.ok) {
throw new ApiError(payload?.message || 'Die API-Anfrage ist fehlgeschlagen.', {
status: response.status,
errors: payload?.errors ?? null
})
}
return payload
}
export async function loginRemote(email, password) {
return apiJson('/login', {
method: 'POST',
auth: false,
body: { email, password }
})
}
export async function logoutRemote() {
return apiJson('/logout', { method: 'POST' })
}

View file

@ -1,8 +1,6 @@
import { ref } from 'vue'
import { db } from 'src/db'
// API base URL — configured per environment
const API_BASE = import.meta.env.VITE_API_BASE || '/api'
import { apiFetch, getToken, setToken } from 'src/services/apiClient'
const isSyncing = ref(false)
const isOnline = ref(navigator.onLine)
@ -17,51 +15,6 @@ window.addEventListener('offline', () => {
isOnline.value = false
})
/**
* Get the stored OAuth access token.
*/
async function getToken() {
try {
const meta = await db.meta.get('accessToken')
return meta?.value || null
} catch {
return null
}
}
/**
* Store an OAuth access token.
*/
async function setToken(token) {
await db.meta.put({ key: 'accessToken', value: token })
}
/**
* Authenticated fetch wrapper.
*/
async function apiFetch(path, options = {}) {
const token = await getToken()
if (!token) throw new Error('Not authenticated')
const response = await fetch(`${API_BASE}${path}`, {
...options,
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
Authorization: `Bearer ${token}`,
...options.headers,
},
})
if (response.status === 401) {
// Token expired — clear it
await db.meta.delete('accessToken')
throw new Error('Unauthorized')
}
return response
}
/**
* Process the outbound sync queue (FIFO).
* Called on app start, every 30s when online, and on reconnect.

View file

@ -1,41 +1,98 @@
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
import { clearToken, loginRemote, logoutRemote, setToken } from 'src/services/apiClient'
export const AUTH_STORAGE_KEY = 'thatsme-auth'
export const DEMO_USERS = Array.from({ length: 5 }, (_, index) => {
export const LOCAL_DEMO_USER = {
id: 'local-demo',
email: 'demo@local',
password: '',
name: 'Demo',
avatar: 'D',
mode: 'local',
seedDemoEvents: false
}
export const REMOTE_USERS = Array.from({ length: 6 }, (_, index) => {
const number = index + 1
return {
id: `demo-user-${number}`,
id: `remote-user-${number}`,
email: `user${number}@thats-me.app`,
password: 'pass',
name: `User ${number}`,
avatar: `U${number}`
avatar: `U${number}`,
mode: 'remote',
seedDemoEvents: false
}
})
function loadStoredUserId() {
function normalizeUser(user) {
const mode = user.mode || 'local'
return {
id: String(user.id),
email: String(user.email).trim().toLowerCase(),
password: mode === 'local' ? '' : String(user.password || 'pass'),
name: String(user.name || user.email),
avatar: String(user.avatar || '?').slice(0, 3).toUpperCase(),
mode,
seedDemoEvents: false
}
}
function normalizeRemoteUser(user) {
return {
id: String(user.id),
email: String(user.email).trim().toLowerCase(),
name: String(user.name || user.email),
avatar: String(user.avatar || '?').slice(0, 3).toUpperCase(),
mode: 'remote',
seedDemoEvents: false
}
}
export function getAvailableUsers() {
return [normalizeUser(LOCAL_DEMO_USER), ...REMOTE_USERS]
}
function loadStoredAuth() {
try {
const stored = localStorage.getItem(AUTH_STORAGE_KEY)
return stored ? JSON.parse(stored)?.userId ?? null : null
return stored ? JSON.parse(stored) : null
} catch {
return null
}
}
export function hasStoredAuth() {
const stored = loadStoredAuth()
return Boolean(stored?.user || stored?.userId)
}
function persistAuth(user) {
localStorage.setItem(AUTH_STORAGE_KEY, JSON.stringify({
userId: user.id,
mode: user.mode,
user
}))
}
export const useAuthStore = defineStore('auth', () => {
const currentUserId = ref(loadStoredUserId())
const storedAuth = loadStoredAuth()
const currentUserId = ref(storedAuth?.user?.id ?? storedAuth?.userId ?? null)
const currentUserProfile = ref(storedAuth?.user ? normalizeUser(storedAuth.user) : null)
const lastError = ref('')
const users = ref(getAvailableUsers())
const currentUser = computed(() =>
DEMO_USERS.find(user => user.id === currentUserId.value) ?? null
currentUserProfile.value ?? users.value.find(user => user.id === currentUserId.value) ?? null
)
const isAuthenticated = computed(() => currentUser.value !== null)
function login(email, password) {
async function login(email, password) {
const normalizedEmail = String(email).trim().toLowerCase()
const user = DEMO_USERS.find(candidate =>
candidate.email === normalizedEmail && candidate.password === password
const user = users.value.find(candidate =>
candidate.email === normalizedEmail
)
if (!user) {
@ -43,20 +100,61 @@ export const useAuthStore = defineStore('auth', () => {
return false
}
if (user.mode === 'remote') {
try {
const data = await loginRemote(normalizedEmail, password)
const remoteUser = normalizeRemoteUser(data.user)
await setToken(data.token)
currentUserId.value = remoteUser.id
currentUserProfile.value = remoteUser
lastError.value = ''
persistAuth(remoteUser)
return true
} catch (error) {
console.warn('Remote login failed:', error)
lastError.value = error?.status === 422
? 'E-Mail oder Passwort ist falsch.'
: 'Login über die API ist gerade nicht möglich.'
return false
}
}
if (user.mode === 'local') {
currentUserId.value = user.id
currentUserProfile.value = user
lastError.value = ''
persistAuth(user)
return true
}
if (user.password !== password) {
lastError.value = 'E-Mail oder Passwort ist falsch.'
return false
}
currentUserId.value = user.id
currentUserProfile.value = user
lastError.value = ''
localStorage.setItem(AUTH_STORAGE_KEY, JSON.stringify({ userId: user.id }))
persistAuth(user)
return true
}
function logout() {
if (currentUser.value?.mode === 'remote') {
logoutRemote().catch(() => {})
}
currentUserId.value = null
currentUserProfile.value = null
lastError.value = ''
localStorage.removeItem(AUTH_STORAGE_KEY)
clearToken().catch(() => {})
}
return {
users: DEMO_USERS,
users,
currentUserId,
currentUser,
isAuthenticated,

View file

@ -2,6 +2,7 @@ import { defineStore } from 'pinia'
import { ref, computed, watch } from 'vue'
import Dexie from 'dexie'
import { db } from 'src/db'
import { apiJson } from 'src/services/apiClient'
import { startAutoSync, getToken } from 'src/services/syncService'
import { useSettingsStore, DEFAULT_EMOTION_GRADIENT_START, DEFAULT_EMOTION_GRADIENT_END } from 'stores/settings'
import { useAuthStore } from 'stores/auth'
@ -28,6 +29,14 @@ function emotionToColor(emotion, gradientStartColor = null, gradientEndColor = n
return lerpColor(start, end, t)
}
function todayLocalDate() {
const now = new Date()
const year = now.getFullYear()
const month = String(now.getMonth() + 1).padStart(2, '0')
const day = String(now.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}
// Demo seed data
const demoEvents = [
{ id: crypto.randomUUID(), title: 'Erster Schultag', date: '1995-09-01', location: '', emotion: 0.6, customColor: null, gradientPreset: null, image: null, note: '', syncStatus: 'local', createdAt: Date.now(), updatedAt: Date.now() },
@ -154,8 +163,179 @@ export const useEventsStore = defineStore('events', () => {
const AUTOSAVE_DELAY_MS = 300
let persistTimer = null
let skipNextPersist = false
const pendingRemoteCreates = new Map()
// Load events from IndexedDB; seed demo data on first launch
const isRemoteUser = computed(() => authStore.currentUser?.mode === 'remote')
function normalizeRemoteEvent(event) {
const media = Array.isArray(event.media) ? event.media : []
const keyImage = media.find(item => item.collection === 'key_image')
return {
id: event.id,
userId: authStore.currentUserId,
title: event.title,
date: event.date,
location: event.location ?? '',
emotion: event.emotion,
customColor: event.customColor ?? null,
gradientPreset: event.gradientPreset ?? null,
gradientStartColor: event.gradientStartColor ?? null,
gradientEndColor: event.gradientEndColor ?? null,
image: keyImage?.thumbnailUrl ?? event.image ?? null,
keyImagePreviewUrl: keyImage?.previewUrl ?? null,
keyImageOriginalUrl: keyImage?.originalUrl ?? null,
keyImageTitle: event.keyImageTitle ?? '',
media,
note: event.note ?? '',
syncStatus: 'synced',
createdAt: event.createdAt ?? Date.now(),
updatedAt: event.updatedAt ?? Date.now()
}
}
function eventPayload(event) {
return {
id: event.id,
title: event.title,
date: event.date,
emotion: event.emotion,
customColor: event.customColor,
gradientPreset: event.gradientPreset,
image: event.image,
note: event.note
}
}
async function loadRemoteEvents() {
const remoteEvents = []
let nextUrl = '/events?limit=200'
while (nextUrl) {
const payload = await apiJson(nextUrl)
remoteEvents.push(...(payload.data || []).map(normalizeRemoteEvent))
if (payload.links?.next) {
const url = new URL(payload.links.next)
nextUrl = `${url.pathname.replace(/^\/api/, '')}${url.search}`
} else {
nextUrl = null
}
}
events.value = remoteEvents
await db.events
.where('[userId+date]')
.between([authStore.currentUserId, Dexie.minKey], [authStore.currentUserId, Dexie.maxKey])
.delete()
if (remoteEvents.length > 0) {
await db.events.bulkPut(remoteEvents)
}
}
function createRemoteEvent(event) {
const request = apiJson('/events', {
method: 'POST',
body: eventPayload(event)
})
.then((payload) => {
const syncedEvent = normalizeRemoteEvent(payload.data)
const idx = events.value.findIndex(item => item.id === syncedEvent.id)
if (idx !== -1) {
events.value[idx] = {
...events.value[idx],
...syncedEvent
}
}
dbPut(syncedEvent)
})
.catch((error) => {
console.warn('Remote event create failed:', error)
const idx = events.value.findIndex(item => item.id === event.id)
if (idx !== -1) {
events.value[idx] = { ...events.value[idx], syncStatus: 'error' }
}
})
.finally(() => {
pendingRemoteCreates.delete(event.id)
})
pendingRemoteCreates.set(event.id, request)
}
async function updateRemoteEvent(event) {
const pendingCreate = pendingRemoteCreates.get(event.id)
if (pendingCreate) {
await pendingCreate
}
try {
const payload = await apiJson(`/events/${event.id}`, {
method: 'PUT',
body: eventPayload(event)
})
const syncedEvent = normalizeRemoteEvent(payload.data)
const idx = events.value.findIndex(item => item.id === syncedEvent.id)
if (idx !== -1) {
events.value[idx] = {
...events.value[idx],
...syncedEvent
}
}
dbPut(syncedEvent)
} catch (error) {
console.warn('Remote event update failed:', error)
const idx = events.value.findIndex(item => item.id === event.id)
if (idx !== -1) {
events.value[idx] = { ...events.value[idx], syncStatus: 'error' }
}
}
}
async function deleteRemoteEvent(id) {
const pendingCreate = pendingRemoteCreates.get(id)
if (pendingCreate) {
await pendingCreate
}
try {
await apiJson(`/events/${id}`, { method: 'DELETE' })
} catch (error) {
console.warn('Remote event delete failed:', error)
}
}
async function uploadRemoteMedia(file, collection = 'gallery') {
if (!editingEventId.value) return null
const pendingCreate = pendingRemoteCreates.get(editingEventId.value)
if (pendingCreate) {
await pendingCreate
}
const formData = new FormData()
formData.append('collection', collection)
formData.append('file', file)
const payload = await apiJson(`/events/${editingEventId.value}/media`, {
method: 'POST',
body: formData
})
return payload.data
}
async function deleteRemoteMedia(mediaId) {
if (!isRemoteUser.value || !editingEventId.value || !mediaId) return
try {
await apiJson(`/events/${editingEventId.value}/media/${mediaId}`, { method: 'DELETE' })
} catch (error) {
console.warn('Remote media delete failed:', error)
}
}
// Load local Demo events from IndexedDB; Remote users load from the API.
async function init() {
const userId = authStore.currentUserId
if (!userId) {
@ -166,22 +346,20 @@ export const useEventsStore = defineStore('events', () => {
isLoaded.value = false
try {
let stored = await db.events
if (isRemoteUser.value) {
await loadRemoteEvents()
isLoaded.value = true
return
}
const stored = await db.events
.where('[userId+date]')
.between([userId, Dexie.minKey], [userId, Dexie.maxKey])
.toArray()
if (stored.length === 0) {
const seed = generateManyEvents(500).map(event => ({
...event,
userId
}))
await db.events.bulkPut(seed)
stored = seed
}
events.value = stored
} catch (e) {
console.warn('Dexie load failed, using demo data:', e)
events.value = [...demoEvents]
console.warn('Dexie load failed:', e)
events.value = []
}
isLoaded.value = true
@ -197,26 +375,11 @@ export const useEventsStore = defineStore('events', () => {
}
function dbDelete(id) {
db.events.delete(id).catch(e => console.warn('Dexie delete failed:', e))
}
function dbQueueSync(eventId, action, payload) {
const userId = authStore.currentUserId
if (!userId) return
const queue = async () => {
if (action === 'update') {
await db.syncQueue
.where('eventId')
.equals(eventId)
.and(item => item.userId === userId && item.action === 'update')
.delete()
}
await db.syncQueue.add({ userId, eventId, action, payload, createdAt: Date.now() })
const remove = async () => {
await db.events.delete(id)
await db.eventMedia.where('eventId').equals(id).delete()
}
queue().catch(e => console.warn('Dexie sync queue failed:', e))
remove().catch(e => console.warn('Dexie delete failed:', e))
}
function cloneMedia(media) {
@ -226,11 +389,16 @@ export const useEventsStore = defineStore('events', () => {
}
function mediaMeta(media) {
return cloneMedia(media).map(({ id, type, name, createdAt }) => ({
id,
type,
name,
createdAt
return cloneMedia(media).map((item) => ({
id: item.id,
uuid: item.uuid,
type: item.type,
collection: item.collection,
name: item.name,
src: item.src,
thumbnailUrl: item.thumbnailUrl,
originalUrl: item.originalUrl,
createdAt: item.createdAt
}))
}
@ -301,10 +469,40 @@ export const useEventsStore = defineStore('events', () => {
selectedEventId.value = id
}
function createEvent() {
const now = Date.now()
const newEvent = {
id: crypto.randomUUID(),
userId: authStore.currentUserId,
title: 'Neues Event',
date: todayLocalDate(),
location: '',
emotion: 0,
customColor: null,
gradientPreset: null,
image: null,
keyImageTitle: '',
media: [],
note: '',
syncStatus: 'local',
createdAt: now,
updatedAt: now
}
events.value.push(newEvent)
dbPut(newEvent)
if (isRemoteUser.value) {
createRemoteEvent(newEvent)
}
return newEvent
}
function openPanel(eventId = null) {
if (eventId) {
editingEventId.value = eventId
const event = events.value.find((e) => e.id === eventId)
const panelEventId = eventId || createEvent().id
if (panelEventId) {
editingEventId.value = panelEventId
const event = events.value.find((e) => e.id === panelEventId)
if (event) {
skipNextPersist = true
ghostTitle.value = event.title
@ -312,22 +510,11 @@ export const useEventsStore = defineStore('events', () => {
ghostLocation.value = event.location || ''
ghostEmotion.value = event.emotion
ghostCustomColor.value = event.customColor
ghostImage.value = event.image || null
ghostImage.value = event.keyImagePreviewUrl || event.image || null
ghostKeyImageTitle.value = event.keyImageTitle || ''
loadEventMedia(event)
ghostNote.value = event.note
}
} else {
editingEventId.value = null
ghostTitle.value = ''
ghostDate.value = new Date().toISOString().slice(0, 10)
ghostLocation.value = ''
ghostEmotion.value = 0
ghostCustomColor.value = null
ghostImage.value = null
ghostKeyImageTitle.value = ''
ghostMedia.value = []
ghostNote.value = ''
}
panelOpen.value = true
}
@ -356,7 +543,9 @@ export const useEventsStore = defineStore('events', () => {
events.value[idx] = updated
dbPut(updated)
persistEventMedia(updated.id, updated.userId, ghostMedia.value)
dbQueueSync(updated.id, 'update', { ...updated })
if (isRemoteUser.value) {
updateRemoteEvent(updated)
}
}
function schedulePersistToEvent() {
@ -397,29 +586,6 @@ export const useEventsStore = defineStore('events', () => {
function closePanel() {
flushPersistToEvent()
if (!editingEventId.value && ghostTitle.value.trim()) {
const newEvent = {
id: crypto.randomUUID(),
userId: authStore.currentUserId,
title: ghostTitle.value,
date: ghostDate.value,
location: ghostLocation.value,
emotion: ghostEmotion.value,
customColor: ghostCustomColor.value,
gradientPreset: null,
image: ghostImage.value,
keyImageTitle: ghostKeyImageTitle.value,
media: mediaMeta(ghostMedia.value),
note: ghostNote.value,
syncStatus: 'local',
createdAt: Date.now(),
updatedAt: Date.now()
}
events.value.push(newEvent)
dbPut(newEvent)
persistEventMedia(newEvent.id, newEvent.userId, ghostMedia.value)
dbQueueSync(newEvent.id, 'create', { ...newEvent })
}
panelOpen.value = false
editingEventId.value = null
selectedEventId.value = null
@ -432,10 +598,105 @@ export const useEventsStore = defineStore('events', () => {
}
events.value = events.value.filter((e) => e.id !== id)
dbDelete(id)
dbQueueSync(id, 'delete', null)
if (isRemoteUser.value) {
deleteRemoteEvent(id)
}
closePanel()
}
async function uploadGhostKeyImage(file) {
if (!isRemoteUser.value) return null
const media = await uploadRemoteMedia(file, 'key_image')
if (!media) return null
ghostImage.value = media.originalUrl
if (!ghostKeyImageTitle.value) {
ghostKeyImageTitle.value = 'Key Image'
}
const idx = events.value.findIndex((event) => event.id === editingEventId.value)
if (idx !== -1) {
const nextMedia = [
...(events.value[idx].media || []).filter(item => item.collection !== 'key_image'),
media
]
events.value[idx] = {
...events.value[idx],
image: media.thumbnailUrl,
keyImagePreviewUrl: media.previewUrl,
keyImageOriginalUrl: media.originalUrl,
media: nextMedia,
updatedAt: Date.now()
}
dbPut(events.value[idx])
}
return media
}
async function uploadGhostMedia(files) {
if (!isRemoteUser.value) return []
const uploaded = []
for (const file of files) {
const media = await uploadRemoteMedia(file, 'gallery')
if (media) uploaded.push(media)
}
if (uploaded.length > 0) {
ghostMedia.value = [...ghostMedia.value, ...uploaded]
const idx = events.value.findIndex((event) => event.id === editingEventId.value)
if (idx !== -1) {
events.value[idx] = {
...events.value[idx],
media: [...(events.value[idx].media || []), ...uploaded],
updatedAt: Date.now()
}
dbPut(events.value[idx])
}
}
return uploaded
}
async function deleteGhostMedia(mediaId) {
await deleteRemoteMedia(mediaId)
const idx = events.value.findIndex((event) => event.id === editingEventId.value)
if (idx !== -1) {
events.value[idx] = {
...events.value[idx],
media: (events.value[idx].media || []).filter(item => item.id !== mediaId),
updatedAt: Date.now()
}
dbPut(events.value[idx])
}
}
async function deleteGhostKeyImage() {
const idx = events.value.findIndex((event) => event.id === editingEventId.value)
const keyImage = idx !== -1
? (events.value[idx].media || []).find(item => item.collection === 'key_image')
: null
if (keyImage?.id) {
await deleteRemoteMedia(keyImage.id)
}
if (idx !== -1) {
events.value[idx] = {
...events.value[idx],
image: null,
keyImagePreviewUrl: null,
keyImageOriginalUrl: null,
media: (events.value[idx].media || []).filter(item => item.collection !== 'key_image'),
updatedAt: Date.now()
}
dbPut(events.value[idx])
}
}
function getGlowColor(event) {
if (event.customColor) return event.customColor
return emotionToColor(
@ -478,8 +739,13 @@ export const useEventsStore = defineStore('events', () => {
sortedEvents,
selectEvent,
openPanel,
createEvent,
closePanel,
deleteEvent,
uploadGhostKeyImage,
uploadGhostMedia,
deleteGhostMedia,
deleteGhostKeyImage,
saveGhostNow,
getGlowColor
}

View file

@ -1,5 +1,6 @@
import { defineStore } from 'pinia'
import { ref, watch } from 'vue'
import { apiJson } from 'src/services/apiClient'
import { useAuthStore } from 'stores/auth'
const STORAGE_KEY_PREFIX = 'thatsme-settings'
@ -126,6 +127,7 @@ export const useSettingsStore = defineStore('settings', () => {
const initialActivePreset = stored?.presets?.find(preset => preset.id === stored?.activePresetId)
const initialSettings = initialActivePreset?.settings ?? stored
let persistTimer = null
let isApplyingSettings = false
const theme = ref(initialSettings?.theme ?? 'light')
const floatingLines = ref({
@ -161,6 +163,19 @@ export const useSettingsStore = defineStore('settings', () => {
}
}
function createStoredSettings() {
return {
...createSnapshot(),
timelineScrollLeft: timelineScrollLeft.value,
presets: presets.value,
activePresetId: activePresetId.value
}
}
function isRemoteUser() {
return authStore.currentUser?.mode === 'remote'
}
function applySnapshot(snapshot) {
theme.value = snapshot?.theme ?? 'light'
floatingLines.value = {
@ -182,37 +197,59 @@ export const useSettingsStore = defineStore('settings', () => {
persistTimer = null
}
if (!authStore.currentUserId) return
if (!authStore.currentUserId || isApplyingSettings) return
localStorage.setItem(
getStorageKey(authStore.currentUserId),
JSON.stringify({
...createSnapshot(),
timelineScrollLeft: timelineScrollLeft.value,
presets: presets.value,
activePresetId: activePresetId.value
})
)
const storedSettings = createStoredSettings()
localStorage.setItem(getStorageKey(authStore.currentUserId), JSON.stringify(storedSettings))
if (isRemoteUser()) {
apiJson('/settings', {
method: 'PUT',
body: { settings: storedSettings }
}).catch(error => console.warn('Remote settings persist failed:', error))
}
}
function schedulePersist() {
if (isApplyingSettings) return
if (persistTimer) clearTimeout(persistTimer)
persistTimer = setTimeout(persist, PERSIST_DELAY_MS)
}
function applyStoredSettingsForUser(userId) {
async function loadRemoteSettings() {
const payload = await apiJson('/settings')
return payload.data ?? null
}
async function applyStoredSettingsForUser(userId) {
if (persistTimer) {
clearTimeout(persistTimer)
persistTimer = null
}
const nextStored = loadFromStorage(userId)
isApplyingSettings = true
let nextStored = loadFromStorage(userId)
if (userId && isRemoteUser()) {
try {
nextStored = await loadRemoteSettings()
if (nextStored) {
localStorage.setItem(getStorageKey(userId), JSON.stringify(nextStored))
}
} catch (error) {
console.warn('Remote settings load failed:', error)
}
}
presets.value = nextStored?.presets ?? []
activePresetId.value = nextStored?.activePresetId ?? null
const activePreset = presets.value.find(preset => preset.id === activePresetId.value)
applySnapshot(activePreset?.settings ?? nextStored)
timelineScrollLeft.value = nextStored?.timelineScrollLeft ?? DEFAULT_TIMELINE_SCROLL_LEFT
isApplyingSettings = false
}
watch([theme, floatingLines, appearance, accentColor, language, emotionGradientStart, emotionGradientEnd, timelineZoom, timelineScrollLeft, showFps, presets, activePresetId], schedulePersist, { deep: true })
@ -234,6 +271,37 @@ export const useSettingsStore = defineStore('settings', () => {
floatingLines.value = { ...floatingLines.value, ...updates }
}
async function uploadBackgroundImage(file) {
if (!isRemoteUser()) return null
const formData = new FormData()
formData.append('file', file)
const payload = await apiJson('/settings/media/background', {
method: 'POST',
body: formData
})
const url = payload?.data?.url
if (url) {
updateFloatingLines({ backgroundImage: url })
}
return payload?.data ?? null
}
async function clearBackgroundImage() {
updateFloatingLines({ backgroundImage: '' })
if (!isRemoteUser()) return
try {
await apiJson('/settings/media/background', { method: 'DELETE' })
} catch (error) {
console.warn('Remote settings background delete failed:', error)
}
}
function resetFloatingLines() {
floatingLines.value = { ...FLOATING_LINES_DEFAULTS }
}
@ -280,6 +348,8 @@ export const useSettingsStore = defineStore('settings', () => {
return true
}
applyStoredSettingsForUser(authStore.currentUserId)
return {
theme,
floatingLines,
@ -298,6 +368,8 @@ export const useSettingsStore = defineStore('settings', () => {
resetFloatingLines,
resetEmotionGradient,
saveTimelineScrollLeft,
uploadBackgroundImage,
clearBackgroundImage,
savePreset,
applyPreset
}