Author SHA1 Message Date
evandro 49795981eb wip: snapshot das alterações não commitadas (2026-10-20)
Origem: D:/Documents/Github/Downloader
Arquivos omitidos (segredos, bancos de dados, logs, >50 MB): 0
2026-10-20 00:49:26 -03:00
23 changed files with 621 additions and 578 deletions

No files matched your search

+9
View File
@@ -0,0 +1,9 @@
{
"permissions": {
"allow": [
"mcp__plugin_context-mode_context-mode__ctx_batch_execute",
"mcp__context-mode__ctx_batch_execute",
"mcp__context-mode__ctx_execute_file"
]
}
}
Submodule .claude/worktrees/agent-a0cb243b added at 5b1600c210.
Submodule .claude/worktrees/agent-a6424abe added at 5b1600c210.
Submodule .claude/worktrees/agent-a85e5c45 added at 5b1600c210.
Submodule .claude/worktrees/agent-aa2cb71d added at 5b1600c210.
+21
View File
@@ -0,0 +1,21 @@
node_modules/
.next/
dist/
build/
out/
coverage/
.git/
.pnpm-store/
.cache/
*.log
.env
.env.*
__pycache__/
*.pyc
.venv/
venv/
target/
*.exe
*.dll
*.so
*.dylib
+9
View File
@@ -0,0 +1,9 @@
{
"profile": "codex",
"env": {
"OPENAI_BASE_URL": "https://chatgpt.com/backend-api/codex",
"OPENAI_MODEL": "codexplan",
"CHATGPT_ACCOUNT_ID": "6dbec33c-c751-4383-b112-bfc776f39ed7"
},
"createdAt": "2026-04-05T18:55:08.995Z"
}
+2 -3
View File
@@ -13,7 +13,6 @@
"workspaces": [ "workspaces": [
"server", "server",
"web" "web"
] ],
"packageManager": "[email protected]"
} }
-290
View File
@@ -1,290 +0,0 @@
.app-root {
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 2.5rem 1.5rem 3rem;
gap: 2.5rem;
background: radial-gradient(circle at top, #27272a 0, #09090b 45%, #020617 100%);
}
.app-header {
max-width: 640px;
text-align: center;
}
.app-header h1 {
font-size: 2rem;
margin-bottom: 0.5rem;
}
.app-header p {
font-size: 0.98rem;
color: #e4e4e7;
}
.app-header-meta {
margin-top: 0.5rem;
display: flex;
justify-content: center;
gap: 1rem;
flex-wrap: wrap;
font-size: 0.8rem;
color: #a1a1aa;
}
.app-main {
width: 100%;
display: flex;
justify-content: center;
align-items: flex-start;
gap: 2rem;
max-width: 1200px;
margin: 0 auto;
}
.download-form {
width: 100%;
max-width: 560px;
display: flex;
flex-direction: column;
gap: 0.75rem;
padding: 1.5rem 1.75rem;
border-radius: 1rem;
border: 1px solid rgba(63, 63, 70, 0.9);
background: radial-gradient(circle at top left, #18181b 0, #020617 60%);
box-shadow:
0 18px 40px rgba(0, 0, 0, 0.7),
0 0 0 1px rgba(24, 24, 27, 0.9);
}
.field-label {
font-size: 0.9rem;
font-weight: 500;
}
.field-helper {
font-size: 0.78rem;
color: #a1a1aa;
margin-top: -0.1rem;
}
.url-input {
padding: 0.75rem 0.9rem;
border-radius: 0.5rem;
border: 1px solid #27272a;
background-color: #09090b;
color: #f4f4f5;
font-size: 0.95rem;
resize: vertical;
}
.url-input--multiline {
line-height: 1.4;
}
.url-input:focus {
outline: 2px solid #4f46e5;
outline-offset: 1px;
border-color: transparent;
}
.primary-button {
margin-top: 0.5rem;
padding: 0.75rem 1rem;
border-radius: 0.5rem;
border: none;
background: #4f46e5;
color: #f9fafb;
font-weight: 600;
font-size: 0.95rem;
cursor: pointer;
transition: background 180ms ease-out, transform 160ms ease-out;
}
.primary-button:hover {
background: #4338ca;
}
.primary-button:active {
transform: translateY(1px) scale(0.99);
}
.primary-button:focus-visible {
outline: 2px solid #a5b4fc;
outline-offset: 2px;
}
.error-text {
font-size: 0.85rem;
color: #f97373;
}
.downloads-section {
width: 100%;
max-width: 620px;
margin-top: 0;
padding: 1.25rem 1.5rem;
border-radius: 1rem;
border: 1px solid rgba(63, 63, 70, 0.9);
background-color: rgba(9, 9, 11, 0.9);
backdrop-filter: blur(14px);
}
.downloads-title {
font-size: 1rem;
margin-bottom: 0.5rem;
}
.downloads-empty {
margin-top: 0.5rem;
font-size: 0.85rem;
color: #a1a1aa;
}
.downloads-list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.download-item {
padding: 0.6rem 0.1rem 0.7rem;
border-bottom: 1px solid rgba(39, 39, 42, 0.8);
}
.download-item:last-of-type {
border-bottom: none;
}
.download-main {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.75rem;
margin-bottom: 0.35rem;
}
.download-url {
font-size: 0.8rem;
color: #e5e5e5;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.badge {
padding: 0.2rem 0.55rem;
border-radius: 999px;
font-size: 0.7rem;
font-weight: 500;
}
.badge--pending {
background-color: rgba(39, 39, 42, 0.9);
color: #e5e5e5;
}
.badge--downloading {
background-color: rgba(37, 99, 235, 0.18);
color: #bfdbfe;
}
.badge--completed {
background-color: rgba(22, 163, 74, 0.25);
color: #bbf7d0;
}
.badge--error {
background-color: rgba(239, 68, 68, 0.18);
color: #fecaca;
}
.progress-wrapper {
display: flex;
align-items: center;
gap: 0.5rem;
}
.progress-bar {
flex: 1;
height: 0.4rem;
border-radius: 999px;
background-color: #18181b;
overflow: hidden;
}
.progress-bar__fill {
height: 100%;
border-radius: inherit;
background: linear-gradient(to right, #4f46e5, #22c55e);
transition: width 160ms ease-out;
}
.progress-label {
font-size: 0.75rem;
color: #e4e4e7;
width: 3ch;
text-align: right;
}
.download-error {
margin-top: 0.1rem;
font-size: 0.78rem;
color: #fecaca;
}
.download-meta {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
margin-top: 0.25rem;
font-size: 0.8rem;
color: #d4d4d8;
}
.download-footer {
margin-top: 0.35rem;
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
}
.secondary-button {
padding: 0.35rem 0.75rem;
border-radius: 999px;
border: 1px solid #3f3f46;
background-color: transparent;
color: #e4e4e7;
font-size: 0.78rem;
font-weight: 500;
cursor: pointer;
transition:
background-color 160ms ease-out,
border-color 160ms ease-out,
transform 120ms ease-out;
}
.secondary-button:hover {
background-color: rgba(63, 63, 70, 0.4);
border-color: #71717a;
}
.secondary-button:active {
transform: translateY(1px) scale(0.99);
}
@media (min-width: 768px) {
.app-header h1 {
font-size: 2.25rem;
}
.download-form {
padding: 1.75rem 2rem;
}
}
+79 -35
View File
@@ -1,26 +1,43 @@
import "./App.css"; import { AlertCircle, HardDrive, Server } from "lucide-react";
import { DownloadForm } from "./components/feature/DownloadForm"; import { DownloadForm } from "./components/feature/DownloadForm";
import { DownloadItem } from "./components/feature/DownloadItem"; import { DownloadItem } from "./components/feature/DownloadItem";
import { Layout } from "./components/ui/Layout";
import { Alert, AlertDescription, AlertTitle } from "./components/ui/Alert";
import { AlertCircle } from "lucide-react";
import { SessionProvider, useSession } from "./context/SessionContext";
import { SessionTimer } from "./components/feature/SessionTimer"; import { SessionTimer } from "./components/feature/SessionTimer";
import { Alert, AlertDescription, AlertTitle } from "./components/ui/Alert";
import { Layout } from "./components/ui/Layout";
import { SessionProvider } from "./context/SessionContext";
import { useSession } from "./context/session-context";
function DownloadList() { function DownloadList() {
const { tasks, expiresAt } = useSession(); const { tasks, expiresAt } = useSession();
return ( return (
<div className="space-y-4"> <section className="space-y-5">
<div className="flex items-center justify-between"> <div className="flex flex-col gap-4 rounded-[2rem] border border-border/70 bg-card/60 p-6 shadow-[0_24px_80px_-40px_rgba(15,23,42,0.95)] backdrop-blur-xl md:flex-row md:items-center md:justify-between">
<h2 className="text-xl font-semibold tracking-tight">Active Downloads</h2> <div className="space-y-2">
<SessionTimer expiresAt={expiresAt} /> <p className="text-xs font-semibold uppercase tracking-[0.24em] text-primary/80">
Transfer activity
</p>
<h2 className="text-2xl font-semibold tracking-tight text-foreground">
Downloads in this session
</h2>
<p className="max-w-2xl text-sm leading-6 text-muted-foreground">
Monitor queue status, transfer speed and final files from a single workspace.
</p>
</div>
<SessionTimer expiresAt={expiresAt} className="md:min-w-[260px]" />
</div> </div>
{tasks.length === 0 ? ( {tasks.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-center text-muted-foreground border-2 border-dashed rounded-lg bg-card/50"> <div className="rounded-[2rem] border border-dashed border-border/80 bg-card/35 px-6 py-14 text-center shadow-[0_24px_80px_-50px_rgba(15,23,42,0.95)]">
<p>No downloads in session</p> <p className="text-xs font-semibold uppercase tracking-[0.24em] text-primary/75">
<p className="text-xs opacity-70">Files are removed after 1 hour of inactivity</p> Queue is empty
</p>
<h3 className="mt-3 text-2xl font-semibold tracking-tight text-foreground">
No downloads yet
</h3>
<p className="mx-auto mt-3 max-w-xl text-sm leading-6 text-muted-foreground">
Your queued, active and completed transfers will appear here with progress, speed and the final save action.
</p>
</div> </div>
) : ( ) : (
<div className="grid gap-4"> <div className="grid gap-4">
@@ -29,50 +46,77 @@ function DownloadList() {
))} ))}
</div> </div>
)} )}
</div> </section>
); );
} }
function Main() { function Main() {
const { queueDownload } = useSession(); const { queueDownload, sessionId } = useSession();
const handleDownload = async (url: string) => { const handleDownload = async (url: string): Promise<void> => {
try {
await queueDownload(url); await queueDownload(url);
} catch (error) {
console.error("Failed to queue", error);
}
}; };
return ( return (
<Layout> <Layout>
<div className="grid gap-8 lg:grid-cols-12"> <div className="grid gap-8 xl:grid-cols-[minmax(0,430px)_minmax(0,1fr)] xl:items-start">
{/* Left Column: Input Form */} <div className="space-y-6 xl:sticky xl:top-28">
<div className="lg:col-span-4 space-y-6"> <section className="rounded-[2rem] border border-border/70 bg-card/55 p-6 shadow-[0_24px_90px_-45px_rgba(15,23,42,0.98)] backdrop-blur-xl md:p-7">
<div className="sticky top-24 space-y-6"> <span className="inline-flex rounded-full border border-primary/20 bg-primary/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.24em] text-primary">
<section className="space-y-4"> Private download workspace
<div className="space-y-2"> </span>
<h2 className="text-xl font-semibold tracking-tight">New Download</h2>
<p className="text-sm text-muted-foreground"> <div className="mt-5 space-y-3">
Paste a link to start. Files are downloaded to the server first. <h1 className="max-w-lg text-3xl font-semibold tracking-tight text-foreground md:text-[2.5rem] md:leading-[1.05]">
Queue secure transfers from a cleaner control panel.
</h1>
<p className="max-w-xl text-sm leading-7 text-muted-foreground md:text-[0.95rem]">
Paste a file URL, let the server fetch it first, then keep every transfer visible until the session window expires.
</p> </p>
</div> </div>
<DownloadForm onDownload={handleDownload} />
<div className="mt-6 grid gap-3 sm:grid-cols-2">
<div className="rounded-[1.5rem] border border-white/6 bg-background/45 p-4">
<Server className="h-5 w-5 text-primary" />
<p className="mt-4 text-sm font-semibold text-foreground">Server-side transfer</p>
<p className="mt-2 text-sm leading-6 text-muted-foreground">
Downloads are fetched on the VPS first, then exposed as a final file in your session.
</p>
</div>
<div className="rounded-[1.5rem] border border-white/6 bg-background/45 p-4">
<HardDrive className="h-5 w-5 text-primary" />
<p className="mt-4 text-sm font-semibold text-foreground">Ephemeral storage</p>
<p className="mt-2 text-sm leading-6 text-muted-foreground">
Files stay available for one hour of activity and are removed automatically after expiration.
</p>
</div>
</div>
</section>
<section className="rounded-[2rem] border border-border/70 bg-card/70 p-6 shadow-[0_24px_90px_-45px_rgba(15,23,42,0.98)] backdrop-blur-xl md:p-7">
<div className="space-y-2">
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-primary/80">New transfer</p>
<h2 className="text-2xl font-semibold tracking-tight text-foreground">Start a new download</h2>
<p className="text-sm leading-6 text-muted-foreground">
Paste a direct file URL to add it to the active session queue.
</p>
</div>
<div className="mt-6">
<DownloadForm onDownload={handleDownload} isReady={Boolean(sessionId)} />
</div>
</section> </section>
<Alert variant="destructive"> <Alert variant="destructive">
<AlertCircle className="h-4 w-4" /> <AlertCircle className="h-4 w-4" />
<AlertTitle>Ephemeral Session</AlertTitle> <AlertTitle>Ephemeral session policy</AlertTitle>
<AlertDescription> <AlertDescription>
Files are stored for <strong>1 hour</strong>. The timer resets on interaction. Files are stored for <strong>1 hour</strong>. The timer resets on activity, and once the session expires every stored file is deleted permanently.
After expiration, all files are permanently deleted.
</AlertDescription> </AlertDescription>
</Alert> </Alert>
</div> </div>
</div>
{/* Right Column: Downloads List */} <div className="min-w-0">
<div className="lg:col-span-8">
<DownloadList /> <DownloadList />
</div> </div>
</div> </div>
+51 -14
View File
@@ -1,22 +1,32 @@
import { useState } from "react"; import { useState } from "react";
import { Download, Loader2 } from "lucide-react";
import { z } from "zod";
import { Button } from "../ui/Button"; import { Button } from "../ui/Button";
import { Input } from "../ui/Input"; import { Input } from "../ui/Input";
import { Download } from "lucide-react";
import { z } from "zod";
const formSchema = z.object({ const formSchema = z.object({
url: z.string().url("Please enter a valid URL"), url: z.string().url("Please enter a valid URL"),
}); });
interface DownloadFormProps { interface DownloadFormProps {
onDownload: (url: string) => void; onDownload: (url: string) => Promise<void> | void;
isReady: boolean;
} }
export function DownloadForm({ onDownload }: DownloadFormProps) { function getErrorMessage(error: unknown): string {
if (error instanceof Error && error.message) {
return error.message;
}
return "Unable to queue the download right now.";
}
export function DownloadForm({ onDownload, isReady }: DownloadFormProps) {
const [url, setUrl] = useState(""); const [url, setUrl] = useState("");
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const handleSubmit = (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault(); e.preventDefault();
setError(null); setError(null);
@@ -27,26 +37,53 @@ export function DownloadForm({ onDownload }: DownloadFormProps) {
return; return;
} }
onDownload(url); if (!isReady) {
setError("Session is still initializing. Try again in a moment.");
return;
}
try {
setIsSubmitting(true);
await onDownload(url);
setUrl(""); setUrl("");
} catch (submitError: unknown) {
setError(getErrorMessage(submitError));
} finally {
setIsSubmitting(false);
}
}; };
return ( return (
<form onSubmit={handleSubmit} className="flex w-full flex-col gap-4"> <form onSubmit={handleSubmit} className="flex w-full flex-col gap-5">
<div className="flex w-full items-start gap-2"> <div className="space-y-3">
<div className="flex-1 space-y-1"> <label htmlFor="download-url" className="text-sm font-semibold text-foreground">
File URL
</label>
<Input <Input
id="download-url"
type="url" type="url"
placeholder="https://example.com/file.zip" placeholder="https://example.com/file.zip"
value={url} value={url}
onChange={(e) => setUrl(e.target.value)} onChange={(e) => setUrl(e.target.value)}
className={error ? "border-destructive focus-visible:ring-destructive" : ""} disabled={isSubmitting}
className={error ? "border-destructive/60 focus-visible:ring-destructive/15" : ""}
/> />
{error && <p className="text-xs text-destructive">{error}</p>} <div className="flex flex-col gap-2 text-xs text-muted-foreground sm:flex-row sm:items-center sm:justify-between">
<span>Use a direct file link whenever possible for faster server-side fetching.</span>
<span className="font-medium text-primary/80">
{isReady ? "Validated before queueing" : "Preparing session"}
</span>
</div> </div>
<Button type="submit"> {error ? <p className="text-sm text-destructive">{error}</p> : null}
<Download className="mr-2 h-4 w-4" /> </div>
Download
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<p className="max-w-sm text-sm leading-6 text-muted-foreground">
The transfer starts on the server first, then becomes available in your session list.
</p>
<Button type="submit" disabled={!isReady || isSubmitting} className="w-full gap-2 sm:w-auto sm:min-w-[180px]">
{isSubmitting ? <Loader2 className="h-4 w-4 animate-spin" /> : <Download className="h-4 w-4" />}
{isSubmitting ? "Queueing..." : "Queue download"}
</Button> </Button>
</div> </div>
</form> </form>
+158 -78
View File
@@ -1,138 +1,218 @@
import { useState } from "react";
import { import {
FileCheck, FileCheck,
FileWarning, FileWarning,
HardDrive,
Loader2, Loader2,
Server, Server,
HardDrive
} from "lucide-react"; } from "lucide-react";
import { DownloadStatus, DownloadTask, useSession } from "../../context/session-context";
import { formatBytes, formatSpeed } from "../../lib/utils";
import { Button } from "../ui/Button"; import { Button } from "../ui/Button";
import { Card, CardContent } from "../ui/Card"; import { Card, CardContent } from "../ui/Card";
import { Progress } from "../ui/ProgressBar"; import { Progress } from "../ui/ProgressBar";
import { formatBytes, formatSpeed } from "../../lib/utils";
const API_URL = import.meta.env.VITE_API_URL || "http://localhost:3000"; const API_URL = import.meta.env.VITE_API_URL || "";
const createdAtFormatter = new Intl.DateTimeFormat(undefined, {
export type DownloadStatus = "queued" | "downloading" | "completed" | "error"; hour: "2-digit",
minute: "2-digit",
export type DownloadItemData = { });
id: string;
sessionId: string;
url: string;
filename: string;
status: DownloadStatus;
progress: number;
totalBytes: number;
receivedBytes: number;
speed: number;
error?: string;
createdAt: number;
};
interface DownloadItemProps { interface DownloadItemProps {
item: DownloadItemData; item: DownloadTask;
}
const statusLabel: Record<DownloadStatus, string> = {
queued: "Queued",
downloading: "Transferring",
completed: "Ready",
error: "Issue",
};
function getDownloadErrorMessage(error: unknown): string {
if (error instanceof Error && error.message) {
return error.message;
}
return "Failed to download file.";
} }
export function DownloadItem({ item }: DownloadItemProps) { export function DownloadItem({ item }: DownloadItemProps) {
const { sessionId } = useSession();
const [downloadError, setDownloadError] = useState<string | null>(null);
const [isSaving, setIsSaving] = useState(false);
const isCompleted = item.status === "completed"; const isCompleted = item.status === "completed";
const isError = item.status === "error"; const isError = item.status === "error";
const isQueued = item.status === "queued"; const isQueued = item.status === "queued";
const handleDownloadFile = () => { const statusBadgeClass = isCompleted
const download = async () => { ? "border-emerald-400/20 bg-emerald-400/10 text-emerald-300"
try { : isError
const sessionId = localStorage.getItem("downloader_session_id") || ""; ? "border-destructive/30 bg-destructive/10 text-destructive"
const res = await fetch(`${API_URL}/api/download/${item.id}`, { : isQueued
headers: { "x-session-id": sessionId } ? "border-amber-400/20 bg-amber-400/10 text-amber-300"
}); : "border-primary/20 bg-primary/10 text-primary";
if (!res.ok) throw new Error("Download failed");
const blob = await res.blob(); const statusDetail = isCompleted
? "File cached and ready to be saved locally."
: isError
? "The transfer stopped before completion."
: isQueued
? "Waiting for the server to start processing."
: "Receiving data from the source URL.";
const handleDownloadFile = async (): Promise<void> => {
try {
setDownloadError(null);
setIsSaving(true);
const response = await fetch(`${API_URL}/api/download/${item.id}`, {
headers: { "x-session-id": sessionId },
});
if (!response.ok) {
throw new Error("Failed to fetch the stored file.");
}
const blob = await response.blob();
const url = window.URL.createObjectURL(blob); const url = window.URL.createObjectURL(blob);
const a = document.createElement("a"); const anchor = document.createElement("a");
a.href = url; anchor.href = url;
a.download = item.filename; anchor.download = item.filename;
document.body.appendChild(a); document.body.appendChild(anchor);
a.click(); anchor.click();
window.URL.revokeObjectURL(url); window.URL.revokeObjectURL(url);
document.body.removeChild(a); document.body.removeChild(anchor);
} catch (e) { } catch (error: unknown) {
alert("Failed to download file"); setDownloadError(getDownloadErrorMessage(error));
} finally {
setIsSaving(false);
} }
}; };
download();
};
return ( return (
<Card className="overflow-hidden transition-all hover:bg-muted/50"> <Card className="group overflow-hidden border-border/80 bg-card/75 transition-all duration-200 hover:-translate-y-0.5 hover:border-primary/25 hover:bg-card/90">
<CardContent className="p-4"> <CardContent className="p-5">
<div className="flex items-start gap-4"> <div className="flex flex-col gap-5">
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div className="flex min-w-0 gap-4">
<div className="mt-1"> <div className="mt-1">
{isCompleted ? ( {isCompleted ? (
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-green-500/10 text-green-500"> <div className="flex h-12 w-12 items-center justify-center rounded-[1.25rem] border border-emerald-400/20 bg-emerald-400/10 text-emerald-300">
<FileCheck className="h-5 w-5" /> <FileCheck className="h-5 w-5" />
</div> </div>
) : isError ? ( ) : isError ? (
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-destructive/10 text-destructive"> <div className="flex h-12 w-12 items-center justify-center rounded-[1.25rem] border border-destructive/30 bg-destructive/10 text-destructive">
<FileWarning className="h-5 w-5" /> <FileWarning className="h-5 w-5" />
</div> </div>
) : isQueued ? ( ) : isQueued ? (
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-secondary text-secondary-foreground"> <div className="flex h-12 w-12 items-center justify-center rounded-[1.25rem] border border-amber-400/20 bg-amber-400/10 text-amber-300">
<Server className="h-5 w-5 animate-pulse" /> <Server className="h-5 w-5" />
</div> </div>
) : ( ) : (
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-primary/10 text-primary"> <div className="flex h-12 w-12 items-center justify-center rounded-[1.25rem] border border-primary/20 bg-primary/10 text-primary">
<Loader2 className="h-5 w-5 animate-spin" /> <Loader2 className="h-5 w-5 animate-spin" />
</div> </div>
)} )}
</div> </div>
<div className="flex-1 space-y-1 min-w-0"> <div className="min-w-0 space-y-3">
<div className="flex items-start justify-between gap-2"> <div className="flex flex-wrap items-center gap-2">
<div className="space-y-1 truncate"> <span className={`inline-flex rounded-full border px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.2em] ${statusBadgeClass}`}>
<h3 className="font-medium leading-none truncate" title={item.filename || item.url}> {statusLabel[item.status]}
</span>
<span className="text-xs text-muted-foreground">
Created at {createdAtFormatter.format(item.createdAt)}
</span>
</div>
<div className="space-y-1">
<h3 className="truncate text-base font-semibold tracking-tight text-foreground" title={item.filename || item.url}>
{item.filename || item.url} {item.filename || item.url}
</h3> </h3>
<p className="text-xs text-muted-foreground truncate" title={item.url}> <p className="truncate text-sm text-muted-foreground" title={item.url}>
{item.url} {item.url}
</p> </p>
</div> </div>
{isCompleted && ( </div>
<Button size="sm" onClick={handleDownloadFile} className="shrink-0 gap-2">
<HardDrive className="w-4 h-4" />
Save to Disk
</Button>
)}
</div> </div>
{/* Progress Section */} {isCompleted ? (
{!isCompleted && !isError && ( <Button size="sm" onClick={handleDownloadFile} disabled={isSaving} className="w-full gap-2 lg:w-auto lg:shrink-0">
<div className="space-y-2 pt-2"> {isSaving ? <Loader2 className="h-4 w-4 animate-spin" /> : <HardDrive className="h-4 w-4" />}
<div className="flex justify-between text-xs text-muted-foreground"> {isSaving ? "Saving..." : "Save file"}
<span>{isQueued ? "Queued on server..." : item.status}</span> </Button>
<span>{formatBytes(item.receivedBytes || 0)} / {item.totalBytes ? formatBytes(item.totalBytes) : "??"}</span> ) : null}
</div> </div>
<Progress value={item.progress || 0} className="h-1.5" />
<div className="flex justify-between text-xs text-muted-foreground"> <div className="grid gap-3 md:grid-cols-3">
<span>{item.speed ? formatSpeed(item.speed) : "0 B/s"}</span> <div className="rounded-[1.25rem] border border-white/6 bg-background/35 p-4">
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-muted-foreground/75">
Status
</p>
<p className="mt-2 text-sm font-medium text-foreground">{statusDetail}</p>
</div>
<div className="rounded-[1.25rem] border border-white/6 bg-background/35 p-4">
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-muted-foreground/75">
Size
</p>
<p className="mt-2 text-sm font-medium text-foreground">
{item.totalBytes ? formatBytes(item.totalBytes) : "Unknown until available"}
</p>
</div>
<div className="rounded-[1.25rem] border border-white/6 bg-background/35 p-4">
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-muted-foreground/75">
Throughput
</p>
<p className="mt-2 text-sm font-medium text-foreground">
{item.speed ? formatSpeed(item.speed) : "Waiting for activity"}
</p>
</div>
</div>
{!isCompleted && !isError ? (
<div className="rounded-[1.5rem] border border-white/6 bg-background/45 p-4">
<div className="flex items-center justify-between text-[11px] font-semibold uppercase tracking-[0.22em] text-muted-foreground/75">
<span>{isQueued ? "Waiting on server" : "Transfer progress"}</span>
<span>{item.progress ? `${Math.round(item.progress)}%` : "0%"}</span> <span>{item.progress ? `${Math.round(item.progress)}%` : "0%"}</span>
</div> </div>
<Progress
value={item.progress || 0}
className="mt-3"
indicatorClassName={isQueued ? "opacity-60" : undefined}
/>
<div className="mt-3 flex flex-col gap-2 text-sm text-muted-foreground sm:flex-row sm:items-center sm:justify-between">
<span>
{formatBytes(item.receivedBytes || 0)} / {item.totalBytes ? formatBytes(item.totalBytes) : "Unknown"}
</span>
<span>{item.speed ? formatSpeed(item.speed) : "No transfer speed yet"}</span>
</div> </div>
)} </div>
) : null}
{isCompleted && ( {isCompleted ? (
<div className="pt-2 flex items-center gap-2 text-xs text-green-500"> <div className="rounded-[1.5rem] border border-emerald-400/20 bg-emerald-400/10 p-4 text-sm text-foreground">
<FileCheck className="w-3 h-3" /> <p className="font-semibold text-emerald-300">Transfer completed successfully</p>
<span>Ready for download ({formatBytes(item.totalBytes)})</span> <p className="mt-2 text-muted-foreground">
The file is ready in the current session and can be saved to disk at any time before expiration.
</p>
</div> </div>
)} ) : null}
{isError && ( {downloadError ? (
<div className="text-sm text-destructive pt-1"> <div className="rounded-[1.5rem] border border-destructive/30 bg-destructive/10 p-4 text-sm text-foreground">
{item.error || "Unknown error occurred"} <p className="font-semibold text-destructive">Unable to save file</p>
<p className="mt-2 text-muted-foreground">{downloadError}</p>
</div> </div>
)} ) : null}
{isError ? (
<div className="rounded-[1.5rem] border border-destructive/30 bg-destructive/10 p-4 text-sm text-foreground">
<p className="font-semibold text-destructive">Transfer failed</p>
<p className="mt-2 text-muted-foreground">
{item.error || "Unknown error occurred during the transfer."}
</p>
</div> </div>
) : null}
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
+36 -7
View File
@@ -12,7 +12,9 @@ export function SessionTimer({ expiresAt, className }: SessionTimerProps) {
const [isCritical, setIsCritical] = useState(false); const [isCritical, setIsCritical] = useState(false);
useEffect(() => { useEffect(() => {
if (!expiresAt) return; if (!expiresAt) {
return;
}
const tick = () => { const tick = () => {
const now = Date.now(); const now = Date.now();
@@ -29,20 +31,47 @@ export function SessionTimer({ expiresAt, className }: SessionTimerProps) {
const seconds = Math.floor((diff % 60000) / 1000); const seconds = Math.floor((diff % 60000) / 1000);
setTimeLeft(`${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`); setTimeLeft(`${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`);
setIsCritical(minutes < 5); // Critical if less than 5 minutes setIsCritical(minutes < 5);
}; };
tick(); // Initial call tick();
const interval = setInterval(tick, 1000); const interval = setInterval(tick, 1000);
return () => clearInterval(interval); return () => clearInterval(interval);
}, [expiresAt]); }, [expiresAt]);
if (!expiresAt) return null; if (!expiresAt) {
return null;
}
return ( return (
<div className={cn("flex items-center gap-2 text-sm font-medium", isCritical ? "text-destructive" : "text-muted-foreground", className)}> <div
<Clock className="w-4 h-4" /> className={cn(
<span>Session: {timeLeft}</span> "inline-flex items-center justify-between gap-4 rounded-[1.5rem] border px-4 py-3 shadow-[0_20px_60px_-40px_rgba(15,23,42,0.95)] backdrop-blur-xl",
isCritical
? "border-destructive/30 bg-destructive/10"
: "border-primary/20 bg-primary/10",
className
)}
>
<div className="flex items-center gap-3">
<div
className={cn(
"flex h-10 w-10 items-center justify-center rounded-[1rem] border",
isCritical
? "border-destructive/30 bg-destructive/10 text-destructive"
: "border-primary/20 bg-primary/10 text-primary"
)}
>
<Clock className="h-4 w-4" />
</div>
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-muted-foreground/75">
Session window
</p>
<p className="mt-1 text-sm font-semibold text-foreground">Expires in {timeLeft}</p>
</div>
</div>
<span className="text-xs font-medium text-muted-foreground">Auto cleanup</span>
</div> </div>
); );
} }
+13 -13
View File
@@ -1,5 +1,5 @@
import * as React from "react" import * as React from "react";
import { cn } from "../../lib/utils" import { cn } from "../../lib/utils";
const Alert = React.forwardRef< const Alert = React.forwardRef<
HTMLDivElement, HTMLDivElement,
@@ -9,15 +9,15 @@ const Alert = React.forwardRef<
ref={ref} ref={ref}
role="alert" role="alert"
className={cn( className={cn(
"relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground", "relative w-full rounded-[1.5rem] border border-border/70 bg-card/70 p-5 text-foreground shadow-[0_24px_80px_-40px_rgba(15,23,42,0.9)] backdrop-blur-xl [&>svg~*]:pl-8 [&>svg+div]:translate-y-[-2px] [&>svg]:absolute [&>svg]:left-5 [&>svg]:top-5 [&>svg]:text-foreground",
variant === "destructive" && variant === "destructive" &&
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive", "border-destructive/30 bg-destructive/10 text-destructive-foreground [&>svg]:text-destructive",
className className
)} )}
{...props} {...props}
/> />
)) ));
Alert.displayName = "Alert" Alert.displayName = "Alert";
const AlertTitle = React.forwardRef< const AlertTitle = React.forwardRef<
HTMLParagraphElement, HTMLParagraphElement,
@@ -25,11 +25,11 @@ const AlertTitle = React.forwardRef<
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<h5 <h5
ref={ref} ref={ref}
className={cn("mb-1 font-medium leading-none tracking-tight", className)} className={cn("mb-1 font-semibold leading-none tracking-tight", className)}
{...props} {...props}
/> />
)) ));
AlertTitle.displayName = "AlertTitle" AlertTitle.displayName = "AlertTitle";
const AlertDescription = React.forwardRef< const AlertDescription = React.forwardRef<
HTMLParagraphElement, HTMLParagraphElement,
@@ -37,10 +37,10 @@ const AlertDescription = React.forwardRef<
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<div <div
ref={ref} ref={ref}
className={cn("text-sm [&_p]:leading-relaxed", className)} className={cn("text-sm text-muted-foreground [&_p]:leading-relaxed", className)}
{...props} {...props}
/> />
)) ));
AlertDescription.displayName = "AlertDescription" AlertDescription.displayName = "AlertDescription";
export { Alert, AlertTitle, AlertDescription } export { Alert, AlertTitle, AlertDescription };
+12 -12
View File
@@ -1,8 +1,7 @@
import * as React from "react"; import * as React from "react";
import { cn } from "../../lib/utils"; import { cn } from "../../lib/utils";
export interface ButtonProps export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: "default" | "secondary" | "outline" | "ghost" | "destructive"; variant?: "default" | "secondary" | "outline" | "ghost" | "destructive";
size?: "default" | "sm" | "lg" | "icon"; size?: "default" | "sm" | "lg" | "icon";
} }
@@ -12,21 +11,22 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
return ( return (
<button <button
className={cn( className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50", "inline-flex items-center justify-center whitespace-nowrap rounded-2xl border border-transparent text-sm font-semibold tracking-[-0.01em] text-foreground shadow-[0_18px_40px_-24px_rgba(15,23,42,0.9)] transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-50",
{ {
"bg-primary text-primary-foreground hover:bg-primary/90": "bg-[linear-gradient(135deg,rgba(125,211,252,1),rgba(34,211,238,0.9))] text-slate-950 hover:brightness-105 hover:shadow-[0_22px_48px_-24px_rgba(34,211,238,0.6)]":
variant === "default", variant === "default",
"bg-secondary text-secondary-foreground hover:bg-secondary/80": "border-border/70 bg-secondary/80 text-secondary-foreground hover:bg-secondary":
variant === "secondary", variant === "secondary",
"border border-input bg-background hover:bg-accent hover:text-accent-foreground": "border-border/70 bg-background/60 text-foreground hover:bg-accent/70":
variant === "outline", variant === "outline",
"hover:bg-accent hover:text-accent-foreground": variant === "ghost", "border-transparent bg-transparent text-muted-foreground shadow-none hover:bg-accent/60 hover:text-foreground":
"bg-destructive text-destructive-foreground hover:bg-destructive/90": variant === "ghost",
"border-destructive/40 bg-destructive/90 text-destructive-foreground hover:bg-destructive":
variant === "destructive", variant === "destructive",
"h-10 px-4 py-2": size === "default", "h-11 px-5 py-2.5": size === "default",
"h-9 rounded-md px-3": size === "sm", "h-10 rounded-xl px-4": size === "sm",
"h-11 rounded-md px-8": size === "lg", "h-12 px-6 text-sm": size === "lg",
"h-10 w-10": size === "icon", "h-11 w-11 rounded-2xl": size === "icon",
}, },
className className
)} )}
+3 -3
View File
@@ -8,7 +8,7 @@ const Card = React.forwardRef<
<div <div
ref={ref} ref={ref}
className={cn( className={cn(
"rounded-lg border bg-card text-card-foreground shadow-sm", "rounded-[1.75rem] border border-border/70 bg-card/80 text-card-foreground shadow-[0_24px_80px_-40px_rgba(15,23,42,0.95)] backdrop-blur-xl",
className className
)} )}
{...props} {...props}
@@ -22,11 +22,11 @@ const CardHeader = React.forwardRef<
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<div <div
ref={ref} ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)} className={cn("flex flex-col space-y-2 p-6", className)}
{...props} {...props}
/> />
)); ));
Card.displayName = "CardHeader"; CardHeader.displayName = "CardHeader";
const CardTitle = React.forwardRef< const CardTitle = React.forwardRef<
HTMLParagraphElement, HTMLParagraphElement,
+2 -3
View File
@@ -1,8 +1,7 @@
import * as React from "react"; import * as React from "react";
import { cn } from "../../lib/utils"; import { cn } from "../../lib/utils";
export interface InputProps export type InputProps = React.InputHTMLAttributes<HTMLInputElement>;
extends React.InputHTMLAttributes<HTMLInputElement> { }
const Input = React.forwardRef<HTMLInputElement, InputProps>( const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => { ({ className, type, ...props }, ref) => {
@@ -10,7 +9,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
<input <input
type={type} type={type}
className={cn( className={cn(
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50", "flex h-12 w-full rounded-2xl border border-input/80 bg-background/70 px-4 py-3 text-sm text-foreground shadow-[inset_0_1px_0_rgba(255,255,255,0.03)] outline-none ring-offset-background transition-all placeholder:text-muted-foreground/80 focus-visible:border-primary/60 focus-visible:ring-4 focus-visible:ring-primary/15 disabled:cursor-not-allowed disabled:opacity-50",
className className
)} )}
ref={ref} ref={ref}
+34 -17
View File
@@ -7,31 +7,48 @@ type LayoutProps = {
export function Layout({ children }: LayoutProps) { export function Layout({ children }: LayoutProps) {
return ( return (
<div className="min-h-screen bg-background text-foreground font-sans antialiased selection:bg-primary selection:text-primary-foreground"> <div className="relative min-h-screen overflow-hidden bg-background text-foreground selection:bg-primary/20 selection:text-foreground">
<div className="absolute inset-0 -z-10 h-full w-full bg-background bg-[linear-gradient(to_right,#8080800a_1px,transparent_1px),linear-gradient(to_bottom,#8080800a_1px,transparent_1px)] bg-[size:14px_24px]"></div> <div className="pointer-events-none absolute inset-0 -z-20 bg-[radial-gradient(circle_at_top,rgba(34,211,238,0.14),transparent_0_30%),linear-gradient(to_right,rgba(148,163,184,0.04)_1px,transparent_1px),linear-gradient(to_bottom,rgba(148,163,184,0.04)_1px,transparent_1px)] bg-[auto,72px_72px,72px_72px]" />
<div className="absolute left-0 right-0 top-0 -z-10 m-auto h-[310px] w-[310px] rounded-full bg-primary/20 opacity-20 blur-[100px]"></div> <div className="pointer-events-none absolute inset-x-0 top-[-120px] -z-10 mx-auto h-[360px] w-[min(760px,100%)] rounded-full bg-[radial-gradient(circle,rgba(56,189,248,0.18),transparent_65%)] blur-3xl" />
<div className="pointer-events-none absolute bottom-[-180px] right-[-80px] -z-10 h-[340px] w-[340px] rounded-full bg-[radial-gradient(circle,rgba(34,211,238,0.14),transparent_70%)] blur-3xl" />
<header className="sticky top-0 z-50 w-full border-b border-border/40 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60"> <header className="sticky top-0 z-50 border-b border-white/6 bg-[rgba(8,13,22,0.78)] backdrop-blur-2xl">
<div className="container flex h-14 max-w-screen-2xl items-center px-4 md:px-8"> <div className="mx-auto flex h-20 w-full max-w-[1400px] items-center justify-between px-4 md:px-8">
<div className="mr-4 flex items-center space-x-2"> <div className="flex items-center gap-4">
<div className="flex aspect-square size-8 items-center justify-center rounded-lg bg-primary text-primary-foreground"> <div className="flex h-12 w-12 items-center justify-center rounded-[1.25rem] border border-primary/20 bg-primary/10 text-primary shadow-[0_18px_48px_-28px_rgba(34,211,238,0.8)]">
<MoveDown className="size-5" /> <MoveDown className="h-5 w-5" />
</div> </div>
<span className="hidden font-bold sm:inline-block"> <div>
Ephemeral Downloader <p className="text-[11px] font-semibold uppercase tracking-[0.28em] text-primary/80">
Downloader
</p>
<div className="mt-1 flex flex-wrap items-center gap-3">
<span className="text-sm font-semibold text-foreground md:text-base">
Ephemeral transfer console
</span>
<span className="hidden text-sm text-muted-foreground md:inline">
Download workflow with session-bound storage
</span> </span>
</div> </div>
<div className="flex flex-1 items-center justify-end space-x-2 md:justify-end">
<nav className="flex items-center space-x-2 text-sm font-medium text-muted-foreground">
<div className="flex items-center gap-2 rounded-full border border-border bg-secondary/50 px-3 py-1 text-xs">
<Server className="size-3" />
<span>VPS Proxy Active</span>
</div> </div>
</nav> </div>
<div className="hidden items-center gap-3 md:flex">
<div className="rounded-full border border-white/6 bg-white/4 px-4 py-2 text-right">
<p className="text-[11px] font-semibold uppercase tracking-[0.2em] text-muted-foreground">
Environment
</p>
<p className="mt-1 text-sm font-medium text-foreground">Production-ready shell</p>
</div>
<div className="inline-flex items-center gap-2 rounded-full border border-primary/20 bg-primary/10 px-4 py-2 text-sm font-medium text-primary">
<Server className="h-4 w-4" />
<span>Proxy active</span>
</div>
</div> </div>
</div> </div>
</header> </header>
<main className="container max-w-screen-xl py-6 md:py-10 px-4 md:px-8">
<main className="relative mx-auto w-full max-w-[1400px] px-4 pb-10 pt-8 md:px-8 md:pb-16 md:pt-10">
{children} {children}
</main> </main>
</div> </div>
+2 -2
View File
@@ -15,14 +15,14 @@ const Progress = React.forwardRef<HTMLDivElement, ProgressProps>(
<div <div
ref={ref} ref={ref}
className={cn( className={cn(
"relative h-2 w-full overflow-hidden rounded-full bg-secondary", "relative h-2.5 w-full overflow-hidden rounded-full bg-white/6",
className className
)} )}
{...props} {...props}
> >
<div <div
className={cn( className={cn(
"h-full w-full flex-1 bg-primary transition-all duration-200 ease-in-out", "h-full w-full flex-1 rounded-full bg-[linear-gradient(90deg,rgba(125,211,252,0.95),rgba(34,211,238,0.95))] transition-all duration-300 ease-out",
indicatorClassName indicatorClassName
)} )}
style={{ transform: `translateX(-${100 - (percent / max) * 100}%)` }} style={{ transform: `translateX(-${100 - (percent / max) * 100}%)` }}
+2 -3
View File
@@ -1,15 +1,14 @@
import * as React from "react"; import * as React from "react";
import { cn } from "../../lib/utils"; import { cn } from "../../lib/utils";
export interface TextareaProps export type TextareaProps = React.TextareaHTMLAttributes<HTMLTextAreaElement>;
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> { }
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>( const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
({ className, ...props }, ref) => { ({ className, ...props }, ref) => {
return ( return (
<textarea <textarea
className={cn( className={cn(
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50", "flex min-h-[120px] w-full rounded-2xl border border-input/80 bg-background/70 px-4 py-3 text-sm text-foreground shadow-[inset_0_1px_0_rgba(255,255,255,0.03)] outline-none ring-offset-background transition-all placeholder:text-muted-foreground/80 focus-visible:border-primary/60 focus-visible:ring-4 focus-visible:ring-primary/15 disabled:cursor-not-allowed disabled:opacity-50",
className className
)} )}
ref={ref} ref={ref}
+67 -79
View File
@@ -1,34 +1,5 @@
import React, { createContext, useContext, useEffect, useState } from "react"; import React, { useCallback, useEffect, useState } from "react";
import { DownloadTask, SessionContext, SessionData } from "./session-context";
type SessionData = {
sessionId: string;
expiresAt: string;
remainingMs: number;
};
type DownloadTask = {
id: string;
sessionId: string;
url: string;
filename: string;
status: "queued" | "downloading" | "completed" | "error";
progress: number;
totalBytes: number;
receivedBytes: number;
speed: number;
error?: string;
createdAt: number;
};
type SessionContextType = {
sessionId: string;
tasks: DownloadTask[];
expiresAt: string | null;
queueDownload: (url: string) => Promise<void>;
refreshTasks: () => void;
};
const SessionContext = createContext<SessionContextType | null>(null);
const API_URL = import.meta.env.VITE_API_URL || ""; const API_URL = import.meta.env.VITE_API_URL || "";
@@ -39,60 +10,79 @@ export function SessionProvider({ children }: { children: React.ReactNode }) {
const [tasks, setTasks] = useState<DownloadTask[]>([]); const [tasks, setTasks] = useState<DownloadTask[]>([]);
const [expiresAt, setExpiresAt] = useState<string | null>(null); const [expiresAt, setExpiresAt] = useState<string | null>(null);
// Initialize Session
useEffect(() => { useEffect(() => {
const initSession = async () => { const initSession = async () => {
try { try {
const headers: Record<string, string> = {}; const headers: Record<string, string> = {};
if (sessionId) headers["x-session-id"] = sessionId; if (sessionId) {
headers["x-session-id"] = sessionId;
}
const res = await fetch(`${API_URL}/api/session`, { headers }); const response = await fetch(`${API_URL}/api/session`, { headers });
const data = (await res.json()) as SessionData; if (!response.ok) {
throw new Error("Failed to initialize session.");
}
const data = (await response.json()) as SessionData;
if (data.sessionId && data.sessionId !== sessionId) { if (data.sessionId && data.sessionId !== sessionId) {
setSessionId(data.sessionId); setSessionId(data.sessionId);
localStorage.setItem("downloader_session_id", data.sessionId); localStorage.setItem("downloader_session_id", data.sessionId);
} }
setExpiresAt(data.expiresAt); setExpiresAt(data.expiresAt);
} catch (err) { } catch {
console.error("Failed to init session", err); setExpiresAt(null);
} }
}; };
initSession(); void initSession();
}, [sessionId]); }, [sessionId]);
// Poll Tasks const refreshTasks = useCallback(async (): Promise<void> => {
const refreshTasks = async () => { if (!sessionId) {
if (!sessionId) return; return;
}
try { try {
const res = await fetch(`${API_URL}/api/tasks`, { const response = await fetch(`${API_URL}/api/tasks`, {
headers: { "x-session-id": sessionId }, headers: { "x-session-id": sessionId },
}); });
if (res.ok) {
const data = await res.json(); if (!response.ok) {
throw new Error("Failed to fetch tasks.");
}
const data = (await response.json()) as DownloadTask[];
setTasks(data); setTasks(data);
} catch {
return;
} }
} catch (err) {
console.error("Failed to fetch tasks", err);
}
};
useEffect(() => {
if (!sessionId) return;
// Initial fetch
refreshTasks();
// Poll every 2 seconds
const interval = setInterval(refreshTasks, 2000);
return () => clearInterval(interval);
}, [sessionId]); }, [sessionId]);
const queueDownload = async (url: string) => { useEffect(() => {
if (!sessionId) return; if (!sessionId) {
try { return;
const res = await fetch(`${API_URL}/api/queue`, { }
const syncTasks = async (): Promise<void> => {
await refreshTasks();
};
void syncTasks();
const interval = setInterval(() => {
void refreshTasks();
}, 2000);
return () => clearInterval(interval);
}, [refreshTasks, sessionId]);
const queueDownload = async (url: string): Promise<void> => {
if (!sessionId) {
throw new Error("Session is still initializing. Try again in a moment.");
}
const response = await fetch(`${API_URL}/api/queue`, {
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
@@ -101,21 +91,25 @@ export function SessionProvider({ children }: { children: React.ReactNode }) {
body: JSON.stringify({ url }), body: JSON.stringify({ url }),
}); });
if (!res.ok) throw new Error("Failed to queue download"); if (!response.ok) {
throw new Error("Failed to queue download.");
}
await refreshTasks(); // Immediate refresh await refreshTasks();
// Update expiration from action try {
// Ideally we should re-fetch /api/session here too, or just rely on the side-effect const sessionResponse = await fetch(`${API_URL}/api/session`, {
const sessionRes = await fetch(`${API_URL}/api/session`, { headers: { "x-session-id": sessionId },
headers: { "x-session-id": sessionId }
}); });
const sessionData = await sessionRes.json();
setExpiresAt(sessionData.expiresAt);
} catch (err) { if (!sessionResponse.ok) {
console.error(err); return;
throw err; }
const sessionData = (await sessionResponse.json()) as SessionData;
setExpiresAt(sessionData.expiresAt);
} catch {
return;
} }
}; };
@@ -125,9 +119,3 @@ export function SessionProvider({ children }: { children: React.ReactNode }) {
</SessionContext.Provider> </SessionContext.Provider>
); );
} }
export function useSession() {
const context = useContext(SessionContext);
if (!context) throw new Error("useSession must be used within SessionProvider");
return context;
}
+43
View File
@@ -0,0 +1,43 @@
import { createContext, useContext } from "react";
export type SessionData = {
sessionId: string;
expiresAt: string;
remainingMs: number;
};
export type DownloadStatus = "queued" | "downloading" | "completed" | "error";
export type DownloadTask = {
id: string;
sessionId: string;
url: string;
filename: string;
status: DownloadStatus;
progress: number;
totalBytes: number;
receivedBytes: number;
speed: number;
error?: string;
createdAt: number;
};
export type SessionContextType = {
sessionId: string;
tasks: DownloadTask[];
expiresAt: string | null;
queueDownload: (url: string) => Promise<void>;
refreshTasks: () => Promise<void>;
};
export const SessionContext = createContext<SessionContextType | null>(null);
export function useSession(): SessionContextType {
const context = useContext(SessionContext);
if (!context) {
throw new Error("useSession must be used within SessionProvider");
}
return context;
}
+74 -19
View File
@@ -4,6 +4,9 @@
--color-background: hsl(var(--background)); --color-background: hsl(var(--background));
--color-foreground: hsl(var(--foreground)); --color-foreground: hsl(var(--foreground));
--color-card: hsl(var(--card));
--color-card-foreground: hsl(var(--card-foreground));
--color-primary: hsl(var(--primary)); --color-primary: hsl(var(--primary));
--color-primary-foreground: hsl(var(--primary-foreground)); --color-primary-foreground: hsl(var(--primary-foreground));
@@ -24,42 +27,94 @@
--color-ring: hsl(var(--ring)); --color-ring: hsl(var(--ring));
--radius-lg: var(--radius); --radius-lg: var(--radius);
--radius-md: calc(var(--radius) - 2px); --radius-md: calc(var(--radius) - 4px);
--radius-sm: calc(var(--radius) - 4px); --radius-sm: calc(var(--radius) - 8px);
} }
@layer base { @layer base {
:root { :root {
--background: 222.2 84% 4.9%; --background: 222 36% 8%;
--foreground: 210 40% 98%; --foreground: 210 40% 96%;
--primary: 210 40% 98%; --card: 222 31% 12%;
--primary-foreground: 222.2 47.4% 11.2%; --card-foreground: 210 40% 96%;
--secondary: 217.2 32.6% 17.5%; --primary: 191 95% 67%;
--secondary-foreground: 210 40% 98%; --primary-foreground: 222 47% 11%;
--muted: 217.2 32.6% 17.5%; --secondary: 223 24% 18%;
--muted-foreground: 215 20.2% 65.1%; --secondary-foreground: 210 40% 96%;
--accent: 217.2 32.6% 17.5%; --muted: 223 19% 22%;
--accent-foreground: 210 40% 98%; --muted-foreground: 214 18% 70%;
--destructive: 0 62.8% 30.6%; --accent: 222 24% 17%;
--destructive-foreground: 210 40% 98%; --accent-foreground: 210 40% 96%;
--border: 217.2 32.6% 17.5%; --destructive: 0 72% 56%;
--input: 217.2 32.6% 17.5%; --destructive-foreground: 0 0% 100%;
--ring: 212.7 26.8% 83.9%;
--radius: 0.5rem; --border: 223 17% 22%;
--input: 223 17% 22%;
--ring: 191 95% 67%;
--radius: 1.5rem;
} }
* { * {
@apply border-border; @apply border-border;
} }
html {
color-scheme: dark;
}
body { body {
@apply bg-background text-foreground; @apply min-h-screen bg-background text-foreground antialiased;
font-family:
Inter,
ui-sans-serif,
system-ui,
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
sans-serif;
background-image:
radial-gradient(circle at top, rgba(56, 189, 248, 0.12), transparent 0 32%),
radial-gradient(circle at 80% 18%, rgba(14, 165, 233, 0.08), transparent 0 22%),
linear-gradient(180deg, rgba(255, 255, 255, 0.02), transparent 28%),
linear-gradient(135deg, #080d16 0%, #0d1320 52%, #090d16 100%);
}
#root {
min-height: 100vh;
}
::selection {
background: hsl(var(--primary) / 0.25);
color: hsl(var(--foreground));
}
::-webkit-scrollbar {
width: 10px;
height: 10px;
}
::-webkit-scrollbar-track {
background: rgba(15, 23, 42, 0.35);
}
::-webkit-scrollbar-thumb {
background: rgba(148, 163, 184, 0.22);
border-radius: 999px;
border: 2px solid transparent;
background-clip: padding-box;
}
::-webkit-scrollbar-thumb:hover {
background: rgba(148, 163, 184, 0.32);
border-radius: 999px;
border: 2px solid transparent;
background-clip: padding-box;
} }
} }