APP als Hybrid Version - Anbindung an API
This commit is contained in:
parent
d054732bf5
commit
c1514999be
46 changed files with 3418 additions and 196 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
|
|
|
|||
|
|
@ -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'])
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
2
frontend/src/config/appVersion.js
Normal file
2
frontend/src/config/appVersion.js
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export const APP_VERSION = '0.0.1'
|
||||
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 } }
|
||||
|
|
|
|||
136
frontend/src/services/apiClient.js
Normal file
136
frontend/src/services/apiClient.js
Normal 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' })
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue