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 @@
+
+
+
+
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 */}
-
+
{/* 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"
>
-
+
{item.transcriptText}
diff --git a/frontend/src/routes/_authenticated/record.tsx b/frontend/src/routes/_authenticated/record.tsx
index d1ed3f4..0de52b6 100644
--- a/frontend/src/routes/_authenticated/record.tsx
+++ b/frontend/src/routes/_authenticated/record.tsx
@@ -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
("idle");
const [seconds, setSeconds] = useState(0);
const [levels, setLevels] = useState(Array(44).fill(0.12));
@@ -43,8 +46,6 @@ function RecordPage() {
const [saving, setSaving] = useState(false);
const [lastError, setLastError] = useState(null);
const [showSend, setShowSend] = useState(false);
- const [users, setUsers] = useState([]);
- const [userQuery, setUserQuery] = useState("");
const [sending, setSending] = useState(false);
const mediaRecorderRef = useRef(null);
@@ -54,17 +55,37 @@ function RecordPage() {
const analyserRef = useRef(null);
const rafRef = useRef(null);
const timerRef = useRef | 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)]"
/>
))}
@@ -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"
/>
-
+
)}
@@ -431,11 +495,8 @@ function RecordPage() {
)}
{showSend && (
- 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 (
-
-
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.
-
-
-
-
-
-
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"
- />
-
- {users.length === 0 && (
-
No users found.
- )}
- {users.map((user) => (
-
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"
- >
-
- {user.fullName.charAt(0).toUpperCase()}
-
-
-
{user.fullName}
-
@{user.username}
-
-
- ))}
-
-
-
- );
-}
-
function formatTime(seconds: number) {
return `${String(Math.floor(seconds / 60)).padStart(2, "0")}:${String(seconds % 60).padStart(2, "0")}`;
}
diff --git a/frontend/src/routes/_authenticated/sent.tsx b/frontend/src/routes/_authenticated/sent.tsx
index f20dc3b..ec9d4fa 100644
--- a/frontend/src/routes/_authenticated/sent.tsx
+++ b/frontend/src/routes/_authenticated/sent.tsx
@@ -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"
/>
-
- 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"
/>
+
diff --git a/frontend/src/routes/_authenticated/transcripts.$id.tsx b/frontend/src/routes/_authenticated/transcripts.$id.tsx
index 1bb64bf..8e10191 100644
--- a/frontend/src/routes/_authenticated/transcripts.$id.tsx
+++ b/frontend/src/routes/_authenticated/transcripts.$id.tsx
@@ -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
([]);
- 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(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 {
+ 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 Loading transcript...
;
- const isReceived = transcript.receiverId === user?.id && transcript.senderId !== user?.id;
-
return (
@@ -140,8 +199,23 @@ function TranscriptDetailPage() {
)}
{!isReceived && (
+ {retranscribing ? (
+
+ ) : (
+
+ )}
+ Re-transcribe
+
+ )}
+ {!isReceived && (
+ 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"
>
@@ -176,7 +250,11 @@ function TranscriptDetailPage() {
{transcript.receiverId ? "sent" : "saved"}
-
+
@@ -228,83 +306,18 @@ function TranscriptDetailPage() {
)}
{showSend && (
- setShowSend(false)}
- onSend={send}
- />
+ setShowSend(false)} onSend={send} />
)}
-
- );
-}
-
-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 (
-
-
event.stopPropagation()}
- className="glass-strong w-full max-w-md rounded-2xl p-6"
- >
-
-
-
Send transcript
-
Choose a recipient.
-
-
-
-
-
-
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"
- />
-
- {users.length === 0 && (
-
No users found.
- )}
- {users.map((recipient) => (
-
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"
- >
-
- {recipient.fullName.charAt(0).toUpperCase()}
-
-
-
{recipient.fullName}
-
@{recipient.username}
-
-
- ))}
-
-
+
setConfirmDelete(false)}
+ onConfirm={remove}
+ />
);
}
diff --git a/frontend/src/routes/_authenticated/transcripts.index.tsx b/frontend/src/routes/_authenticated/transcripts.index.tsx
index 8199cc9..212da84 100644
--- a/frontend/src/routes/_authenticated/transcripts.index.tsx
+++ b/frontend/src/routes/_authenticated/transcripts.index.tsx
@@ -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>(() => 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 (
@@ -53,6 +106,28 @@ function TranscriptsPage() {
+ {deletableIds.length > 0 && (
+
+
+
+ {allDeletableSelected ? "Clear selection" : "Select deletable"}
+
+ 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"
+ >
+
+ Delete selected {selectedIds.length > 0 ? `(${selectedIds.length})` : ""}
+
+
+ )}
+
{isLoading &&
Loading transcripts...
}
{!isLoading && filtered.length === 0 && (
@@ -61,34 +136,60 @@ function TranscriptsPage() {
No transcripts yet.
)}
- {filtered.map((item) => (
-
-
-
-
{item.title || `Transcript #${item.id}`}
-
- {item.transcriptText}
-
-
-
{new Date(item.createdAt).toLocaleString()}
- {item.language ?
{item.language} : null}
- {item.metadata?.duration ? (
-
{Math.round(item.metadata.duration)}s
- ) : null}
+ {filtered.map((item) => {
+ const canDelete = Number(item.senderId) === Number(user?.id);
+ return (
+
+
+
+
+
+ {item.title || `Transcript #${item.id}`}
+
+
+ {item.transcriptText}
+
+
+ {new Date(item.createdAt).toLocaleString()}
+ {item.language ? {item.language} : null}
+ {item.metadata?.duration ? (
+ {Math.round(item.metadata.duration)}s
+ ) : null}
+
+
+ {item.receiverId ? "sent" : "saved"}
+
-
- {item.receiverId ? "sent" : "saved"}
-
-
-
- ))}
+
+ );
+ })}
+
setConfirmDelete(false)}
+ onConfirm={deleteSelected}
+ />
);
}
diff --git a/frontend/src/routes/login.tsx b/frontend/src/routes/login.tsx
index 3df524d..33ec3a6 100644
--- a/frontend/src/routes/login.tsx
+++ b/frontend/src/routes/login.tsx
@@ -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({
+
>({ 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",
diff --git a/frontend/src/services/users.ts b/frontend/src/services/users.ts
index 887a41e..a28c493 100644
--- a/frontend/src/services/users.ts
+++ b/frontend/src/services/users.ts
@@ -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()}` });
},
};
diff --git a/frontend/src/styles.css b/frontend/src/styles.css
index fa8f00d..1d39e7f 100644
--- a/frontend/src/styles.css
+++ b/frontend/src/styles.css
@@ -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;
+ }
+}