diff --git a/components/DragDropTierList.tsx b/components/DragDropTierList.tsx index fe2ca16..666e19c 100644 --- a/components/DragDropTierList.tsx +++ b/components/DragDropTierList.tsx @@ -1,262 +1,390 @@ -import React, {useCallback, useEffect, useRef, memo} from 'react'; -import {DragDropContext, Droppable, Draggable, DropResult} from '@hello-pangea/dnd'; -import {toast} from "sonner"; -import {v4 as uuidv4} from 'uuid'; -import {useTierContext} from "@/contexts/TierContext"; +import React, { useCallback, useEffect, useRef, memo, useState } from "react"; +import { + DragDropContext, + Droppable, + Draggable, + DropResult +} from "@hello-pangea/dnd"; +import { toast } from "sonner"; +import { v4 as uuidv4 } from "uuid"; +import { useTierContext } from "@/contexts/TierContext"; import Item from "@/models/Item"; import Tier from "@/models/Tier"; -import EditableLabel from '../components/EditableLabel'; -import RowHandle from '../components/RowHandle'; +import EditableLabel from "../components/EditableLabel"; +import RowHandle from "../components/RowHandle"; import ItemTile from "@/components/ItemTile"; -import {getTierGradient} from "@/lib/utils"; +import { getTierGradient } from "@/lib/utils"; interface DragDropTierListProps { - tiers: Tier[]; - onTiersUpdate: (updatedTiers: Tier[]) => void; + tiers: Tier[]; + onItemsCreate: (newItems: Item[]) => void; + onTiersUpdate: (updatedTiers: Tier[]) => void; } interface DeletedItemInfo { - item: Item; - tierId: string; - id: string; + item: Item; + tierId: string; + id: string; } -const reorder = (list: T[], startIndex: number, endIndex: number): T[] => { - const result = Array.from(list); - const [removed] = result.splice(startIndex, 1); - result.splice(endIndex, 0, removed); - return result; +const reorder = (list: T[], startIndex: number, endIndex: number): T[] => { + const result = Array.from(list); + const [removed] = result.splice(startIndex, 1); + result.splice(endIndex, 0, removed); + return result; }; const TierItems = memo<{ - tier: Tier; - showLabels: boolean; - onDeleteItem: (itemId: string) => void; -}>(({tier, showLabels, onDeleteItem}) => { - return ( - - {(provided, snapshot) => ( -
- {tier.items.map((item, itemIndex) => ( - - {(provided, snapshot) => ( + tier: Tier; + showLabels: boolean; + onDeleteItem: (itemId: string) => void; +}>(({ tier, showLabels, onDeleteItem }) => { + return ( + + {(provided, snapshot) => (
+ {tier.items.map((item, itemIndex) => ( + + {(provided, snapshot) => ( +
- + style={{ + ...provided.draggableProps.style, + transition: snapshot.isDropAnimating + ? "all 0.3s cubic-bezier(0.2, 0, 0, 1)" + : provided.draggableProps.style + ?.transition + }} + > + +
+ )} +
+ ))} + {provided.placeholder}
- )} -
- ))} - {provided.placeholder} -
- )} -
- ); + )} + + ); }); -TierItems.displayName = 'TierItems'; +TierItems.displayName = "TierItems"; const TierRow = memo<{ - tier: Tier; - index: number; - tiersLength: number; - showLabels: boolean; - onSaveLabel: (index: number, newText: string) => void; - onDeleteItem: (itemId: string) => void; -}>(({tier, index, tiersLength, showLabels, onSaveLabel, onDeleteItem}) => { - const labelPosition = tier.labelPosition || 'left'; - const tierGradient = getTierGradient(index, tiersLength); + tiers: Tier[]; + onTiersUpdate: (updatedTiers: Tier[]) => void; + onItemsCreate: (newItems: Item[]) => void; + tier: Tier; + index: number; + tiersLength: number; + showLabels: boolean; + onSaveLabel: (index: number, newText: string) => void; + onDeleteItem: (itemId: string) => void; +}>( + ({ + tier, + index, + tiersLength, + showLabels, + onSaveLabel, + onDeleteItem, + onItemsCreate + }) => { + const labelPosition = tier.labelPosition || "left"; + const tierGradient = getTierGradient(index, tiersLength); + const generateId = () => Math.random().toString(36).slice(2, 11); + const { tierCortex } = useTierContext(); - return ( - - {(provided, snapshot) => ( -
) => { + const items = e.clipboardData.items; + for (const index in items) { + const item = items[index]; + if (item.kind === "file") { + const blob = item.getAsFile(); + const reader = new FileReader(); + const newItemId = generateId(); + reader.onload = function (e) { + const pastedItem = { + id: newItemId, + content: `Pasted Image${newItemId}`, + imageUrl: e.target?.result as string, + onDelete: onDeleteItem, + showLabel: showLabels + }; + tierCortex.addCustomItems([pastedItem]); + onItemsCreate([pastedItem]); + }; + if (blob) { + reader.readAsDataURL(blob); + } + } + } + }; + + return ( + + {(provided, snapshot) => ( +
onPasteItem(e)} + ref={provided.innerRef} + {...provided.draggableProps} + className={` border rounded-md min-w-full sm:min-w-[500px] md:min-w-[600px] lg:min-w-[800px] min-h-20 flex items-center - ${snapshot.isDragging ? 'shadow-lg ring-2' : ''} + ${snapshot.isDragging ? "shadow-lg ring-2" : ""} `} - style={{ - ...provided.draggableProps.style, - background: tierGradient, - transition: snapshot.isDropAnimating - ? 'all 0.3s cubic-bezier(0.2, 0, 0, 1)' - : provided.draggableProps.style?.transition, - }} - > -
- {labelPosition === 'top' && index !== tiersLength - 1 && ( - onSaveLabel(index, newText)} - className="p-1" - contentClassName="tracking-wide text-xl font-semibold" - as="h2" - /> - )} -
- {(labelPosition === 'left' || labelPosition === 'right') && ( -
- onSaveLabel(index, newText)} - className="p-1 flex flex-1 min-w-16 max-w-20 sm:max-w-full justify-center text-center" - contentClassName="tracking-wide text-xl font-semibold" - as="h2" - /> -
- )} - -
-
- -
- )} -
- ); -}); -TierRow.displayName = 'TierRow'; + style={{ + ...provided.draggableProps.style, + background: tierGradient, + transition: snapshot.isDropAnimating + ? "all 0.3s cubic-bezier(0.2, 0, 0, 1)" + : provided.draggableProps.style?.transition + }} + > +
+ {labelPosition === "top" && + index !== tiersLength - 1 && ( + + onSaveLabel(index, newText) + } + className="p-1" + contentClassName="tracking-wide text-xl font-semibold" + as="h2" + /> + )} +
+ {(labelPosition === "left" || + labelPosition === "right") && ( +
+ + onSaveLabel(index, newText) + } + className="p-1 flex flex-1 min-w-16 max-w-20 sm:max-w-full justify-center text-center" + contentClassName="tracking-wide text-xl font-semibold" + as="h2" + /> +
+ )} + +
+
+ +
+ )} +
+ ); + } +); +TierRow.displayName = "TierRow"; -const DragDropTierList: React.FC = ({tiers, onTiersUpdate}) => { - const {showLabels} = useTierContext(); - const tiersRef = useRef(tiers); - const deletedItemsRef = useRef([]); +const DragDropTierList: React.FC = ({ + tiers, + onTiersUpdate, + onItemsCreate +}) => { + const { showLabels } = useTierContext(); + const tiersRef = useRef(tiers); + const deletedItemsRef = useRef([]); - useEffect(() => { - tiersRef.current = tiers; - }, [tiers]); + useEffect(() => { + tiersRef.current = tiers; + }, [tiers]); - const onDragEnd = useCallback((result: DropResult) => { - const {source, destination, type} = result; + const onDragEnd = useCallback( + (result: DropResult) => { + const { source, destination, type } = result; - if (!destination) return; + if (!destination) return; - let newTiers: Tier[]; + let newTiers: Tier[]; - if (type === 'TIER') { - newTiers = reorder(tiers, source.index, destination.index); - newTiers = newTiers.map((tier, index) => ({ - ...tier, - name: tiers[index].name - })); - } else { - newTiers = [...tiers]; - const sourceTier = newTiers.find(t => t.id === source.droppableId)!; - const destTier = newTiers.find(t => t.id === destination.droppableId)!; + if (type === "TIER") { + newTiers = reorder(tiers, source.index, destination.index); + newTiers = newTiers.map((tier, index) => ({ + ...tier, + name: tiers[index].name + })); + } else { + newTiers = [...tiers]; + const sourceTier = newTiers.find( + (t) => t.id === source.droppableId + )!; + const destTier = newTiers.find( + (t) => t.id === destination.droppableId + )!; - if (sourceTier.id === destTier.id) { - sourceTier.items = reorder(sourceTier.items, source.index, destination.index); - } else { - const [movedItem] = sourceTier.items.splice(source.index, 1); - destTier.items.splice(destination.index, 0, movedItem); - } - } + if (sourceTier.id === destTier.id) { + sourceTier.items = reorder( + sourceTier.items, + source.index, + destination.index + ); + } else { + const [movedItem] = sourceTier.items.splice( + source.index, + 1 + ); + destTier.items.splice(destination.index, 0, movedItem); + } + } - onTiersUpdate(newTiers); - }, [tiers, onTiersUpdate]); + onTiersUpdate(newTiers); + }, + [tiers, onTiersUpdate] + ); - const handleUndoDelete = useCallback((uniqueId: string) => { - const deletedItemIndex = deletedItemsRef.current.findIndex((item) => item.id === uniqueId); - if (deletedItemIndex === -1) return; + const handleUndoDelete = useCallback( + (uniqueId: string) => { + const deletedItemIndex = deletedItemsRef.current.findIndex( + (item) => item.id === uniqueId + ); + if (deletedItemIndex === -1) return; - const [deletedItem] = deletedItemsRef.current.splice(deletedItemIndex, 1); + const [deletedItem] = deletedItemsRef.current.splice( + deletedItemIndex, + 1 + ); - const newTiers = tiersRef.current.map((tier) => { - if (tier.id === deletedItem.tierId) { - return {...tier, items: [...tier.items, deletedItem.item]}; - } - return tier; - }); + const newTiers = tiersRef.current.map((tier) => { + if (tier.id === deletedItem.tierId) { + return { + ...tier, + items: [...tier.items, deletedItem.item] + }; + } + return tier; + }); - onTiersUpdate(newTiers); + onTiersUpdate(newTiers); - toast('Item restored', { - description: `${deletedItem.item.content} has been restored.`, - }); - }, [onTiersUpdate]); + toast("Item restored", { + description: `${deletedItem.item.content} has been restored.` + }); + }, + [onTiersUpdate] + ); - const handleDeleteItem = useCallback((itemId: string) => { - let deletedItemInfo: DeletedItemInfo | undefined; + const handleDeleteItem = useCallback( + (itemId: string) => { + let deletedItemInfo: DeletedItemInfo | undefined; - const newTiers = tiers.map(tier => { - const itemIndex = tier.items.findIndex(item => item.id === itemId); - if (itemIndex !== -1) { - const [deletedItem] = tier.items.splice(itemIndex, 1); - deletedItemInfo = { - item: deletedItem, - tierId: tier.id, - id: uuidv4() - }; - return {...tier, items: [...tier.items]}; - } - return tier; - }); + const newTiers = tiers.map((tier) => { + const itemIndex = tier.items.findIndex( + (item) => item.id === itemId + ); + if (itemIndex !== -1) { + const [deletedItem] = tier.items.splice(itemIndex, 1); + deletedItemInfo = { + item: deletedItem, + tierId: tier.id, + id: uuidv4() + }; + return { ...tier, items: [...tier.items] }; + } + return tier; + }); - if (deletedItemInfo) { - deletedItemsRef.current.push(deletedItemInfo); - onTiersUpdate(newTiers); + if (deletedItemInfo) { + deletedItemsRef.current.push(deletedItemInfo); + onTiersUpdate(newTiers); - toast('Item deleted', { - description: `${deletedItemInfo.item.content} was removed.`, - action: { - label: 'Undo', - onClick: () => handleUndoDelete(deletedItemInfo!.id), + toast("Item deleted", { + description: `${deletedItemInfo.item.content} was removed.`, + action: { + label: "Undo", + onClick: () => handleUndoDelete(deletedItemInfo!.id) + } + }); + } }, - }); - } - }, [tiers, onTiersUpdate, handleUndoDelete]); + [tiers, onTiersUpdate, handleUndoDelete] + ); - const handleSaveLabel = useCallback((index: number, newText: string) => { - const newTiers = tiers.map((tier, i) => (i === index ? {...tier, name: newText} : tier)); - onTiersUpdate(newTiers); - }, [tiers, onTiersUpdate]); + const handleSaveLabel = useCallback( + (index: number, newText: string) => { + const newTiers = tiers.map((tier, i) => + i === index ? { ...tier, name: newText } : tier + ); + onTiersUpdate(newTiers); + }, + [tiers, onTiersUpdate] + ); - return ( - - - {(provided, snapshot) => ( -
- {tiers.map((tier, index) => ( - - ))} - {provided.placeholder} -
- )} -
-
- ); + return ( + + + {(provided, snapshot) => ( +
+ {tiers.map((tier, index) => ( + + ))} + {provided.placeholder} +
+ )} +
+
+ ); }; export default DragDropTierList; diff --git a/components/TierListManager.tsx b/components/TierListManager.tsx index 04902e0..07a7cd3 100644 --- a/components/TierListManager.tsx +++ b/components/TierListManager.tsx @@ -1,301 +1,385 @@ -'use client'; - -import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; -import DragDropTierList from './DragDropTierList'; -import {TierContext} from '@/contexts/TierContext'; +"use client"; + +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState +} from "react"; +import DragDropTierList from "./DragDropTierList"; +import { TierContext } from "@/contexts/TierContext"; import TierTemplateSelector from "@/components/TierTemplateSelector"; import EditableLabel from "@/components/EditableLabel"; import ItemManager from "@/components/ItemManager"; -import Tier, {LabelPosition} from "@/models/Tier"; +import Tier, { LabelPosition } from "@/models/Tier"; import Item from "@/models/Item"; import ShareButton from "@/components/ShareButton"; -import {TierCortex, TierWithSimplifiedItems} from "@/lib/TierCortex"; -import {usePathname, useRouter, useSearchParams} from "next/navigation"; -import {ItemSet} from "@/models/ItemSet"; -import {Alert, AlertDescription, AlertTitle} from "@/components/ui/alert"; -import {CameraIcon, QuestionMarkCircledIcon} from "@radix-ui/react-icons"; -import {GiCardAceSpades, GiLightBackpack, GiScrollQuill} from "react-icons/gi"; -import {Tooltip, TooltipContent, TooltipProvider, TooltipTrigger} from "@/components/ui/tooltip"; +import { TierCortex, TierWithSimplifiedItems } from "@/lib/TierCortex"; +import { usePathname, useRouter, useSearchParams } from "next/navigation"; +import { ItemSet } from "@/models/ItemSet"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { CameraIcon, QuestionMarkCircledIcon } from "@radix-ui/react-icons"; +import { + GiCardAceSpades, + GiLightBackpack, + GiScrollQuill +} from "react-icons/gi"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger +} from "@/components/ui/tooltip"; interface TierListManagerProps { - initialItemSet?: ItemSet; - initialState?: string; - title?: string; + initialItemSet?: ItemSet; + initialState?: string; + title?: string; } -const TierListManager: React.FC = ({initialItemSet, initialState, title}) => { - const router = useRouter(); - const pathname = usePathname(); - const searchParams = useSearchParams(); - - const tierCortex = useMemo(() => new TierCortex(), []); - const [tiers, setTiers] = useState(() => - tierCortex.getInitialTiers(initialState, initialItemSet) - ); - const [name, setName] = useState(title ?? ''); - const [showLabels, setShowLabels] = useState(true); - const [labelPosition, setLabelPosition] = useState(tiers[0].labelPosition ?? 'left'); - const previousTiersRef = useRef(tiers); - - const [urlLength, setUrlLength] = useState(0); - - useEffect(() => { - const state = searchParams.get('state'); - if (!state) return; - - const decodedState = tierCortex.decodeTierStateFromURL(state); - if (!decodedState) return; - - // Update name if present - if (decodedState.title) { - setName(decodedState.title); - } - - // Updated label positions - const updatedTiers = decodedState.tiers.map(tier => ({ - ...tier, - labelPosition - })); - - setTiers(updatedTiers); - setUrlLength(pathname.length + state.length + 7); // 7 is the length of "?state=" - }, [pathname.length, searchParams, tierCortex, labelPosition]); - - const handleTiersUpdate = useCallback((updatedTiers: Tier[]) => { - previousTiersRef.current = updatedTiers; - setTiers(updatedTiers); - - const optimizedTiersForEncoding: TierWithSimplifiedItems[] = updatedTiers.map(tier => ({ - ...tier, - items: tier.items.map(item => ({ - i: item.id, - c: tierCortex.isCustomItem(item.id) ? item.content : undefined - })) - })); - - router.push(`${pathname}?state=${TierCortex.encodeTierStateForURL(name, optimizedTiersForEncoding)}`, {scroll: false}); - }, [router, pathname, name, tierCortex]); - - useEffect(() => { - if (name !== title) { - handleTiersUpdate(tiers); - } - }, [handleTiersUpdate, name, tiers, title]) - - const handleItemsCreate = useCallback((newItems: Item[]) => { - const updatedTiers = [...tiers]; - const lastTier = updatedTiers[updatedTiers.length - 1]; - - const uniqueNewItems = newItems.filter(newItem => - !updatedTiers.some(tier => - tier.items.some(item => item.id === newItem.id || item.content === newItem.content) - ) +const TierListManager: React.FC = ({ + initialItemSet, + initialState, + title +}) => { + const router = useRouter(); + const pathname = usePathname(); + const searchParams = useSearchParams(); + + const tierCortex = useMemo(() => new TierCortex(), []); + const [tiers, setTiers] = useState(() => + tierCortex.getInitialTiers(initialState, initialItemSet) + ); + const [name, setName] = useState(title ?? ""); + const [showLabels, setShowLabels] = useState(true); + const [labelPosition, setLabelPosition] = useState( + tiers[0].labelPosition ?? "left" + ); + const previousTiersRef = useRef(tiers); + + const [urlLength, setUrlLength] = useState(0); + + useEffect(() => { + const state = searchParams.get("state"); + if (!state) return; + + const decodedState = tierCortex.decodeTierStateFromURL(state); + if (!decodedState) return; + + // Update name if present + if (decodedState.title) { + setName(decodedState.title); + } + + // Updated label positions + const updatedTiers = decodedState.tiers.map((tier) => ({ + ...tier, + labelPosition + })); + + setTiers(updatedTiers); + setUrlLength(pathname.length + state.length + 7); // 7 is the length of "?state=" + }, [pathname.length, searchParams, tierCortex, labelPosition]); + + const handleTiersUpdate = useCallback( + (updatedTiers: Tier[]) => { + previousTiersRef.current = updatedTiers; + setTiers(updatedTiers); + + const optimizedTiersForEncoding: TierWithSimplifiedItems[] = + updatedTiers.map((tier) => ({ + ...tier, + items: tier.items.map((item) => ({ + i: item.id, + c: tierCortex.isCustomItem(item.id) + ? item.content + : undefined + })) + })); + + router.push( + `${pathname}?state=${TierCortex.encodeTierStateForURL( + name, + optimizedTiersForEncoding + )}`, + { scroll: false } + ); + }, + [router, pathname, name, tierCortex] + ); + + useEffect(() => { + if (name !== title) { + handleTiersUpdate(tiers); + } + }, [handleTiersUpdate, name, tiers, title]); + + const handleItemsCreate = useCallback( + (newItems: Item[]) => { + const updatedTiers = [...tiers]; + const lastTier = updatedTiers[updatedTiers.length - 1]; + + const uniqueNewItems = newItems.filter( + (newItem) => + !updatedTiers.some((tier) => + tier.items.some( + (item) => + item.id === newItem.id || + item.content === newItem.content + ) + ) + ); + + lastTier.items = [...lastTier.items, ...uniqueNewItems]; + + handleTiersUpdate(updatedTiers); + }, + [tiers, handleTiersUpdate] + ); + + const handleUndoItemsCreate = useCallback( + (itemIds: string[]) => { + const updatedTiers = tiers.map((tier) => ({ + ...tier, + items: tier.items.filter((item) => !itemIds.includes(item.id)) + })); + handleTiersUpdate(updatedTiers); + }, + [tiers, handleTiersUpdate] + ); + + const toggleLabels = useCallback(() => { + setShowLabels((prev) => !prev); + }, []); + + const handleLabelPositionChange = useCallback( + (newPosition: LabelPosition) => { + setLabelPosition(newPosition); + const updatedTiers = tiers.map((tier) => ({ + ...tier, + labelPosition: newPosition + })); + handleTiersUpdate(updatedTiers); + }, + [tiers, handleTiersUpdate] ); - lastTier.items = [...lastTier.items, ...uniqueNewItems]; - - handleTiersUpdate(updatedTiers); - }, [tiers, handleTiersUpdate]); - - const handleUndoItemsCreate = useCallback((itemIds: string[]) => { - const updatedTiers = tiers.map(tier => ({ - ...tier, - items: tier.items.filter(item => !itemIds.includes(item.id)) - })); - handleTiersUpdate(updatedTiers); - }, [tiers, handleTiersUpdate]); - - const toggleLabels = useCallback(() => { - setShowLabels(prev => !prev); - }, []); - - const handleLabelPositionChange = useCallback((newPosition: LabelPosition) => { - setLabelPosition(newPosition); - const updatedTiers = tiers.map(tier => ({...tier, labelPosition: newPosition})); - handleTiersUpdate(updatedTiers); - }, [tiers, handleTiersUpdate]); - - const handleTemplateChange = useCallback((newTemplate: Tier[]) => { - // Create a map of all existing items with their tier IDs - const allItemsMap = new Map( - tiers.flatMap(tier => - tier.items.map(item => [item.id, {item, tierId: tier.id}]) - ) + const handleTemplateChange = useCallback( + (newTemplate: Tier[]) => { + // Create a map of all existing items with their tier IDs + const allItemsMap = new Map( + tiers.flatMap((tier) => + tier.items.map((item) => [ + item.id, + { item, tierId: tier.id } + ]) + ) + ); + + // Create new tiers based on the template + const newTiers: Tier[] = newTemplate.map((templateTier) => ({ + ...templateTier, + items: [] as Item[], + labelPosition + })); + + // Distribute existing items to new tiers + allItemsMap.forEach(({ item, tierId }) => { + const targetTier = + newTiers.find((tier) => tier.id === tierId) || + newTiers.find((tier) => tier.id === "uncategorized"); + + if (targetTier) { + targetTier.items.push(item); + } else { + // If no matching tier and no uncategorized tier, create one + const uncategorizedTier: Tier = { + id: "uncategorized", + name: "", + items: [item], + labelPosition: labelPosition + }; + newTiers.push(uncategorizedTier); + } + }); + + handleTiersUpdate(newTiers); + }, + [tiers, labelPosition, handleTiersUpdate] ); - // Create new tiers based on the template - const newTiers: Tier[] = newTemplate.map(templateTier => ({ - ...templateTier, - items: [] as Item[], - labelPosition - })); - - // Distribute existing items to new tiers - allItemsMap.forEach(({item, tierId}) => { - const targetTier = newTiers.find(tier => tier.id === tierId) || - newTiers.find(tier => tier.id === 'uncategorized'); - - if (targetTier) { - targetTier.items.push(item); - } else { - // If no matching tier and no uncategorized tier, create one - const uncategorizedTier: Tier = { - id: 'uncategorized', - name: '', - items: [item], - labelPosition: labelPosition - }; - newTiers.push(uncategorizedTier); - } - }); - - handleTiersUpdate(newTiers); - }, [tiers, labelPosition, handleTiersUpdate]); - - const resetItems = useCallback(() => { - previousTiersRef.current = tiers; - - const allItems = tiers.flatMap(tier => tier.items) - .sort((a, b) => a.content.localeCompare(b.content)); - - const resetTiers = tiers.map(tier => ({...tier, items: [] as Item[]})); - - let uncategorizedTier = resetTiers[resetTiers.length - 1]; - if (!uncategorizedTier || uncategorizedTier.id !== 'uncategorized') { - uncategorizedTier = { - id: 'uncategorized', - name: '', - items: [], - labelPosition: labelPosition - }; - resetTiers.push(uncategorizedTier); - } - - uncategorizedTier.items = allItems; - - handleTiersUpdate(resetTiers); - }, [tiers, labelPosition, handleTiersUpdate]); - - const deleteAllItems = useCallback(() => { - previousTiersRef.current = tiers; - const emptyTiers = tiers.map(tier => ({...tier, items: []})); - handleTiersUpdate(emptyTiers); - }, [tiers, handleTiersUpdate]); - - const undoReset = useCallback(() => { - handleTiersUpdate(previousTiersRef.current); - }, [handleTiersUpdate]); - - const undoDelete = useCallback(() => { - handleTiersUpdate(previousTiersRef.current); - }, [handleTiersUpdate]); - - const contextValue = { - tierCortex, - tiers, - labelPosition, - showLabels, - setLabelPosition: handleLabelPositionChange, - toggleLabels, - onTemplateChange: handleTemplateChange - }; - - const UrlLengthWarning: React.FC<{ urlLength: number }> = ({urlLength}) => { - if (urlLength <= 2000) return null; + const resetItems = useCallback(() => { + previousTiersRef.current = tiers; + + const allItems = tiers + .flatMap((tier) => tier.items) + .sort((a, b) => a.content.localeCompare(b.content)); + + const resetTiers = tiers.map((tier) => ({ + ...tier, + items: [] as Item[] + })); + + let uncategorizedTier = resetTiers[resetTiers.length - 1]; + if (!uncategorizedTier || uncategorizedTier.id !== "uncategorized") { + uncategorizedTier = { + id: "uncategorized", + name: "", + items: [], + labelPosition: labelPosition + }; + resetTiers.push(uncategorizedTier); + } + + uncategorizedTier.items = allItems; + + handleTiersUpdate(resetTiers); + }, [tiers, labelPosition, handleTiersUpdate]); + + const deleteAllItems = useCallback(() => { + previousTiersRef.current = tiers; + const emptyTiers = tiers.map((tier) => ({ ...tier, items: [] })); + handleTiersUpdate(emptyTiers); + }, [tiers, handleTiersUpdate]); + + const undoReset = useCallback(() => { + handleTiersUpdate(previousTiersRef.current); + }, [handleTiersUpdate]); + + const undoDelete = useCallback(() => { + handleTiersUpdate(previousTiersRef.current); + }, [handleTiersUpdate]); + + const contextValue = { + tierCortex, + tiers, + labelPosition, + showLabels, + setLabelPosition: handleLabelPositionChange, + toggleLabels, + onTemplateChange: handleTemplateChange + }; + + const UrlLengthWarning: React.FC<{ urlLength: number }> = ({ + urlLength + }) => { + if (urlLength <= 2000) return null; + + return ( + + + + SIDE-QUEST UNLOCKED: THE VANISHING LINK PREVIEW + + + + + + + The current URL length exceeds 2000 characters. + This may cause link previews to fail and may + also be incompatible with older browsers. + Consider removing some items or sharing your + tier list preview using an image, or an + alternative method. Functionality of the tier + list itself is not affected. + + + + + +

+ Hark, brave adventurer! A most peculiar affliction has + befallen your URL. Tis' stretched yon' mortal + limits, surpassing 2000 characters in length! +

+

+ This arcane enchantment of elongation threatens to + render link previews invisible to the denizens of + Twitter and Discord. Even the ancient browsers of yore + may falter before its might. Fear not, for your tier + list remains unscathed, its power undiminished. +

+

+ Brave adventurer, the choice is yours. Your tier list + shall endure regardless, but should you wish to embark + on this quest for the denizens, pray, consider the paths + our scouts have identified: +

+
    +
  • + {" "} + Use your Inventory Management skills (Remove some + items) +
  • +
  • + Use{" "} + "Freeze Frame"! (Share as picture) +
  • +
  • + {" "} + Activate your Secret Sharing Technique (Will you + activate your Trump Card?) +
  • +
+
+ "In the face of adversity, true heroes forge their + own paths." - Sage of the Endless URL +
+
+
+ ); + }; return ( - - - - SIDE-QUEST UNLOCKED: THE VANISHING LINK PREVIEW - - - - - - - The current URL length exceeds 2000 characters. - This may cause link previews to fail and may also be incompatible with older browsers. Consider removing - some items or sharing your tier list preview using an image, or an alternative method. - Functionality of the tier list itself is not affected. - - - - - -

- Hark, brave adventurer! A most peculiar affliction has befallen your URL. Tis' stretched - yon' mortal limits, surpassing 2000 characters in length! -

-

- This arcane enchantment of elongation threatens to render link previews invisible to the denizens of - Twitter and Discord. - Even the ancient browsers of yore may falter before its might. Fear not, for your tier list remains - unscathed, its power undiminished. -

-

- Brave adventurer, the choice is yours. Your tier list shall endure regardless, but should you wish to embark - on this quest for the denizens, pray, consider the paths our scouts have identified: -

-
    -
  • - Use your Inventory Management skills (Remove some - items) -
  • -
  • - Use "Freeze Frame"! (Share as picture) -
  • -
  • - Activate your Secret Sharing Technique (Will you - activate your Trump Card?) -
  • -
-
- "In the face of adversity, true heroes forge their own paths." - Sage of the Endless URL -
-
-
+ +
+ +
+
+
+ + + +
+
+

+ Drag to reorder. +

+

+ Right-click on any item to delete. +

+

+ Long-press on any item to delete. +

+ +
+
+ +
); - }; - - return ( - -
- -
-
-
- - - -
-
-

- Drag to reorder. -

-

- Right-click on any item to delete. -

-

- Long-press on any item to delete. -

- -
-
- -
- ); }; export default TierListManager; diff --git a/lib/TierCortex.ts b/lib/TierCortex.ts index cc67a79..9448425 100644 --- a/lib/TierCortex.ts +++ b/lib/TierCortex.ts @@ -1,265 +1,294 @@ -import Tier, {DEFAULT_TIER_TEMPLATE} from "@/models/Tier"; +import Tier, { DEFAULT_TIER_TEMPLATE } from "@/models/Tier"; import Item from "@/models/Item"; import imagesetConfig from "@/imageset.config.json"; import ImageSetConfig from "@/models/ImageSet"; -import {ItemSet} from "@/models/ItemSet"; -import LZString from 'lz-string'; +import { ItemSet } from "@/models/ItemSet"; +import LZString from "lz-string"; -const CUSTOM_ITEMS_KEY = 'customItems'; +const CUSTOM_ITEMS_KEY = "customItems"; const typedImageSetConfig = imagesetConfig as ImageSetConfig; interface EncodedState { - title?: string; - tiers: SimplifiedTier[]; + title?: string; + tiers: SimplifiedTier[]; } interface SimplifiedTier { - i: string; // id - n: string; // name - t: SimplifiedItem[]; // items + i: string; // id + n: string; // name + t: SimplifiedItem[]; // items } interface SimplifiedItem { - i: string; // id - c?: string; // content (only for custom items) + i: string; // id + c?: string; // content (only for custom items) } -export interface TierWithSimplifiedItems extends Omit { - items: SimplifiedItem[]; +export interface TierWithSimplifiedItems extends Omit { + items: SimplifiedItem[]; } interface StoredCustomItem { - i: string; // id - c: string; // content - d: string; // imageData + i: string; // id + c: string; // content + d: string; // imageData } export interface PackageItemLookup { - [key: string]: Item; + [key: string]: Item; } const OG_TIER_GRADIENTS = [ - 'linear-gradient(to right, #d21203, #c40a01)', - 'linear-gradient(to right, #ee1e1e, #f84a44)', - 'linear-gradient(to right, #dca414, #dc991d)', - 'linear-gradient(to right, #c98b17, #e8910f)', - 'linear-gradient(to right, #7ad21f, #1aea1d)', - 'linear-gradient(to right, #72b231, #0cd30e)', - 'linear-gradient(to right, #4d7e15, #01b004)', + "linear-gradient(to right, #d21203, #c40a01)", + "linear-gradient(to right, #ee1e1e, #f84a44)", + "linear-gradient(to right, #dca414, #dc991d)", + "linear-gradient(to right, #c98b17, #e8910f)", + "linear-gradient(to right, #7ad21f, #1aea1d)", + "linear-gradient(to right, #72b231, #0cd30e)", + "linear-gradient(to right, #4d7e15, #01b004)" ]; export class TierCortex { - // this class isn't concerned about managing UI states, so tiers are managed externally - private readonly packageItemLookup: PackageItemLookup; - private customItemsMap: Map; - private readonly isServer: boolean; - private baseUrl: string; - - - constructor() { - this.isServer = typeof window === 'undefined'; - this.baseUrl = this.isServer ? process.env.VERCEL_PROJECT_PRODUCTION_URL - ? `https://${process.env.VERCEL_PROJECT_PRODUCTION_URL}` - : 'http://localhost:3000' - : window.location.origin; - this.packageItemLookup = this.initializePackageItemLookup(); - this.customItemsMap = new Map(); - - if (!this.isServer) { - this.initializeCustomItemsMap(); + // this class isn't concerned about managing UI states, so tiers are managed externally + private readonly packageItemLookup: PackageItemLookup; + private customItemsMap: Map; + private readonly isServer: boolean; + private baseUrl: string; + + constructor() { + this.isServer = typeof window === "undefined"; + this.baseUrl = this.isServer + ? process.env.VERCEL_PROJECT_PRODUCTION_URL + ? `https://${process.env.VERCEL_PROJECT_PRODUCTION_URL}` + : "http://localhost:3000" + : window.location.origin; + this.packageItemLookup = this.initializePackageItemLookup(); + this.customItemsMap = new Map(); + + if (!this.isServer) { + this.initializeCustomItemsMap(); + } } - } - - public static encodeTierStateForURL(title: string | undefined, tiers: TierWithSimplifiedItems[]): string { - const simplifiedTiers: SimplifiedTier[] = tiers.map(tier => ({ - i: tier.id, - n: tier.name, - t: tier.items - })); - const encodedState: EncodedState = { - tiers: simplifiedTiers - }; - if (title) { - encodedState.title = title; + + public static encodeTierStateForURL( + title: string | undefined, + tiers: TierWithSimplifiedItems[] + ): string { + const simplifiedTiers: SimplifiedTier[] = tiers.map((tier) => ({ + i: tier.id, + n: tier.name, + t: tier.items + })); + const encodedState: EncodedState = { + tiers: simplifiedTiers + }; + if (title) { + encodedState.title = title; + } + const jsonString = JSON.stringify(encodedState); + return LZString.compressToEncodedURIComponent(jsonString); + } + + public isCustomItem(itemId: string): boolean { + return !this.packageItemLookup.hasOwnProperty(itemId); } - const jsonString = JSON.stringify(encodedState); - return LZString.compressToEncodedURIComponent(jsonString); - } - - public isCustomItem(itemId: string): boolean { - return !this.packageItemLookup.hasOwnProperty(itemId); - } - - public getOgTierGradient(index: number, tiersLength: number): string { - if (index === tiersLength - 1) return 'linear-gradient(to right, #f0f0f0, #f0f0f0)'; - return OG_TIER_GRADIENTS[index % OG_TIER_GRADIENTS.length]; - } - - public decodeTierStateFromURL(encodedState: string): { title?: string, tiers: Tier[] } | null { - try { - const jsonString = LZString.decompressFromEncodedURIComponent(encodedState); - if (!jsonString) throw new Error('Failed to decompress state'); - - const parsed = JSON.parse(jsonString); - - let title: string | undefined; - let simplifiedTiers: SimplifiedTier[]; - - if (Array.isArray(parsed)) { - // Old format: array of tiers - simplifiedTiers = parsed; - } else if (typeof parsed === 'object' && Array.isArray(parsed.tiers)) { - // New format: object with title and tiers - title = parsed.title; - simplifiedTiers = parsed.tiers; - } else { - throw new Error('Invalid state format'); - } - - const tiers = simplifiedTiers.map(simplifiedTier => ({ - id: simplifiedTier.i, - name: simplifiedTier.n, - items: simplifiedTier.t.map(item => this.resolveItem(item.i, item.c)) - })); - - return {title, tiers}; - } catch (error) { - console.error('Failed to decode tier state from URL:', error); - return null; + + public getOgTierGradient(index: number, tiersLength: number): string { + if (index === tiersLength - 1) + return "linear-gradient(to right, #f0f0f0, #f0f0f0)"; + return OG_TIER_GRADIENTS[index % OG_TIER_GRADIENTS.length]; + } + + public decodeTierStateFromURL( + encodedState: string + ): { title?: string; tiers: Tier[] } | null { + try { + const jsonString = + LZString.decompressFromEncodedURIComponent(encodedState); + if (!jsonString) throw new Error("Failed to decompress state"); + + const parsed = JSON.parse(jsonString); + + let title: string | undefined; + let simplifiedTiers: SimplifiedTier[]; + + if (Array.isArray(parsed)) { + // Old format: array of tiers + simplifiedTiers = parsed; + } else if ( + typeof parsed === "object" && + Array.isArray(parsed.tiers) + ) { + // New format: object with title and tiers + title = parsed.title; + simplifiedTiers = parsed.tiers; + } else { + throw new Error("Invalid state format"); + } + + const tiers = simplifiedTiers.map((simplifiedTier) => ({ + id: simplifiedTier.i, + name: simplifiedTier.n, + items: simplifiedTier.t.map((item) => + this.resolveItem(item.i, item.c) + ) + })); + + return { title, tiers }; + } catch (error) { + console.error("Failed to decode tier state from URL:", error); + return null; + } } - } - public addCustomItems(items: Item[]): void { - if (this.isServer) return; + public addCustomItems(items: Item[]): void { + if (this.isServer) return; - const newItems = items.map(item => ({ - i: item.id, - c: item.content, - d: item.imageUrl ?? '' - })); + const newItems = items.map((item) => ({ + i: item.id, + c: item.content, + d: item.imageUrl ?? "" + })); - newItems.forEach(item => this.customItemsMap.set(item.i, item)); + newItems.forEach((item) => this.customItemsMap.set(item.i, item)); - const allItems = Array.from(this.customItemsMap.values()); - localStorage.setItem(CUSTOM_ITEMS_KEY, JSON.stringify(allItems)); - } + const allItems = Array.from(this.customItemsMap.values()); + localStorage.setItem(CUSTOM_ITEMS_KEY, JSON.stringify(allItems)); + } - public getInitialTiers(initialState: string | undefined, initialItemSet: ItemSet | undefined): Tier[] { - if (initialState) { - const decodedState = this.decodeTierStateFromURL(initialState); - if (decodedState) return decodedState.tiers; + public getInitialTiers( + initialState: string | undefined, + initialItemSet: ItemSet | undefined + ): Tier[] { + if (initialState) { + const decodedState = this.decodeTierStateFromURL(initialState); + if (decodedState) return decodedState.tiers; + } + + const initialTiers: Tier[] = JSON.parse( + JSON.stringify(DEFAULT_TIER_TEMPLATE) + ); + + if (initialItemSet) { + const lastTierIndex = initialTiers.length - 1; + + initialTiers[lastTierIndex].items = initialItemSet.images.map( + (filename) => { + const itemId = `${initialItemSet.packageName}-${filename}`; + return ( + this.packageItemLookup[itemId] || + this.createPlaceholderItem(itemId, filename) + ); + } + ); + } + + return initialTiers; } - const initialTiers: Tier[] = JSON.parse(JSON.stringify(DEFAULT_TIER_TEMPLATE)); + public getOgSafeImageUrl(url: string): string { + // Convert WebP to PNG + if (url.toLowerCase().endsWith(".webp")) { + url = url.substring(0, url.length - 5) + ".png"; + } - if (initialItemSet) { - const lastTierIndex = initialTiers.length - 1; + // Make URL absolute if it's not already + if (!url.startsWith("http")) { + url = new URL(url, this.baseUrl).toString(); + } - initialTiers[lastTierIndex].items = initialItemSet.images.map((filename) => { - const itemId = `${initialItemSet.packageName}-${filename}`; - return this.packageItemLookup[itemId] || this.createPlaceholderItem(itemId, filename); - }); + return url; } - return initialTiers; - } + public getOgSafeItem(item: Item): Item { + return { + ...item, + imageUrl: this.getOgSafeImageUrl(item.imageUrl ?? "") + }; + } - public getOgSafeImageUrl(url: string): string { - // Convert WebP to PNG - if (url.toLowerCase().endsWith('.webp')) { - url = url.substring(0, url.length - 5) + '.png'; + public resolveItemsFromPackage( + packageName: string, + filenames: string[] + ): Item[] { + return filenames.map((filename) => { + const itemId = `${packageName}-${filename}`; + return this.resolveItem(itemId, filename.split(".")[0]); + }); } - // Make URL absolute if it's not already - if (!url.startsWith('http')) { - url = new URL(url, this.baseUrl).toString(); + public getAssetUrl(path: string): string { + return this.getAbsoluteUrl(path); } - return url; - } - - public getOgSafeItem(item: Item): Item { - return { - ...item, - imageUrl: this.getOgSafeImageUrl(item.imageUrl ?? '') - }; - } - - public resolveItemsFromPackage(packageName: string, filenames: string[]): Item[] { - return filenames.map(filename => { - const itemId = `${packageName}-${filename}`; - return this.resolveItem(itemId, filename.split('.')[0]); - }); - } - - public getAssetUrl(path: string): string { - return this.getAbsoluteUrl(path); - } - - private initializePackageItemLookup(): PackageItemLookup { - let packageItemLookup: PackageItemLookup = {}; - for (const [packageName, packageData] of Object.entries(typedImageSetConfig.packages)) { - for (const image of packageData.images) { - const id = `${packageName}-${image.filename}`; - packageItemLookup[id] = { - id, - content: image.label || image.filename.split('.')[0], - imageUrl: `/images/${packageName}/${image.filename}`, - }; - } + private initializePackageItemLookup(): PackageItemLookup { + let packageItemLookup: PackageItemLookup = {}; + for (const [packageName, packageData] of Object.entries( + typedImageSetConfig.packages + )) { + for (const image of packageData.images) { + const id = `${packageName}-${image.filename}`; + packageItemLookup[id] = { + id, + content: image.label || image.filename.split(".")[0], + imageUrl: `/images/${packageName}/${image.filename}` + }; + } + } + return packageItemLookup; } - return packageItemLookup; - } - private initializeCustomItemsMap(): void { - if (this.customItemsMap.size === 0) { - const customItems = this.loadCustomItemsFromLocalStorage(); - customItems.forEach(item => this.customItemsMap.set(item.i, item)); + private initializeCustomItemsMap(): void { + if (this.customItemsMap.size === 0) { + const customItems = this.loadCustomItemsFromLocalStorage(); + customItems.forEach((item) => + this.customItemsMap.set(item.i, item) + ); + } } - } - private resolveItem(itemId: string, content?: string): Item { - const packageItem = this.packageItemLookup[itemId]; - if (packageItem) return packageItem; + private resolveItem(itemId: string, content?: string): Item { + const packageItem = this.packageItemLookup[itemId]; + if (packageItem) return packageItem; + + if (!this.isServer) { + const customItem = this.customItemsMap.get(itemId); + if (customItem) { + return { + id: customItem.i, + content: content || customItem.c, + imageUrl: customItem.d + }; + } + } + + return this.createPlaceholderItem(itemId, content || ""); + } - if (!this.isServer) { - const customItem = this.customItemsMap.get(itemId); - if (customItem) { + private createPlaceholderItem(itemId: string, content: string): Item { return { - id: customItem.i, - content: content || customItem.c, - imageUrl: customItem.d, + id: itemId, + content, + imageUrl: this.getAbsoluteUrl("/placeholder-image.png") }; - } } - return this.createPlaceholderItem(itemId, content || ''); - } - - private createPlaceholderItem(itemId: string, content: string): Item { - return { - id: itemId, - content, - imageUrl: this.getAbsoluteUrl('/placeholder-image.png'), - }; - } - - private loadCustomItemsFromLocalStorage(): StoredCustomItem[] { - if (this.isServer) return []; - - const storedItems = localStorage.getItem(CUSTOM_ITEMS_KEY); - if (!storedItems) return []; - try { - return JSON.parse(storedItems); - } catch (error) { - console.error('Failed to parse stored custom items:', error); - return []; + private loadCustomItemsFromLocalStorage(): StoredCustomItem[] { + if (this.isServer) return []; + + const storedItems = localStorage.getItem(CUSTOM_ITEMS_KEY); + if (!storedItems) return []; + try { + return JSON.parse(storedItems); + } catch (error) { + console.error("Failed to parse stored custom items:", error); + return []; + } } - } - private getAbsoluteUrl(path: string): string { - return new URL(path, this.baseUrl).toString(); - } + private getAbsoluteUrl(path: string): string { + return new URL(path, this.baseUrl).toString(); + } } export default TierCortex;