529 lines
17 KiB
TypeScript
529 lines
17 KiB
TypeScript
'use client';
|
|
|
|
import { useRouter } from 'next/navigation';
|
|
import { type ChangeEvent, useContext, useEffect, useMemo, useState } from 'react';
|
|
import { useForm } from 'react-hook-form';
|
|
import { useSWRConfig } from 'swr';
|
|
|
|
import AppLink from './AppLink';
|
|
import Button from './Button';
|
|
import Dialog from './Dialog';
|
|
import Icon from './Icon';
|
|
import Input from './Input';
|
|
import Select from './Select';
|
|
|
|
import { UserContext } from '@/context/user';
|
|
import { useGroups } from '@/lib/hooks/use-groups';
|
|
import { usePuzzles } from '@/lib/hooks/use-puzzles';
|
|
import type { Chapter, Puzzle } from '@/lib/puzzles';
|
|
import { cn } from '@/lib/utils';
|
|
import useLocalStorage from '@/lib/hooks/use-local-storage';
|
|
|
|
const difficulty = [
|
|
{ value: 'easy', label: 'Facile' },
|
|
{ value: 'medium', label: 'Moyen' },
|
|
{ value: 'hard', label: 'Difficile' }
|
|
];
|
|
|
|
// TODO: REFACTOR FILTER TO AVOID WARNINGS
|
|
|
|
export default function Puzzles({ token }: { token: string }) {
|
|
const { data: me } = useContext(UserContext);
|
|
const { data, isLoading } = usePuzzles({ token });
|
|
const [isOpen, setIsOpen] = useState<boolean[]>([]);
|
|
const [filterTags, setFilterTags] = useState<string>('');
|
|
const [filterDifficulty, setFilterDifficulty] = useState<string>('');
|
|
const [filterChapter, setFilterChapter] = useState<number>();
|
|
|
|
function handleClick(index: number) {
|
|
setIsOpen((prevState) => {
|
|
const newState = [...prevState];
|
|
newState[index] = !newState[index];
|
|
return newState;
|
|
});
|
|
}
|
|
|
|
function isInEventGroup(chapter: Chapter) {
|
|
return (
|
|
chapter.startDate &&
|
|
chapter.endDate &&
|
|
me?.groups?.some((group) => group.chapter && group.chapter === chapter.id)
|
|
);
|
|
}
|
|
|
|
function isBeforeStart(chapter: Chapter) {
|
|
if (!chapter.startDate || !chapter.endDate) {
|
|
return false;
|
|
}
|
|
const startDate = new Date(chapter.startDate);
|
|
const now = new Date();
|
|
return now.getTime() < startDate.getTime() + 10 * 60 * 1000;
|
|
}
|
|
|
|
const filteredData = useMemo(() => {
|
|
if ((filterTags || filterDifficulty) && filterChapter) {
|
|
return data
|
|
?.find((chapter) => chapter.id === filterChapter)
|
|
?.puzzles.filter((puzzle) => {
|
|
if (!puzzle?.tags) return false;
|
|
if (filterDifficulty && filterTags) {
|
|
if (filterTags === 'completed') {
|
|
return puzzle!.tags!.some((tag) => tag.name === filterDifficulty) && puzzle!.score;
|
|
} else if (filterTags === 'not-completed') {
|
|
return puzzle!.tags!.some((tag) => tag.name === filterDifficulty) && !puzzle!.score;
|
|
}
|
|
return (
|
|
puzzle!.tags!.some((tag) => tag.name === filterTags) &&
|
|
puzzle!.tags!.some((tag) => tag.name === filterDifficulty)
|
|
);
|
|
}
|
|
if (filterDifficulty) {
|
|
return puzzle!.tags!.some((tag) => tag.name === filterDifficulty);
|
|
}
|
|
if (filterTags) {
|
|
if (filterTags === 'completed') {
|
|
return puzzle!.score;
|
|
} else if (filterTags === 'not-completed') {
|
|
return !puzzle!.score;
|
|
}
|
|
return puzzle!.tags!.some((tag) => tag.name === filterTags);
|
|
}
|
|
return puzzle;
|
|
})
|
|
.map((puzzle) => puzzle);
|
|
}
|
|
return data?.find((chapter) => chapter.id === filterChapter)?.puzzles;
|
|
}, [data, filterTags, filterDifficulty, filterChapter]);
|
|
|
|
return (
|
|
<>
|
|
{(!isLoading &&
|
|
data?.map((chapter) => (
|
|
<div key={chapter.id} className="flex flex-col">
|
|
<div className="flex flex-col justify-between md:flex-row md:items-center">
|
|
<div className="flex items-center gap-x-2">
|
|
<h1 className="text-xl font-semibold">{chapter.name}</h1>
|
|
{!isInEventGroup(chapter) && isBeforeStart(chapter) && (
|
|
<Dialog
|
|
key={chapter.id}
|
|
title={chapter.name}
|
|
open={isOpen[chapter.id]}
|
|
onOpenChange={() => handleClick(chapter.id)}
|
|
trigger={
|
|
<button className="flex items-center gap-x-2 text-sm font-semibold text-muted hover:text-brand">
|
|
<Icon name="group-line" />
|
|
Rejoindre un groupe
|
|
</button>
|
|
}
|
|
className="right-96 p-4"
|
|
>
|
|
<GroupForm chapter={chapter} token={token} />
|
|
</Dialog>
|
|
)}
|
|
</div>
|
|
<div className="flex flex-col">
|
|
{chapter.startDate && chapter.endDate ? (
|
|
<div className="flex items-center justify-start gap-x-2 md:justify-end">
|
|
<Icon name="calendar-line" className="text-sm text-muted" />
|
|
<span className="text-sm text-muted">
|
|
{new Date(chapter.startDate).toLocaleDateString('fr-FR', {
|
|
day: 'numeric',
|
|
month: 'long',
|
|
year: 'numeric',
|
|
hour: 'numeric',
|
|
minute: 'numeric'
|
|
})}{' '}
|
|
-{' '}
|
|
{new Date(chapter.endDate).toLocaleDateString('fr-FR', {
|
|
day: 'numeric',
|
|
month: 'long',
|
|
year: 'numeric',
|
|
hour: 'numeric',
|
|
minute: 'numeric'
|
|
})}
|
|
</span>
|
|
</div>
|
|
) : (
|
|
<div className="h-1 w-1/4 rounded-lg bg-gray-200">
|
|
<div className="h-1 w-1/2 rounded-lg bg-gradient-to-tl from-brand to-brand-accent" />
|
|
</div>
|
|
)}
|
|
<div className="mt-1 flex justify-start gap-x-2">
|
|
{isInEventGroup(chapter) && (
|
|
<>
|
|
<FilterDifficulty
|
|
chapters={data}
|
|
chapter={chapter}
|
|
filter={filterDifficulty}
|
|
setFilter={setFilterDifficulty}
|
|
setFilterChapter={setFilterChapter}
|
|
/>
|
|
<FilterTags
|
|
chapters={data}
|
|
chapter={chapter}
|
|
filter={filterTags}
|
|
setFilter={setFilterTags}
|
|
setFilterChapter={setFilterChapter}
|
|
/>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{isInEventGroup(chapter) && (
|
|
<ul className="mt-4 flex flex-col space-y-4">
|
|
{filteredData &&
|
|
filteredData
|
|
.sort((a, b) => a.scoreMax - b.scoreMax)
|
|
.map((puzzle) => (
|
|
<PuzzleProp key={puzzle.id} puzzle={puzzle} chapter={chapter} />
|
|
))}
|
|
</ul>
|
|
)}
|
|
</div>
|
|
))) || (
|
|
<div className="flex flex-col space-y-6">
|
|
{[...Array(3).keys()].map((i) => (
|
|
<div key={i} className="flex flex-col space-y-4">
|
|
<div className="flex items-center justify-between">
|
|
<span className="inline-block h-8 w-1/2 rounded-lg bg-primary-600" />
|
|
<span
|
|
className="inline-block h-1 w-1/4 animate-pulse rounded-lg bg-primary-600"
|
|
style={{
|
|
animationDelay: `${i * 0.05}s`,
|
|
animationDuration: '1s'
|
|
}}
|
|
/>
|
|
</div>
|
|
<ul className="flex flex-col space-y-4">
|
|
{[...Array(6).keys()].map((j) => (
|
|
<span
|
|
key={j}
|
|
className="inline-block h-14 animate-pulse rounded-lg bg-primary-600"
|
|
style={{
|
|
animationDelay: `${j * 0.05}s`,
|
|
animationDuration: '1s'
|
|
}}
|
|
/>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</>
|
|
);
|
|
}
|
|
|
|
function PuzzleProp({ puzzle, chapter }: { puzzle: Puzzle; chapter: Chapter }) {
|
|
function isStarted(chapter: Chapter) {
|
|
if (!chapter.startDate || !chapter.endDate) {
|
|
return false;
|
|
}
|
|
const startDate = new Date(chapter.startDate);
|
|
const now = new Date();
|
|
return now > startDate;
|
|
}
|
|
return (
|
|
<li
|
|
className={cn(
|
|
'group relative flex h-full w-full rounded-md border-2 bg-primary-700 font-code transition-colors duration-150 hover:bg-primary-600',
|
|
{
|
|
'border-green-600/30': puzzle.tags?.find((tag) => tag.name.toLowerCase() === 'easy'),
|
|
'border-yellow-600/30': puzzle.tags?.find((tag) => tag.name.toLowerCase() === 'medium'),
|
|
'border-red-600/30': puzzle.tags?.find((tag) => tag.name.toLowerCase() === 'hard'),
|
|
'border-highlight-primary': !puzzle.tags?.length,
|
|
'cursor-not-allowed': !isStarted(chapter)
|
|
}
|
|
)}
|
|
>
|
|
{isStarted(chapter) ? (
|
|
<AppLink
|
|
className="flex h-full w-full items-center justify-between p-4"
|
|
key={puzzle.id}
|
|
href={`/dashboard/puzzles/${puzzle.id}`}
|
|
>
|
|
<div className="flex w-10/12 flex-col gap-2 lg:w-full lg:flex-row">
|
|
<span className="text-base font-semibold">
|
|
{puzzle.name}{' '}
|
|
<span className="text-sm text-highlight-secondary">
|
|
({puzzle.score ? `${puzzle.score}` : '?'}/{puzzle.scoreMax} points)
|
|
</span>
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center gap-x-6">
|
|
{puzzle.tags?.length && (
|
|
<div className="flex gap-x-2 text-sm text-muted">
|
|
{puzzle.tags
|
|
.filter((tag) => !['easy', 'medium', 'hard'].includes(tag.name))
|
|
.map((tag, i) => (
|
|
<span key={i} className="inline-block rounded-md bg-primary-900 px-2 py-1">
|
|
{tag.name}
|
|
</span>
|
|
))}
|
|
</div>
|
|
)}
|
|
<Icon
|
|
className="-translate-x-2 transform-gpu duration-300 group-hover:translate-x-0"
|
|
name="arrow-right-line"
|
|
/>
|
|
</div>
|
|
</AppLink>
|
|
) : (
|
|
<div className="flex h-full w-full items-center justify-between p-4 opacity-50">
|
|
<div className="flex w-10/12 flex-col gap-2 lg:w-full lg:flex-row">
|
|
<span className="text-base font-semibold">
|
|
{puzzle.name}{' '}
|
|
<span className="text-sm text-highlight-secondary">
|
|
({puzzle.score ? `${puzzle.score}` : '?'}/{puzzle.scoreMax} points)
|
|
</span>
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center gap-x-6">
|
|
{puzzle.tags?.length && (
|
|
<div className="flex gap-x-2 text-sm text-muted">
|
|
{puzzle.tags
|
|
.filter((tag) => !['easy', 'medium', 'hard'].includes(tag.name))
|
|
.map((tag, i) => (
|
|
<span key={i} className="inline-block rounded-md bg-primary-900 px-2 py-1">
|
|
{tag.name}
|
|
</span>
|
|
))}
|
|
</div>
|
|
)}
|
|
<Icon
|
|
className="-translate-x-2 transform-gpu duration-300 group-hover:translate-x-0"
|
|
name="arrow-right-line"
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</li>
|
|
);
|
|
}
|
|
|
|
function FilterDifficulty({
|
|
chapters,
|
|
chapter,
|
|
filter,
|
|
setFilter,
|
|
setFilterChapter
|
|
}: {
|
|
chapters: Chapter[];
|
|
chapter: Chapter;
|
|
filter: string;
|
|
setFilter: (filter: string) => void;
|
|
setFilterChapter: (chapter: number) => void;
|
|
}) {
|
|
const [stored, setStored] = useLocalStorage({
|
|
key: 'filter-difficulty',
|
|
initialValue: ''
|
|
});
|
|
|
|
let options = [] as { title: string; value: string }[];
|
|
|
|
options = chapters
|
|
.find((c) => c.id === chapter.id)
|
|
?.puzzles?.map((p) => p.tags)
|
|
.flat()
|
|
.filter((tag) => difficulty.some((d) => tag?.name === d.value))
|
|
.filter((tag, index, self) => self.findIndex((t) => t!.name === tag!.name) === index)
|
|
.map((t) => {
|
|
return { title: t!.name, value: t!.name };
|
|
}) as { title: string; value: string }[];
|
|
|
|
options?.unshift({ title: 'Toutes les difficultés', value: '' });
|
|
|
|
setFilterChapter(chapter.id);
|
|
|
|
function handleChange(event: ChangeEvent<HTMLSelectElement>) {
|
|
setFilter(event.target.value);
|
|
// TODO OPTI
|
|
// @ts-ignore
|
|
setStored(event.target.value);
|
|
}
|
|
|
|
useEffect(() => {
|
|
if (stored) {
|
|
// TODO OPTI
|
|
// @ts-ignore
|
|
setFilter(stored);
|
|
}
|
|
}, [stored]);
|
|
|
|
return (
|
|
<Select className="w-full sm:w-44" options={options} value={filter} onChange={handleChange} />
|
|
);
|
|
}
|
|
|
|
function FilterTags({
|
|
chapters,
|
|
chapter,
|
|
filter,
|
|
setFilter,
|
|
setFilterChapter
|
|
}: {
|
|
chapters: Chapter[];
|
|
chapter: Chapter;
|
|
filter: string;
|
|
setFilter: (filter: string) => void;
|
|
setFilterChapter: (chapter: number) => void;
|
|
}) {
|
|
const [stored, setStored] = useLocalStorage({
|
|
key: 'filter-tags',
|
|
initialValue: ''
|
|
});
|
|
|
|
let options = [] as { title: string; value: string }[];
|
|
|
|
options = chapters
|
|
.find((c) => c.id === chapter.id)
|
|
?.puzzles?.map((p) => p.tags)
|
|
.flat()
|
|
.filter((tag) => !difficulty.some((d) => tag?.name === d.value))
|
|
.filter((tag, index, self) => self.findIndex((t) => t!.name === tag!.name) === index)
|
|
.map((t) => {
|
|
return { title: t!.name, value: t!.name };
|
|
}) as { title: string; value: string }[];
|
|
|
|
options?.unshift({ title: 'Pas encore terminé(s)', value: 'not-completed' });
|
|
options?.unshift({ title: 'Terminé(s)', value: 'completed' });
|
|
options?.unshift({ title: 'Tout les puzzles', value: '' });
|
|
|
|
setFilterChapter(chapter.id);
|
|
|
|
function handleChange(event: ChangeEvent<HTMLSelectElement>) {
|
|
setFilter(event.target.value);
|
|
// TODO OPTI
|
|
// @ts-ignore
|
|
setStored(event.target.value);
|
|
}
|
|
|
|
useEffect(() => {
|
|
if (stored) {
|
|
// TODO OPTI
|
|
// @ts-ignore
|
|
setFilter(stored);
|
|
}
|
|
}, [stored]);
|
|
|
|
return (
|
|
<Select className="w-full sm:w-44" options={options} value={filter} onChange={handleChange} />
|
|
);
|
|
}
|
|
|
|
type GroupData = {
|
|
name?: string;
|
|
chapter?: number;
|
|
puzzle?: number;
|
|
};
|
|
|
|
function GroupForm({ chapter, token }: { chapter: Chapter; token: string }) {
|
|
const [isJoining, setIsJoining] = useState(false);
|
|
|
|
const { data: groups } = useGroups({ token });
|
|
const { mutate } = useSWRConfig();
|
|
|
|
const router = useRouter();
|
|
|
|
const { register, handleSubmit, reset } = useForm<GroupData>({
|
|
defaultValues: {
|
|
name: undefined,
|
|
chapter: chapter.id,
|
|
puzzle: undefined
|
|
}
|
|
});
|
|
|
|
async function onSubmit(data: GroupData) {
|
|
const res = await fetch(
|
|
`${process.env.NEXT_PUBLIC_API_URL}/${isJoining ? 'groupJoin' : 'groupCreate'}`,
|
|
{
|
|
method: 'POST',
|
|
body: JSON.stringify(data),
|
|
headers: {
|
|
Authorization: `Bearer ${token}`
|
|
}
|
|
}
|
|
);
|
|
|
|
if (res.ok) {
|
|
mutate('me');
|
|
// TODO REFACTOR
|
|
router.refresh();
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="flex flex-col">
|
|
<div className="flex justify-between">
|
|
<button
|
|
onClick={() => {
|
|
setIsJoining(false);
|
|
reset();
|
|
}}
|
|
className={cn('rounded-lg p-2 font-semibold', {
|
|
'bg-primary-500': !isJoining
|
|
})}
|
|
>
|
|
Créer un groupe
|
|
</button>
|
|
<button
|
|
onClick={() => {
|
|
setIsJoining(true);
|
|
reset();
|
|
}}
|
|
className={cn('rounded-lg p-2 font-semibold', {
|
|
'bg-primary-500': isJoining
|
|
})}
|
|
>
|
|
Rejoindre un groupe
|
|
</button>
|
|
</div>
|
|
<div className="px-2 py-4">
|
|
<hr className="border-primary-600" />
|
|
</div>
|
|
<form
|
|
className="flex w-full flex-col justify-between"
|
|
onSubmit={handleSubmit(onSubmit)}
|
|
encType="multipart/form-data"
|
|
>
|
|
<div className="flex w-56 flex-col space-y-4">
|
|
{!isJoining ? (
|
|
<>
|
|
<div className="flex flex-col">
|
|
<Input
|
|
className="w-full"
|
|
label="Nom du groupe"
|
|
type="text"
|
|
placeholder="Terre en vue mon capitaine !"
|
|
required
|
|
{...register('name')}
|
|
/>
|
|
</div>
|
|
</>
|
|
) : (
|
|
<>
|
|
<Select
|
|
className="w-full"
|
|
label="Groupes disponibles"
|
|
required
|
|
{...register('name')}
|
|
options={
|
|
groups
|
|
?.filter((group) => group.chapter === chapter.id)
|
|
.map((group) => ({
|
|
title: group.name,
|
|
value: group.name
|
|
})) || []
|
|
}
|
|
/>
|
|
</>
|
|
)}
|
|
<Button kind="brand" type="submit">
|
|
{isJoining ? 'Rejoindre' : 'Créer'}
|
|
</Button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
);
|
|
}
|