mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-23 02:46:42 +02:00
refactor: consolidate codebase — remove archived code, deduplicate packages, standardize middleware
- Delete 17 server-archived/ directories (consolidated into apps/api/) - Delete apps-archived/ (clock, wisekeep) and services-archived/ (it-landing, ollama-metrics-proxy) - Fix type safety: replace all `any` casts in cross-app-queries.ts with proper Local* types - Remove duplicate shared-auth-stores package (identical copy of shared-auth-ui/stores/) - Remove duplicate theme store from shared-stores (canonical version in shared-theme) - Migrate memoro-server rate-limiter to shared-hono/rateLimitMiddleware - Migrate uload-server JWT auth + error handler to shared-hono (authMiddleware, errorHandler) - Migrate arcade-server error handling to shared-hono - Merge shared-profile-ui and shared-app-onboarding into shared-ui - Unify /clock route into /times/clock, remove redirect stubs Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
7ee57b7afd
commit
d8ce4eaf34
309 changed files with 172 additions and 21667 deletions
|
|
@ -57,7 +57,6 @@
|
|||
"@manacore/shared-i18n": "workspace:*",
|
||||
"@manacore/shared-icons": "workspace:*",
|
||||
"@manacore/shared-links": "workspace:*",
|
||||
"@manacore/shared-profile-ui": "workspace:*",
|
||||
"@manacore/shared-stores": "workspace:*",
|
||||
"@manacore/shared-tags": "workspace:*",
|
||||
"@manacore/shared-tailwind": "workspace:*",
|
||||
|
|
|
|||
|
|
@ -83,7 +83,7 @@
|
|||
</div>
|
||||
{/if}
|
||||
|
||||
<a href="/clock" class="mt-2 block text-center text-sm text-primary hover:underline">
|
||||
<a href="/times/clock" class="mt-2 block text-center text-sm text-primary hover:underline">
|
||||
Uhr öffnen →
|
||||
</a>
|
||||
{/if}
|
||||
|
|
|
|||
|
|
@ -39,8 +39,8 @@
|
|||
let appColor = $derived(appEntry?.color ?? '#6B7280');
|
||||
|
||||
// ── Cross-module drop target ────────────────────────────
|
||||
let targetEntity = $derived(getEntity(appId));
|
||||
let acceptedDropTypes = $derived(targetEntity?.acceptsDropFrom ?? []);
|
||||
// TODO: re-enable after fixing entity descriptor hang
|
||||
let acceptedDropTypes: string[] = [];
|
||||
|
||||
function handleCrossModuleDrop(payload: DragPayload) {
|
||||
const sourceEntity = getEntityByDragType(payload.type);
|
||||
|
|
|
|||
|
|
@ -8,14 +8,27 @@
|
|||
import { useLiveQueryWithDefault } from '@manacore/local-store/svelte';
|
||||
import { db } from './database';
|
||||
|
||||
import type { LocalTask } from '$lib/modules/todo/types';
|
||||
import type { LocalEvent } from '$lib/modules/calendar/types';
|
||||
import type { LocalContact } from '$lib/modules/contacts/types';
|
||||
import type { LocalConversation } from '$lib/modules/chat/types';
|
||||
import type { LocalFavorite } from '$lib/modules/zitare/types';
|
||||
import type { LocalImage } from '$lib/modules/picture/types';
|
||||
import type { LocalAlarm, LocalCountdownTimer } from '$lib/modules/times/types';
|
||||
import type { LocalFile } from '$lib/modules/storage/types';
|
||||
import type { LocalSong, LocalPlaylist } from '$lib/modules/mukke/types';
|
||||
import type { LocalDeck as LocalPresiDeck } from '$lib/modules/presi/types';
|
||||
import type { LocalDocument, LocalContextSpace } from '$lib/modules/context/types';
|
||||
import type { LocalDeck as LocalCardDeck, LocalCard } from '$lib/modules/cards/types';
|
||||
|
||||
// ─── Todo Queries ───────────────────────────────────────────
|
||||
|
||||
/** All open (incomplete) tasks, sorted by order. */
|
||||
export function useOpenTasks() {
|
||||
return useLiveQueryWithDefault(async () => {
|
||||
const all = await db.table('tasks').orderBy('order').toArray();
|
||||
return all.filter((t: any) => !t.isCompleted && !t.deletedAt);
|
||||
}, [] as any[]);
|
||||
const all = await db.table<LocalTask>('tasks').orderBy('order').toArray();
|
||||
return all.filter((t) => !t.isCompleted && !t.deletedAt);
|
||||
}, [] as LocalTask[]);
|
||||
}
|
||||
|
||||
/** Tasks due today or overdue. */
|
||||
|
|
@ -25,13 +38,13 @@ export function useTodayTasks() {
|
|||
today.setHours(0, 0, 0, 0);
|
||||
const todayStr = today.toISOString().slice(0, 10);
|
||||
|
||||
const all = await db.table('tasks').orderBy('order').toArray();
|
||||
return all.filter((t: any) => {
|
||||
const all = await db.table<LocalTask>('tasks').orderBy('order').toArray();
|
||||
return all.filter((t) => {
|
||||
if (t.isCompleted || t.deletedAt) return false;
|
||||
if (!t.dueDate) return false;
|
||||
return t.dueDate.slice(0, 10) <= todayStr;
|
||||
});
|
||||
}, [] as any[]);
|
||||
}, [] as LocalTask[]);
|
||||
}
|
||||
|
||||
/** Tasks upcoming in the next N days. */
|
||||
|
|
@ -45,14 +58,14 @@ export function useUpcomingTasks(days = 7) {
|
|||
future.setDate(future.getDate() + days);
|
||||
const futureStr = future.toISOString().slice(0, 10);
|
||||
|
||||
const all = await db.table('tasks').orderBy('dueDate').toArray();
|
||||
return all.filter((t: any) => {
|
||||
const all = await db.table<LocalTask>('tasks').orderBy('dueDate').toArray();
|
||||
return all.filter((t) => {
|
||||
if (t.isCompleted || t.deletedAt) return false;
|
||||
if (!t.dueDate) return false;
|
||||
const due = t.dueDate.slice(0, 10);
|
||||
return due > todayStr && due <= futureStr;
|
||||
});
|
||||
}, [] as any[]);
|
||||
}, [] as LocalTask[]);
|
||||
}
|
||||
|
||||
// ─── Calendar Queries ───────────────────────────────────────
|
||||
|
|
@ -67,12 +80,12 @@ export function useUpcomingEvents(days = 7) {
|
|||
const nowStr = now.toISOString();
|
||||
const futureStr = future.toISOString();
|
||||
|
||||
const all = await db.table('events').orderBy('startDate').toArray();
|
||||
return all.filter((e: any) => {
|
||||
const all = await db.table<LocalEvent>('events').orderBy('startDate').toArray();
|
||||
return all.filter((e) => {
|
||||
if (e.deletedAt) return false;
|
||||
return e.startDate >= nowStr && e.startDate <= futureStr;
|
||||
});
|
||||
}, [] as any[]);
|
||||
}, [] as LocalEvent[]);
|
||||
}
|
||||
|
||||
// ─── Contacts Queries ───────────────────────────────────────
|
||||
|
|
@ -80,9 +93,9 @@ export function useUpcomingEvents(days = 7) {
|
|||
/** Favorite contacts. */
|
||||
export function useFavoriteContacts(limit = 5) {
|
||||
return useLiveQueryWithDefault(async () => {
|
||||
const all = await db.table('contacts').orderBy('firstName').toArray();
|
||||
return all.filter((c: any) => c.isFavorite && !c.isArchived && !c.deletedAt).slice(0, limit);
|
||||
}, [] as any[]);
|
||||
const all = await db.table<LocalContact>('contacts').orderBy('firstName').toArray();
|
||||
return all.filter((c) => c.isFavorite && !c.isArchived && !c.deletedAt).slice(0, limit);
|
||||
}, [] as LocalContact[]);
|
||||
}
|
||||
|
||||
// ─── Chat Queries ───────────────────────────────────────────
|
||||
|
|
@ -90,24 +103,27 @@ export function useFavoriteContacts(limit = 5) {
|
|||
/** Recent conversations, sorted by updatedAt desc. */
|
||||
export function useRecentConversations(limit = 5) {
|
||||
return useLiveQueryWithDefault(async () => {
|
||||
const all = await db.table('conversations').toArray();
|
||||
const all = await db.table<LocalConversation>('conversations').toArray();
|
||||
return all
|
||||
.filter((c: any) => !c.isArchived && !c.deletedAt)
|
||||
.sort((a: any, b: any) => (b.updatedAt ?? '').localeCompare(a.updatedAt ?? ''))
|
||||
.filter((c) => !c.isArchived && !c.deletedAt)
|
||||
.sort((a, b) => (b.updatedAt ?? '').localeCompare(a.updatedAt ?? ''))
|
||||
.slice(0, limit);
|
||||
}, [] as any[]);
|
||||
}, [] as LocalConversation[]);
|
||||
}
|
||||
|
||||
// ─── Zitare Queries ─────────────────────────────────────────
|
||||
|
||||
/** A random favorite quote. */
|
||||
export function useRandomFavorite() {
|
||||
return useLiveQueryWithDefault(async () => {
|
||||
const all = await db.table('zitareFavorites').toArray();
|
||||
const active = all.filter((f: any) => !f.deletedAt);
|
||||
if (active.length === 0) return null;
|
||||
return active[Math.floor(Math.random() * active.length)];
|
||||
}, null as any);
|
||||
return useLiveQueryWithDefault(
|
||||
async () => {
|
||||
const all = await db.table<LocalFavorite>('zitareFavorites').toArray();
|
||||
const active = all.filter((f) => !f.deletedAt);
|
||||
if (active.length === 0) return null;
|
||||
return active[Math.floor(Math.random() * active.length)];
|
||||
},
|
||||
null as LocalFavorite | null
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Picture Queries ────────────────────────────────────────
|
||||
|
|
@ -115,12 +131,12 @@ export function useRandomFavorite() {
|
|||
/** Recent generated images. */
|
||||
export function useRecentImages(limit = 6) {
|
||||
return useLiveQueryWithDefault(async () => {
|
||||
const all = await db.table('images').toArray();
|
||||
const all = await db.table<LocalImage>('images').toArray();
|
||||
return all
|
||||
.filter((i: any) => !i.archivedAt && !i.deletedAt)
|
||||
.sort((a: any, b: any) => (b.createdAt ?? '').localeCompare(a.createdAt ?? ''))
|
||||
.filter((i) => !i.isArchived && !i.deletedAt)
|
||||
.sort((a, b) => (b.createdAt ?? '').localeCompare(a.createdAt ?? ''))
|
||||
.slice(0, limit);
|
||||
}, [] as any[]);
|
||||
}, [] as LocalImage[]);
|
||||
}
|
||||
|
||||
// ─── Clock Queries ──────────────────────────────────────────
|
||||
|
|
@ -128,60 +144,71 @@ export function useRecentImages(limit = 6) {
|
|||
/** Enabled alarms. */
|
||||
export function useEnabledAlarms() {
|
||||
return useLiveQueryWithDefault(async () => {
|
||||
const all = await db.table('alarms').toArray();
|
||||
return all.filter((a: any) => a.enabled && !a.deletedAt);
|
||||
}, [] as any[]);
|
||||
const all = await db.table<LocalAlarm>('alarms').toArray();
|
||||
return all.filter((a) => a.enabled && !a.deletedAt);
|
||||
}, [] as LocalAlarm[]);
|
||||
}
|
||||
|
||||
/** Active/running timers. */
|
||||
export function useActiveTimers() {
|
||||
return useLiveQueryWithDefault(async () => {
|
||||
const all = await db.table('timers').toArray();
|
||||
return all.filter(
|
||||
(t: any) => (t.status === 'running' || t.status === 'paused') && !t.deletedAt
|
||||
);
|
||||
}, [] as any[]);
|
||||
const all = await db.table<LocalCountdownTimer>('timers').toArray();
|
||||
return all.filter((t) => (t.status === 'running' || t.status === 'paused') && !t.deletedAt);
|
||||
}, [] as LocalCountdownTimer[]);
|
||||
}
|
||||
|
||||
// ─── Storage Queries ────────────────────────────────────────
|
||||
|
||||
interface StorageStats {
|
||||
totalFiles: number;
|
||||
totalSize: number;
|
||||
recentFiles: LocalFile[];
|
||||
}
|
||||
|
||||
/** Storage stats: total files and total size. */
|
||||
export function useStorageStats() {
|
||||
return useLiveQueryWithDefault(
|
||||
async () => {
|
||||
const files = await db.table('files').toArray();
|
||||
const active = files.filter((f: any) => !f.isDeleted && !f.deletedAt);
|
||||
const totalSize = active.reduce((sum: number, f: any) => sum + (f.size || 0), 0);
|
||||
async (): Promise<StorageStats> => {
|
||||
const files = await db.table<LocalFile>('files').toArray();
|
||||
const active = files.filter((f) => !f.isDeleted && !f.deletedAt);
|
||||
const totalSize = active.reduce((sum, f) => sum + (f.size || 0), 0);
|
||||
const recent = active
|
||||
.sort((a: any, b: any) => (b.updatedAt ?? '').localeCompare(a.updatedAt ?? ''))
|
||||
.sort((a, b) => (b.updatedAt ?? '').localeCompare(a.updatedAt ?? ''))
|
||||
.slice(0, 5);
|
||||
return { totalFiles: active.length, totalSize, recentFiles: recent };
|
||||
},
|
||||
{ totalFiles: 0, totalSize: 0, recentFiles: [] as any[] }
|
||||
{ totalFiles: 0, totalSize: 0, recentFiles: [] as LocalFile[] }
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Mukke Queries ──────────────────────────────────────────
|
||||
|
||||
interface MukkeStats {
|
||||
totalSongs: number;
|
||||
totalPlaylists: number;
|
||||
favoriteCount: number;
|
||||
recentSongs: LocalSong[];
|
||||
}
|
||||
|
||||
/** Mukke library stats + recent songs. */
|
||||
export function useMukkeStats() {
|
||||
return useLiveQueryWithDefault(
|
||||
async () => {
|
||||
const songs = await db.table('songs').toArray();
|
||||
const playlists = await db.table('mukkePlaylists').toArray();
|
||||
const activeSongs = songs.filter((s: any) => !s.deletedAt);
|
||||
const activePlaylists = playlists.filter((p: any) => !p.deletedAt);
|
||||
async (): Promise<MukkeStats> => {
|
||||
const songs = await db.table<LocalSong>('songs').toArray();
|
||||
const playlists = await db.table<LocalPlaylist>('mukkePlaylists').toArray();
|
||||
const activeSongs = songs.filter((s) => !s.deletedAt);
|
||||
const activePlaylists = playlists.filter((p) => !p.deletedAt);
|
||||
const recent = activeSongs
|
||||
.sort((a: any, b: any) => (b.updatedAt ?? '').localeCompare(a.updatedAt ?? ''))
|
||||
.sort((a, b) => (b.updatedAt ?? '').localeCompare(a.updatedAt ?? ''))
|
||||
.slice(0, 5);
|
||||
return {
|
||||
totalSongs: activeSongs.length,
|
||||
totalPlaylists: activePlaylists.length,
|
||||
favoriteCount: activeSongs.filter((s: any) => s.favorite).length,
|
||||
favoriteCount: activeSongs.filter((s) => s.favorite).length,
|
||||
recentSongs: recent,
|
||||
};
|
||||
},
|
||||
{ totalSongs: 0, totalPlaylists: 0, favoriteCount: 0, recentSongs: [] as any[] }
|
||||
{ totalSongs: 0, totalPlaylists: 0, favoriteCount: 0, recentSongs: [] as LocalSong[] }
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -190,12 +217,12 @@ export function useMukkeStats() {
|
|||
/** Recent presentation decks. */
|
||||
export function useRecentDecks(limit = 5) {
|
||||
return useLiveQueryWithDefault(async () => {
|
||||
const all = await db.table('presiDecks').toArray();
|
||||
const all = await db.table<LocalPresiDeck>('presiDecks').toArray();
|
||||
return all
|
||||
.filter((d: any) => !d.deletedAt)
|
||||
.sort((a: any, b: any) => (b.updatedAt ?? '').localeCompare(a.updatedAt ?? ''))
|
||||
.filter((d) => !d.deletedAt)
|
||||
.sort((a, b) => (b.updatedAt ?? '').localeCompare(a.updatedAt ?? ''))
|
||||
.slice(0, limit);
|
||||
}, [] as any[]);
|
||||
}, [] as LocalPresiDeck[]);
|
||||
}
|
||||
|
||||
// ─── Context Queries ────────────────────────────────────────
|
||||
|
|
@ -203,43 +230,51 @@ export function useRecentDecks(limit = 5) {
|
|||
/** Recent documents + spaces. */
|
||||
export function useRecentDocuments(limit = 5) {
|
||||
return useLiveQueryWithDefault(async () => {
|
||||
const all = await db.table('documents').toArray();
|
||||
const all = await db.table<LocalDocument>('documents').toArray();
|
||||
return all
|
||||
.filter((d: any) => !d.deletedAt)
|
||||
.sort((a: any, b: any) => (b.updatedAt ?? '').localeCompare(a.updatedAt ?? ''))
|
||||
.filter((d) => !d.deletedAt)
|
||||
.sort((a, b) => (b.updatedAt ?? '').localeCompare(a.updatedAt ?? ''))
|
||||
.slice(0, limit);
|
||||
}, [] as any[]);
|
||||
}, [] as LocalDocument[]);
|
||||
}
|
||||
|
||||
export function useSpaces() {
|
||||
return useLiveQueryWithDefault(async () => {
|
||||
const all = await db.table('contextSpaces').toArray();
|
||||
const all = await db.table<LocalContextSpace>('contextSpaces').toArray();
|
||||
return all
|
||||
.filter((s: any) => !s.deletedAt)
|
||||
.sort((a: any, b: any) => {
|
||||
.filter((s) => !s.deletedAt)
|
||||
.sort((a, b) => {
|
||||
if (a.pinned && !b.pinned) return -1;
|
||||
if (!a.pinned && b.pinned) return 1;
|
||||
return 0;
|
||||
});
|
||||
}, [] as any[]);
|
||||
}, [] as LocalContextSpace[]);
|
||||
}
|
||||
|
||||
// ─── Cards Queries ─────────────────────────────────────────
|
||||
|
||||
interface CardsProgress {
|
||||
totalDecks: number;
|
||||
totalCards: number;
|
||||
cardsLearned: number;
|
||||
dueForReview: number;
|
||||
decks: LocalCardDeck[];
|
||||
}
|
||||
|
||||
/** Cards learning progress. */
|
||||
export function useCardsProgress() {
|
||||
return useLiveQueryWithDefault(
|
||||
async () => {
|
||||
const decks = await db.table('cardDecks').toArray();
|
||||
const cards = await db.table('cards').toArray();
|
||||
const activeDecks = decks.filter((d: any) => !d.deletedAt);
|
||||
const activeCards = cards.filter((c: any) => !c.deletedAt);
|
||||
async (): Promise<CardsProgress> => {
|
||||
const decks = await db.table<LocalCardDeck>('cardDecks').toArray();
|
||||
const cards = await db.table<LocalCard>('cards').toArray();
|
||||
const activeDecks = decks.filter((d) => !d.deletedAt);
|
||||
const activeCards = cards.filter((c) => !c.deletedAt);
|
||||
const now = new Date().toISOString();
|
||||
const dueCards = activeCards.filter((c: any) => c.nextReview && c.nextReview <= now);
|
||||
const dueCards = activeCards.filter((c) => c.nextReview && c.nextReview <= now);
|
||||
return {
|
||||
totalDecks: activeDecks.length,
|
||||
totalCards: activeCards.length,
|
||||
cardsLearned: activeCards.filter((c: any) => (c.reviewCount ?? 0) > 0).length,
|
||||
cardsLearned: activeCards.filter((c) => (c.reviewCount ?? 0) > 0).length,
|
||||
dueForReview: dueCards.length,
|
||||
decks: activeDecks,
|
||||
};
|
||||
|
|
@ -249,7 +284,7 @@ export function useCardsProgress() {
|
|||
totalCards: 0,
|
||||
cardsLearned: 0,
|
||||
dueForReview: 0,
|
||||
decks: [] as any[],
|
||||
decks: [] as LocalCardDeck[],
|
||||
}
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +0,0 @@
|
|||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
let { children }: { children: Snippet } = $props();
|
||||
</script>
|
||||
|
||||
{@render children()}
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
onMount(() => {
|
||||
goto('/times/clock', { replaceState: true });
|
||||
});
|
||||
</script>
|
||||
|
||||
<p>Redirecting...</p>
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
onMount(() => {
|
||||
goto('/times/clock/alarms', { replaceState: true });
|
||||
});
|
||||
</script>
|
||||
|
||||
<p>Redirecting...</p>
|
||||
|
|
@ -1,11 +1,15 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { ProfilePage } from '@manacore/shared-profile-ui';
|
||||
import type { UserProfile, ProfileActions } from '@manacore/shared-profile-ui';
|
||||
import { ProfilePage } from '@manacore/shared-ui';
|
||||
import type { UserProfile, ProfileActions } from '@manacore/shared-ui';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { profileService, type UserProfile as ApiUserProfile } from '$lib/api/profile';
|
||||
import { EditProfileModal, ChangePasswordModal, DeleteAccountModal } from '$lib/components/profile';
|
||||
import {
|
||||
EditProfileModal,
|
||||
ChangePasswordModal,
|
||||
DeleteAccountModal,
|
||||
} from '$lib/components/profile';
|
||||
|
||||
// Profile data from API
|
||||
let apiProfile = $state<ApiUserProfile | null>(null);
|
||||
|
|
@ -84,7 +88,9 @@
|
|||
|
||||
{#if loading}
|
||||
<div class="flex items-center justify-center py-12">
|
||||
<div class="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent"></div>
|
||||
<div
|
||||
class="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent"
|
||||
></div>
|
||||
</div>
|
||||
{:else}
|
||||
<ProfilePage
|
||||
|
|
@ -130,7 +136,9 @@
|
|||
|
||||
<!-- Toast Notification -->
|
||||
{#if toastMessage}
|
||||
<div class="fixed bottom-4 right-4 z-50 px-4 py-3 bg-green-600 text-white rounded-lg shadow-lg animate-fade-in">
|
||||
<div
|
||||
class="fixed bottom-4 right-4 z-50 px-4 py-3 bg-green-600 text-white rounded-lg shadow-lg animate-fade-in"
|
||||
>
|
||||
{toastMessage}
|
||||
</div>
|
||||
{/if}
|
||||
|
|
|
|||
|
|
@ -3,28 +3,28 @@
|
|||
|
||||
const quickLinks = [
|
||||
{
|
||||
href: '/clock/world-clock',
|
||||
href: '/times/clock/world-clock',
|
||||
icon: Globe,
|
||||
label: 'Weltzeituhr',
|
||||
description: 'Zeitzonen im Blick',
|
||||
color: 'bg-blue-500',
|
||||
},
|
||||
{
|
||||
href: '/clock/alarms',
|
||||
href: '/times/clock/alarms',
|
||||
icon: Bell,
|
||||
label: 'Wecker',
|
||||
description: 'Alarme verwalten',
|
||||
color: 'bg-amber-500',
|
||||
},
|
||||
{
|
||||
href: '/clock/timers',
|
||||
href: '/times/clock/timers',
|
||||
icon: Timer,
|
||||
label: 'Timer',
|
||||
description: 'Countdowns starten',
|
||||
color: 'bg-green-500',
|
||||
},
|
||||
{
|
||||
href: '/clock/stopwatch',
|
||||
href: '/times/clock/stopwatch',
|
||||
icon: Hourglass,
|
||||
label: 'Stoppuhr',
|
||||
description: 'Zeit messen',
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue