mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
@@ -1,4 +1,4 @@
|
||||
import { Trophy } from "lucide-react";
|
||||
import { Trophy, Clock } from "lucide-react";
|
||||
|
||||
/**
|
||||
* Props:
|
||||
@@ -25,6 +25,15 @@ const CourseCompleteBlock = ({ course }) => {
|
||||
You've passed all required units and the final assessment for this course.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-start gap-2.5 rounded-lg border border-amber-500/30 bg-amber-500/5 px-4 py-3 text-left">
|
||||
<Clock className="size-4 text-amber-500 shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-amber-700 dark:text-amber-400">Certificate Pending</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Certificates are issued automatically every hour. Yours will be ready within the next hour — check your notifications.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -10,15 +10,10 @@ import {
|
||||
Paperclip,
|
||||
Plus,
|
||||
X,
|
||||
AlertTriangle,
|
||||
Database,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { toast } from "sonner";
|
||||
|
||||
// ── Constants ─────────────────────────────────────────────────────────────────
|
||||
const DEFAULT_MAX_BYTES = 500 * 1024 * 1024; // 500 MB
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
function formatBytes(b) {
|
||||
if (b >= 1024 * 1024 * 1024) return (b / 1024 / 1024 / 1024).toFixed(1) + " GB";
|
||||
@@ -84,46 +79,12 @@ const FileItem = ({ file, onRemove }) => {
|
||||
);
|
||||
};
|
||||
|
||||
// ── StorageBar ────────────────────────────────────────────────────────────────
|
||||
const StorageBar = ({ usedBytes, maxBytes }) => {
|
||||
const pct = Math.min(100, (usedBytes / maxBytes) * 100);
|
||||
const isOver = usedBytes > maxBytes;
|
||||
const isWarn = pct > 75 && !isOver;
|
||||
|
||||
return (
|
||||
<div className="mt-3 p-4 border rounded-md bg-muted/50 flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="flex items-center gap-1.5 text-sm">
|
||||
<Database className="size-4" /> Total size
|
||||
</span>
|
||||
<span className={cn(
|
||||
"text-sm font-medium",
|
||||
isOver && "text-destructive",
|
||||
isWarn && "text-amber-600 dark:text-amber-400",
|
||||
!isOver && !isWarn && "text-muted-foreground"
|
||||
)}>
|
||||
{formatBytes(usedBytes)} of {formatBytes(maxBytes)}
|
||||
</span>
|
||||
</div>
|
||||
<Progress
|
||||
value={pct}
|
||||
className={cn(
|
||||
"h-1.5",
|
||||
isOver && "[&>div]:bg-destructive",
|
||||
isWarn && "[&>div]:bg-amber-500"
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ── FileUpload ────────────────────────────────────────────────────────────────
|
||||
/**
|
||||
* Standalone file upload UI — no modal, no footer buttons.
|
||||
* Compose inside <ResponsiveModal> or any container.
|
||||
*
|
||||
* Props:
|
||||
* maxBytes {number} – total size cap (default: 500 MB)
|
||||
* accept {string} – native <input accept> string (fallback if
|
||||
* allowedFileTypes not provided)
|
||||
* hint {string} – dropzone helper text (fallback if
|
||||
@@ -135,12 +96,11 @@ const StorageBar = ({ usedBytes, maxBytes }) => {
|
||||
* maxFileCount {number} – max number of files allowed. Displayed in
|
||||
* the hint and enforced client-side on file add.
|
||||
* onChange {function} – fires on every file list change:
|
||||
* ({ files, isUploading, isOverLimit }) => void
|
||||
* ({ files, isUploading }) => void
|
||||
* onUploadDone {function} – fires when all uploads finish:
|
||||
* ({ files }) => void
|
||||
*/
|
||||
const FileUpload = ({
|
||||
maxBytes = DEFAULT_MAX_BYTES,
|
||||
accept,
|
||||
hint = "PDF, DOCX, MP4, PNG, JPG",
|
||||
allowedFileTypes,
|
||||
@@ -152,8 +112,6 @@ const FileUpload = ({
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const fileInputRef = useRef(null);
|
||||
|
||||
const totalBytes = files.reduce((sum, f) => sum + (f.bytes || 0), 0);
|
||||
const isOverLimit = totalBytes > maxBytes;
|
||||
const isUploading = files.some((f) => f.status === "uploading");
|
||||
|
||||
// ── Derived accept string ───────────────────────────────────────────────────
|
||||
@@ -177,8 +135,7 @@ const FileUpload = ({
|
||||
// Notify parent with full state
|
||||
const notify = (next) => {
|
||||
const uploading = next.some((f) => f.status === "uploading");
|
||||
const overLimit = next.reduce((s, f) => s + (f.bytes || 0), 0) > maxBytes;
|
||||
onChange?.({ files: next, isUploading: uploading, isOverLimit: overLimit });
|
||||
onChange?.({ files: next, isUploading: uploading });
|
||||
};
|
||||
|
||||
// ── simulate upload progress ──────────────────────────────────────────────
|
||||
@@ -204,7 +161,7 @@ const FileUpload = ({
|
||||
});
|
||||
};
|
||||
setTimeout(tick, 300);
|
||||
}, [onChange, onUploadDone, maxBytes]);
|
||||
}, [onChange, onUploadDone]);
|
||||
|
||||
// ── add files ─────────────────────────────────────────────────────────────
|
||||
const addFiles = useCallback((rawFiles) => {
|
||||
@@ -277,7 +234,7 @@ const FileUpload = ({
|
||||
return next;
|
||||
});
|
||||
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||
}, [simulateUpload, onChange, maxBytes, allowedFileTypes, maxFileCount]);
|
||||
}, [simulateUpload, onChange, allowedFileTypes, maxFileCount]);
|
||||
|
||||
// ── remove ────────────────────────────────────────────────────────────────
|
||||
const removeFile = (id) => {
|
||||
@@ -315,7 +272,7 @@ const FileUpload = ({
|
||||
<CloudUpload className={cn("size-8 mx-auto mb-3", isDragging ? "text-blue-500" : "text-muted-foreground")} />
|
||||
<p className="text-sm font-medium">Drop files here or click to browse</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">Upload your work to submit with this task</p>
|
||||
<p className="text-sm mt-2">{derivedHint} · Max total: {formatBytes(maxBytes)}</p>
|
||||
<p className="text-sm mt-2">{derivedHint}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -348,18 +305,6 @@ const FileUpload = ({
|
||||
)}
|
||||
|
||||
<p className="text-xs text-muted-foreground mt-2">{derivedHint}</p>
|
||||
|
||||
<StorageBar usedBytes={totalBytes} maxBytes={maxBytes} />
|
||||
|
||||
{isOverLimit && (
|
||||
<div className="mt-2.5 flex items-start gap-2 px-3 py-2.5 rounded-md bg-destructive/10 border border-destructive/30">
|
||||
<AlertTriangle className="size-4 text-destructive shrink-0 mt-0.5" />
|
||||
<p className="text-xs text-destructive leading-relaxed">
|
||||
Total file size exceeds the <strong>{formatBytes(maxBytes)}</strong> limit.
|
||||
Please remove some files before turning in.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -376,4 +321,4 @@ const FileUpload = ({
|
||||
};
|
||||
|
||||
export default FileUpload;
|
||||
export { FileUpload, FileItem, FileIcon, StorageBar, formatBytes, iconForFile };
|
||||
export { FileUpload, FileItem, FileIcon, formatBytes, iconForFile };
|
||||
File diff suppressed because it is too large
Load Diff
@@ -9,20 +9,24 @@ import { Button } from "@/components/ui/button";
|
||||
import { SendHorizonal } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import api from "@/utils/api.util";
|
||||
import { useClientTiers } from "@/contexts/ClientTiersProvider";
|
||||
import { resolveTierBadge } from "@/utils/tierBadge.util";
|
||||
|
||||
const LVL_LABEL = { beginner: 'Beginner', intermediate: 'Intermediate', advanced: 'Advanced' };
|
||||
|
||||
function TierBadge({ tier, locked = false }) {
|
||||
if (tier === 'premium')
|
||||
return <Badge className="gap-1 bg-gradient-to-r from-fuchsia-600 to-purple-600 text-white border-0 w-fit shrink-0"><Lock className="size-3" /> Premium</Badge>;
|
||||
if (tier === 'exclusive')
|
||||
return <Badge className="gap-1 bg-gradient-to-r from-rose-500 to-red-600 text-white border-0 w-fit shrink-0"><Lock className="size-3" /> Exclusive</Badge>;
|
||||
if (!locked)
|
||||
return <Badge className="gap-1 bg-green-500 text-white border-0 w-fit shrink-0"><Tag className="size-3" /> Free</Badge>;
|
||||
return null;
|
||||
const { tierMap } = useClientTiers();
|
||||
const { rank, label, cls } = resolveTierBadge(tier ?? 'free', tierMap);
|
||||
if (rank === 0 && !locked) return null;
|
||||
return (
|
||||
<Badge className={`gap-1 ${cls} w-fit shrink-0`}>
|
||||
{rank > 0 ? <Lock className="size-3" /> : <Tag className="size-3" />}
|
||||
{label}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
const ReadCourse = ({ title = "Read Course", courses = [] }) => {
|
||||
const ReadCourse = ({ title = "Read Course", courses = [], groupId, taskListId, taskId }) => {
|
||||
const navigate = useNavigate();
|
||||
const [selected, setSelected] = useState(null);
|
||||
const [details, setDetails] = useState({});
|
||||
@@ -173,7 +177,21 @@ const ReadCourse = ({ title = "Read Course", courses = [] }) => {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h1 className="text-base font-semibold leading-snug line-clamp-2 group-hover:text-blue-700 dark:group-hover:text-blue-400 transition-colors">
|
||||
<h1
|
||||
onClick={(e) => {
|
||||
if (!taskId || !groupId || !taskListId) return;
|
||||
e.stopPropagation();
|
||||
navigate(
|
||||
`/group/${groupId}/view/${taskListId}/task/${taskId}/requirement`,
|
||||
{ state: { course: { id: course.id, reference_id: course.reference_id, title: course.title } } }
|
||||
);
|
||||
}}
|
||||
className={`text-base font-semibold leading-snug line-clamp-2 transition-colors ${
|
||||
taskId
|
||||
? 'text-blue-600 dark:text-blue-400 hover:underline cursor-pointer'
|
||||
: 'group-hover:text-blue-700 dark:group-hover:text-blue-400'
|
||||
}`}
|
||||
>
|
||||
{course.title}
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed line-clamp-2 mt-1">
|
||||
@@ -223,7 +241,19 @@ const ReadCourse = ({ title = "Read Course", courses = [] }) => {
|
||||
<Button variant="outline" onClick={() => setSelected(null)}>Cancel</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (info?.course_id) navigate(`/course/${info.course_id}/unit`, { state: allRead ? { seekFirstIncomplete: true } : undefined });
|
||||
if (!info?.course_id) return;
|
||||
if (taskId && groupId && taskListId) {
|
||||
// Task context — read inside ViewRequirement
|
||||
navigate(
|
||||
`/group/${groupId}/view/${taskListId}/task/${taskId}/requirement`,
|
||||
{ state: { course: { id: selected.id, reference_id: selected.reference_id, title: selected.title } } }
|
||||
);
|
||||
} else {
|
||||
// No task context — fall back to standalone course reader
|
||||
navigate(`/course/${info.course_id}/unit`, {
|
||||
state: allRead ? { seekFirstIncomplete: true } : {},
|
||||
});
|
||||
}
|
||||
}}
|
||||
disabled={done || !info?.course_id}
|
||||
>
|
||||
|
||||
@@ -2,17 +2,22 @@ import { useNavigate } from "react-router-dom";
|
||||
import { useState, useEffect } from "react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { FileText, CheckCheck, Lock, Zap, Info } from "lucide-react";
|
||||
import { FileText, CheckCheck, Lock, Zap, Info, Tag } from "lucide-react";
|
||||
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import api from "@/utils/api.util";
|
||||
import { useClientTiers } from "@/contexts/ClientTiersProvider";
|
||||
import { resolveTierBadge } from "@/utils/tierBadge.util";
|
||||
|
||||
function TierBadge({ tier }) {
|
||||
if (tier === 'premium')
|
||||
return <Badge className="gap-1 bg-gradient-to-r from-fuchsia-600 to-purple-600 text-white border-0 w-fit shrink-0"><Lock className="size-3" /> Premium</Badge>;
|
||||
if (tier === 'exclusive')
|
||||
return <Badge className="gap-1 bg-gradient-to-r from-rose-500 to-red-600 text-white border-0 w-fit shrink-0"><Lock className="size-3" /> Exclusive</Badge>;
|
||||
return null;
|
||||
const { tierMap } = useClientTiers();
|
||||
const { rank, label, cls } = resolveTierBadge(tier ?? 'free', tierMap);
|
||||
return (
|
||||
<Badge className={`gap-1 ${cls} w-fit shrink-0`}>
|
||||
{rank > 0 ? <Lock className="size-3" /> : <Tag className="size-3" />}
|
||||
{label}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
const ReadLesson = ({ title = "Read Lessons", lessons = [], groupId, taskListId, taskId }) => {
|
||||
@@ -156,6 +161,12 @@ const ReadLesson = ({ title = "Read Lessons", lessons = [], groupId, taskListId,
|
||||
<div className="flex items-center gap-1.5">
|
||||
<FileText className="size-3.5 text-muted-foreground shrink-0" />
|
||||
<span className="text-xs text-muted-foreground font-medium">Lesson</span>
|
||||
<div className="ml-auto">
|
||||
{info?.unit?.course?.subscription
|
||||
? <TierBadge tier={info.unit.course.subscription} />
|
||||
: isFetching && <Badge variant="secondary" className="opacity-40 text-xs">Loading…</Badge>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Course breadcrumb */}
|
||||
@@ -166,7 +177,7 @@ const ReadLesson = ({ title = "Read Lessons", lessons = [], groupId, taskListId,
|
||||
)}
|
||||
|
||||
<div>
|
||||
<h1 className="text-base font-semibold leading-snug line-clamp-2 group-hover:text-blue-700 dark:group-hover:text-blue-400 transition-colors">
|
||||
<h1 className="text-base font-semibold leading-snug line-clamp-2 text-blue-600 dark:text-blue-400 hover:underline transition-colors cursor-pointer">
|
||||
{lesson.title}
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed line-clamp-2 mt-1">
|
||||
|
||||
@@ -1,19 +1,24 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Layers, CheckCheck, RefreshCw, SendHorizonal, Lock, Zap, Info } from "lucide-react";
|
||||
import { Layers, CheckCheck, RefreshCw, SendHorizonal, Lock, Zap, Info, Tag } from "lucide-react";
|
||||
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import ResponsiveModal from "@/components/generic/ResponsiveModal";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import api from "@/utils/api.util";
|
||||
import { useClientTiers } from "@/contexts/ClientTiersProvider";
|
||||
import { resolveTierBadge } from "@/utils/tierBadge.util";
|
||||
|
||||
function TierBadge({ tier }) {
|
||||
if (tier === 'premium')
|
||||
return <Badge className="gap-1 bg-gradient-to-r from-fuchsia-600 to-purple-600 text-white border-0 w-fit shrink-0"><Lock className="size-3" /> Premium</Badge>;
|
||||
if (tier === 'exclusive')
|
||||
return <Badge className="gap-1 bg-gradient-to-r from-rose-500 to-red-600 text-white border-0 w-fit shrink-0"><Lock className="size-3" /> Exclusive</Badge>;
|
||||
return null;
|
||||
const { tierMap } = useClientTiers();
|
||||
const { rank, label, cls } = resolveTierBadge(tier ?? 'free', tierMap);
|
||||
return (
|
||||
<Badge className={`gap-1 ${cls} w-fit shrink-0`}>
|
||||
{rank > 0 ? <Lock className="size-3" /> : <Tag className="size-3" />}
|
||||
{label}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
const ReadUnit = ({ title = "Read Units", units = [], groupId, taskListId, taskId }) => {
|
||||
@@ -172,10 +177,36 @@ const ReadUnit = ({ title = "Read Units", units = [], groupId, taskListId, taskI
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Layers className="size-3.5 text-muted-foreground shrink-0" />
|
||||
<span className="text-xs text-muted-foreground font-medium">Unit</span>
|
||||
<div className="ml-auto">
|
||||
{info?.course?.subscription
|
||||
? <TierBadge tier={info.course.subscription} />
|
||||
: isFetching && <Badge variant="secondary" className="opacity-40 text-xs">Loading…</Badge>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{info?.course?.title && (
|
||||
<p className="text-xs text-muted-foreground truncate -mt-1">
|
||||
from <span className="text-foreground/70 font-medium">{info.course.title}</span>
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<h1 className="text-base font-semibold leading-snug line-clamp-2 group-hover:text-blue-700 dark:group-hover:text-blue-400 transition-colors">
|
||||
<h1
|
||||
onClick={(e) => {
|
||||
if (!taskId || !groupId || !taskListId) return;
|
||||
e.stopPropagation();
|
||||
navigate(
|
||||
`/group/${groupId}/view/${taskListId}/task/${taskId}/requirement`,
|
||||
{ state: { unit } }
|
||||
);
|
||||
}}
|
||||
className={`text-base font-semibold leading-snug line-clamp-2 transition-colors ${
|
||||
taskId
|
||||
? 'text-blue-600 dark:text-blue-400 hover:underline cursor-pointer'
|
||||
: 'group-hover:text-blue-700 dark:group-hover:text-blue-400'
|
||||
}`}
|
||||
>
|
||||
{unit.title}
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed line-clamp-2 mt-1">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ExternalLink, CheckCheck } from "lucide-react";
|
||||
import { ExternalLink, CheckCheck, RefreshCcw } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Card,
|
||||
@@ -22,25 +22,32 @@ const normalizeUrl = (url) => {
|
||||
|
||||
// ── Meta fetcher ──────────────────────────────────────────────────────────────
|
||||
const fetchLinkMeta = async (url) => {
|
||||
const normalized = normalizeUrl(url);
|
||||
try {
|
||||
const res = await fetch(`https://api.microlink.io/?url=${encodeURIComponent(url)}`);
|
||||
const res = await fetch(`https://api.microlink.io/?url=${encodeURIComponent(normalized)}`);
|
||||
const json = await res.json();
|
||||
if (json.status === "success") {
|
||||
return {
|
||||
title: json.data.title ?? null,
|
||||
title: json.data.title ?? null,
|
||||
description: json.data.description ?? null,
|
||||
image: json.data.image?.url ?? json.data.logo?.url ?? null,
|
||||
image: json.data.image?.url ?? json.data.logo?.url ?? null,
|
||||
};
|
||||
}
|
||||
} catch { /* silently fail */ }
|
||||
return { title: null, description: null, image: null };
|
||||
};
|
||||
|
||||
const getDomain = (url) => {
|
||||
try { return new URL(normalizeUrl(url)).hostname.replace(/^www\./, ''); }
|
||||
catch { return url; }
|
||||
};
|
||||
|
||||
// ── LinkCard ──────────────────────────────────────────────────────────────────
|
||||
const LinkCard = ({ link, visited, onTurnIn, submitting }) => {
|
||||
const LinkCard = ({ link, visited, onTurnIn, onUnvisit, submitting, unsubmitting }) => {
|
||||
const [meta, setMeta] = useState({ title: null, description: null, image: null });
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [viewModalOpen, setViewModalOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!link.url) return;
|
||||
@@ -49,8 +56,10 @@ const LinkCard = ({ link, visited, onTurnIn, submitting }) => {
|
||||
.finally(() => setLoading(false));
|
||||
}, [link.url]);
|
||||
|
||||
const displayImage = meta.image ?? `https://avatar.vercel.sh/${encodeURIComponent(link.url)}`;
|
||||
const displayTitle = meta.title ?? link.label;
|
||||
const domain = getDomain(link.url);
|
||||
const displayImage = meta.image ?? null;
|
||||
const displayFavicon = `https://www.google.com/s2/favicons?domain=${domain}&sz=128`;
|
||||
const displayTitle = meta.title ?? link.label ?? domain;
|
||||
const displayDescription = meta.description ?? link.url;
|
||||
|
||||
const handleTurnIn = async () => {
|
||||
@@ -58,20 +67,33 @@ const LinkCard = ({ link, visited, onTurnIn, submitting }) => {
|
||||
setModalOpen(false);
|
||||
};
|
||||
|
||||
const handleUnsubmit = async () => {
|
||||
await onUnvisit(link.requirement_id);
|
||||
setViewModalOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card className="relative w-72 shrink-0 pt-0">
|
||||
{loading ? (
|
||||
<div className="relative z-20 h-40 w-full rounded-t-lg bg-muted animate-pulse" />
|
||||
) : (
|
||||
<div className="h-40 w-full rounded-t-lg bg-muted animate-pulse" />
|
||||
) : displayImage ? (
|
||||
<img
|
||||
src={displayImage}
|
||||
alt={displayTitle}
|
||||
className="relative z-20 h-40 w-full object-cover rounded-t-lg"
|
||||
onError={(e) => {
|
||||
e.currentTarget.src = `https://avatar.vercel.sh/${encodeURIComponent(link.url)}`;
|
||||
}}
|
||||
className="h-40 w-full object-cover rounded-t-lg"
|
||||
onError={(e) => { e.currentTarget.style.display = 'none'; }}
|
||||
/>
|
||||
) : (
|
||||
<div className="h-40 w-full rounded-t-lg bg-muted flex flex-col items-center justify-center gap-2">
|
||||
<img
|
||||
src={displayFavicon}
|
||||
alt={domain}
|
||||
className="w-12 h-12 rounded-xl"
|
||||
onError={(e) => { e.currentTarget.style.display = 'none'; }}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground font-medium">{domain}</span>
|
||||
</div>
|
||||
)}
|
||||
<CardHeader>
|
||||
<CardTitle className="line-clamp-1">
|
||||
@@ -89,9 +111,8 @@ const LinkCard = ({ link, visited, onTurnIn, submitting }) => {
|
||||
</CardHeader>
|
||||
<CardFooter>
|
||||
{visited ? (
|
||||
<Button className="w-full" variant="secondary" disabled>
|
||||
<CheckCheck className="size-4" />
|
||||
Visited
|
||||
<Button variant="secondary" className="w-full" onClick={() => setViewModalOpen(true)}>
|
||||
<CheckCheck /> Visited
|
||||
</Button>
|
||||
) : (
|
||||
<Button className="w-full" onClick={() => setModalOpen(true)}>
|
||||
@@ -101,16 +122,15 @@ const LinkCard = ({ link, visited, onTurnIn, submitting }) => {
|
||||
</CardFooter>
|
||||
</Card>
|
||||
|
||||
{/* Turn-in modal — first visit */}
|
||||
<ResponsiveModal
|
||||
open={modalOpen}
|
||||
onOpenChange={setModalOpen}
|
||||
title={`Visit Link: ${displayTitle}`}
|
||||
description={`By visiting a link, you are about to explore it then Turn-in after.`}
|
||||
description="By visiting a link, you are about to explore it then Turn-in after."
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" onClick={() => setModalOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => setModalOpen(false)}>Cancel</Button>
|
||||
<Button onClick={handleTurnIn} disabled={submitting}>
|
||||
<SendHorizonal /> {submitting ? "Submitting…" : "Turn In"}
|
||||
</Button>
|
||||
@@ -121,12 +141,52 @@ const LinkCard = ({ link, visited, onTurnIn, submitting }) => {
|
||||
<p className="text-xs text-muted-foreground break-all">{link.url}</p>
|
||||
<Button asChild variant="outline" className="w-full">
|
||||
<a href={normalizeUrl(link.url)} target="_blank" rel="noopener noreferrer">
|
||||
<ExternalLink className="size-4" />
|
||||
Open Link
|
||||
<ExternalLink className="size-4" /> Open Link
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</ResponsiveModal>
|
||||
|
||||
{/* View modal — after visited, with Resubmit */}
|
||||
<ResponsiveModal
|
||||
open={viewModalOpen}
|
||||
onOpenChange={setViewModalOpen}
|
||||
title={displayTitle}
|
||||
description={displayDescription}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" onClick={() => setViewModalOpen(false)}>Close</Button>
|
||||
<Button asChild variant="outline">
|
||||
<a href={normalizeUrl(link.url)} target="_blank" rel="noopener noreferrer">
|
||||
<ExternalLink className="size-4" /> Open Link
|
||||
</a>
|
||||
</Button>
|
||||
<Button onClick={handleUnsubmit} disabled={unsubmitting} variant="destructive">
|
||||
<RefreshCcw className="size-4" /> {unsubmitting ? "Removing…" : "Unsubmit"}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
{displayImage ? (
|
||||
<img
|
||||
src={displayImage}
|
||||
alt={displayTitle}
|
||||
className="w-full h-40 object-cover rounded-lg"
|
||||
onError={(e) => { e.currentTarget.style.display = 'none'; }}
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-40 rounded-lg bg-muted flex flex-col items-center justify-center gap-2">
|
||||
<img src={displayFavicon} alt={domain} className="w-12 h-12 rounded-xl" onError={(e) => { e.currentTarget.style.display = 'none'; }} />
|
||||
<span className="text-xs text-muted-foreground font-medium">{domain}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2 bg-green-500/10 text-green-600 dark:text-green-400 rounded-lg px-4 py-3 text-sm font-medium">
|
||||
<CheckCheck className="size-4 shrink-0" /> Already submitted — you can unsubmit if needed.
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground break-all">{link.url}</p>
|
||||
</div>
|
||||
</ResponsiveModal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -140,16 +200,20 @@ const LinkCard = ({ link, visited, onTurnIn, submitting }) => {
|
||||
* onVisit {function} – async (requirement_id) => void
|
||||
* called when user confirms "Turn In"
|
||||
*/
|
||||
const VisitLink = ({ title = "Visit Links", links = [], visitedMap = {}, onVisit }) => {
|
||||
const [submittingId, setSubmittingId] = useState(null);
|
||||
const VisitLink = ({ title = "Visit Links", links = [], visitedMap = {}, onVisit, onUnvisit }) => {
|
||||
const [submittingId, setSubmittingId] = useState(null);
|
||||
const [unsubmittingId, setUnsubmittingId] = useState(null);
|
||||
|
||||
const handleTurnIn = async (requirementId) => {
|
||||
setSubmittingId(requirementId);
|
||||
try {
|
||||
await onVisit?.(requirementId);
|
||||
} finally {
|
||||
setSubmittingId(null);
|
||||
}
|
||||
try { await onVisit?.(requirementId); }
|
||||
finally { setSubmittingId(null); }
|
||||
};
|
||||
|
||||
const handleUnvisit = async (requirementId) => {
|
||||
setUnsubmittingId(requirementId);
|
||||
try { await onUnvisit?.(requirementId); }
|
||||
finally { setUnsubmittingId(null); }
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -167,7 +231,9 @@ const VisitLink = ({ title = "Visit Links", links = [], visitedMap = {}, onVisit
|
||||
link={link}
|
||||
visited={!!visitedMap[link.requirement_id]}
|
||||
onTurnIn={handleTurnIn}
|
||||
onUnvisit={handleUnvisit}
|
||||
submitting={submittingId === link.requirement_id}
|
||||
unsubmitting={unsubmittingId === link.requirement_id}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user