chore: archive 25 standalone web apps, move wisekeep to apps-archived

All standalone SvelteKit web apps have been superseded by the unified
ManaCore app (apps/manacore/apps/web). Moved to web-archived/ within
each project to preserve history while removing from active workspace.

Archived: calc, cards, chat, citycorners, contacts, context, guides,
inventar, moodlit, mukke, news, nutriphi, photos, picture, planta,
presi, questions, skilltree, storage, times, zitare, todo, calendar,
uload, memoro

Moved to apps-archived/: wisekeep (not integrated, inactive)

Kept active: manacore (unified), matrix, manavoxel, arcade (separate containers)

Server, landing, and package directories remain active for each project.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-02 20:14:29 +02:00
parent 373976a11b
commit 2eb1a0cd76
1994 changed files with 278 additions and 1310 deletions

View file

@ -0,0 +1,13 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
--color-primary: theme('colors.primary.600');
--color-primary-hover: theme('colors.primary.700');
}
body {
@apply bg-gray-50 text-gray-900 antialiased;
font-family: system-ui, -apple-system, sans-serif;
}

View file

@ -0,0 +1,13 @@
// See https://kit.svelte.dev/docs/types#app
// for information about these interfaces
declare global {
namespace App {
// interface Error {}
// interface Locals {}
// interface PageData {}
// interface PageState {}
// interface Platform {}
}
}
export {};

View file

@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

View file

@ -0,0 +1,11 @@
<script lang="ts">
let { size = 48, color = '#8b5cf6' }: { size?: number; color?: string } = $props();
</script>
<svg width={size} height={size} viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
<rect width="100" height="100" rx="22" fill={color} />
<circle cx="50" cy="40" r="12" stroke="white" stroke-width="4" fill="none" />
<line x1="50" y1="52" x2="50" y2="72" stroke="white" stroke-width="4" stroke-linecap="round" />
<line x1="38" y1="62" x2="50" y2="72" stroke="white" stroke-width="3" stroke-linecap="round" />
<line x1="62" y1="62" x2="50" y2="72" stroke="white" stroke-width="3" stroke-linecap="round" />
</svg>

View file

@ -0,0 +1,5 @@
import { createManaAuthStore } from '@manacore/shared-auth-stores';
export const authStore = createManaAuthStore({
devBackendPort: 3072,
});

View file

@ -0,0 +1,5 @@
<script lang="ts">
let { children } = $props();
</script>
{@render children()}

View file

@ -0,0 +1,22 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { LoginPage } from '@manacore/shared-auth-ui';
import WisekeepLogo from '$lib/components/WisekeepLogo.svelte';
import { authStore } from '$lib/stores/auth.svelte';
</script>
<LoginPage
appName="Wisekeep"
logo={WisekeepLogo}
primaryColor="#8b5cf6"
onSignIn={(email, password) => authStore.signIn(email, password)}
onResendVerification={(email) => authStore.resendVerificationEmail(email)}
passkeyAvailable={authStore.isPasskeyAvailable()}
onSignInWithPasskey={() => authStore.signInWithPasskey()}
onVerifyTwoFactor={(code, trust) => authStore.verifyTwoFactor(code, trust)}
onVerifyBackupCode={(code) => authStore.verifyBackupCode(code)}
onSendMagicLink={(email) => authStore.sendMagicLink(email)}
{goto}
successRedirect="/transcribe"
registerPath="/auth/register"
/>

View file

@ -0,0 +1,16 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { RegisterPage } from '@manacore/shared-auth-ui';
import WisekeepLogo from '$lib/components/WisekeepLogo.svelte';
import { authStore } from '$lib/stores/auth.svelte';
</script>
<RegisterPage
appName="Wisekeep"
logo={WisekeepLogo}
primaryColor="#8b5cf6"
onSignUp={(email, password) => authStore.signUp(email, password)}
{goto}
successRedirect="/transcribe"
loginPath="/auth/login"
/>

View file

@ -0,0 +1,103 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { PillNavigation } from '@manacore/shared-ui';
import type { PillNavItem } from '@manacore/shared-ui';
import { getPillAppItems, getManaApp } from '@manacore/shared-branding';
import { AuthGate, GuestWelcomeModal, SessionExpiredBanner } from '@manacore/shared-auth-ui';
import { shouldShowGuestWelcome } from '@manacore/shared-auth-ui';
import { authStore } from '$lib/stores/auth.svelte';
import { wisekeepStore } from '$lib/data/local-store';
let { children } = $props();
let appItems = $derived(getPillAppItems(undefined, undefined, undefined, authStore.user?.tier));
let userEmail = $derived(authStore.isAuthenticated ? (authStore.user?.email ?? '') : '');
let showGuestWelcome = $state(false);
let isCollapsed = $state(false);
let isDark = $state(true);
const navItems: PillNavItem[] = [
{ href: '/transcribe', label: 'Transkribieren', icon: 'mic' },
{ href: '/transcripts', label: 'Bibliothek', icon: 'book' },
{ href: '/playlists', label: 'Playlists', icon: 'list' },
];
function handleCollapsedChange(collapsed: boolean) {
isCollapsed = collapsed;
localStorage?.setItem('wisekeep-collapsed', String(collapsed));
}
function handleToggleTheme() {
isDark = !isDark;
document.documentElement.classList.toggle('dark', isDark);
localStorage?.setItem('wisekeep-dark', String(isDark));
}
async function handleLogout() {
wisekeepStore.stopSync();
await authStore.signOut();
goto('/auth/login');
}
function handleAuthReady() {
if (authStore.isAuthenticated) wisekeepStore.startSync(() => authStore.getValidToken());
if (!authStore.isAuthenticated && shouldShowGuestWelcome('wisekeep')) showGuestWelcome = true;
const c = localStorage?.getItem('wisekeep-collapsed');
if (c === 'true') isCollapsed = true;
const d = localStorage?.getItem('wisekeep-dark');
isDark = d !== 'false';
document.documentElement.classList.toggle('dark', isDark);
}
</script>
<AuthGate
{authStore}
{goto}
allowGuest={true}
onReady={handleAuthReady}
requiredTier={getManaApp('wisekeep')?.requiredTier}
appName={getManaApp('wisekeep')?.name}
>
<div class="flex min-h-screen flex-col">
<PillNavigation
items={navItems}
currentPath={$page.url.pathname}
appName="Wisekeep"
homeRoute="/transcribe"
onLogout={handleLogout}
onToggleTheme={handleToggleTheme}
{isDark}
{isCollapsed}
onCollapsedChange={handleCollapsedChange}
showThemeToggle={true}
primaryColor="#8b5cf6"
showAppSwitcher={true}
{appItems}
{userEmail}
settingsHref="/settings"
>
{#snippet logo()}
<span class="text-xl">🎙️</span>
<span class="pill-label font-bold">Wisekeep</span>
{/snippet}
</PillNavigation>
<main class="main-content flex-1 transition-all duration-300 {isCollapsed ? '' : 'pt-20'}">
<div class="container mx-auto px-4 py-8">
{@render children()}
</div>
</main>
</div>
<GuestWelcomeModal
appId="wisekeep"
visible={showGuestWelcome}
onClose={() => (showGuestWelcome = false)}
onLogin={() => goto('/auth/login')}
onRegister={() => goto('/auth/register')}
locale="de"
/>
{#if authStore.isAuthenticated}
<SessionExpiredBanner locale="de" loginHref="/auth/login" />
{/if}
</AuthGate>

View file

@ -0,0 +1,91 @@
<script lang="ts">
import { useLiveQuery } from '@manacore/local-store/svelte';
import { playlistCollection } from '$lib/data/local-store';
import { toast } from 'svelte-sonner';
import { Trash } from '@manacore/shared-icons';
const playlists = useLiveQuery(() => playlistCollection.getAll({}, { sortBy: 'order' }));
let newName = $state('');
let newCategory = $state('');
async function createPlaylist() {
if (!newName || !newCategory) return;
await playlistCollection.insert({
id: crypto.randomUUID(),
name: newName,
category: newCategory,
order: playlists.value?.length ?? 0,
});
toast.success(`Playlist "${newName}" erstellt`);
newName = '';
newCategory = '';
}
async function deletePlaylist(id: string, name: string) {
if (!confirm(`"${name}" löschen?`)) return;
await playlistCollection.delete(id);
toast.success('Gelöscht');
}
</script>
<div class="mx-auto max-w-2xl">
<h1 class="mb-6 text-3xl font-bold">Playlists</h1>
<div class="mb-6 rounded-xl border border-gray-800 bg-gray-900 p-5">
<div class="flex gap-3">
<input
type="text"
bind:value={newCategory}
placeholder="Kategorie"
class="w-1/3 rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-gray-100 placeholder-gray-500"
/>
<input
type="text"
bind:value={newName}
placeholder="Name"
class="flex-1 rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-gray-100 placeholder-gray-500"
onkeydown={(e) => e.key === 'Enter' && createPlaylist()}
/>
<button
onclick={createPlaylist}
disabled={!newName || !newCategory}
class="rounded-lg bg-violet-600 px-4 py-2 text-sm font-medium text-white hover:bg-violet-700 disabled:opacity-50"
>Erstellen</button
>
</div>
</div>
{#if playlists.loading}
<div class="space-y-3">
{#each Array(2) as _}
<div class="h-16 animate-pulse rounded-xl bg-gray-800"></div>
{/each}
</div>
{:else if !playlists.value?.length}
<div class="rounded-xl border-2 border-dashed border-gray-700 p-12 text-center">
<p class="text-lg font-medium text-gray-400">Keine Playlists</p>
</div>
{:else}
<div class="space-y-3">
{#each playlists.value as pl (pl.id)}
<div
class="group flex items-center justify-between rounded-xl border border-gray-800 bg-gray-900 p-4 hover:border-gray-700"
>
<div>
<span class="rounded bg-violet-900 px-2 py-0.5 text-xs text-violet-300"
>{pl.category}</span
>
<span class="ml-2 font-semibold">{pl.name}</span>
</div>
<button
onclick={() => deletePlaylist(pl.id, pl.name)}
class="rounded p-1 text-gray-500 opacity-0 hover:bg-gray-800 hover:text-red-400 group-hover:opacity-100"
>
<Trash size={16} />
</button>
</div>
{/each}
</div>
{/if}
</div>

View file

@ -0,0 +1,99 @@
<script lang="ts">
import { transcriptCollection } from '$lib/data/local-store';
import { authStore } from '$lib/stores/auth.svelte';
import { toast } from 'svelte-sonner';
const SERVER = import.meta.env.PUBLIC_WISEKEEP_SERVER_URL || 'http://localhost:3072';
let url = $state('');
let language = $state('de');
let transcribing = $state(false);
async function transcribe() {
if (!url) return;
transcribing = true;
try {
const token = authStore.isAuthenticated ? await authStore.getValidToken() : null;
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
if (token) headers['Authorization'] = `Bearer ${token}`;
const res = await fetch(`${SERVER}/api/v1/transcribe`, {
method: 'POST',
headers,
body: JSON.stringify({ url, language }),
});
if (!res.ok) throw new Error('Transcription failed');
const result = await res.json();
await transcriptCollection.insert({
id: result.id,
url,
title: result.title,
channel: result.channel,
duration: result.duration,
transcript: result.transcript,
language: result.language,
model: result.model,
status: 'completed',
isArchived: false,
});
toast.success(`"${result.title}" transkribiert!`);
url = '';
} catch (err) {
toast.error('Transkription fehlgeschlagen. Ist der Server erreichbar?');
}
transcribing = false;
}
</script>
<div class="mx-auto max-w-2xl">
<h1 class="mb-6 text-3xl font-bold">Transkribieren</h1>
<div class="rounded-xl border border-gray-800 bg-gray-900 p-6">
<div class="space-y-4">
<div>
<label for="url" class="mb-1 block text-sm font-medium text-gray-300">YouTube URL</label>
<input
id="url"
type="url"
bind:value={url}
placeholder="https://www.youtube.com/watch?v=..."
class="w-full rounded-lg border border-gray-700 bg-gray-800 px-4 py-3 text-gray-100 placeholder-gray-500 focus:border-violet-500 focus:outline-none"
onkeydown={(e) => e.key === 'Enter' && transcribe()}
/>
</div>
<div>
<label for="lang" class="mb-1 block text-sm font-medium text-gray-300">Sprache</label>
<select
id="lang"
bind:value={language}
class="rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-gray-100"
>
<option value="de">Deutsch</option>
<option value="en">English</option>
<option value="fr">Français</option>
<option value="es">Español</option>
</select>
</div>
<button
onclick={transcribe}
disabled={!url || transcribing}
class="w-full rounded-lg bg-violet-600 py-3 font-medium text-white hover:bg-violet-700 disabled:opacity-50"
>
{transcribing ? 'Wird transkribiert...' : 'Transkribieren'}
</button>
</div>
{#if transcribing}
<div class="mt-4 rounded-lg bg-violet-900/20 p-4 text-center">
<div
class="mb-2 inline-block h-6 w-6 animate-spin rounded-full border-2 border-violet-500 border-r-transparent"
></div>
<p class="text-sm text-violet-300">
Video wird heruntergeladen und transkribiert... Dies kann einige Minuten dauern.
</p>
</div>
{/if}
</div>
</div>

View file

@ -0,0 +1,116 @@
<script lang="ts">
import { useLiveQuery } from '@manacore/local-store/svelte';
import { transcriptCollection } from '$lib/data/local-store';
import type { LocalTranscript } from '$lib/data/local-store';
import { toast } from 'svelte-sonner';
import { CaretDown } from '@manacore/shared-icons';
const transcripts = useLiveQuery(() => transcriptCollection.getAll({ isArchived: false }));
let searchQuery = $state('');
let expandedId = $state<string | null>(null);
let filtered = $derived.by(() => {
const all = transcripts.value ?? [];
if (!searchQuery) return all;
const q = searchQuery.toLowerCase();
return all.filter(
(t) =>
t.title.toLowerCase().includes(q) ||
t.transcript.toLowerCase().includes(q) ||
t.channel?.toLowerCase().includes(q)
);
});
async function deleteTranscript(t: LocalTranscript) {
if (!confirm(`"${t.title}" löschen?`)) return;
await transcriptCollection.delete(t.id);
toast.success('Gelöscht');
}
function formatDuration(seconds: number | null | undefined): string {
if (!seconds) return '';
const m = Math.floor(seconds / 60);
const s = seconds % 60;
return `${m}:${String(s).padStart(2, '0')}`;
}
</script>
<div class="mx-auto max-w-4xl">
<div class="mb-6 flex items-center justify-between">
<h1 class="text-3xl font-bold">Bibliothek</h1>
<span class="text-sm text-gray-500">{filtered.length} Transkripte</span>
</div>
<input
type="text"
bind:value={searchQuery}
placeholder="Transkripte durchsuchen..."
class="mb-4 w-full rounded-lg border border-gray-700 bg-gray-800 px-4 py-2 text-sm text-gray-100 placeholder-gray-500 focus:border-violet-500 focus:outline-none"
/>
{#if transcripts.loading}
<div class="space-y-3">
{#each Array(3) as _}
<div class="h-20 animate-pulse rounded-xl bg-gray-800"></div>
{/each}
</div>
{:else if filtered.length === 0}
<div class="rounded-xl border-2 border-dashed border-gray-700 p-12 text-center">
<p class="text-lg font-medium text-gray-400">Keine Transkripte</p>
<p class="mt-1 text-sm text-gray-500">Transkribiere ein Video um zu starten.</p>
<a
href="/transcribe"
class="mt-4 inline-block rounded-lg bg-violet-600 px-4 py-2 text-sm text-white hover:bg-violet-700"
>Transkribieren</a
>
</div>
{:else}
<div class="space-y-3">
{#each filtered as t (t.id)}
<div
class="rounded-xl border border-gray-800 bg-gray-900 transition-all hover:border-gray-700"
>
<button
onclick={() => (expandedId = expandedId === t.id ? null : t.id)}
class="flex w-full items-center justify-between p-4 text-left"
>
<div class="min-w-0 flex-1">
<h3 class="truncate font-semibold text-gray-100">{t.title}</h3>
<div class="mt-1 flex items-center gap-3 text-xs text-gray-500">
{#if t.channel}<span>{t.channel}</span>{/if}
{#if t.duration}<span>{formatDuration(t.duration)}</span>{/if}
<span>{t.language.toUpperCase()}</span>
</div>
</div>
<CaretDown
size={20}
class="shrink-0 text-gray-500 transition-transform {expandedId === t.id
? 'rotate-180'
: ''}"
/>
</button>
{#if expandedId === t.id}
<div class="border-t border-gray-800 p-4">
<pre
class="max-h-96 overflow-y-auto whitespace-pre-wrap text-sm text-gray-300">{t.transcript}</pre>
<div class="mt-3 flex gap-2">
<button
onclick={() => {
navigator.clipboard.writeText(t.transcript);
toast.success('Kopiert!');
}}
class="rounded px-3 py-1 text-sm text-gray-400 hover:bg-gray-800">Kopieren</button
>
<button
onclick={() => deleteTranscript(t)}
class="rounded px-3 py-1 text-sm text-red-400 hover:bg-red-900/20">Löschen</button
>
</div>
</div>
{/if}
</div>
{/each}
</div>
{/if}
</div>

View file

@ -0,0 +1,37 @@
<script lang="ts">
import '../app.css';
import { onMount } from 'svelte';
import { Toaster } from 'svelte-sonner';
import { authStore } from '$lib/stores/auth.svelte';
import { wisekeepStore } from '$lib/data/local-store';
let { children } = $props();
let loading = $state(true);
onMount(async () => {
await authStore.initialize();
await wisekeepStore.initialize();
loading = false;
});
</script>
{#if loading}
<div class="flex min-h-screen items-center justify-center bg-gray-950">
<div
class="inline-block h-10 w-10 animate-spin rounded-full border-4 border-solid border-violet-500 border-r-transparent"
></div>
</div>
{:else}
<div class="min-h-screen bg-gray-950 text-gray-100">
{@render children()}
</div>
{/if}
<Toaster
position="bottom-right"
expand={false}
richColors
closeButton
duration={4000}
visibleToasts={3}
/>

View file

@ -0,0 +1,5 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
onMount(() => goto('/transcribe'));
</script>