Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions apps/examlense/frontend/src/components/shared/ExamModeRedirect.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { Navigate } from "react-router-dom";
import {
examModePath,
examModeSlug,
type Exam,
type ExamModeSlug,
} from "@/lib/exam/exam-helpers";

/**
* Returns a `<Navigate replace>` to the exam's canonical mode when `exam`'s
* status doesn't belong in `expected`, else `null`. Call it AFTER the loading
* and `!exam` guards so `exam` is known to be present.
*
* Routing keeps each exam in the single mode its status maps to (see
* `examModeSlug`); visiting another mode's URL redirects instead of silently
* transitioning the exam.
*/
export function examModeRedirect(exam: Exam, expected: ExamModeSlug) {
return examModeSlug(exam.status) !== expected ? (
<Navigate to={examModePath(exam.id, exam.status)} replace />
) : null;
}
4 changes: 2 additions & 2 deletions apps/examlense/frontend/src/components/shared/ModelLogo.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { cn } from "@/lib/utils/utils";
import { solverModelMeta } from "@/lib/exam/solver-model-meta";
import { modelMeta } from "@/lib/exam/model-meta";

interface Props {
modelId: string;
Expand All @@ -12,7 +12,7 @@ interface Props {
* logo is mapped. Callers own the surrounding chip/container.
*/
export const ModelLogo = ({ modelId, className }: Props) => {
const meta = solverModelMeta(modelId);
const meta = modelMeta(modelId);

if (meta?.logoSrc) {
return (
Expand Down
Original file line number Diff line number Diff line change
@@ -1,22 +1,20 @@
import { useEffect, useState } from "react";
import { Pencil } from "lucide-react";
import { Input } from "@/components/ui/input";
import { useClickToEdit } from "@/hooks/ui/use-click-to-edit";

interface Props {
value: string;
onSave: (v: string) => void;
}

export const InlineTitle = ({ value, onSave }: Props) => {
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(value);
useEffect(() => setDraft(value), [value]);
const { editing, startEditing, inputProps } = useClickToEdit(value, onSave);

if (!editing) {
return (
<button
type="button"
onClick={() => setEditing(true)}
onClick={startEditing}
className="group flex max-w-full items-center gap-1.5 text-left font-body text-base font-semibold leading-tight text-hestia-text transition-colors hover:text-hestia-primary"
>
<span className="truncate">
Expand All @@ -36,20 +34,7 @@ export const InlineTitle = ({ value, onSave }: Props) => {
}
return (
<Input
autoFocus
value={draft}
onChange={(e) => setDraft(e.target.value)}
onBlur={() => {
onSave(draft.trim());
setEditing(false);
}}
onKeyDown={(e) => {
if (e.key === "Enter") (e.target as HTMLInputElement).blur();
if (e.key === "Escape") {
setDraft(value);
setEditing(false);
}
}}
{...inputProps}
className="h-auto border-hestia-border bg-transparent py-1 font-body text-base font-semibold"
/>
);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { type ReactNode } from "react";
import { MoreVertical } from "lucide-react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";

interface Props {
/** aria-label for the "…" trigger (e.g. "Task actions"). */
ariaLabel: string;
/** Invoked when the destructive item is chosen (usually opens a confirm). */
onDelete: () => void;
/** Destructive item copy (defaults to "Delete"). */
deleteLabel?: string;
/** Extra menu items rendered above the delete item (a separator is auto-added). */
children?: ReactNode;
}

/**
* The "…" actions dropdown shared by the editable block cards. Renders any
* extra items, then a separator (only when extras exist), then the destructive
* delete item.
*/
export const BlockActionsMenu = ({
ariaLabel,
onDelete,
deleteLabel = "Delete",
children,
}: Props) => (
<DropdownMenu>
<DropdownMenuTrigger
aria-label={ariaLabel}
className="rounded-hestia-sm p-1 text-hestia-text-muted hover:bg-hestia-primary-muted/40 hover:text-hestia-text"
>
<MoreVertical size={16} />
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{children}
{children && <DropdownMenuSeparator />}
<DropdownMenuItem
onClick={onDelete}
className="text-hestia-danger focus:text-hestia-danger"
>
{deleteLabel}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { type ReactNode } from "react";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";

interface Props {
open: boolean;
onOpenChange: (open: boolean) => void;
title: ReactNode;
description: ReactNode;
onConfirm: (event: React.MouseEvent<HTMLButtonElement>) => void;
/** Confirm-button copy (defaults to "Delete"). */
confirmLabel?: string;
}

/**
* Destructive-confirm AlertDialog scaffold shared by the editor's delete/convert
* prompts. The confirm button carries the danger styling so every destructive
* action reads the same.
*/
export const ConfirmDeleteDialog = ({
open,
onOpenChange,
title,
description,
onConfirm,
confirmLabel = "Delete",
}: Props) => (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{title}</AlertDialogTitle>
<AlertDialogDescription>{description}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={onConfirm}
className="bg-hestia-danger text-white hover:bg-hestia-danger/90"
>
{confirmLabel}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { Textarea } from "@/components/ui/textarea";
import { cn } from "@/lib/utils/utils";
import {
MarkdownView,
markdownSurfaceClassName,
markdownTextareaClassName,
} from "@/components/shared/exam-content/MarkdownView";
import type { InlineTextEdit } from "@/hooks/ui/use-inline-text-edit";

interface Props {
field: InlineTextEdit;
placeholder: string;
/** aria-label for the read-view click target. */
ariaLabel: string;
rows?: number;
/** Extra classes appended to the textarea's markdown class. */
textareaClassName?: string;
/** Class for the read-view click target (defaults to the bordered surface). */
readViewClassName?: string;
/** Class forwarded to MarkdownView in the read view. */
markdownClassName?: string;
hint?: string;
}

/**
* The textarea ↔ MarkdownView toggle shared by the editable task and context
* cards. Editing (or a blank value) shows the textarea plus a "Markdown
* supported" hint; otherwise a click-to-edit rendered-markdown surface.
*/
export const MarkdownEditField = ({
field,
placeholder,
ariaLabel,
rows = 2,
textareaClassName,
readViewClassName = markdownSurfaceClassName,
markdownClassName,
hint = "Code blocks and snippets (Markdown) supported",
}: Props) => {
const { editing, isEmpty, enterEdit, textareaRef, textareaProps, value } = field;

if (editing || isEmpty) {
return (
<>
<Textarea
ref={textareaRef}
{...textareaProps}
placeholder={placeholder}
rows={rows}
className={cn(markdownTextareaClassName, textareaClassName)}
/>
<p className="mt-1 text-xs text-hestia-text-muted">{hint}</p>
</>
);
}

return (
<div
role="button"
tabIndex={0}
onClick={enterEdit}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
enterEdit();
}
}}
aria-label={ariaLabel}
className={readViewClassName}
>
<MarkdownView content={value} className={markdownClassName} />
</div>
);
};
26 changes: 26 additions & 0 deletions apps/examlense/frontend/src/hooks/data/use-exam-bundle.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { useExam, useTasks } from "@/hooks/data/use-exam";
import { useSections, useSectionBlocks } from "@/hooks/data/use-sections";

/**
* The four exam-scoped queries every route page opens together
* (`ExamEdit`, `GradingView`, `ExamResults`). Returns the resolved data plus a
* combined loading flag so pages don't re-declare the same fetch boilerplate.
*
* Deliberately excludes `useTaskAnswers`/`useTaskGrades` (only Grading + Results
* need them, and they build their own lookup maps) and `useSectionFigures`
* (keyed by block id, not exam id).
*/
export function useExamBundle(id: string | undefined) {
const exam = useExam(id);
const tasks = useTasks(id);
const sections = useSections(id);
const blocks = useSectionBlocks(id);
return {
exam: exam.data,
tasks: tasks.data,
sections: sections.data,
blocks: blocks.data,
isLoading:
exam.isLoading || tasks.isLoading || sections.isLoading || blocks.isLoading,
};
}
32 changes: 32 additions & 0 deletions apps/examlense/frontend/src/hooks/data/use-exam-realtime.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { useEffect, useRef } from "react";
import { subscribeExam, type ExamEventHandlers } from "@/lib/api/sse";

/**
* Subscribe to one exam's SSE stream for the lifetime of the component,
* invoking the latest handlers without re-subscribing on every render. Wraps
* the subscribe + cache-invalidation effect that ExamEdit and GradingView
* otherwise duplicate. (The evaluation-progress channel keeps its own wrapper
* in `use-exam-progress`.)
*/
export function useExamRealtime(
id: string | undefined,
handlers: ExamEventHandlers,
) {
const handlersRef = useRef(handlers);
handlersRef.current = handlers;

// Re-subscribe only when the exam or the set of active channels changes —
// handler identity is read through the ref, so inline closures are fine.
const hasExam = !!handlers.onExam;
const hasProgress = !!handlers.onProgress;
const hasTasks = !!handlers.onTasks;

useEffect(() => {
if (!id) return;
return subscribeExam(id, {
onExam: hasExam ? () => handlersRef.current.onExam?.() : undefined,
onProgress: hasProgress ? () => handlersRef.current.onProgress?.() : undefined,
onTasks: hasTasks ? () => handlersRef.current.onTasks?.() : undefined,
});
}, [id, hasExam, hasProgress, hasTasks]);
}
35 changes: 35 additions & 0 deletions apps/examlense/frontend/src/hooks/ui/use-click-to-edit.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { useEffect, useState } from "react";

/**
* Click-to-edit title state machine shared by InlineTitle and SectionTitleInput:
* a resting affordance flips to an input that commits the trimmed draft on blur
* / Enter and reverts on Escape. The commit guard (e.g. skip if unchanged) is
* left to the caller's `onSave`.
*/
export function useClickToEdit(value: string, onSave: (next: string) => void) {
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(value);
useEffect(() => setDraft(value), [value]);

return {
editing,
startEditing: () => setEditing(true),
inputProps: {
value: draft,
autoFocus: true,
onChange: (e: React.ChangeEvent<HTMLInputElement>) =>
setDraft(e.target.value),
onBlur: () => {
onSave(draft.trim());
setEditing(false);
},
onKeyDown: (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter") (e.target as HTMLInputElement).blur();
if (e.key === "Escape") {
setDraft(value);
setEditing(false);
}
},
},
};
}
Loading
Loading