add sidebar components for descriptions and details

This commit is contained in:
Justin Edmund 2025-09-23 22:08:51 -07:00
parent fc32e18ea8
commit 838e09d17b
27 changed files with 965 additions and 893 deletions

View file

@ -0,0 +1,276 @@
<script lang="ts">
import type { JSONContent } from '@tiptap/core'
interface Props {
content?: string
truncate?: boolean
maxLines?: number
}
let { content, truncate = false, maxLines = 3 }: Props = $props()
// Convert TipTap JSON to HTML manually
function jsonToHtml(node: JSONContent): string {
if (!node) return ''
// Handle text nodes
if (node.type === 'text') {
let text = node.text || ''
// Apply marks (formatting)
if (node.marks) {
node.marks.forEach(mark => {
switch (mark.type) {
case 'bold':
text = `<strong>${text}</strong>`
break
case 'italic':
text = `<em>${text}</em>`
break
case 'strike':
text = `<s>${text}</s>`
break
case 'underline':
text = `<u>${text}</u>`
break
case 'highlight':
text = `<mark>${text}</mark>`
break
case 'link':
text = `<a href="${mark.attrs?.href}" target="_blank" rel="noopener noreferrer">${text}</a>`
break
case 'code':
text = `<code>${text}</code>`
break
}
})
}
return text
}
// Handle different node types
switch (node.type) {
case 'doc':
return (node.content || []).map(jsonToHtml).join('')
case 'paragraph':
const content = (node.content || []).map(jsonToHtml).join('')
return `<p>${content || '<br>'}</p>`
case 'heading':
const level = node.attrs?.level || 1
const headingContent = (node.content || []).map(jsonToHtml).join('')
return `<h${level}>${headingContent}</h${level}>`
case 'bulletList':
const listItems = (node.content || []).map(jsonToHtml).join('')
return `<ul>${listItems}</ul>`
case 'orderedList':
const orderedItems = (node.content || []).map(jsonToHtml).join('')
return `<ol>${orderedItems}</ol>`
case 'listItem':
const itemContent = (node.content || []).map(jsonToHtml).join('')
return `<li>${itemContent}</li>`
case 'blockquote':
const quoteContent = (node.content || []).map(jsonToHtml).join('')
return `<blockquote>${quoteContent}</blockquote>`
case 'codeBlock':
const codeContent = (node.content || []).map(n => n.text || '').join('')
return `<pre><code>${codeContent}</code></pre>`
case 'hardBreak':
return '<br>'
case 'horizontalRule':
return '<hr>'
case 'youtube':
// For now, show a link to the video in truncated view
const videoUrl = node.attrs?.src || ''
return `<p><a href="${videoUrl}" target="_blank" rel="noopener noreferrer">📹 View Video</a></p>`
case 'mention':
// Handle game item mentions
const mentionName = node.attrs?.id?.name?.en || node.attrs?.id?.granblue_en || 'Unknown'
const wikiUrl = `https://gbf.wiki/${mentionName}`
return `<a href="${wikiUrl}" target="_blank" rel="noopener noreferrer" class="mention">${mentionName}</a>`
default:
// For unknown types, try to render content if it exists
if (node.content) {
return (node.content || []).map(jsonToHtml).join('')
}
return ''
}
}
// Parse content - handle both JSON and plain text
function parseContent(content?: string): string {
if (!content) return ''
// Try to parse as JSON first
try {
const json = JSON.parse(content) as JSONContent
return jsonToHtml(json)
} catch {
// If not JSON, treat as plain text
// Convert double newlines to paragraphs and single newlines to br tags
const paragraphs = content.split('\n\n')
const formatted = paragraphs
.map((p) => {
const lines = p.split('\n')
return `<p>${lines.join('<br />')}</p>`
})
.join('')
return formatted
}
}
const parsedHTML = $derived(parseContent(content))
</script>
<div
class="description-content"
class:truncate
style={truncate ? `--max-lines: ${maxLines}` : ''}
>
{@html parsedHTML}
</div>
<style lang="scss">
@use '$src/themes/typography' as *;
@use '$src/themes/colors' as *;
@use '$src/themes/spacing' as *;
.description-content {
color: var(--text-primary);
font-size: $font-regular;
line-height: 1.6;
// Basic HTML styling for generated content
:global {
p {
margin: 0 0 $unit 0;
&:last-child {
margin-bottom: 0;
}
}
h1, h2, h3 {
font-weight: $bold;
margin: $unit 0 $unit-half 0;
}
h1 {
font-size: $font-xlarge;
}
h2 {
font-size: $font-large;
}
h3 {
font-size: $font-medium;
}
strong, b {
font-weight: $bold;
}
em, i {
font-style: italic;
}
a {
color: var(--accent-blue);
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
mark {
background: rgba(255, 237, 76, 0.3);
color: var(--text-primary);
padding: 0 $unit-fourth;
border-radius: 2px;
font-weight: $medium;
}
.mention {
color: var(--accent-blue);
font-weight: $medium;
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
ul, ol {
margin: 0 0 $unit 0;
padding-left: $unit-3x;
}
li {
margin: $unit-half 0;
}
code {
background: var(--button-bg);
padding: 2px $unit-half;
border-radius: 3px;
font-family: monospace;
font-size: 0.9em;
}
pre {
background: var(--button-bg);
padding: $unit;
border-radius: $unit-half;
overflow-x: auto;
margin: $unit 0;
code {
background: none;
padding: 0;
}
}
blockquote {
border-left: 3px solid var(--accent-blue);
padding-left: $unit-2x;
margin: $unit 0;
font-style: italic;
color: var(--text-secondary);
}
hr {
border: none;
border-top: 1px solid var(--button-bg);
margin: $unit-2x 0;
}
}
&.truncate {
display: -webkit-box;
-webkit-line-clamp: var(--max-lines, 3);
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
// Hide block elements that might break truncation
:global {
pre, blockquote, ul, ol {
display: inline;
}
}
}
}
</style>

View file

@ -0,0 +1,138 @@
<script lang="ts">
import DescriptionRenderer from '$lib/components/DescriptionRenderer.svelte'
import Button from '$lib/components/ui/Button.svelte'
interface Props {
title?: string
description?: string
canEdit?: boolean
onEdit?: () => void
}
let { title, description, canEdit = false, onEdit }: Props = $props()
</script>
<div class="description-sidebar">
<div class="content-section">
{#if title}
<div class="party-title">
<h2>{title}</h2>
</div>
{/if}
{#if description}
<div class="description-content">
<DescriptionRenderer content={description} truncate={false} />
</div>
{:else}
<div class="empty-state">
<p>No description available for this party.</p>
{#if canEdit}
<Button
variant="primary"
onclick={onEdit}
>
Add Description
</Button>
{/if}
</div>
{/if}
</div>
{#if canEdit && description}
<div class="actions-section">
<Button
variant="secondary"
onclick={onEdit}
class="edit-button"
>
Edit Description
</Button>
</div>
{/if}
</div>
<style lang="scss">
@use '$src/themes/colors' as *;
@use '$src/themes/spacing' as *;
@use '$src/themes/typography' as *;
@use '$src/themes/effects' as *;
.description-sidebar {
display: flex;
flex-direction: column;
height: 100%;
padding: $unit-2x;
color: var(--text-primary);
}
.content-section {
flex: 1;
overflow-y: auto;
}
.party-title {
margin-bottom: $unit-3x;
padding-bottom: $unit-2x;
border-bottom: 1px solid var(--button-bg);
h2 {
margin: 0;
font-size: $font-xlarge;
font-weight: $bold;
color: var(--text-primary);
line-height: 1.2;
}
}
.description-content {
// Allow the content to scroll if it's very long
overflow-y: auto;
padding-right: $unit;
// Custom scrollbar styling
&::-webkit-scrollbar {
width: 6px;
}
&::-webkit-scrollbar-track {
background: var(--button-bg);
border-radius: 3px;
}
&::-webkit-scrollbar-thumb {
background: var(--text-secondary);
border-radius: 3px;
&:hover {
background: var(--text-primary);
}
}
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
padding: $unit-4x;
min-height: 200px;
p {
margin: 0 0 $unit-2x 0;
color: var(--text-secondary);
font-size: $font-regular;
}
}
.actions-section {
margin-top: $unit-2x;
padding-top: $unit-2x;
border-top: 1px solid var(--button-bg);
:global(.edit-button) {
width: 100%;
}
}
</style>

View file

@ -0,0 +1,441 @@
<script lang="ts">
import type { GridCharacter, GridWeapon, GridSummon } from '$lib/types/api/party'
import { getElementLabel } from '$lib/utils/element'
import { getRarityLabel } from '$lib/utils/rarity'
import { getProficiencyLabel } from '$lib/utils/proficiency'
import { getRaceLabel } from '$lib/utils/race'
import { getGenderLabel } from '$lib/utils/gender'
import UncapIndicator from '$lib/components/uncap/UncapIndicator.svelte'
interface Props {
type: 'character' | 'weapon' | 'summon'
item: GridCharacter | GridWeapon | GridSummon
}
let { type, item }: Props = $props()
// Helper to get the actual item data
function getItemData() {
if (type === 'character') {
return (item as GridCharacter).character
} else if (type === 'weapon') {
return (item as GridWeapon).weapon
} else {
return (item as GridSummon).summon
}
}
// Helper for localized names
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 '—'
}
// Get the item's actual data
const itemData = $derived(getItemData())
// Grid item info (uncap levels from the grid item itself)
const gridUncapLevel = $derived(
type === 'character' ? (item as GridCharacter).uncapLevel :
type === 'weapon' ? (item as GridWeapon).uncapLevel :
(item as GridSummon).uncapLevel
)
const gridTranscendence = $derived(
type === 'character' ? (item as GridCharacter).transcendenceStep :
type === 'weapon' ? (item as GridWeapon).transcendenceStep :
(item as GridSummon).transcendenceStep
)
// Get image URL based on type
function getImageUrl(): string {
if (!itemData?.granblueId) {
return type === 'character' ? '/images/placeholders/placeholder-character-main.png' :
type === 'weapon' ? '/images/placeholders/placeholder-weapon-main.png' :
'/images/placeholders/placeholder-summon-main.png'
}
const id = itemData.granblueId
if (type === 'character') {
let pose = '01'
if (gridTranscendence && gridTranscendence > 0) pose = '04'
else if (gridUncapLevel && gridUncapLevel >= 5) pose = '03'
else if (gridUncapLevel && gridUncapLevel > 2) pose = '02'
return `/images/character-main/${id}_${pose}.jpg`
} else if (type === 'weapon') {
return `/images/weapon-main/${id}.jpg`
} else {
return `/images/summon-main/${id}.jpg`
}
}
</script>
<div class="details-sidebar">
<div class="item-header">
<img
src={getImageUrl()}
alt={displayName(itemData)}
class="item-image"
/>
<div class="item-title">
<h2>{displayName(itemData)}</h2>
{#if itemData?.granblueId}
<span class="granblue-id">ID: {itemData.granblueId}</span>
{/if}
</div>
</div>
<div class="details-section">
<h3>Basic Information</h3>
<div class="detail-row">
<span class="label">Rarity</span>
<span class="value">{getRarityLabel(itemData?.rarity)}</span>
</div>
<div class="detail-row">
<span class="label">Element</span>
<span class="value">{getElementLabel(itemData?.element)}</span>
</div>
{#if type === 'character'}
{#if itemData?.race && itemData.race.length > 0}
<div class="detail-row">
<span class="label">Race</span>
<span class="value">
{itemData.race.map(r => getRaceLabel(r)).filter(Boolean).join(', ') || '—'}
</span>
</div>
{/if}
<div class="detail-row">
<span class="label">Gender</span>
<span class="value">{getGenderLabel(itemData?.gender)}</span>
</div>
{#if itemData?.proficiency && itemData.proficiency.length > 0}
<div class="detail-row">
<span class="label">Proficiencies</span>
<span class="value">
{itemData.proficiency.map(p => getProficiencyLabel(p)).filter(Boolean).join(', ') || '—'}
</span>
</div>
{/if}
{:else if type === 'weapon'}
<div class="detail-row">
<span class="label">Proficiency</span>
<span class="value">{getProficiencyLabel(itemData?.proficiency?.[0])}</span>
</div>
{/if}
</div>
<div class="details-section">
<h3>Uncap Status</h3>
<div class="uncap-display">
<UncapIndicator
type={type}
uncapLevel={gridUncapLevel}
transcendenceStage={gridTranscendence}
special={itemData?.special}
flb={itemData?.uncap?.flb}
ulb={itemData?.uncap?.ulb}
transcendence={itemData?.uncap?.transcendence}
editable={false}
/>
</div>
<div class="detail-row">
<span class="label">Current Uncap</span>
<span class="value">{gridUncapLevel ?? 0}</span>
</div>
{#if gridTranscendence && gridTranscendence > 0}
<div class="detail-row">
<span class="label">Transcendence</span>
<span class="value">Stage {gridTranscendence}</span>
</div>
{/if}
{#if itemData?.uncap}
<div class="detail-row">
<span class="label">Available Uncaps</span>
<span class="value">
{[
itemData.uncap.flb && 'FLB',
itemData.uncap.ulb && 'ULB',
itemData.uncap.transcendence && 'Transcendence'
].filter(Boolean).join(', ') || 'Standard'}
</span>
</div>
{/if}
</div>
<div class="details-section">
<h3>Stats</h3>
{#if itemData?.hp}
<div class="stats-group">
<h4>HP</h4>
<div class="detail-row">
<span class="label">Base</span>
<span class="value">{itemData.hp.minHp ?? '—'}</span>
</div>
<div class="detail-row">
<span class="label">Max</span>
<span class="value">{itemData.hp.maxHp ?? '—'}</span>
</div>
{#if itemData.uncap?.flb && itemData.hp.maxHpFlb}
<div class="detail-row">
<span class="label">Max (FLB)</span>
<span class="value">{itemData.hp.maxHpFlb}</span>
</div>
{/if}
{#if itemData.uncap?.ulb && itemData.hp.maxHpUlb}
<div class="detail-row">
<span class="label">Max (ULB)</span>
<span class="value">{itemData.hp.maxHpUlb}</span>
</div>
{/if}
</div>
{/if}
{#if itemData?.atk}
<div class="stats-group">
<h4>Attack</h4>
<div class="detail-row">
<span class="label">Base</span>
<span class="value">{itemData.atk.minAtk ?? '—'}</span>
</div>
<div class="detail-row">
<span class="label">Max</span>
<span class="value">{itemData.atk.maxAtk ?? '—'}</span>
</div>
{#if itemData.uncap?.flb && itemData.atk.maxAtkFlb}
<div class="detail-row">
<span class="label">Max (FLB)</span>
<span class="value">{itemData.atk.maxAtkFlb}</span>
</div>
{/if}
{#if itemData.uncap?.ulb && itemData.atk.maxAtkUlb}
<div class="detail-row">
<span class="label">Max (ULB)</span>
<span class="value">{itemData.atk.maxAtkUlb}</span>
</div>
{/if}
</div>
{/if}
</div>
{#if type === 'weapon' && itemData?.weaponSkills && itemData.weaponSkills.length > 0}
<div class="details-section">
<h3>Skills</h3>
<div class="skills-list">
{#each itemData.weaponSkills as skill}
<div class="skill-item">
<h4>{displayName(skill) || 'Unknown Skill'}</h4>
{#if skill.description}
<p>{skill.description}</p>
{/if}
</div>
{/each}
</div>
</div>
{/if}
{#if type === 'summon' && itemData?.summonAuras && itemData.summonAuras.length > 0}
<div class="details-section">
<h3>Auras</h3>
<div class="auras-list">
{#each itemData.summonAuras as aura}
<div class="aura-item">
<h4>{displayName(aura) || 'Unknown Aura'}</h4>
{#if aura.description}
<p>{aura.description}</p>
{/if}
</div>
{/each}
</div>
</div>
{/if}
{#if type === 'weapon' && itemData?.weaponKeys && itemData.weaponKeys.length > 0}
<div class="details-section">
<h3>Weapon Keys</h3>
<div class="keys-list">
{#each itemData.weaponKeys as key}
<div class="key-item">
<span class="key-slot">Slot {key.slot}</span>
<span class="key-name">{displayName(key.weaponKey1) || '—'}</span>
</div>
{/each}
</div>
</div>
{/if}
{#if type === 'character' && itemData?.special}
<div class="details-section">
<div class="detail-row special-indicator">
<span class="label">Special Character</span>
<span class="value"></span>
</div>
</div>
{/if}
</div>
<style lang="scss">
@use '$src/themes/colors' as colors;
@use '$src/themes/spacing' as spacing;
@use '$src/themes/typography' as typography;
@use '$src/themes/layout' as layout;
.details-sidebar {
padding: spacing.$unit-2x;
color: var(--text-primary, colors.$grey-10);
}
.item-header {
display: flex;
gap: spacing.$unit-2x;
align-items: flex-start;
margin-bottom: spacing.$unit-3x;
padding-bottom: spacing.$unit-2x;
border-bottom: 1px solid var(--border-color, colors.$grey-70);
}
.item-image {
width: 80px;
height: 80px;
border-radius: layout.$item-corner;
object-fit: cover;
background: colors.$grey-80;
border: 1px solid colors.$grey-70;
}
.item-title {
flex: 1;
h2 {
margin: 0 0 calc(spacing.$unit * 0.5) 0;
font-size: typography.$font-xlarge;
font-weight: typography.$medium;
color: var(--text-primary, colors.$grey-10);
}
.granblue-id {
font-size: typography.$font-small;
color: var(--text-secondary, colors.$grey-50);
}
}
.details-section {
margin-bottom: spacing.$unit-3x;
h3 {
margin: 0 0 calc(spacing.$unit * 1.5) 0;
font-size: typography.$font-regular;
font-weight: typography.$medium;
color: var(--text-secondary, colors.$grey-40);
text-transform: uppercase;
letter-spacing: 0.5px;
}
h4 {
margin: spacing.$unit 0 calc(spacing.$unit * 0.5) 0;
font-size: typography.$font-regular;
font-weight: typography.$medium;
color: var(--text-primary, colors.$grey-20);
}
}
.detail-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: calc(spacing.$unit * 0.75) 0;
border-bottom: 1px solid rgba(colors.$grey-70, 0.5);
&:last-child {
border-bottom: none;
}
.label {
font-size: typography.$font-regular;
color: var(--text-secondary, colors.$grey-50);
}
.value {
font-size: typography.$font-regular;
color: var(--text-primary, colors.$grey-10);
font-weight: typography.$medium;
text-align: right;
}
}
.uncap-display {
margin-bottom: calc(spacing.$unit * 1.5);
padding: spacing.$unit;
background: colors.$grey-90;
border-radius: calc(layout.$item-corner * 0.5);
display: flex;
justify-content: center;
}
.stats-group {
margin-bottom: spacing.$unit-2x;
&:last-child {
margin-bottom: 0;
}
}
.skills-list,
.auras-list {
.skill-item,
.aura-item {
padding: spacing.$unit;
background: colors.$grey-90;
border-radius: calc(layout.$item-corner * 0.5);
margin-bottom: spacing.$unit;
h4 {
margin: 0 0 calc(spacing.$unit * 0.5) 0;
font-size: typography.$font-regular;
color: var(--text-primary, colors.$grey-10);
}
p {
margin: 0;
font-size: typography.$font-small;
color: var(--text-secondary, colors.$grey-50);
line-height: 1.4;
}
}
}
.keys-list {
.key-item {
display: flex;
justify-content: space-between;
padding: calc(spacing.$unit * 0.75);
background: colors.$grey-90;
border-radius: calc(layout.$item-corner * 0.5);
margin-bottom: calc(spacing.$unit * 0.5);
.key-slot {
font-size: typography.$font-small;
color: var(--text-secondary, colors.$grey-50);
}
.key-name {
font-size: typography.$font-small;
color: var(--text-primary, colors.$grey-10);
font-weight: typography.$medium;
}
}
}
.special-indicator {
.value {
color: var(--color-success, #4caf50);
font-size: typography.$font-large;
}
}
</style>

View file

@ -18,20 +18,10 @@
headerActions?: Snippet
}
const {
open = false,
title,
onclose,
children,
headerActions
}: Props = $props()
const { open = false, title, onclose, children, headerActions }: Props = $props()
</script>
<aside
class="sidebar"
class:open
style:--sidebar-width={open ? SIDEBAR_WIDTH : '0'}
>
<aside class="sidebar" class:open style:--sidebar-width={open ? SIDEBAR_WIDTH : '0'}>
{#if title}
<SidebarHeader {title} {onclose} actions={headerActions} />
{/if}
@ -51,6 +41,9 @@
@use '$src/themes/effects' as *;
.sidebar {
position: fixed;
top: 0;
right: 0;
height: 100vh;
background: var(--bg-primary);
border-left: 1px solid var(--border-primary);
@ -60,6 +53,7 @@
width: 0;
overflow: hidden;
transition: width $duration-slide ease-in-out;
z-index: 50;
&.open {
width: var(--sidebar-width);
@ -68,24 +62,50 @@
.sidebar-content {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
padding: $unit-2x;
// Smooth scrolling
scroll-behavior: smooth;
// Better scrollbar styling to match main content
&::-webkit-scrollbar {
width: 8px;
}
&::-webkit-scrollbar-track {
background: var(--bg-secondary, #f1f1f1);
}
&::-webkit-scrollbar-thumb {
background: var(--border-primary, #888);
border-radius: 4px;
&:hover {
background: var(--text-secondary, #555);
}
}
// Improve mobile scrolling performance
@media (max-width: 768px) {
-webkit-overflow-scrolling: touch;
}
}
// Mobile styles - overlay approach
@media (max-width: 768px) {
position: fixed;
top: 0;
right: 0;
bottom: 0;
z-index: 100;
transform: translateX(100%);
transition: transform $duration-slide ease-in-out, width 0s;
width: 100vw !important;
transition:
transform $duration-slide ease-in-out,
width 0s;
width: 90vw !important;
max-width: 400px;
box-shadow: -2px 0 8px rgba(0, 0, 0, 0.15);
&.open {
transform: translateX(0);
}
}
}
</style>
</style>

View file

@ -0,0 +1,25 @@
import { sidebar } from '$lib/stores/sidebar.svelte'
import DescriptionSidebar from '$lib/components/sidebar/DescriptionSidebar.svelte'
interface DescriptionSidebarOptions {
title?: string
description?: string
canEdit?: boolean
onEdit?: () => void
}
export function openDescriptionSidebar(options: DescriptionSidebarOptions) {
const { title, description, canEdit = false, onEdit } = options
// Open the sidebar with the description component
sidebar.openWithComponent('Description', DescriptionSidebar, {
title,
description,
canEdit,
onEdit
})
}
export function closeDescriptionSidebar() {
sidebar.close()
}

View file

@ -0,0 +1,46 @@
import { sidebar } from '$lib/stores/sidebar.svelte'
import DetailsSidebar from '$lib/components/sidebar/DetailsSidebar.svelte'
import type { GridCharacter, GridWeapon, GridSummon } from '$lib/types/api/party'
interface DetailsSidebarOptions {
type: 'weapon' | 'character' | 'summon'
item: GridCharacter | GridWeapon | GridSummon
}
export function openDetailsSidebar(options: DetailsSidebarOptions) {
const { type, item } = options
// Get the item name for the title
let itemName = 'Details'
if (type === 'character' && (item as GridCharacter).character) {
const char = (item as GridCharacter).character
itemName = getName(char)
} else if (type === 'weapon' && (item as GridWeapon).weapon) {
const weapon = (item as GridWeapon).weapon
itemName = getName(weapon)
} else if (type === 'summon' && (item as GridSummon).summon) {
const summon = (item as GridSummon).summon
itemName = getName(summon)
}
// Open the sidebar with the details component
const title = itemName !== 'Details' ? itemName : `${type.charAt(0).toUpperCase() + type.slice(1)} Details`
sidebar.openWithComponent(title, DetailsSidebar, {
type,
item
})
}
function getName(obj: any): string {
if (!obj) return 'Details'
const name = obj.name ?? obj
if (typeof name === 'string') return name
if (name && typeof name === 'object') {
return name.en || name.ja || 'Details'
}
return 'Details'
}
export function closeDetailsSidebar() {
sidebar.close()
}

View file

@ -1,55 +0,0 @@
import { PUBLIC_SIERO_API_URL } from '$env/static/public'
/**
* Utility functions for API route handlers
* These routes act as proxies to the Rails API
*/
export const API_BASE = new URL(PUBLIC_SIERO_API_URL || 'http://localhost:3000').href
/**
* Build a full URL for the Rails API
*/
export function buildApiUrl(path: string, params?: Record<string, any>): string {
const url = new URL(path.startsWith('http') ? path : `${API_BASE}${path}`, API_BASE)
if (params) {
for (const [key, value] of Object.entries(params)) {
if (value === undefined || value === null) continue
if (Array.isArray(value)) {
value.forEach((x) => url.searchParams.append(key, String(x)))
} else {
url.searchParams.set(key, String(value))
}
}
}
return url.toString()
}
/**
* Extract headers that should be forwarded to the Rails API
*/
export function extractHeaders(request: Request): Record<string, string> {
const headers: Record<string, string> = {
'Content-Type': 'application/json'
}
const editKey = request.headers.get('X-Edit-Key')
if (editKey) {
headers['X-Edit-Key'] = editKey
}
return headers
}
/**
* Common error handler for API routes
*/
export function handleApiError(error: any, action: string) {
console.error(`Error ${action}:`, error)
return {
error: `Failed to ${action}`,
details: error instanceof Error ? error.message : 'Unknown error'
}
}

View file

@ -1,26 +0,0 @@
import { json, type RequestHandler } from '@sveltejs/kit'
import { buildApiUrl, extractHeaders, handleApiError } from '../_utils'
/**
* POST /api/parties - Create a new party
* Proxies to Rails API with proper authentication
*/
export const POST: RequestHandler = async ({ request, fetch }) => {
try {
const body = await request.json()
const headers = extractHeaders(request)
// Forward to Rails API
// The server-side fetch will automatically add Bearer token if user is authenticated
const response = await fetch(buildApiUrl('/parties'), {
method: 'POST',
headers,
body: JSON.stringify(body)
})
const data = await response.json()
return json(data, { status: response.status })
} catch (error) {
return json(handleApiError(error, 'create party'), { status: 500 })
}
}

View file

@ -1,52 +0,0 @@
import { json, type RequestHandler } from '@sveltejs/kit'
import { buildApiUrl, extractHeaders, handleApiError } from '../../_utils'
/**
* PUT /api/parties/[id] - Update a party
* DELETE /api/parties/[id] - Delete a party
* Proxies to Rails API with proper authentication
*/
export const PUT: RequestHandler = async ({ request, params, fetch }) => {
try {
const { id } = params
const body = await request.json()
const headers = extractHeaders(request)
// Forward to Rails API
const response = await fetch(buildApiUrl(`/parties/${id}`), {
method: 'PUT',
headers,
body: JSON.stringify(body)
})
const data = await response.json()
return json(data, { status: response.status })
} catch (error) {
return json(handleApiError(error, 'update party'), { status: 500 })
}
}
export const DELETE: RequestHandler = async ({ request, params, fetch }) => {
try {
const { id } = params
const headers = extractHeaders(request)
// Forward to Rails API
const response = await fetch(buildApiUrl(`/parties/${id}`), {
method: 'DELETE',
headers
})
if (response.ok) {
const data = await response.json()
return json(data, { status: response.status })
}
// Handle error responses
const errorData = await response.json().catch(() => ({}))
return json(errorData, { status: response.status })
} catch (error) {
return json(handleApiError(error, 'delete party'), { status: 500 })
}
}

View file

@ -1,78 +0,0 @@
import { json, type RequestHandler } from '@sveltejs/kit'
import { buildApiUrl, extractHeaders, handleApiError } from '../../../_utils'
/**
* POST /api/parties/[id]/characters - Add character to party
* DELETE /api/parties/[id]/characters - Remove character from party
* Proxies to Rails API with proper authentication
*/
export const POST: RequestHandler = async ({ request, params, fetch }) => {
try {
const body = await request.json()
const editKey = request.headers.get('X-Edit-Key')
// Transform to Rails API format
const railsBody = {
character: {
party_id: params.id,
character_id: body.characterId,
position: body.position,
uncap_level: body.uncapLevel ?? 3,
transcendence_step: body.transcendenceStep ?? 0,
perpetuity: body.perpetuity ?? false
}
}
// Forward to Rails API
const response = await fetch(buildApiUrl('/characters'), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(editKey ? { 'X-Edit-Key': editKey } : {})
},
body: JSON.stringify(railsBody)
})
const data = await response.json()
return json(data, { status: response.status })
} catch (error) {
console.error('Error adding character:', error)
return json(
{ error: 'Failed to add character' },
{ status: 500 }
)
}
}
export const DELETE: RequestHandler = async ({ request, params, fetch }) => {
try {
const body = await request.json()
const editKey = request.headers.get('X-Edit-Key')
// Forward to Rails API - use grid_characters endpoint with the ID
const response = await fetch(buildApiUrl(`/grid_characters/${body.gridCharacterId}`), {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
...(editKey ? { 'X-Edit-Key': editKey } : {})
}
})
if (response.ok) {
// DELETE might not return a body
const text = await response.text()
const data = text ? JSON.parse(text) : {}
return json(data, { status: response.status })
}
const errorData = await response.json().catch(() => ({}))
return json(errorData, { status: response.status })
} catch (error) {
console.error('Error removing character:', error)
return json(
{ error: 'Failed to remove character' },
{ status: 500 }
)
}
}

View file

@ -1,38 +0,0 @@
import { json, type RequestHandler } from '@sveltejs/kit'
import { buildApiUrl, extractHeaders, handleApiError } from '../../../../_utils'
/**
* PUT /api/parties/[id]/characters/[characterId] - Update character in party
* Proxies to Rails API with proper authentication
*/
export const PUT: RequestHandler = async ({ request, params, fetch }) => {
try {
const body = await request.json()
const editKey = request.headers.get('X-Edit-Key')
// Transform to Rails API format
const railsBody = {
character: body
}
// Forward to Rails API
const response = await fetch(buildApiUrl(`/grid_characters/${params.characterId}`), {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
...(editKey ? { 'X-Edit-Key': editKey } : {})
},
body: JSON.stringify(railsBody)
})
const data = await response.json()
return json(data, { status: response.status })
} catch (error) {
console.error('Error updating character:', error)
return json(
{ error: 'Failed to update character' },
{ status: 500 }
)
}
}

View file

@ -1,39 +0,0 @@
import { json, type RequestHandler } from '@sveltejs/kit'
import { buildApiUrl, extractHeaders, handleApiError } from '../../../_utils'
/**
* POST /api/parties/[id]/grid_characters - Add character to party
* Proxies to Rails API with proper authentication
*/
export const POST: RequestHandler = async ({ request, params, fetch, cookies }) => {
try {
const body = await request.json()
const editKey = request.headers.get('X-Edit-Key')
// Forward to Rails API
const response = await fetch(buildApiUrl('/characters'), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${cookies.get('access_token')}`,
...(editKey ? { 'X-Edit-Key': editKey } : {})
},
body: JSON.stringify({
character: {
party_id: params.id,
...body
}
})
})
const data = await response.json()
return json(data, { status: response.status })
} catch (error) {
console.error('Error adding character:', error)
return json(
{ error: 'Failed to add character' },
{ status: 500 }
)
}
}

View file

@ -1,31 +0,0 @@
import { json } from '@sveltejs/kit'
import type { RequestHandler } from './$types'
import { buildApiUrl, extractHeaders, handleApiError } from '../../../../../_utils'
export const PUT: RequestHandler = async ({ params, request, fetch, cookies }) => {
const { id: partyId, characterId } = params
const body = await request.json()
const editKey = request.headers.get('X-Edit-Key')
// Forward the request to the Rails API
const apiResponse = await fetch(
buildApiUrl(`/parties/${partyId}/grid_characters/${characterId}/position`),
{
method: 'PUT',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${cookies.get('access_token')}`,
...(editKey ? { 'X-Edit-Key': editKey } : {})
},
body: JSON.stringify(body)
}
)
if (!apiResponse.ok) {
const error = await apiResponse.json().catch(() => ({ error: 'Failed to update character position' }))
return json(error, { status: apiResponse.status })
}
const data = await apiResponse.json()
return json(data)
}

View file

@ -1,31 +0,0 @@
import { json } from '@sveltejs/kit'
import type { RequestHandler } from './$types'
import { buildApiUrl, extractHeaders, handleApiError } from '../../../../_utils'
export const POST: RequestHandler = async ({ params, request, fetch, cookies }) => {
const { id: partyId } = params
const body = await request.json()
const editKey = request.headers.get('X-Edit-Key')
// Forward the request to the Rails API
const apiResponse = await fetch(
buildApiUrl(`/parties/${partyId}/grid_characters/swap`),
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${cookies.get('access_token')}`,
...(editKey ? { 'X-Edit-Key': editKey } : {})
},
body: JSON.stringify(body)
}
)
if (!apiResponse.ok) {
const error = await apiResponse.json().catch(() => ({ error: 'Failed to swap characters' }))
return json(error, { status: apiResponse.status })
}
const data = await apiResponse.json()
return json(data)
}

View file

@ -1,39 +0,0 @@
import { json, type RequestHandler } from '@sveltejs/kit'
import { buildApiUrl, extractHeaders, handleApiError } from '../../../_utils'
/**
* POST /api/parties/[id]/grid_summons - Add summon to party
* Proxies to Rails API with proper authentication
*/
export const POST: RequestHandler = async ({ request, params, fetch, cookies }) => {
try {
const body = await request.json()
const editKey = request.headers.get('X-Edit-Key')
// Forward to Rails API
const response = await fetch(buildApiUrl('/summons'), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${cookies.get('access_token')}`,
...(editKey ? { 'X-Edit-Key': editKey } : {})
},
body: JSON.stringify({
summon: {
party_id: params.id,
...body
}
})
})
const data = await response.json()
return json(data, { status: response.status })
} catch (error) {
console.error('Error adding summon:', error)
return json(
{ error: 'Failed to add summon' },
{ status: 500 }
)
}
}

View file

@ -1,31 +0,0 @@
import { json } from '@sveltejs/kit'
import type { RequestHandler } from './$types'
import { buildApiUrl, extractHeaders, handleApiError } from '../../../../../_utils'
export const PUT: RequestHandler = async ({ params, request, fetch, cookies }) => {
const { id: partyId, summonId } = params
const body = await request.json()
const editKey = request.headers.get('X-Edit-Key')
// Forward the request to the Rails API
const apiResponse = await fetch(
buildApiUrl(`/parties/${partyId}/grid_summons/${summonId}/position`),
{
method: 'PUT',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${cookies.get('access_token')}`,
...(editKey ? { 'X-Edit-Key': editKey } : {})
},
body: JSON.stringify(body)
}
)
if (!apiResponse.ok) {
const error = await apiResponse.json().catch(() => ({ error: 'Failed to update summon position' }))
return json(error, { status: apiResponse.status })
}
const data = await apiResponse.json()
return json(data)
}

View file

@ -1,31 +0,0 @@
import { json } from '@sveltejs/kit'
import type { RequestHandler } from './$types'
import { buildApiUrl, extractHeaders, handleApiError } from '../../../../_utils'
export const POST: RequestHandler = async ({ params, request, fetch, cookies }) => {
const { id: partyId } = params
const body = await request.json()
const editKey = request.headers.get('X-Edit-Key')
// Forward the request to the Rails API
const apiResponse = await fetch(
buildApiUrl(`/parties/${partyId}/grid_summons/swap`),
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${cookies.get('access_token')}`,
...(editKey ? { 'X-Edit-Key': editKey } : {})
},
body: JSON.stringify(body)
}
)
if (!apiResponse.ok) {
const error = await apiResponse.json().catch(() => ({ error: 'Failed to swap summons' }))
return json(error, { status: apiResponse.status })
}
const data = await apiResponse.json()
return json(data)
}

View file

@ -1,34 +0,0 @@
import { json, type RequestHandler } from '@sveltejs/kit'
import { buildApiUrl, extractHeaders, handleApiError } from '../../../_utils'
/**
* POST /api/parties/[id]/grid_weapons - Add weapon to party
* Proxies to Rails API with proper authentication
*/
export const POST: RequestHandler = async ({ request, params, fetch, cookies }) => {
try {
const body = await request.json()
const headers = {
...extractHeaders(request),
Authorization: `Bearer ${cookies.get('access_token')}`
}
// Forward to Rails API
const response = await fetch(buildApiUrl('/weapons'), {
method: 'POST',
headers,
body: JSON.stringify({
weapon: {
party_id: params.id,
...body
}
})
})
const data = await response.json()
return json(data, { status: response.status })
} catch (error) {
return json(handleApiError(error, 'add weapon'), { status: 500 })
}
}

View file

@ -1,31 +0,0 @@
import { json } from '@sveltejs/kit'
import type { RequestHandler } from './$types'
import { buildApiUrl, extractHeaders, handleApiError } from '../../../../../_utils'
export const PUT: RequestHandler = async ({ params, request, fetch, cookies }) => {
const { id: partyId, weaponId } = params
const body = await request.json()
const editKey = request.headers.get('X-Edit-Key')
// Forward the request to the Rails API
const apiResponse = await fetch(
buildApiUrl(`/parties/${partyId}/grid_weapons/${weaponId}/position`),
{
method: 'PUT',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${cookies.get('access_token')}`,
...(editKey ? { 'X-Edit-Key': editKey } : {})
},
body: JSON.stringify(body)
}
)
if (!apiResponse.ok) {
const error = await apiResponse.json().catch(() => ({ error: 'Failed to update weapon position' }))
return json(error, { status: apiResponse.status })
}
const data = await apiResponse.json()
return json(data)
}

View file

@ -1,31 +0,0 @@
import { json } from '@sveltejs/kit'
import type { RequestHandler } from './$types'
import { buildApiUrl, extractHeaders, handleApiError } from '../../../../_utils'
export const POST: RequestHandler = async ({ params, request, fetch, cookies }) => {
const { id: partyId } = params
const body = await request.json()
const editKey = request.headers.get('X-Edit-Key')
// Forward the request to the Rails API
const apiResponse = await fetch(
buildApiUrl(`/parties/${partyId}/grid_weapons/swap`),
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${cookies.get('access_token')}`,
...(editKey ? { 'X-Edit-Key': editKey } : {})
},
body: JSON.stringify(body)
}
)
if (!apiResponse.ok) {
const error = await apiResponse.json().catch(() => ({ error: 'Failed to swap weapons' }))
return json(error, { status: apiResponse.status })
}
const data = await apiResponse.json()
return json(data)
}

View file

@ -1,80 +0,0 @@
import { json, type RequestHandler } from '@sveltejs/kit'
import { buildApiUrl, extractHeaders, handleApiError } from '../../../_utils'
/**
* POST /api/parties/[id]/summons - Add summon to party
* DELETE /api/parties/[id]/summons - Remove summon from party
* Proxies to Rails API with proper authentication
*/
export const POST: RequestHandler = async ({ request, params, fetch }) => {
try {
const body = await request.json()
const editKey = request.headers.get('X-Edit-Key')
// Transform to Rails API format
const railsBody = {
summon: {
party_id: params.id,
summon_id: body.summonId,
position: body.position,
main: body.position === -1 || body.main,
friend: body.position === 6 || body.friend,
quick_summon: body.quickSummon ?? false,
uncap_level: body.uncapLevel ?? 3,
transcendence_step: body.transcendenceStep ?? 0
}
}
// Forward to Rails API
const response = await fetch(buildApiUrl('/summons'), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(editKey ? { 'X-Edit-Key': editKey } : {})
},
body: JSON.stringify(railsBody)
})
const data = await response.json()
return json(data, { status: response.status })
} catch (error) {
console.error('Error adding summon:', error)
return json(
{ error: 'Failed to add summon' },
{ status: 500 }
)
}
}
export const DELETE: RequestHandler = async ({ request, params, fetch }) => {
try {
const body = await request.json()
const editKey = request.headers.get('X-Edit-Key')
// Forward to Rails API - use grid_summons endpoint with the ID
const response = await fetch(buildApiUrl(`/grid_summons/${body.gridSummonId}`), {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
...(editKey ? { 'X-Edit-Key': editKey } : {})
}
})
if (response.ok) {
// DELETE might not return a body
const text = await response.text()
const data = text ? JSON.parse(text) : {}
return json(data, { status: response.status })
}
const errorData = await response.json().catch(() => ({}))
return json(errorData, { status: response.status })
} catch (error) {
console.error('Error removing summon:', error)
return json(
{ error: 'Failed to remove summon' },
{ status: 500 }
)
}
}

View file

@ -1,38 +0,0 @@
import { json, type RequestHandler } from '@sveltejs/kit'
import { buildApiUrl, extractHeaders, handleApiError } from '../../../../_utils'
/**
* PUT /api/parties/[id]/summons/[summonId] - Update summon in party
* Proxies to Rails API with proper authentication
*/
export const PUT: RequestHandler = async ({ request, params, fetch }) => {
try {
const body = await request.json()
const editKey = request.headers.get('X-Edit-Key')
// Transform to Rails API format
const railsBody = {
summon: body
}
// Forward to Rails API
const response = await fetch(buildApiUrl(`/grid_summons/${params.summonId}`), {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
...(editKey ? { 'X-Edit-Key': editKey } : {})
},
body: JSON.stringify(railsBody)
})
const data = await response.json()
return json(data, { status: response.status })
} catch (error) {
console.error('Error updating summon:', error)
return json(
{ error: 'Failed to update summon' },
{ status: 500 }
)
}
}

View file

@ -1,81 +0,0 @@
import { json, type RequestHandler } from '@sveltejs/kit'
import { buildApiUrl, extractHeaders, handleApiError } from '../../../_utils'
/**
* POST /api/parties/[id]/weapons - Add weapon to party
* DELETE /api/parties/[id]/weapons - Remove weapon from party
* Proxies to Rails API with proper authentication
*/
export const POST: RequestHandler = async ({ request, params, fetch }) => {
try {
const body = await request.json()
const editKey = request.headers.get('X-Edit-Key')
// Transform to Rails API format
const railsBody = {
weapon: {
party_id: params.id,
weapon_id: body.weaponId,
position: body.position,
mainhand: body.position === -1 || body.mainhand,
uncap_level: body.uncapLevel ?? 3,
transcendence_step: body.transcendenceStep ?? 0,
element: body.element
}
}
// Forward to Rails API
const response = await fetch(buildApiUrl('/weapons'), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(editKey ? { 'X-Edit-Key': editKey } : {})
},
body: JSON.stringify(railsBody)
})
const data = await response.json()
return json(data, { status: response.status })
} catch (error) {
return json(handleApiError(error, 'adding weapon'), { status: 500 })
return json(
{ error: 'Failed to add weapon' },
{ status: 500 }
)
}
}
export const DELETE: RequestHandler = async ({ request, params, fetch }) => {
try {
const body = await request.json()
const editKey = request.headers.get('X-Edit-Key')
console.log('DELETE weapon request:', { body, params })
// Forward to Rails API - use grid_weapons endpoint with the ID
const response = await fetch(buildApiUrl(`/grid_weapons/${body.gridWeaponId}`), {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
...(editKey ? { 'X-Edit-Key': editKey } : {})
}
})
if (response.ok) {
// DELETE might not return a body
const text = await response.text()
const data = text ? JSON.parse(text) : {}
return json(data, { status: response.status })
}
const errorData = await response.json().catch(() => ({}))
return json(errorData, { status: response.status })
} catch (error) {
return json(handleApiError(error, 'removing weapon'), { status: 500 })
return json(
{ error: 'Failed to remove weapon' },
{ status: 500 }
)
}
}

View file

@ -1,38 +0,0 @@
import { json, type RequestHandler } from '@sveltejs/kit'
import { buildApiUrl, extractHeaders, handleApiError } from '../../../../_utils'
/**
* PUT /api/parties/[id]/weapons/[weaponId] - Update weapon in party
* Proxies to Rails API with proper authentication
*/
export const PUT: RequestHandler = async ({ request, params, fetch }) => {
try {
const body = await request.json()
const editKey = request.headers.get('X-Edit-Key')
// Transform to Rails API format
const railsBody = {
weapon: body
}
// Forward to Rails API
const response = await fetch(buildApiUrl(`/grid_weapons/${params.weaponId}`), {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
...(editKey ? { 'X-Edit-Key': editKey } : {})
},
body: JSON.stringify(railsBody)
})
const data = await response.json()
return json(data, { status: response.status })
} catch (error) {
return json(handleApiError(error, 'updating weapon'), { status: 500 })
return json(
{ error: 'Failed to update weapon' },
{ status: 500 }
)
}
}

View file

@ -1,30 +0,0 @@
import { json, type RequestHandler } from '@sveltejs/kit'
import { buildApiUrl, extractHeaders, handleApiError } from '../../_utils'
export const POST: RequestHandler = async ({ request, fetch }) => {
try {
const body = await request.json()
const editKey = request.headers.get('X-Edit-Key')
// Forward to Rails API with automatic auth via handleFetch
const response = await fetch(buildApiUrl('/characters/update_uncap'), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(editKey ? { 'X-Edit-Key': editKey } : {})
},
body: JSON.stringify(body)
})
if (!response.ok) {
const error = await response.json().catch(() => ({ error: response.statusText }))
return json(error, { status: response.status })
}
const data = await response.json()
return json(data)
} catch (error) {
console.error('Error updating character uncap:', error)
return json({ error: 'Internal server error' }, { status: 500 })
}
}

View file

@ -1,30 +0,0 @@
import { json, type RequestHandler } from '@sveltejs/kit'
import { buildApiUrl, extractHeaders, handleApiError } from '../../_utils'
export const POST: RequestHandler = async ({ request, fetch }) => {
try {
const body = await request.json()
const editKey = request.headers.get('X-Edit-Key')
// Forward to Rails API with automatic auth via handleFetch
const response = await fetch(buildApiUrl('/summons/update_uncap'), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(editKey ? { 'X-Edit-Key': editKey } : {})
},
body: JSON.stringify(body)
})
if (!response.ok) {
const error = await response.json().catch(() => ({ error: response.statusText }))
return json(error, { status: response.status })
}
const data = await response.json()
return json(data)
} catch (error) {
console.error('Error updating summon uncap:', error)
return json({ error: 'Internal server error' }, { status: 500 })
}
}

View file

@ -1,30 +0,0 @@
import { json, type RequestHandler } from '@sveltejs/kit'
import { buildApiUrl, extractHeaders, handleApiError } from '../../_utils'
export const POST: RequestHandler = async ({ request, fetch }) => {
try {
const body = await request.json()
const editKey = request.headers.get('X-Edit-Key')
// Forward to Rails API with automatic auth via handleFetch
const response = await fetch(buildApiUrl('/weapons/update_uncap'), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(editKey ? { 'X-Edit-Key': editKey } : {})
},
body: JSON.stringify(body)
})
if (!response.ok) {
const error = await response.json().catch(() => ({ error: response.statusText }))
return json(error, { status: response.status })
}
const data = await response.json()
return json(data)
} catch (error) {
console.error('Error updating weapon uncap:', error)
return json({ error: 'Internal server error' }, { status: 500 })
}
}