13 Commits

20 changed files with 419 additions and 154 deletions

BIN
bun.lockb

Binary file not shown.

View File

@@ -1,10 +1,15 @@
<!doctype html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + React + TS</title> <title>NWaifu Tier Maker</title>
<script
defer
src="https://stats.nwaifu.su/script.js"
data-website-id="2167decc-21d6-4f30-bdf3-f00da4b74ee9"
data-domains="tiermaker.nwaifu.su"
></script>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View File

@@ -1,7 +1,7 @@
{ {
"name": "nwtierlist", "name": "nwtierlist",
"private": true, "private": true,
"version": "0.0.0", "version": "0.0.3",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
@@ -11,10 +11,14 @@
}, },
"dependencies": { "dependencies": {
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
"@reduxjs/toolkit": "^2.5.0", "@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.0",
"@mui/icons-material": "^6.3.1",
"@mui/material": "^6.3.1",
"html2canvas": "^1.4.1",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-redux": "^9.2.0" "zustand": "^5.0.3"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.17.0", "@eslint/js": "^9.17.0",
@@ -27,6 +31,7 @@
"eslint-plugin-react-refresh": "^0.4.16", "eslint-plugin-react-refresh": "^0.4.16",
"globals": "^15.14.0", "globals": "^15.14.0",
"postcss": "^8.4.49", "postcss": "^8.4.49",
"sass-embedded": "^1.83.1",
"tailwindcss": "^3.4.17", "tailwindcss": "^3.4.17",
"typescript": "~5.6.2", "typescript": "~5.6.2",
"typescript-eslint": "^8.18.2", "typescript-eslint": "^8.18.2",

View File

@@ -1,40 +1,22 @@
import { useEffect, useRef } from 'react'; import { DndContext, DragEndEvent } from '@dnd-kit/core';
import './App.css'; import { GitHub } from '@mui/icons-material';
import html2canvas from 'html2canvas';
import { useRef } from 'react';
import { useShallow } from 'zustand/shallow';
import './App.scss';
import { EditTierModal } from './components/EditTierModal';
import { TierImage } from './components/Image'; import { TierImage } from './components/Image';
import { Tier, TierProps } from './components/Tier'; import { Tier } from './components/Tier';
import { useAppDispatch, useAppSelector } from './hooks'; import { TierModal } from './components/TierModal';
import { addTierImage } from './store'; import { tierImage } from './dto/tier';
import useStore from './store';
const default_tier_levels: TierProps[] = [ const App = () => {
{ const [images, addTierImage, tierLevels, editTierImage] = useStore(
name: 'S', useShallow(state => [state.images, state.addTierImage, state.tierLevels, state.editTierImage]),
color: 'green', );
textColor: 'black',
},
{
name: 'A',
color: 'yellow',
textColor: 'black',
},
{
name: 'B',
color: 'red',
textColor: 'black',
},
];
interface tierImage { const tierListRef = useRef<HTMLDivElement>(null);
name: string;
url: string;
category: string;
}
function App() {
const images = useAppSelector(state => state.tierImages);
const dispatch = useAppDispatch();
useEffect(() => {
console.log(images);
}, [images]);
const uploadBtn = useRef<HTMLInputElement>(null); const uploadBtn = useRef<HTMLInputElement>(null);
@@ -42,43 +24,81 @@ function App() {
if (uploadBtn.current) uploadBtn.current.click(); if (uploadBtn.current) uploadBtn.current.click();
}; };
const getRandomCategoryName = () => {
const categoryNames = default_tier_levels.flatMap(category => category.name);
return categoryNames[Math.floor(Math.random() * categoryNames.length)];
};
const handleAdd = (images: tierImage[]) => { const handleAdd = (images: tierImage[]) => {
images.forEach(image => { images.forEach(image => {
dispatch(addTierImage(image)); addTierImage(image);
}); });
}; };
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (active.data.current && over && over.data.current) {
const image: tierImage = active.data.current.image;
editTierImage(image, over.data.current.name);
}
};
const handleDownloadImage = async () => {
if (!tierListRef.current) return;
const el = tierListRef.current;
const canvas = await html2canvas(el);
const data = canvas.toDataURL('image/png', 1.0);
const link = document.createElement('a');
if (typeof link.download === 'string') {
link.href = data;
link.download = 'tierlist.png';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
} else {
window.open(data);
}
};
return ( return (
<> <div className='bg-[#2d3436] w-full h-full pb-5'>
<div className='flex flex-col w-full'> <div className='bg-[#2d3436] w-full h-12 flex flex-row justify-center items-center text-[#dfe6e9] text-2xl border-b-2'>
{default_tier_levels.map(tier_level => ( NwTierList
<Tier
color={tier_level.color}
textColor={tier_level.textColor}
name={tier_level.name}
key={tier_level.name}
/>
))}
</div> </div>
<div className='flex flex-row w-full'> <DndContext onDragEnd={handleDragEnd}>
{images <div className='flex flex-col w-full' ref={tierListRef}>
.filter(image => image.category === '') {tierLevels.map(tier_level => (
.map((image, index) => ( <Tier
<TierImage image={image} key={index} /> color={tier_level.color}
textColor={tier_level.textColor}
name={tier_level.name}
id={tier_level.id}
key={tier_level.id ?? tier_level.name}
/>
))} ))}
</div> </div>
<div className='border border-black w-20 text-center cursor-pointer' onClick={clickUpload}> {images.some(image => image.category === '') && (
<span>Upload</span> <div className='flex flex-row w-full gap-1 p-5 flex-wrap'>
{images
.filter(image => image.category === '')
.map((image, index) => (
<TierImage image={image} key={index} />
))}
</div>
)}
<TierModal />
<EditTierModal />
</DndContext>
<div
className='border border-[#dfe6e9] w-32 text-center cursor-pointer text-[#dfe6e9] hover:text-[#2d3436] hover:bg-[#dfe6e9]
hover:rounded-xl hover:scale-110 active:text-[#2d3436] active:bg-[#dfe6e9] active:rounded-xl active:scale-110 rounded-lg mt-5 ms-auto me-auto
transition-all duration-300 ease-in-out'
onClick={clickUpload}
>
<span>Add photo</span>
<input <input
type='file' type='file'
name='imageUpload' name='imageUpload'
accept='.jpeg' accept='.jpeg'
ref={uploadBtn} ref={uploadBtn}
multiple
style={{ display: 'none' }} style={{ display: 'none' }}
onChange={event => { onChange={event => {
console.log(event.target.files); console.log(event.target.files);
@@ -89,7 +109,7 @@ function App() {
upload_images.push({ upload_images.push({
url: url, url: url,
name: event.target.files[i].name.replace('.jpeg', ''), name: event.target.files[i].name.replace('.jpeg', ''),
category: getRandomCategoryName(), category: '',
}); });
} }
handleAdd(upload_images); handleAdd(upload_images);
@@ -97,8 +117,23 @@ function App() {
}} }}
/> />
</div> </div>
</> <div
className='border border-[#dfe6e9] w-32 text-center cursor-pointer text-[#dfe6e9] hover:text-[#2d3436] hover:bg-[#dfe6e9]
hover:rounded-xl hover:scale-110 active:text-[#2d3436] active:bg-[#dfe6e9] active:rounded-xl active:scale-110 rounded-lg mt-5 ms-auto me-auto
transition-all duration-300 ease-in-out'
onClick={handleDownloadImage}
>
<span>Export as PNG</span>
</div>
<div className='w-full text-[#dfe6e9] flex flex-row items-center justify-center md:justify-between mt-5 gap-1 md:pe-5 md:ps-5'>
<span className='text-[#dfe6e9]'>Version 0.0.3</span>
<a href='https://git.nwaifu.su/sergey/NwTierList' target='_blank'>
Source Code <GitHub />
</a>
</div>
</div>
); );
} };
export default App; export default App;

View File

@@ -0,0 +1,57 @@
import { useEffect, useState } from 'react';
import { useShallow } from 'zustand/shallow';
import useStore from '../store';
export const EditTierModal = () => {
const [tierLevel, setTierLevel, editTierLevel] = useStore(
useShallow(state => [state.editingTierLevel, state.setEditingTierLevel, state.editTierLevelName]),
);
const [newName, setNewName] = useState('');
useEffect(() => {
if (tierLevel) {
setNewName(tierLevel.name);
document.body.style.overflow = 'hidden';
} else document.body.style.overflow = 'auto';
return () => {
document.body.style.overflow = 'auto';
};
}, [tierLevel]);
return (
<div
className={`top-0 left-0 w-screen h-screen z-50 bg-black bg-opacity-50 ${tierLevel ? 'fixed' : 'hidden'}`}
onClick={e => {
e.preventDefault();
e.stopPropagation();
setTierLevel(null);
}}
>
<div
className={`absolute top-[50%] left-[50%] translate-x-[-50%] translate-y-[-50%] flex md:w-2/5 w-5/6 h-1/4 bg-[#2d3436] ps-8 pe-8 pt-8 pb-8 z-50 rounded-3xl flex-row gap-4 justify-center items-center text-white border-white border-2`}
onClick={e => {
e.preventDefault();
e.stopPropagation();
}}
>
{tierLevel && (
<div className='flex flex-col gap-4 justify-start items-center'>
<input
type='text'
defaultValue={tierLevel.name}
placeholder='Tier Name'
className='text-black'
onChange={e => setNewName(e.target.value)}
></input>
<button
onClick={() => {
editTierLevel(tierLevel.id as string, newName);
setTierLevel(null);
}}
>
Save
</button>
<button onClick={() => setTierLevel(null)}>Cancel</button>
</div>
)}
</div>
</div>
);
};

10
src/components/Image.scss Normal file
View File

@@ -0,0 +1,10 @@
.anime-container:hover, .anime-container:active {
.anime-name {
opacity: 0;
}
@media(min-width: 768px) {
.anime-name {
opacity: 100;
}
}
}

View File

@@ -1,22 +1,39 @@
import { tierImage } from '../store'; import { useDraggable } from '@dnd-kit/core';
import { useEffect } from 'react';
import { tierImage } from '../dto/tier';
import useStore from '../store';
import './Image.scss';
interface ImageProps { interface ImageProps {
image: tierImage; image: tierImage;
onDragStart?: () => void;
} }
export const TierImage = ({ image, onDragStart }: ImageProps) => { export const TierImage = ({ image }: ImageProps) => {
const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({
id: `draggable:${image.name}`,
data: { image },
});
const setModalOpen = useStore(state => state.setTierLevelModalOpen);
const style = transform
? {
transform: `translate3d(${transform.x}px, ${transform.y}px, 0)`,
zIndex: 99999,
}
: undefined;
useEffect(() => {
setModalOpen(isDragging);
}, [isDragging, setModalOpen]);
return ( return (
<div <div
className='flex flex-wrap justify-center gap-4 mr-1' className='flex flex-wrap justify-center gap-4 mr-1 anime-container touch-manipulation'
onDragStart={() => { {...listeners}
// e.preventDefault(); {...attributes}
if (onDragStart) onDragStart(); ref={setNodeRef}
}} style={style}
> >
<div className={`w-[calc(20rem*.5625)] h-80 relative`}> <div className={`w-[calc(10rem*.5625)] h-40 relative`}>
<img src={image.url} alt={image.name} className='h-full w-full object-cover' /> <img src={image.url} alt={image.name} className='h-full w-full object-cover' />
<div className='w-full bg-slate-500 text-white bg-opacity-70 text-center bottom-0 left-0 absolute'> <div className='w-full bg-slate-500 text-white bg-opacity-70 left-0 top-0 absolute h-full flex justify-center items-center md:opacity-0 anime-name overflow-hidden p-1 text-center transition duration-500 ease-in-out text-sm break-all'>
{image.name} {image.name.length > 30 ? image.name.slice(0, 30) + '...' : image.name}
</div> </div>
</div> </div>
</div> </div>

7
src/components/Tier.scss Normal file
View File

@@ -0,0 +1,7 @@
.tier_name:hover {
@media(min-width: 768px) {
button {
opacity: 1;
}
}
}

View File

@@ -1,18 +1,19 @@
import { useState } from 'react'; import EditIcon from '@mui/icons-material/Edit';
import { useAppDispatch, useAppSelector } from '../hooks'; import { useShallow } from 'zustand/shallow';
import { changeTierImage, tierImage } from '../store'; import useStore from '../store';
import { TierImage } from './Image'; import { TierImage } from './Image';
import './Tier.scss';
export interface TierProps { export interface TierProps {
color: 'red' | 'yellow' | 'green'; color: string;
name: string; name: string;
textColor: 'white' | 'black'; textColor: string;
id?: string;
} }
export const Tier = ({ color, name, textColor }: TierProps) => { export const Tier = ({ color, name, textColor, id }: TierProps) => {
let color_code = ''; let color_code = '';
let text_color_code = ''; let text_color_code = '';
const tierImages = useAppSelector(state => state.tierImages); const [tierImages, setEditingTierLevel] = useStore(useShallow(state => [state.images, state.setEditingTierLevel]));
const dispatch = useAppDispatch();
switch (color) { switch (color) {
case 'green': case 'green':
color_code = '#00b894'; color_code = '#00b894';
@@ -24,46 +25,44 @@ export const Tier = ({ color, name, textColor }: TierProps) => {
color_code = '#ffeaa7'; color_code = '#ffeaa7';
break; break;
default: default:
color_code = '#6c5ce7'; color_code = color;
} }
switch (textColor) { switch (textColor) {
case 'white': case 'white':
text_color_code = '#dfe6e9'; text_color_code = '#dfe6e9';
break; break;
default: case 'black':
text_color_code = '#2d3436'; text_color_code = '#2d3436';
break;
default:
text_color_code = textColor;
} }
const [currentDragImage, setCurrentDragImage] = useState<tierImage | null>(null);
const onDragStart = (image: tierImage) => {
setCurrentDragImage(image);
};
return ( return (
<> <>
<div <div className='w-full min-h-40 bg-[#2d3436] h-auto flex flex-row'>
className='w-full min-h-64 bg-[#2d3436] h-auto flex flex-row'
onDragEnd={() => {
// e.preventDefault();
if (currentDragImage) dispatch(changeTierImage(currentDragImage));
setCurrentDragImage(null);
}}
>
<div <div
className='w-52 h-80 flex items-center justify-center' className='w-24 min-h-40 flex items-center justify-center relative tier_name text-center text-xs'
style={{ style={{
color: text_color_code, color: text_color_code,
backgroundColor: color_code, backgroundColor: color_code,
}} }}
> >
<button
className='absolute right-[5%] top-[5%] md:opacity-0 transition duration-250 ease-in-out'
onClick={() => {
setEditingTierLevel({ name, color, textColor, id });
}}
>
<EditIcon />
</button>
<p>{name}</p> <p>{name}</p>
</div> </div>
<div className='image-container flex flex-row gap-1'> <div className='image-container flex flex-row flex-wrap justify-start w-full h-auto gap-1'>
{tierImages {tierImages
.filter(image => image.category === name) .filter(image => image.category === name)
.map((image, index) => ( .map((image, index) => (
<TierImage image={image} key={index} onDragStart={() => onDragStart(image)} /> <TierImage image={image} key={index} />
))} ))}
</div> </div>
</div> </div>

View File

@@ -0,0 +1,28 @@
import { useEffect } from 'react';
import { useShallow } from 'zustand/shallow';
import useStore from '../store';
import { TierModalCategory } from './TierModalCategory';
export const TierModal = () => {
const [modalOpen, tierLevels] = useStore(useShallow(state => [state.tierLevelsModalOpen, state.tierLevels]));
useEffect(() => {
if (modalOpen) document.body.style.overflow = 'hidden';
else document.body.style.overflow = 'auto';
return () => {
document.body.style.overflow = 'auto';
};
}, [modalOpen]);
return (
<div
className={`fixed top-[50%] left-[50%] translate-x-[-50%] translate-y-[-50%] ${
modalOpen ? 'flex' : 'hidden'
} min-w-[300px] md:w-[70vw] md:h-1/4 h-[90vh] bg-[#1e2324] ps-8 pe-8 pt-8 pb-8 z-50 rounded-3xl flex-col md:flex-row gap-4 justify-center items-center text-white border-white border-2`}
>
{tierLevels.map(tier_level => (
<TierModalCategory name={tier_level.name} color={tier_level.color} textColor={tier_level.textColor} />
))}
</div>
);
};

View File

@@ -0,0 +1,47 @@
import { useDroppable } from '@dnd-kit/core';
import { darkenRgbColor } from '../tools/colors';
interface TierModalCategoryProps {
color: string;
textColor: string;
name: string;
}
export const TierModalCategory = ({ color, textColor, name }: TierModalCategoryProps) => {
const { setNodeRef, isOver } = useDroppable({
id: 'droppable:' + name,
data: { name },
});
const darkRGB = darkenRgbColor(color, 40);
const getTextColorCode = (textColor: string) => {
let text_color_code = '';
switch (textColor) {
case 'white':
text_color_code = '#dfe6e9';
break;
case 'black':
text_color_code = '#2d3436';
break;
default:
text_color_code = textColor;
}
return text_color_code;
};
const style = {
backgroundColor: isOver ? darkRGB : color,
color: getTextColorCode(textColor),
};
return (
<div
className={`md:w-32 md:min-h-32 w-16 min-h-16 flex justify-center items-center font-bold text-sm md:text-xl rounded-lg text-center`}
style={style}
key={name}
ref={setNodeRef}
>
{name}
</div>
);
};

6
src/dto/tier.ts Normal file
View File

@@ -0,0 +1,6 @@
export interface tierImage {
name: string;
url: string;
category: string;
id?: string;
}

View File

@@ -1,5 +0,0 @@
import { useDispatch, useSelector } from 'react-redux';
import { AppDispatch, RootState } from './store';
export const useAppDispatch = useDispatch.withTypes<AppDispatch>();
export const useAppSelector = useSelector.withTypes<RootState>();

View File

@@ -1,3 +1,7 @@
@tailwind base; @tailwind base;
@tailwind components; @tailwind components;
@tailwind utilities; @tailwind utilities;
:root {
background-color: #2d3436;
}

View File

@@ -1,14 +1,10 @@
import { StrictMode } from 'react'; import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client'; import { createRoot } from 'react-dom/client';
import { Provider } from 'react-redux';
import App from './App.tsx'; import App from './App.tsx';
import './index.css'; import './index.scss';
import store from './store.ts';
createRoot(document.getElementById('root')!).render( createRoot(document.getElementById('root')!).render(
<StrictMode> <StrictMode>
<Provider store={store}> <App />
<App />
</Provider>
</StrictMode>, </StrictMode>,
); );

View File

@@ -1,39 +1,62 @@
import { configureStore, createSlice, PayloadAction } from '@reduxjs/toolkit'; import { create } from 'zustand';
import { devtools } from 'zustand/middleware';
import { TierProps } from './components/Tier';
import { tierImage } from './dto/tier';
import { generateTierLevel } from './tools/genarators';
export interface tierImage { const defaultTierLevels: TierProps[] = [
name: string; generateTierLevel('S', 'rgb(255, 127, 127)', 'black'),
url: string; generateTierLevel('A', 'rgb(255, 191, 127)', 'black'),
category: string; generateTierLevel('B', 'rgb(255, 223, 127)', 'black'),
generateTierLevel('C', 'rgb(255, 255, 127)', 'black'),
generateTierLevel('D', 'rgb(191, 255, 127)', 'black'),
generateTierLevel('E', 'rgb(127, 255, 127)', 'black'),
generateTierLevel('F', 'rgb(127, 255, 255)', 'black'),
generateTierLevel("Haven't watched yet", 'rgb(127, 191, 255)', 'black'),
];
interface tierStore {
images: tierImage[];
addTierImage: (image: tierImage) => void;
editTierImage: (image: tierImage, tier: string) => void;
removeTierImage: (image: tierImage) => void;
tierLevelsModalOpen: boolean;
setTierLevelModalOpen: (open: boolean) => void;
tierLevels: TierProps[];
addTierLevel: (name: string, color: string, textColor: string) => void;
editTierLevelName: (id: string, name: string) => void;
editingTierLevel: TierProps | null;
setEditingTierLevel: (level: TierProps | null) => void;
} }
const tierImages = createSlice({ const useStore = create(
name: 'images', devtools<tierStore>(
initialState: [] as tierImage[], set => ({
reducers: { images: [] as tierImage[],
addTierImage: (state, action: PayloadAction<tierImage>) => { addTierImage: (image: tierImage) => set(state => ({ images: [...state.images, image] })),
state.push(action.payload); editTierImage: (image: tierImage, tier: string) =>
}, set(state => ({
removeTierImage: (state, action: PayloadAction<string>) => { images:
return state.filter(image => image.name !== action.payload); image.category === tier
}, ? state.images
changeTierImage: (state, action: PayloadAction<tierImage>) => { : state.images.map(i => (i.name === image.name ? { ...i, category: tier } : i)),
return state.map(image => { })),
if (image.name == action.payload.name) return action.payload; removeTierImage: (image: tierImage) => {
return image; URL.revokeObjectURL(image.url);
}); set(state => ({ images: state.images.filter(i => i.name !== image.name) }));
}, },
}, tierLevelsModalOpen: false,
}); setTierLevelModalOpen: (open: boolean) => set({ tierLevelsModalOpen: open }),
tierLevels: defaultTierLevels,
addTierLevel: (name: string, color: string, textColor: string) =>
set(state => ({ tierLevels: [...state.tierLevels, generateTierLevel(name, color, textColor)] })),
editTierLevelName: (id: string, name: string) =>
set(state => ({ tierLevels: state.tierLevels.map(t => (t.id === id ? { ...t, name } : t)) })),
editingTierLevel: null,
setEditingTierLevel: (level: TierProps | null) => set({ editingTierLevel: level }),
}),
{ name: 'tierStore' },
),
);
export const { addTierImage, removeTierImage, changeTierImage } = tierImages.actions; export default useStore;
const store = configureStore({
reducer: {
tierImages: tierImages.reducer,
},
});
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
export default store;

18
src/tools/colors.ts Normal file
View File

@@ -0,0 +1,18 @@
export const darkenRgbColor = (rgbColor: string, percentage: number): string => {
const rgbRegex = /^rgb\((\d{1,3}),\s*(\d{1,3}),\s*(\d{1,3})\)$/;
const match = rgbColor.match(rgbRegex);
if (!match || percentage < 0 || percentage > 100) {
return rgbColor;
}
const r = parseInt(match[1]);
const g = parseInt(match[2]);
const b = parseInt(match[3]);
const factor = percentage / 100;
const newR = Math.max(0, Math.round(r * (1 - factor)));
const newG = Math.max(0, Math.round(g * (1 - factor)));
const newB = Math.max(0, Math.round(b * (1 - factor)));
return `rgb(${newR}, ${newG}, ${newB})`;
};

10
src/tools/genarators.ts Normal file
View File

@@ -0,0 +1,10 @@
import { TierProps } from '../components/Tier';
export const generateTierLevel = (name: string, color: string, textColor: string): TierProps => {
return {
name,
color,
textColor,
id: crypto.randomUUID(),
};
};

View File

@@ -1,7 +1,10 @@
import { defineConfig } from 'vite' import react from '@vitejs/plugin-react-swc';
import react from '@vitejs/plugin-react-swc' import { defineConfig } from 'vite';
// https://vite.dev/config/ // https://vite.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [react()],
}) define: {
'process.env': {},
},
});