add: more things

Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
This commit is contained in:
2026-07-06 15:21:36 +08:00
parent 41e98bb602
commit 244aa607f7
71 changed files with 2998 additions and 1014 deletions
@@ -178,9 +178,15 @@ const FileUpload = ({
return ok;
});
if (rejected.length > 0) {
setTimeout(() => toast.error(
setTimeout(() => toast(
`${rejected.length === 1 ? `"${rejected[0]}" is` : `${rejected.length} files are`} not allowed. ` +
`Accepted types: ${allowed.join(", ")}.`
`Accepted types: ${allowed.join(", ")}.`,
{
action: {
label: "Close",
onClick: () => {}
}
}
), 0);
}
}
@@ -190,14 +196,26 @@ const FileUpload = ({
if (maxFileCount) {
const availableSlots = maxFileCount - prev.length;
if (availableSlots <= 0) {
setTimeout(() => toast.error(
`You can only attach up to ${maxFileCount} file${maxFileCount !== 1 ? "s" : ""}.`
setTimeout(() => toast(
`You can only attach up to ${maxFileCount} file${maxFileCount !== 1 ? "s" : ""}.`,
{
action: {
label: "Close",
onClick: () => {}
}
}
), 0);
incoming = [];
} else if (incoming.length > availableSlots) {
setTimeout(() => toast.error(
setTimeout(() => toast(
`Only ${availableSlots} more file${availableSlots !== 1 ? "s" : ""} can be added ` +
`(max ${maxFileCount}).`
`(max ${maxFileCount}).`,
{
action: {
label: "Close",
onClick: () => {}
}
}
), 0);
incoming = incoming.slice(0, availableSlots);
}
@@ -222,11 +240,14 @@ const FileUpload = ({
}
});
if (duplicates.length > 0) {
setTimeout(() => toast.error(
duplicates.length === 1
? `"${duplicates[0]}" is already attached.`
: `${duplicates.length} files are already attached.`
), 0);
setTimeout(() => toast(duplicates.length === 1
? `"${duplicates[0]}" is already attached.`
: `${duplicates.length} files are already attached.`, {
action: {
label: "Close",
onClick: () => {}
}
}), 0);
}
const next = prev.concat(toAdd);
toAdd.forEach((e) => simulateUpload(e.id));
@@ -40,7 +40,12 @@ const ReadCourse = ({ title = "Read Course", courses = [], groupId, taskListId,
courses.forEach((course) => {
const prev = prevCompletedRef.current[course.id];
if (course.completed && prev === false) {
toast.success(`"${course.title}" has been automatically turned in!`);
toast(`"${course.title}" has been automatically turned in!`, {
action: {
label: "Close",
onClick: () => {}
}
});
}
prevCompletedRef.current[course.id] = !!course.completed;
});
@@ -1,4 +1,4 @@
import { ExternalLink, CheckCheck, RefreshCcw } from "lucide-react";
import { ExternalLink, CheckCheck, RefreshCcw, Globe } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import {
Card,
@@ -9,7 +9,7 @@ import {
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
import { useEffect, useState } from "react";
import { useState } from "react";
import ResponsiveModal from "@/components/generic/ResponsiveModal";
import { SendHorizonal } from "lucide-react";
@@ -20,47 +20,41 @@ const normalizeUrl = (url) => {
return `https://${url}`;
};
// ── Meta fetcher ──────────────────────────────────────────────────────────────
const fetchLinkMeta = async (url) => {
const normalized = normalizeUrl(url);
try {
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,
description: json.data.description ?? 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; }
};
// ── Fallback banner — favicon over a gradient, no external preview fetch ──────
const LinkImageFallback = ({ domain, favicon, className = "h-40" }) => {
const [faviconFailed, setFaviconFailed] = useState(false);
return (
<div className={`w-full ${className} bg-gradient-to-br from-muted via-muted to-primary/10 flex flex-col items-center justify-center gap-2`}>
{!faviconFailed && favicon ? (
<img
src={favicon}
alt={domain}
className="w-12 h-12 rounded-xl"
onError={() => setFaviconFailed(true)}
/>
) : (
<Globe className="size-10 text-muted-foreground/50" />
)}
<span className="text-xs text-muted-foreground font-medium">{domain}</span>
</div>
);
};
// ── LinkCard ──────────────────────────────────────────────────────────────────
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;
fetchLinkMeta(link.url)
.then((data) => setMeta(data))
.finally(() => setLoading(false));
}, [link.url]);
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 domain = getDomain(link.url);
const displayFavicon = `https://www.google.com/s2/favicons?domain=${domain}&sz=128`;
const displayTitle = link.label ?? domain;
const displayDescription = link.url;
const handleTurnIn = async () => {
await onTurnIn(link.requirement_id);
@@ -75,39 +69,10 @@ const LinkCard = ({ link, visited, onTurnIn, onUnvisit, submitting, unsubmitting
return (
<>
<Card className="relative w-72 shrink-0 pt-0">
{loading ? (
<div className="h-40 w-full rounded-t-lg bg-muted animate-pulse" />
) : displayImage ? (
<img
src={displayImage}
alt={displayTitle}
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>
)}
<LinkImageFallback domain={domain} favicon={displayFavicon} className="h-40 rounded-t-lg" />
<CardHeader>
<CardTitle className="line-clamp-1">
{loading
? <span className="block h-4 w-32 bg-muted animate-pulse rounded" />
: displayTitle
}
</CardTitle>
<CardDescription className="truncate text-xs">
{loading
? <span className="block h-3 w-48 bg-muted animate-pulse rounded" />
: displayDescription
}
</CardDescription>
<CardTitle className="line-clamp-1">{displayTitle}</CardTitle>
<CardDescription className="truncate text-xs">{displayDescription}</CardDescription>
</CardHeader>
<CardFooter>
{visited ? (
@@ -137,11 +102,10 @@ const LinkCard = ({ link, visited, onTurnIn, onUnvisit, submitting, unsubmitting
</>
}
>
<div className="flex flex-col gap-3">
<p className="text-xs text-muted-foreground break-all">{link.url}</p>
<Button asChild variant="outline" className="w-full">
<div className="flex flex-col gap-3 w-fit">
<Button asChild variant="link" className="text-blue-500">
<a href={normalizeUrl(link.url)} target="_blank" rel="noopener noreferrer">
<ExternalLink className="size-4" /> Open Link
<ExternalLink /> {link.url}
</a>
</Button>
</div>
@@ -156,11 +120,6 @@ const LinkCard = ({ link, visited, onTurnIn, onUnvisit, submitting, unsubmitting
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>
@@ -168,23 +127,18 @@ const LinkCard = ({ link, visited, onTurnIn, onUnvisit, submitting, unsubmitting
}
>
<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>
)}
<LinkImageFallback domain={domain} favicon={displayFavicon} className="h-40 rounded-lg" />
<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>
<Button asChild variant="link" className="text-blue-500">
<a href={normalizeUrl(link.url)} target="_blank" rel="noopener noreferrer">
<ExternalLink /> {link.url}
</a>
</Button>
</div>
</div>
</ResponsiveModal>
</>
@@ -201,7 +155,7 @@ const LinkCard = ({ link, visited, onTurnIn, onUnvisit, submitting, unsubmitting
* called when user confirms "Turn In"
*/
const VisitLink = ({ title = "Visit Links", links = [], visitedMap = {}, onVisit, onUnvisit }) => {
const [submittingId, setSubmittingId] = useState(null);
const [submittingId, setSubmittingId] = useState(null);
const [unsubmittingId, setUnsubmittingId] = useState(null);
const handleTurnIn = async (requirementId) => {