more tweaks

This commit is contained in:
2026-07-29 08:10:59 +02:00
parent e25464af9c
commit b8e3c64868
56 changed files with 3123 additions and 212 deletions
+2 -40
View File
@@ -5,47 +5,9 @@
</template>
<script setup lang="ts">
import { onMounted } from 'vue'
import AppLayout from './layout/AppLayout.vue'
import { RouterView, useRouter } from 'vue-router'
import { useMatomo } from './composables/useMatomo'
declare global {
interface Window { _paq?: Array<unknown[]> }
}
const router = useRouter()
onMounted(() => {
const _paq = (window._paq = window._paq || [])
_paq.push(['enableLinkTracking'])
const u = '//matomo.vps.teletype.hu/'
_paq.push(['setTrackerUrl', u + 'matomo.php'])
_paq.push(['setSiteId', '1'])
_paq.push(['setCustomUrl', window.location.href])
_paq.push(['setDocumentTitle', document.title])
_paq.push(['trackPageView'])
const d = document
const g = d.createElement('script')
const s = d.getElementsByTagName('script')[0]
g.async = true
g.src = u + 'matomo.js'
s.parentNode!.insertBefore(g, s)
})
router.beforeEach((_to, from) => {
const _paq = window._paq
if (_paq && from.fullPath) {
_paq.push(['setReferrerUrl', window.location.origin + from.fullPath])
}
})
router.afterEach((to) => {
const _paq = window._paq
if (!_paq) return
_paq.push(['setCustomUrl', window.location.origin + to.fullPath])
setTimeout(() => {
_paq.push(['setDocumentTitle', document.title])
_paq.push(['trackPageView'])
}, 0)
})
useMatomo(useRouter())
</script>
+5 -7
View File
@@ -1,15 +1,13 @@
import { CONFIG } from '../lib/config'
import type { GiteaRepo, Commit } from '../lib/interfaces/git.interface'
const BASE = import.meta.env.VITE_GIT_BASE || 'https://git.teletypegames.org/api/v1'
const TOKEN = import.meta.env.WEBAPP_GITEA_TOKEN
function buildHeaders() {
return { 'Authorization': `token ${TOKEN}`, 'Accept': 'application/json' }
return { 'Authorization': `token ${CONFIG.gitToken}`, 'Accept': 'application/json' }
}
const repos = async (): Promise<GiteaRepo[]> => {
if (!TOKEN) throw new Error('Gitea API token (WEBAPP_GITEA_TOKEN) is not configured.')
const res = await fetch(`${BASE}/repos/search?q=&private=false&limit=50`, { headers: buildHeaders() })
if (!CONFIG.gitToken) throw new Error('Gitea API token (WEBAPP_GITEA_TOKEN) is not configured.')
const res = await fetch(`${CONFIG.gitBase}/repos/search?q=&private=false&limit=50`, { headers: buildHeaders() })
if (!res.ok) throw new Error(`Gitea API responded with status ${res.status}`)
const data = await res.json()
return data.data || []
@@ -17,7 +15,7 @@ const repos = async (): Promise<GiteaRepo[]> => {
const commits = async (owner: string, name: string, htmlUrl: string): Promise<Commit[]> => {
try {
const res = await fetch(`${BASE}/repos/${owner}/${name}/commits?limit=10&page=1`, { headers: buildHeaders() })
const res = await fetch(`${CONFIG.gitBase}/repos/${owner}/${name}/commits?limit=10&page=1`, { headers: buildHeaders() })
if (!res.ok) return []
const data = await res.json()
if (!Array.isArray(data)) return []
+3 -2
View File
@@ -1,8 +1,9 @@
import type { WikiPage, WikiPageWithContent, WikiPageContent } from '../lib/interfaces/wiki.interface'
import { CONFIG } from '../lib/config'
// Public content base — used only for building external links to wiki pages.
// Points at Grav (which is taking over from wiki.teletypegames.org).
const WIKI_BASE = import.meta.env.VITE_WIKI_BASE || 'https://wiki.teletypegames.org'
const WIKI_BASE = CONFIG.wikiBase
interface RawWikiPage {
id: number
@@ -0,0 +1,16 @@
<template>
<div class="animate-pulse">
<div v-for="n in count" :key="n" class="bg-white rounded-2xl shadow-xl border border-gray-100 overflow-hidden mb-6">
<div class="p-6 md:p-8">
<div class="h-4 bg-gray-200 rounded w-1/4 mb-4"></div>
<div class="h-6 bg-gray-200 rounded w-3/4 mb-3"></div>
<div class="h-4 bg-gray-200 rounded w-full mb-2"></div>
<div class="h-4 bg-gray-200 rounded w-5/6"></div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
withDefaults(defineProps<{ count?: number }>(), { count: 3 })
</script>
@@ -0,0 +1,72 @@
import { describe, it, expect, vi } from 'vitest'
import { useLoadable } from '../useLoadable'
describe('useLoadable', () => {
it('executes fn on first call', async () => {
const { loading, error, withCache } = useLoadable()
const fn = vi.fn()
await withCache(fn)
expect(fn).toHaveBeenCalledOnce()
expect(loading.value).toBe(false)
expect(error.value).toBeNull()
})
it('skips fn on second call within TTL', async () => {
const { withCache } = useLoadable()
const fn = vi.fn()
await withCache(fn)
await withCache(fn)
expect(fn).toHaveBeenCalledOnce()
})
it('re-executes after invalidate', async () => {
const { withCache, invalidate } = useLoadable()
const fn = vi.fn()
await withCache(fn)
invalidate()
await withCache(fn)
expect(fn).toHaveBeenCalledTimes(2)
})
it('re-executes after TTL expires', async () => {
vi.useFakeTimers()
const { withCache } = useLoadable(100) // 100ms TTL
const fn = vi.fn()
await withCache(fn)
vi.advanceTimersByTime(150)
await withCache(fn)
expect(fn).toHaveBeenCalledTimes(2)
vi.useRealTimers()
})
it('sets error on fn failure', async () => {
const { error, withCache } = useLoadable()
await withCache(async () => {
throw new Error('test error')
})
expect(error.value).toBe('test error')
})
it('sets loading during execution', async () => {
const { loading, withCache } = useLoadable()
const states: boolean[] = []
await withCache(async () => {
states.push(loading.value)
})
expect(states).toEqual([true])
expect(loading.value).toBe(false)
})
})
@@ -0,0 +1,32 @@
import { ref } from 'vue'
const DEFAULT_TTL = 15 * 60 * 1000 // 15 perc
export function useLoadable(ttlMs?: number) {
const loading = ref(false)
const error = ref<string | null>(null)
let _loaded = false
let _loadedAt = 0
async function withCache(fn: () => Promise<void>) {
const effectiveTtl = ttlMs ?? DEFAULT_TTL
if (_loaded && Date.now() - _loadedAt < effectiveTtl) return
loading.value = true
error.value = null
try {
await fn()
_loaded = true
_loadedAt = Date.now()
} catch (e) {
error.value = e instanceof Error ? e.message : String(e)
} finally {
loading.value = false
}
}
function invalidate() {
_loaded = false
}
return { loading, error, withCache, invalidate }
}
@@ -0,0 +1,43 @@
import { onMounted } from 'vue'
import type { Router } from 'vue-router'
import { CONFIG } from '../lib/config'
declare global {
interface Window { _paq?: Array<unknown[]> }
}
export function useMatomo(router: Router) {
onMounted(() => {
const _paq = (window._paq = window._paq || [])
_paq.push(['enableLinkTracking'])
const u = CONFIG.matomoUrl
_paq.push(['setTrackerUrl', u + 'matomo.php'])
_paq.push(['setSiteId', CONFIG.matomoSiteId])
_paq.push(['setCustomUrl', window.location.href])
_paq.push(['setDocumentTitle', document.title])
_paq.push(['trackPageView'])
const d = document
const g = d.createElement('script')
const s = d.getElementsByTagName('script')[0]
g.async = true
g.src = u + 'matomo.js'
s.parentNode!.insertBefore(g, s)
})
router.beforeEach((_to, from) => {
const _paq = window._paq
if (_paq && from.fullPath) {
_paq.push(['setReferrerUrl', window.location.origin + from.fullPath])
}
})
router.afterEach((to) => {
const _paq = window._paq
if (!_paq) return
_paq.push(['setCustomUrl', window.location.origin + to.fullPath])
setTimeout(() => {
_paq.push(['setDocumentTitle', document.title])
_paq.push(['trackPageView'])
}, 0)
})
}
@@ -0,0 +1,24 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { getCookie, setCookie } from '../cookie'
describe('cookie utils', () => {
beforeEach(() => {
document.cookie.split(';').forEach(c => {
document.cookie = c.replace(/^ +/, '').replace(/=.*/, '=;expires=Thu, 01 Jan 1970 00:00:00 GMT;path=/')
})
})
it('getCookie returns null for missing cookie', () => {
expect(getCookie('nonexistent')).toBeNull()
})
it('setCookie + getCookie roundtrip', () => {
setCookie('test-key', 'test-value')
expect(getCookie('test-key')).toBe('test-value')
})
it('handles encoded values', () => {
setCookie('encoded', 'hello world')
expect(getCookie('encoded')).toBe('hello world')
})
})
@@ -0,0 +1,28 @@
/**
* @vitest-environment jsdom
*/
import { describe, it, expect } from 'vitest'
import { sanitize } from '../sanitize'
describe('sanitize', () => {
it('keeps safe HTML content', () => {
const result = sanitize('<p>Hello <strong>world</strong></p>')
expect(result).toContain('Hello')
expect(result).toContain('<strong>world</strong>')
})
it('strips script tags', () => {
const result = sanitize('<p>Safe</p><script>alert("xss")</script>')
expect(result).toContain('Safe')
expect(result).not.toContain('alert')
})
it('strips event handlers', () => {
const result = sanitize('<img src="x" onerror="alert(1)">')
expect(result).not.toContain('onerror')
})
it('handles empty string', () => {
expect(sanitize('')).toBe('')
})
})
+10
View File
@@ -0,0 +1,10 @@
export const CONFIG = {
wikiBase: import.meta.env.VITE_WIKI_BASE || 'https://wiki.teletypegames.org',
gitBase: import.meta.env.VITE_GIT_BASE || 'https://git.teletypegames.org/api/v1',
gitToken: import.meta.env.WEBAPP_GITEA_TOKEN || '',
discordInvite: import.meta.env.DISCORD_INVITE_LINK || 'https://discord.gg/TJKxGfG',
matomoUrl: import.meta.env.VITE_MATOMO_URL || '//matomo.vps.teletype.hu/',
matomoSiteId: import.meta.env.VITE_MATOMO_SITE_ID || '1',
youtubeApiKey: import.meta.env.YOUTUBE_API_KEY || '',
youtubeChannelId: import.meta.env.YOUTUBE_CHANNEL_ID || '',
} as const
+9
View File
@@ -0,0 +1,9 @@
export function getCookie(name: string): string | null {
const match = document.cookie.match(new RegExp('(?:^|; )' + name + '=([^;]*)'))
return match ? decodeURIComponent(match[1]) : null
}
export function setCookie(name: string, value: string, days = 365) {
const expires = new Date(Date.now() + days * 864e5).toUTCString()
document.cookie = `${name}=${encodeURIComponent(value)};expires=${expires};path=/`
}
+5
View File
@@ -0,0 +1,5 @@
import DOMPurify from 'dompurify'
export function sanitize(html: string): string {
return DOMPurify.sanitize(html)
}
@@ -30,6 +30,8 @@
<p class="empty-state-desc">{{ t('blog.noPostsDesc') }}</p>
</div>
<SkeletonCard v-else-if="loading" :count="3" />
<div v-else class="blog-posts-list">
<article v-for="page in blogPages" :key="page.id" class="blog-post-card">
<div class="blog-post-content">
@@ -78,6 +80,7 @@ import { RouterLink } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { formatDateTime } from '../../lib/dateFormat'
import { useBlogStore } from '../../stores/blog.store'
import SkeletonCard from '../../components/SkeletonCard.vue'
const { t } = useI18n()
+2 -1
View File
@@ -27,7 +27,7 @@
<article v-else-if="pageContent" class="blog-post-card">
<div class="blog-post-content">
<div v-if="pageContent.render" class="wiki-content max-w-none text-gray-700 leading-relaxed" v-html="pageContent.render" />
<div v-if="pageContent.render" class="wiki-content max-w-none text-gray-700 leading-relaxed" v-html="sanitize(pageContent.render)" />
<div v-else class="blog-post-fallback">No content available for this post.</div>
</div>
</article>
@@ -44,6 +44,7 @@ import { onMounted } from 'vue'
import { storeToRefs } from 'pinia'
import { RouterLink, useRoute } from 'vue-router'
import { formatDateTime } from '../../lib/dateFormat'
import { sanitize } from '../../lib/sanitize'
import { useBlogStore } from '../../stores/blog.store'
const route = useRoute()
@@ -15,6 +15,8 @@
<span class="block sm:inline"> {{ error }}</span>
</div>
<SkeletonCard v-if="!error && publicRepos.length === 0 && !recentCommits.length" :count="4" />
<div v-if="!error && publicRepos.length > 0" class="card-grid mb-16">
<div v-for="repo in publicRepos" :key="repo.name" class="card-base">
<h3 class="repo-card-title">
@@ -62,6 +64,7 @@ import { storeToRefs } from 'pinia'
import { useI18n } from 'vue-i18n'
import { formatDateTime } from '../../lib/dateFormat'
import { useGitStore } from '../../stores/git.store'
import SkeletonCard from '../../components/SkeletonCard.vue'
const { t } = useI18n()
@@ -130,10 +130,11 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import { CONFIG } from '../../lib/config'
const { t } = useI18n()
const DISCORD_INVITE_LINK = import.meta.env.DISCORD_INVITE_LINK || 'https://discord.gg/your-discord-link'
const DISCORD_INVITE_LINK = CONFIG.discordInvite
function openEmail() {
window.location.href = 'mailto:contact@teletype.hu'
@@ -0,0 +1,43 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { useSoftwareStore } from '../software.store'
const mockIndex = vi.fn().mockResolvedValue([
{ software: { name: 'test-game', title: 'Test Game', status: 'released', platform: 'tic80', author: 'dev' }, releases: [], totalDownloads: 0 },
])
vi.mock('../../api/software.api', () => ({
default: {
index: (...args: unknown[]) => mockIndex(...args),
highlighted: vi.fn().mockResolvedValue(null),
},
}))
describe('useSoftwareStore', () => {
beforeEach(() => {
setActivePinia(createPinia())
mockIndex.mockClear()
})
it('fetches software items', async () => {
const store = useSoftwareStore()
await store.fetchAll()
expect(store.items).toHaveLength(1)
expect(store.items[0].software.name).toBe('test-game')
expect(store.loading).toBe(false)
})
it('caches after first fetch', async () => {
const store = useSoftwareStore()
await store.fetchAll()
await store.fetchAll()
expect(mockIndex).toHaveBeenCalledOnce()
})
it('findByName returns correct entry', async () => {
const store = useSoftwareStore()
await store.fetchAll()
expect(store.findByName('test-game')).toBeDefined()
expect(store.findByName('nonexistent')).toBeUndefined()
})
})
@@ -0,0 +1,39 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { useUiStore } from '../ui.store'
describe('useUiStore', () => {
beforeEach(() => {
setActivePinia(createPinia())
document.cookie.split(';').forEach(c => {
document.cookie = c.replace(/^ +/, '').replace(/=.*/, '=;expires=Thu, 01 Jan 1970 00:00:00 GMT;path=/')
})
})
it('starts with retro mode off by default', () => {
const store = useUiStore()
expect(store.isRetroMode).toBe(false)
})
it('toggles retro mode', () => {
const store = useUiStore()
store.toggleRetroMode()
expect(store.isRetroMode).toBe(true)
store.toggleRetroMode()
expect(store.isRetroMode).toBe(false)
})
it('toggles menu', () => {
const store = useUiStore()
expect(store.menuOpen).toBe(false)
store.toggleMenu()
expect(store.menuOpen).toBe(true)
})
it('closes menu', () => {
const store = useUiStore()
store.toggleMenu()
store.closeMenu()
expect(store.menuOpen).toBe(false)
})
})
+5 -14
View File
@@ -2,30 +2,21 @@ import { defineStore } from 'pinia'
import { ref } from 'vue'
import wikiApi from '../api/wiki.api'
import { isNew } from '../lib/softwareUtils'
import { useLoadable } from '../composables/useLoadable'
import type { WikiPageWithContent, WikiPageContent } from '../lib/interfaces/wiki.interface'
export const useBlogStore = defineStore('blog', () => {
const pages = ref<WikiPageWithContent[]>([])
const loading = ref(false)
const error = ref<string | null>(null)
const { loading, error, withCache, invalidate } = useLoadable()
const currentPage = ref<WikiPageContent | null>(null)
const currentPageLoading = ref(false)
const currentPageError = ref<string | null>(null)
let _loaded = false
async function fetchPages() {
if (_loaded) return
loading.value = true
try {
await withCache(async () => {
pages.value = await wikiApi.listBlogPages()
_loaded = true
} catch (e) {
error.value = `Failed to fetch wiki data: ${e instanceof Error ? e.message : String(e)}`
} finally {
loading.value = false
}
})
}
async function fetchPage(slug: string) {
@@ -56,5 +47,5 @@ export const useBlogStore = defineStore('blog', () => {
return content.replace(/[#*`_[\]()]/g, '').trim().slice(0, 300) + '...'
}
return { pages, loading, error, currentPage, currentPageLoading, currentPageError, fetchPages, fetchPage, isNew, getPermalink, getCleanPreview }
return { pages, loading, error, currentPage, currentPageLoading, currentPageError, fetchPages, fetchPage, isNew, getPermalink, getCleanPreview, invalidate }
})
+5 -14
View File
@@ -2,6 +2,7 @@ import { defineStore } from 'pinia'
import { ref } from 'vue'
import eventApi from '../api/event.api'
import { formatDateTime } from '../lib/dateFormat'
import { useLoadable } from '../composables/useLoadable'
import type { Event } from '../lib/interfaces/event.interface'
type EventWithText = Event & { dateISO: string; dateText: string }
@@ -11,11 +12,9 @@ export const useEventStore = defineStore('event', () => {
const nextEvent = ref<EventWithText | null>(null)
const followingEvents = ref<FollowingEvent[]>([])
const countdownText = ref('-- : -- : -- : --')
const loading = ref(false)
const error = ref<string | null>(null)
const { loading, error, withCache, invalidate } = useLoadable()
let countdownInterval: ReturnType<typeof setInterval> | null = null
let _loaded = false
function startCountdown(targetISO: string) {
if (countdownInterval) clearInterval(countdownInterval)
@@ -42,10 +41,7 @@ export const useEventStore = defineStore('event', () => {
}
async function fetch() {
if (_loaded) return
loading.value = true
error.value = null
try {
await withCache(async () => {
const events = await eventApi.index()
if (events.length > 0) {
const first = events[0]
@@ -54,12 +50,7 @@ export const useEventStore = defineStore('event', () => {
followingEvents.value = events.slice(1).map(e => ({ name: e.name, date: e.date, dateText: formatDateTime(new Date(e.date)) }))
startCountdown(firstDate.toISOString())
}
_loaded = true
} catch (e) {
error.value = e instanceof Error ? e.message : String(e)
} finally {
loading.value = false
}
})
}
function cleanup() {
@@ -69,5 +60,5 @@ export const useEventStore = defineStore('event', () => {
}
}
return { nextEvent, followingEvents, countdownText, loading, error, fetch, cleanup }
return { nextEvent, followingEvents, countdownText, loading, error, fetch, cleanup, invalidate }
})
+5 -14
View File
@@ -1,20 +1,16 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import gitApi from '../api/git.api'
import { useLoadable } from '../composables/useLoadable'
import type { GiteaRepo, Commit } from '../lib/interfaces/git.interface'
export const useGitStore = defineStore('git', () => {
const repos = ref<GiteaRepo[]>([])
const commits = ref<Commit[]>([])
const loading = ref(false)
const error = ref<string | null>(null)
let _loaded = false
const { loading, error, withCache, invalidate } = useLoadable()
async function fetchAll() {
if (_loaded) return
loading.value = true
error.value = null
try {
await withCache(async () => {
repos.value = await gitApi.repos()
const commitPromises = repos.value.map(repo =>
@@ -24,13 +20,8 @@ export const useGitStore = defineStore('git', () => {
const all = (await Promise.all(commitPromises)).flat()
all.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime())
commits.value = all.slice(0, 20)
_loaded = true
} catch (e) {
error.value = e instanceof Error ? e.message : String(e)
} finally {
loading.value = false
}
})
}
return { repos, commits, loading, error, fetchAll }
return { repos, commits, loading, error, fetchAll, invalidate }
})
+5 -13
View File
@@ -2,26 +2,18 @@ import { defineStore } from 'pinia'
import { ref } from 'vue'
import wikiApi from '../api/wiki.api'
import { isNew } from '../lib/softwareUtils'
import { useLoadable } from '../composables/useLoadable'
import type { WikiPage } from '../lib/interfaces/wiki.interface'
export const useHowtosStore = defineStore('howtos', () => {
const pages = ref<WikiPage[]>([])
const loading = ref(false)
const error = ref<string | null>(null)
let _loaded = false
const { loading, error, withCache, invalidate } = useLoadable()
async function fetchPages() {
if (_loaded) return
loading.value = true
try {
await withCache(async () => {
pages.value = await wikiApi.listHowtoPages()
_loaded = true
} catch (e) {
error.value = `Failed to fetch wiki data: ${e instanceof Error ? e.message : String(e)}`
} finally {
loading.value = false
}
})
}
return { pages, loading, error, fetchPages, isNew }
return { pages, loading, error, fetchPages, isNew, invalidate }
})
+5 -14
View File
@@ -1,26 +1,17 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import memberApi from '../api/member.api'
import { useLoadable } from '../composables/useLoadable'
import type { Member } from '../lib/interfaces/member.interface'
export const useMemberStore = defineStore('member', () => {
const members = ref<Member[]>([])
const loading = ref(false)
const error = ref<string | null>(null)
let _loaded = false
const { loading, error, withCache, invalidate } = useLoadable()
async function fetch() {
if (_loaded) return
loading.value = true
error.value = null
try {
await withCache(async () => {
members.value = await memberApi.index()
_loaded = true
} catch (e) {
error.value = e instanceof Error ? e.message : String(e)
} finally {
loading.value = false
}
})
}
function resolvedAvatarUrl(member: Member): string {
@@ -28,5 +19,5 @@ export const useMemberStore = defineStore('member', () => {
return new URL(`../assets/team/${member.avatar_filename}`, import.meta.url).href
}
return { members, loading, error, fetch, resolvedAvatarUrl }
return { members, loading, error, fetch, resolvedAvatarUrl, invalidate }
})
+5 -14
View File
@@ -2,14 +2,13 @@ import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import softwareApi from '../api/software.api'
import { getLatestStable } from '../lib/softwareUtils'
import { useLoadable } from '../composables/useLoadable'
import type { SoftwareEntry, Release } from '../lib/interfaces/software.interface'
export const useSoftwareStore = defineStore('software', () => {
const items = ref<SoftwareEntry[]>([])
const highlighted = ref<SoftwareEntry | null>(null)
const loading = ref(false)
const error = ref<string | null>(null)
let _loaded = false
const { loading, error, withCache, invalidate } = useLoadable()
const highlightedStableRelease = computed((): Release | null => {
if (!highlighted.value?.releases) return null
@@ -17,17 +16,9 @@ export const useSoftwareStore = defineStore('software', () => {
})
async function fetchAll() {
if (_loaded) return
loading.value = true
error.value = null
try {
await withCache(async () => {
items.value = await softwareApi.index()
_loaded = true
} catch (e) {
error.value = e instanceof Error ? e.message : String(e)
} finally {
loading.value = false
}
})
}
async function fetchHighlighted() {
@@ -42,5 +33,5 @@ export const useSoftwareStore = defineStore('software', () => {
return items.value.find(s => s.software.name === name)
}
return { items, highlighted, loading, error, highlightedStableRelease, fetchAll, fetchHighlighted, findByName }
return { items, highlighted, loading, error, highlightedStableRelease, fetchAll, fetchHighlighted, findByName, invalidate }
})
+1 -10
View File
@@ -1,15 +1,6 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
function getCookie(name: string): string | null {
const match = document.cookie.match(new RegExp('(?:^|; )' + name + '=([^;]*)'))
return match ? decodeURIComponent(match[1]) : null
}
function setCookie(name: string, value: string, days = 365) {
const expires = new Date(Date.now() + days * 864e5).toUTCString()
document.cookie = `${name}=${encodeURIComponent(value)};expires=${expires};path=/`
}
import { getCookie, setCookie } from '../lib/cookie'
export const useUiStore = defineStore('ui', () => {
const isRetroMode = ref(getCookie('retro-mode') === '1')