-
Notifications
You must be signed in to change notification settings - Fork 0
Allow easy reordering within a tier #13
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
Patertuck
wants to merge
7
commits into
main
Choose a base branch
from
allow-easy-reordering-within-a-tier
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
7 commits
Select commit
Hold shift + click to select a range
45c4240
try 1
Patertuck f4d1b62
fix items put on left side
Patertuck 26892e5
Fix hovering hitbox
Patertuck 17aa9d0
fix different padding for ghost image
Patertuck 4a8cc1f
don't show ghost on each side of hovered image
Patertuck 8164a70
Merge branch 'main' into allow-easy-reordering-within-a-tier
Patertuck cfabed9
Adapt reordering with ghost preview to backend
Patertuck File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -15,37 +15,44 @@ | |
| let uploadedImages: TierImage[] = $state([]); | ||
| let tiers: Tier[] = $state([]); | ||
|
|
||
| let draggedImage: TierImage | null = null; | ||
| let draggedFrom: SourceType | null = null; | ||
| let draggedImage: TierImage | null = $state(null); | ||
| let draggedFrom: SourceType | null = $state(null); | ||
| let draggedIndex: number | null = $state(null); | ||
| let activeDropTarget: { tier: SourceType; index: number } | null = $state(null); | ||
|
|
||
| onMount(async () => { | ||
| tierlistId = data.tierlistId; | ||
|
|
||
| let response = await fetch(`${PUBLIC_API_URL}/tierlist/${tierlistId}`); | ||
| let tierlist = await response.json(); | ||
| tierlistName = tierlist['Name']; | ||
| uploadedImages = tierlist['UnassignedEntries'].map((x) => ({ | ||
| id: x['id'], | ||
| src: `${PUBLIC_API_URL}/images/${x['file_key']}` | ||
| })); | ||
| tiers = tierlist['Tiers'].map((x) => ({ | ||
| id: x['id'], | ||
| name: x['name'], | ||
| entries: x['entries'].map((y) => ({ | ||
| id: y['id'], | ||
| src: `${PUBLIC_API_URL}/images/${y['file_key']}` | ||
| })) | ||
| })); | ||
| try { | ||
| let response = await fetch(`${PUBLIC_API_URL}/tierlist/${tierlistId}`); | ||
| if (response.ok) { | ||
| let tierlist = await response.json(); | ||
| tierlistName = tierlist['Name']; | ||
| uploadedImages = tierlist['UnassignedEntries'].map((x: any) => ({ | ||
| id: x['id'], | ||
| src: `${PUBLIC_API_URL}/images/${x['file_key']}` | ||
| })); | ||
| tiers = tierlist['Tiers'].map((x: any) => ({ | ||
| id: x['id'], | ||
| name: x['name'], | ||
| entries: x['entries'].map((y: any) => ({ | ||
| id: y['id'], | ||
| src: `${PUBLIC_API_URL}/images/${y['file_key']}` | ||
| })) | ||
| })); | ||
| } | ||
| } catch (error) { | ||
| console.error("Failed to load tierlist:", error); | ||
| } | ||
| }); | ||
|
|
||
| async function handleUpload(event: Event) { | ||
| const input = event.target as HTMLInputElement; | ||
| const files = Array.from(input.files ?? []); | ||
| let newImages = []; | ||
|
|
||
| for (const file of files) { | ||
| const formData = new FormData(); | ||
| formData.append('image', file); | ||
|
|
||
| try { | ||
| const response = await fetch(`${PUBLIC_API_URL}/tierlist/${tierlistId}/upload`, { | ||
| method: 'POST', | ||
|
|
@@ -60,12 +67,30 @@ | |
| uploadedImages = [...uploadedImages, ...newImages]; | ||
| } | ||
|
|
||
| async function handleDragStart(image: TierImage, from: SourceType) { | ||
| function handleDragStart(image: TierImage, from: SourceType, index: number) { | ||
| draggedImage = image; | ||
| draggedFrom = from; | ||
| draggedIndex = index; | ||
| } | ||
|
|
||
| function handleDragEnd() { | ||
| draggedImage = null; | ||
| draggedFrom = null; | ||
| draggedIndex = null; | ||
| activeDropTarget = null; | ||
| } | ||
|
|
||
| function handleEnter(tier: SourceType, index: number) { | ||
| activeDropTarget = { tier, index }; | ||
| } | ||
|
|
||
| async function handleDrop(target: SourceType) { | ||
| function handleLeave(tier: SourceType, index: number) { | ||
| if (activeDropTarget?.tier === tier && activeDropTarget?.index === index) { | ||
| activeDropTarget = null; | ||
| } | ||
| } | ||
|
|
||
| async function handleDrop(target: SourceType, insertIndex: number) { | ||
| if (!draggedImage || draggedFrom === null) return; | ||
|
|
||
| if (draggedFrom === 'uploaded') { | ||
|
|
@@ -77,21 +102,24 @@ | |
| } | ||
|
|
||
| if (target === 'uploaded') { | ||
| uploadedImages = [...uploadedImages, draggedImage]; | ||
| const items = [...uploadedImages]; | ||
| items.splice(insertIndex, 0, draggedImage); | ||
| uploadedImages = items; | ||
| } else { | ||
| tiers[target].entries = [...tiers[target].entries, draggedImage]; | ||
| const items = [...tiers[target].entries]; | ||
| items.splice(insertIndex, 0, draggedImage); | ||
| tiers[target].entries = items; | ||
| } | ||
|
|
||
| const response = await fetch(`${PUBLIC_API_URL}/tierlist/${tierlistId}/move`, { | ||
| await fetch(`${PUBLIC_API_URL}/tierlist/${tierlistId}/move`, { | ||
| method: 'POST', | ||
| body: JSON.stringify({ | ||
| TierID: target === 'uploaded' ? null : tiers[target].id, | ||
| ID: draggedImage?.id | ||
| }) | ||
| }); | ||
|
|
||
| draggedImage = null; | ||
| draggedFrom = null; | ||
| handleDragEnd(); | ||
| } | ||
|
|
||
| function allowDrop(event: DragEvent) { | ||
|
|
@@ -106,7 +134,6 @@ | |
|
|
||
| <h1 class="my-6 select-none text-center text-3xl font-bold">Tierlist Creator</h1> | ||
|
|
||
| <!-- Tier rows --> | ||
| <div class="mx-4 flex flex-col divide-y-2 border-y-2"> | ||
| {#each tiers as tier, i} | ||
| <div class="grid h-24 grid-cols-[90px_1fr]"> | ||
|
|
@@ -116,24 +143,87 @@ | |
| > | ||
| {tier.name} | ||
| </div> | ||
|
|
||
| <div | ||
| role="list" | ||
| aria-label="Tier {tier.name} drop zone" | ||
| class="flex items-center gap-2 overflow-x-auto border-l border-black bg-neutral-900 p-2" | ||
| class="flex items-center justify-start gap-2 overflow-x-auto border-l border-black bg-neutral-900 px-2" | ||
| ondragover={allowDrop} | ||
| ondrop={() => handleDrop(i)} | ||
| > | ||
| {#each tier.entries as image} | ||
| <img | ||
| src={image.src} | ||
| alt="tier item" | ||
| class="h-16 w-16 cursor-pointer rounded object-cover" | ||
| draggable="true" | ||
| ondragstart={() => handleDragStart(image, i)} | ||
| /> | ||
| {#if tier.entries.length === 0} | ||
| <div | ||
| class="full-drop-target" | ||
| ondragenter={() => handleEnter(i, 0)} | ||
| ondragleave={() => handleLeave(i, 0)} | ||
| ondragover={allowDrop} | ||
| ondrop={() => handleDrop(i, 0)} | ||
| role="listitem" | ||
| > | ||
| {#if activeDropTarget?.tier === i && activeDropTarget?.index === 0 && draggedImage} | ||
| <img | ||
| src={draggedImage.src} | ||
| alt="ghost preview" | ||
| class="h-16 w-16 rounded object-cover opacity-40 pointer-events-none" | ||
| /> | ||
| {:else} | ||
| <p class="text-gray-500 italic select-none pointer-events-none">Drop items here...</p> | ||
| {/if} | ||
| </div> | ||
| {:else} | ||
| <p class="text-gray-500 italic select-none">Drop items here...</p> | ||
| {/each} | ||
| {#each tier.entries as image, index (image.id.toString())} | ||
| <div | ||
| class="drop-target" | ||
| class:is-dragging={draggedImage !== null} | ||
| class:drop-hover={activeDropTarget?.tier === i && | ||
| activeDropTarget?.index === index && | ||
| (draggedFrom !== i || (draggedIndex !== index && draggedIndex !== index - 1))} | ||
| ondragenter={() => handleEnter(i, index)} | ||
| ondragleave={() => handleLeave(i, index)} | ||
| ondragover={allowDrop} | ||
| ondrop={() => handleDrop(i, index)} | ||
| role="listitem" | ||
| > | ||
| {#if activeDropTarget?.tier === i && activeDropTarget?.index === index && draggedImage && (draggedFrom !== i || (draggedIndex !== index && draggedIndex !== index - 1))} | ||
| <img | ||
| src={draggedImage.src} | ||
| alt="ghost preview" | ||
| class="h-16 w-16 rounded object-cover opacity-40 pointer-events-none" | ||
| /> | ||
| {/if} | ||
| </div> | ||
|
|
||
| <img | ||
| src={image.src} | ||
| alt="tier item" | ||
| class="h-16 w-16 cursor-pointer rounded object-cover" | ||
| draggable="true" | ||
| ondragstart={() => handleDragStart(image, i, index)} | ||
| ondragend={handleDragEnd} | ||
| /> | ||
| {/each} | ||
|
|
||
| <div | ||
| class="drop-target drop-expand" | ||
| class:is-dragging={draggedImage !== null} | ||
| class:drop-hover={activeDropTarget?.tier === i && | ||
| activeDropTarget?.index === tier.entries.length && | ||
| (draggedFrom !== i || | ||
| (draggedIndex !== tier.entries.length && draggedIndex !== tier.entries.length - 1))} | ||
| ondragenter={() => handleEnter(i, tier.entries.length)} | ||
| ondragleave={() => handleLeave(i, tier.entries.length)} | ||
| ondragover={allowDrop} | ||
| ondrop={() => handleDrop(i, tier.entries.length)} | ||
| role="listitem" | ||
| > | ||
| {#if activeDropTarget?.tier === i && activeDropTarget?.index === tier.entries.length && draggedImage && (draggedFrom !== i || (draggedIndex !== tier.entries.length && draggedIndex !== tier.entries.length - 1))} | ||
| <img | ||
| src={draggedImage.src} | ||
| alt="ghost preview" | ||
| class="h-16 w-16 rounded object-cover opacity-40 pointer-events-none" | ||
| /> | ||
| {/if} | ||
| </div> | ||
| {/if} | ||
| </div> | ||
| </div> | ||
| {/each} | ||
|
|
@@ -144,25 +234,66 @@ | |
| aria-label="Uploaded items drop zone" | ||
| class="mx-4 mb-6 mt-8 min-h-[120px] rounded-lg border-2 border-dashed border-gray-400 bg-gray-50 p-4" | ||
| ondragover={allowDrop} | ||
| ondrop={() => handleDrop('uploaded')} | ||
| ondrop={() => handleDrop('uploaded', uploadedImages.length)} | ||
| > | ||
| <h2 class="mb-2 select-none text-lg font-semibold">Uploaded Items</h2> | ||
| <div class="flex flex-wrap gap-4"> | ||
| {#each uploadedImages as image} | ||
| {#each uploadedImages as image, index (image.id.toString())} | ||
| <div | ||
| class="drop-target" | ||
| class:is-dragging={draggedImage !== null} | ||
| class:drop-hover={activeDropTarget?.tier === 'uploaded' && | ||
| activeDropTarget?.index === index && | ||
| (draggedFrom !== 'uploaded' || (draggedIndex !== index && draggedIndex !== index - 1))} | ||
| ondragenter={() => handleEnter('uploaded', index)} | ||
| ondragleave={() => handleLeave('uploaded', index)} | ||
| ondragover={allowDrop} | ||
| ondrop={() => handleDrop('uploaded', index)} | ||
| role="listitem" | ||
| > | ||
| {#if activeDropTarget?.tier === 'uploaded' && activeDropTarget?.index === index && draggedImage && (draggedFrom !== 'uploaded' || (draggedIndex !== index && draggedIndex !== index - 1))} | ||
| <img | ||
| src={draggedImage.src} | ||
| alt="ghost preview" | ||
| class="h-16 w-16 rounded object-cover opacity-40 pointer-events-none" | ||
| /> | ||
| {/if} | ||
| </div> | ||
|
|
||
| <img | ||
| src={image.src} | ||
| alt="uploaded item" | ||
| class="h-16 w-16 cursor-pointer rounded object-cover" | ||
| draggable="true" | ||
| ondragstart={() => handleDragStart(image, 'uploaded')} | ||
| ondragstart={() => handleDragStart(image, 'uploaded', index)} | ||
| ondragend={handleDragEnd} | ||
| /> | ||
| {:else} | ||
| <p class="text-gray-500 italic select-none">No images uploaded yet.</p> | ||
| {/each} | ||
|
|
||
| <div | ||
| class="drop-target" | ||
| class:is-dragging={draggedImage !== null} | ||
| class:drop-hover={activeDropTarget?.tier === 'uploaded' && | ||
| activeDropTarget?.index === uploadedImages.length && | ||
| (draggedFrom !== 'uploaded' || | ||
| (draggedIndex !== uploadedImages.length && draggedIndex !== uploadedImages.length - 1))} | ||
| ondragenter={() => handleEnter('uploaded', uploadedImages.length)} | ||
| ondragleave={() => handleLeave('uploaded', uploadedImages.length)} | ||
| ondragover={allowDrop} | ||
| ondrop={() => handleDrop('uploaded', uploadedImages.length)} | ||
| role="listitem" | ||
| > | ||
| {#if activeDropTarget?.tier === 'uploaded' && activeDropTarget?.index === uploadedImages.length && draggedImage && (draggedFrom !== 'uploaded' || (draggedIndex !== uploadedImages.length && draggedIndex !== uploadedImages.length - 1))} | ||
| <img | ||
| src={draggedImage.src} | ||
| alt="ghost preview" | ||
| class="h-16 w-16 rounded object-cover opacity-40 pointer-events-none" | ||
| /> | ||
| {/if} | ||
| </div> | ||
| </div> | ||
| </div> | ||
|
|
||
| <!-- Upload button --> | ||
| <div class="mt-8 text-center"> | ||
| <label | ||
| class="inline-block cursor-pointer select-none rounded bg-sky-500 px-4 py-2 font-bold text-white hover:bg-sky-700" | ||
|
|
@@ -171,3 +302,66 @@ | |
| <input type="file" accept="image/*" multiple class="hidden" onchange={handleUpload} /> | ||
| </label> | ||
| </div> | ||
|
|
||
| <style> | ||
| .drop-target { | ||
| position: relative; | ||
| width: 4px; | ||
| min-width: 4px; | ||
| height: 64px; | ||
| border: 2px dashed transparent; | ||
| background-color: transparent; | ||
| transition: | ||
| width 0.2s ease, | ||
| background-color 0.2s ease, | ||
| border-color 0.2s ease; | ||
| flex-shrink: 0; | ||
|
|
||
| display: flex; | ||
| align-items: center; | ||
| justify-content: center; | ||
| } | ||
|
|
||
| .drop-target.is-dragging::after { | ||
| content: ''; | ||
| position: absolute; | ||
| top: 0; | ||
| bottom: 0; | ||
| left: 50%; | ||
| transform: translateX(-50%); | ||
| width: 60px; | ||
| height: 100%; | ||
| z-index: 20; | ||
| } | ||
|
|
||
| .drop-target.is-dragging.drop-hover::after { | ||
| width: 120px; | ||
| } | ||
|
|
||
| .drop-hover { | ||
| width: 64px; | ||
| background-color: rgba(74, 222, 128, 0.1); | ||
| border-color: #4ade80; | ||
| } | ||
|
|
||
| .full-drop-target { | ||
| width: 100%; | ||
| height: 100%; | ||
| display: flex; | ||
| align-items: center; | ||
| justify-content: flex-start; | ||
| border: 2px dashed #ccc; | ||
| transition: border-color 0.2s; | ||
| padding-left: 4px; | ||
| } | ||
|
|
||
| .full-drop-target:hover { | ||
| border-color: #4ade80; | ||
| } | ||
|
|
||
| .drop-expand { | ||
| flex-grow: 1; | ||
| justify-content: flex-start; | ||
| padding-left: 4px; | ||
| } | ||
| </style> | ||
|
Comment on lines
+305
to
+367
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Some of this could probably be done with Tailwind classes instead. |
||
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can this condition be a little bit cleaner, maybe by moving it into a function or something?