redesign profile header with gamertag + gbf profile link

This commit is contained in:
Justin Edmund 2025-12-13 21:24:09 -08:00
parent 14819f0b73
commit aee62522e9
6 changed files with 346 additions and 92 deletions

View file

@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round">
<polyline points="14.5 17.5 3 6 3 3 6 3 17.5 14.5"/>
<line x1="13" y1="19" x2="19" y2="13"/>
<line x1="16" y1="16" x2="20" y2="20"/>
<line x1="19" y1="21" x2="21" y2="19"/>
</svg>

After

Width:  |  Height:  |  Size: 353 B

View file

@ -3,6 +3,32 @@ import type { Party } from '$lib/types/api/party'
import type { RequestOptions } from './types'
import { DEFAULT_ADAPTER_CONFIG } from './config'
/**
* API response for user data (already camelCased by BaseAdapter.transformResponse)
* Note: BaseAdapter automatically transforms snake_case to camelCase,
* so we receive granblueId, showGamertag, etc.
*/
interface ApiUserResponse {
id: string
username: string
language: string
private: boolean
gender: number
theme: string
role: number
granblueId?: number | string | null // API returns number, transformed to camelCase
showGamertag?: boolean // transformed from show_gamertag
gamertag?: string
avatar: {
picture: string
element: string
}
}
/**
* Transformed user info (camelCase for frontend)
* Uses our preferred naming convention (showCrewGamertag, crewGamertag)
*/
export interface UserInfo {
id: string
username: string
@ -11,6 +37,9 @@ export interface UserInfo {
gender: number
theme: string
role: number
granblueId?: string
showCrewGamertag?: boolean
crewGamertag?: string
avatar: {
picture: string
element: string
@ -30,6 +59,29 @@ export interface UserProfileResponse {
perPage?: number
}
/**
* Transform API user response to frontend UserInfo format
* Renames API fields to our preferred naming convention
*/
function transformUserResponse(apiUser: ApiUserResponse): UserInfo {
return {
id: apiUser.id,
username: apiUser.username,
language: apiUser.language,
private: apiUser.private,
gender: apiUser.gender,
theme: apiUser.theme,
role: apiUser.role,
// granblueId comes as number from API, convert to string
granblueId: apiUser.granblueId != null ? String(apiUser.granblueId) : undefined,
// Rename showGamertag to showCrewGamertag
showCrewGamertag: apiUser.showGamertag,
// Rename gamertag to crewGamertag
crewGamertag: apiUser.gamertag,
avatar: apiUser.avatar
}
}
/**
* Adapter for user-related API operations
*/
@ -38,7 +90,11 @@ export class UserAdapter extends BaseAdapter {
* Get user information
*/
async getInfo(username: string, options?: RequestOptions): Promise<UserInfo> {
return this.request<UserInfo>(`/users/info/${encodeURIComponent(username)}`, options)
const result = await this.request<ApiUserResponse>(
`/users/info/${encodeURIComponent(username)}`,
options
)
return transformUserResponse(result)
}
/**
@ -47,14 +103,26 @@ export class UserAdapter extends BaseAdapter {
async getProfile(username: string, page = 1): Promise<UserProfileResponse> {
const params = page > 1 ? { page } : undefined
const response = await this.request<{
profile: UserProfile
meta?: { count?: number; total_pages?: number; totalPages?: number; per_page?: number; perPage?: number }
profile: ApiUserResponse & { parties?: Party[] }
meta?: {
count?: number
total_pages?: number
totalPages?: number
per_page?: number
perPage?: number
}
}>(`/users/${encodeURIComponent(username)}`, { params })
const items = Array.isArray(response.profile?.parties) ? response.profile.parties : []
// Transform API response to frontend format
const user: UserProfile = {
...transformUserResponse(response.profile),
parties: items
}
const result: UserProfileResponse = {
user: response.profile,
user,
items,
page
}
@ -150,7 +218,7 @@ export class UserAdapter extends BaseAdapter {
*/
async updateProfile(updates: Partial<UserInfo>): Promise<UserInfo> {
// Wrap updates in 'user' key as required by Rails backend
const result = await this.request<UserInfo>('/users/me', {
const result = await this.request<ApiUserResponse>('/users/me', {
method: 'PUT',
body: JSON.stringify({ user: updates })
})
@ -158,14 +226,15 @@ export class UserAdapter extends BaseAdapter {
// Clear cache for current user after update
this.clearCache('/users/me')
return result
return transformUserResponse(result)
}
/**
* Get current user
*/
async getCurrentUser(): Promise<UserInfo> {
return this.request<UserInfo>('/users/me')
const result = await this.request<ApiUserResponse>('/users/me')
return transformUserResponse(result)
}
}

View file

@ -6,6 +6,8 @@ export interface UserUpdateParams {
gender?: number | undefined
language?: string | undefined
theme?: string | undefined
granblueId?: string | undefined
showCrewGamertag?: boolean | undefined
}
export interface UserResponse {
@ -19,6 +21,8 @@ export interface UserResponse {
language: string
theme: string
role: number
granblueId?: string
showCrewGamertag?: boolean
}
export const users = {
@ -33,6 +37,8 @@ export const users = {
gender?: number | undefined
language?: string | undefined
theme?: string | undefined
granblue_id?: string | undefined
show_gamertag?: boolean | undefined
} = {}
if (params.picture !== undefined) updates.picture = params.picture
@ -40,6 +46,8 @@ export const users = {
if (params.gender !== undefined) updates.gender = params.gender
if (params.language !== undefined) updates.language = params.language
if (params.theme !== undefined) updates.theme = params.theme
if (params.granblueId !== undefined) updates.granblue_id = params.granblueId
if (params.showCrewGamertag !== undefined) updates.show_gamertag = params.showCrewGamertag
const result = await userAdapter.updateProfile(updates)
return {
@ -49,7 +57,9 @@ export const users = {
gender: result.gender,
language: result.language,
theme: result.theme,
role: result.role
role: result.role,
granblueId: result.granblueId,
showCrewGamertag: result.showCrewGamertag
}
}
}

View file

@ -2,6 +2,7 @@
import { getAvatarSrc, getAvatarSrcSet } from '$lib/utils/avatar'
import { DropdownMenu } from 'bits-ui'
import Icon from '$lib/components/Icon.svelte'
import Tooltip from '$lib/components/ui/Tooltip.svelte'
import DropdownItem from '$lib/components/ui/dropdown/DropdownItem.svelte'
import InviteUserModal from '$lib/components/crew/InviteUserModal.svelte'
import type { CrewRole } from '$lib/types/api/crew'
@ -13,6 +14,14 @@
title?: string
activeTab: 'teams' | 'favorites' | 'collection'
isOwner?: boolean
/** User's selected element for theming */
element?: string
/** User's Granblue Fantasy ID for profile link */
granblueId?: string
/** Whether to show crew gamertag */
showCrewGamertag?: boolean
/** The crew's gamertag to display */
crewGamertag?: string
/** Current user's crew role (null if not in a crew) */
viewerCrewRole?: CrewRole | null
/** Current user's crew ID */
@ -28,11 +37,20 @@
title,
activeTab,
isOwner = false,
element = 'null',
granblueId,
showCrewGamertag = false,
crewGamertag,
viewerCrewRole = null,
viewerCrewId = null,
targetUserHasCrew = false
}: Props = $props()
// GBF profile URL
const gbfProfileUrl = $derived(
granblueId ? `https://game.granbluefantasy.jp/#profile/${granblueId}` : null
)
const avatarSrc = $derived(getAvatarSrc(avatarPicture))
const avatarSrcSet = $derived(getAvatarSrcSet(avatarPicture))
const displayTitle = $derived(title || username)
@ -54,98 +72,123 @@
</script>
<header class="header">
{#if avatarPicture}
<img
class="avatar"
alt={`Avatar of ${username}`}
src={avatarSrc}
srcset={avatarSrcSet}
width="64"
height="64"
/>
{:else}
<div class="avatar" aria-hidden="true"></div>
{/if}
<div class="header-content">
<h1>{displayTitle}</h1>
<nav class="tabs" aria-label="Profile sections">
<a
class:active={activeTab === 'teams' || activeTab === 'favorites'}
href="/{username}"
data-sveltekit-preload-data="hover"
>
Teams
</a>
{#if isOwner}
<a
class:active={activeTab === 'favorites'}
href="/{username}?tab=favorites"
data-sveltekit-preload-data="hover"
>
Favorites
</a>
<div class="header-top">
<div class="profile-info">
{#if avatarPicture}
<img
class="avatar"
alt={`Avatar of ${username}`}
src={avatarSrc}
srcset={avatarSrcSet}
width="56"
height="56"
/>
{:else}
<div class="avatar" aria-hidden="true"></div>
{/if}
<a
class:active={activeTab === 'collection'}
href="/{username}/collection/characters"
data-sveltekit-preload-data="hover"
>
Collection
</a>
</nav>
<div class="name-section">
<div class="name-row">
<h1>{displayTitle}</h1>
{#if showCrewGamertag && crewGamertag}
<span class="gamertag-pill" data-element={element}>{crewGamertag}</span>
{/if}
</div>
</div>
</div>
<div class="header-actions">
{#if gbfProfileUrl}
<Tooltip content="In-game profile">
<a
href={gbfProfileUrl}
target="_blank"
rel="noopener noreferrer"
class="gbf-profile-link"
>
<Icon name="sword" size={24} />
</a>
</Tooltip>
{/if}
{#if showMenu}
<DropdownMenu.Root>
<DropdownMenu.Trigger class="menu-trigger">
<Icon name="ellipsis" size={16} />
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content class="dropdown-content" sideOffset={5} align="end">
{#if canInvite}
<DropdownItem>
<button onclick={() => (inviteModalOpen = true)}>
<Icon name="user-plus" size={14} />
<span>Invite to Crew</span>
</button>
</DropdownItem>
{/if}
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
{/if}
</div>
</div>
{#if showMenu}
<div class="header-actions">
<DropdownMenu.Root>
<DropdownMenu.Trigger class="menu-trigger">
<Icon name="ellipsis" size={16} />
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content class="dropdown-content" sideOffset={5} align="end">
{#if canInvite}
<DropdownItem>
<button onclick={() => (inviteModalOpen = true)}>
<Icon name="user-plus" size={14} />
<span>Invite to Crew</span>
</button>
</DropdownItem>
{/if}
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
</div>
{/if}
<nav class="tabs" aria-label="Profile sections" data-element={element}>
<a class:active={activeTab === 'teams'} href="/{username}" data-sveltekit-preload-data="hover">
Teams
</a>
{#if isOwner}
<a
class:active={activeTab === 'favorites'}
href="/{username}/favorites"
data-sveltekit-preload-data="hover"
>
Favorites
</a>
{/if}
<a
class:active={activeTab === 'collection'}
href="/{username}/collection/characters"
data-sveltekit-preload-data="hover"
>
Collection
</a>
</nav>
</header>
{#if canInvite && userId && viewerCrewId}
<InviteUserModal
bind:open={inviteModalOpen}
{userId}
{username}
crewId={viewerCrewId}
/>
<InviteUserModal bind:open={inviteModalOpen} {userId} {username} crewId={viewerCrewId} />
{/if}
<style lang="scss">
@use '$src/themes/spacing' as *;
@use '$src/themes/colors' as *;
@use '$src/themes/layout' as *;
@use '$src/themes/typography' as *;
.header {
background: var(--card-bg);
border-radius: $card-corner;
padding: $unit-2x;
margin-bottom: $unit-2x;
overflow: hidden;
}
.header-top {
display: flex;
align-items: center;
justify-content: space-between;
padding: $unit-2x $unit-3x;
}
.profile-info {
display: flex;
align-items: center;
gap: $unit-2x;
margin-bottom: $unit-2x;
}
.avatar {
width: 64px;
height: 64px;
width: 56px;
height: 56px;
border-radius: 50%;
background: $grey-80;
border: 1px solid $grey-75;
@ -153,38 +196,159 @@
flex-shrink: 0;
}
.header-content {
flex: 1;
.name-section {
display: flex;
flex-direction: column;
gap: $unit-half;
}
.name-row {
display: flex;
align-items: center;
gap: $unit;
}
h1 {
margin: 0 0 $unit-half;
font-size: 24px;
margin: 0;
font-size: 20px;
font-weight: $medium;
}
.gamertag-pill {
display: inline-flex;
align-items: center;
padding: 2px $unit;
border-radius: $full-corner;
font-size: $font-small;
font-weight: $medium;
// Element-based pill colors
&[data-element='wind'] {
background: $wind-bg-20;
color: $wind-text-20;
}
&[data-element='fire'] {
background: $fire-bg-20;
color: $fire-text-20;
}
&[data-element='water'] {
background: $water-bg-20;
color: $water-text-20;
}
&[data-element='earth'] {
background: $earth-bg-20;
color: $earth-text-20;
}
&[data-element='light'] {
background: $light-bg-20;
color: $light-text-20;
}
&[data-element='dark'] {
background: $dark-bg-20;
color: $dark-text-20;
}
&[data-element='null'],
&:not([data-element]) {
background: $grey-90;
color: $grey-30;
}
}
.gbf-profile-link {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
border-radius: $card-corner;
color: var(--text-secondary);
text-decoration: none;
cursor: pointer;
transition:
background-color 0.15s ease,
color 0.15s ease;
:global(svg) {
stroke-width: 2px;
}
&:hover {
background: var(--button-contained-bg-hover, $grey-90);
color: var(--text-primary);
}
&:focus-visible {
outline: 2px solid var(--focus-ring);
outline-offset: 2px;
}
}
.tabs {
display: flex;
gap: $unit-2x;
}
.tabs a {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: $unit-2x $unit;
border-top: 1px solid var(--border-color, rgba(0, 0, 0, 0.08));
text-decoration: none;
color: var(--text-secondary);
padding-bottom: 2px;
border-bottom: 2px solid transparent;
font-size: $font-small;
font-weight: $medium;
transition:
color 0.15s ease,
background-color 0.15s ease;
&:hover {
color: var(--text-primary);
}
&.active {
border-color: var(--accent-color, #3366ff);
color: var(--accent-color, #3366ff);
background: rgba(0, 0, 0, 0.02);
}
}
// Element-based active tab colors
.tabs[data-element='wind'] a.active {
color: var(--wind-nav-selected-text);
background: var(--wind-nav-selected-bg);
}
.tabs[data-element='fire'] a.active {
color: var(--fire-nav-selected-text);
background: var(--fire-nav-selected-bg);
}
.tabs[data-element='water'] a.active {
color: var(--water-nav-selected-text);
background: var(--water-nav-selected-bg);
}
.tabs[data-element='earth'] a.active {
color: var(--earth-nav-selected-text);
background: var(--earth-nav-selected-bg);
}
.tabs[data-element='light'] a.active {
color: var(--light-nav-selected-text);
background: var(--light-nav-selected-bg);
}
.tabs[data-element='dark'] a.active {
color: var(--dark-nav-selected-text);
background: var(--dark-nav-selected-bg);
}
.tabs[data-element='null'] a.active,
.tabs:not([data-element]) a.active {
color: var(--null-nav-selected-text);
background: var(--null-nav-selected-bg);
}
.header-actions {
margin-left: auto;
display: flex;
align-items: center;
gap: $unit-half;
flex-shrink: 0;
}
@ -199,7 +363,9 @@
border: none;
cursor: pointer;
color: var(--text-secondary);
transition: background-color 0.15s ease, color 0.15s ease;
transition:
background-color 0.15s ease,
color 0.15s ease;
&:hover {
background: var(--button-contained-bg-hover, $grey-90);

View file

@ -5,4 +5,6 @@ export interface UserCookie {
gender: number
theme: string
bahamut?: boolean
granblueId?: string
showCrewGamertag?: boolean
}

View file

@ -130,6 +130,7 @@ export interface Summon {
}
transcendenceHp?: number
transcendenceAtk?: number
series?: number
// Database/admin fields
releaseDate?: string
flbDate?: string