feat: add colle details page
All checks were successful
Deploy to Netlify / Deploy to Netlify (push) Successful in 1m39s
All checks were successful
Deploy to Netlify / Deploy to Netlify (push) Successful in 1m39s
This commit is contained in:
parent
f988f8b7e7
commit
85e2552db8
18 changed files with 979 additions and 190 deletions
52
app/components/details/attachment.tsx
Normal file
52
app/components/details/attachment.tsx
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import { FileText, Image, File } from "lucide-react";
|
||||
|
||||
export default function AttachmentItem({ attachment }: { attachment: string }) {
|
||||
return (
|
||||
<a
|
||||
// TODO: BAD: hardcoded URL, should be dynamic (environment variable or config)
|
||||
href={"https://bjcolle.fr/" + attachment}
|
||||
target="_blank"
|
||||
className="flex items-center gap-2 p-3 border rounded-md hover:bg-muted transition-colors cursor-pointer"
|
||||
>
|
||||
{getIcon(attachment)}
|
||||
<span className="font-medium truncate">
|
||||
{getName(attachment) || "Sans Nom"}
|
||||
</span>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
const getType = (attachment: string) => {
|
||||
const ext = attachment.split(".").pop();
|
||||
if (ext === "pdf") {
|
||||
return "pdf";
|
||||
} else if (["jpg", "jpeg", "png", "gif"].includes(ext!)) {
|
||||
return "image";
|
||||
} else {
|
||||
console.error(`Unknown attachment type: ${ext}`);
|
||||
return "other";
|
||||
}
|
||||
};
|
||||
|
||||
const getIcon = (attachment: string) => {
|
||||
switch (getType(attachment)) {
|
||||
case "pdf":
|
||||
return <FileText className="h-5 w-5 text-red-500" />;
|
||||
case "image":
|
||||
return <Image className="h-5 w-5 text-blue-500" />;
|
||||
// case "document":
|
||||
// return <FileText className="h-5 w-5 text-blue-500" />
|
||||
// case "spreadsheet":
|
||||
// return <FileText className="h-5 w-5 text-green-500" />
|
||||
// case "code":
|
||||
// return <FileText className="h-5 w-5 text-purple-500" />
|
||||
default:
|
||||
return <File className="h-5 w-5 text-gray-500" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getName = (attachment: string) => {
|
||||
const parts = attachment.replace("pj_doc", "").split("_");
|
||||
const nameParts = parts.slice(2); // remove the first two parts
|
||||
return nameParts.join("_");
|
||||
};
|
||||
92
app/components/details/skeleton-details.tsx
Normal file
92
app/components/details/skeleton-details.tsx
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
import { Button } from "~/components/ui/button"
|
||||
import { Card, CardContent, CardHeader } from "~/components/ui/card"
|
||||
import { Skeleton } from "~/components/ui/skeleton"
|
||||
import { Separator } from "~/components/ui/separator"
|
||||
import { ArrowLeft } from "lucide-react"
|
||||
|
||||
export default function DetailsSkeleton() {
|
||||
return (
|
||||
<div className="container mx-auto py-8 px-4">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<Button variant="outline" disabled>
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
Back
|
||||
</Button>
|
||||
<Skeleton className="h-9 w-9 rounded-md" />
|
||||
</div>
|
||||
|
||||
<Card className="max-w-3xl mx-auto">
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<Skeleton className="h-8 w-48" />
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="h-8 w-8 rounded-full" />
|
||||
<Skeleton className="h-6 w-20 rounded-md" />
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Skeleton className="h-4 w-24 mb-1" />
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="h-4 w-4 rounded-full" />
|
||||
<Skeleton className="h-4 w-40" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Skeleton className="h-4 w-24 mb-1" />
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="h-4 w-4 rounded-full" />
|
||||
<Skeleton className="h-4 w-32" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Skeleton className="h-4 w-24 mb-1" />
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="h-4 w-4 rounded-full" />
|
||||
<Skeleton className="h-4 w-28" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Skeleton className="h-4 w-24 mb-1" />
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="h-4 w-4 rounded-full" />
|
||||
<Skeleton className="h-4 w-36" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Skeleton className="h-5 w-5 rounded-full" />
|
||||
<Skeleton className="h-6 w-40" />
|
||||
</div>
|
||||
<Skeleton className="h-24 w-full rounded-lg" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Skeleton className="h-5 w-5 rounded-full" />
|
||||
<Skeleton className="h-6 w-32" />
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<Skeleton className="h-14 w-full rounded-md" />
|
||||
<Skeleton className="h-14 w-full rounded-md" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -18,7 +18,7 @@ export default function Error({
|
|||
description = "La page que vous recherchez a peut-être été supprimée, son nom a été modifié ou est temporairement indisponible.",
|
||||
}) {
|
||||
return (
|
||||
<div className="container flex items-center justify-center min-h-[80vh]">
|
||||
<div className="flex items-center justify-center h-full w-full">
|
||||
<Card className="max-w-md w-full">
|
||||
<CardHeader className="text-center">
|
||||
<div className="flex justify-center mb-4">
|
||||
|
|
@ -36,9 +36,9 @@ export default function Error({
|
|||
<ChevronLeft />
|
||||
<Link to="/">Retour</Link>
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={forceReload}>
|
||||
<Button variant="destructive" className="text-white" onClick={forceReload}>
|
||||
<RotateCw />
|
||||
Réessayer
|
||||
Recharger la page
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import type React from "react";
|
||||
import type { Colle } from "~/lib/api";
|
||||
|
||||
import { DateTime } from "luxon";
|
||||
import { Link, useNavigate } from "react-router";
|
||||
import {
|
||||
Card,
|
||||
|
|
@ -12,7 +11,7 @@ import {
|
|||
import { User, UserCheck, Paperclip, Star, MapPinHouse } from "lucide-react";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { titleCase } from "~/lib/utils";
|
||||
import { formatDate, formatGrade, formatTime } from "~/lib/utils";
|
||||
|
||||
// TODO: Preferences for subject colors
|
||||
const getSubjectColor = (_: string) => {
|
||||
|
|
@ -132,47 +131,15 @@ export default function ColleCard({
|
|||
)}
|
||||
</div>
|
||||
{/* TODO: Attachments */}
|
||||
{colle.attachmentsCount > 0 && (
|
||||
{/* {colle.attachmentsCount > 0 && (
|
||||
<div className="flex items-center gap-1 text-muted-foreground">
|
||||
<Paperclip className="h-3.5 w-3.5" />
|
||||
<span className="text-xs">{colle.attachmentsCount}</span>
|
||||
</div>
|
||||
)}
|
||||
)} */}
|
||||
</div>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
const formatDate = (date: string) => {
|
||||
const dt = DateTime.fromISO(date).setLocale("fr");
|
||||
const str = dt.toLocaleString({
|
||||
weekday: "long",
|
||||
day: "numeric",
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
});
|
||||
return titleCase(str);
|
||||
};
|
||||
|
||||
const formatTime = (date: string) => {
|
||||
const dt = DateTime.fromISO(date).setLocale("fr");
|
||||
return dt.toLocaleString({
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
};
|
||||
|
||||
const formatGrade = (grade?: number) => {
|
||||
if (grade === undefined || grade === null || grade < 0 || grade > 20)
|
||||
return "N/A";
|
||||
|
||||
const rounded = Math.round(grade * 10) / 10;
|
||||
const str =
|
||||
rounded % 1 === 0
|
||||
? rounded.toFixed(0) // no decimals if .0
|
||||
: rounded.toFixed(1); // one decimal otherwise
|
||||
|
||||
return str.replace(".", ",").padStart(2, "0"); // pad with zero if needed
|
||||
};
|
||||
|
|
|
|||
|
|
@ -52,6 +52,8 @@ export default function DatePickerWithRange({
|
|||
onSelect={handleDateSelect}
|
||||
weekStartsOn={1}
|
||||
locale={fr}
|
||||
className="rounded-md border shadow-sm"
|
||||
captionLayout="dropdown"
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
|
|
|||
26
app/components/ui/separator.tsx
Normal file
26
app/components/ui/separator.tsx
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import * as React from "react";
|
||||
import * as SeparatorPrimitive from "@radix-ui/react-separator";
|
||||
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
function Separator({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
decorative = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
|
||||
return (
|
||||
<SeparatorPrimitive.Root
|
||||
data-slot="separator"
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Separator };
|
||||
23
app/components/ui/sonner.tsx
Normal file
23
app/components/ui/sonner.tsx
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import { Toaster as Sonner, type ToasterProps } from "sonner";
|
||||
|
||||
const Toaster = ({ ...props }: ToasterProps) => {
|
||||
// TODO: Use theme hook
|
||||
const { theme = "system" } = {};
|
||||
|
||||
return (
|
||||
<Sonner
|
||||
theme={theme as ToasterProps["theme"]}
|
||||
className="toaster group"
|
||||
style={
|
||||
{
|
||||
"--normal-bg": "var(--popover)",
|
||||
"--normal-text": "var(--popover-foreground)",
|
||||
"--normal-border": "var(--border)",
|
||||
} as React.CSSProperties
|
||||
}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default Toaster;
|
||||
|
|
@ -22,7 +22,7 @@ import {
|
|||
import { useNavigate } from "react-router";
|
||||
import { logout, type User } from "~/lib/api";
|
||||
|
||||
export function UserDropdown({ user }: { user: User }) {
|
||||
export default function UserDropdown({ user }: { user: User }) {
|
||||
// TODO: const { theme, setTheme } = useTheme();
|
||||
const [theme, setTheme] = useState("light");
|
||||
const [open, setOpen] = useState(false);
|
||||
|
|
|
|||
|
|
@ -82,6 +82,50 @@ export const registerUser = async (
|
|||
);
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* === USER API ===
|
||||
*/
|
||||
const fetchUser = async () => {
|
||||
return makeRequest(
|
||||
"/users/@me",
|
||||
"Échec de la récupération des informations utilisateur"
|
||||
);
|
||||
};
|
||||
|
||||
const defaultUser = {
|
||||
id: 0,
|
||||
firstName: "",
|
||||
lastName: "",
|
||||
fullName: "",
|
||||
email: "",
|
||||
className: "",
|
||||
};
|
||||
|
||||
export type User = typeof defaultUser;
|
||||
|
||||
export const useUser = () => {
|
||||
const { data, ...props } = useQuery({
|
||||
queryKey: ["user"],
|
||||
queryFn: fetchUser,
|
||||
staleTime: Duration.fromObject({
|
||||
minutes: 5, // 5 minutes
|
||||
}).toMillis(),
|
||||
gcTime: Duration.fromObject({
|
||||
days: 3, // 3 days
|
||||
}).toMillis(),
|
||||
});
|
||||
return {
|
||||
user: (data ? Object.assign(defaultUser, data) : defaultUser) as User,
|
||||
...props,
|
||||
};
|
||||
};
|
||||
|
||||
export const logout = async () => {
|
||||
// TODO: POST
|
||||
// TODO: Invalidate user query (cache)
|
||||
};
|
||||
|
||||
/**
|
||||
* === COLLES API ===
|
||||
*/
|
||||
|
|
@ -161,49 +205,46 @@ export const useColles = (startDate: DateTime) => {
|
|||
|
||||
return {
|
||||
...mergedData,
|
||||
...props
|
||||
}
|
||||
...props,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* === USER API ===
|
||||
*/
|
||||
const fetchUser = async () => {
|
||||
return makeRequest(
|
||||
"/users/@me",
|
||||
"Échec de la récupération des informations utilisateur"
|
||||
);
|
||||
const fetchColle = async (id: number) => {
|
||||
return makeRequest(`/colles/${id}`, "Échec de la récupération de la colle");
|
||||
};
|
||||
|
||||
const defaultUser = {
|
||||
const defaultColle = {
|
||||
id: 0,
|
||||
firstName: "",
|
||||
lastName: "",
|
||||
fullName: "",
|
||||
email: "",
|
||||
className: "",
|
||||
date: "",
|
||||
subject: {
|
||||
id: 0,
|
||||
name: "",
|
||||
},
|
||||
examiner: {
|
||||
id: 0,
|
||||
name: "",
|
||||
},
|
||||
room: {
|
||||
id: 0,
|
||||
name: "",
|
||||
},
|
||||
student: defaultUser,
|
||||
attachments: [],
|
||||
};
|
||||
|
||||
export type User = typeof defaultUser;
|
||||
|
||||
export const useUser = () => {
|
||||
export const useColle = (id: number) => {
|
||||
const { data, ...props } = useQuery({
|
||||
queryKey: ["user"],
|
||||
queryFn: fetchUser,
|
||||
queryKey: ["colle", id],
|
||||
queryFn: () => fetchColle(id),
|
||||
staleTime: Duration.fromObject({
|
||||
minutes: 5, // 5 minutes
|
||||
seconds: 30, // 30 seconds
|
||||
}).toMillis(),
|
||||
gcTime: Duration.fromObject({
|
||||
days: 3, // 3 days
|
||||
}).toMillis(),
|
||||
});
|
||||
return {
|
||||
user: (data ? Object.assign(defaultUser, data) : defaultUser) as User,
|
||||
colle: (data || defaultColle) as Colle,
|
||||
...props,
|
||||
};
|
||||
};
|
||||
|
||||
export const logout = async () => {
|
||||
// TODO: POST
|
||||
// TODO: Invalidate user query (cache)
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,109 +1,17 @@
|
|||
import { QueryClient } from '@tanstack/react-query';
|
||||
import { persistQueryClient } from '@tanstack/query-persist-client-core';
|
||||
import { get, set, del } from 'idb-keyval';
|
||||
import LZString from 'lz-string';
|
||||
import { get, set, del } from "idb-keyval";
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
import { persistQueryClient } from "@tanstack/react-query-persist-client";
|
||||
import { createAsyncStoragePersister } from "@tanstack/query-async-storage-persister";
|
||||
|
||||
const CACHE_KEY = 'khollise-cache'; // Key for IndexedDB storage
|
||||
const CACHE_KEY = "khollise-cache"; // Key for IndexedDB storage
|
||||
|
||||
// Check if we're in a browser environment with IndexedDB support
|
||||
const isIndexedDBAvailable = () => {
|
||||
return typeof window !== 'undefined' &&
|
||||
typeof window.indexedDB !== 'undefined' &&
|
||||
window.indexedDB !== null;
|
||||
};
|
||||
|
||||
// Custom IndexedDB persister with LZ-string compression
|
||||
const createIDBPersister = () => {
|
||||
// Return a no-op persister if IndexedDB is not available
|
||||
if (!isIndexedDBAvailable()) {
|
||||
console.warn('IndexedDB not available - cache persistence disabled');
|
||||
return {
|
||||
persistClient: async () => { },
|
||||
restoreClient: async () => undefined,
|
||||
removeClient: async () => { },
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
persistClient: async (client: any) => {
|
||||
try {
|
||||
// Double-check IndexedDB availability before operation
|
||||
if (!isIndexedDBAvailable()) {
|
||||
console.warn('IndexedDB not available during persist operation');
|
||||
return;
|
||||
}
|
||||
|
||||
// Serialize the client data
|
||||
const serializedClient = JSON.stringify(client);
|
||||
|
||||
// Compress the serialized data
|
||||
const compressedData = LZString.compress(serializedClient);
|
||||
|
||||
// Store compressed data in IndexedDB
|
||||
await set(CACHE_KEY, compressedData);
|
||||
} catch (error) {
|
||||
console.error('Failed to persist client cache:', error);
|
||||
}
|
||||
},
|
||||
|
||||
restoreClient: async () => {
|
||||
try {
|
||||
// Double-check IndexedDB availability before operation
|
||||
if (!isIndexedDBAvailable()) {
|
||||
console.warn('IndexedDB not available during restore operation');
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Get compressed data from IndexedDB
|
||||
const compressedData = await get(CACHE_KEY);
|
||||
|
||||
if (!compressedData) {
|
||||
console.log('No cached data found in IndexedDB');
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Decompress the data
|
||||
const decompressedData = LZString.decompress(compressedData);
|
||||
|
||||
if (!decompressedData) {
|
||||
console.warn('Failed to decompress cached data');
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Parse and return the client data
|
||||
const client = JSON.parse(decompressedData);
|
||||
console.log('Cache restored from IndexedDB');
|
||||
return client;
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to restore client cache:', error);
|
||||
// Clear corrupted cache if IndexedDB is available
|
||||
if (isIndexedDBAvailable()) {
|
||||
try {
|
||||
await del(CACHE_KEY);
|
||||
} catch (delError) {
|
||||
console.error('Failed to clear corrupted cache:', delError);
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
},
|
||||
|
||||
removeClient: async () => {
|
||||
try {
|
||||
// Double-check IndexedDB availability before operation
|
||||
if (!isIndexedDBAvailable()) {
|
||||
console.warn('IndexedDB not available during remove operation');
|
||||
return;
|
||||
}
|
||||
|
||||
await del(CACHE_KEY);
|
||||
console.log('Cache cleared from IndexedDB');
|
||||
} catch (error) {
|
||||
console.error('Failed to remove client cache:', error);
|
||||
}
|
||||
},
|
||||
};
|
||||
return (
|
||||
typeof window !== "undefined" &&
|
||||
typeof window.indexedDB !== "undefined" &&
|
||||
window.indexedDB !== null
|
||||
);
|
||||
};
|
||||
|
||||
// Create QueryClient with persistence
|
||||
|
|
@ -123,12 +31,15 @@ const createQueryClient = () => {
|
|||
if (isIndexedDBAvailable()) {
|
||||
persistQueryClient({
|
||||
queryClient,
|
||||
persister: createIDBPersister(),
|
||||
persister: createAsyncStoragePersister({
|
||||
storage: { getItem: get, setItem: set, removeItem: del },
|
||||
key: CACHE_KEY,
|
||||
}),
|
||||
maxAge: 1000 * 60 * 60 * 24, // 24 hours
|
||||
buster: 'v1', // Change this to invalidate cache
|
||||
})
|
||||
buster: "v1", // Change this to invalidate cache
|
||||
});
|
||||
} else {
|
||||
console.warn('Cache persistence disabled - IndexedDB not available');
|
||||
console.warn("Cache persistence disabled - IndexedDB not available");
|
||||
}
|
||||
|
||||
return queryClient;
|
||||
|
|
|
|||
33
app/lib/latex.ts
Normal file
33
app/lib/latex.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
export function extractLatexImages(html: string) {
|
||||
const imgRegex =
|
||||
/<img[^>]+src="(https:\/\/latex\.codecogs\.com\/gif\.latex\?(=?.*?))"[^>]*>/g;
|
||||
let parts = [];
|
||||
let latexMatches: string[] = [];
|
||||
let lastIndex = 0;
|
||||
|
||||
html.replace(imgRegex, (match, _, latex, index) => {
|
||||
parts.push(html.slice(lastIndex, index)); // Add HTML before image
|
||||
latexMatches.push(decodeURIComponent(latex)); // Extract and decode LaTeX
|
||||
lastIndex = index + match.length;
|
||||
return "";
|
||||
});
|
||||
|
||||
parts.push(html.slice(lastIndex)); // Add remaining HTML after last image
|
||||
|
||||
return { parts, latexMatches };
|
||||
}
|
||||
|
||||
export function renderLatex(html: string) {
|
||||
const { parts, latexMatches } = extractLatexImages(html);
|
||||
const outputHtml = parts
|
||||
.map((part, i) => {
|
||||
if (!latexMatches[i]) {
|
||||
return part;
|
||||
}
|
||||
return `${part}$$${latexMatches[i]}$$`;
|
||||
})
|
||||
.join("");
|
||||
// Remove all "\," from string
|
||||
const regex = /\\,/g;
|
||||
return outputHtml.replace(regex, " ");
|
||||
}
|
||||
|
|
@ -1,10 +1,15 @@
|
|||
import { type ClassValue, clsx } from "clsx";
|
||||
import { DateTime } from "luxon";
|
||||
import type { NavigateFunction } from "react-router";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
/**
|
||||
* === STRING UTILS ===
|
||||
*/
|
||||
export function capitalizeFirstLetter(str: string): string {
|
||||
return str.charAt(0).toUpperCase() + str.slice(1);
|
||||
}
|
||||
|
|
@ -16,7 +21,60 @@ export function titleCase(str: string) {
|
|||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* === NAVIGATION UTILS ===
|
||||
*/
|
||||
// Force a reload of the page
|
||||
export function forceReload() {
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
export function goBack(navigate: NavigateFunction) {
|
||||
let canGoBack = false;
|
||||
// Check if there is a history stack to go back to
|
||||
try {
|
||||
canGoBack = window.history.length > 1;
|
||||
} catch (e) {
|
||||
canGoBack = false;
|
||||
}
|
||||
if (!canGoBack) {
|
||||
return navigate("/");
|
||||
} else {
|
||||
return navigate(-1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* === COLLES UTILS ===
|
||||
*/
|
||||
export const formatDate = (date: string) => {
|
||||
const dt = DateTime.fromISO(date).setLocale("fr");
|
||||
const str = dt.toLocaleString({
|
||||
weekday: "long",
|
||||
day: "numeric",
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
});
|
||||
return titleCase(str);
|
||||
};
|
||||
|
||||
export const formatTime = (date: string) => {
|
||||
const dt = DateTime.fromISO(date).setLocale("fr");
|
||||
return dt.toLocaleString({
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
};
|
||||
|
||||
export const formatGrade = (grade?: number) => {
|
||||
if (grade === undefined || grade === null || grade < 0 || grade > 20)
|
||||
return "N/A";
|
||||
|
||||
const rounded = Math.round(grade * 10) / 10;
|
||||
const str =
|
||||
rounded % 1 === 0
|
||||
? rounded.toFixed(0) // no decimals if .0
|
||||
: rounded.toFixed(1); // one decimal otherwise
|
||||
|
||||
return str.replace(".", ",").padStart(2, "0"); // pad with zero if needed
|
||||
};
|
||||
|
|
|
|||
17
app/root.tsx
17
app/root.tsx
|
|
@ -10,7 +10,8 @@ import {
|
|||
import type { Route } from "./+types/root";
|
||||
import "./app.css";
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import queryClient from "./lib/client";
|
||||
import queryClient from "~/lib/client";
|
||||
import Toaster from "~/components/ui/sonner";
|
||||
|
||||
export const links: Route.LinksFunction = () => [
|
||||
{ rel: "preconnect", href: "https://fonts.googleapis.com" },
|
||||
|
|
@ -31,10 +32,19 @@ export function Layout({ children }: { children: React.ReactNode }) {
|
|||
<head>
|
||||
<meta charSet="utf-8" />
|
||||
{/* Favicon */}
|
||||
<link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96" />
|
||||
<link
|
||||
rel="icon"
|
||||
type="image/png"
|
||||
href="/favicon-96x96.png"
|
||||
sizes="96x96"
|
||||
/>
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="shortcut icon" href="/favicon.ico" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||
<link
|
||||
rel="apple-touch-icon"
|
||||
sizes="180x180"
|
||||
href="/apple-touch-icon.png"
|
||||
/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<Meta />
|
||||
<Links />
|
||||
|
|
@ -43,6 +53,7 @@ export function Layout({ children }: { children: React.ReactNode }) {
|
|||
<QueryClientProvider client={queryClient}>
|
||||
{children}
|
||||
</QueryClientProvider>
|
||||
<Toaster />
|
||||
<ScrollRestoration />
|
||||
<Scripts />
|
||||
</body>
|
||||
|
|
|
|||
|
|
@ -5,4 +5,5 @@ export default [
|
|||
route("/login", "routes/login.tsx"),
|
||||
route("/verify", "routes/verify.tsx"),
|
||||
route("/register", "routes/register.tsx"),
|
||||
route("/colles/:colleId", "routes/colles.tsx"),
|
||||
] satisfies RouteConfig;
|
||||
|
|
|
|||
463
app/routes/colles.tsx
Normal file
463
app/routes/colles.tsx
Normal file
|
|
@ -0,0 +1,463 @@
|
|||
import "katex/dist/katex.min.css";
|
||||
import { DomUtils, parseDocument } from "htmlparser2";
|
||||
// TODO: API - remove trailing lines from HTML comment/content
|
||||
// TODO: Server side image extraction and latex rendering
|
||||
// TEMP SOLUTION
|
||||
import { renderLatex } from "~/lib/latex"; // Custom LaTeX rendering function
|
||||
// function removeTrailingLines(htmlString: string) {
|
||||
// return htmlString.replace(/(<br\s*\/?>\s*)+$/gi, "").trim();
|
||||
// }
|
||||
|
||||
import Latex from "react-latex-next";
|
||||
import { useState } from "react";
|
||||
import { Navigate, useNavigate, useParams } from "react-router";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
import {
|
||||
ArrowLeft,
|
||||
Clock,
|
||||
User,
|
||||
UserCheck,
|
||||
MessageSquare,
|
||||
Paperclip,
|
||||
Star,
|
||||
ChevronUp,
|
||||
ChevronDown,
|
||||
MapPinHouse,
|
||||
Users,
|
||||
RefreshCw,
|
||||
Share2,
|
||||
ExternalLink,
|
||||
} from "lucide-react";
|
||||
import { Separator } from "~/components/ui/separator";
|
||||
import UserDropdown from "~/components/user-dropdown";
|
||||
import ColleDetailsSkeleton from "~/components/details/skeleton-details";
|
||||
import AttachmentItem from "~/components/details/attachment";
|
||||
// TODO: Scroll restoration
|
||||
// import { ScrollToTopOnMount } from "~/components/noscroll";
|
||||
import Error from "~/components/error";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { useColle, useUser } from "~/lib/api";
|
||||
import { toast } from "sonner";
|
||||
import { formatDate, formatGrade, formatTime, goBack } from "~/lib/utils";
|
||||
|
||||
// TODO: Preferences for subject colors
|
||||
const getSubjectColor = (_: string) => {
|
||||
// Mock placeholder function
|
||||
return "bg-blue-100 text-blue-800"; // Default color
|
||||
};
|
||||
const getSubjectEmoji = (_: string) => {
|
||||
// Mock placeholder function
|
||||
return "📚"; // Default emoji
|
||||
};
|
||||
|
||||
// TODO: Move all code to components
|
||||
export default function ColleDetailPage() {
|
||||
const { user, isLoading: isUserLoading, error: userError } = useUser();
|
||||
|
||||
const navigate = useNavigate();
|
||||
const params = useParams<{ colleId: string }>();
|
||||
|
||||
const [isReloading, setIsReloading] = useState(false);
|
||||
|
||||
// TODO: Handle user loading state
|
||||
if (userError) {
|
||||
console.error(userError);
|
||||
return <Navigate to="/login" />;
|
||||
}
|
||||
|
||||
// TODO: Favorite toggle function
|
||||
const toggleStar = () => {};
|
||||
|
||||
const colleId = parseInt(params.colleId!);
|
||||
if (isNaN(colleId)) {
|
||||
return <Navigate to="/" />;
|
||||
}
|
||||
|
||||
const { colle, error, isLoading } = useColle(colleId);
|
||||
if (error)
|
||||
return (
|
||||
<Error
|
||||
title="Impossible de charger cette colle"
|
||||
message={error?.toString()}
|
||||
code={500}
|
||||
description="Une erreur s'est produite lors du chargement de la colle."
|
||||
/>
|
||||
);
|
||||
|
||||
if (isLoading || !colle) return <ColleDetailsSkeleton />;
|
||||
|
||||
const handleToggleFavorite = () => {};
|
||||
|
||||
const handleReload = () => {
|
||||
setIsReloading(true);
|
||||
// TODO: HARD RELOAD
|
||||
};
|
||||
|
||||
const handleShare = async () => {
|
||||
const shareUrl = window.location.href;
|
||||
const shareTitle = `Colle de ${colle.subject.name} - ${colle.student.firstName}`;
|
||||
const shareText = `Colle de ${colle.subject.name} du ${formatDate(
|
||||
colle.date
|
||||
)} à ${formatTime(colle.date)} - ${colle.student} avec ${
|
||||
colle.examiner
|
||||
}.\n\nConsultez-le résumé ici :`;
|
||||
try {
|
||||
if (navigator.share) {
|
||||
await navigator.share({
|
||||
title: shareTitle,
|
||||
text: shareText,
|
||||
url: shareUrl,
|
||||
});
|
||||
} else {
|
||||
// Fallback to copying the URL
|
||||
await navigator.clipboard.writeText(shareUrl);
|
||||
toast("Lien copié dans le presse-papiers", {
|
||||
icon: "📋",
|
||||
description: "Vous pouvez le partager avec vos amis !",
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error sharing:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenBJColle = () => {
|
||||
const url = `https://bjcolle.fr/students_oral_disp.php?colle=${colle.bjid}&hgfebrgl8ri3h=${colle.bjsecret}`;
|
||||
window.open(url, "_blank");
|
||||
toast("Ouverture de BJColle", {
|
||||
icon: "🔗",
|
||||
description: "Redirection vers BJColle...",
|
||||
});
|
||||
};
|
||||
|
||||
const subjectColor = getSubjectColor(colle.subject.name);
|
||||
const subjectEmoji = getSubjectEmoji(colle.subject.name);
|
||||
|
||||
return (
|
||||
<div className="container mx-auto py-6 px-4 pb-20 md:pb-6 md:py-8">
|
||||
{/* <ScrollToTopOnMount /> */}
|
||||
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<Button variant="outline" onClick={() => goBack(navigate)}>
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
Retour
|
||||
</Button>
|
||||
<div className="hidden md:block">
|
||||
<UserDropdown user={user} />
|
||||
</div>
|
||||
<div className="flex md:hidden items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-10 w-10"
|
||||
onClick={handleReload}
|
||||
disabled={isReloading}
|
||||
>
|
||||
<RefreshCw
|
||||
className={`h-5 w-5 ${isReloading ? "animate-spin" : ""}`}
|
||||
/>
|
||||
<span className="sr-only">Recharger</span>
|
||||
</Button>
|
||||
<UserDropdown user={user} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card className="max-w-3xl mx-auto">
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-2">
|
||||
<div className="flex flex-col">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-2xl">Résumé de Colle</CardTitle>
|
||||
{colle.grade && (
|
||||
<div
|
||||
className={`md:hidden px-3 py-1 rounded-md text-sm font-medium ${subjectColor}`}
|
||||
>
|
||||
{formatGrade(colle.grade)}/20
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<Badge className={subjectColor}>
|
||||
{colle.subject.name} {subjectEmoji}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Desktop Action Buttons */}
|
||||
<div className="hidden md:flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-9 w-9"
|
||||
onClick={handleShare}
|
||||
title="Partager"
|
||||
>
|
||||
<Share2 className="h-4 w-4" />
|
||||
<span className="sr-only">Partager</span>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-9 w-9"
|
||||
onClick={handleReload}
|
||||
disabled={isReloading}
|
||||
title="Recharger"
|
||||
>
|
||||
<RefreshCw
|
||||
className={`h-4 w-4 ${isReloading ? "animate-spin" : ""}`}
|
||||
/>
|
||||
<span className="sr-only">Recharger</span>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={`h-9 w-9 ${
|
||||
// TODO: Use a proper favorite state
|
||||
false ? "text-yellow-500" : "text-muted-foreground"
|
||||
}`}
|
||||
onClick={handleToggleFavorite}
|
||||
>
|
||||
<Star
|
||||
className={`h-5 w-5 ${
|
||||
// TODO: Use a proper favorite state
|
||||
false ? "fill-yellow-500" : ""
|
||||
}`}
|
||||
/>
|
||||
<span className="sr-only">Ajouter aux favoris</span>
|
||||
</Button>
|
||||
|
||||
{colle.grade && (
|
||||
<div
|
||||
className={`px-3 py-1 rounded-md text-sm font-medium ${subjectColor}`}
|
||||
>
|
||||
{formatGrade(colle.grade)}/20
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-muted-foreground mb-1">
|
||||
Date
|
||||
</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="h-4 w-4" />
|
||||
<span>
|
||||
{formatDate(colle.date)}, à {formatTime(colle.date)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-muted-foreground mb-1">
|
||||
Colleur
|
||||
</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<UserCheck className="h-4 w-4" />
|
||||
<span>{colle.examiner.name}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-muted-foreground mb-1">
|
||||
Étudiant
|
||||
</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<User className="h-4 w-4" />
|
||||
<span>{colle.student.fullName}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* TODO: Colles groups - others students */}
|
||||
{/* {colle.group?.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-muted-foreground mb-1">
|
||||
Autres élèves
|
||||
</h3>
|
||||
{colle.group.map((linkedColle) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<Users className="h-4 w-4" />
|
||||
<Link
|
||||
to={`/colles/${linkedColle.id}`}
|
||||
key={linkedColle.id}
|
||||
>
|
||||
{linkedColle.student}
|
||||
</Link>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)} */}
|
||||
</div>
|
||||
|
||||
{colle.room && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-muted-foreground mb-1">
|
||||
Salle
|
||||
</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<MapPinHouse className="h-4 w-4" />
|
||||
<span>{colle.room.name}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{colle.content && (
|
||||
<>
|
||||
<Separator />
|
||||
|
||||
<div>
|
||||
<h3 className="text-lg font-medium mb-2 flex items-center gap-2">
|
||||
<MessageSquare className="h-5 w-5" />
|
||||
Sujet
|
||||
</h3>
|
||||
|
||||
<div className="p-4 bg-muted rounded-lg">
|
||||
<ExpandableComment comment={colle.content} />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{colle.comment && (
|
||||
<>
|
||||
<Separator />
|
||||
|
||||
<div>
|
||||
<h3 className="text-lg font-medium mb-2 flex items-center gap-2">
|
||||
<MessageSquare className="h-5 w-5" />
|
||||
Commentaires
|
||||
</h3>
|
||||
<div className="p-4 bg-muted rounded-lg">
|
||||
<ExpandableComment comment={colle.comment} />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* TODO: Attachments */}
|
||||
{colle.attachments && colle.attachments?.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-lg font-medium mb-3 flex items-center gap-2">
|
||||
<Paperclip className="h-5 w-5" />
|
||||
Attachments
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{colle.attachments.map((attachment, index) => (
|
||||
<AttachmentItem key={index} attachment={attachment} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* BJColle External Link */}
|
||||
<div className="mt-8 pt-4 border-t border-dashed">
|
||||
<div className="flex justify-end items-center space-x-1 mt-1 text-xs text-muted-foreground">
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
<span onClick={handleOpenBJColle}>
|
||||
Accéder à la colle depuis BJColle
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Mobile Action Bar - Fixed at Bottom */}
|
||||
<div className="fixed bottom-0 left-0 right-0 bg-background border-t border-border md:hidden z-50">
|
||||
<div className="container mx-auto px-4 py-2 flex items-center justify-around">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="lg"
|
||||
className={`flex-1 h-12 ${
|
||||
// TODO: Use a proper favorite state
|
||||
false ? "text-yellow-500" : "text-muted-foreground"
|
||||
}`}
|
||||
onClick={handleToggleFavorite}
|
||||
>
|
||||
<Star
|
||||
className={`h-5 w-5 mr-2 ${
|
||||
// TODO: Use a proper favorite state
|
||||
false ? "fill-yellow-500" : ""
|
||||
}`}
|
||||
/>
|
||||
{/* TODO: */}
|
||||
{false ? "Favori" : "Ajouter"}
|
||||
</Button>
|
||||
|
||||
<Separator orientation="vertical" className="h-8" />
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="lg"
|
||||
className="flex-1 h-12"
|
||||
onClick={handleShare}
|
||||
>
|
||||
<Share2 className="h-5 w-5 mr-2" />
|
||||
Partager
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// TODO: Custom render component for LaTeX rendering
|
||||
// Component for expandable comments
|
||||
function ExpandableComment({ comment }: { comment: string }) {
|
||||
// Crop comments longer than 750 characters
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const commentLimit = 750; // Character limit before truncating
|
||||
const isLongComment = removeHtmlElements(comment).length > commentLimit;
|
||||
|
||||
const toggleExpand = () => {
|
||||
setIsExpanded(!isExpanded);
|
||||
};
|
||||
|
||||
const displayedComment =
|
||||
isExpanded || !isLongComment
|
||||
? comment
|
||||
: `${comment.substring(0, commentLimit)}...`;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Latex delimiters={[{ left: "$$", right: "$$", display: false }]}>
|
||||
{renderLatex(displayedComment)}
|
||||
</Latex>
|
||||
{isLongComment && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="mt-1 h-7 px-2 text-primary"
|
||||
onClick={toggleExpand}
|
||||
>
|
||||
{isExpanded ? (
|
||||
<span className="flex items-center gap-1">
|
||||
Afficher moins <ChevronUp className="h-3 w-3" />
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-1">
|
||||
Afficher plus <ChevronDown className="h-3 w-3" />
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Remove HTML elements and return plain text (for text length calculation and cropping)
|
||||
function removeHtmlElements(htmlString: string) {
|
||||
const document = parseDocument(htmlString);
|
||||
return DomUtils.textContent(document).trim();
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import HomePage from "~/components/home";
|
||||
import { UserDropdown } from "~/components/user-dropdown";
|
||||
import UserDropdown from "~/components/user-dropdown";
|
||||
import { MainLayout } from "~/layout";
|
||||
import { useUser } from "~/lib/api";
|
||||
import { forceReload } from "~/lib/utils";
|
||||
|
|
|
|||
|
|
@ -18,27 +18,32 @@
|
|||
"@radix-ui/react-label": "^2.1.6",
|
||||
"@radix-ui/react-popover": "^1.1.14",
|
||||
"@radix-ui/react-select": "^2.2.5",
|
||||
"@radix-ui/react-separator": "^1.1.7",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@radix-ui/react-tabs": "^1.1.12",
|
||||
"@react-router/node": "^7.5.3",
|
||||
"@react-router/serve": "^7.5.3",
|
||||
"@tanstack/query-persist-client-core": "^5.83.0",
|
||||
"@tanstack/query-async-storage-persister": "^5.83.0",
|
||||
"@tanstack/react-query": "^5.83.0",
|
||||
"@tanstack/react-query-persist-client": "^5.83.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"htmlparser2": "^10.0.0",
|
||||
"idb-keyval": "^6.2.2",
|
||||
"input-otp": "^1.4.2",
|
||||
"isbot": "^5.1.27",
|
||||
"katex": "^0.16.22",
|
||||
"lucide-react": "^0.511.0",
|
||||
"luxon": "^3.7.1",
|
||||
"lz-string": "^1.5.0",
|
||||
"netlify-cli": "^22.1.3",
|
||||
"react": "^19.1.0",
|
||||
"react-day-picker": "^9.8.1",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-latex-next": "^3.0.0",
|
||||
"react-router": "^7.5.3",
|
||||
"sonner": "^2.0.6",
|
||||
"tailwind-merge": "^3.3.0",
|
||||
"tw-animate-css": "^1.3.0"
|
||||
},
|
||||
|
|
|
|||
124
pnpm-lock.yaml
generated
124
pnpm-lock.yaml
generated
|
|
@ -32,6 +32,9 @@ importers:
|
|||
'@radix-ui/react-select':
|
||||
specifier: ^2.2.5
|
||||
version: 2.2.5(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
|
||||
'@radix-ui/react-separator':
|
||||
specifier: ^1.1.7
|
||||
version: 1.1.7(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
|
||||
'@radix-ui/react-slot':
|
||||
specifier: ^1.2.3
|
||||
version: 1.2.3(@types/react@19.1.8)(react@19.1.1)
|
||||
|
|
@ -44,12 +47,15 @@ importers:
|
|||
'@react-router/serve':
|
||||
specifier: ^7.5.3
|
||||
version: 7.7.1(react-router@7.7.1(react-dom@19.1.1(react@19.1.1))(react@19.1.1))(typescript@5.8.3)
|
||||
'@tanstack/query-persist-client-core':
|
||||
'@tanstack/query-async-storage-persister':
|
||||
specifier: ^5.83.0
|
||||
version: 5.83.0
|
||||
'@tanstack/react-query':
|
||||
specifier: ^5.83.0
|
||||
version: 5.83.0(react@19.1.1)
|
||||
'@tanstack/react-query-persist-client':
|
||||
specifier: ^5.83.0
|
||||
version: 5.83.0(@tanstack/react-query@5.83.0(react@19.1.1))(react@19.1.1)
|
||||
class-variance-authority:
|
||||
specifier: ^0.7.1
|
||||
version: 0.7.1
|
||||
|
|
@ -62,6 +68,9 @@ importers:
|
|||
date-fns:
|
||||
specifier: ^4.1.0
|
||||
version: 4.1.0
|
||||
htmlparser2:
|
||||
specifier: ^10.0.0
|
||||
version: 10.0.0
|
||||
idb-keyval:
|
||||
specifier: ^6.2.2
|
||||
version: 6.2.2
|
||||
|
|
@ -71,15 +80,15 @@ importers:
|
|||
isbot:
|
||||
specifier: ^5.1.27
|
||||
version: 5.1.28
|
||||
katex:
|
||||
specifier: ^0.16.22
|
||||
version: 0.16.22
|
||||
lucide-react:
|
||||
specifier: ^0.511.0
|
||||
version: 0.511.0(react@19.1.1)
|
||||
luxon:
|
||||
specifier: ^3.7.1
|
||||
version: 3.7.1
|
||||
lz-string:
|
||||
specifier: ^1.5.0
|
||||
version: 1.5.0
|
||||
netlify-cli:
|
||||
specifier: ^22.1.3
|
||||
version: 22.4.0(@types/node@20.19.9)(idb-keyval@6.2.2)(picomatch@4.0.3)(rollup@4.46.1)
|
||||
|
|
@ -92,9 +101,15 @@ importers:
|
|||
react-dom:
|
||||
specifier: ^19.1.0
|
||||
version: 19.1.1(react@19.1.1)
|
||||
react-latex-next:
|
||||
specifier: ^3.0.0
|
||||
version: 3.0.0(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
|
||||
react-router:
|
||||
specifier: ^7.5.3
|
||||
version: 7.7.1(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
|
||||
sonner:
|
||||
specifier: ^2.0.6
|
||||
version: 2.0.6(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
|
||||
tailwind-merge:
|
||||
specifier: ^3.3.0
|
||||
version: 3.3.1
|
||||
|
|
@ -1488,6 +1503,19 @@ packages:
|
|||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-separator@1.1.7':
|
||||
resolution: {integrity: sha512-0HEb8R9E8A+jZjvmFCy/J4xhbXy3TV+9XSnGJ3KvTtjlIUy/YQ/p6UYZvi7YbeoeXdyU9+Y3scizK6hkY37baA==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
'@types/react-dom': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-slot@1.2.3':
|
||||
resolution: {integrity: sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==}
|
||||
peerDependencies:
|
||||
|
|
@ -1875,12 +1903,21 @@ packages:
|
|||
peerDependencies:
|
||||
vite: ^5.2.0 || ^6 || ^7
|
||||
|
||||
'@tanstack/query-async-storage-persister@5.83.0':
|
||||
resolution: {integrity: sha512-kONVCkTndW+UDspJUQKbd4hCdun+uIn2RXxpzlXYWxlefIfNjcIvefJ0+7xpap6nDqRLXIuaMaCZmFFw56uE+Q==}
|
||||
|
||||
'@tanstack/query-core@5.83.0':
|
||||
resolution: {integrity: sha512-0M8dA+amXUkyz5cVUm/B+zSk3xkQAcuXuz5/Q/LveT4ots2rBpPTZOzd7yJa2Utsf8D2Upl5KyjhHRY+9lB/XA==}
|
||||
|
||||
'@tanstack/query-persist-client-core@5.83.0':
|
||||
resolution: {integrity: sha512-hdKgHkr1MYnwZX+QHj/9JjXZx9gL2RUCD5xSX0EHZiqUQhMk4Gcryq9xosn8LmYRMlhkjk7n9uV+X4UXRvgoIg==}
|
||||
|
||||
'@tanstack/react-query-persist-client@5.83.0':
|
||||
resolution: {integrity: sha512-uEqJnSbqlvzlhYJ+RU+2c2DmbbT7cw6eFjiewEXZFXaSGWNjvUG02LePrwL8cdLlRQFcZKas30IdckboOoVg9Q==}
|
||||
peerDependencies:
|
||||
'@tanstack/react-query': ^5.83.0
|
||||
react: ^18 || ^19
|
||||
|
||||
'@tanstack/react-query@5.83.0':
|
||||
resolution: {integrity: sha512-/XGYhZ3foc5H0VM2jLSD/NyBRIOK4q9kfeml4+0x2DlL6xVuAcVEW+hTlTapAmejObg0i3eNqhkr2dT+eciwoQ==}
|
||||
peerDependencies:
|
||||
|
|
@ -2428,6 +2465,10 @@ packages:
|
|||
commander@2.20.3:
|
||||
resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==}
|
||||
|
||||
commander@8.3.0:
|
||||
resolution: {integrity: sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==}
|
||||
engines: {node: '>= 12'}
|
||||
|
||||
commander@9.5.0:
|
||||
resolution: {integrity: sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==}
|
||||
engines: {node: ^12.20.0 || >=14}
|
||||
|
|
@ -2777,6 +2818,10 @@ packages:
|
|||
resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==}
|
||||
engines: {node: '>=0.12'}
|
||||
|
||||
entities@6.0.1:
|
||||
resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==}
|
||||
engines: {node: '>=0.12'}
|
||||
|
||||
env-paths@3.0.0:
|
||||
resolution: {integrity: sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==}
|
||||
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
|
||||
|
|
@ -3229,6 +3274,9 @@ packages:
|
|||
resolution: {integrity: sha512-D4iAs/145g7EJ/wIzBLVANEpysTPthUy/K+4EUIw02YJQTqvzD1vUpYiM3vwR0qPAQj4FhQpQz8wBpY8KDcM0g==}
|
||||
engines: {node: '>=10.0.0'}
|
||||
|
||||
htmlparser2@10.0.0:
|
||||
resolution: {integrity: sha512-TwAZM+zE5Tq3lrEHvOlvwgj1XLWQCtaaibSN11Q+gGBAS7Y1uZSWwXXRe4iF6OXnaq1riyQAPFOBtYc77Mxq0g==}
|
||||
|
||||
http-cache-semantics@4.2.0:
|
||||
resolution: {integrity: sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==}
|
||||
|
||||
|
|
@ -3550,6 +3598,10 @@ packages:
|
|||
resolution: {integrity: sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
katex@0.16.22:
|
||||
resolution: {integrity: sha512-XCHRdUw4lf3SKBaJe4EvgqIuWwkPSo9XoeO8GjQW94Bp7TWv9hNhzZjZ+OH9yf1UmLygb7DIT5GSFQiyt16zYg==}
|
||||
hasBin: true
|
||||
|
||||
keep-func-props@6.0.0:
|
||||
resolution: {integrity: sha512-XDYA44ccm6W2MXZeQcDZykS5srkTpPf6Z59AEuOFbfuqdQ5TVxhAjxgzAEFBpr8XpsCEgr/XeCBFAmc9x6wRmQ==}
|
||||
engines: {node: '>=16.17.0'}
|
||||
|
|
@ -3735,10 +3787,6 @@ packages:
|
|||
resolution: {integrity: sha512-RkRWjA926cTvz5rAb1BqyWkKbbjzCGchDUIKMCUvNi17j6f6j8uHGDV82Aqcqtzd+icoYpELmG3ksgGiFNNcNg==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
lz-string@1.5.0:
|
||||
resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==}
|
||||
hasBin: true
|
||||
|
||||
macos-release@3.4.0:
|
||||
resolution: {integrity: sha512-wpGPwyg/xrSp4H4Db4xYSeAr6+cFQGHfspHzDUdYxswDnUW0L5Ov63UuJiSr8NMSpyaChO4u1n0MXUvVPtrN6A==}
|
||||
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
|
||||
|
|
@ -4394,6 +4442,13 @@ packages:
|
|||
peerDependencies:
|
||||
react: ^19.1.1
|
||||
|
||||
react-latex-next@3.0.0:
|
||||
resolution: {integrity: sha512-x70f1b1G7TronVigsRgKHKYYVUNfZk/3bciFyYX1lYLQH2y3/TXku3+5Vap8MDbJhtopePSYBsYWS6jhzIdz+g==}
|
||||
engines: {node: '>=12', npm: '>=5'}
|
||||
peerDependencies:
|
||||
react: ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0
|
||||
react-dom: ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0
|
||||
|
||||
react-refresh@0.14.2:
|
||||
resolution: {integrity: sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
|
@ -4681,6 +4736,12 @@ packages:
|
|||
sonic-boom@4.2.0:
|
||||
resolution: {integrity: sha512-INb7TM37/mAcsGmc9hyyI6+QR3rR1zVRu36B0NeGXKnOOLiZOfER5SA+N7X7k3yUYRzLWafduTDvJAfDswwEww==}
|
||||
|
||||
sonner@2.0.6:
|
||||
resolution: {integrity: sha512-yHFhk8T/DK3YxjFQXIrcHT1rGEeTLliVzWbO0xN8GberVun2RiBnxAjXAYpZrqwEVHBG9asI/Li8TAAhN9m59Q==}
|
||||
peerDependencies:
|
||||
react: ^18.0.0 || ^19.0.0 || ^19.0.0-rc
|
||||
react-dom: ^18.0.0 || ^19.0.0 || ^19.0.0-rc
|
||||
|
||||
sort-keys-length@1.0.1:
|
||||
resolution: {integrity: sha512-GRbEOUqCxemTAk/b32F2xa8wDTs+Z1QHOkbhJDQTvv/6G3ZkbJ+frYWsTcc7cBB3Fu4wy4XlLCuNtJuMn7Gsvw==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
|
@ -6777,6 +6838,15 @@ snapshots:
|
|||
'@types/react': 19.1.8
|
||||
'@types/react-dom': 19.1.6(@types/react@19.1.8)
|
||||
|
||||
'@radix-ui/react-separator@1.1.7(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)':
|
||||
dependencies:
|
||||
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
|
||||
react: 19.1.1
|
||||
react-dom: 19.1.1(react@19.1.1)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.1.8
|
||||
'@types/react-dom': 19.1.6(@types/react@19.1.8)
|
||||
|
||||
'@radix-ui/react-slot@1.2.3(@types/react@19.1.8)(react@19.1.1)':
|
||||
dependencies:
|
||||
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.8)(react@19.1.1)
|
||||
|
|
@ -7109,12 +7179,22 @@ snapshots:
|
|||
tailwindcss: 4.1.11
|
||||
vite: 6.3.5(@types/node@20.19.9)(jiti@2.5.1)(lightningcss@1.30.1)(yaml@2.8.0)
|
||||
|
||||
'@tanstack/query-async-storage-persister@5.83.0':
|
||||
dependencies:
|
||||
'@tanstack/query-persist-client-core': 5.83.0
|
||||
|
||||
'@tanstack/query-core@5.83.0': {}
|
||||
|
||||
'@tanstack/query-persist-client-core@5.83.0':
|
||||
dependencies:
|
||||
'@tanstack/query-core': 5.83.0
|
||||
|
||||
'@tanstack/react-query-persist-client@5.83.0(@tanstack/react-query@5.83.0(react@19.1.1))(react@19.1.1)':
|
||||
dependencies:
|
||||
'@tanstack/query-persist-client-core': 5.83.0
|
||||
'@tanstack/react-query': 5.83.0(react@19.1.1)
|
||||
react: 19.1.1
|
||||
|
||||
'@tanstack/react-query@5.83.0(react@19.1.1)':
|
||||
dependencies:
|
||||
'@tanstack/query-core': 5.83.0
|
||||
|
|
@ -7732,6 +7812,8 @@ snapshots:
|
|||
|
||||
commander@2.20.3: {}
|
||||
|
||||
commander@8.3.0: {}
|
||||
|
||||
commander@9.5.0: {}
|
||||
|
||||
comment-json@4.2.5:
|
||||
|
|
@ -8060,6 +8142,8 @@ snapshots:
|
|||
|
||||
entities@4.5.0: {}
|
||||
|
||||
entities@6.0.1: {}
|
||||
|
||||
env-paths@3.0.0: {}
|
||||
|
||||
envinfo@7.14.0: {}
|
||||
|
|
@ -8598,6 +8682,13 @@ snapshots:
|
|||
optionalDependencies:
|
||||
unix-dgram: 2.0.6
|
||||
|
||||
htmlparser2@10.0.0:
|
||||
dependencies:
|
||||
domelementtype: 2.3.0
|
||||
domhandler: 5.0.3
|
||||
domutils: 3.2.2
|
||||
entities: 6.0.1
|
||||
|
||||
http-cache-semantics@4.2.0: {}
|
||||
|
||||
http-errors@1.8.1:
|
||||
|
|
@ -8907,6 +8998,10 @@ snapshots:
|
|||
|
||||
jwt-decode@4.0.0: {}
|
||||
|
||||
katex@0.16.22:
|
||||
dependencies:
|
||||
commander: 8.3.0
|
||||
|
||||
keep-func-props@6.0.0:
|
||||
dependencies:
|
||||
mimic-fn: 4.0.0
|
||||
|
|
@ -9082,8 +9177,6 @@ snapshots:
|
|||
|
||||
luxon@3.7.1: {}
|
||||
|
||||
lz-string@1.5.0: {}
|
||||
|
||||
macos-release@3.4.0: {}
|
||||
|
||||
magic-string@0.30.17:
|
||||
|
|
@ -9810,6 +9903,12 @@ snapshots:
|
|||
react: 19.1.1
|
||||
scheduler: 0.26.0
|
||||
|
||||
react-latex-next@3.0.0(react-dom@19.1.1(react@19.1.1))(react@19.1.1):
|
||||
dependencies:
|
||||
katex: 0.16.22
|
||||
react: 19.1.1
|
||||
react-dom: 19.1.1(react@19.1.1)
|
||||
|
||||
react-refresh@0.14.2: {}
|
||||
|
||||
react-remove-scroll-bar@2.3.8(@types/react@19.1.8)(react@19.1.1):
|
||||
|
|
@ -10142,6 +10241,11 @@ snapshots:
|
|||
dependencies:
|
||||
atomic-sleep: 1.0.0
|
||||
|
||||
sonner@2.0.6(react-dom@19.1.1(react@19.1.1))(react@19.1.1):
|
||||
dependencies:
|
||||
react: 19.1.1
|
||||
react-dom: 19.1.1(react@19.1.1)
|
||||
|
||||
sort-keys-length@1.0.1:
|
||||
dependencies:
|
||||
sort-keys: 1.1.2
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue