redesign profile header with gamertag + gbf profile link
This commit is contained in:
parent
14819f0b73
commit
aee62522e9
6 changed files with 346 additions and 92 deletions
6
src/assets/icons/sword.svg
Normal file
6
src/assets/icons/sword.svg
Normal 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 |
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
2
src/lib/types/UserCookie.d.ts
vendored
2
src/lib/types/UserCookie.d.ts
vendored
|
|
@ -5,4 +5,6 @@ export interface UserCookie {
|
|||
gender: number
|
||||
theme: string
|
||||
bahamut?: boolean
|
||||
granblueId?: string
|
||||
showCrewGamertag?: boolean
|
||||
}
|
||||
|
|
|
|||
|
|
@ -130,6 +130,7 @@ export interface Summon {
|
|||
}
|
||||
transcendenceHp?: number
|
||||
transcendenceAtk?: number
|
||||
series?: number
|
||||
// Database/admin fields
|
||||
releaseDate?: string
|
||||
flbDate?: string
|
||||
|
|
|
|||
Loading…
Reference in a new issue