Add ported grid components
This commit is contained in:
parent
f978be2bfd
commit
cf351ef1fc
18 changed files with 1687 additions and 0 deletions
71
src/lib/components/Icon.svelte
Normal file
71
src/lib/components/Icon.svelte
Normal 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>
|
||||
35
src/lib/components/explore/ExploreGrid.svelte
Normal file
35
src/lib/components/explore/ExploreGrid.svelte
Normal 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>
|
||||
80
src/lib/components/extra/ExtraSummonsGrid.svelte
Normal file
80
src/lib/components/extra/ExtraSummonsGrid.svelte
Normal 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>
|
||||
|
||||
32
src/lib/components/extra/ExtraWeaponsGrid.svelte
Normal file
32
src/lib/components/extra/ExtraWeaponsGrid.svelte
Normal 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>
|
||||
|
||||
69
src/lib/components/extra/GuidebookUnit.svelte
Normal file
69
src/lib/components/extra/GuidebookUnit.svelte
Normal 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>
|
||||
|
||||
35
src/lib/components/extra/GuidebooksGrid.svelte
Normal file
35
src/lib/components/extra/GuidebooksGrid.svelte
Normal 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>
|
||||
|
||||
77
src/lib/components/grids/CharacterGrid.svelte
Normal file
77
src/lib/components/grids/CharacterGrid.svelte
Normal 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>
|
||||
128
src/lib/components/grids/SummonGrid.svelte
Normal file
128
src/lib/components/grids/SummonGrid.svelte
Normal 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>
|
||||
140
src/lib/components/grids/WeaponGrid.svelte
Normal file
140
src/lib/components/grids/WeaponGrid.svelte
Normal 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>
|
||||
393
src/lib/components/party/Party.svelte
Normal file
393
src/lib/components/party/Party.svelte
Normal 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>
|
||||
58
src/lib/components/reps/CharacterRep.svelte
Normal file
58
src/lib/components/reps/CharacterRep.svelte
Normal 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>
|
||||
|
||||
128
src/lib/components/reps/GridRep.svelte
Normal file
128
src/lib/components/reps/GridRep.svelte
Normal 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>
|
||||
23
src/lib/components/reps/GridRepCollection.svelte
Normal file
23
src/lib/components/reps/GridRepCollection.svelte
Normal 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>
|
||||
|
||||
45
src/lib/components/reps/SummonRep.svelte
Normal file
45
src/lib/components/reps/SummonRep.svelte
Normal 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>
|
||||
|
||||
49
src/lib/components/reps/WeaponRep.svelte
Normal file
49
src/lib/components/reps/WeaponRep.svelte
Normal 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>
|
||||
|
||||
105
src/lib/components/units/CharacterUnit.svelte
Normal file
105
src/lib/components/units/CharacterUnit.svelte
Normal 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>
|
||||
94
src/lib/components/units/SummonUnit.svelte
Normal file
94
src/lib/components/units/SummonUnit.svelte
Normal 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>
|
||||
125
src/lib/components/units/WeaponUnit.svelte
Normal file
125
src/lib/components/units/WeaponUnit.svelte
Normal 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>
|
||||
Loading…
Reference in a new issue