pushy

Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
This commit is contained in:
2026-06-28 11:30:01 +08:00
parent b03b204861
commit bac7168b1e
100 changed files with 5958 additions and 1976 deletions
@@ -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>