Proper runs structure
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
]),
|
||||
),
|
||||
];
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user