add collection artifact feature (cards, rows, pane, route)
- CollectionArtifactCard for grid view - CollectionArtifactRow for list view - CollectionArtifactPane for sidebar details - artifacts collection page with filters and infinite scroll - getArtifactImage util - update collection layout for artifacts tab
This commit is contained in:
parent
3a41adc4f2
commit
52660f3fb1
8 changed files with 899 additions and 5 deletions
|
|
@ -40,9 +40,10 @@ export interface ArtifactListParams {
|
||||||
export interface CollectionArtifactListParams {
|
export interface CollectionArtifactListParams {
|
||||||
page?: number
|
page?: number
|
||||||
limit?: number
|
limit?: number
|
||||||
element?: number
|
element?: number | number[]
|
||||||
artifactId?: string
|
artifactId?: string
|
||||||
proficiency?: number
|
proficiency?: number
|
||||||
|
rarity?: 'standard' | 'quirk'
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
147
src/lib/components/collection/CollectionArtifactCard.svelte
Normal file
147
src/lib/components/collection/CollectionArtifactCard.svelte
Normal file
|
|
@ -0,0 +1,147 @@
|
||||||
|
<svelte:options runes={true} />
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import type { CollectionArtifact } from '$lib/types/api/artifact'
|
||||||
|
import { getArtifactImage } from '$lib/utils/images'
|
||||||
|
import ElementLabel from '$lib/components/labels/ElementLabel.svelte'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
artifact: CollectionArtifact
|
||||||
|
onClick?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
let { artifact, onClick }: Props = $props()
|
||||||
|
|
||||||
|
const imageUrl = $derived(getArtifactImage(artifact.artifact?.granblueId))
|
||||||
|
|
||||||
|
const displayName = $derived.by(() => {
|
||||||
|
const name = artifact.artifact?.name
|
||||||
|
if (!name) return '—'
|
||||||
|
if (typeof name === 'string') return name
|
||||||
|
return name.en || name.ja || '—'
|
||||||
|
})
|
||||||
|
|
||||||
|
// Get grade letter for badge
|
||||||
|
const gradeLetter = $derived(artifact.grade?.letter)
|
||||||
|
const gradeClass = $derived(gradeLetter?.toLowerCase() ?? 'none')
|
||||||
|
|
||||||
|
// Is this a quirk artifact?
|
||||||
|
const isQuirk = $derived(artifact.artifact?.rarity === 'quirk')
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<button type="button" class="artifact-card" onclick={onClick}>
|
||||||
|
<div class="card-image">
|
||||||
|
<img class="artifact-image" src={imageUrl} alt={displayName} loading="lazy" />
|
||||||
|
{#if gradeLetter}
|
||||||
|
<span class="grade-badge grade-{gradeClass}">{gradeLetter}</span>
|
||||||
|
{/if}
|
||||||
|
{#if isQuirk}
|
||||||
|
<span class="quirk-badge">Q</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="card-info">
|
||||||
|
<ElementLabel element={artifact.element} size="small" />
|
||||||
|
<span class="level">Lv.{artifact.level}</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
@use '$src/themes/spacing' as *;
|
||||||
|
@use '$src/themes/typography' as *;
|
||||||
|
@use '$src/themes/layout' as *;
|
||||||
|
|
||||||
|
.artifact-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: $unit-half;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus-visible {
|
||||||
|
outline: 2px solid var(--accent-color, #3366ff);
|
||||||
|
outline-offset: 2px;
|
||||||
|
border-radius: $item-corner;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-image {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 1 / 1;
|
||||||
|
border-radius: $item-corner;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--card-bg, #f5f5f5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.artifact-image {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
border-radius: $item-corner;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grade-badge {
|
||||||
|
position: absolute;
|
||||||
|
top: $unit-fourth;
|
||||||
|
right: $unit-fourth;
|
||||||
|
font-size: $font-small;
|
||||||
|
font-weight: $bold;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
line-height: 1;
|
||||||
|
|
||||||
|
&.grade-s {
|
||||||
|
background: linear-gradient(135deg, #ffd700, #ffb347);
|
||||||
|
color: #6b4c00;
|
||||||
|
}
|
||||||
|
&.grade-a {
|
||||||
|
background: linear-gradient(135deg, #4ade80, #22c55e);
|
||||||
|
color: #14532d;
|
||||||
|
}
|
||||||
|
&.grade-b {
|
||||||
|
background: linear-gradient(135deg, #60a5fa, #3b82f6);
|
||||||
|
color: #1e3a5f;
|
||||||
|
}
|
||||||
|
&.grade-c,
|
||||||
|
&.grade-d {
|
||||||
|
background: var(--grey-80, #e9e9e9);
|
||||||
|
color: var(--grey-40, #444);
|
||||||
|
}
|
||||||
|
&.grade-f {
|
||||||
|
background: linear-gradient(135deg, #f87171, #ef4444);
|
||||||
|
color: #7f1d1d;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.quirk-badge {
|
||||||
|
position: absolute;
|
||||||
|
top: $unit-fourth;
|
||||||
|
left: $unit-fourth;
|
||||||
|
font-size: $font-small;
|
||||||
|
font-weight: $bold;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
line-height: 1;
|
||||||
|
background: var(--purple-50, #b6b2fc);
|
||||||
|
color: var(--purple-10, #4f3c79);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: $unit-half;
|
||||||
|
}
|
||||||
|
|
||||||
|
.level {
|
||||||
|
font-size: $font-small;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
157
src/lib/components/collection/CollectionArtifactPane.svelte
Normal file
157
src/lib/components/collection/CollectionArtifactPane.svelte
Normal file
|
|
@ -0,0 +1,157 @@
|
||||||
|
<svelte:options runes={true} />
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import type { CollectionArtifact } from '$lib/types/api/artifact'
|
||||||
|
import { useUpdateCollectionArtifact, useDeleteCollectionArtifact } from '$lib/api/mutations/artifact.mutations'
|
||||||
|
import { getArtifactImage } from '$lib/utils/images'
|
||||||
|
import ArtifactEditPane from '$lib/components/artifact/ArtifactEditPane.svelte'
|
||||||
|
import Button from '$lib/components/ui/Button.svelte'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
artifact: CollectionArtifact
|
||||||
|
isOwner?: boolean
|
||||||
|
onClose?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
let { artifact, isOwner = false, onClose }: Props = $props()
|
||||||
|
|
||||||
|
// Mutations
|
||||||
|
const updateMutation = useUpdateCollectionArtifact()
|
||||||
|
const deleteMutation = useDeleteCollectionArtifact()
|
||||||
|
|
||||||
|
// Image and name for header
|
||||||
|
const imageUrl = $derived(getArtifactImage(artifact.artifact?.granblueId))
|
||||||
|
const displayName = $derived.by(() => {
|
||||||
|
const name = artifact.artifact?.name
|
||||||
|
if (!name) return '—'
|
||||||
|
if (typeof name === 'string') return name
|
||||||
|
return name.en || name.ja || '—'
|
||||||
|
})
|
||||||
|
|
||||||
|
// Handle updates from ArtifactEditPane
|
||||||
|
function handleUpdate(updates: Partial<CollectionArtifact>) {
|
||||||
|
updateMutation.mutate({
|
||||||
|
id: artifact.id,
|
||||||
|
input: {
|
||||||
|
element: updates.element,
|
||||||
|
level: updates.level,
|
||||||
|
proficiency: updates.proficiency,
|
||||||
|
skill1: updates.skills?.[0] ?? undefined,
|
||||||
|
skill2: updates.skills?.[1] ?? undefined,
|
||||||
|
skill3: updates.skills?.[2] ?? undefined,
|
||||||
|
skill4: updates.skills?.[3] ?? undefined
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle delete
|
||||||
|
function handleDelete() {
|
||||||
|
if (confirm('Are you sure you want to delete this artifact from your collection?')) {
|
||||||
|
deleteMutation.mutate(artifact.id, {
|
||||||
|
onSuccess: () => {
|
||||||
|
onClose?.()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="artifact-pane">
|
||||||
|
<!-- Header with image -->
|
||||||
|
<div class="pane-header">
|
||||||
|
<div class="artifact-image">
|
||||||
|
<img src={imageUrl} alt={displayName} />
|
||||||
|
</div>
|
||||||
|
<h2 class="artifact-name">{displayName}</h2>
|
||||||
|
{#if artifact.nickname}
|
||||||
|
<p class="artifact-nickname">"{artifact.nickname}"</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Edit pane content -->
|
||||||
|
<div class="pane-content">
|
||||||
|
<ArtifactEditPane
|
||||||
|
{artifact}
|
||||||
|
onUpdate={isOwner ? handleUpdate : undefined}
|
||||||
|
disabled={!isOwner}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Actions footer (owner only) -->
|
||||||
|
{#if isOwner}
|
||||||
|
<div class="pane-footer">
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
size="small"
|
||||||
|
onclick={handleDelete}
|
||||||
|
disabled={deleteMutation.isPending}
|
||||||
|
>
|
||||||
|
{deleteMutation.isPending ? 'Deleting...' : 'Delete'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
@use '$src/themes/spacing' as *;
|
||||||
|
@use '$src/themes/typography' as *;
|
||||||
|
@use '$src/themes/layout' as *;
|
||||||
|
|
||||||
|
.artifact-pane {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pane-header {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
padding: $unit-2x;
|
||||||
|
border-bottom: 1px solid var(--border-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.artifact-image {
|
||||||
|
width: 80px;
|
||||||
|
height: 80px;
|
||||||
|
border-radius: $item-corner;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--card-bg, #f5f5f5);
|
||||||
|
margin-bottom: $unit;
|
||||||
|
|
||||||
|
img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.artifact-name {
|
||||||
|
margin: 0;
|
||||||
|
font-size: $font-large;
|
||||||
|
font-weight: $bold;
|
||||||
|
color: var(--text-primary);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.artifact-nickname {
|
||||||
|
margin: $unit-half 0 0;
|
||||||
|
font-size: $font-small;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-style: italic;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pane-content {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pane-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding: $unit-2x;
|
||||||
|
border-top: 1px solid var(--border-secondary);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
244
src/lib/components/collection/CollectionArtifactRow.svelte
Normal file
244
src/lib/components/collection/CollectionArtifactRow.svelte
Normal file
|
|
@ -0,0 +1,244 @@
|
||||||
|
<svelte:options runes={true} />
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import type { CollectionArtifact } from '$lib/types/api/artifact'
|
||||||
|
import { getArtifactImage } from '$lib/utils/images'
|
||||||
|
import ElementLabel from '$lib/components/labels/ElementLabel.svelte'
|
||||||
|
import ProficiencyLabel from '$lib/components/labels/ProficiencyLabel.svelte'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
artifact: CollectionArtifact
|
||||||
|
onClick?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
let { artifact, onClick }: Props = $props()
|
||||||
|
|
||||||
|
const imageUrl = $derived(getArtifactImage(artifact.artifact?.granblueId))
|
||||||
|
|
||||||
|
const displayName = $derived.by(() => {
|
||||||
|
const name = artifact.artifact?.name
|
||||||
|
if (!name) return '—'
|
||||||
|
if (typeof name === 'string') return name
|
||||||
|
return name.en || name.ja || '—'
|
||||||
|
})
|
||||||
|
|
||||||
|
// Get grade display
|
||||||
|
const gradeLetter = $derived(artifact.grade?.letter)
|
||||||
|
const gradeClass = $derived(gradeLetter?.toLowerCase() ?? 'none')
|
||||||
|
|
||||||
|
// Is this a quirk artifact?
|
||||||
|
const isQuirk = $derived(artifact.artifact?.rarity === 'quirk')
|
||||||
|
|
||||||
|
// Proficiency - from artifact for standard, from instance for quirk
|
||||||
|
const proficiency = $derived(
|
||||||
|
(isQuirk ? artifact.proficiency : artifact.artifact?.proficiency) ?? undefined
|
||||||
|
)
|
||||||
|
|
||||||
|
// Skills summary (count of non-null skills)
|
||||||
|
const skillCount = $derived(
|
||||||
|
artifact.skills?.filter((s) => s !== null).length ?? 0
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<button type="button" class="artifact-row" onclick={onClick}>
|
||||||
|
<div class="core-info">
|
||||||
|
<div class="thumbnail">
|
||||||
|
<img src={imageUrl} alt={displayName} loading="lazy" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="name-cell">
|
||||||
|
<span class="name">{displayName}</span>
|
||||||
|
{#if artifact.nickname}
|
||||||
|
<span class="nickname">"{artifact.nickname}"</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="element-cell">
|
||||||
|
<ElementLabel element={artifact.element} size="medium" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="proficiency-cell">
|
||||||
|
<ProficiencyLabel {proficiency} size="medium" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="level-cell">
|
||||||
|
<span class="level">Lv.{artifact.level}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="skills-cell">
|
||||||
|
{#if isQuirk}
|
||||||
|
<span class="quirk-badge">Quirk</span>
|
||||||
|
{:else if skillCount > 0}
|
||||||
|
<span class="skills">{skillCount}/4 skills</span>
|
||||||
|
{:else}
|
||||||
|
<span class="placeholder">—</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grade-cell">
|
||||||
|
{#if gradeLetter}
|
||||||
|
<span class="grade-badge grade-{gradeClass}">{gradeLetter}</span>
|
||||||
|
{:else}
|
||||||
|
<span class="placeholder">—</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
@use '$src/themes/layout' as *;
|
||||||
|
@use '$src/themes/spacing' as *;
|
||||||
|
@use '$src/themes/typography' as *;
|
||||||
|
|
||||||
|
.artifact-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: $unit-4x;
|
||||||
|
padding: $unit $unit-2x $unit $unit;
|
||||||
|
border: none;
|
||||||
|
background: var(--list-cell-bg);
|
||||||
|
cursor: pointer;
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
border-radius: $card-corner;
|
||||||
|
transition:
|
||||||
|
background 0.15s,
|
||||||
|
box-shadow 0.15s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--list-cell-bg-hover);
|
||||||
|
box-shadow: 0 0 8px rgba(0, 0, 0, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus-visible {
|
||||||
|
outline: 2px solid var(--accent-color, #3366ff);
|
||||||
|
outline-offset: -2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.core-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: $unit-2x;
|
||||||
|
flex-grow: 1;
|
||||||
|
|
||||||
|
.thumbnail {
|
||||||
|
width: 64px;
|
||||||
|
aspect-ratio: 1 / 1;
|
||||||
|
border-radius: $item-corner;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--card-bg, #f5f5f5);
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.name-cell {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $unit-fourth;
|
||||||
|
|
||||||
|
.name {
|
||||||
|
font-size: $font-regular;
|
||||||
|
font-weight: $medium;
|
||||||
|
color: var(--text-primary);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nickname {
|
||||||
|
font-size: $font-small;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-style: italic;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.element-cell,
|
||||||
|
.proficiency-cell {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.level-cell {
|
||||||
|
width: 48px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
.level {
|
||||||
|
font-size: $font-small;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-weight: $medium;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.skills-cell {
|
||||||
|
width: 80px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-start;
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
.skills {
|
||||||
|
font-size: $font-small;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.quirk-badge {
|
||||||
|
font-size: $font-small;
|
||||||
|
font-weight: $medium;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--purple-50, #b6b2fc);
|
||||||
|
color: var(--purple-10, #4f3c79);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.grade-cell {
|
||||||
|
width: 40px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grade-badge {
|
||||||
|
font-size: $font-small;
|
||||||
|
font-weight: $bold;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
line-height: 1;
|
||||||
|
|
||||||
|
&.grade-s {
|
||||||
|
background: linear-gradient(135deg, #ffd700, #ffb347);
|
||||||
|
color: #6b4c00;
|
||||||
|
}
|
||||||
|
&.grade-a {
|
||||||
|
background: linear-gradient(135deg, #4ade80, #22c55e);
|
||||||
|
color: #14532d;
|
||||||
|
}
|
||||||
|
&.grade-b {
|
||||||
|
background: linear-gradient(135deg, #60a5fa, #3b82f6);
|
||||||
|
color: #1e3a5f;
|
||||||
|
}
|
||||||
|
&.grade-c,
|
||||||
|
&.grade-d {
|
||||||
|
background: var(--grey-80, #e9e9e9);
|
||||||
|
color: var(--grey-40, #444);
|
||||||
|
}
|
||||||
|
&.grade-f {
|
||||||
|
background: linear-gradient(135deg, #f87171, #ef4444);
|
||||||
|
color: #7f1d1d;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.placeholder {
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -364,6 +364,16 @@ export function getElementIcon(element: number): string {
|
||||||
return `${getBasePath()}/elements/element-${name}.png`
|
return `${getBasePath()}/elements/element-${name}.png`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== Artifact Images =====
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get artifact image URL
|
||||||
|
*/
|
||||||
|
export function getArtifactImage(granblueId: string | number | null | undefined): string {
|
||||||
|
if (!granblueId) return '/images/placeholders/placeholder-weapon-grid.png'
|
||||||
|
return `${getBasePath()}/artifacts/${granblueId}.png`
|
||||||
|
}
|
||||||
|
|
||||||
// ===== Other Game Images =====
|
// ===== Other Game Images =====
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -18,16 +18,21 @@
|
||||||
const path = $page.url.pathname
|
const path = $page.url.pathname
|
||||||
if (path.includes('/weapons')) return 'weapons'
|
if (path.includes('/weapons')) return 'weapons'
|
||||||
if (path.includes('/summons')) return 'summons'
|
if (path.includes('/summons')) return 'summons'
|
||||||
|
if (path.includes('/artifacts')) return 'artifacts'
|
||||||
return 'characters'
|
return 'characters'
|
||||||
})
|
})
|
||||||
|
|
||||||
// Map entity type to singular form for modal
|
// Map entity type to singular form for modal (only for supported types)
|
||||||
const modalEntityType = $derived.by(() => {
|
const modalEntityType = $derived.by((): 'character' | 'weapon' | 'summon' | undefined => {
|
||||||
if (activeEntityType === 'weapons') return 'weapon'
|
if (activeEntityType === 'weapons') return 'weapon'
|
||||||
if (activeEntityType === 'summons') return 'summon'
|
if (activeEntityType === 'summons') return 'summon'
|
||||||
|
if (activeEntityType === 'artifacts') return undefined // Artifacts use different flow
|
||||||
return 'character'
|
return 'character'
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Whether the current entity type supports the add modal
|
||||||
|
const supportsAddModal = $derived(activeEntityType !== 'artifacts')
|
||||||
|
|
||||||
// Dynamic button text
|
// Dynamic button text
|
||||||
const addButtonText = $derived(`Add ${activeEntityType}`)
|
const addButtonText = $derived(`Add ${activeEntityType}`)
|
||||||
|
|
||||||
|
|
@ -62,9 +67,10 @@
|
||||||
<Segment value="characters">Characters</Segment>
|
<Segment value="characters">Characters</Segment>
|
||||||
<Segment value="weapons">Weapons</Segment>
|
<Segment value="weapons">Weapons</Segment>
|
||||||
<Segment value="summons">Summons</Segment>
|
<Segment value="summons">Summons</Segment>
|
||||||
|
<Segment value="artifacts">Artifacts</Segment>
|
||||||
</SegmentedControl>
|
</SegmentedControl>
|
||||||
|
|
||||||
{#if data.isOwner}
|
{#if data.isOwner && supportsAddModal}
|
||||||
<Button
|
<Button
|
||||||
variant="primary"
|
variant="primary"
|
||||||
size="small"
|
size="small"
|
||||||
|
|
@ -82,7 +88,7 @@
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{#if data.isOwner}
|
{#if data.isOwner && modalEntityType}
|
||||||
<AddToCollectionModal
|
<AddToCollectionModal
|
||||||
userId={data.user.id}
|
userId={data.user.id}
|
||||||
entityType={modalEntityType}
|
entityType={modalEntityType}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
import type { PageServerLoad } from './$types'
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async ({ parent }) => {
|
||||||
|
const { user, isOwner } = await parent()
|
||||||
|
|
||||||
|
// User info comes from layout, collection data is fetched client-side via TanStack Query
|
||||||
|
// The unified API endpoint handles privacy checks server-side
|
||||||
|
return {
|
||||||
|
user,
|
||||||
|
isOwner
|
||||||
|
}
|
||||||
|
}
|
||||||
317
src/routes/(app)/[username]/collection/artifacts/+page.svelte
Normal file
317
src/routes/(app)/[username]/collection/artifacts/+page.svelte
Normal file
|
|
@ -0,0 +1,317 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type { PageData } from './$types'
|
||||||
|
import type { CollectionArtifact } from '$lib/types/api/artifact'
|
||||||
|
import { createInfiniteQuery } from '@tanstack/svelte-query'
|
||||||
|
import { artifactQueries } from '$lib/api/queries/artifact.queries'
|
||||||
|
import CollectionArtifactPane from '$lib/components/collection/CollectionArtifactPane.svelte'
|
||||||
|
import CollectionArtifactCard from '$lib/components/collection/CollectionArtifactCard.svelte'
|
||||||
|
import CollectionArtifactRow from '$lib/components/collection/CollectionArtifactRow.svelte'
|
||||||
|
import Icon from '$lib/components/Icon.svelte'
|
||||||
|
import { IsInViewport } from 'runed'
|
||||||
|
import { sidebar } from '$lib/stores/sidebar.svelte'
|
||||||
|
import { viewMode, type ViewMode } from '$lib/stores/viewMode.svelte'
|
||||||
|
import SegmentedControl from '$lib/components/ui/segmented-control/SegmentedControl.svelte'
|
||||||
|
import Segment from '$lib/components/ui/segmented-control/Segment.svelte'
|
||||||
|
|
||||||
|
const { data }: { data: PageData } = $props()
|
||||||
|
|
||||||
|
// Filter state
|
||||||
|
let elementFilters = $state<number[]>([])
|
||||||
|
let rarityFilter = $state<'all' | 'standard' | 'quirk'>('all')
|
||||||
|
|
||||||
|
// Sentinel for infinite scroll
|
||||||
|
let sentinelEl = $state<HTMLElement>()
|
||||||
|
|
||||||
|
// Build filters for query
|
||||||
|
const queryFilters = $derived({
|
||||||
|
element: elementFilters.length > 0 ? elementFilters : undefined,
|
||||||
|
rarity: rarityFilter !== 'all' ? rarityFilter : undefined
|
||||||
|
})
|
||||||
|
|
||||||
|
// Query for artifacts collection
|
||||||
|
const collectionQuery = createInfiniteQuery(() => {
|
||||||
|
const userId = data.user.id
|
||||||
|
const filters = queryFilters
|
||||||
|
return artifactQueries.collection(userId, filters)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Flatten all artifacts from pages
|
||||||
|
const allArtifacts = $derived.by((): CollectionArtifact[] => {
|
||||||
|
if (!collectionQuery.data?.pages) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
return collectionQuery.data.pages.flatMap((page) => page.results ?? [])
|
||||||
|
})
|
||||||
|
|
||||||
|
// Infinite scroll
|
||||||
|
const inViewport = new IsInViewport(() => sentinelEl, {
|
||||||
|
rootMargin: '200px'
|
||||||
|
})
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (
|
||||||
|
inViewport.current &&
|
||||||
|
collectionQuery.hasNextPage &&
|
||||||
|
!collectionQuery.isFetchingNextPage &&
|
||||||
|
!collectionQuery.isLoading
|
||||||
|
) {
|
||||||
|
collectionQuery.fetchNextPage()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const isLoading = $derived(collectionQuery.isLoading)
|
||||||
|
const isEmpty = $derived(!isLoading && allArtifacts.length === 0)
|
||||||
|
const showSentinel = $derived(collectionQuery.hasNextPage && !collectionQuery.isFetchingNextPage)
|
||||||
|
|
||||||
|
// Current view mode from store
|
||||||
|
const currentViewMode = $derived(viewMode.collectionView)
|
||||||
|
|
||||||
|
function handleViewModeChange(mode: ViewMode) {
|
||||||
|
viewMode.setCollectionView(mode)
|
||||||
|
}
|
||||||
|
|
||||||
|
function openArtifactDetails(artifact: CollectionArtifact) {
|
||||||
|
const artifactName =
|
||||||
|
typeof artifact.artifact?.name === 'string'
|
||||||
|
? artifact.artifact.name
|
||||||
|
: artifact.artifact?.name?.en || 'Artifact'
|
||||||
|
|
||||||
|
sidebar.openWithComponent(artifactName, CollectionArtifactPane, {
|
||||||
|
artifact,
|
||||||
|
isOwner: data.isOwner,
|
||||||
|
onClose: () => sidebar.close()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="collection-page">
|
||||||
|
<!-- Filters bar -->
|
||||||
|
<div class="filters-bar">
|
||||||
|
<SegmentedControl
|
||||||
|
value={rarityFilter}
|
||||||
|
onValueChange={(v) => (rarityFilter = v as 'all' | 'standard' | 'quirk')}
|
||||||
|
variant="blended"
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
<Segment value="all">All</Segment>
|
||||||
|
<Segment value="standard">Standard</Segment>
|
||||||
|
<Segment value="quirk">Quirk</Segment>
|
||||||
|
</SegmentedControl>
|
||||||
|
|
||||||
|
<div class="view-toggle">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="toggle-btn"
|
||||||
|
class:active={currentViewMode === 'grid'}
|
||||||
|
onclick={() => handleViewModeChange('grid')}
|
||||||
|
aria-label="Grid view"
|
||||||
|
>
|
||||||
|
<Icon name="grid-2x2" size={18} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="toggle-btn"
|
||||||
|
class:active={currentViewMode === 'list'}
|
||||||
|
onclick={() => handleViewModeChange('list')}
|
||||||
|
aria-label="List view"
|
||||||
|
>
|
||||||
|
<Icon name="list" size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Collection grid -->
|
||||||
|
<div class="grid-area">
|
||||||
|
{#if isLoading}
|
||||||
|
<div class="loading-state">
|
||||||
|
<Icon name="loader-2" size={32} />
|
||||||
|
<p>Loading collection...</p>
|
||||||
|
</div>
|
||||||
|
{:else if isEmpty}
|
||||||
|
<div class="empty-state">
|
||||||
|
{#if data.isOwner}
|
||||||
|
<Icon name="gem" size={48} />
|
||||||
|
<h3>Your artifact collection is empty</h3>
|
||||||
|
<p>Artifacts will appear here once added</p>
|
||||||
|
{:else}
|
||||||
|
<Icon name="lock" size={48} />
|
||||||
|
<p>This collection is empty or private</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{:else if currentViewMode === 'grid'}
|
||||||
|
<div class="artifact-grid">
|
||||||
|
{#each allArtifacts as artifact (artifact.id)}
|
||||||
|
<CollectionArtifactCard
|
||||||
|
{artifact}
|
||||||
|
onClick={() => openArtifactDetails(artifact)}
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="artifact-list">
|
||||||
|
{#each allArtifacts as artifact (artifact.id)}
|
||||||
|
<CollectionArtifactRow
|
||||||
|
{artifact}
|
||||||
|
onClick={() => openArtifactDetails(artifact)}
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if !isLoading && !isEmpty}
|
||||||
|
{#if showSentinel}
|
||||||
|
<div class="load-more-sentinel" bind:this={sentinelEl}></div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if collectionQuery.isFetchingNextPage}
|
||||||
|
<div class="loading-more">
|
||||||
|
<Icon name="loader-2" size={20} />
|
||||||
|
<span>Loading more...</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if !collectionQuery.hasNextPage && allArtifacts.length > 0}
|
||||||
|
<div class="end-message">
|
||||||
|
<p>
|
||||||
|
{allArtifacts.length} artifact{allArtifacts.length === 1 ? '' : 's'} in {data.isOwner
|
||||||
|
? 'your'
|
||||||
|
: 'this'} collection
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
@use '$src/themes/spacing' as *;
|
||||||
|
@use '$src/themes/colors' as *;
|
||||||
|
@use '$src/themes/typography' as *;
|
||||||
|
@use '$src/themes/layout' as *;
|
||||||
|
|
||||||
|
.collection-page {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $unit-2x;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filters-bar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: $unit-2x;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-toggle {
|
||||||
|
display: flex;
|
||||||
|
gap: $unit-fourth;
|
||||||
|
padding: $unit-fourth;
|
||||||
|
background: var(--segmented-control-bg, $grey-90);
|
||||||
|
border-radius: $item-corner;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: $unit-half;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
border-radius: $item-corner-small;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
transition: all 0.15s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
background: var(--unit-bg, white);
|
||||||
|
color: var(--text-primary);
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-area {
|
||||||
|
min-height: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.artifact-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(5, 100px);
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: $unit-4x;
|
||||||
|
}
|
||||||
|
|
||||||
|
.artifact-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $unit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-state,
|
||||||
|
.empty-state {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 300px;
|
||||||
|
color: var(--text-secondary, #666);
|
||||||
|
gap: $unit;
|
||||||
|
|
||||||
|
:global(svg) {
|
||||||
|
color: var(--icon-secondary, #999);
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state h3 {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text-primary, #333);
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-state :global(svg) {
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.load-more-sentinel {
|
||||||
|
height: 1px;
|
||||||
|
margin-top: $unit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-more {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: $unit;
|
||||||
|
padding: $unit-2x;
|
||||||
|
color: var(--text-secondary, #666);
|
||||||
|
|
||||||
|
:global(svg) {
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.end-message {
|
||||||
|
text-align: center;
|
||||||
|
padding: $unit-2x;
|
||||||
|
color: var(--text-secondary, #666);
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
from {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Loading…
Reference in a new issue