mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user