feat: add harp branding, title autosave, and fix user lookup
This commit is contained in:
parent
30894e7f27
commit
25f80d4a81
@ -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", "")),
|
||||
|
||||
@ -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)) {
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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 };
|
||||
|
||||
@ -14,9 +14,13 @@ export const updateTranscriptSchema = z.object({
|
||||
params: z.object({
|
||||
id: z.string().regex(/^\d+$/),
|
||||
}),
|
||||
body: z.object({
|
||||
body: z
|
||||
.object({
|
||||
title: z.string().trim().max(220).optional(),
|
||||
transcriptText: z.string().trim().min(1),
|
||||
transcriptText: z.string().trim().min(1).optional(),
|
||||
})
|
||||
.refine((value) => value.title !== undefined || value.transcriptText !== undefined, {
|
||||
message: "Provide a title or transcript text to update",
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
@ -0,0 +1,9 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
|
||||
export const Route = createFileRoute('/_authenticated')({
|
||||
component: RouteComponent,
|
||||
})
|
||||
|
||||
function RouteComponent() {
|
||||
return <div>Hello "/_authenticated"!</div>
|
||||
}
|
||||
@ -0,0 +1,9 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
|
||||
export const Route = createFileRoute('/_authenticated')({
|
||||
component: RouteComponent,
|
||||
})
|
||||
|
||||
function RouteComponent() {
|
||||
return <div>Hello "/_authenticated"!</div>
|
||||
}
|
||||
58
frontend/src/assets/orphion-harp.svg
Normal file
58
frontend/src/assets/orphion-harp.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 40 KiB |
133
frontend/src/components/audio-player.tsx
Normal file
133
frontend/src/components/audio-player.tsx
Normal file
@ -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<HTMLAudioElement | null>(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 (
|
||||
<div className="rounded-xl border border-border bg-background/45 px-3 py-3 shadow-inner shadow-black/20">
|
||||
<audio
|
||||
ref={audioRef}
|
||||
src={src}
|
||||
preload="metadata"
|
||||
onLoadedMetadata={syncFromAudio}
|
||||
onDurationChange={syncFromAudio}
|
||||
onTimeUpdate={syncFromAudio}
|
||||
onEnded={() => setPlaying(false)}
|
||||
/>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggle}
|
||||
aria-label={playing ? `Pause ${label}` : `Play ${label}`}
|
||||
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-primary text-primary-foreground shadow-[var(--shadow-glow)] transition hover:scale-105"
|
||||
>
|
||||
{playing ? (
|
||||
<Pause className="h-4 w-4 fill-current" />
|
||||
) : (
|
||||
<Play className="h-4 w-4 fill-current" />
|
||||
)}
|
||||
</button>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="mb-2 flex items-center justify-between gap-3 text-xs text-muted-foreground">
|
||||
<span className="flex min-w-0 items-center gap-1.5 truncate">
|
||||
<Volume2 className="h-3.5 w-3.5 text-primary" />
|
||||
<span className="truncate">{label}</span>
|
||||
</span>
|
||||
<span className="shrink-0 font-mono tabular-nums">
|
||||
{formatAudioTime(currentTime)} / {formatAudioTime(duration)}
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max={duration || 0}
|
||||
step="0.1"
|
||||
value={Math.min(currentTime, duration || 0)}
|
||||
onChange={(event) => seek(event.target.value)}
|
||||
aria-label={`Seek ${label}`}
|
||||
className="h-2 w-full cursor-pointer appearance-none rounded-full bg-secondary accent-primary"
|
||||
style={{
|
||||
background: `linear-gradient(to right, var(--primary) ${progress}%, rgba(255,255,255,0.12) ${progress}%)`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={restart}
|
||||
aria-label={`Restart ${label}`}
|
||||
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border border-border bg-secondary/40 text-muted-foreground transition hover:border-primary/50 hover:text-foreground"
|
||||
>
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
</button>
|
||||
{downloadUrl ? (
|
||||
<a
|
||||
href={downloadUrl}
|
||||
download
|
||||
aria-label={`Download ${label}`}
|
||||
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border border-border bg-secondary/40 text-muted-foreground transition hover:border-primary/50 hover:text-foreground"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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")}`;
|
||||
}
|
||||
100
frontend/src/components/confirm-dialog.tsx
Normal file
100
frontend/src/components/confirm-dialog.tsx
Normal file
@ -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 (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-background/78 p-4 backdrop-blur-sm"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="confirm-dialog-title"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="glass-strong w-full max-w-md rounded-2xl p-6 shadow-[var(--shadow-card)]"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div
|
||||
className={`mt-0.5 flex h-10 w-10 shrink-0 items-center justify-center rounded-xl ${
|
||||
destructive ? "bg-destructive/15 text-destructive" : "bg-primary/15 text-primary"
|
||||
}`}
|
||||
>
|
||||
<AlertTriangle className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 id="confirm-dialog-title" className="font-display text-lg font-semibold">
|
||||
{title}
|
||||
</h2>
|
||||
<p className="mt-1 text-sm leading-6 text-muted-foreground">{description}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded-lg p-2 text-muted-foreground transition hover:bg-secondary hover:text-foreground"
|
||||
aria-label="Close confirmation"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-6 flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
disabled={loading}
|
||||
className="rounded-xl border border-border bg-secondary/40 px-4 py-2 text-sm transition hover:border-primary/50 disabled:opacity-60"
|
||||
>
|
||||
{cancelLabel}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onConfirm}
|
||||
disabled={loading}
|
||||
className={`rounded-xl px-4 py-2 text-sm font-semibold text-primary-foreground transition disabled:opacity-60 ${
|
||||
destructive
|
||||
? "bg-destructive hover:bg-destructive/90"
|
||||
: "bg-primary hover:bg-primary/90"
|
||||
}`}
|
||||
>
|
||||
{loading ? "Working..." : confirmLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
94
frontend/src/components/filter-controls.tsx
Normal file
94
frontend/src/components/filter-controls.tsx
Normal file
@ -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<HTMLDivElement | null>(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 (
|
||||
<div ref={rootRef} className="relative">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={ariaLabel}
|
||||
aria-expanded={open}
|
||||
onClick={() => setOpen((value) => !value)}
|
||||
className="flex w-full items-center justify-between gap-2 rounded-xl border border-border bg-input/40 px-3 py-2.5 text-left text-sm outline-none transition hover:border-primary/50 focus:border-primary/60"
|
||||
>
|
||||
<span className="truncate">{selected?.label}</span>
|
||||
<ChevronDown
|
||||
className={`h-4 w-4 shrink-0 text-primary transition ${open ? "rotate-180" : ""}`}
|
||||
/>
|
||||
</button>
|
||||
{open && (
|
||||
<div className="absolute left-0 right-0 top-[calc(100%+0.4rem)] z-30 max-h-72 overflow-y-auto rounded-xl border border-border bg-popover p-1 shadow-[var(--shadow-card)]">
|
||||
{options.map((option) => {
|
||||
const active = option.value === value;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={option.value}
|
||||
onClick={() => {
|
||||
onChange(option.value);
|
||||
setOpen(false);
|
||||
}}
|
||||
className={`flex w-full items-center justify-between gap-2 rounded-lg px-3 py-2 text-left text-sm transition ${
|
||||
active ? "bg-primary/12 text-primary" : "hover:bg-secondary"
|
||||
}`}
|
||||
>
|
||||
<span className="truncate">{option.label}</span>
|
||||
{active ? <Check className="h-4 w-4 shrink-0" /> : null}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type DateFieldProps = {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
ariaLabel: string;
|
||||
};
|
||||
|
||||
export function DateField({ value, onChange, ariaLabel }: DateFieldProps) {
|
||||
return (
|
||||
<div className="relative">
|
||||
<Calendar className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-primary" />
|
||||
<input
|
||||
type="date"
|
||||
value={value}
|
||||
onChange={(event) => 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" }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
116
frontend/src/components/send-transcript-dialog.tsx
Normal file
116
frontend/src/components/send-transcript-dialog.tsx
Normal file
@ -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<UserSummary[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(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 (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-background/75 p-4 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
className="glass-strong w-full max-w-md rounded-2xl p-6 shadow-[var(--shadow-card)]"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="font-display text-lg font-semibold">Send transcript</h3>
|
||||
<p className="mt-1 text-sm text-muted-foreground">Choose a registered Orphion user.</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded-lg p-2 text-muted-foreground transition hover:bg-secondary hover:text-foreground"
|
||||
aria-label="Close send dialog"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="relative mt-4">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<input
|
||||
value={query}
|
||||
onChange={(event) => 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"
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-3 max-h-72 space-y-1 overflow-y-auto">
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center gap-2 py-5 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Loading users
|
||||
</div>
|
||||
)}
|
||||
{!loading && error && (
|
||||
<p className="py-5 text-center text-sm text-destructive">{error}</p>
|
||||
)}
|
||||
{!loading && !error && users.length === 0 && (
|
||||
<p className="py-5 text-center text-sm text-muted-foreground">
|
||||
No registered users found.
|
||||
</p>
|
||||
)}
|
||||
{!loading &&
|
||||
!error &&
|
||||
users.map((user) => (
|
||||
<button
|
||||
key={user.id}
|
||||
disabled={sending}
|
||||
onClick={() => onSend(user.id)}
|
||||
className="flex w-full items-center gap-3 rounded-xl px-3 py-2.5 text-left transition hover:bg-secondary disabled:opacity-60"
|
||||
>
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-primary text-sm font-semibold text-primary-foreground">
|
||||
{user.fullName.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-medium">{user.fullName}</div>
|
||||
<div className="truncate text-xs text-muted-foreground">
|
||||
@{user.username} - {user.email}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -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 (
|
||||
<div className={`flex items-center gap-2.5 ${className}`}>
|
||||
<div className="relative h-8 w-8">
|
||||
<div className="absolute inset-0 rounded-full bg-gradient-to-br from-primary to-primary/40 blur-sm opacity-70" />
|
||||
<div className="relative flex h-full w-full items-center justify-center rounded-full border border-primary/40 bg-background/60">
|
||||
<div className="h-2 w-2 rounded-full bg-primary shadow-[0_0_10px_var(--glow)]" />
|
||||
</div>
|
||||
</div>
|
||||
<span className="font-display text-xl font-semibold">ORPHION</span>
|
||||
<span className="relative flex h-10 w-7 shrink-0 items-center justify-center">
|
||||
<span className="absolute inset-0 bg-primary/30 blur-md" style={harpMaskStyle} />
|
||||
<span className="orphion-harp-mark absolute inset-0" style={harpMaskStyle} />
|
||||
</span>
|
||||
{showText && <span className="font-display text-xl font-semibold">ORPHION</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function OrphionHarpBackdrop({ className = "" }: { className?: string }) {
|
||||
return (
|
||||
<div aria-hidden="true" className={`pointer-events-none ${className}`}>
|
||||
<span className="orphion-harp-backdrop block h-full w-full" style={harpMaskStyle} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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 () => {
|
||||
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 (
|
||||
<div className="flex min-h-screen bg-background/80">
|
||||
<div className="relative flex min-h-screen overflow-hidden bg-background/80">
|
||||
<OrphionHarpBackdrop className="fixed -right-20 top-20 h-[78vh] w-[42vw] min-w-72 opacity-60" />
|
||||
{/* mobile top bar */}
|
||||
<div className="fixed top-0 left-0 right-0 z-40 flex items-center justify-between border-b border-border bg-background/80 px-4 py-3 backdrop-blur md:hidden">
|
||||
<OrphionLogo />
|
||||
@ -68,25 +82,55 @@ function AuthenticatedLayout() {
|
||||
</div>
|
||||
|
||||
{/* sidebar */}
|
||||
<TooltipProvider delayDuration={120}>
|
||||
<aside
|
||||
className={`fixed inset-y-0 left-0 z-30 flex w-64 flex-col border-r border-sidebar-border bg-sidebar/95 backdrop-blur-xl transition-transform md:translate-x-0 ${open ? "translate-x-0" : "-translate-x-full"}`}
|
||||
className={`fixed inset-y-0 left-0 z-30 flex w-64 flex-col border-r border-sidebar-border bg-sidebar/95 backdrop-blur-xl transition-all md:translate-x-0 ${collapsed ? "md:w-20" : "md:w-64"} ${open ? "translate-x-0" : "-translate-x-full"}`}
|
||||
>
|
||||
<div className="flex h-16 items-center px-6 border-b border-sidebar-border">
|
||||
<OrphionLogo />
|
||||
<div
|
||||
className={`flex h-16 items-center gap-2 border-b border-sidebar-border px-4 ${collapsed ? "md:justify-center md:px-2" : "justify-between"}`}
|
||||
>
|
||||
<OrphionLogo className={collapsed ? "md:hidden" : ""} />
|
||||
{collapsed ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCollapsed((value) => !value)}
|
||||
className="hidden h-9 w-9 items-center justify-center rounded-xl border border-border bg-secondary/30 text-muted-foreground transition hover:border-primary/50 hover:text-foreground md:flex"
|
||||
aria-label="Expand sidebar"
|
||||
>
|
||||
<PanelLeftOpen className="h-4 w-4" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">Expand sidebar</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCollapsed((value) => !value)}
|
||||
className="hidden h-9 w-9 items-center justify-center rounded-xl border border-border bg-secondary/30 text-muted-foreground transition hover:border-primary/50 hover:text-foreground md:flex"
|
||||
aria-label="Collapse sidebar"
|
||||
>
|
||||
<PanelLeftClose className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<nav className="flex-1 space-y-1 px-3 py-6">
|
||||
{navItems.map((item) => {
|
||||
const active = pathname === item.to || pathname.startsWith(item.to + "/");
|
||||
return (
|
||||
const link = (
|
||||
<Link
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
onClick={() => setOpen(false)}
|
||||
className={`group flex items-center gap-3 rounded-xl px-3 py-2.5 text-sm font-medium transition relative ${
|
||||
className={`group relative flex items-center rounded-xl px-3 py-2.5 text-sm font-medium transition ${
|
||||
collapsed ? "md:justify-center md:gap-0" : "gap-3"
|
||||
} ${
|
||||
active
|
||||
? "text-primary bg-primary/10"
|
||||
: "text-sidebar-foreground hover:bg-sidebar-accent hover:text-foreground"
|
||||
}`}
|
||||
aria-label={item.label}
|
||||
>
|
||||
{active && (
|
||||
<motion.div
|
||||
@ -94,34 +138,54 @@ function AuthenticatedLayout() {
|
||||
className="absolute inset-0 rounded-xl border border-primary/30 bg-primary/5"
|
||||
/>
|
||||
)}
|
||||
<item.icon className="h-4 w-4 relative z-10" />
|
||||
<span className="relative z-10">{item.label}</span>
|
||||
<item.icon className="relative z-10 h-4 w-4" />
|
||||
<span className={`relative z-10 ${collapsed ? "md:hidden" : ""}`}>
|
||||
{item.label}
|
||||
</span>
|
||||
</Link>
|
||||
);
|
||||
if (!collapsed) return link;
|
||||
return (
|
||||
<Tooltip key={item.to}>
|
||||
<TooltipTrigger asChild>{link}</TooltipTrigger>
|
||||
<TooltipContent side="right">{item.label}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
<div className="border-t border-sidebar-border p-4">
|
||||
<div className="mb-3 flex items-center gap-3 rounded-xl bg-sidebar-accent/40 px-3 py-2">
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-gradient-to-br from-primary to-[#B22222] text-primary-foreground font-semibold text-sm">
|
||||
{user.fullName.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-medium">{user.fullName}</div>
|
||||
<div className="truncate text-xs text-muted-foreground">@{user.username}</div>
|
||||
</div>
|
||||
</div>
|
||||
{collapsed ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="flex w-full items-center gap-2 rounded-xl px-3 py-2 text-sm text-muted-foreground hover:bg-sidebar-accent hover:text-foreground transition"
|
||||
onClick={() => setConfirmLogout(true)}
|
||||
className="flex w-full items-center rounded-xl px-3 py-2 text-sm text-muted-foreground transition hover:bg-sidebar-accent hover:text-foreground md:justify-center md:gap-0"
|
||||
aria-label="Sign out"
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
Sign out
|
||||
<span className="md:hidden">Sign out</span>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">Sign out</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setConfirmLogout(true)}
|
||||
className="flex w-full items-center gap-2 rounded-xl px-3 py-2 text-sm text-muted-foreground transition hover:bg-sidebar-accent hover:text-foreground"
|
||||
aria-label="Sign out"
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
<span>Sign out</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
</TooltipProvider>
|
||||
|
||||
{/* main */}
|
||||
<main className="flex-1 md:ml-64 pt-14 md:pt-0">
|
||||
<main
|
||||
className={`relative z-10 flex-1 pt-14 transition-all md:pt-0 ${collapsed ? "md:ml-20" : "md:ml-64"}`}
|
||||
>
|
||||
<header className="sticky top-0 z-20 hidden border-b border-border bg-background/70 px-8 py-4 backdrop-blur-xl md:block">
|
||||
<div className="mx-auto flex max-w-7xl items-center justify-between gap-4">
|
||||
<div className="relative w-full max-w-md">
|
||||
@ -132,9 +196,19 @@ function AuthenticatedLayout() {
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<button className="flex h-10 w-10 items-center justify-center rounded-xl border border-border bg-secondary/40 text-muted-foreground transition hover:border-primary/50 hover:text-foreground">
|
||||
<TooltipProvider delayDuration={120}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
className="flex h-10 w-10 items-center justify-center rounded-xl border border-border bg-secondary/40 text-muted-foreground transition hover:border-primary/50 hover:text-foreground"
|
||||
aria-label="Notifications"
|
||||
>
|
||||
<Bell className="h-4 w-4" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Notifications</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<div className="flex items-center gap-3 rounded-xl border border-border bg-secondary/40 px-3 py-2">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-primary text-xs font-semibold text-primary-foreground">
|
||||
{user.fullName.charAt(0).toUpperCase()}
|
||||
@ -151,6 +225,15 @@ function AuthenticatedLayout() {
|
||||
<Outlet />
|
||||
</div>
|
||||
</main>
|
||||
<ConfirmDialog
|
||||
open={confirmLogout}
|
||||
title="Sign out"
|
||||
description="You will return to the sign-in screen. Any unsaved transcript edits should be saved first."
|
||||
confirmLabel="Sign out"
|
||||
loading={loggingOut}
|
||||
onClose={() => setConfirmLogout(false)}
|
||||
onConfirm={handleLogout}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -87,8 +87,8 @@ function DashboardPage() {
|
||||
<AreaChart data={chartData}>
|
||||
<defs>
|
||||
<linearGradient id="activity" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#B22222" stopOpacity={0.55} />
|
||||
<stop offset="95%" stopColor="#B22222" stopOpacity={0} />
|
||||
<stop offset="5%" stopColor="#ff4d5e" stopOpacity={0.55} />
|
||||
<stop offset="95%" stopColor="#ff4d5e" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<XAxis
|
||||
@ -108,7 +108,7 @@ function DashboardPage() {
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="count"
|
||||
stroke="#B22222"
|
||||
stroke="#ff4d5e"
|
||||
fill="url(#activity)"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
|
||||
@ -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"
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
<FilterSelect
|
||||
value={sender}
|
||||
onChange={(event) => setSender(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"
|
||||
>
|
||||
<option value="all">All senders</option>
|
||||
{senders.map((item) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
{item.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<input
|
||||
type="date"
|
||||
value={date}
|
||||
onChange={(event) => 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"
|
||||
/>
|
||||
<DateField value={date} onChange={setDate} ariaLabel="Filter inbox by date" />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4">
|
||||
@ -147,7 +145,11 @@ function TranscriptInboxCard({
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="mt-5 space-y-4"
|
||||
>
|
||||
<audio src={transcriptService.audioUrl(item.id)} controls className="w-full" />
|
||||
<AudioPlayer
|
||||
src={transcriptService.audioUrl(item.id)}
|
||||
downloadUrl={transcriptService.audioUrl(item.id)}
|
||||
label={item.title || `Transcript #${item.id} audio`}
|
||||
/>
|
||||
<div className="rounded-xl border border-border bg-background/35 p-4 text-sm leading-relaxed text-muted-foreground">
|
||||
{item.transcriptText}
|
||||
</div>
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { createFileRoute, Link, useNavigate } from "@tanstack/react-router";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import {
|
||||
@ -7,18 +8,19 @@ import {
|
||||
Mic,
|
||||
Pause,
|
||||
Play,
|
||||
RotateCcw,
|
||||
Save,
|
||||
Send,
|
||||
Sparkles,
|
||||
Square,
|
||||
Trash2,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { transcribeAudio } from "@/services/audio";
|
||||
import { transcriptService } from "@/services/transcripts";
|
||||
import { userService } from "@/services/users";
|
||||
import type { Transcript, UserSummary } from "@/services/types";
|
||||
import type { Transcript } from "@/services/types";
|
||||
import { AudioPlayer } from "@/components/audio-player";
|
||||
import { SendTranscriptDialog } from "@/components/send-transcript-dialog";
|
||||
|
||||
export const Route = createFileRoute("/_authenticated/record")({
|
||||
head: () => ({ meta: [{ title: "Record — Orphion" }] }),
|
||||
@ -29,6 +31,7 @@ type Phase = "idle" | "recording" | "paused" | "stopped" | "uploading" | "transc
|
||||
|
||||
function RecordPage() {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const [phase, setPhase] = useState<Phase>("idle");
|
||||
const [seconds, setSeconds] = useState(0);
|
||||
const [levels, setLevels] = useState<number[]>(Array(44).fill(0.12));
|
||||
@ -43,8 +46,6 @@ function RecordPage() {
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [lastError, setLastError] = useState<string | null>(null);
|
||||
const [showSend, setShowSend] = useState(false);
|
||||
const [users, setUsers] = useState<UserSummary[]>([]);
|
||||
const [userQuery, setUserQuery] = useState("");
|
||||
const [sending, setSending] = useState(false);
|
||||
|
||||
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
|
||||
@ -54,17 +55,37 @@ function RecordPage() {
|
||||
const analyserRef = useRef<AnalyserNode | null>(null);
|
||||
const rafRef = useRef<number | null>(null);
|
||||
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const lastSavedTitleRef = useRef("");
|
||||
|
||||
useEffect(() => () => cleanup(), []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!showSend) return;
|
||||
if (!currentTranscript) return;
|
||||
lastSavedTitleRef.current = currentTranscript.title ?? "";
|
||||
}, [currentTranscript]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentTranscript || phase !== "ready") return;
|
||||
|
||||
const nextTitle = title.trim();
|
||||
if (nextTitle === lastSavedTitleRef.current) return;
|
||||
|
||||
const handle = window.setTimeout(async () => {
|
||||
const data = await userService.search(userQuery).catch(() => ({ users: [] }));
|
||||
setUsers(data.users);
|
||||
}, 160);
|
||||
setSaving(true);
|
||||
try {
|
||||
const data = await transcriptService.update(currentTranscript.id, { title: nextTitle });
|
||||
setCurrentTranscript(data.transcript);
|
||||
lastSavedTitleRef.current = data.transcript.title ?? "";
|
||||
await queryClient.invalidateQueries({ queryKey: ["transcripts"] });
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "Unable to auto-save title");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, 700);
|
||||
|
||||
return () => window.clearTimeout(handle);
|
||||
}, [showSend, userQuery]);
|
||||
}, [currentTranscript, phase, queryClient, title]);
|
||||
|
||||
const isBusy = phase === "uploading" || phase === "transcribing";
|
||||
const canTranscribe = phase === "stopped" && audioBlob;
|
||||
@ -157,6 +178,7 @@ function RecordPage() {
|
||||
setAudioUrl(null);
|
||||
setTranscript("");
|
||||
setCurrentTranscript(null);
|
||||
lastSavedTitleRef.current = "";
|
||||
setLanguage(null);
|
||||
setTitle("");
|
||||
setSeconds(0);
|
||||
@ -166,10 +188,22 @@ function RecordPage() {
|
||||
setPhase("idle");
|
||||
}
|
||||
|
||||
async function reRecord() {
|
||||
reset();
|
||||
await startRecording();
|
||||
}
|
||||
|
||||
function startNewRecording() {
|
||||
reset();
|
||||
}
|
||||
|
||||
async function transcribeNow() {
|
||||
if (!audioBlob) return;
|
||||
setUploadProgress(0);
|
||||
setLastError(null);
|
||||
setTranscript("");
|
||||
setCurrentTranscript(null);
|
||||
setLanguage(null);
|
||||
setPhase("uploading");
|
||||
try {
|
||||
const response = await transcribeAudio(
|
||||
@ -220,6 +254,8 @@ function RecordPage() {
|
||||
transcriptText: transcript,
|
||||
});
|
||||
setCurrentTranscript(data.transcript);
|
||||
lastSavedTitleRef.current = data.transcript.title ?? "";
|
||||
await queryClient.invalidateQueries({ queryKey: ["transcripts"] });
|
||||
toast.success("Transcript updated");
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "Unable to save");
|
||||
@ -277,7 +313,7 @@ function RecordPage() {
|
||||
phase === "recording"
|
||||
? "bg-primary shadow-[var(--shadow-glow)]"
|
||||
: phase === "paused"
|
||||
? "bg-[#B22222]"
|
||||
? "bg-[var(--glow)]"
|
||||
: isBusy
|
||||
? "bg-secondary"
|
||||
: phase === "ready"
|
||||
@ -306,7 +342,7 @@ function RecordPage() {
|
||||
key={index}
|
||||
animate={{ height: `${Math.max(8, level * 100)}%` }}
|
||||
transition={{ duration: 0.1 }}
|
||||
className="w-1.5 rounded-full bg-gradient-to-t from-primary/25 via-primary to-[#B22222]"
|
||||
className="w-1.5 rounded-full bg-gradient-to-t from-primary/25 via-primary to-[var(--glow)]"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@ -350,11 +386,17 @@ function RecordPage() {
|
||||
{canTranscribe && (
|
||||
<>
|
||||
<button
|
||||
onClick={reset}
|
||||
onClick={startNewRecording}
|
||||
className="flex items-center gap-2 rounded-full border border-border bg-secondary/40 px-5 py-2.5 text-sm transition hover:border-primary/50"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" /> Discard
|
||||
</button>
|
||||
<button
|
||||
onClick={reRecord}
|
||||
className="flex items-center gap-2 rounded-full border border-border bg-secondary/40 px-5 py-2.5 text-sm transition hover:border-primary/50"
|
||||
>
|
||||
<RotateCcw className="h-4 w-4" /> Re-record
|
||||
</button>
|
||||
<button
|
||||
onClick={transcribeNow}
|
||||
className="flex items-center gap-2 rounded-full bg-primary px-6 py-2.5 text-sm font-semibold text-primary-foreground shadow-[var(--shadow-glow)] transition hover:scale-[1.02]"
|
||||
@ -364,6 +406,28 @@ function RecordPage() {
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{phase === "ready" && audioBlob && (
|
||||
<>
|
||||
<button
|
||||
onClick={startNewRecording}
|
||||
className="flex items-center gap-2 rounded-full border border-border bg-secondary/40 px-5 py-2.5 text-sm transition hover:border-primary/50"
|
||||
>
|
||||
<Mic className="h-4 w-4" /> New recording
|
||||
</button>
|
||||
<button
|
||||
onClick={reRecord}
|
||||
className="flex items-center gap-2 rounded-full border border-border bg-secondary/40 px-5 py-2.5 text-sm transition hover:border-primary/50"
|
||||
>
|
||||
<RotateCcw className="h-4 w-4" /> Re-record
|
||||
</button>
|
||||
<button
|
||||
onClick={transcribeNow}
|
||||
className="flex items-center gap-2 rounded-full bg-primary px-6 py-2.5 text-sm font-semibold text-primary-foreground shadow-[var(--shadow-glow)] transition hover:scale-[1.02]"
|
||||
>
|
||||
<Sparkles className="h-4 w-4" /> Re-transcribe
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{lastError && <p className="text-sm text-destructive">{lastError}</p>}
|
||||
@ -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"
|
||||
/>
|
||||
<audio src={audioUrl} controls className="w-full" />
|
||||
<AudioPlayer src={audioUrl} label={title || "Recorded audio"} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@ -431,11 +495,8 @@ function RecordPage() {
|
||||
)}
|
||||
|
||||
{showSend && (
|
||||
<SendDialog
|
||||
users={users}
|
||||
query={userQuery}
|
||||
<SendTranscriptDialog
|
||||
sending={sending}
|
||||
onQuery={setUserQuery}
|
||||
onClose={() => setShowSend(false)}
|
||||
onSend={sendTranscript}
|
||||
/>
|
||||
@ -444,74 +505,6 @@ function RecordPage() {
|
||||
);
|
||||
}
|
||||
|
||||
function SendDialog({
|
||||
users,
|
||||
query,
|
||||
sending,
|
||||
onQuery,
|
||||
onClose,
|
||||
onSend,
|
||||
}: {
|
||||
users: UserSummary[];
|
||||
query: string;
|
||||
sending: boolean;
|
||||
onQuery: (value: string) => void;
|
||||
onClose: () => void;
|
||||
onSend: (id: number) => void;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-background/75 p-4 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
className="glass-strong w-full max-w-md rounded-2xl p-6 shadow-[var(--shadow-card)]"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="font-display text-lg font-semibold">Send transcript</h3>
|
||||
<p className="mt-1 text-sm text-muted-foreground">Choose a registered Orphion user.</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded-lg p-2 text-muted-foreground transition hover:bg-secondary hover:text-foreground"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
value={query}
|
||||
onChange={(event) => onQuery(event.target.value)}
|
||||
placeholder="Search by name, username, or email"
|
||||
className="mt-4 w-full rounded-xl border border-border bg-input/40 px-4 py-2.5 text-sm outline-none transition focus:border-primary/60"
|
||||
/>
|
||||
<div className="mt-3 max-h-72 space-y-1 overflow-y-auto">
|
||||
{users.length === 0 && (
|
||||
<p className="py-5 text-center text-sm text-muted-foreground">No users found.</p>
|
||||
)}
|
||||
{users.map((user) => (
|
||||
<button
|
||||
key={user.id}
|
||||
disabled={sending}
|
||||
onClick={() => onSend(user.id)}
|
||||
className="flex w-full items-center gap-3 rounded-xl px-3 py-2.5 text-left transition hover:bg-secondary disabled:opacity-60"
|
||||
>
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-primary text-sm font-semibold text-primary-foreground">
|
||||
{user.fullName.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-medium">{user.fullName}</div>
|
||||
<div className="truncate text-xs text-muted-foreground">@{user.username}</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatTime(seconds: number) {
|
||||
return `${String(Math.floor(seconds / 60)).padStart(2, "0")}:${String(seconds % 60).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
@ -3,6 +3,7 @@ import { useQuery } from "@tanstack/react-query";
|
||||
import { useMemo, useState } from "react";
|
||||
import { CheckCircle2, Download, Search, Send } from "lucide-react";
|
||||
import { transcriptService } from "@/services/transcripts";
|
||||
import { DateField, FilterSelect } from "@/components/filter-controls";
|
||||
|
||||
export const Route = createFileRoute("/_authenticated/sent")({
|
||||
head: () => ({ meta: [{ title: "Sent — Orphion" }] }),
|
||||
@ -26,6 +27,13 @@ function SentPage() {
|
||||
});
|
||||
return Array.from(map, ([id, name]) => ({ id, name }));
|
||||
}, [items]);
|
||||
const receiverOptions = useMemo(
|
||||
() => [
|
||||
{ value: "all", label: "All recipients" },
|
||||
...receivers.map((item) => ({ value: String(item.id), label: item.name })),
|
||||
],
|
||||
[receivers],
|
||||
);
|
||||
|
||||
const filtered = items.filter((item) => {
|
||||
const haystack =
|
||||
@ -59,24 +67,13 @@ function SentPage() {
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
<FilterSelect
|
||||
value={receiver}
|
||||
onChange={(event) => setReceiver(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"
|
||||
>
|
||||
<option value="all">All recipients</option>
|
||||
{receivers.map((item) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
{item.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<input
|
||||
type="date"
|
||||
value={date}
|
||||
onChange={(event) => 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={receiverOptions}
|
||||
onChange={setReceiver}
|
||||
ariaLabel="Filter by recipient"
|
||||
/>
|
||||
<DateField value={date} onChange={setDate} ariaLabel="Filter sent transcripts by date" />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4">
|
||||
|
||||
@ -1,12 +1,14 @@
|
||||
import { createFileRoute, Link, useNavigate } from "@tanstack/react-router";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useEffect, useState } from "react";
|
||||
import { ArrowLeft, Copy, Download, Loader2, Save, Send, Sparkles, Trash2, X } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { ArrowLeft, Copy, Download, Loader2, Save, Send, Sparkles, Trash2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { transcriptService } from "@/services/transcripts";
|
||||
import { userService } from "@/services/users";
|
||||
import { useAuth } from "@/context/auth";
|
||||
import type { UserSummary } from "@/services/types";
|
||||
import type { Transcript } from "@/services/types";
|
||||
import { AudioPlayer } from "@/components/audio-player";
|
||||
import { ConfirmDialog } from "@/components/confirm-dialog";
|
||||
import { SendTranscriptDialog } from "@/components/send-transcript-dialog";
|
||||
|
||||
export const Route = createFileRoute("/_authenticated/transcripts/$id")({
|
||||
head: () => ({ meta: [{ title: "Transcript — Orphion" }] }),
|
||||
@ -22,36 +24,59 @@ function TranscriptDetailPage() {
|
||||
const [title, setTitle] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [showSend, setShowSend] = useState(false);
|
||||
const [users, setUsers] = useState<UserSummary[]>([]);
|
||||
const [searchQ, setSearchQ] = useState("");
|
||||
const [sending, setSending] = useState(false);
|
||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [retranscribing, setRetranscribing] = useState(false);
|
||||
const lastSavedTitleRef = useRef("");
|
||||
const hydratedTranscriptIdRef = useRef<number | null>(null);
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ["transcript", id],
|
||||
queryFn: () => transcriptService.get(id),
|
||||
});
|
||||
const transcript = data?.transcript ?? null;
|
||||
const isReceived = transcript
|
||||
? transcript.receiverId === user?.id && transcript.senderId !== user?.id
|
||||
: false;
|
||||
|
||||
useEffect(() => {
|
||||
if (!transcript) return;
|
||||
if (hydratedTranscriptIdRef.current === transcript.id) return;
|
||||
hydratedTranscriptIdRef.current = transcript.id;
|
||||
setText(transcript.transcriptText);
|
||||
setTitle(transcript.title ?? "");
|
||||
lastSavedTitleRef.current = transcript.title ?? "";
|
||||
}, [transcript]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!showSend) return;
|
||||
if (!transcript || isReceived) return;
|
||||
|
||||
const nextTitle = title.trim();
|
||||
if (nextTitle === lastSavedTitleRef.current) return;
|
||||
|
||||
const handle = window.setTimeout(async () => {
|
||||
const data = await userService.search(searchQ).catch(() => ({ users: [] }));
|
||||
setUsers(data.users);
|
||||
}, 160);
|
||||
setSaving(true);
|
||||
try {
|
||||
const data = await transcriptService.update(transcript.id, { title: nextTitle });
|
||||
lastSavedTitleRef.current = data.transcript.title ?? "";
|
||||
await queryClient.invalidateQueries({ queryKey: ["transcripts"] });
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "Unable to auto-save title");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, 700);
|
||||
|
||||
return () => window.clearTimeout(handle);
|
||||
}, [showSend, searchQ]);
|
||||
}, [isReceived, queryClient, title, transcript]);
|
||||
|
||||
async function save() {
|
||||
if (!transcript) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
await transcriptService.update(transcript.id, { title, transcriptText: text });
|
||||
lastSavedTitleRef.current = title.trim();
|
||||
await queryClient.invalidateQueries({ queryKey: ["transcript", id] });
|
||||
await queryClient.invalidateQueries({ queryKey: ["transcripts"] });
|
||||
toast.success("Saved");
|
||||
@ -63,7 +88,8 @@ function TranscriptDetailPage() {
|
||||
}
|
||||
|
||||
async function remove() {
|
||||
if (!transcript || !confirm("Delete this transcript?")) return;
|
||||
if (!transcript) return;
|
||||
setDeleting(true);
|
||||
try {
|
||||
await transcriptService.remove(transcript.id);
|
||||
await queryClient.invalidateQueries({ queryKey: ["transcripts"] });
|
||||
@ -71,6 +97,9 @@ function TranscriptDetailPage() {
|
||||
navigate({ to: "/transcripts" });
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "Unable to delete");
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
setConfirmDelete(false);
|
||||
}
|
||||
}
|
||||
|
||||
@ -96,11 +125,41 @@ function TranscriptDetailPage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function retranscribe() {
|
||||
if (!transcript) return;
|
||||
setRetranscribing(true);
|
||||
try {
|
||||
const response = await transcriptService.retranscribe(transcript.id);
|
||||
toast.success("Re-transcription queued");
|
||||
const completed = await waitForTranscript(response.job.id);
|
||||
setText(completed.transcriptText);
|
||||
setTitle(completed.title ?? "");
|
||||
await queryClient.invalidateQueries({ queryKey: ["transcript", id] });
|
||||
await queryClient.invalidateQueries({ queryKey: ["transcripts"] });
|
||||
toast.success("Transcript refreshed");
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "Unable to re-transcribe");
|
||||
} finally {
|
||||
setRetranscribing(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForTranscript(jobId: number): Promise<Transcript> {
|
||||
for (;;) {
|
||||
await new Promise((resolve) => {
|
||||
window.setTimeout(resolve, 1800);
|
||||
});
|
||||
const status = await transcriptService.transcriptionStatus(jobId);
|
||||
if (status.job.status === "completed") return status.transcript;
|
||||
if (status.job.status === "failed") {
|
||||
throw new Error(status.job.lastError ?? "Transcription failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading || !transcript)
|
||||
return <div className="text-sm text-muted-foreground">Loading transcript...</div>;
|
||||
|
||||
const isReceived = transcript.receiverId === user?.id && transcript.senderId !== user?.id;
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-4xl space-y-6">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
@ -140,8 +199,23 @@ function TranscriptDetailPage() {
|
||||
)}
|
||||
{!isReceived && (
|
||||
<button
|
||||
onClick={remove}
|
||||
onClick={retranscribe}
|
||||
disabled={retranscribing}
|
||||
className="flex items-center gap-1.5 rounded-lg border border-border bg-secondary/30 px-3 py-2 text-xs transition hover:border-primary/50 disabled:opacity-60"
|
||||
>
|
||||
{retranscribing ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Sparkles className="h-3.5 w-3.5" />
|
||||
)}
|
||||
Re-transcribe
|
||||
</button>
|
||||
)}
|
||||
{!isReceived && (
|
||||
<button
|
||||
onClick={() => setConfirmDelete(true)}
|
||||
className="rounded-lg border border-destructive/30 bg-secondary/30 px-3 py-2 text-xs text-destructive transition hover:bg-destructive/10"
|
||||
aria-label="Delete transcript"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
@ -176,7 +250,11 @@ function TranscriptDetailPage() {
|
||||
{transcript.receiverId ? "sent" : "saved"}
|
||||
</span>
|
||||
</div>
|
||||
<audio src={transcriptService.audioUrl(transcript.id)} controls className="w-full" />
|
||||
<AudioPlayer
|
||||
src={transcriptService.audioUrl(transcript.id)}
|
||||
downloadUrl={transcriptService.audioUrl(transcript.id)}
|
||||
label={transcript.title || `Transcript #${transcript.id} audio`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="glass rounded-2xl p-6">
|
||||
@ -228,83 +306,18 @@ function TranscriptDetailPage() {
|
||||
)}
|
||||
|
||||
{showSend && (
|
||||
<SendDialog
|
||||
users={users}
|
||||
query={searchQ}
|
||||
sending={sending}
|
||||
onQuery={setSearchQ}
|
||||
onClose={() => setShowSend(false)}
|
||||
onSend={send}
|
||||
/>
|
||||
<SendTranscriptDialog sending={sending} onClose={() => setShowSend(false)} onSend={send} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SendDialog({
|
||||
users,
|
||||
query,
|
||||
sending,
|
||||
onQuery,
|
||||
onClose,
|
||||
onSend,
|
||||
}: {
|
||||
users: UserSummary[];
|
||||
query: string;
|
||||
sending: boolean;
|
||||
onQuery: (value: string) => void;
|
||||
onClose: () => void;
|
||||
onSend: (id: number) => void;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-background/75 p-4 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
className="glass-strong w-full max-w-md rounded-2xl p-6"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="font-display text-lg font-semibold">Send transcript</h3>
|
||||
<p className="mt-1 text-sm text-muted-foreground">Choose a recipient.</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded-lg p-2 text-muted-foreground transition hover:bg-secondary hover:text-foreground"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
value={query}
|
||||
onChange={(event) => onQuery(event.target.value)}
|
||||
placeholder="Search users..."
|
||||
className="mt-4 w-full rounded-xl border border-border bg-input/40 px-4 py-2.5 text-sm outline-none transition focus:border-primary/60"
|
||||
<ConfirmDialog
|
||||
open={confirmDelete}
|
||||
title="Delete transcript"
|
||||
description="This permanently removes the transcript and its linked audio from your workspace."
|
||||
confirmLabel="Delete"
|
||||
destructive
|
||||
loading={deleting}
|
||||
onClose={() => setConfirmDelete(false)}
|
||||
onConfirm={remove}
|
||||
/>
|
||||
<div className="mt-3 max-h-72 space-y-1 overflow-y-auto">
|
||||
{users.length === 0 && (
|
||||
<p className="py-4 text-center text-sm text-muted-foreground">No users found.</p>
|
||||
)}
|
||||
{users.map((recipient) => (
|
||||
<button
|
||||
key={recipient.id}
|
||||
disabled={sending}
|
||||
onClick={() => onSend(recipient.id)}
|
||||
className="flex w-full items-center gap-3 rounded-xl px-3 py-2.5 text-left transition hover:bg-secondary disabled:opacity-60"
|
||||
>
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-primary text-sm font-semibold text-primary-foreground">
|
||||
{recipient.fullName.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-medium">{recipient.fullName}</div>
|
||||
<div className="truncate text-xs text-muted-foreground">@{recipient.username}</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,8 +1,11 @@
|
||||
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { FileText, Mic, Search } from "lucide-react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { CheckSquare, FileText, Mic, Search, Trash2 } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { transcriptService } from "@/services/transcripts";
|
||||
import { useAuth } from "@/context/auth";
|
||||
import { ConfirmDialog } from "@/components/confirm-dialog";
|
||||
|
||||
export const Route = createFileRoute("/_authenticated/transcripts/")({
|
||||
head: () => ({ meta: [{ title: "Transcripts — Orphion" }] }),
|
||||
@ -10,7 +13,12 @@ export const Route = createFileRoute("/_authenticated/transcripts/")({
|
||||
});
|
||||
|
||||
function TranscriptsPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const { user } = useAuth();
|
||||
const [q, setQ] = useState("");
|
||||
const [selected, setSelected] = useState<Set<number>>(() => new Set());
|
||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ["transcripts"],
|
||||
queryFn: transcriptService.list,
|
||||
@ -22,6 +30,51 @@ function TranscriptsPage() {
|
||||
`${item.title ?? ""} ${item.transcriptText} ${item.sender?.fullName ?? ""} ${item.receiver?.fullName ?? ""}`.toLowerCase();
|
||||
return !q || haystack.includes(q.toLowerCase());
|
||||
});
|
||||
const deletableIds = filtered
|
||||
.filter((item) => Number(item.senderId) === Number(user?.id))
|
||||
.map((item) => item.id);
|
||||
const selectedIds = Array.from(selected).filter((id) => deletableIds.includes(id));
|
||||
const allDeletableSelected =
|
||||
deletableIds.length > 0 && deletableIds.every((id) => selected.has(id));
|
||||
|
||||
function toggleSelected(id: number) {
|
||||
setSelected((current) => {
|
||||
const next = new Set(current);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function toggleAll() {
|
||||
setSelected((current) => {
|
||||
const next = new Set(current);
|
||||
if (allDeletableSelected) {
|
||||
deletableIds.forEach((id) => next.delete(id));
|
||||
} else {
|
||||
deletableIds.forEach((id) => next.add(id));
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
async function deleteSelected() {
|
||||
if (selectedIds.length === 0) return;
|
||||
setDeleting(true);
|
||||
try {
|
||||
await Promise.all(selectedIds.map((id) => transcriptService.remove(id)));
|
||||
setSelected(new Set());
|
||||
await queryClient.invalidateQueries({ queryKey: ["transcripts"] });
|
||||
toast.success(
|
||||
`${selectedIds.length} transcript${selectedIds.length === 1 ? "" : "s"} deleted`,
|
||||
);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "Unable to delete selected transcripts");
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
setConfirmDelete(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@ -53,6 +106,28 @@ function TranscriptsPage() {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{deletableIds.length > 0 && (
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 rounded-2xl border border-border bg-secondary/25 px-4 py-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleAll}
|
||||
className="flex items-center gap-2 rounded-xl border border-border bg-background/30 px-3 py-2 text-sm transition hover:border-primary/50"
|
||||
>
|
||||
<CheckSquare className="h-4 w-4 text-primary" />
|
||||
{allDeletableSelected ? "Clear selection" : "Select deletable"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={selectedIds.length === 0}
|
||||
onClick={() => setConfirmDelete(true)}
|
||||
className="flex items-center gap-2 rounded-xl bg-destructive px-4 py-2 text-sm font-semibold text-destructive-foreground transition hover:bg-destructive/90 disabled:opacity-50"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
Delete selected {selectedIds.length > 0 ? `(${selectedIds.length})` : ""}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid gap-3">
|
||||
{isLoading && <p className="text-sm text-muted-foreground">Loading transcripts...</p>}
|
||||
{!isLoading && filtered.length === 0 && (
|
||||
@ -61,16 +136,29 @@ function TranscriptsPage() {
|
||||
<p className="mt-3 text-sm text-muted-foreground">No transcripts yet.</p>
|
||||
</div>
|
||||
)}
|
||||
{filtered.map((item) => (
|
||||
{filtered.map((item) => {
|
||||
const canDelete = Number(item.senderId) === Number(user?.id);
|
||||
return (
|
||||
<article key={item.id} className="glass rounded-2xl p-5 transition hover:border-glow">
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
<label className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl border border-border bg-secondary/30 transition hover:border-primary/50">
|
||||
<input
|
||||
type="checkbox"
|
||||
disabled={!canDelete}
|
||||
checked={selected.has(item.id)}
|
||||
onChange={() => toggleSelected(item.id)}
|
||||
className="h-4 w-4 accent-primary disabled:opacity-40"
|
||||
aria-label={`Select ${item.title || `Transcript #${item.id}`}`}
|
||||
/>
|
||||
</label>
|
||||
<div className="min-w-0 flex-1">
|
||||
<Link
|
||||
key={item.id}
|
||||
to="/transcripts/$id"
|
||||
params={{ id: String(item.id) }}
|
||||
className="glass rounded-2xl p-5 transition hover:border-glow"
|
||||
className="font-medium transition hover:text-primary"
|
||||
>
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="font-medium">{item.title || `Transcript #${item.id}`}</div>
|
||||
{item.title || `Transcript #${item.id}`}
|
||||
</Link>
|
||||
<p className="mt-1 line-clamp-2 text-sm text-muted-foreground">
|
||||
{item.transcriptText}
|
||||
</p>
|
||||
@ -86,9 +174,22 @@ function TranscriptsPage() {
|
||||
{item.receiverId ? "sent" : "saved"}
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<ConfirmDialog
|
||||
open={confirmDelete}
|
||||
title="Delete selected transcripts"
|
||||
description={`This will permanently remove ${selectedIds.length} selected transcript${
|
||||
selectedIds.length === 1 ? "" : "s"
|
||||
} and linked audio files you own.`}
|
||||
confirmLabel="Delete selected"
|
||||
destructive
|
||||
loading={deleting}
|
||||
onClose={() => setConfirmDelete(false)}
|
||||
onConfirm={deleteSelected}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -3,7 +3,7 @@ import { useEffect, useState, type FormEvent, type InputHTMLAttributes } from "r
|
||||
import { motion } from "framer-motion";
|
||||
import { toast } from "sonner";
|
||||
import { AtSign, Lock, ArrowRight } from "lucide-react";
|
||||
import { OrphionLogo } from "@/lib/orphion-logo";
|
||||
import { OrphionHarpBackdrop, OrphionLogo } from "@/lib/orphion-logo";
|
||||
import { useAuth } from "@/context/auth";
|
||||
|
||||
export const Route = createFileRoute("/login")({
|
||||
@ -86,6 +86,7 @@ export function AuthShell({
|
||||
<div className="relative flex min-h-screen items-center justify-center px-4">
|
||||
<div className="pointer-events-none absolute -top-40 left-1/2 h-[500px] w-[700px] -translate-x-1/2 rounded-full bg-primary/20 blur-[120px]" />
|
||||
<div className="pointer-events-none absolute inset-0 grid-bg opacity-20" />
|
||||
<OrphionHarpBackdrop className="fixed -right-20 bottom-[-10%] h-[78vh] w-[42vw] min-w-72 opacity-55" />
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
|
||||
@ -48,6 +48,13 @@ export const transcriptService = {
|
||||
return apiRequest<Record<string, never>>({ url: `/transcripts/${id}`, method: "DELETE" });
|
||||
},
|
||||
|
||||
retranscribe(id: number | string) {
|
||||
return apiRequest<{ job: TranscriptionJob; transcript: Transcript }>({
|
||||
url: `/transcripts/${id}/retranscribe`,
|
||||
method: "POST",
|
||||
});
|
||||
},
|
||||
|
||||
send(input: { transcriptId: number; receiverId: number }) {
|
||||
return apiRequest<{ transcript: Transcript }>({
|
||||
url: "/transcripts/send",
|
||||
|
||||
@ -7,8 +7,12 @@ export const userService = {
|
||||
},
|
||||
|
||||
search(q: string) {
|
||||
const trimmed = q.trim();
|
||||
if (!trimmed) {
|
||||
return apiRequest<{ users: UserSummary[] }>({ url: "/users" });
|
||||
}
|
||||
const params = new URLSearchParams();
|
||||
if (q) params.set("q", q);
|
||||
params.set("q", trimmed);
|
||||
return apiRequest<{ users: UserSummary[] }>({ url: `/users?${params.toString()}` });
|
||||
},
|
||||
};
|
||||
|
||||
@ -46,51 +46,52 @@
|
||||
:root {
|
||||
--radius: 0.875rem;
|
||||
|
||||
/* Orphion — premium black + deep red */
|
||||
--background: #050505;
|
||||
/* Orphion — premium black + vivid crimson */
|
||||
--background: #060607;
|
||||
--foreground: #f5f5f5;
|
||||
|
||||
--card: #111111;
|
||||
--card: #101113;
|
||||
--card-foreground: #f5f5f5;
|
||||
--popover: #111111;
|
||||
--popover: #111113;
|
||||
--popover-foreground: #f5f5f5;
|
||||
|
||||
--primary: #8b0000;
|
||||
--primary: #dc2626;
|
||||
--primary-foreground: #f5f5f5;
|
||||
--glow: #b22222;
|
||||
--glow: #ff4d5e;
|
||||
|
||||
--secondary: #111111;
|
||||
--secondary: #121318;
|
||||
--secondary-foreground: #f5f5f5;
|
||||
--muted: #171717;
|
||||
--muted-foreground: #9a9a9a;
|
||||
--accent: #1a0b0b;
|
||||
--muted: #181a20;
|
||||
--muted-foreground: #a4a6ad;
|
||||
--accent: #251013;
|
||||
--accent-foreground: #f5f5f5;
|
||||
--destructive: #b22222;
|
||||
--destructive: #ef4444;
|
||||
--destructive-foreground: #f5f5f5;
|
||||
|
||||
--border: rgba(255, 255, 255, 0.08);
|
||||
--input: #111111;
|
||||
--ring: #b22222;
|
||||
--input: #111318;
|
||||
--ring: #ff4d5e;
|
||||
|
||||
--sidebar: #070707;
|
||||
--sidebar: #090a0d;
|
||||
--sidebar-foreground: #d7d7d7;
|
||||
--sidebar-primary: #8b0000;
|
||||
--sidebar-primary: #dc2626;
|
||||
--sidebar-primary-foreground: #f5f5f5;
|
||||
--sidebar-accent: #141010;
|
||||
--sidebar-accent: #171116;
|
||||
--sidebar-accent-foreground: #f5f5f5;
|
||||
--sidebar-border: rgba(255, 255, 255, 0.08);
|
||||
--sidebar-ring: #b22222;
|
||||
--sidebar-ring: #ff4d5e;
|
||||
|
||||
--gradient-hero:
|
||||
radial-gradient(ellipse 80% 60% at 50% -10%, rgba(139, 0, 0, 0.24), transparent 60%),
|
||||
radial-gradient(ellipse 45% 40% at 90% 90%, rgba(178, 34, 34, 0.12), transparent 60%);
|
||||
--gradient-cyan: linear-gradient(135deg, #b22222, #8b0000);
|
||||
--shadow-glow: 0 0 44px -12px rgba(178, 34, 34, 0.62);
|
||||
radial-gradient(ellipse 80% 60% at 50% -10%, rgba(220, 38, 38, 0.22), transparent 60%),
|
||||
radial-gradient(ellipse 52% 42% at 88% 92%, rgba(255, 77, 94, 0.13), transparent 62%),
|
||||
linear-gradient(135deg, rgba(14, 18, 28, 0.58), rgba(6, 6, 7, 0.1) 45%, rgba(26, 7, 9, 0.2));
|
||||
--gradient-cyan: linear-gradient(135deg, #ff4d5e, #dc2626 55%, #991b1b);
|
||||
--shadow-glow: 0 0 44px -12px rgba(255, 77, 94, 0.66);
|
||||
--shadow-card: 0 18px 54px -22px rgba(0, 0, 0, 0.84);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: #050505;
|
||||
--background: #060607;
|
||||
--foreground: #f5f5f5;
|
||||
}
|
||||
|
||||
@ -117,19 +118,19 @@
|
||||
letter-spacing: 0;
|
||||
}
|
||||
::selection {
|
||||
background: rgba(178, 34, 34, 0.35);
|
||||
background: rgba(255, 77, 94, 0.35);
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
.glass {
|
||||
background: rgba(17, 17, 17, 0.58);
|
||||
background: rgba(16, 17, 19, 0.62);
|
||||
backdrop-filter: blur(18px) saturate(140%);
|
||||
-webkit-backdrop-filter: blur(18px) saturate(140%);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
.glass-strong {
|
||||
background: rgba(17, 17, 17, 0.78);
|
||||
background: rgba(16, 17, 19, 0.82);
|
||||
backdrop-filter: blur(24px) saturate(160%);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
@ -152,19 +153,19 @@
|
||||
box-shadow: var(--shadow-glow);
|
||||
}
|
||||
.border-glow {
|
||||
border: 1px solid rgba(178, 34, 34, 0.42);
|
||||
border: 1px solid rgba(255, 77, 94, 0.46);
|
||||
}
|
||||
.grid-bg {
|
||||
background-image:
|
||||
linear-gradient(rgba(255, 255, 255, 0.04) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(255, 255, 255, 0.04) 1px, transparent 1px);
|
||||
linear-gradient(rgba(255, 255, 255, 0.035) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(255, 255, 255, 0.035) 1px, transparent 1px);
|
||||
background-size: 48px 48px;
|
||||
}
|
||||
.input {
|
||||
width: 100%;
|
||||
border-radius: 0.75rem;
|
||||
border: 1px solid var(--border);
|
||||
background: rgba(17, 17, 17, 0.55);
|
||||
background: rgba(17, 19, 24, 0.58);
|
||||
padding: 0.625rem 1rem;
|
||||
font-size: 0.875rem;
|
||||
outline: none;
|
||||
@ -173,20 +174,48 @@
|
||||
background 160ms ease;
|
||||
}
|
||||
.input:focus {
|
||||
border-color: rgba(178, 34, 34, 0.62);
|
||||
background: rgba(17, 17, 17, 0.72);
|
||||
border-color: rgba(255, 77, 94, 0.62);
|
||||
background: rgba(17, 19, 24, 0.76);
|
||||
}
|
||||
.orphion-harp-mark {
|
||||
overflow: hidden;
|
||||
background: linear-gradient(165deg, #fff1f2 0%, #ff4d5e 48%, #dc2626 78%, #7f1d1d 100%);
|
||||
filter: drop-shadow(0 0 12px rgba(255, 77, 94, 0.48));
|
||||
transform-origin: 46% 18%;
|
||||
animation: harp-sway 5.8s ease-in-out infinite;
|
||||
}
|
||||
.orphion-harp-mark::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent 32%,
|
||||
rgba(255, 255, 255, 0.38) 48%,
|
||||
transparent 64%
|
||||
);
|
||||
transform: translateX(-76%);
|
||||
animation: harp-glint 4.8s ease-in-out infinite;
|
||||
}
|
||||
.orphion-harp-backdrop {
|
||||
background:
|
||||
linear-gradient(165deg, rgba(245, 245, 245, 0.16), rgba(255, 77, 94, 0.3) 48%),
|
||||
linear-gradient(0deg, rgba(220, 38, 38, 0.2), rgba(127, 29, 29, 0.12));
|
||||
filter: drop-shadow(0 0 54px rgba(255, 77, 94, 0.24));
|
||||
transform-origin: 50% 16%;
|
||||
animation: harp-breathe 9s ease-in-out infinite;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse-ring {
|
||||
0% {
|
||||
box-shadow: 0 0 0 0 rgba(178, 34, 34, 0.6);
|
||||
box-shadow: 0 0 0 0 rgba(255, 77, 94, 0.6);
|
||||
}
|
||||
70% {
|
||||
box-shadow: 0 0 0 28px rgba(178, 34, 34, 0);
|
||||
box-shadow: 0 0 0 28px rgba(255, 77, 94, 0);
|
||||
}
|
||||
100% {
|
||||
box-shadow: 0 0 0 0 rgba(178, 34, 34, 0);
|
||||
box-shadow: 0 0 0 0 rgba(255, 77, 94, 0);
|
||||
}
|
||||
}
|
||||
.animate-pulse-ring {
|
||||
@ -205,3 +234,51 @@
|
||||
.animate-float {
|
||||
animation: float-slow 6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes harp-sway {
|
||||
0%,
|
||||
100% {
|
||||
transform: rotate(-1deg) translateY(0);
|
||||
}
|
||||
50% {
|
||||
transform: rotate(1.2deg) translateY(-1px);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes harp-glint {
|
||||
0%,
|
||||
42% {
|
||||
transform: translateX(-76%);
|
||||
opacity: 0;
|
||||
}
|
||||
52% {
|
||||
opacity: 0.68;
|
||||
}
|
||||
66%,
|
||||
100% {
|
||||
transform: translateX(76%);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes harp-breathe {
|
||||
0%,
|
||||
100% {
|
||||
transform: rotate(-2deg) translateY(0) scale(1);
|
||||
opacity: 0.54;
|
||||
}
|
||||
50% {
|
||||
transform: rotate(1deg) translateY(-10px) scale(1.018);
|
||||
opacity: 0.72;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.orphion-harp-mark,
|
||||
.orphion-harp-mark::after,
|
||||
.orphion-harp-backdrop,
|
||||
.animate-float,
|
||||
.animate-pulse-ring {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user