Proper runs structure

This commit is contained in:
2026-02-07 22:41:29 +01:00
parent bfd2ce5719
commit 73c33691d5
9 changed files with 22 additions and 8 deletions
+114
View File
@@ -0,0 +1,114 @@
"use client";
import CodeMirror, { EditorState, EditorView } from "@uiw/react-codemirror";
import { devRun } from "./theme";
import { javascript } from "@codemirror/lang-javascript";
import { rust } from "@codemirror/lang-rust";
import { go } from "@codemirror/lang-go";
import { json } from "@codemirror/lang-json";
import { python } from "@codemirror/lang-python";
import { java } from "@codemirror/lang-java";
import { useEffect, useState } from "react";
import useAction from "@/hook/use-action";
import { getLiveRunMoves } from "@/modules/live-run/actions";
type Language =
| "javascript"
| "typescript"
| "rust"
| "go"
| "python"
| "java"
| "json";
function getLanguageExtension(lang: Language) {
switch (lang) {
case "javascript":
return javascript();
case "rust":
return rust();
case "go":
return go();
case "python":
return python();
case "java":
return java();
case "json":
return json();
default:
return javascript();
}
}
type Move = {
latency: number;
cursor: number;
changes: {
from: number;
to: number;
insert: string;
};
};
export default function Editor() {
const [editorView, setEditorView] = useState<EditorView>();
const [moves] = useAction<Move[]>(getLiveRunMoves);
useEffect(() => {
if (!editorView || !moves) return;
let cancelled = false;
const executeMove = async (move: Move) => {
return new Promise<void>((resolve) => {
const timeout = setTimeout(() => {
if (!editorView || cancelled) return resolve();
editorView.dispatch({
selection: { anchor: move.cursor },
scrollIntoView: true,
changes: move.changes,
});
resolve();
}, move.latency);
return () => clearTimeout(timeout);
});
};
(async () => {
if (!moves) return;
for (const move of moves) {
if (cancelled) break;
await executeMove(move);
}
})();
return () => {
cancelled = true;
};
}, [editorView, moves]);
return (
<CodeMirror
readOnly
className="h-full w-full"
value={``}
extensions={[
getLanguageExtension("javascript"),
EditorState.transactionFilter.of((tr) => {
if (tr.selection && tr.isUserEvent("select")) {
return [];
}
return tr;
}),
]}
theme={devRun}
onCreateEditor={(view) => {
view.focus();
setEditorView(view);
}}
/>
);
}
@@ -0,0 +1,69 @@
"use client";
import { useState } from "react";
import { Folder, FolderMinus, FolderPlus, File } from "lucide-react";
type TreeNode = {
type: "directory" | "file";
uri: string;
children?: TreeNode[];
};
interface FileTreeProps {
tree: TreeNode;
level?: number; // for indentation
delay?: number; // stagger delay in ms
}
export const FileTree: React.FC<FileTreeProps> = ({
tree,
level = 0,
delay = 0,
}) => {
const [expanded, setExpanded] = useState(
tree.type !== "directory" || level === 0 ? true : false,
);
const toggleExpand = () => {
if (tree.type === "directory") {
setExpanded(!expanded);
}
};
return (
<div>
{/* Directory/File Row */}
<div
onClick={toggleExpand}
style={{
paddingLeft: level * 20,
animationDelay: `${delay}ms`,
}}
className="flex items-center cursor-pointer select-none animate-fade-in opacity-0"
>
{tree.type === "directory" ? (
expanded ? (
<FolderMinus size={16} className="mr-2" />
) : (
<FolderPlus size={16} className="mr-2" />
)
) : (
<File size={16} className="mr-2" />
)}
{tree.uri}
</div>
{/* Children */}
{expanded &&
tree.children?.map((child, index) => (
<FileTree
key={child.uri}
tree={child}
level={level + 1}
// stagger each child by 100ms
delay={delay + (index + 1) * 100}
/>
))}
</div>
);
};
@@ -0,0 +1,36 @@
import Rank from "@/components/app/Rank";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent } from "@/components/ui/card";
export default function ProfileCard({
user,
}: {
user: { username: string; rank: number };
}) {
return (
<Card className="relative w-full h-full overflow-hidden">
<div className="absolute inset-0 bg-linear-to-r from-primary/10 via-transparent to-transparent" />
<CardContent className="relative flex flex-row gap-6 w-full h-full">
{/* Avatar */}
<div className="relative shrink-0 w-1/3 h-full flex items-center justify-center">
<img
src="http://github.com/selimaj-dev.png"
alt="Img"
className="rounded-2xl aspect-square max-h-full w-auto object-contain"
/>
<div className="absolute -bottom-2 -right-2 w-8 h-8">
<Rank rank={user.rank} />
</div>
</div>
{/* User Info */}
<div className="flex-1 flex flex-col justify-center w-full">
<h1 className="text-lg font-bold mb-2">{user.username}</h1>
<Badge>WR Holder</Badge>
</div>
</CardContent>
</Card>
);
}
@@ -0,0 +1,52 @@
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Separator } from "@/components/ui/separator";
export type ChatMessage = {
user: string;
message: string;
time: string;
};
export default function RunChat({
chatMessages,
}: {
chatMessages: ChatMessage[];
}) {
return (
<Card className="flex flex-col flex-1">
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="text-base">Chat</CardTitle>
<span className="text-xs text-muted-foreground">
{chatMessages.length} messages
</span>
</div>
</CardHeader>
<Separator />
<CardContent className="flex flex-col flex-1 gap-3">
{/* Messages */}
<div className="flex-1 overflow-y-auto space-y-2">
{chatMessages.map((msg, i) => (
<div
key={i}
className="rounded-lg bg-secondary/50 p-2 text-sm opacity-0 animate-fade-in"
style={{ animationDelay: `${i * 100}ms` }} // stagger by 100ms
>
<span className="font-semibold text-foreground">{msg.user}</span>:{" "}
<span className="text-muted-foreground">{msg.message}</span>
<span className="ml-2 text-xs text-muted-foreground">
{msg.time}
</span>
</div>
))}
</div>
{/* Input */}
<Input placeholder="Type a message…" />
</CardContent>
</Card>
);
}
@@ -0,0 +1,38 @@
import { Card, CardContent } from "@/components/ui/card";
import formatTime from "@/lib/time-format";
import { LiveRun } from "@/modules/live-run/types";
import { Eye } from "lucide-react";
import { useEffect, useState } from "react";
export default function RunTimer({ run }: { run: LiveRun }) {
const [time, setTime] = useState("00:00:00");
useEffect(() => {
// Update function calculates elapsed time
const updateTime = () => setTime(formatTime(Date.now() - run.start));
// Update immediately and then every second
updateTime();
const interval = setInterval(updateTime, 50);
return () => clearInterval(interval); // cleanup on unmount
}, [run.start]);
return (
<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" />
<CardContent className="relative flex flex-col justify-center items-center h-full">
<p className="timer-display text-primary text-3xl font-bold">{time}</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>
</Card>
);
}
+119
View File
@@ -0,0 +1,119 @@
import { EditorView } from "@codemirror/view";
import { HighlightStyle, syntaxHighlighting } from "@codemirror/language";
import { tags as t } from "@lezer/highlight";
/**
* devRun — direct CodeMirror 6 theme
* Based on thememirror (dracula), app-integrated
*/
export const devRun = [
EditorView.theme(
{
"&": {
backgroundColor: "var(--card)",
color: "#e6e6eb",
},
".cm-content": {
caretColor: "var(--primary)",
},
".cm-cursor, .cm-dropCursor": {
borderLeftColor: "var(--primary)",
},
"&.cm-focused .cm-selectionBackground, .cm-content ::selection": {
backgroundColor: "rgba(120, 120, 150, 0.25)",
},
".cm-activeLine": {
backgroundColor: "rgba(255, 255, 255, 0.03)",
},
".cm-gutters": {
backgroundColor: "var(--card)",
color: "rgb(120, 122, 128)",
border: "none",
},
".cm-activeLineGutter": {
backgroundColor: "rgba(255, 255, 255, 0.03)",
},
// - Scrollbar -
".cm-scroller": {
scrollbarWidth: "thin",
scrollbarColor: "rgba(201, 133, 255, 0.3) transparent",
},
},
{ dark: true },
),
syntaxHighlighting(
HighlightStyle.define([
// Comments — low priority, low contrast
{
tag: t.comment,
color: "#5f6a8a",
fontStyle: "italic",
},
// Strings & literals — data, not logic
{
tag: [t.string, t.special(t.brace)],
color: "#f4e58c",
},
// Numbers / booleans — benchmarks & truth
{
tag: [t.number, t.bool, t.null],
color: "#c792ea",
fontWeight: "500",
},
// Keywords & operators — decisions & flow
{
tag: [t.keyword, t.operator],
color: "#ff5fa2",
fontWeight: "600",
},
// Types & definitions — structure
{
tag: [t.definitionKeyword, t.typeName],
color: "#6be6ff",
},
// Type definitions — neutral, readable
{
tag: t.definition(t.typeName),
color: "#e6e6eb",
},
// Functions, classes, props — execution paths
{
tag: [
t.className,
t.definition(t.propertyName),
t.function(t.variableName),
t.attributeName,
],
color: "#4dff9f",
fontWeight: "500",
},
// Variables — slightly muted, context-aware
{
tag: t.variableName,
color: "#cfd3dc",
},
// Invalid / errors — instant attention
{
tag: t.invalid,
color: "#ff5555",
fontWeight: "700",
},
]),
),
];
+98
View File
@@ -0,0 +1,98 @@
"use client";
import { Card, CardContent } from "@/components/ui/card";
import RunTimer from "./components/RunTimer";
import RunChat, { ChatMessage } from "./components/RunChat";
import { FileTree } from "./components/FileTree";
import { File } from "lucide-react";
import Editor from "./components/Editor";
import useAction, { useActionInterval } from "@/hook/use-action";
import { getLiveRun } from "@/modules/live-run/actions";
import ProblemCard from "@/app/problems/components/ProblemCard";
import ProfileCard from "./components/ProfileCard";
import { getProblem } from "@/modules/problems/actions";
export default function Run() {
const [run] = useActionInterval(getLiveRun, 1000);
const [problem] = useAction(
async () => (run?.id ? getProblem(run.problem) : null),
[run],
);
console.log(problem);
const chatMessages: ChatMessage[] = [
{ user: "Alice", message: "Nice split!", time: "1:53" },
{ user: "Bob", message: "Watch out for the string problem!", time: "1:54" },
{ user: "Charlie", message: "PB incoming? 👀", time: "1:55" },
];
return (
<div
className="
h-svh
px-4 sm:px-8 lg:px-20
pt-20 sm:pt-24
pb-6 sm:pb-12
flex flex-col lg:flex-row
gap-5
"
>
{/* Main column */}
<div className="h-full w-full flex flex-col gap-4">
{/* Top cards */}
<div className="w-full flex flex-wrap gap-4 md:gap-5">
<div className="flex-1 animate-fade-in opacity-0">
<ProfileCard user={{ username: "selimaj", rank: 1 }} />
</div>
<div className="flex-1 animate-fade-in opacity-0 stagger-2">
{run && <RunTimer run={run} />}
</div>
<div className="flex-2">
{problem && <ProblemCard index={4} problem={problem} />}
</div>
</div>
{/* Editor */}
<Card className="flex-1 h-svh overflow-hidden">
<CardContent className="flex h-full">
{/* File tree – hide on small screens */}
<div className="hidden md:block w-2xs border-r border-border">
<FileTree
tree={{
type: "directory",
uri: "my-app",
children: [
{ type: "file", uri: "myfile.js" },
{
type: "directory",
uri: "my-app",
children: [{ type: "file", uri: "myfile.js" }],
},
],
}}
/>
</div>
{/* Code */}
<div className="flex-1 w-full max-h-svh md:max-h-full h-full pl-0 md:pl-5 flex flex-col">
<header className="text-sm font-medium text-muted-foreground flex items-center gap-1">
<File size={16} /> myfile.js
</header>
<div className="flex-1 pt-3 pb-4 md:pb-5 w-full h-full">
<Editor />
</div>
</div>
</CardContent>
</Card>
</div>
{/* Timer & Chat */}
<div className="flex lg:w-lg min-h-[60vh]">
<RunChat chatMessages={chatMessages} />
</div>
</div>
);
}