Merge pull request #12 from orus-dev/ui-api-prep

UI api prep
This commit is contained in:
Klesti Selimaj
2026-02-08 02:44:34 +01:00
committed by GitHub
17 changed files with 209 additions and 194 deletions
+7 -1
View File
@@ -97,7 +97,13 @@ const ProblemCard = ({
{/* Footer */} {/* Footer */}
<CardFooter className="absolute bottom-4 right-4 p-0 opacity-0 group-hover:opacity-100 transition-all"> <CardFooter className="absolute bottom-4 right-4 p-0 opacity-0 group-hover:opacity-100 transition-all">
<Button size="icon"> <Button
size="icon"
onClick={async () => {
await fetch(`http://127.0.0.1:63780/start-run/${problem.id}`);
window.open("vscode://", "_blank");
}}
>
<Zap className="h-4 w-4" /> <Zap className="h-4 w-4" />
</Button> </Button>
</CardFooter> </CardFooter>
-2
View File
@@ -14,8 +14,6 @@ import { getProblems } from "@/modules/problems/actions";
export default function Problems() { export default function Problems() {
const [problems, problemsLoaded, problemsError] = useAction(getProblems); const [problems, problemsLoaded, problemsError] = useAction(getProblems);
console.log(problems);
const [viewMode, setViewMode] = useState<"grid" | "list">("grid"); const [viewMode, setViewMode] = useState<"grid" | "list">("grid");
const difficulties = ["All", "Easy", "Medium", "Hard"]; const difficulties = ["All", "Easy", "Medium", "Hard"];
@@ -9,8 +9,9 @@ import { json } from "@codemirror/lang-json";
import { python } from "@codemirror/lang-python"; import { python } from "@codemirror/lang-python";
import { java } from "@codemirror/lang-java"; import { java } from "@codemirror/lang-java";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import useAction from "@/hook/use-action"; import useAction, { useActionOnce } from "@/hook/use-action";
import { getLiveRunMoves } from "@/modules/live-run/actions"; import { getLiveRunMoves } from "@/modules/live-run/actions";
import { LiveRun, LiveRunMove } from "@/modules/live-run/types";
type Language = type Language =
| "javascript" | "javascript"
@@ -40,26 +41,19 @@ function getLanguageExtension(lang: Language) {
} }
} }
type Move = { export default function Editor({ run }: { run: LiveRun | null | undefined }) {
latency: number;
cursor: number;
changes: {
from: number;
to: number;
insert: string;
};
};
export default function Editor() {
const [editorView, setEditorView] = useState<EditorView>(); const [editorView, setEditorView] = useState<EditorView>();
const [moves] = useAction<Move[]>(getLiveRunMoves); const [moves] = useActionOnce<LiveRunMove[]>(
async () => (run ? (await getLiveRunMoves(run.id)) || [] : []),
[run],
);
useEffect(() => { useEffect(() => {
if (!editorView || !moves) return; if (!editorView || !moves) return;
let cancelled = false; let cancelled = false;
const executeMove = async (move: Move) => { const executeMove = async (move: LiveRunMove) => {
return new Promise<void>((resolve) => { return new Promise<void>((resolve) => {
const timeout = setTimeout(() => { const timeout = setTimeout(() => {
if (!editorView || cancelled) return resolve(); if (!editorView || cancelled) return resolve();
@@ -1,28 +1,37 @@
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import formatTime from "@/lib/time-format"; import formatTime from "@/lib/time-format";
import { LiveRun } from "@/modules/live-run/types"; import { LiveRun } from "@/modules/live-run/types";
import { Eye } from "lucide-react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
export default function RunTimer({ runInfo }: { runInfo: LiveRun }) { export default function RunTimer({ run }: { run: LiveRun }) {
const [time, setTime] = useState("00:00:00"); const [time, setTime] = useState("00:00:00");
useEffect(() => { useEffect(() => {
// Update function calculates elapsed time // Update function calculates elapsed time
const updateTime = () => setTime(formatTime(Date.now() - runInfo.start)); const updateTime = () => setTime(formatTime(Date.now() - run.start));
// Update immediately and then every second // Update immediately and then every second
updateTime(); updateTime();
const interval = setInterval(updateTime, 50); const interval = setInterval(updateTime, 50);
return () => clearInterval(interval); // cleanup on unmount return () => clearInterval(interval); // cleanup on unmount
}, [runInfo.start]); }, [run.start]);
return ( return (
<Card className="relative text-center overflow-hidden h-full"> <Card className="relative text-center overflow-hidden h-full">
<div className="absolute inset-0 bg-linear-to-b from-primary/5 to-transparent pointer-events-none" /> <div className="absolute inset-0 bg-linear-to-b from-primary/5 to-transparent pointer-events-none" />
<CardContent className="relative flex flex-col justify-center items-center h-full"> <CardContent className="relative flex flex-col justify-center items-center h-full">
<p className="timer-display text-primary text-3xl font-bold">{time}</p> <p className="timer-display text-primary text-3xl font-bold">{time}</p>
<p className="text-muted-foreground">{runInfo.category}</p> <span className="flex gap-4">
<p className="text-muted-foreground flex gap-1 items-center">
{run.category}
</p>
<p className="text-muted-foreground flex gap-1 items-center">
<Eye className="w-4 h-4" />
{run.views}
</p>
</span>
</CardContent> </CardContent>
</Card> </Card>
); );
@@ -6,13 +6,24 @@ import RunChat, { ChatMessage } from "./components/RunChat";
import { FileTree } from "./components/FileTree"; import { FileTree } from "./components/FileTree";
import { File } from "lucide-react"; import { File } from "lucide-react";
import Editor from "./components/Editor"; import Editor from "./components/Editor";
import useAction from "@/hook/use-action"; import useAction, { useActionInterval } from "@/hook/use-action";
import { getLiveRun } from "@/modules/live-run/actions"; import { getLiveRun } from "@/modules/live-run/actions";
import ProblemCard from "../problems/components/ProblemCard"; import ProblemCard from "@/app/problems/components/ProblemCard";
import ProfileCard from "./components/ProfileCard"; import ProfileCard from "./components/ProfileCard";
import { getProblem } from "@/modules/problems/actions";
import { useParams } from "next/navigation";
export default function Run() { export default function Run() {
const [runInfo] = useAction(getLiveRun); const params = useParams<{ runId: string }>();
const [run] = useActionInterval(() => getLiveRun(params.runId), 1000, [
params,
]);
const [problem] = useAction(
async () => (run?.id ? getProblem(run.problem) : null),
[run],
);
const chatMessages: ChatMessage[] = [ const chatMessages: ChatMessage[] = [
{ user: "Alice", message: "Nice split!", time: "1:53" }, { user: "Alice", message: "Nice split!", time: "1:53" },
@@ -40,21 +51,11 @@ export default function Run() {
</div> </div>
<div className="flex-1 animate-fade-in opacity-0 stagger-2"> <div className="flex-1 animate-fade-in opacity-0 stagger-2">
{runInfo && <RunTimer runInfo={runInfo} />} {run && <RunTimer run={run} />}
</div> </div>
<div className="flex-2"> <div className="flex-2">
<ProblemCard {problem && <ProblemCard index={4} problem={problem} />}
index={4}
problem={{
id: "fix-auth-callback",
title: "Fix the Broken Auth Callback",
description:
"Users can log in with OAuth, but sessions aren't persisted. Fix it.",
language: "typescript",
difficulty: "easy",
}}
/>
</div> </div>
</div> </div>
@@ -85,7 +86,7 @@ export default function Run() {
<File size={16} /> myfile.js <File size={16} /> myfile.js
</header> </header>
<div className="flex-1 pt-3 pb-4 md:pb-5 w-full h-full"> <div className="flex-1 pt-3 pb-4 md:pb-5 w-full h-full">
<Editor /> <Editor run={run} />
</div> </div>
</div> </div>
</CardContent> </CardContent>
+28 -35
View File
@@ -1,12 +1,15 @@
"use client"; "use client";
import { Eye } from "lucide-react";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { TableRow, TableCell } from "@/components/ui/table"; import { TableRow, TableCell } from "@/components/ui/table";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { LiveRun } from "@/modules/live-run/types"; import { LiveRun } from "@/modules/live-run/types";
import formatTime from "@/lib/time-format"; import formatTime from "@/lib/time-format";
import useAction from "@/hook/use-action";
import { getProblem } from "@/modules/problems/actions";
import Link from "next/link";
import { Eye, LinkIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
export default function LiveRunRow({ export default function LiveRunRow({
index, index,
@@ -15,8 +18,10 @@ export default function LiveRunRow({
index: number; index: number;
run: LiveRun; run: LiveRun;
}) { }) {
const [problem] = useAction(() => getProblem(run.problem));
const now = new Date().getTime() + 1000; const now = new Date().getTime() + 1000;
const isTop = false; const isTop = run.views > 5;
return ( return (
<TableRow <TableRow
@@ -29,57 +34,45 @@ export default function LiveRunRow({
animationFillMode: "forwards", animationFillMode: "forwards",
}} }}
> >
<TableCell className="font-medium pl-5"> {/* Runner */}
<TableCell className="font-medium pl-5 py-5">
{isTop ? `🔥 ${run.username}` : run.username} {isTop ? `🔥 ${run.username}` : run.username}
</TableCell> </TableCell>
{/* Problem + Category */}
<TableCell> <TableCell>
<div className="flex flex-col gap-1"> <Link
<span>{run.problem}</span> href={`problems/${run.problem}`}
<Badge variant="outline" className="w-fit text-xs"> className="hover:text-primary flex items-center gap-1"
{run.category} >
</Badge> <LinkIcon className="w-3 h-3" />
</div> {problem?.title || run.problem}
</Link>
</TableCell> </TableCell>
{/* Runtime */}
<TableCell <TableCell
className={`font-mono ${isTop ? "text-primary font-bold" : ""}`} className={`font-mono ${isTop ? "text-primary font-bold" : ""}`}
> >
{formatTime(now - run.start, false)} {formatTime(now - run.start, false)}
</TableCell> </TableCell>
<TableCell> {/* Views */}
<Badge <TableCell className="text-center">
className={ {isTop ? <Badge>{run.views}</Badge> : run.views}
false
? "bg-red-500/10 text-red-400"
: "bg-green-500/10 text-green-400"
}
>
0
</Badge>
</TableCell> </TableCell>
<TableCell> {/* Category */}
<div className="flex items-center gap-2 text-sm"> <TableCell className="text-center">{run.category}</TableCell>
<span
className={`h-2 w-2 rounded-full ${
status === "pb"
? "bg-green-500"
: status === "danger"
? "bg-red-500"
: "bg-emerald-400"
}`}
/>
Live
</div>
</TableCell>
<TableCell className="text-right pr-5"> {/* Actions */}
<TableCell className="pr-5 text-end">
<Link href={`/runs/${run.id}`}>
<Button size="sm" variant={isTop ? "default" : "ghost"}> <Button size="sm" variant={isTop ? "default" : "ghost"}>
<Eye className="h-4 w-4 mr-1" /> <Eye className="h-4 w-4 mr-1" />
Watch Watch
</Button> </Button>
</Link>
</TableCell> </TableCell>
</TableRow> </TableRow>
); );
+10 -30
View File
@@ -1,27 +1,20 @@
"use client"; "use client";
import { Zap, Users, Timer, TrendingUp, Eye } from "lucide-react"; import { Card } from "@/components/ui/card";
import { Card, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { import {
Table, Table,
TableBody, TableBody,
TableHead, TableHead,
TableHeader, TableHeader,
TableRow, TableRow,
TableCell,
} from "@/components/ui/table"; } from "@/components/ui/table";
import { cn } from "@/lib/utils";
import LiveRunRow from "./components/LiveRunRow"; import LiveRunRow from "./components/LiveRunRow";
import StatCard from "@/components/app/StatCard";
import useAction from "@/hook/use-action"; import useAction from "@/hook/use-action";
import { getLiveRuns } from "@/modules/live-run/actions"; import { getLiveRuns } from "@/modules/live-run/actions";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
export default function LiveRuns() { export default function LiveRuns() {
const [liveRuns, liveRunsLoaded, liveRunsError] = useAction(getLiveRuns); const [liveRuns] = useAction(getLiveRuns);
// Sort top runners (status === 'pb') to the top // Sort top runners (status === 'pb') to the top
const sortedRuns = [...(liveRuns || [])]; const sortedRuns = [...(liveRuns || [])];
@@ -33,30 +26,17 @@ export default function LiveRuns() {
<div> <div>
<h1 className="text-4xl font-bold">Live Runs</h1> <h1 className="text-4xl font-bold">Live Runs</h1>
<p className="text-muted-foreground"> <p className="text-muted-foreground">
Watch runners compete in real time. Every second counts. 42 live runs · Streaming in real time
</p> </p>
</div> </div>
{/* Stats */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<StatCard icon={Zap} label="Live Runs" value="42" index={0} />
<StatCard icon={Users} label="Active Runners" value="39" index={1} />
<StatCard icon={Timer} label="Avg Runtime" value="1:47" index={2} />
<StatCard
icon={TrendingUp}
label="PB Pace Runs"
value="7"
index={3}
/>
</div>
{/* Filters */} {/* Filters */}
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
<ToggleGroup variant="outline" type="single" defaultValue="any"> <ToggleGroup variant="outline" type="single">
<ToggleGroupItem value="any" aria-label="any%"> <ToggleGroupItem value="any%" aria-label="any%">
any% any%
</ToggleGroupItem> </ToggleGroupItem>
<ToggleGroupItem value="100" aria-label="100%"> <ToggleGroupItem value="100%" aria-label="100%">
100% 100%
</ToggleGroupItem> </ToggleGroupItem>
</ToggleGroup> </ToggleGroup>
@@ -69,10 +49,10 @@ export default function LiveRuns() {
<TableRow> <TableRow>
<TableHead className="pl-5">Runner</TableHead> <TableHead className="pl-5">Runner</TableHead>
<TableHead>Problem</TableHead> <TableHead>Problem</TableHead>
<TableHead>Time</TableHead> <TableHead className="w-20 text-center">Time</TableHead>
<TableHead>Pace</TableHead> <TableHead className="w-20 text-center">Views</TableHead>
<TableHead>Status</TableHead> <TableHead className="w-20 text-center">Category</TableHead>
<TableHead className="pr-5" /> <TableHead className="pr-5 w-24" />
</TableRow> </TableRow>
</TableHeader> </TableHeader>
+47 -1
View File
@@ -2,7 +2,7 @@ import { useEffect, useState } from "react";
export default function useAction<T>( export default function useAction<T>(
action: () => Promise<T>, action: () => Promise<T>,
dependencies = [], dependencies: any[] = [],
): [T | null, boolean, Error | null] { ): [T | null, boolean, Error | null] {
const [value, setValue] = useState<T | null>(null); const [value, setValue] = useState<T | null>(null);
const [loaded, setLoaded] = useState(false); const [loaded, setLoaded] = useState(false);
@@ -17,3 +17,49 @@ export default function useAction<T>(
return [value, loaded, error]; return [value, loaded, error];
} }
export function useActionOnce<T>(
action: () => Promise<T>,
dependencies: any[] = [],
): [T | null, boolean, Error | null] {
const [value, setValue] = useState<T | null>(null);
const [loaded, setLoaded] = useState(false);
const [error, setError] = useState(null);
useEffect(() => {
if (loaded) return;
action()
.then(setValue)
.catch(setError)
.finally(() => setLoaded(true));
}, dependencies);
return [value, loaded, error];
}
export function useActionInterval<T>(
action: () => Promise<T>,
interval: number,
dependencies: any[] = [],
): [T | null, boolean, Error | null] {
const [value, setValue] = useState<T | null>(null);
const [loaded, setLoaded] = useState(false);
const [error, setError] = useState(null);
useEffect(() => {
const f = () =>
action()
.then(setValue)
.catch(setError)
.finally(() => setLoaded(true));
f();
const inv = setInterval(f, interval);
return () => clearInterval(inv);
}, dependencies);
return [value, loaded, error];
}
+5 -83
View File
@@ -1,94 +1,16 @@
"use server"; "use server";
import { createClient } from "@/lib/supabase/server";
import { getSession, getSessionRedirect } from "../account/core";
import * as Core from "./core"; import * as Core from "./core";
import { LiveRun } from "./types"; import { LiveRun } from "./types";
export async function getLiveRuns(): Promise<LiveRun[]> { export async function getLiveRuns(): Promise<LiveRun[]> {
const supabase = await createClient(); return Core.getLiveRuns();
const _ = await getSession(supabase);
return [
{
id: "1",
username: "speedmaster_x",
problem: "Two Sum Dash",
category: "any%",
runsCount: 1,
start: new Date().getTime(),
},
{
id: "2",
username: "algo_ninja",
problem: "Binary Sprint",
category: "any%",
runsCount: 1,
start: new Date().getTime(),
},
{
id: "3",
username: "stackattack",
problem: "String Scramble",
category: "any%",
runsCount: 1,
start: new Date().getTime(),
},
{
id: "4",
username: "reset_king",
problem: "Linked List Rush",
category: "any%",
runsCount: 1,
start: new Date().getTime(),
},
];
} }
export async function getLiveRun(): Promise<LiveRun> { export async function getLiveRun(id: string): Promise<LiveRun | undefined> {
return { return Core.getLiveRun(id);
id: "0",
username: "selimaj",
start: new Date().getTime(),
problem: "solve",
category: "any%",
runsCount: 0,
};
} }
export async function getLiveRunMoves() { export async function getLiveRunMoves(id: string) {
const code = `// Import the necessary React library features return Core.getLiveRunMoves(id);
import { createRoot } from 'react-dom/client';
// Define a functional component named "App"
// Component names must start with a capital letter
function App() {
// Components return JSX markup
return (
<h1>Hello, world!</h1>
);
}
// Target a DOM element in your HTML (e.g., <div id="root"></div>)
const domNode = document.getElementById('root');
// Create a root and render your component into the DOM
const root = createRoot(domNode);
root.render(<App />);`.repeat(3);
const chunks = [];
for (let i = 0; i < code.length; i += 4) {
chunks.push(code.slice(i, i + 4));
}
return chunks.map((chunk, index) => ({
latency: 120, // 100ms per chunk
cursor: index * 4, // approximate cursor position
changes: {
from: index * 4,
to: index * 4,
insert: chunk,
},
}));
} }
+37
View File
@@ -1,3 +1,40 @@
"use server"; "use server";
import "server-only"; import "server-only";
import { LiveRun, LiveRunMove } from "./types";
const liveRuns: LiveRun[] = [];
const liveRunMoves: Record<string, LiveRunMove[]> = {};
export async function getLiveRuns(): Promise<LiveRun[]> {
return liveRuns;
}
export async function getLiveRun(id: string): Promise<LiveRun | undefined> {
return liveRuns.find((r) => r.id === id);
}
export async function getLiveRunMoves(
id: string,
): Promise<LiveRunMove[] | undefined> {
return liveRunMoves[id];
}
export async function addRun(run: LiveRun) {
if (!liveRuns.find((r) => r.id === run.id || r.username === run.username))
throw "Duplicate run";
liveRuns.push(run);
}
export async function addLiveRunMoves(id: string, moves: LiveRunMove[]) {
if (!liveRunMoves[id]) throw "Invalid run";
// Add the moves immediately
liveRunMoves[id] = [...liveRunMoves[id], ...moves];
// After 3 seconds, remove only the moves we just added
setTimeout(() => {
liveRunMoves[id] = liveRunMoves[id].filter((move) => !moves.includes(move));
}, 3000);
}
+11
View File
@@ -1,8 +1,19 @@
export interface LiveRun { export interface LiveRun {
id: string; id: string;
username: string; username: string;
views: number;
start: number; start: number;
runsCount: number; runsCount: number;
problem: string; problem: string;
category: "any%" | "100%"; category: "any%" | "100%";
} }
export interface LiveRunMove {
latency: number;
cursor: number;
changes?: {
from: number;
to: number;
insert: string;
};
}
+4
View File
@@ -6,3 +6,7 @@ import { Problem } from "./types";
export async function getProblems(): Promise<Problem[]> { export async function getProblems(): Promise<Problem[]> {
return await Core.getProblems(); return await Core.getProblems();
} }
export async function getProblem(id: string): Promise<Problem | undefined> {
return await Core.getProblem(id);
}
+18 -4
View File
@@ -1,7 +1,7 @@
"use server"; "use server";
import "server-only"; import "server-only";
import { ProblemDefinition } from "./types"; import { Problem, ProblemDefinition } from "./types";
const problems: ProblemDefinition[] = [ const problems: ProblemDefinition[] = [
{ {
@@ -20,7 +20,7 @@ const problems: ProblemDefinition[] = [
description: description:
"Production build fails while development works. Fix the build.", "Production build fails while development works. Fix the build.",
language: "javascript", language: "javascript",
difficulty: "easy", difficulty: "hard",
validate100: () => true, validate100: () => true,
validateAny: () => true, validateAny: () => true,
}, },
@@ -29,13 +29,13 @@ const problems: ProblemDefinition[] = [
title: "Implement Missing API Endpoint", title: "Implement Missing API Endpoint",
description: "The frontend expects `/api/me`. Implement it correctly.", description: "The frontend expects `/api/me`. Implement it correctly.",
language: "go", language: "go",
difficulty: "easy", difficulty: "medium",
validate100: () => true, validate100: () => true,
validateAny: () => true, validateAny: () => true,
}, },
]; ];
export async function getProblems() { export async function getProblems(): Promise<Problem[]> {
return problems.map((def) => ({ return problems.map((def) => ({
id: def.id, id: def.id,
title: def.title, title: def.title,
@@ -44,3 +44,17 @@ export async function getProblems() {
language: def.language, language: def.language,
})); }));
} }
export async function getProblem(id: string): Promise<Problem | undefined> {
const problem = problems.find((p) => p.id === id);
if (!problem) return;
return {
id: problem.id,
title: problem.title,
description: problem.description,
difficulty: problem.difficulty,
language: problem.language,
};
}