add collection page route and character pane components
- Add collection route structure at [username]/collection/characters - Create CharacterEditPane as shared component for character customizations - Create CollectionCharacterPane with Info and My Collection tabs - Add character grid with filters and infinite scroll - Fix CollectionFilters a11y warnings
This commit is contained in:
parent
a9de4a60c0
commit
dea784780a
8 changed files with 1233 additions and 14 deletions
303
src/lib/components/collection/CollectionCharacterPane.svelte
Normal file
303
src/lib/components/collection/CollectionCharacterPane.svelte
Normal file
|
|
@ -0,0 +1,303 @@
|
|||
<script lang="ts">
|
||||
/**
|
||||
* CollectionCharacterPane - Details and edit pane for collection characters
|
||||
*
|
||||
* Displays character information with two views:
|
||||
* - "Info" tab: Shows base character stats, skills, etc.
|
||||
* - "My Collection" tab: Shows user's customizations (rings, earring, awakening, etc.)
|
||||
*
|
||||
* The "My Collection" tab includes an edit mode using CharacterEditPane.
|
||||
*/
|
||||
import type { CollectionCharacter, ExtendedMastery } from '$lib/types/api/collection'
|
||||
import { useUpdateCollectionCharacter } from '$lib/api/mutations/collection.mutations'
|
||||
import SegmentedControl from '$lib/components/ui/segmented-control/SegmentedControl.svelte'
|
||||
import Segment from '$lib/components/ui/segmented-control/Segment.svelte'
|
||||
import ItemHeader from '$lib/components/sidebar/details/ItemHeader.svelte'
|
||||
import BasicInfoSection from '$lib/components/sidebar/details/BasicInfoSection.svelte'
|
||||
import StatsSection from '$lib/components/sidebar/details/StatsSection.svelte'
|
||||
import SkillsSection from '$lib/components/sidebar/details/SkillsSection.svelte'
|
||||
import CharacterEditPane, {
|
||||
type CharacterEditValues,
|
||||
type CharacterEditUpdates
|
||||
} from '$lib/components/sidebar/CharacterEditPane.svelte'
|
||||
import Button from '$lib/components/ui/Button.svelte'
|
||||
import Icon from '$lib/components/Icon.svelte'
|
||||
|
||||
interface Props {
|
||||
character: CollectionCharacter
|
||||
isOwner: boolean
|
||||
onClose?: () => void
|
||||
}
|
||||
|
||||
let { character, isOwner, onClose }: Props = $props()
|
||||
|
||||
// Tab state
|
||||
let selectedTab = $state<'info' | 'collection'>('collection')
|
||||
|
||||
// Edit mode state
|
||||
let isEditing = $state(false)
|
||||
|
||||
// Update mutation
|
||||
const updateMutation = useUpdateCollectionCharacter()
|
||||
|
||||
// Derived values
|
||||
const characterData = $derived(character.character)
|
||||
|
||||
// Current edit values from the collection character
|
||||
const currentValues = $derived<CharacterEditValues>({
|
||||
awakening: character.awakening
|
||||
? {
|
||||
type: character.awakening.type,
|
||||
level: character.awakening.level
|
||||
}
|
||||
: null,
|
||||
rings: [
|
||||
character.ring1 ?? { modifier: 1, strength: 0 },
|
||||
character.ring2 ?? { modifier: 2, strength: 0 },
|
||||
character.ring3 ?? { modifier: 0, strength: 0 },
|
||||
character.ring4 ?? { modifier: 0, strength: 0 }
|
||||
],
|
||||
earring: character.earring,
|
||||
perpetuity: character.perpetuity
|
||||
})
|
||||
|
||||
// Element name for theming
|
||||
const ELEMENT_MAP: Record<number, 'wind' | 'fire' | 'water' | 'earth' | 'dark' | 'light'> = {
|
||||
1: 'wind',
|
||||
2: 'fire',
|
||||
3: 'water',
|
||||
4: 'earth',
|
||||
5: 'dark',
|
||||
6: 'light'
|
||||
}
|
||||
const elementName = $derived(
|
||||
characterData?.element ? ELEMENT_MAP[characterData.element] : undefined
|
||||
)
|
||||
|
||||
async function handleSave(updates: CharacterEditUpdates) {
|
||||
try {
|
||||
// Transform updates to API format
|
||||
const input: Record<string, unknown> = {}
|
||||
|
||||
// Handle awakening
|
||||
if (updates.awakening !== undefined) {
|
||||
if (updates.awakening === null) {
|
||||
input.awakeningId = null
|
||||
input.awakeningLevel = null
|
||||
} else {
|
||||
input.awakeningId = updates.awakening.id
|
||||
input.awakeningLevel = updates.awakening.level
|
||||
}
|
||||
}
|
||||
|
||||
// Handle rings (API expects ring1, ring2, ring3, ring4)
|
||||
if (updates.rings) {
|
||||
updates.rings.forEach((ring, index) => {
|
||||
const key = `ring${index + 1}` as keyof typeof input
|
||||
input[key] = ring
|
||||
})
|
||||
}
|
||||
|
||||
// Handle earring
|
||||
if (updates.earring !== undefined) {
|
||||
input.earring = updates.earring
|
||||
}
|
||||
|
||||
// Handle perpetuity
|
||||
if (updates.perpetuity !== undefined) {
|
||||
input.perpetuity = updates.perpetuity
|
||||
}
|
||||
|
||||
await updateMutation.mutateAsync({
|
||||
id: character.id,
|
||||
input
|
||||
})
|
||||
|
||||
isEditing = false
|
||||
} catch (error) {
|
||||
console.error('Failed to update collection character:', error)
|
||||
}
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
isEditing = false
|
||||
}
|
||||
|
||||
function handleTabChange(value: string) {
|
||||
selectedTab = value as 'info' | 'collection'
|
||||
// Exit edit mode when switching tabs
|
||||
if (isEditing) {
|
||||
isEditing = false
|
||||
}
|
||||
}
|
||||
|
||||
function displayRing(ring: ExtendedMastery | null): string {
|
||||
if (!ring || ring.modifier === 0) return '—'
|
||||
// TODO: Add proper ring modifier labels
|
||||
return `Modifier ${ring.modifier}: ${ring.strength}`
|
||||
}
|
||||
|
||||
function displayAwakening(): string {
|
||||
if (!character.awakening) return '—'
|
||||
const name =
|
||||
typeof character.awakening.type.name === 'string'
|
||||
? character.awakening.type.name
|
||||
: character.awakening.type.name?.en || 'Unknown'
|
||||
return `${name} Lv.${character.awakening.level}`
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="collection-character-pane">
|
||||
<ItemHeader
|
||||
type="character"
|
||||
item={character as any}
|
||||
itemData={characterData}
|
||||
gridUncapLevel={character.uncapLevel}
|
||||
gridTranscendence={character.transcendenceStep}
|
||||
/>
|
||||
|
||||
<!-- Tab navigation -->
|
||||
<div class="tab-nav">
|
||||
<SegmentedControl value={selectedTab} onValueChange={handleTabChange} variant="background" grow>
|
||||
<Segment value="info">Info</Segment>
|
||||
<Segment value="collection">My Collection</Segment>
|
||||
</SegmentedControl>
|
||||
</div>
|
||||
|
||||
<div class="pane-content">
|
||||
{#if selectedTab === 'info'}
|
||||
<!-- Info view: base character data -->
|
||||
<div class="info-view">
|
||||
<BasicInfoSection type="character" itemData={characterData} />
|
||||
<StatsSection
|
||||
itemData={characterData}
|
||||
gridUncapLevel={character.uncapLevel}
|
||||
gridTranscendence={character.transcendenceStep}
|
||||
/>
|
||||
<SkillsSection type="character" itemData={characterData} />
|
||||
</div>
|
||||
{:else if isEditing}
|
||||
<!-- Edit mode -->
|
||||
<CharacterEditPane
|
||||
{characterData}
|
||||
{currentValues}
|
||||
showPerpetuity={true}
|
||||
onSave={handleSave}
|
||||
onCancel={handleCancel}
|
||||
saving={updateMutation.isPending}
|
||||
/>
|
||||
{:else}
|
||||
<!-- My Collection view: user's customizations -->
|
||||
<div class="collection-view">
|
||||
<div class="customization-section">
|
||||
<h4>Awakening</h4>
|
||||
<p>{displayAwakening()}</p>
|
||||
</div>
|
||||
|
||||
<div class="customization-section">
|
||||
<h4>Over Mastery Rings</h4>
|
||||
<div class="rings-list">
|
||||
<p>Ring 1: {displayRing(character.ring1)}</p>
|
||||
<p>Ring 2: {displayRing(character.ring2)}</p>
|
||||
<p>Ring 3: {displayRing(character.ring3)}</p>
|
||||
<p>Ring 4: {displayRing(character.ring4)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="customization-section">
|
||||
<h4>Aetherial Mastery</h4>
|
||||
<p>{displayRing(character.earring)}</p>
|
||||
</div>
|
||||
|
||||
<div class="customization-section">
|
||||
<h4>Perpetuity Ring</h4>
|
||||
<p>{character.perpetuity ? 'Equipped' : 'Not equipped'}</p>
|
||||
</div>
|
||||
|
||||
{#if isOwner}
|
||||
<div class="edit-button-container">
|
||||
<Button
|
||||
variant="primary"
|
||||
element={elementName}
|
||||
elementStyle={!!elementName}
|
||||
onclick={() => (isEditing = true)}
|
||||
>
|
||||
<Icon name="pencil" size={16} />
|
||||
Edit Customizations
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
@use '$src/themes/spacing' as spacing;
|
||||
@use '$src/themes/colors' as colors;
|
||||
@use '$src/themes/typography' as typography;
|
||||
|
||||
.collection-character-pane {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
color: var(--text-primary, colors.$grey-10);
|
||||
}
|
||||
|
||||
.tab-nav {
|
||||
padding: 0 spacing.$unit-2x spacing.$unit-2x;
|
||||
}
|
||||
|
||||
.pane-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.info-view {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: spacing.$unit-4x;
|
||||
padding: 0 spacing.$unit-2x;
|
||||
}
|
||||
|
||||
.collection-view {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: spacing.$unit-3x;
|
||||
padding: 0 spacing.$unit-2x;
|
||||
}
|
||||
|
||||
.customization-section {
|
||||
h4 {
|
||||
font-size: typography.$font-small;
|
||||
font-weight: typography.$bold;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-secondary, colors.$grey-50);
|
||||
margin: 0 0 spacing.$unit-half;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
color: var(--text-primary, colors.$grey-10);
|
||||
}
|
||||
}
|
||||
|
||||
.rings-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: spacing.$unit-half;
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.edit-button-container {
|
||||
margin-top: spacing.$unit-2x;
|
||||
padding-top: spacing.$unit-2x;
|
||||
border-top: 1px solid var(--border-secondary, colors.$grey-80);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -189,8 +189,8 @@
|
|||
|
||||
<div class="filters" class:horizontal={layout === 'horizontal'} class:vertical={layout === 'vertical'}>
|
||||
{#if showFilters.element}
|
||||
<div class="filter-group">
|
||||
<label class="filter-label">Element</label>
|
||||
<div class="filter-group" role="group" aria-label="Element filters">
|
||||
<span class="filter-label">Element</span>
|
||||
<div class="filter-buttons">
|
||||
{#each elements as element}
|
||||
<button
|
||||
|
|
@ -209,8 +209,8 @@
|
|||
{/if}
|
||||
|
||||
{#if showFilters.rarity}
|
||||
<div class="filter-group">
|
||||
<label class="filter-label">Rarity</label>
|
||||
<div class="filter-group" role="group" aria-label="Rarity filters">
|
||||
<span class="filter-label">Rarity</span>
|
||||
<div class="filter-buttons">
|
||||
{#each rarities as rarity}
|
||||
<button
|
||||
|
|
@ -228,8 +228,8 @@
|
|||
{/if}
|
||||
|
||||
{#if showFilters.season}
|
||||
<div class="filter-group">
|
||||
<label class="filter-label">Season</label>
|
||||
<div class="filter-group" role="group" aria-label="Season filters">
|
||||
<span class="filter-label">Season</span>
|
||||
<div class="filter-buttons">
|
||||
{#each seasons as season}
|
||||
<button
|
||||
|
|
@ -247,8 +247,8 @@
|
|||
{/if}
|
||||
|
||||
{#if showFilters.series}
|
||||
<div class="filter-group">
|
||||
<label class="filter-label">Series</label>
|
||||
<div class="filter-group" role="group" aria-label="Series filters">
|
||||
<span class="filter-label">Series</span>
|
||||
<div class="filter-buttons wrap">
|
||||
{#each series as s}
|
||||
<button
|
||||
|
|
@ -266,8 +266,8 @@
|
|||
{/if}
|
||||
|
||||
{#if showFilters.race}
|
||||
<div class="filter-group">
|
||||
<label class="filter-label">Race</label>
|
||||
<div class="filter-group" role="group" aria-label="Race filters">
|
||||
<span class="filter-label">Race</span>
|
||||
<div class="filter-buttons">
|
||||
{#each races as race}
|
||||
<button
|
||||
|
|
@ -285,8 +285,8 @@
|
|||
{/if}
|
||||
|
||||
{#if showFilters.proficiency}
|
||||
<div class="filter-group">
|
||||
<label class="filter-label">Proficiency</label>
|
||||
<div class="filter-group" role="group" aria-label="Proficiency filters">
|
||||
<span class="filter-label">Proficiency</span>
|
||||
<div class="filter-buttons proficiency-grid">
|
||||
{#each proficiencies as prof}
|
||||
<button
|
||||
|
|
@ -304,8 +304,8 @@
|
|||
{/if}
|
||||
|
||||
{#if showFilters.gender}
|
||||
<div class="filter-group">
|
||||
<label class="filter-label">Gender</label>
|
||||
<div class="filter-group" role="group" aria-label="Gender filters">
|
||||
<span class="filter-label">Gender</span>
|
||||
<div class="filter-buttons">
|
||||
{#each genders as gender}
|
||||
<button
|
||||
|
|
|
|||
282
src/lib/components/sidebar/CharacterEditPane.svelte
Normal file
282
src/lib/components/sidebar/CharacterEditPane.svelte
Normal file
|
|
@ -0,0 +1,282 @@
|
|||
<script lang="ts">
|
||||
/**
|
||||
* CharacterEditPane - Unified character edit component
|
||||
*
|
||||
* This component provides all edit controls for character customization:
|
||||
* - Awakening selection and level
|
||||
* - Over Mastery rings (4 slots)
|
||||
* - Aetherial Mastery (earring)
|
||||
* - Perpetuity ring toggle
|
||||
*
|
||||
* Used by both:
|
||||
* - Party grid character editing (EditCharacterSidebar)
|
||||
* - Collection character editing (CollectionCharacterPane)
|
||||
*
|
||||
* The caller is responsible for handling the save action with their specific mutation.
|
||||
*/
|
||||
import type { Character, Awakening } from '$lib/types/api/entities'
|
||||
import type { ExtendedMastery } from '$lib/types/api/collection'
|
||||
import DetailsSection from './details/DetailsSection.svelte'
|
||||
import AwakeningSelect from './edit/AwakeningSelect.svelte'
|
||||
import RingsSelect from './edit/RingsSelect.svelte'
|
||||
import EarringSelect from './edit/EarringSelect.svelte'
|
||||
import PerpetuityToggle from './edit/PerpetuityToggle.svelte'
|
||||
import Button from '$lib/components/ui/Button.svelte'
|
||||
|
||||
export interface CharacterEditValues {
|
||||
awakening?: {
|
||||
type?: Awakening
|
||||
level: number
|
||||
} | null
|
||||
rings: ExtendedMastery[]
|
||||
earring?: ExtendedMastery | null
|
||||
perpetuity: boolean
|
||||
}
|
||||
|
||||
export interface CharacterEditUpdates {
|
||||
awakening?: {
|
||||
id: string
|
||||
level: number
|
||||
} | null
|
||||
rings: ExtendedMastery[]
|
||||
earring?: ExtendedMastery | null
|
||||
perpetuity?: boolean
|
||||
}
|
||||
|
||||
interface Props {
|
||||
/** The base character data (for awakenings list, element, etc.) */
|
||||
characterData: Character | undefined
|
||||
/** Current values for all edit fields */
|
||||
currentValues: CharacterEditValues
|
||||
/** Whether to show the perpetuity toggle (false for MC/position 0) */
|
||||
showPerpetuity?: boolean
|
||||
/** Callback when save is clicked, receives API-formatted updates */
|
||||
onSave?: (updates: CharacterEditUpdates) => void
|
||||
/** Callback when cancel is clicked */
|
||||
onCancel?: () => void
|
||||
/** Whether save is in progress (disables buttons) */
|
||||
saving?: boolean
|
||||
}
|
||||
|
||||
let {
|
||||
characterData,
|
||||
currentValues,
|
||||
showPerpetuity = true,
|
||||
onSave,
|
||||
onCancel,
|
||||
saving = false
|
||||
}: Props = $props()
|
||||
|
||||
// Internal state - initialized from currentValues
|
||||
let selectedAwakening = $state<Awakening | undefined>(currentValues.awakening?.type)
|
||||
let awakeningLevel = $state(currentValues.awakening?.level ?? 1)
|
||||
let rings = $state<ExtendedMastery[]>(
|
||||
currentValues.rings.length > 0
|
||||
? currentValues.rings
|
||||
: [
|
||||
{ modifier: 1, strength: 0 },
|
||||
{ modifier: 2, strength: 0 },
|
||||
{ modifier: 0, strength: 0 },
|
||||
{ modifier: 0, strength: 0 }
|
||||
]
|
||||
)
|
||||
let earring = $state<ExtendedMastery | undefined>(currentValues.earring ?? undefined)
|
||||
let perpetuity = $state(currentValues.perpetuity)
|
||||
|
||||
// Re-initialize when currentValues changes (e.g., switching between characters)
|
||||
$effect(() => {
|
||||
selectedAwakening = currentValues.awakening?.type
|
||||
awakeningLevel = currentValues.awakening?.level ?? 1
|
||||
rings =
|
||||
currentValues.rings.length > 0
|
||||
? currentValues.rings
|
||||
: [
|
||||
{ modifier: 1, strength: 0 },
|
||||
{ modifier: 2, strength: 0 },
|
||||
{ modifier: 0, strength: 0 },
|
||||
{ modifier: 0, strength: 0 }
|
||||
]
|
||||
earring = currentValues.earring ?? undefined
|
||||
perpetuity = currentValues.perpetuity
|
||||
})
|
||||
|
||||
// Derived conditions
|
||||
const characterElement = $derived(characterData?.element)
|
||||
const hasAwakening = $derived((characterData?.awakenings?.length ?? 0) > 0)
|
||||
const availableAwakenings = $derived(characterData?.awakenings ?? [])
|
||||
const maxAwakeningLevel = 10
|
||||
|
||||
// Element name for theming
|
||||
const ELEMENT_MAP: Record<number, 'wind' | 'fire' | 'water' | 'earth' | 'dark' | 'light'> = {
|
||||
1: 'wind',
|
||||
2: 'fire',
|
||||
3: 'water',
|
||||
4: 'earth',
|
||||
5: 'dark',
|
||||
6: 'light'
|
||||
}
|
||||
const elementName = $derived(characterElement ? ELEMENT_MAP[characterElement] : undefined)
|
||||
|
||||
// Awakening slug to UUID map (awakenings from API have id: null, only slugs)
|
||||
const AWAKENING_MAP: Record<string, string> = {
|
||||
'character-balanced': 'b1847c82-ece0-4d7a-8af1-c7868d90f34a',
|
||||
'character-atk': '6e233877-8cda-4c8f-a091-3db6f68749e2',
|
||||
'character-def': 'c95441de-f949-4a62-b02b-101aa2e0a638',
|
||||
'character-multi': 'e36b0573-79c3-4dd2-9524-c95def4bbb1a'
|
||||
}
|
||||
|
||||
function handleSave() {
|
||||
const updates: CharacterEditUpdates = {
|
||||
rings,
|
||||
perpetuity: showPerpetuity ? perpetuity : undefined
|
||||
}
|
||||
|
||||
// Format awakening for API
|
||||
if (hasAwakening) {
|
||||
if (selectedAwakening?.slug) {
|
||||
const awakeningId = AWAKENING_MAP[selectedAwakening.slug]
|
||||
if (awakeningId) {
|
||||
updates.awakening = {
|
||||
id: awakeningId,
|
||||
level: awakeningLevel
|
||||
}
|
||||
}
|
||||
} else {
|
||||
updates.awakening = null
|
||||
}
|
||||
}
|
||||
|
||||
// Format earring for API
|
||||
if (earring) {
|
||||
updates.earring = earring
|
||||
}
|
||||
|
||||
onSave?.(updates)
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
// Reset to original values
|
||||
selectedAwakening = currentValues.awakening?.type
|
||||
awakeningLevel = currentValues.awakening?.level ?? 1
|
||||
rings =
|
||||
currentValues.rings.length > 0
|
||||
? currentValues.rings
|
||||
: [
|
||||
{ modifier: 1, strength: 0 },
|
||||
{ modifier: 2, strength: 0 },
|
||||
{ modifier: 0, strength: 0 },
|
||||
{ modifier: 0, strength: 0 }
|
||||
]
|
||||
earring = currentValues.earring ?? undefined
|
||||
perpetuity = currentValues.perpetuity
|
||||
onCancel?.()
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="character-edit-pane">
|
||||
<div class="edit-sections">
|
||||
{#if hasAwakening && availableAwakenings.length > 0}
|
||||
<DetailsSection title="Awakening">
|
||||
<div class="section-content">
|
||||
<AwakeningSelect
|
||||
awakenings={availableAwakenings}
|
||||
value={selectedAwakening}
|
||||
level={awakeningLevel}
|
||||
maxLevel={maxAwakeningLevel}
|
||||
onAwakeningChange={(awakening) => {
|
||||
selectedAwakening = awakening
|
||||
}}
|
||||
onLevelChange={(level) => {
|
||||
awakeningLevel = level
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</DetailsSection>
|
||||
{/if}
|
||||
|
||||
<DetailsSection title="Over Mastery Rings">
|
||||
<div class="section-content">
|
||||
<RingsSelect
|
||||
{rings}
|
||||
onChange={(newRings) => {
|
||||
rings = newRings
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</DetailsSection>
|
||||
|
||||
<DetailsSection title="Aetherial Mastery">
|
||||
<div class="section-content">
|
||||
<EarringSelect
|
||||
value={earring}
|
||||
element={characterElement}
|
||||
onChange={(newEarring) => {
|
||||
earring = newEarring
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</DetailsSection>
|
||||
|
||||
{#if showPerpetuity}
|
||||
<DetailsSection title="Perpetuity">
|
||||
<div class="section-content">
|
||||
<PerpetuityToggle
|
||||
value={perpetuity}
|
||||
element={elementName}
|
||||
onChange={(value) => {
|
||||
perpetuity = value
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</DetailsSection>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="edit-footer">
|
||||
<Button variant="secondary" onclick={handleCancel} disabled={saving}>Cancel</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
element={elementName}
|
||||
elementStyle={!!elementName}
|
||||
onclick={handleSave}
|
||||
disabled={saving}
|
||||
>
|
||||
{saving ? 'Saving...' : 'Save'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
@use '$src/themes/spacing' as spacing;
|
||||
|
||||
.character-edit-pane {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
gap: spacing.$unit-2x;
|
||||
}
|
||||
|
||||
.edit-sections {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: spacing.$unit-3x;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.section-content {
|
||||
padding: spacing.$unit;
|
||||
}
|
||||
|
||||
.edit-footer {
|
||||
display: flex;
|
||||
gap: spacing.$unit-2x;
|
||||
padding: spacing.$unit-2x;
|
||||
border-top: 1px solid var(--border-secondary);
|
||||
flex-shrink: 0;
|
||||
|
||||
:global(button) {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
24
src/routes/(app)/[username]/collection/+layout.server.ts
Normal file
24
src/routes/(app)/[username]/collection/+layout.server.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import type { LayoutServerLoad } from './$types'
|
||||
import { error } from '@sveltejs/kit'
|
||||
import { userAdapter } from '$lib/api/adapters/user.adapter'
|
||||
|
||||
export const load: LayoutServerLoad = async ({ params, locals }) => {
|
||||
const username = params.username
|
||||
const isOwner = locals.session?.account?.username === username
|
||||
|
||||
try {
|
||||
// Get basic user info
|
||||
const userInfo = await userAdapter.getInfo(username)
|
||||
|
||||
return {
|
||||
user: userInfo,
|
||||
isOwner
|
||||
}
|
||||
} catch (e: any) {
|
||||
// 403 means collection is private
|
||||
if (e?.status === 403) {
|
||||
throw error(403, 'This collection is private')
|
||||
}
|
||||
throw error(e?.status || 502, e?.message || 'Failed to load user')
|
||||
}
|
||||
}
|
||||
142
src/routes/(app)/[username]/collection/+layout.svelte
Normal file
142
src/routes/(app)/[username]/collection/+layout.svelte
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
<script lang="ts">
|
||||
import type { LayoutData } from './$types'
|
||||
import { page } from '$app/stores'
|
||||
import { goto } from '$app/navigation'
|
||||
import { getAvatarSrc, getAvatarSrcSet } from '$lib/utils/avatar'
|
||||
import SegmentedControl from '$lib/components/ui/segmented-control/SegmentedControl.svelte'
|
||||
import Segment from '$lib/components/ui/segmented-control/Segment.svelte'
|
||||
|
||||
let { data, children }: { data: LayoutData; children: any } = $props()
|
||||
|
||||
const avatarFile = $derived(data.user?.avatar?.picture || '')
|
||||
const avatarSrc = $derived(getAvatarSrc(avatarFile))
|
||||
const avatarSrcSet = $derived(getAvatarSrcSet(avatarFile))
|
||||
|
||||
// Determine active tab from URL path
|
||||
const activeTab = $derived.by(() => {
|
||||
const path = $page.url.pathname
|
||||
if (path.includes('/weapons')) return 'weapons'
|
||||
if (path.includes('/summons')) return 'summons'
|
||||
return 'characters'
|
||||
})
|
||||
|
||||
const username = $derived(data.user?.username || $page.params.username)
|
||||
|
||||
function handleTabChange(value: string) {
|
||||
goto(`/${username}/collection/${value}`)
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{username}'s Collection</title>
|
||||
</svelte:head>
|
||||
|
||||
<section class="collection">
|
||||
<header class="header">
|
||||
{#if data.user?.avatar?.picture}
|
||||
<img
|
||||
class="avatar"
|
||||
alt={`Avatar of ${username}`}
|
||||
src={avatarSrc}
|
||||
srcset={avatarSrcSet}
|
||||
width="64"
|
||||
height="64"
|
||||
/>
|
||||
{:else}
|
||||
<div class="avatar" aria-hidden="true"></div>
|
||||
{/if}
|
||||
<div class="header-content">
|
||||
<h1>{username}'s Collection</h1>
|
||||
<nav class="tabs" aria-label="Profile sections">
|
||||
<a href="/{username}" data-sveltekit-preload-data="hover">Teams</a>
|
||||
<a href="/{username}/collection/characters" class="active">Collection</a>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Entity type segmented control -->
|
||||
<nav class="entity-nav" aria-label="Collection type">
|
||||
<SegmentedControl value={activeTab} onValueChange={handleTabChange} gap={true}>
|
||||
<Segment value="characters">
|
||||
Characters
|
||||
</Segment>
|
||||
<Segment value="weapons" disabled>
|
||||
Weapons
|
||||
</Segment>
|
||||
<Segment value="summons" disabled>
|
||||
Summons
|
||||
</Segment>
|
||||
</SegmentedControl>
|
||||
</nav>
|
||||
|
||||
<div class="content">
|
||||
{@render children()}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style lang="scss">
|
||||
@use '$src/themes/spacing' as *;
|
||||
@use '$src/themes/colors' as *;
|
||||
|
||||
.collection {
|
||||
padding: $unit-2x 0;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $unit-2x;
|
||||
margin-bottom: $unit-2x;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: 50%;
|
||||
background: $grey-80;
|
||||
border: 1px solid $grey-75;
|
||||
object-fit: cover;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.header-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0 0 $unit-half;
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: $unit-2x;
|
||||
}
|
||||
|
||||
.tabs a {
|
||||
text-decoration: none;
|
||||
color: var(--text-secondary);
|
||||
padding-bottom: 2px;
|
||||
border-bottom: 2px solid transparent;
|
||||
|
||||
&:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
&.active {
|
||||
border-color: var(--accent-color, #3366ff);
|
||||
color: var(--accent-color, #3366ff);
|
||||
}
|
||||
}
|
||||
|
||||
.entity-nav {
|
||||
margin-bottom: $unit-2x;
|
||||
max-width: 500px;
|
||||
}
|
||||
|
||||
.content {
|
||||
min-height: 400px;
|
||||
}
|
||||
</style>
|
||||
7
src/routes/(app)/[username]/collection/+page.ts
Normal file
7
src/routes/(app)/[username]/collection/+page.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import { redirect } from '@sveltejs/kit'
|
||||
import type { PageLoad } from './$types'
|
||||
|
||||
export const load: PageLoad = async ({ params }) => {
|
||||
// Redirect to characters as the default collection tab
|
||||
throw redirect(307, `/${params.username}/collection/characters`)
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
import type { PageServerLoad } from './$types'
|
||||
import { error } from '@sveltejs/kit'
|
||||
import { collectionAdapter } from '$lib/api/adapters/collection.adapter'
|
||||
|
||||
export const load: PageServerLoad = async ({ parent }) => {
|
||||
const { user, isOwner } = await parent()
|
||||
|
||||
try {
|
||||
// Fetch the user's public character collection
|
||||
const characters = await collectionAdapter.getPublicCharacters(user.id)
|
||||
|
||||
return {
|
||||
characters,
|
||||
isOwner
|
||||
}
|
||||
} catch (e: any) {
|
||||
// 403 means collection is private
|
||||
if (e?.status === 403) {
|
||||
throw error(403, 'This collection is private')
|
||||
}
|
||||
throw error(e?.status || 502, e?.message || 'Failed to load collection')
|
||||
}
|
||||
}
|
||||
438
src/routes/(app)/[username]/collection/characters/+page.svelte
Normal file
438
src/routes/(app)/[username]/collection/characters/+page.svelte
Normal file
|
|
@ -0,0 +1,438 @@
|
|||
<script lang="ts">
|
||||
import type { PageData } from './$types'
|
||||
import type { CollectionCharacter } from '$lib/types/api/collection'
|
||||
import { createInfiniteQuery } from '@tanstack/svelte-query'
|
||||
import { collectionQueries } from '$lib/api/queries/collection.queries'
|
||||
import CollectionFilters, {
|
||||
type CollectionFilterState
|
||||
} from '$lib/components/collection/CollectionFilters.svelte'
|
||||
import AddToCollectionModal from '$lib/components/collection/AddToCollectionModal.svelte'
|
||||
import CollectionCharacterPane from '$lib/components/collection/CollectionCharacterPane.svelte'
|
||||
import Button from '$lib/components/ui/Button.svelte'
|
||||
import Icon from '$lib/components/Icon.svelte'
|
||||
import { IsInViewport } from 'runed'
|
||||
import { getCharacterImageWithPose } from '$lib/utils/images'
|
||||
import UncapIndicator from '$lib/components/uncap/UncapIndicator.svelte'
|
||||
import { sidebar } from '$lib/stores/sidebar.svelte'
|
||||
import perpetuityFilled from '$src/assets/icons/perpetuity/filled.svg'
|
||||
|
||||
const { data }: { data: PageData } = $props()
|
||||
|
||||
// Filter state
|
||||
let elementFilters = $state<number[]>([])
|
||||
let rarityFilters = $state<number[]>([])
|
||||
let raceFilters = $state<number[]>([])
|
||||
let proficiencyFilters = $state<number[]>([])
|
||||
let genderFilters = $state<number[]>([])
|
||||
|
||||
// Modal state
|
||||
let addModalOpen = $state(false)
|
||||
|
||||
// Sentinel for infinite scroll
|
||||
let sentinelEl = $state<HTMLElement>()
|
||||
|
||||
// Build filters for query
|
||||
const queryFilters = $derived({
|
||||
element: elementFilters.length > 0 ? elementFilters : undefined,
|
||||
rarity: rarityFilters.length > 0 ? rarityFilters : undefined
|
||||
})
|
||||
|
||||
// For owner, use the authenticated collection query with infinite scroll
|
||||
// For non-owner, use the server-loaded public data
|
||||
const collectionQuery = createInfiniteQuery(() => ({
|
||||
...collectionQueries.characters(queryFilters),
|
||||
enabled: data.isOwner,
|
||||
initialData: data.isOwner
|
||||
? undefined
|
||||
: {
|
||||
pages: [
|
||||
{
|
||||
results: data.characters || [],
|
||||
page: 1,
|
||||
totalPages: 1,
|
||||
total: data.characters?.length || 0,
|
||||
perPage: data.characters?.length || 20
|
||||
}
|
||||
],
|
||||
pageParams: [1]
|
||||
}
|
||||
}))
|
||||
|
||||
// Flatten all characters from pages
|
||||
const allCharacters = $derived.by((): CollectionCharacter[] => {
|
||||
if (!data.isOwner) {
|
||||
return data.characters || []
|
||||
}
|
||||
if (!collectionQuery.data?.pages) {
|
||||
return []
|
||||
}
|
||||
return collectionQuery.data.pages.flatMap((page) => page.results ?? [])
|
||||
})
|
||||
|
||||
// Client-side filtering for non-API-supported filters
|
||||
const filteredCharacters = $derived.by((): CollectionCharacter[] => {
|
||||
let result = allCharacters
|
||||
|
||||
// Apply element filter (client-side for non-owner)
|
||||
if (elementFilters.length > 0) {
|
||||
result = result.filter((c) => elementFilters.includes(c.character?.element ?? 0))
|
||||
}
|
||||
|
||||
// Apply rarity filter (client-side for non-owner)
|
||||
if (rarityFilters.length > 0) {
|
||||
result = result.filter((c) => rarityFilters.includes(c.character?.rarity ?? 0))
|
||||
}
|
||||
|
||||
// Apply race filter (client-side) - race is nested object
|
||||
if (raceFilters.length > 0) {
|
||||
result = result.filter((c) => {
|
||||
const race1 = c.character?.race?.race1 ?? 0
|
||||
const race2 = c.character?.race?.race2 ?? 0
|
||||
return raceFilters.includes(race1) || (race2 && raceFilters.includes(race2))
|
||||
})
|
||||
}
|
||||
|
||||
// Apply proficiency filter (client-side) - proficiency is an array
|
||||
if (proficiencyFilters.length > 0) {
|
||||
result = result.filter((c) => {
|
||||
const proficiencies = c.character?.proficiency ?? []
|
||||
return proficiencies.some((p) => proficiencyFilters.includes(p))
|
||||
})
|
||||
}
|
||||
|
||||
// Apply gender filter (client-side)
|
||||
if (genderFilters.length > 0) {
|
||||
result = result.filter((c) => genderFilters.includes(c.character?.gender ?? 0))
|
||||
}
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
// Infinite scroll
|
||||
const inViewport = new IsInViewport(() => sentinelEl, {
|
||||
rootMargin: '200px'
|
||||
})
|
||||
|
||||
$effect(() => {
|
||||
if (
|
||||
data.isOwner &&
|
||||
inViewport.current &&
|
||||
collectionQuery.hasNextPage &&
|
||||
!collectionQuery.isFetchingNextPage &&
|
||||
!collectionQuery.isLoading
|
||||
) {
|
||||
collectionQuery.fetchNextPage()
|
||||
}
|
||||
})
|
||||
|
||||
const isLoading = $derived(data.isOwner && collectionQuery.isLoading)
|
||||
const isEmpty = $derived(!isLoading && filteredCharacters.length === 0)
|
||||
const showSentinel = $derived(
|
||||
data.isOwner && collectionQuery.hasNextPage && !collectionQuery.isFetchingNextPage
|
||||
)
|
||||
|
||||
function handleFiltersChange(filters: CollectionFilterState) {
|
||||
elementFilters = filters.element
|
||||
rarityFilters = filters.rarity
|
||||
raceFilters = filters.race
|
||||
proficiencyFilters = filters.proficiency
|
||||
genderFilters = filters.gender
|
||||
}
|
||||
|
||||
function getImageUrl(character: CollectionCharacter): string {
|
||||
return getCharacterImageWithPose(
|
||||
character.character?.granblueId,
|
||||
'grid',
|
||||
character.uncapLevel,
|
||||
character.transcendenceStep
|
||||
)
|
||||
}
|
||||
|
||||
function displayName(character: CollectionCharacter): string {
|
||||
const name = character.character?.name
|
||||
if (!name) return '—'
|
||||
if (typeof name === 'string') return name
|
||||
return name.en || name.ja || '—'
|
||||
}
|
||||
|
||||
function openCharacterDetails(character: CollectionCharacter) {
|
||||
const characterName =
|
||||
typeof character.character?.name === 'string'
|
||||
? character.character.name
|
||||
: character.character?.name?.en || 'Character'
|
||||
|
||||
sidebar.openWithComponent(characterName, CollectionCharacterPane, {
|
||||
character,
|
||||
isOwner: data.isOwner,
|
||||
onClose: () => sidebar.close()
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="collection-page">
|
||||
<!-- Action bar -->
|
||||
<div class="action-bar">
|
||||
<CollectionFilters
|
||||
bind:elementFilters
|
||||
bind:rarityFilters
|
||||
bind:raceFilters
|
||||
bind:proficiencyFilters
|
||||
bind:genderFilters
|
||||
onFiltersChange={handleFiltersChange}
|
||||
showFilters={{
|
||||
element: true,
|
||||
rarity: true,
|
||||
season: false,
|
||||
series: false,
|
||||
race: true,
|
||||
proficiency: true,
|
||||
gender: true
|
||||
}}
|
||||
layout="horizontal"
|
||||
/>
|
||||
|
||||
{#if data.isOwner}
|
||||
<Button variant="primary" onclick={() => (addModalOpen = true)}>
|
||||
<Icon name="plus" size={16} />
|
||||
Add to Collection
|
||||
</Button>
|
||||
{/if}
|
||||
</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="users" size={48} />
|
||||
<h3>Your collection is empty</h3>
|
||||
<p>Add characters to start building your collection</p>
|
||||
<Button variant="primary" onclick={() => (addModalOpen = true)}>
|
||||
<Icon name="plus" size={16} />
|
||||
Add Characters
|
||||
</Button>
|
||||
{:else}
|
||||
<Icon name="lock" size={48} />
|
||||
<p>This collection is empty or private</p>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="character-grid">
|
||||
{#each filteredCharacters as character (character.id)}
|
||||
<button
|
||||
type="button"
|
||||
class="character-card"
|
||||
onclick={() => openCharacterDetails(character)}
|
||||
>
|
||||
<div class="card-image">
|
||||
{#if character.perpetuity}
|
||||
<img
|
||||
class="perpetuity-badge"
|
||||
src={perpetuityFilled}
|
||||
alt="Perpetuity Ring"
|
||||
title="Perpetuity Ring"
|
||||
/>
|
||||
{/if}
|
||||
<img
|
||||
class="character-image"
|
||||
src={getImageUrl(character)}
|
||||
alt={displayName(character)}
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
<UncapIndicator
|
||||
type="character"
|
||||
uncapLevel={character.uncapLevel}
|
||||
transcendenceStage={character.transcendenceStep}
|
||||
special={character.character?.special}
|
||||
flb={character.character?.uncap?.flb}
|
||||
ulb={character.character?.uncap?.ulb}
|
||||
transcendence={!character.character?.special && character.character?.uncap?.ulb}
|
||||
/>
|
||||
<span class="character-name">{displayName(character)}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#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 data.isOwner && !collectionQuery.hasNextPage && filteredCharacters.length > 0}
|
||||
<div class="end-message">
|
||||
<p>{filteredCharacters.length} character{filteredCharacters.length === 1 ? '' : 's'} in your collection</p>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add to Collection Modal -->
|
||||
{#if data.isOwner}
|
||||
<AddToCollectionModal bind:open={addModalOpen} />
|
||||
{/if}
|
||||
|
||||
<style lang="scss">
|
||||
@use '$src/themes/spacing' as *;
|
||||
@use '$src/themes/colors' as *;
|
||||
@use '$src/themes/typography' as *;
|
||||
@use '$src/themes/rep' as rep;
|
||||
|
||||
.collection-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $unit-2x;
|
||||
}
|
||||
|
||||
.action-bar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: $unit-2x;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.grid-area {
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
.character-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(90px, 1fr));
|
||||
gap: $unit;
|
||||
}
|
||||
|
||||
.character-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: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.card-image {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
@include rep.aspect(rep.$char-cell-w, rep.$char-cell-h);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background: var(--card-bg, #f5f5f5);
|
||||
}
|
||||
|
||||
.perpetuity-badge {
|
||||
position: absolute;
|
||||
top: -$unit-half;
|
||||
right: $unit;
|
||||
width: $unit-3x;
|
||||
height: $unit-3x;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.character-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.character-name {
|
||||
font-size: $font-small;
|
||||
text-align: center;
|
||||
color: $grey-50;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.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