Add ported grid components

This commit is contained in:
Justin Edmund 2025-09-11 10:46:02 -07:00
parent f978be2bfd
commit cf351ef1fc
18 changed files with 1687 additions and 0 deletions

View file

@ -0,0 +1,71 @@
<script lang="ts">
interface Props {
name: string;
size?: number | string;
color?: string;
class?: string;
}
const {
name,
size = 24,
color = 'currentColor',
class: className = ''
}: Props = $props();
let svgContent = $state<string>('');
let loading = $state(true);
$effect(() => {
loadIcon();
});
async function loadIcon() {
try {
loading = true;
const iconModule = await import(`../../assets/icons/${name}.svg?raw`);
let content = iconModule.default;
// Remove width and height attributes to make it responsive
content = content.replace(/width="[^"]*"/g, '');
content = content.replace(/height="[^"]*"/g, '');
// Add viewBox if not present (fallback to 0 0 24 24)
if (!content.includes('viewBox')) {
content = content.replace('<svg', '<svg viewBox="0 0 24 24"');
}
svgContent = content;
} catch (error) {
console.error(`Failed to load icon: ${name}`, error);
svgContent = '';
} finally {
loading = false;
}
}
</script>
{#if !loading && svgContent}
<span
class="icon {className}"
style="width: {typeof size === 'number' ? `${size}px` : size};
height: {typeof size === 'number' ? `${size}px` : size};
color: {color};
display: inline-flex;
align-items: center;
justify-content: center;"
>
{@html svgContent.replace('<svg', `<svg width="100%" height="100%"`)}
</span>
{/if}
<style>
.icon {
line-height: 0;
flex-shrink: 0;
}
.icon :global(svg) {
display: block;
}
</style>

View file

@ -0,0 +1,35 @@
<script lang="ts">
import type { PartyView } from '$lib/api/schemas/party'
import GridRep from '$lib/components/reps/GridRep.svelte'
export let items: PartyView[] = []
</script>
{#if items.length === 0}
<p class="empty">No teams found.</p>
{:else}
<ul class="grid" role="list">
{#each items as p}
<li><GridRep party={p} /></li>
{/each}
</ul>
{/if}
<style lang="scss">
@use '$src/themes/spacing' as *;
@use '$src/themes/mixins' as *;
.grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: $unit-3x;
padding: $unit-2x 0;
@include breakpoint(tablet) { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: $unit-2x; }
@include breakpoint(phone) { grid-template-columns: 1fr; gap: $unit; }
& > li { list-style: none; }
}
.empty { padding: $unit-2x 0; }
</style>

View file

@ -0,0 +1,80 @@
<script lang="ts">
import SummonUnit from '$lib/components/units/SummonUnit.svelte'
import type { GridSummonItemView } from '$lib/api/schemas/party'
export let summons: GridSummonItemView[] = []
export let offset = 4
</script>
<div class="container">
<h3>Subaura</h3>
<ul class="grid" id="ExtraSummons">
{#each [0,1] as i}
<li>
<SummonUnit item={summons[offset + i]} position={offset + i} />
</li>
{/each}
</ul>
</div>
<style lang="scss">
@use '$src/themes/colors' as *;
@use '$src/themes/spacing' as *;
@use '$src/themes/mixins' as *;
.container {
background: var(--subaura-orange-bg);
border-radius: 8px;
box-sizing: border-box;
display: grid;
grid-template-columns: 2.32fr 2fr;
justify-content: center;
margin: 20px auto;
max-width: calc($grid-width + 20px);
padding: $unit-3x $unit-3x $unit-3x 0;
position: relative;
left: 9px;
@include breakpoint(tablet) {
left: auto;
max-width: $grid-width;
padding: $unit-2x;
width: 100%;
}
@include breakpoint(phone) {
display: flex;
gap: $unit-2x;
padding: $unit-2x;
flex-direction: column;
#ExtraSummons {
max-width: 50vw;
margin: 0 auto;
}
}
h3 {
color: var(--subaura-orange-text);
display: flex;
align-items: center;
justify-content: center;
line-height: 1.2;
font-weight: 500;
text-align: center;
}
.grid {
display: grid;
gap: $unit-3x;
grid-template-columns: repeat(2, minmax(0, 1fr));
@include breakpoint(tablet) { gap: $unit-2x; }
@include breakpoint(phone) { gap: $unit; }
& > li { list-style: none; min-height: 0; }
}
}
</style>

View file

@ -0,0 +1,32 @@
<script lang="ts">
import WeaponUnit from '$lib/components/units/WeaponUnit.svelte'
import type { GridWeaponItemView } from '$lib/api/schemas/party'
export let weapons: GridWeaponItemView[] = []
export let offset = 9
</script>
<ul class="grid">
{#each [0,1,2] as i}
<li class:empty={!weapons[offset + i]}>
<WeaponUnit item={weapons[offset + i]} position={offset + i} />
</li>
{/each}
</ul>
<style lang="scss">
@use '$src/themes/spacing' as *;
@use '$src/themes/mixins' as *;
.grid {
display: grid;
gap: $unit-3x;
grid-template-columns: repeat(3, minmax(0, 1fr));
@include breakpoint(tablet) { gap: $unit-2x; }
@include breakpoint(phone) { gap: $unit; }
& > li { list-style: none; }
}
</style>

View file

@ -0,0 +1,69 @@
<script lang="ts">
import { getContext } from 'svelte'
import type { PartyView } from '$lib/api/schemas/party'
export let item: any | undefined
export let position: number // 1..3
type PartyCtx = {
getParty: () => PartyView
updateParty: (p: PartyView) => void
canEdit: () => boolean
services: { partyService: any }
}
const ctx = getContext<PartyCtx>('party')
function displayName(input: any): string {
if (!input) return '—'
const maybe = input.name ?? input
if (typeof maybe === 'string') return maybe
if (maybe && typeof maybe === 'object') return maybe.en || maybe.ja || '—'
return '—'
}
function guidebookImageUrl(g?: any): string {
const id = g?.granblueId
if (!id) return '/images/placeholders/placeholder-weapon-grid.png'
return `/images/guidebooks/book_${id}.png`
}
async function remove() {
const party = ctx.getParty()
const editKey = ctx.services.partyService.getEditKey(party.shortcode)
const updated = await ctx.services.partyService.updateGuidebooks(party.id, position - 1, null, editKey || undefined)
ctx.updateParty(updated)
}
async function add() {
const party = ctx.getParty()
const editKey = ctx.services.partyService.getEditKey(party.shortcode)
const id = window.prompt('Enter guidebook ID to add')
if (!id) return
const updated = await ctx.services.partyService.updateGuidebooks(party.id, position - 1, id, editKey || undefined)
ctx.updateParty(updated)
}
</script>
<div class="unit">
<img class="image" alt={item ? displayName(item) : ''} src={guidebookImageUrl(item)} />
<div class="name">{item ? displayName(item) : '—'}</div>
{#if ctx.canEdit() && !item}
<button class="add" title="Add" on:click={add}></button>
{/if}
{#if ctx.canEdit() && item}
<button class="remove" title="Remove" on:click={remove}>×</button>
{/if}
</div>
<style lang="scss">
@use '$src/themes/colors' as *;
@use '$src/themes/typography' as *;
@use '$src/themes/spacing' as *;
.unit { position: relative; width: 100%; display: flex; flex-direction: column; align-items: center; gap: $unit; }
.image { width: 100%; height: auto; border: 1px solid $grey-75; border-radius: 8px; display: block; background: var(--extra-purple-card-bg); }
.name { font-size: $font-small; text-align: center; color: $grey-50; }
.remove, .add { position: absolute; top: 6px; right: 6px; background: rgba(0,0,0,.6); color: white; border: none; border-radius: 12px; width: 24px; height: 24px; line-height: 24px; cursor: pointer; }
.add { right: 6px; }
</style>

View file

@ -0,0 +1,35 @@
<script lang="ts">
import GuidebookUnit from '$lib/components/extra/GuidebookUnit.svelte'
// In API, guidebooks comes as a record with keys '1','2','3'
export let guidebooks: Record<string, any> | undefined
</script>
<div class="guidebooks">
<ul class="grid">
{#each [1,2,3] as pos}
<li>
<GuidebookUnit item={guidebooks?.[String(pos)]} position={pos} />
</li>
{/each}
</ul>
</div>
<style lang="scss">
@use '$src/themes/spacing' as *;
@use '$src/themes/mixins' as *;
.guidebooks {
.grid {
display: grid;
gap: $unit-3x;
grid-template-columns: repeat(3, minmax(0, 1fr));
@include breakpoint(tablet) { gap: $unit-2x; }
@include breakpoint(phone) { gap: $unit; }
& > li { list-style: none; }
}
}
</style>

View file

@ -0,0 +1,77 @@
<script lang="ts">
import type { GridCharacterItemView } from '$lib/api/schemas/party'
export let characters: GridCharacterItemView[] = []
export let mainWeaponElement: number | null | undefined = undefined
export let partyElement: number | null | undefined = undefined
function displayName(input: any): string {
if (!input) return '—'
const maybe = input.name ?? input
if (typeof maybe === 'string') return maybe
if (maybe && typeof maybe === 'object') return maybe.en || maybe.ja || '—'
return '—'
}
function characterImageUrl(c?: GridCharacterItemView): string {
const id = (c as any)?.object?.granblueId
if (!id) return '/images/placeholders/placeholder-weapon-grid.png'
const uncap = (c as any)?.uncapLevel ?? 0
const transStep = (c as any)?.transcendenceStep ?? 0
let suffix = '01'
if (transStep > 0) suffix = '04'
else if (uncap >= 5) suffix = '03'
else if (uncap > 2) suffix = '02'
// Lyria special case (3030182000): append element
if (String(id) === '3030182000') {
let element = mainWeaponElement || partyElement || 1
suffix = `${suffix}_0${element}`
}
return `/images/character-main/${id}_${suffix}.jpg`
}
import CharacterUnit from '$lib/components/units/CharacterUnit.svelte'
const grid = Array.from({ length: 5 }, (_, i) => characters.find((c) => c.position === i))
</script>
<div class="wrapper">
<ul class="characters" aria-label="Character Grid">
{#each grid as c, i}
<li aria-label={`Character slot ${i}`}>
<CharacterUnit item={c} position={i} {mainWeaponElement} {partyElement} />
</li>
{/each}
</ul>
</div>
<style lang="scss">
@use '$src/themes/colors' as *;
@use '$src/themes/typography' as *;
@use '$src/themes/spacing' as *;
.characters {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: $unit-3x;
& > li { list-style: none; }
}
.unit {
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
gap: $unit;
}
.image { width: 100%; height: auto; border: 1px solid $grey-75; border-radius: 8px; display: block; }
.name {
font-size: $font-small;
text-align: center;
color: $grey-50;
}
</style>

View file

@ -0,0 +1,128 @@
<script lang="ts">
import type { GridSummonItemView } from '$lib/api/schemas/party'
export let summons: GridSummonItemView[] = []
function displayName(input: any): string {
if (!input) return '—'
const maybe = input.name ?? input
if (typeof maybe === 'string') return maybe
if (maybe && typeof maybe === 'object') return maybe.en || maybe.ja || '—'
return '—'
}
function summonImageUrl(s?: GridSummonItemView): string {
const id = (s as any)?.object?.granblueId
const isMain = s?.main || s?.position === -1 || s?.friend || s?.position === 6
if (!id) return isMain ? '/images/placeholders/placeholder-summon-main.png' : '/images/placeholders/placeholder-summon-grid.png'
const folder = isMain ? 'summon-main' : 'summon-grid'
return `/images/${folder}/${id}.jpg`
}
import SummonUnit from '$lib/components/units/SummonUnit.svelte'
import ExtraSummons from '$lib/components/extra/ExtraSummonsGrid.svelte'
const main = summons.find((s) => s.main || s.position === -1)
const friend = summons.find((s) => s.friend || s.position === 6)
const grid = Array.from({ length: 4 }, (_, i) => summons.find((s) => s.position === i))
</script>
<div class="wrapper">
<div class="grid">
<div class="LabeledUnit">
<div class="label">Main</div>
<SummonUnit item={main} position={-1} />
</div>
<section>
<div class="label">Summons</div>
<ul class="summons">
{#each grid as s, i}
<li aria-label={`Summon slot ${i}`}>
<SummonUnit item={s} position={i} />
</li>
{/each}
</ul>
</section>
<div class="LabeledUnit">
<div class="label friend">Friend</div>
<SummonUnit item={friend} position={6} />
</div>
</div>
<ExtraSummons summons={summons} offset={4} />
</div>
<style lang="scss">
@use '$src/themes/colors' as *;
@use '$src/themes/typography' as *;
@use '$src/themes/spacing' as *;
@use '$src/themes/mixins' as *;
.grid {
display: grid;
grid-template-columns: 1.17fr 2fr 1.17fr;
gap: $unit-3x;
justify-content: center;
margin: 0 auto;
max-width: $grid-width;
@include breakpoint(tablet) {
gap: $unit-2x;
}
@include breakpoint(phone) {
gap: $unit;
}
& .label {
color: $grey-55;
font-size: $font-tiny;
font-weight: $medium;
margin-bottom: $unit;
text-align: center;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
@include breakpoint(phone) {
&.friend {
max-width: 78px;
}
}
}
.summons {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
grid-template-rows: repeat(2, minmax(0, 1fr));
gap: $unit-3x;
@include breakpoint(tablet) {
gap: $unit-2x;
}
@include breakpoint(phone) {
gap: $unit;
}
& > li {
list-style: none;
}
}
}
.unit {
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
gap: $unit;
}
.image { width: 100%; height: auto; border: 1px solid $grey-75; border-radius: 8px; display: block; }
.name {
font-size: $font-small;
text-align: center;
color: $grey-50;
}
</style>

View file

@ -0,0 +1,140 @@
<script lang="ts">
import type { GridWeaponItemView } from '$lib/api/schemas/party'
export let weapons: GridWeaponItemView[] = []
export let raidExtra: boolean | undefined = undefined
export let showGuidebooks: boolean | undefined = undefined
export let guidebooks: Record<string, any> | undefined = undefined
function displayName(input: any): string {
if (!input) return '—'
const maybe = input.name ?? input
if (typeof maybe === 'string') return maybe
if (maybe && typeof maybe === 'object') return maybe.en || maybe.ja || '—'
return '—'
}
function weaponImageUrl(w?: GridWeaponItemView): string {
const id = (w as any)?.object?.granblueId
const isMain = !!(w && ((w as any).mainhand || (w as any).position === -1))
if (!id) return isMain
? '/images/placeholders/placeholder-weapon-main.png'
: '/images/placeholders/placeholder-weapon-grid.png'
const folder = isMain ? 'weapon-main' : 'weapon-grid'
// Neutral element override: if object.element == 0 and instance element present, append _<element>
const objElement = (w as any)?.object?.element
const instElement = (w as any)?.element
if (objElement === 0 && instElement) {
return `/images/${folder}/${id}_${instElement}.jpg`
}
// For local static images, transcendence variants are not split by suffix; use base
return `/images/${folder}/${id}.jpg`
}
import WeaponUnit from '$lib/components/units/WeaponUnit.svelte'
import ExtraWeapons from '$lib/components/extra/ExtraWeaponsGrid.svelte'
import Guidebooks from '$lib/components/extra/GuidebooksGrid.svelte'
const mainhand = weapons.find((w) => (w as any).mainhand || w.position === -1)
const grid = Array.from({ length: 9 }, (_, i) => weapons.find((w) => w.position === i))
</script>
<div class="wrapper">
<div class="grid">
<div aria-label="Mainhand Weapon">
<WeaponUnit item={mainhand} position={-1} />
</div>
<ul class="weapons" aria-label="Weapon Grid">
{#each grid as w, i}
<li class:Empty={!w} aria-label={`Weapon slot ${i}`}>
<WeaponUnit item={w} {i} position={i} />
</li>
{/each}
</ul>
</div>
{#if raidExtra}
<ExtraWeapons weapons={weapons} offset={9} />
{/if}
{#if showGuidebooks}
<Guidebooks {guidebooks} />
{/if}
</div>
<style lang="scss">
@use '$src/themes/colors' as *;
@use '$src/themes/typography' as *;
@use '$src/themes/spacing' as *;
@use '$src/themes/mixins' as *;
.wrapper {
align-items: center;
display: flex;
flex-direction: column;
justify-content: center;
@include breakpoint(phone) {
margin: 0 2px;
}
.grid {
display: grid;
gap: $unit-3x;
grid-template-columns: 1.278fr 3fr;
justify-items: center;
grid-template-areas: 'mainhand grid';
max-width: $grid-width;
@include breakpoint(tablet) {
gap: $unit-2x;
}
@include breakpoint(phone) {
gap: $unit;
}
.weapons {
display: grid; /* make the right-images container a grid */
grid-template-columns: repeat(
3,
minmax(0, 1fr)
); /* create 3 columns, each taking up 1 fraction */
grid-template-rows: repeat(
3,
1fr
); /* create 3 rows, each taking up 1 fraction */
gap: $unit-3x;
@include breakpoint(tablet) {
gap: $unit-2x;
}
@include breakpoint(phone) {
gap: $unit;
}
& > li {
list-style: none;
}
}
}
}
.unit {
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
gap: $unit;
}
.image { width: 100%; height: auto; border: 1px solid $grey-75; border-radius: 8px; display: block; }
.name {
font-size: $font-small;
text-align: center;
color: $grey-50;
}
</style>

View file

@ -0,0 +1,393 @@
<script lang="ts">
import { onMount, getContext, setContext } from 'svelte'
import type { PartyView } from '$lib/api/schemas/party'
import { PartyService } from '$lib/services/party.service'
import { GridService } from '$lib/services/grid.service'
import { ConflictService } from '$lib/services/conflict.service'
import WeaponGrid from '$lib/components/grids/WeaponGrid.svelte'
import SummonGrid from '$lib/components/grids/SummonGrid.svelte'
import CharacterGrid from '$lib/components/grids/CharacterGrid.svelte'
interface Props {
initial: PartyView
canEditServer?: boolean
authUserId?: string
}
let { initial, canEditServer = false, authUserId }: Props = $props()
// Per-route local state using Svelte 5 runes
let party = $state<PartyView>(initial)
let activeTab = $state<'weapons' | 'summons' | 'characters'>('weapons')
let loading = $state(false)
let error = $state<string | null>(null)
// Services
const partyService = new PartyService(fetch)
const gridService = new GridService(fetch)
const conflictService = new ConflictService(fetch)
// Localized name helper: accepts either an object with { name: { en, ja } }
// or a direct { en, ja } map, or a plain string.
function displayName(input: any): string {
if (!input) return '—'
const maybe = input.name ?? input
if (typeof maybe === 'string') return maybe
if (maybe && typeof maybe === 'object') {
return maybe.en || maybe.ja || '—'
}
return '—'
}
// Client-side editability state
let localId = $state<string>('')
let editKey = $state<string | null>(null)
// Derived editability (combines server and client state)
let canEdit = $derived(() => {
if (canEditServer) return true
// Re-compute on client with localStorage values
const result = partyService.computeEditability(
party,
authUserId,
localId,
editKey
)
return result.canEdit
})
// Tab configuration
const tabs = [
{ key: 'weapons', label: 'Weapons', count: party.weapons?.length || 0 },
{ key: 'summons', label: 'Summons', count: party.summons?.length || 0 },
{ key: 'characters', label: 'Characters', count: party.characters?.length || 0 }
] as const
// Derived elements for character image logic
const mainWeapon = $derived(() => party.weapons.find(w => (w as any)?.mainhand || (w as any)?.position === -1))
const mainWeaponElement = $derived(() => (mainWeapon as any)?.element ?? (mainWeapon as any)?.object?.element)
const partyElement = $derived(() => (party as any)?.element)
function selectTab(key: typeof tabs[number]['key']) {
activeTab = key
}
// Party operations
async function updatePartyDetails(updates: Partial<Party>) {
if (!canEdit()) return
loading = true
error = null
try {
const updated = await partyService.update(
party.id,
updates,
editKey || undefined
)
party = updated
} catch (err: any) {
error = err.message || 'Failed to update party'
} finally {
loading = false
}
}
async function toggleFavorite() {
if (!authUserId) return // Must be logged in to favorite
loading = true
error = null
try {
if (party.favorited) {
await partyService.unfavorite(party.id)
party.favorited = false
} else {
await partyService.favorite(party.id)
party.favorited = true
}
} catch (err: any) {
error = err.message || 'Failed to update favorite status'
} finally {
loading = false
}
}
async function remixParty() {
loading = true
error = null
try {
const result = await partyService.remix(
party.shortcode,
localId,
editKey || undefined
)
// Store new edit key if returned
if (result.editKey) {
editKey = result.editKey
}
// Navigate to new party
window.location.href = `/teams/${result.party.shortcode}`
} catch (err: any) {
error = err.message || 'Failed to remix party'
} finally {
loading = false
}
}
// Client-side initialization
onMount(() => {
// Get or create local ID
localId = partyService.getLocalId()
// Get edit key for this party if it exists
editKey = partyService.getEditKey(party.shortcode)
})
// Provide services to child components via context
setContext('party', {
getParty: () => party,
updateParty: (p: PartyView) => party = p,
canEdit: () => canEdit(),
services: { partyService, gridService, conflictService }
})
</script>
<section class="party-container">
<header class="party-header">
<div class="party-info">
<h1>{party.name || '(untitled party)'}</h1>
{#if party.description}
<p class="description">{party.description}</p>
{/if}
</div>
<div class="party-actions">
{#if authUserId}
<button
class="favorite-btn"
class:favorited={party.favorited}
on:click={toggleFavorite}
disabled={loading}
aria-label={party.favorited ? 'Remove from favorites' : 'Add to favorites'}
>
{party.favorited ? '★' : '☆'}
</button>
{/if}
<button
class="remix-btn"
on:click={remixParty}
disabled={loading}
aria-label="Remix this party"
>
Remix
</button>
</div>
</header>
{#if party.raid}
<div class="raid-info">
<span class="raid-name">
{typeof party.raid.name === 'string' ? party.raid.name : party.raid.name?.en || party.raid.name?.ja || 'Unknown Raid'}
</span>
{#if party.raid.group}
<span class="raid-difficulty">Difficulty: {party.raid.group.difficulty}</span>
{/if}
</div>
{/if}
<nav class="party-tabs" aria-label="Party sections">
{#each tabs as t}
<button
class="tab-btn"
aria-pressed={activeTab === t.key}
class:active={activeTab === t.key}
on:click={() => selectTab(t.key)}
>
{t.label}
{#if t.count > 0}
<span class="count">({t.count})</span>
{/if}
</button>
{/each}
</nav>
{#if error}
<div class="error-message" role="alert">
{error}
</div>
{/if}
<div class="party-content">
{#if activeTab === 'weapons'}
<WeaponGrid
weapons={party.weapons}
raidExtra={(party as any)?.raid?.group?.extra}
showGuidebooks={(party as any)?.raid?.group?.guidebooks}
guidebooks={(party as any)?.guidebooks}
/>
{:else if activeTab === 'summons'}
<SummonGrid summons={party.summons} />
{:else}
<CharacterGrid characters={party.characters} mainWeaponElement={mainWeaponElement} partyElement={partyElement} />
{/if}
</div>
{#if canEdit()}
<footer class="party-footer">
<p class="edit-indicator">✏️ You can edit this party</p>
</footer>
{/if}
</section>
<style>
.party-container {
max-width: 1200px;
margin: 0 auto;
padding: 1rem;
}
.party-header {
display: flex;
justify-content: space-between;
align-items: start;
margin-bottom: 1rem;
}
.party-info h1 {
margin: 0 0 0.5rem 0;
font-size: 1.5rem;
}
.description {
color: #666;
margin: 0;
}
.party-actions {
display: flex;
gap: 0.5rem;
}
.favorite-btn,
.remix-btn {
padding: 0.5rem 1rem;
border: 1px solid #ccc;
border-radius: 6px;
background: white;
cursor: pointer;
transition: all 0.2s;
}
.favorite-btn {
font-size: 1.2rem;
padding: 0.5rem;
}
.favorite-btn.favorited {
color: gold;
}
.favorite-btn:hover,
.remix-btn:hover {
background: #f5f5f5;
}
.favorite-btn:disabled,
.remix-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.raid-info {
display: flex;
gap: 1rem;
margin-bottom: 1rem;
padding: 0.5rem;
background: #f9f9f9;
border-radius: 4px;
font-size: 0.9rem;
}
.raid-name {
font-weight: 600;
}
.party-tabs {
display: flex;
gap: 0.5rem;
margin-bottom: 1rem;
border-bottom: 2px solid #eee;
}
.tab-btn {
padding: 0.5rem 1rem;
border: none;
background: transparent;
cursor: pointer;
position: relative;
transition: all 0.2s;
}
.tab-btn.active {
color: #3366ff;
}
.tab-btn.active::after {
content: '';
position: absolute;
bottom: -2px;
left: 0;
right: 0;
height: 2px;
background: #3366ff;
}
.tab-btn .count {
color: #999;
font-size: 0.9em;
}
.error-message {
padding: 0.75rem;
background: #fee;
border: 1px solid #fcc;
border-radius: 4px;
color: #c00;
margin-bottom: 1rem;
}
.party-content {
min-height: 400px;
}
.grid-placeholder {
padding: 2rem;
background: #f9f9f9;
border-radius: 8px;
text-align: center;
}
.grid-placeholder ul {
text-align: left;
max-width: 400px;
margin: 1rem auto;
}
.party-footer {
margin-top: 2rem;
padding-top: 1rem;
border-top: 1px solid #eee;
}
.edit-indicator {
color: #3366ff;
font-size: 0.9rem;
}
</style>

View file

@ -0,0 +1,58 @@
<script lang="ts">
import type { PartyView } from '$lib/api/schemas/party'
export let party: PartyView
const characters = party.characters || []
const grid = Array.from({ length: 3 }, (_, i) => characters.find((c: any) => c?.position === i))
function protagonistClass(): string {
const main = (party.weapons || []).find((w: any) => w?.mainhand || w?.position === -1)
const el = (main as any)?.element || (main as any)?.object?.element
switch (el) { case 1: return 'wind'; case 2: return 'fire'; case 3: return 'water'; case 4: return 'earth'; case 5: return 'dark'; case 6: return 'light'; default: return '' }
}
function characterImageUrl(c?: any): string {
const id = c?.object?.granblueId
if (!id) return ''
const uncap = c?.uncapLevel ?? 0
const trans = c?.transcendenceStep ?? 0
let suffix = '01'
if (trans > 0) suffix = '04'
else if (uncap >= 5) suffix = '03'
else if (uncap > 2) suffix = '02'
if (String(id) === '3030182000') {
const main = (party.weapons || []).find((w: any) => w?.mainhand || w?.position === -1)
const el = (main as any)?.element || (main as any)?.object?.element || 1
suffix = `${suffix}_0${el}`
}
return `/images/character-main/${id}_${suffix}.jpg`
}
</script>
<div class="rep">
<ul class="characters">
<li class={`protagonist ${protagonistClass()}`}></li>
{#each grid as c, i}
<li class="character">{#if c}<img alt="Character" src={characterImageUrl(c)} />{/if}</li>
{/each}
</ul>
</div>
<style lang="scss">
@use '$src/themes/spacing' as *;
.rep { aspect-ratio: 2/0.99; border-radius: 10px; grid-gap: $unit-half; height: $rep-height; opacity: .5; }
.character, .protagonist { aspect-ratio: 16/33; background: var(--card-bg); border-radius: 4px; box-sizing: border-box; display: grid; overflow: hidden; }
.character img { border-radius: 4px; width: 100%; }
.characters { display: grid; grid-template-columns: repeat(4, 1fr); gap: $unit-half; }
.protagonist { border-color: transparent; border-width: 1px; border-style: solid; aspect-ratio: 32/66; }
.protagonist img { position: relative; width: 100%; height: 100%; }
.protagonist.wind { background: var(--wind-portrait-bg); border-color: var(--wind-bg); }
.protagonist.fire { background: var(--fire-portrait-bg); border-color: var(--fire-bg); }
.protagonist.water { background: var(--water-portrait-bg); border-color: var(--water-bg); }
.protagonist.earth { background: var(--earth-portrait-bg); border-color: var(--earth-bg); }
.protagonist.light { background: var(--light-portrait-bg); border-color: var(--light-bg); }
.protagonist.dark { background: var(--dark-portrait-bg); border-color: var(--dark-bg); }
.protagonist.empty { background: var(--card-bg); }
</style>

View file

@ -0,0 +1,128 @@
<script lang="ts">
import type { PartyView } from '$lib/api/schemas/party'
import WeaponRep from '$lib/components/reps/WeaponRep.svelte'
import SummonRep from '$lib/components/reps/SummonRep.svelte'
import CharacterRep from '$lib/components/reps/CharacterRep.svelte'
export let party: PartyView
export let href: string = `/teams/${party.shortcode}`
export let loading = false
let currentView: 'weapons' | 'summons' | 'characters' = 'weapons'
function displayName(input: any): string {
if (!input) return '—'
const maybe = input.name ?? input
if (typeof maybe === 'string') return maybe
if (maybe && typeof maybe === 'object') return maybe.en || maybe.ja || '—'
return '—'
}
</script>
<div class={`gridRep ${loading ? 'hidden' : 'visible'}`} on:mouseleave={() => currentView='weapons'}>
<a href={href} data-sveltekit-preload-data="hover">
<div class="details">
<div class="top">
<div class="info">
<h2 class:empty={!party.name}>{party.name || '(untitled)'}</h2>
<div class="properties">
<span class={`raid ${!party.raid ? 'empty' : ''}`}>{party.raid ? displayName(party.raid) : 'No raid'}</span>
{#if party.fullAuto}<span class="fullAuto"> · Full Auto</span>{/if}
{#if party.raid?.group?.extra}<span class="extra"> · EX</span>{/if}
</div>
</div>
</div>
</div>
<div class="gridContainer">
{#if currentView==='characters'}
<div class="characterGrid"><CharacterRep {party} /></div>
{:else if currentView==='summons'}
<div class="summonGrid"><SummonRep {party} /></div>
{:else}
<div class="weaponGrid"><WeaponRep {party} /></div>
{/if}
</div>
<ul class="indicators">
<li class:active={currentView==='characters'} on:mouseenter={() => currentView='characters'}>
<div class="indicator" />
<span class="sr-only">Characters</span>
</li>
<li class:active={currentView==='weapons'} on:mouseenter={() => currentView='weapons'}>
<div class="indicator" />
<span class="sr-only">Weapons</span>
</li>
<li class:active={currentView==='summons'} on:mouseenter={() => currentView='summons'}>
<div class="indicator" />
<span class="sr-only">Summons</span>
</li>
</ul>
</a>
</div>
<style lang="scss">
@use '$src/themes/spacing' as *;
.gridRep { aspect-ratio: 3/2; border-radius: 10px; box-sizing: border-box; min-width: 320px; position: relative; width: 100%; opacity: 1; }
.gridRep.visible { transition: opacity .3s ease-in-out; opacity: 1; }
.gridRep.hidden { opacity: 0; transition: opacity .12s ease-in-out; }
.gridRep a {
display: grid;
grid-template-rows: auto 1fr;
gap: 8px;
padding: $unit-2x;
text-decoration: none;
color: inherit;
width: 100%;
height: 100%;
border: 1px solid transparent;
border-radius: 10px;
box-sizing: border-box;
background: var(--card-bg);
overflow: hidden;
}
.gridRep a:hover { background: var(--grid-rep-hover); border-color: rgba(0,0,0,.1); }
.gridRep a:hover .indicators { opacity: 1; }
.gridContainer { aspect-ratio: 2.1/1; width: 100%; align-self: start; }
.weaponGrid { aspect-ratio: 3.25/1; }
.characterGrid { aspect-ratio: 2.1/1; }
.summonGrid { aspect-ratio: 2/0.91; }
.details { display: flex; flex-direction: column; gap: $unit; }
.details .top { display: flex; flex-direction: row; gap: calc($unit/2); align-items: center; }
.details .info { display: flex; flex-direction: column; flex-grow: 1; gap: calc($unit/2); max-width: calc(100% - 44px - $unit); }
.details h2 { color: var(--text-primary); font-size: 1.6rem; font-weight: 600; overflow: hidden; padding-bottom: 1px; text-overflow: ellipsis; white-space: nowrap; min-height: 24px; max-width: 258px; }
.details h2.empty { color: var(--text-tertiary); }
.properties { display: flex; font-size: 1.3rem; gap: $unit-half; }
.raid.empty { color: var(--text-tertiary); }
.fullAuto { color: var(--full-auto-label-text); white-space: nowrap; }
.extra { color: var(--extra-purple-light-text); white-space: nowrap; }
.indicators {
display: flex;
flex-direction: row;
gap: $unit;
margin-top: $unit;
margin-bottom: $unit;
justify-content: center;
opacity: 0;
list-style: none;
padding-left: 0;
}
.indicators li { flex-grow: 1; padding: $unit 0; position: relative; }
.indicator { transition: background-color .12s ease-in-out; height: $unit; border-radius: $unit-half; background-color: var(--button-contained-bg-hover); }
.indicators li:hover .indicator, .indicators li.active .indicator { background-color: var(--text-secondary); }
/* Visually hidden, accessible to screen readers */
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
</style>

View file

@ -0,0 +1,23 @@
<script lang="ts">
import type { PartyView } from '$lib/api/schemas/party'
import GridRep from '$lib/components/reps/GridRep.svelte'
export let items: PartyView[] = []
</script>
<ul class="collection" role="list">
{#each items as p}
<li><GridRep party={p} /></li>
{/each}
</ul>
<style lang="scss">
@use '$src/themes/spacing' as *;
@use '$src/themes/mixins' as *;
.collection { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: $unit-2x; padding: 0; }
.collection > li { list-style: none; }
@include breakpoint(phone) { .collection { grid-template-columns: 1fr; } }
</style>

View file

@ -0,0 +1,45 @@
<script lang="ts">
import type { PartyView, GridSummonItemView } from '$lib/api/schemas/party'
export let party: PartyView
const summons = party.summons || []
const main = summons.find((s: any) => s?.main || s?.position === -1)
const grid = Array.from({ length: 4 }, (_, i) => summons.find((s: any) => s?.position === i))
function summonImageUrl(s?: any, isMain = false): string {
const id = s?.object?.granblueId
if (!id) return ''
const folder = isMain ? 'summon-main' : 'summon-grid'
return `/images/${folder}/${id}.jpg`
}
</script>
<div class="rep">
<div class="mainSummon">{#if main}<img alt="Main Summon" src={summonImageUrl(main, true)} />{/if}</div>
<ul class="summons">
{#each grid as s, i}
<li class="summon">{#if s}<img alt="Summon" src={summonImageUrl(s)} />{/if}</li>
{/each}
</ul>
</div>
<style lang="scss">
@use '$src/themes/spacing' as *;
.rep {
aspect-ratio: 2/1.045;
border-radius: 10px;
display: grid;
grid-template-columns: 1fr 2.25fr;
grid-gap: $unit-half;
height: $rep-height;
opacity: .5;
}
.summon, .mainSummon { background: var(--card-bg); border-radius: 4px; }
.mainSummon { aspect-ratio: 56/97; display: grid; }
.summons { display: grid; grid-template-columns: repeat(2, 1fr); grid-template-rows: repeat(2, 1fr); gap: $unit-half; }
.summon { aspect-ratio: 184/138; display: grid; }
.summon img, .mainSummon img { border-radius: 4px; width: 100%; height: 100%; }
</style>

View file

@ -0,0 +1,49 @@
<script lang="ts">
import type { PartyView, GridWeaponItemView } from '$lib/api/schemas/party'
export let party: PartyView
const weapons = party.weapons || []
const mainhand: GridWeaponItemView | undefined = weapons.find((w: any) => w?.mainhand || w?.position === -1)
const grid = Array.from({ length: 9 }, (_, i) => weapons.find((w: any) => w?.position === i))
function weaponImageUrl(w?: any, isMain = false): string {
const id = w?.object?.granblueId
if (!id) return ''
const folder = isMain ? 'weapon-main' : 'weapon-grid'
const objElement = w?.object?.element
const instElement = w?.element
if (objElement === 0 && instElement) return `/images/${folder}/${id}_${instElement}.jpg`
return `/images/${folder}/${id}.jpg`
}
</script>
<div class="rep">
<div class="mainhand">{#if mainhand}<img alt="Mainhand" src={weaponImageUrl(mainhand, true)} />{/if}</div>
<ul class="weapons">
{#each grid as w, i}
<li class="weapon">{#if w}<img alt="Weapon" src={weaponImageUrl(w)} />{/if}</li>
{/each}
</ul>
</div>
<style lang="scss">
@use '$src/themes/spacing' as *;
@use '$src/themes/colors' as *;
.rep {
height: $rep-height;
display: grid;
grid-template-columns: calc(#{$rep-height} * (200 / 420)) auto;
gap: $unit-half;
box-sizing: border-box;
}
.mainhand { background: var(--card-bg); border-radius: 4px; height: 100%; width: 100%; overflow: hidden; }
.mainhand img { width: 100%; height: 100%; object-fit: contain; }
.weapons { margin: 0; padding: 0; list-style: none; height: 100%; display: grid; grid-template-rows: repeat(3, calc((#{$rep-height} - (2 * #{$unit-half})) / 3)); grid-template-columns: repeat(3, calc((#{$rep-height} - (2 * #{$unit-half})) / 3 * (280 / 160))); gap: $unit-half; }
.weapon { background: var(--card-bg); border-radius: 4px; overflow: hidden; display: flex; align-items: center; justify-content: center; }
.weapon img { width: 100%; height: 100%; }
</style>

View file

@ -0,0 +1,105 @@
<script lang="ts">
import type { GridCharacterItemView, PartyView } from '$lib/api/schemas/party'
import { getContext } from 'svelte'
export let item: GridCharacterItemView | undefined
export let position: number
export let mainWeaponElement: number | null | undefined
export let partyElement: number | null | undefined
type PartyCtx = {
getParty: () => PartyView
updateParty: (p: PartyView) => void
canEdit: () => boolean
services: { gridService: any; partyService: any }
}
const ctx = getContext<PartyCtx>('party')
function displayName(input: any): string {
if (!input) return '—'
const maybe = input.name ?? input
if (typeof maybe === 'string') return maybe
if (maybe && typeof maybe === 'object') return maybe.en || maybe.ja || '—'
return '—'
}
function characterImageUrl(c?: GridCharacterItemView): string {
const id = (c as any)?.object?.granblueId
if (!id) return '/images/placeholders/placeholder-weapon-grid.png'
const uncap = (c as any)?.uncapLevel ?? 0
const transStep = (c as any)?.transcendenceStep ?? 0
let suffix = '01'
if (transStep > 0) suffix = '04'
else if (uncap >= 5) suffix = '03'
else if (uncap > 2) suffix = '02'
if (String(id) === '3030182000') {
let element = mainWeaponElement || partyElement || 1
suffix = `${suffix}_0${element}`
}
return `/images/character-main/${id}_${suffix}.jpg`
}
async function remove() {
if (!item?.id) return
const party = ctx.getParty()
const editKey = ctx.services.partyService.getEditKey(party.shortcode)
const updated = await ctx.services.gridService.removeCharacter(party.id, item.id as any, editKey || undefined)
ctx.updateParty(updated)
}
async function add() {
const party = ctx.getParty()
const editKey = ctx.services.partyService.getEditKey(party.shortcode)
const id = window.prompt('Enter character ID to add')
if (!id) return
const res = await ctx.services.gridService.addCharacter(party.id, id, position, editKey || undefined)
if (res.conflicts) {
window.alert('Conflict detected. Please resolve via UI in a later step.')
return
}
ctx.updateParty(res.party)
}
async function replaceItem() {
if (!item?.id) return add()
const party = ctx.getParty()
const editKey = ctx.services.partyService.getEditKey(party.shortcode)
const id = window.prompt('Enter new character ID')
if (!id) return
const res = await ctx.services.gridService.replaceCharacter(party.id, item.id as any, id, editKey || undefined)
if (res.conflicts) {
window.alert('Conflict detected. Please resolve via UI in a later step.')
return
}
ctx.updateParty(res.party)
}
</script>
<div class="unit">
<img class="image" alt={item ? displayName(item.object) : ''} src={characterImageUrl(item)} />
<div class="name">{item ? displayName(item.object) : '—'}</div>
{#if ctx.canEdit() && !item}
<button class="add" title="Add" on:click={add}></button>
{/if}
{#if ctx.canEdit() && item?.id}
<div class="actions">
<button class="replace" title="Replace" on:click={replaceItem}>↺</button>
<button class="remove" title="Remove" on:click={remove}>×</button>
</div>
{/if}
{#if ctx.canEdit() && item?.id}
<button class="remove" title="Remove" on:click={remove}>×</button>
{/if}
</div>
<style lang="scss">
@use '$src/themes/colors' as *;
@use '$src/themes/typography' as *;
@use '$src/themes/spacing' as *;
.unit { position: relative; width: 100%; display: flex; flex-direction: column; align-items: center; gap: $unit; }
.image { width: 100%; height: auto; border: 1px solid $grey-75; border-radius: 8px; display: block; }
.name { font-size: $font-small; text-align: center; color: $grey-50; }
.actions { position: absolute; top: 6px; right: 6px; display: flex; gap: 6px; }
.remove, .replace, .add { background: rgba(0,0,0,.6); color: white; border: none; border-radius: 12px; width: 24px; height: 24px; line-height: 24px; cursor: pointer; }
.add { position: absolute; top: 6px; right: 6px; }
</style>

View file

@ -0,0 +1,94 @@
<script lang="ts">
import type { GridSummonItemView, PartyView } from '$lib/api/schemas/party'
import { getContext } from 'svelte'
export let item: GridSummonItemView | undefined
export let position: number
type PartyCtx = {
getParty: () => PartyView
updateParty: (p: PartyView) => void
canEdit: () => boolean
services: { gridService: any; partyService: any }
}
const ctx = getContext<PartyCtx>('party')
function displayName(input: any): string {
if (!input) return '—'
const maybe = input.name ?? input
if (typeof maybe === 'string') return maybe
if (maybe && typeof maybe === 'object') return maybe.en || maybe.ja || '—'
return '—'
}
function summonImageUrl(s?: GridSummonItemView): string {
const id = (s as any)?.object?.granblueId
const isMain = s?.main || s?.position === -1 || s?.friend || s?.position === 6
if (!id) return isMain ? '/images/placeholders/placeholder-summon-main.png' : '/images/placeholders/placeholder-summon-grid.png'
const folder = isMain ? 'summon-main' : 'summon-grid'
return `/images/${folder}/${id}.jpg`
}
async function remove() {
if (!item?.id) return
const party = ctx.getParty()
const editKey = ctx.services.partyService.getEditKey(party.shortcode)
const updated = await ctx.services.gridService.removeSummon(party.id, item.id as any, editKey || undefined)
ctx.updateParty(updated)
}
async function add() {
const party = ctx.getParty()
const editKey = ctx.services.partyService.getEditKey(party.shortcode)
const id = window.prompt('Enter summon ID to add')
if (!id) return
const updated = await ctx.services.gridService.addSummon(party.id, id, position, editKey || undefined)
ctx.updateParty(updated)
}
async function replaceItem() {
if (!item?.id) return add()
const party = ctx.getParty()
const editKey = ctx.services.partyService.getEditKey(party.shortcode)
const id = window.prompt('Enter new summon ID')
if (!id) return
const updated = await ctx.services.gridService.replaceSummon(party.id, item.id as any, id, editKey || undefined)
ctx.updateParty(updated)
}
</script>
<div class="unit">
<img class="image" alt={item ? displayName(item.object) : ''} src={summonImageUrl(item)} />
<div class="name">{item ? displayName(item.object) : '—'}</div>
{#if ctx.canEdit() && !item}
<button class="add" title="Add" on:click={add}></button>
{/if}
{#if ctx.canEdit() && item?.id}
<div class="actions">
<button class="replace" title="Replace" on:click={replaceItem}>↺</button>
<button class="remove" title="Remove" on:click={remove}>×</button>
</div>
{/if}
{#if ctx.canEdit() && item?.id}
<button class="remove" title="Remove" on:click={remove}>×</button>
{/if}
{#if item?.main || position === -1}
<span class="badge">Main</span>
{/if}
{#if item?.friend || position === 6}
<span class="badge" style="left:auto; right:6px">Friend</span>
{/if}
</div>
<style lang="scss">
@use '$src/themes/colors' as *;
@use '$src/themes/typography' as *;
@use '$src/themes/spacing' as *;
.unit { position: relative; width: 100%; display: flex; flex-direction: column; align-items: center; gap: $unit; }
.image { width: 100%; height: auto; border: 1px solid $grey-75; border-radius: 8px; display: block; }
.name { font-size: $font-small; text-align: center; color: $grey-50; }
.actions { position: absolute; top: 6px; right: 6px; display: flex; gap: 6px; }
.remove, .replace, .add { background: rgba(0,0,0,.6); color: white; border: none; border-radius: 12px; width: 24px; height: 24px; line-height: 24px; cursor: pointer; }
.add { position: absolute; top: 6px; right: 6px; }
.badge { position: absolute; left: 6px; top: 6px; background: #333; color: #fff; font-size: 11px; padding: 2px 6px; border-radius: 10px; }
</style>

View file

@ -0,0 +1,125 @@
<script lang="ts">
import type { GridWeaponItemView, PartyView } from '$lib/api/schemas/party'
import { getContext } from 'svelte'
export let item: GridWeaponItemView | undefined
export let position: number
type PartyCtx = {
getParty: () => PartyView
updateParty: (p: PartyView) => void
canEdit: () => boolean
services: { gridService: any; partyService: any }
}
const ctx = getContext<PartyCtx>('party')
function displayName(input: any): string {
if (!input) return '—'
const maybe = input.name ?? input
if (typeof maybe === 'string') return maybe
if (maybe && typeof maybe === 'object') return maybe.en || maybe.ja || '—'
return '—'
}
function weaponImageUrl(w?: GridWeaponItemView): string {
const id = (w as any)?.object?.granblueId
const isMain = !!(w && ((w as any).mainhand || (w as any).position === -1))
if (!id) return isMain
? '/images/placeholders/placeholder-weapon-main.png'
: '/images/placeholders/placeholder-weapon-grid.png'
const folder = isMain ? 'weapon-main' : 'weapon-grid'
const objElement = (w as any)?.object?.element
const instElement = (w as any)?.element
if (objElement === 0 && instElement) {
return `/images/${folder}/${id}_${instElement}.jpg`
}
return `/images/${folder}/${id}.jpg`
}
async function remove() {
if (!item?.id) return
const party = ctx.getParty()
const editKey = ctx.services.partyService.getEditKey(party.shortcode)
const updated = await ctx.services.gridService.removeWeapon(party.id, item.id as any, editKey || undefined)
ctx.updateParty(updated)
}
async function add() {
const party = ctx.getParty()
const editKey = ctx.services.partyService.getEditKey(party.shortcode)
const id = window.prompt('Enter weapon ID to add')
if (!id) return
const res = await ctx.services.gridService.addWeapon(party.id, id, position, editKey || undefined)
if (res.conflicts) {
window.alert('Conflict detected. Please resolve via UI in a later step.')
return
}
ctx.updateParty(res.party)
}
async function replaceItem() {
if (!item?.id) return add()
const party = ctx.getParty()
const editKey = ctx.services.partyService.getEditKey(party.shortcode)
const id = window.prompt('Enter new weapon ID')
if (!id) return
const res = await ctx.services.gridService.replaceWeapon(party.id, item.id as any, id, editKey || undefined)
if (res.conflicts) {
window.alert('Conflict detected. Please resolve via UI in a later step.')
return
}
ctx.updateParty(res.party)
}
</script>
<div class="unit">
<img class="image" alt={item ? displayName(item.object) : ''} src={weaponImageUrl(item)} />
<div class="name">{item ? displayName(item.object) : '—'}</div>
{#if ctx.canEdit() && !item}
<button class="add" title="Add" on:click={add}></button>
{/if}
{#if ctx.canEdit() && item?.id}
<div class="actions">
<button class="replace" title="Replace" on:click={replaceItem}>↺</button>
<button class="remove" title="Remove" on:click={remove}>×</button>
</div>
{/if}
{#if (item as any)?.mainhand || position === -1}
<span class="badge">Main</span>
{/if}
</div>
<style lang="scss">
@use '$src/themes/colors' as *;
@use '$src/themes/typography' as *;
@use '$src/themes/spacing' as *;
.unit {
position: relative;
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
gap: $unit;
}
.image { width: 100%; height: auto; border: 1px solid $grey-75; border-radius: 8px; display: block; }
.name { font-size: $font-small; text-align: center; color: $grey-50; }
.actions { position: absolute; top: 6px; right: 6px; display: flex; gap: 6px; }
.remove, .replace, .add {
background: rgba(0,0,0,.6);
color: white;
border: none;
border-radius: 12px;
width: 24px; height: 24px; line-height: 24px;
cursor: pointer;
}
.add { position: absolute; top: 6px; right: 6px; }
.badge {
position: absolute;
left: 6px; top: 6px;
background: #333; color: #fff;
font-size: 11px; padding: 2px 6px; border-radius: 10px;
}
</style>