diff --git a/backend/src/config/env.js b/backend/src/config/env.js index 58b790a..b9648b5 100644 --- a/backend/src/config/env.js +++ b/backend/src/config/env.js @@ -79,7 +79,7 @@ export const env = { cookieSameSite: stringEnv("JWT_COOKIE_SAME_SITE", "lax"), }, upload: { - tempDir: path.resolve(backendRoot, stringEnv("UPLOAD_TEMP_DIR", ".tmp/uploads")), + tempDir: path.resolve(backendRoot, stringEnv("UPLOAD_TEMP_DIR", "/tmp/orphion/uploads")), maxAudioSizeMb: numberEnv("MAX_AUDIO_SIZE_MB", 200), allowedMimeTypes: stringEnv( "AUDIO_ALLOWED_MIME_TYPES", @@ -93,7 +93,7 @@ export const env = { driver: stringEnv("STORAGE_DRIVER", "smb"), basePath: path.resolve( backendRoot, - stringEnv("STORAGE_BASE_PATH", stringEnv("REMOTE_STORAGE_PATH", "../storage/audio")), + stringEnv("STORAGE_BASE_PATH", stringEnv("REMOTE_STORAGE_PATH", "/tmp/orphion/audio")), ), publicBaseUrl: normalizeBaseUrl(stringEnv("STORAGE_PUBLIC_BASE_URL", "")), httpBaseUrl: normalizeBaseUrl(stringEnv("STORAGE_HTTP_BASE_URL", "")), diff --git a/backend/src/controllers/transcriptController.js b/backend/src/controllers/transcriptController.js index c26da5c..4e3176a 100644 --- a/backend/src/controllers/transcriptController.js +++ b/backend/src/controllers/transcriptController.js @@ -9,7 +9,10 @@ import { updateTranscript, } from "../repositories/transcriptRepository.js"; import { findUserById } from "../repositories/userRepository.js"; -import { getTranscriptJob } from "../services/transcriptionService.js"; +import { + getTranscriptJob, + requeueTranscriptForTranscription, +} from "../services/transcriptionService.js"; import { openAudioStream, removeAudio } from "../services/storage/storageService.js"; import { AppError } from "../utils/AppError.js"; import { sendSuccess } from "../utils/apiResponse.js"; @@ -58,7 +61,7 @@ export async function updateTranscriptText(req, res) { } const transcript = await updateTranscript(req.params.id, req.user.id, { - title: req.body.title ?? null, + title: Object.hasOwn(req.body, "title") ? req.body.title : undefined, transcriptText: req.body.transcriptText, }); sendSuccess(res, "Transcript updated", { transcript }); @@ -71,6 +74,14 @@ export async function removeTranscript(req, res) { sendSuccess(res, "Transcript deleted"); } +export async function retranscribeTranscript(req, res) { + const result = await requeueTranscriptForTranscription({ + transcriptId: req.params.id, + userId: req.user.id, + }); + sendSuccess(res, "Transcript queued for re-transcription", result, 202); +} + export async function sendTranscript(req, res) { const { transcriptId, receiverId } = req.body; if (receiverId === Number(req.user.id)) { diff --git a/backend/src/repositories/transcriptRepository.js b/backend/src/repositories/transcriptRepository.js index fdcd363..c42c691 100644 --- a/backend/src/repositories/transcriptRepository.js +++ b/backend/src/repositories/transcriptRepository.js @@ -184,9 +184,17 @@ export async function findTranscriptByJobForUser(jobId, userId) { export async function updateTranscript(id, senderId, { title, transcriptText }) { await query( `UPDATE transcripts - SET title = :title, transcript_text = :transcriptText + SET title = CASE WHEN :hasTitle = 1 THEN :title ELSE title END, + transcript_text = CASE WHEN :hasTranscriptText = 1 THEN :transcriptText ELSE transcript_text END WHERE id = :id AND sender_id = :senderId`, - { id, senderId, title: title || null, transcriptText }, + { + id, + senderId, + hasTitle: title === undefined ? 0 : 1, + title: title === undefined ? null : title || null, + hasTranscriptText: transcriptText === undefined ? 0 : 1, + transcriptText: transcriptText ?? null, + }, ); return findTranscriptBySenderOrId(id, senderId); } @@ -269,6 +277,32 @@ export async function deleteTranscript(id, senderId) { return transcript; } +export async function createRetranscriptionJob({ id, senderId, modelName = null }) { + return transaction(async (connection) => { + await connection.execute( + `UPDATE transcripts + SET status = 'queued', failure_reason = NULL + WHERE id = :id AND sender_id = :senderId`, + { id, senderId }, + ); + + await connection.execute( + `UPDATE audio_metadata + SET model_name = :modelName + WHERE transcript_id = :id`, + { id, modelName }, + ); + + const [job] = await connection.execute( + `INSERT INTO transcription_jobs (transcript_id, status) + VALUES (:id, 'queued')`, + { id }, + ); + + return job.insertId; + }); +} + export async function findJobById(id) { const rows = await query("SELECT * FROM transcription_jobs WHERE id = :id LIMIT 1", { id }); return rows[0] ?? null; diff --git a/backend/src/repositories/userRepository.js b/backend/src/repositories/userRepository.js index b8d5634..1718cbc 100644 --- a/backend/src/repositories/userRepository.js +++ b/backend/src/repositories/userRepository.js @@ -51,6 +51,7 @@ export async function updateUserPassword(id, passwordHash) { } export async function listUsers({ excludeId, q = "", limit = 25 }) { + const safeLimit = Math.min(Math.max(Number(limit) || 25, 1), 50); const rows = await query( `SELECT id, full_name, username, email, role, created_at, updated_at FROM users @@ -62,8 +63,8 @@ export async function listUsers({ excludeId, q = "", limit = 25 }) { OR email LIKE :likeQ ) ORDER BY full_name ASC - LIMIT :limit`, - { excludeId, q, likeQ: `%${q}%`, limit: Math.min(Number(limit) || 25, 50) }, + LIMIT ${safeLimit}`, + { excludeId, q, likeQ: `%${q}%` }, ); return rows.map(toUser); } diff --git a/backend/src/routes/v1/transcriptRoutes.js b/backend/src/routes/v1/transcriptRoutes.js index 4f9403f..a429227 100644 --- a/backend/src/routes/v1/transcriptRoutes.js +++ b/backend/src/routes/v1/transcriptRoutes.js @@ -5,6 +5,7 @@ import { inbox, listTranscripts, removeTranscript, + retranscribeTranscript, sendTranscript, sent, streamAudio, @@ -28,6 +29,11 @@ transcriptRoutes.get("/inbox", validate(transcriptListSchema), asyncHandler(inbo transcriptRoutes.get("/sent", validate(transcriptListSchema), asyncHandler(sent)); transcriptRoutes.post("/send", validate(sendTranscriptSchema), asyncHandler(sendTranscript)); transcriptRoutes.get("/:id/audio", validate(transcriptIdSchema), asyncHandler(streamAudio)); +transcriptRoutes.post( + "/:id/retranscribe", + validate(transcriptIdSchema), + asyncHandler(retranscribeTranscript), +); transcriptRoutes.get( "/:id/download", validate(transcriptIdSchema), diff --git a/backend/src/services/transcriptionService.js b/backend/src/services/transcriptionService.js index 01a2aa4..6f0a3a6 100644 --- a/backend/src/services/transcriptionService.js +++ b/backend/src/services/transcriptionService.js @@ -8,6 +8,7 @@ import { completeTranscript, createAudioAsset, createQueuedTranscript, + createRetranscriptionJob, failJob, failTranscript, findJobById, @@ -119,4 +120,26 @@ export async function getTranscriptJob(transcriptId) { return findJobByTranscriptId(transcriptId); } +export async function requeueTranscriptForTranscription({ transcriptId, userId }) { + const transcript = await findTranscriptByIdForUser(transcriptId, userId); + if (!transcript || Number(transcript.senderId) !== Number(userId)) { + throw new AppError("Transcript not found", 404, "TRANSCRIPT_NOT_FOUND"); + } + if (!transcript.audioPath) { + throw new AppError("Transcript audio asset is missing", 409, "AUDIO_MISSING"); + } + + const jobId = await createRetranscriptionJob({ + id: transcript.id, + senderId: userId, + modelName: env.whisper.modelName, + }); + enqueueTranscriptionJob(jobId); + + return { + job: { id: Number(jobId), status: "queued", transcriptId: Number(transcript.id) }, + transcript: await findTranscriptByIdForUser(transcript.id, userId), + }; +} + export { checkWhisperHealth }; diff --git a/backend/src/validators/transcriptValidators.js b/backend/src/validators/transcriptValidators.js index 3fdc36f..a1da906 100644 --- a/backend/src/validators/transcriptValidators.js +++ b/backend/src/validators/transcriptValidators.js @@ -14,10 +14,14 @@ export const updateTranscriptSchema = z.object({ params: z.object({ id: z.string().regex(/^\d+$/), }), - body: z.object({ - title: z.string().trim().max(220).optional(), - transcriptText: z.string().trim().min(1), - }), + body: z + .object({ + title: z.string().trim().max(220).optional(), + transcriptText: z.string().trim().min(1).optional(), + }) + .refine((value) => value.title !== undefined || value.transcriptText !== undefined, { + message: "Provide a title or transcript text to update", + }), }); export const transcriptIdSchema = z.object({ diff --git a/frontend/.tanstack/tmp/96912ac9-b9c359f62fa9597181b6b38efe3cc33c b/frontend/.tanstack/tmp/96912ac9-b9c359f62fa9597181b6b38efe3cc33c new file mode 100644 index 0000000..71fccb5 --- /dev/null +++ b/frontend/.tanstack/tmp/96912ac9-b9c359f62fa9597181b6b38efe3cc33c @@ -0,0 +1,9 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/_authenticated')({ + component: RouteComponent, +}) + +function RouteComponent() { + return
Hello "/_authenticated"!
+} diff --git a/frontend/.tanstack/tmp/db3b3ef5-b9c359f62fa9597181b6b38efe3cc33c b/frontend/.tanstack/tmp/db3b3ef5-b9c359f62fa9597181b6b38efe3cc33c new file mode 100644 index 0000000..71fccb5 --- /dev/null +++ b/frontend/.tanstack/tmp/db3b3ef5-b9c359f62fa9597181b6b38efe3cc33c @@ -0,0 +1,9 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/_authenticated')({ + component: RouteComponent, +}) + +function RouteComponent() { + return
Hello "/_authenticated"!
+} diff --git a/frontend/src/assets/orphion-harp.svg b/frontend/src/assets/orphion-harp.svg new file mode 100644 index 0000000..f1202e6 --- /dev/null +++ b/frontend/src/assets/orphion-harp.svg @@ -0,0 +1,58 @@ + + + + + + + + image/svg+xml + + + + + + + + + diff --git a/frontend/src/components/audio-player.tsx b/frontend/src/components/audio-player.tsx new file mode 100644 index 0000000..dff8729 --- /dev/null +++ b/frontend/src/components/audio-player.tsx @@ -0,0 +1,133 @@ +import { useEffect, useRef, useState } from "react"; +import { Download, Pause, Play, RotateCcw, Volume2 } from "lucide-react"; + +type AudioPlayerProps = { + src: string; + downloadUrl?: string; + label?: string; +}; + +export function AudioPlayer({ src, downloadUrl, label = "Audio playback" }: AudioPlayerProps) { + const audioRef = useRef(null); + const [playing, setPlaying] = useState(false); + const [duration, setDuration] = useState(0); + const [currentTime, setCurrentTime] = useState(0); + + useEffect(() => { + setPlaying(false); + setCurrentTime(0); + }, [src]); + + function syncFromAudio() { + const audio = audioRef.current; + if (!audio) return; + setCurrentTime(Number.isFinite(audio.currentTime) ? audio.currentTime : 0); + setDuration(Number.isFinite(audio.duration) ? audio.duration : 0); + } + + async function toggle() { + const audio = audioRef.current; + if (!audio) return; + if (audio.paused) { + await audio.play(); + setPlaying(true); + return; + } + audio.pause(); + setPlaying(false); + } + + function restart() { + const audio = audioRef.current; + if (!audio) return; + audio.currentTime = 0; + setCurrentTime(0); + } + + function seek(value: string) { + const audio = audioRef.current; + const nextTime = Number(value); + if (!audio || !Number.isFinite(nextTime)) return; + audio.currentTime = nextTime; + setCurrentTime(nextTime); + } + + const progress = duration > 0 ? Math.min((currentTime / duration) * 100, 100) : 0; + + return ( +
+
+ ); +} + +function formatAudioTime(value: number) { + if (!Number.isFinite(value) || value <= 0) return "0:00"; + const minutes = Math.floor(value / 60); + const seconds = Math.floor(value % 60); + return `${minutes}:${String(seconds).padStart(2, "0")}`; +} diff --git a/frontend/src/components/confirm-dialog.tsx b/frontend/src/components/confirm-dialog.tsx new file mode 100644 index 0000000..834041f --- /dev/null +++ b/frontend/src/components/confirm-dialog.tsx @@ -0,0 +1,100 @@ +import { useEffect } from "react"; +import { AlertTriangle, X } from "lucide-react"; + +type ConfirmDialogProps = { + open: boolean; + title: string; + description: string; + confirmLabel: string; + cancelLabel?: string; + destructive?: boolean; + loading?: boolean; + onConfirm: () => void; + onClose: () => void; +}; + +export function ConfirmDialog({ + open, + title, + description, + confirmLabel, + cancelLabel = "Cancel", + destructive = false, + loading = false, + onConfirm, + onClose, +}: ConfirmDialogProps) { + useEffect(() => { + if (!open) return; + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") onClose(); + }; + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, [onClose, open]); + + if (!open) return null; + + return ( +
+
event.stopPropagation()} + > +
+
+
+ +
+
+

+ {title} +

+

{description}

+
+
+ +
+
+ + +
+
+
+ ); +} diff --git a/frontend/src/components/filter-controls.tsx b/frontend/src/components/filter-controls.tsx new file mode 100644 index 0000000..a283efc --- /dev/null +++ b/frontend/src/components/filter-controls.tsx @@ -0,0 +1,94 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { Calendar, Check, ChevronDown } from "lucide-react"; + +export type FilterOption = { + value: string; + label: string; +}; + +type FilterSelectProps = { + value: string; + options: FilterOption[]; + onChange: (value: string) => void; + ariaLabel: string; +}; + +export function FilterSelect({ value, options, onChange, ariaLabel }: FilterSelectProps) { + const [open, setOpen] = useState(false); + const rootRef = useRef(null); + const selected = useMemo( + () => options.find((option) => option.value === value) ?? options[0], + [options, value], + ); + + useEffect(() => { + if (!open) return; + const onPointerDown = (event: PointerEvent) => { + if (!rootRef.current?.contains(event.target as Node)) setOpen(false); + }; + window.addEventListener("pointerdown", onPointerDown); + return () => window.removeEventListener("pointerdown", onPointerDown); + }, [open]); + + return ( +
+ + {open && ( +
+ {options.map((option) => { + const active = option.value === value; + return ( + + ); + })} +
+ )} +
+ ); +} + +type DateFieldProps = { + value: string; + onChange: (value: string) => void; + ariaLabel: string; +}; + +export function DateField({ value, onChange, ariaLabel }: DateFieldProps) { + return ( +
+ + onChange(event.target.value)} + aria-label={ariaLabel} + className="w-full rounded-xl border border-border bg-input/40 py-2.5 pl-10 pr-3 text-sm text-foreground outline-none transition focus:border-primary/60" + style={{ colorScheme: "dark" }} + /> +
+ ); +} diff --git a/frontend/src/components/send-transcript-dialog.tsx b/frontend/src/components/send-transcript-dialog.tsx new file mode 100644 index 0000000..9a7fdbb --- /dev/null +++ b/frontend/src/components/send-transcript-dialog.tsx @@ -0,0 +1,116 @@ +import { useEffect, useState } from "react"; +import { Loader2, Search, X } from "lucide-react"; +import { userService } from "@/services/users"; +import type { UserSummary } from "@/services/types"; + +export function SendTranscriptDialog({ + sending, + onClose, + onSend, +}: { + sending: boolean; + onClose: () => void; + onSend: (id: number) => void; +}) { + const [query, setQuery] = useState(""); + const [users, setUsers] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + let active = true; + const handle = window.setTimeout( + async () => { + setLoading(true); + setError(null); + try { + const data = await userService.search(query); + if (active) setUsers(data.users); + } catch (err) { + if (!active) return; + setUsers([]); + setError(err instanceof Error ? err.message : "Unable to load users"); + } finally { + if (active) setLoading(false); + } + }, + query.trim() ? 160 : 0, + ); + + return () => { + active = false; + window.clearTimeout(handle); + }; + }, [query]); + + return ( +
+
event.stopPropagation()} + className="glass-strong w-full max-w-md rounded-2xl p-6 shadow-[var(--shadow-card)]" + > +
+
+

Send transcript

+

Choose a registered Orphion user.

+
+ +
+
+ + setQuery(event.target.value)} + placeholder="Search by name, username, or email" + className="w-full rounded-xl border border-border bg-input/40 py-2.5 pl-10 pr-3 text-sm outline-none transition focus:border-primary/60" + /> +
+
+ {loading && ( +
+ + Loading users +
+ )} + {!loading && error && ( +

{error}

+ )} + {!loading && !error && users.length === 0 && ( +

+ No registered users found. +

+ )} + {!loading && + !error && + users.map((user) => ( + + ))} +
+
+
+ ); +} diff --git a/frontend/src/lib/orphion-logo.tsx b/frontend/src/lib/orphion-logo.tsx index 33e272c..c7fd967 100644 --- a/frontend/src/lib/orphion-logo.tsx +++ b/frontend/src/lib/orphion-logo.tsx @@ -1,13 +1,39 @@ -export function OrphionLogo({ className = "" }: { className?: string }) { +import type { CSSProperties } from "react"; +import harpUrl from "@/assets/orphion-harp.svg"; + +const harpMaskStyle: CSSProperties = { + WebkitMaskImage: `url(${harpUrl})`, + maskImage: `url(${harpUrl})`, + WebkitMaskRepeat: "no-repeat", + maskRepeat: "no-repeat", + WebkitMaskPosition: "center", + maskPosition: "center", + WebkitMaskSize: "contain", + maskSize: "contain", +}; + +export function OrphionLogo({ + className = "", + showText = true, +}: { + className?: string; + showText?: boolean; +}) { return (
-
-
-
-
-
-
- ORPHION + + + + + {showText && ORPHION} +
+ ); +} + +export function OrphionHarpBackdrop({ className = "" }: { className?: string }) { + return ( + ); } diff --git a/frontend/src/routes/_authenticated.tsx b/frontend/src/routes/_authenticated.tsx index 8f2c460..c9e9731 100644 --- a/frontend/src/routes/_authenticated.tsx +++ b/frontend/src/routes/_authenticated.tsx @@ -13,10 +13,14 @@ import { X, Search, Bell, + PanelLeftClose, + PanelLeftOpen, } from "lucide-react"; -import { OrphionLogo } from "@/lib/orphion-logo"; +import { OrphionHarpBackdrop, OrphionLogo } from "@/lib/orphion-logo"; import { toast } from "sonner"; import { useAuth } from "@/context/auth"; +import { ConfirmDialog } from "@/components/confirm-dialog"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; export const Route = createFileRoute("/_authenticated")({ component: AuthenticatedLayout, @@ -36,15 +40,24 @@ function AuthenticatedLayout() { const pathname = useRouterState({ select: (s) => s.location.pathname }); const { user, loading, logout } = useAuth(); const [open, setOpen] = useState(false); + const [collapsed, setCollapsed] = useState(false); + const [confirmLogout, setConfirmLogout] = useState(false); + const [loggingOut, setLoggingOut] = useState(false); useEffect(() => { if (!loading && !user) navigate({ to: "/login", replace: true }); }, [loading, navigate, user]); const handleLogout = async () => { - await logout(); - toast.success("Signed out"); - navigate({ to: "/login", replace: true }); + setLoggingOut(true); + try { + await logout(); + toast.success("Signed out"); + navigate({ to: "/login", replace: true }); + } finally { + setLoggingOut(false); + setConfirmLogout(false); + } }; if (loading || !user) { @@ -58,7 +71,8 @@ function AuthenticatedLayout() { } return ( -
+
+ {/* mobile top bar */}
@@ -68,60 +82,110 @@ function AuthenticatedLayout() {
{/* sidebar */} - + + {collapsed ? ( + + + + + Expand sidebar + + ) : ( + + )} +
+ +
+ {collapsed ? ( + + + + + Sign out + + ) : ( + + )} +
+ + {/* main */} -
+
@@ -132,9 +196,19 @@ function AuthenticatedLayout() { />
- + + + + + + Notifications + +
{user.fullName.charAt(0).toUpperCase()} @@ -151,6 +225,15 @@ function AuthenticatedLayout() {
+ setConfirmLogout(false)} + onConfirm={handleLogout} + />
); } diff --git a/frontend/src/routes/_authenticated/dashboard.tsx b/frontend/src/routes/_authenticated/dashboard.tsx index 551f135..0dfbd94 100644 --- a/frontend/src/routes/_authenticated/dashboard.tsx +++ b/frontend/src/routes/_authenticated/dashboard.tsx @@ -87,8 +87,8 @@ function DashboardPage() { - - + + diff --git a/frontend/src/routes/_authenticated/inbox.tsx b/frontend/src/routes/_authenticated/inbox.tsx index 5a7e24c..77f6845 100644 --- a/frontend/src/routes/_authenticated/inbox.tsx +++ b/frontend/src/routes/_authenticated/inbox.tsx @@ -5,6 +5,8 @@ import { ChevronDown, Download, Inbox, Search } from "lucide-react"; import { motion } from "framer-motion"; import { transcriptService } from "@/services/transcripts"; import type { Transcript } from "@/services/types"; +import { AudioPlayer } from "@/components/audio-player"; +import { DateField, FilterSelect } from "@/components/filter-controls"; export const Route = createFileRoute("/_authenticated/inbox")({ head: () => ({ meta: [{ title: "Inbox — Orphion" }] }), @@ -29,6 +31,13 @@ function InboxPage() { }); return Array.from(map, ([id, name]) => ({ id, name })); }, [items]); + const senderOptions = useMemo( + () => [ + { value: "all", label: "All senders" }, + ...senders.map((item) => ({ value: String(item.id), label: item.name })), + ], + [senders], + ); const filtered = items.filter((item) => { const haystack = @@ -62,24 +71,13 @@ function InboxPage() { className="w-full rounded-xl border border-border bg-input/40 py-2.5 pl-10 pr-3 text-sm outline-none transition focus:border-primary/60" />
- - setDate(event.target.value)} - className="rounded-xl border border-border bg-input/40 px-3 py-2.5 text-sm outline-none transition focus:border-primary/60" + options={senderOptions} + onChange={setSender} + ariaLabel="Filter by sender" /> +
@@ -147,7 +145,11 @@ function TranscriptInboxCard({ animate={{ opacity: 1, y: 0 }} className="mt-5 space-y-4" > -
@@ -350,11 +386,17 @@ function RecordPage() { {canTranscribe && ( <> + + + + + )} {lastError &&

{lastError}

} @@ -376,7 +440,7 @@ function RecordPage() { placeholder="Title this recording" className="w-full rounded-xl border border-border bg-input/40 px-4 py-3 text-sm outline-none transition placeholder:text-muted-foreground focus:border-primary/60" /> -