Files
wisp/src/app/index.tsx
T
NilsBriggen 8db59d4bbe
CI / test (push) Successful in 16s
CI / build-apk (push) Has been skipped
CI / deploy-web (push) Successful in 29s
feat(phase0): courses, migrations, persisted audio, stable segment IDs, course-aware UI
Data layer (Zod-gated, behind StorageRepo; web Dexie v2 + native expo-sqlite
user_version migrations, both with no-data-loss segment-id backfill):
- Course entity + courseId/lectureDate/instructor/location/tags/lectureNumber on
  transcripts; listCourses/createCourse/updateCourse/deleteCourse, listByCourse,
  reassign. Stable per-segment ids assigned on create/update + backfilled.
- Persisted source audio (Dexie blob / expo-file-system) via putMedia/getMediaUrl;
  the editor replays it after reload.

UI/stores:
- Pre-capture sheet (title + course + lecture date + language) before transcribing.
- Course-aware library home (All/Unsorted/per-course filter) + Courses screen.
- coursesStore; transcriptsStore course filter + reassign; transcribeStore threads
  course/date and persists media.

16 web-repo tests incl. v1->v2 migration no-data-loss; 148 total green; web export OK.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 13:54:38 +02:00

250 lines
9.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Link, useFocusEffect, useRouter } from 'expo-router';
import { useCallback, useState } from 'react';
import {
ActivityIndicator,
Pressable,
ScrollView,
StyleSheet,
TextInput,
View,
} from 'react-native';
import { PreCaptureSheet, type PreCaptureResult } from '@/components/PreCaptureSheet';
import { ThemedText } from '@/components/themed-text';
import { ThemedView } from '@/components/themed-view';
import { MaxContentWidth, Spacing } from '@/constants/theme';
import { useTheme } from '@/hooks/use-theme';
import type { TranscriptMeta } from '@/lib/db';
import { formatClock } from '@/lib/format';
import { MODELS } from '@/lib/models/catalog';
import { pickAudio, type PickedAudio } from '@/lib/pickAudio';
import { useCourses } from '@/stores/coursesStore';
import { useTranscribe } from '@/stores/transcribeStore';
import { useTranscripts, type CourseFilter } from '@/stores/transcriptsStore';
export default function LibraryScreen() {
const theme = useTheme();
const router = useRouter();
const { items, loading, query, setQuery, refresh, remove, courseFilter, setCourseFilter } =
useTranscripts();
const courses = useCourses((s) => s.items);
const refreshCourses = useCourses((s) => s.refresh);
const job = useTranscribe();
const [picked, setPicked] = useState<PickedAudio | null>(null);
useFocusEffect(
useCallback(() => {
void refresh();
void refreshCourses();
}, [refresh, refreshCourses]),
);
const busy = job.status === 'loading' || job.status === 'transcribing';
const onNew = useCallback(async () => {
if (busy) return;
const p = await pickAudio();
if (p) setPicked(p); // opens the pre-capture sheet
}, [busy]);
const onStart = useCallback(
async (r: PreCaptureResult) => {
const p = picked;
setPicked(null);
if (!p) return;
const id = await useTranscribe.getState().start(p, r);
if (id) router.push({ pathname: '/transcript/[id]', params: { id } });
},
[picked, router],
);
const courseName = (cid?: string | null) =>
cid ? courses.find((c) => c.id === cid)?.name ?? 'Course' : null;
return (
<ThemedView style={styles.fill}>
<ScrollView contentContainerStyle={styles.content}>
<View style={styles.headerRow}>
<View style={styles.flex}>
<ThemedText type="subtitle">Wisp</ThemedText>
<ThemedText type="small" themeColor="textSecondary">
Private transcription runs on your device, nothing uploaded.
</ThemedText>
</View>
<View style={styles.headerLinks}>
<Link href="/courses" asChild>
<Pressable hitSlop={8}>
<ThemedText type="link" themeColor="textSecondary">Courses</ThemedText>
</Pressable>
</Link>
<Link href="/settings" asChild>
<Pressable hitSlop={8}>
<ThemedText type="link" themeColor="textSecondary"> Settings</ThemedText>
</Pressable>
</Link>
</View>
</View>
<Pressable
onPress={onNew}
disabled={busy}
style={({ pressed }) => [
styles.newButton,
{ backgroundColor: '#3c87f7', opacity: busy ? 0.5 : pressed ? 0.85 : 1 },
]}>
<ThemedText style={styles.newButtonText}> New transcription</ThemedText>
</Pressable>
{busy && <ActiveJob />}
{job.status === 'error' && (
<ThemedView type="backgroundElement" style={styles.card}>
<ThemedText type="smallBold">Transcription failed</ThemedText>
<ThemedText type="small" themeColor="textSecondary">{job.error}</ThemedText>
</ThemedView>
)}
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={styles.filterBar}>
<FilterChip label="All" active={courseFilter === 'all'} onPress={() => void setCourseFilter('all')} />
<FilterChip label="Unsorted" active={courseFilter === null} onPress={() => void setCourseFilter(null)} />
{courses.map((c) => (
<FilterChip key={c.id} label={c.name} active={courseFilter === c.id} onPress={() => void setCourseFilter(c.id)} />
))}
</ScrollView>
<TextInput
value={query}
onChangeText={(t) => void setQuery(t)}
placeholder="Search transcripts…"
placeholderTextColor={theme.textSecondary}
style={[styles.search, { color: theme.text, backgroundColor: theme.backgroundElement }]}
/>
{loading && items.length === 0 ? (
<ActivityIndicator style={styles.pad} />
) : items.length === 0 ? (
<ThemedText type="small" themeColor="textSecondary" style={styles.pad}>
{query ? 'No matches.' : 'No transcripts here yet. Pick an audio or video file to begin.'}
</ThemedText>
) : (
items.map((t) => (
<TranscriptRow
key={t.id}
item={t}
courseName={courseName(t.courseId)}
onOpen={() => router.push({ pathname: '/transcript/[id]', params: { id: t.id } })}
onDelete={() => void remove(t.id)}
/>
))
)}
</ScrollView>
<PreCaptureSheet
visible={picked !== null}
defaultTitle={picked?.name ?? 'Recording'}
onStart={(r) => void onStart(r)}
onCancel={() => setPicked(null)}
/>
</ThemedView>
);
}
function FilterChip({ label, active, onPress }: { label: string; active: boolean; onPress: () => void }) {
const theme = useTheme();
return (
<Pressable
onPress={onPress}
style={[styles.filterChip, { backgroundColor: active ? '#3c87f7' : theme.backgroundElement }]}>
<ThemedText type="small" style={active ? styles.chipActive : undefined}>{label}</ThemedText>
</Pressable>
);
}
function ActiveJob() {
const { stage, progress, partial, cancel } = useTranscribe();
const pct = Math.round(progress * 100);
return (
<ThemedView type="backgroundElement" style={styles.card}>
<View style={styles.rowBetween}>
<ThemedText type="smallBold">
{stage === 'loading' ? 'Loading model…' : 'Transcribing…'} {pct}%
</ThemedText>
<Pressable onPress={cancel} hitSlop={8}>
<ThemedText type="small" themeColor="textSecondary">Cancel</ThemedText>
</Pressable>
</View>
<ProgressBar value={progress} />
{partial.length > 0 && (
<ThemedText type="small" themeColor="textSecondary" numberOfLines={3}>
{partial.map((s) => s.text).join(' ')}
</ThemedText>
)}
</ThemedView>
);
}
function ProgressBar({ value }: { value: number }) {
return (
<View style={styles.track}>
<View style={[styles.bar, { width: `${Math.max(2, Math.min(100, value * 100))}%` }]} />
</View>
);
}
function TranscriptRow({
item,
courseName,
onOpen,
onDelete,
}: {
item: TranscriptMeta;
courseName: string | null;
onOpen: () => void;
onDelete: () => void;
}) {
const date = new Date(item.lectureDate ?? item.createdAt).toLocaleDateString();
return (
<Pressable onPress={onOpen} style={({ pressed }) => [pressed && styles.pressed]}>
<ThemedView type="backgroundElement" style={styles.card}>
<View style={styles.rowBetween}>
<ThemedText type="smallBold" numberOfLines={1} style={styles.flex}>
{item.title}
</ThemedText>
<Pressable onPress={onDelete} hitSlop={8}>
<ThemedText type="small" themeColor="textSecondary"></ThemedText>
</Pressable>
</View>
<ThemedText type="small" themeColor="textSecondary">
{courseName ? `${courseName} · ` : ''}
{date} · {formatClock(item.durationSec)} · {item.segmentCount} segments
</ThemedText>
</ThemedView>
</Pressable>
);
}
const styles = StyleSheet.create({
fill: { flex: 1 },
content: {
padding: Spacing.three,
gap: Spacing.three,
maxWidth: MaxContentWidth,
width: '100%',
alignSelf: 'center',
},
flex: { flex: 1 },
headerRow: { flexDirection: 'row', alignItems: 'flex-start', gap: Spacing.two },
headerLinks: { flexDirection: 'row', gap: Spacing.three, alignItems: 'center' },
newButton: { paddingVertical: Spacing.three, borderRadius: Spacing.three, alignItems: 'center' },
newButtonText: { color: '#fff', fontWeight: '700', fontSize: 16 },
card: { padding: Spacing.three, borderRadius: Spacing.three, gap: Spacing.two },
rowBetween: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: Spacing.two },
filterBar: { gap: Spacing.two, paddingVertical: Spacing.one, paddingRight: Spacing.three },
filterChip: { paddingHorizontal: Spacing.three, paddingVertical: Spacing.one, borderRadius: 999 },
chipActive: { color: '#fff', fontWeight: '700' },
search: { borderRadius: Spacing.two, paddingHorizontal: Spacing.three, paddingVertical: Spacing.two, fontSize: 15 },
track: { height: 6, borderRadius: 3, backgroundColor: '#88888833', overflow: 'hidden' },
bar: { height: 6, borderRadius: 3, backgroundColor: '#3c87f7' },
pad: { paddingVertical: Spacing.four, textAlign: 'center' },
pressed: { opacity: 0.7 },
});