feat: reskin member pages to zine direction
This commit is contained in:
parent
88caca94c7
commit
1ac21d6a98
4 changed files with 1972 additions and 1754 deletions
315
app/pages/member/account.vue
Normal file
315
app/pages/member/account.vue
Normal file
|
|
@ -0,0 +1,315 @@
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<!-- Unauthenticated -->
|
||||||
|
<div v-if="!memberData" class="loading">
|
||||||
|
<p>Please sign in to access your account settings.</p>
|
||||||
|
<button class="btn btn-primary" @click="openLoginModal({ title: 'Sign in to manage your account' })">Sign In</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else>
|
||||||
|
<!-- PAGE HEADER -->
|
||||||
|
<PageHeader title="Account Settings" subtitle="Manage your membership and billing" />
|
||||||
|
|
||||||
|
<!-- CONTENT AREA WITH EVENTS SIDEBAR -->
|
||||||
|
<div class="content-area">
|
||||||
|
<div class="page-content">
|
||||||
|
<div class="account-columns">
|
||||||
|
|
||||||
|
<!-- LEFT COLUMN: Membership Status & Email -->
|
||||||
|
<div class="account-col-left">
|
||||||
|
<div class="section-label">Current Membership</div>
|
||||||
|
|
||||||
|
<div class="membership-card">
|
||||||
|
<table>
|
||||||
|
<tr>
|
||||||
|
<td>Status</td>
|
||||||
|
<td>
|
||||||
|
<span class="status-dot" :class="memberData.status || 'active'"></span>
|
||||||
|
{{ memberData.status || 'Active' }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Circle</td>
|
||||||
|
<td :style="{ color: `var(--c-${memberData.circle || 'community'})` }">
|
||||||
|
{{ memberData.circle || 'Community' }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Contribution</td>
|
||||||
|
<td>${{ memberData.contributionAmount || 0 }} / month</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Member since</td>
|
||||||
|
<td>{{ formatMemberSince(memberData.createdAt) }}</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Email -->
|
||||||
|
<hr class="section-divider">
|
||||||
|
<div class="section-label">Email</div>
|
||||||
|
<div class="email-display">
|
||||||
|
<span class="email-value">{{ memberData.email }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="email-hint">Used for login magic links and notifications</div>
|
||||||
|
|
||||||
|
<!-- Danger Zone -->
|
||||||
|
<hr class="section-divider danger">
|
||||||
|
<div class="section-label danger">Danger Zone</div>
|
||||||
|
<div class="danger-zone">
|
||||||
|
<p>Cancelling your membership will immediately revoke access to member-only resources, events, and the Slack workspace. <strong>This action cannot be easily undone.</strong></p>
|
||||||
|
<button class="btn btn-danger" @click="handleCancelMembership" :disabled="isCancelling">
|
||||||
|
{{ isCancelling ? 'Cancelling...' : 'Cancel Membership' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- RIGHT COLUMN: Change Contribution & Circle -->
|
||||||
|
<div class="account-col-right">
|
||||||
|
<div class="section-label">Change Contribution</div>
|
||||||
|
|
||||||
|
<TierPicker v-model="selectedTier" :tiers="tiers" />
|
||||||
|
<div class="tier-hint">Changes take effect on your next billing cycle</div>
|
||||||
|
<button
|
||||||
|
class="btn btn-primary btn-section"
|
||||||
|
@click="handleUpdateTier"
|
||||||
|
:disabled="selectedTier === memberData.contributionAmount || isUpdating"
|
||||||
|
>
|
||||||
|
{{ isUpdating ? 'Updating...' : 'Update Contribution' }}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Change Circle -->
|
||||||
|
<hr class="section-divider">
|
||||||
|
<div class="section-label">Change Circle</div>
|
||||||
|
|
||||||
|
<CirclePicker v-model="selectedCircle" :circles="circleOptions" />
|
||||||
|
<button
|
||||||
|
class="btn btn-primary btn-section"
|
||||||
|
@click="handleUpdateCircle"
|
||||||
|
:disabled="selectedCircle === memberData.circle || isUpdating"
|
||||||
|
>
|
||||||
|
{{ isUpdating ? 'Updating...' : 'Update Circle' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- EVENTS MINI SIDEBAR -->
|
||||||
|
<EventsMiniSidebar :events="upcomingEvents" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
definePageMeta({
|
||||||
|
middleware: 'auth',
|
||||||
|
})
|
||||||
|
|
||||||
|
const { memberData, checkMemberStatus } = useAuth()
|
||||||
|
const { openLoginModal } = useLoginModal()
|
||||||
|
const toast = useToast()
|
||||||
|
|
||||||
|
const selectedTier = ref(0)
|
||||||
|
const selectedCircle = ref('')
|
||||||
|
const isUpdating = ref(false)
|
||||||
|
const isCancelling = ref(false)
|
||||||
|
|
||||||
|
const tiers = [
|
||||||
|
{ amount: 0, display: '$0', label: 'Solidarity' },
|
||||||
|
{ amount: 5, display: '$5', label: 'Supporter' },
|
||||||
|
{ amount: 15, display: '$15', label: 'Sustainer' },
|
||||||
|
{ amount: 30, display: '$30', label: 'Builder' },
|
||||||
|
{ amount: 50, display: '$50', label: 'Champion' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const circleOptions = [
|
||||||
|
{ value: 'community', label: 'Community', description: 'For anyone interested in cooperative game dev. Access discussions, events, and resources.' },
|
||||||
|
{ value: 'founder', label: 'Founder', description: 'For those actively building or running a cooperative studio. Peer support and deep dives.' },
|
||||||
|
{ value: 'practitioner', label: 'Practitioner', description: 'For professionals advising co-ops: lawyers, accountants, facilitators, consultants.' },
|
||||||
|
]
|
||||||
|
|
||||||
|
// Initialize from member data
|
||||||
|
watchEffect(() => {
|
||||||
|
if (memberData.value) {
|
||||||
|
selectedTier.value = memberData.value.contributionAmount || 0
|
||||||
|
selectedCircle.value = memberData.value.circle || 'community'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const { data: upcomingEvents } = await useFetch('/api/events', {
|
||||||
|
query: { limit: 3, upcoming: true },
|
||||||
|
default: () => [],
|
||||||
|
})
|
||||||
|
|
||||||
|
const formatMemberSince = (dateStr) => {
|
||||||
|
if (!dateStr) return ''
|
||||||
|
return new Date(dateStr).toLocaleDateString('en-US', { month: 'long', year: 'numeric' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleUpdateTier = async () => {
|
||||||
|
isUpdating.value = true
|
||||||
|
try {
|
||||||
|
await $fetch('/api/members/update-contribution', {
|
||||||
|
method: 'POST',
|
||||||
|
body: { amount: selectedTier.value },
|
||||||
|
})
|
||||||
|
await checkMemberStatus()
|
||||||
|
toast.add({ title: 'Contribution updated', color: 'green' })
|
||||||
|
} catch (err) {
|
||||||
|
toast.add({ title: 'Update failed', description: err.data?.statusMessage || 'Please try again.', color: 'red' })
|
||||||
|
} finally {
|
||||||
|
isUpdating.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleUpdateCircle = async () => {
|
||||||
|
isUpdating.value = true
|
||||||
|
try {
|
||||||
|
await $fetch('/api/members/update-circle', {
|
||||||
|
method: 'POST',
|
||||||
|
body: { circle: selectedCircle.value },
|
||||||
|
})
|
||||||
|
await checkMemberStatus()
|
||||||
|
toast.add({ title: 'Circle updated', color: 'green' })
|
||||||
|
} catch (err) {
|
||||||
|
toast.add({ title: 'Update failed', description: err.data?.statusMessage || 'Please try again.', color: 'red' })
|
||||||
|
} finally {
|
||||||
|
isUpdating.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCancelMembership = async () => {
|
||||||
|
isCancelling.value = true
|
||||||
|
try {
|
||||||
|
await $fetch('/api/members/cancel', { method: 'POST' })
|
||||||
|
await checkMemberStatus()
|
||||||
|
toast.add({ title: 'Membership cancelled', color: 'orange' })
|
||||||
|
} catch (err) {
|
||||||
|
toast.add({ title: 'Cancellation failed', description: err.data?.statusMessage || 'Please try again.', color: 'red' })
|
||||||
|
} finally {
|
||||||
|
isCancelling.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.loading {
|
||||||
|
padding: 48px 32px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- CONTENT AREA ---- */
|
||||||
|
.content-area {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 200px;
|
||||||
|
}
|
||||||
|
.page-content {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- TWO-COLUMN LAYOUT ---- */
|
||||||
|
.account-columns {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
}
|
||||||
|
.account-col-left {
|
||||||
|
padding: 24px 28px;
|
||||||
|
border-right: 1px dashed var(--border);
|
||||||
|
}
|
||||||
|
.account-col-right {
|
||||||
|
padding: 24px 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- MEMBERSHIP CARD ---- */
|
||||||
|
.membership-card {
|
||||||
|
border: 1px dashed var(--border);
|
||||||
|
padding: 16px 20px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.membership-card table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
.membership-card td {
|
||||||
|
padding: 4px 0;
|
||||||
|
font-size: 12px;
|
||||||
|
border-bottom: 1px dashed var(--border);
|
||||||
|
}
|
||||||
|
.membership-card tr:last-child td {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
.membership-card td:first-child {
|
||||||
|
color: var(--text-faint);
|
||||||
|
width: 120px;
|
||||||
|
}
|
||||||
|
.membership-card td:last-child {
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot {
|
||||||
|
display: inline-block;
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
margin-right: 4px;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
.status-dot.active { background: var(--green); }
|
||||||
|
.status-dot.suspended { background: var(--ember); }
|
||||||
|
.status-dot.cancelled { background: var(--text-faint); }
|
||||||
|
.status-dot.pending_payment { background: var(--candle); }
|
||||||
|
|
||||||
|
/* ---- EMAIL ---- */
|
||||||
|
.email-display {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.email-value {
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.email-hint {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-faint);
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- DANGER ZONE ---- */
|
||||||
|
.section-divider.danger {
|
||||||
|
border-color: var(--ember);
|
||||||
|
}
|
||||||
|
.section-label.danger {
|
||||||
|
color: var(--ember);
|
||||||
|
}
|
||||||
|
.danger-zone p {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
line-height: 1.7;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
max-width: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- TIER HINT ---- */
|
||||||
|
.tier-hint {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-faint);
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-section {
|
||||||
|
width: 100%;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- RESPONSIVE ---- */
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
.content-area { grid-template-columns: 1fr; }
|
||||||
|
.account-columns { grid-template-columns: 1fr; }
|
||||||
|
.account-col-left {
|
||||||
|
border-right: none;
|
||||||
|
border-bottom: 1px dashed var(--border);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -1,347 +1,154 @@
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div class="dashboard">
|
||||||
<!-- Page Header -->
|
<!-- Loading State -->
|
||||||
<PageHeader
|
<div v-if="authPending" class="loading-state">
|
||||||
title="Member Dashboard"
|
<div class="spinner" />
|
||||||
:subtitle="`Welcome back, ${memberData?.name || 'Member'}!`"
|
<p>Loading your dashboard...</p>
|
||||||
size="medium"
|
</div>
|
||||||
/>
|
|
||||||
|
|
||||||
<UContainer class="">
|
<!-- Unauthenticated State -->
|
||||||
<!-- Loading State -->
|
<div v-else-if="!memberData" class="unauth-state">
|
||||||
<div
|
<h2>Sign in required</h2>
|
||||||
v-if="authPending"
|
<p>Please sign in to access your member dashboard.</p>
|
||||||
class="flex justify-center items-center py-20"
|
<button
|
||||||
|
class="btn btn-primary"
|
||||||
|
@click="openLoginModal({ title: 'Sign in to your dashboard', description: 'Enter your email to access your member dashboard' })"
|
||||||
>
|
>
|
||||||
<div class="text-center">
|
Sign In
|
||||||
<div
|
</button>
|
||||||
class="w-8 h-8 border-4 border-candlelight-500 border-t-transparent rounded-full animate-spin mx-auto mb-4"
|
</div>
|
||||||
/>
|
|
||||||
<p class="text-guild-300">Loading your dashboard...</p>
|
<!-- Dashboard Content -->
|
||||||
|
<template v-else>
|
||||||
|
<!-- Member Status Banner -->
|
||||||
|
<MemberStatusBanner :dismissible="true" />
|
||||||
|
|
||||||
|
<!-- Welcome Header -->
|
||||||
|
<div class="welcome">
|
||||||
|
<h1>Welcome back, {{ memberData?.name }}</h1>
|
||||||
|
<div class="meta">
|
||||||
|
<CircleBadge :circle="memberData?.circle || 'community'" />
|
||||||
|
<span>${{ memberData?.contributionTier }} CAD/mo</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Unauthenticated State -->
|
<!-- Upcoming Events + Quick Actions -->
|
||||||
<div
|
<div class="content-row">
|
||||||
v-else-if="!memberData"
|
<div class="content-block">
|
||||||
class="flex justify-center items-center py-20"
|
<div class="section-label">Your Upcoming Events</div>
|
||||||
>
|
|
||||||
<div class="text-center max-w-md">
|
|
||||||
<div class="w-16 h-16 bg-guild-800 border border-guild-600 rounded-full flex items-center justify-center mx-auto mb-4">
|
|
||||||
<Icon name="heroicons:lock-closed" class="w-8 h-8 text-guild-400" />
|
|
||||||
</div>
|
|
||||||
<h2 class="text-xl font-semibold text-guild-100 mb-2">Sign in required</h2>
|
|
||||||
<p class="text-guild-400 mb-6">Please sign in to access your member dashboard.</p>
|
|
||||||
<UButton @click="openLoginModal({ title: 'Sign in to your dashboard', description: 'Enter your email to access your member dashboard' })">
|
|
||||||
Sign In
|
|
||||||
</UButton>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Dashboard Content -->
|
<div v-if="loadingEvents" class="loading-inline">
|
||||||
<div v-else class="space-y-8">
|
<div class="spinner spinner-sm" />
|
||||||
<!-- Member Status Banner -->
|
|
||||||
<MemberStatusBanner :dismissible="true" />
|
|
||||||
<!-- Welcome Card -->
|
|
||||||
<UCard
|
|
||||||
|
|
||||||
:ui="{
|
|
||||||
root: 'bg-guild-900 border border-guild-700',
|
|
||||||
header: 'border-b border-guild-700',
|
|
||||||
body: 'bg-guild-900',
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
<template #header>
|
|
||||||
<div class="flex items-start justify-between gap-4">
|
|
||||||
<div class="flex-1">
|
|
||||||
<h1 class="text-display-lg text-guild-100 warm-text">
|
|
||||||
Welcome to Ghost Guild, {{ memberData?.name }}!
|
|
||||||
</h1>
|
|
||||||
<p
|
|
||||||
:class="[
|
|
||||||
'mt-2',
|
|
||||||
isActive ? 'text-guild-300' : statusConfig.textColor,
|
|
||||||
]"
|
|
||||||
>
|
|
||||||
{{
|
|
||||||
isActive ? "Your membership is active!" : statusConfig.label
|
|
||||||
}}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div class="flex-shrink-0" v-if="memberData?.avatar">
|
|
||||||
<img
|
|
||||||
:src="`/ghosties/Ghost-${capitalize(memberData.avatar)}.png`"
|
|
||||||
:alt="memberData.name"
|
|
||||||
class="w-16 h-16"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div v-else class="flex-shrink-0">
|
|
||||||
<div
|
|
||||||
class="w-16 h-16 bg-guild-700 border border-guild-600 flex items-center justify-center text-guild-200 font-bold text-xl"
|
|
||||||
>
|
|
||||||
{{ memberData?.name?.charAt(0)?.toUpperCase() }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<div class="flex flex-wrap gap-4 text-sm">
|
|
||||||
<div class="bg-guild-800 border border-guild-600 px-4 py-2">
|
|
||||||
<span class="text-ui-label text-guild-200">Circle:</span>
|
|
||||||
<span class="font-medium text-stone-50 ml-1 capitalize">{{
|
|
||||||
memberData?.circle
|
|
||||||
}}</span>
|
|
||||||
</div>
|
|
||||||
<div class="bg-guild-800 border border-guild-600 px-4 py-2">
|
|
||||||
<span class="text-ui-label text-guild-200">Contribution:</span>
|
|
||||||
<span class="font-medium text-stone-50 ml-1"
|
|
||||||
>${{ memberData?.contributionTier }} CAD/month</span
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</UCard>
|
|
||||||
|
|
||||||
<!-- Quick Links -->
|
|
||||||
<UCard
|
|
||||||
:ui="{
|
|
||||||
root: 'bg-guild-900 border border-guild-700',
|
|
||||||
header: 'border-b border-guild-700 bg-guild-900',
|
|
||||||
body: 'bg-guild-900',
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
<template #header>
|
|
||||||
<h2 class="text-display-sm text-guild-100 warm-text">
|
|
||||||
Quick Links
|
|
||||||
</h2>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
|
||||||
<UButton
|
|
||||||
to="/members?peerSupport=true"
|
|
||||||
variant="outline"
|
|
||||||
:disabled="!canPeerSupport"
|
|
||||||
:class="[
|
|
||||||
'border-guild-600 text-guild-200 justify-start',
|
|
||||||
canPeerSupport
|
|
||||||
? 'hover:bg-guild-800 hover:border-candlelight-500'
|
|
||||||
: 'opacity-50 cursor-not-allowed',
|
|
||||||
]"
|
|
||||||
block
|
|
||||||
:title="
|
|
||||||
!canPeerSupport
|
|
||||||
? 'Complete your membership to book peer sessions'
|
|
||||||
: ''
|
|
||||||
"
|
|
||||||
>
|
|
||||||
Book a Peer Session
|
|
||||||
</UButton>
|
|
||||||
|
|
||||||
<UButton
|
|
||||||
to="https://wiki.ghostguild.org"
|
|
||||||
target="_blank"
|
|
||||||
variant="outline"
|
|
||||||
class="border-guild-600 text-guild-200 hover:bg-guild-800 hover:border-candlelight-500 justify-start"
|
|
||||||
block
|
|
||||||
>
|
|
||||||
Browse Resources
|
|
||||||
</UButton>
|
|
||||||
|
|
||||||
<UButton
|
|
||||||
to="/member/profile"
|
|
||||||
variant="outline"
|
|
||||||
class="border-guild-600 text-guild-200 hover:bg-guild-800 hover:border-candlelight-500 justify-start"
|
|
||||||
block
|
|
||||||
>
|
|
||||||
Update Profile
|
|
||||||
</UButton>
|
|
||||||
|
|
||||||
<UButton
|
|
||||||
to="/events"
|
|
||||||
variant="outline"
|
|
||||||
class="border-guild-600 text-guild-200 hover:bg-guild-800 hover:border-candlelight-500 justify-start"
|
|
||||||
block
|
|
||||||
>
|
|
||||||
View Events
|
|
||||||
</UButton>
|
|
||||||
|
|
||||||
<UButton
|
|
||||||
to="/members"
|
|
||||||
variant="outline"
|
|
||||||
class="border-guild-600 text-guild-200 hover:bg-guild-800 hover:border-candlelight-500 justify-start"
|
|
||||||
block
|
|
||||||
>
|
|
||||||
Browse Members
|
|
||||||
</UButton>
|
|
||||||
|
|
||||||
<UButton
|
|
||||||
to="/member/profile#account"
|
|
||||||
variant="outline"
|
|
||||||
class="border-guild-600 text-guild-200 hover:bg-guild-800 hover:border-candlelight-500 justify-start"
|
|
||||||
block
|
|
||||||
>
|
|
||||||
Manage Account
|
|
||||||
</UButton>
|
|
||||||
</div>
|
|
||||||
</UCard>
|
|
||||||
|
|
||||||
<!-- Your Registered Events -->
|
|
||||||
<UCard
|
|
||||||
:ui="{
|
|
||||||
root: 'bg-guild-900 border border-guild-700',
|
|
||||||
header: 'border-b border-guild-700 bg-guild-900',
|
|
||||||
body: 'bg-guild-900',
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
<template #header>
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<h2 class="text-display-sm text-guild-100 warm-text">
|
|
||||||
Your Upcoming Events
|
|
||||||
</h2>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<UButton
|
|
||||||
v-if="registeredEvents.length > 0"
|
|
||||||
@click="copyCalendarLink"
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
class="text-guild-300 hover:text-guild-100"
|
|
||||||
icon="heroicons:calendar"
|
|
||||||
>
|
|
||||||
{{
|
|
||||||
calendarLinkCopied ? "Link Copied!" : "Get Calendar Link"
|
|
||||||
}}
|
|
||||||
</UButton>
|
|
||||||
<UButton
|
|
||||||
to="/events"
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
class="text-guild-300 hover:text-guild-100"
|
|
||||||
>
|
|
||||||
Browse All Events
|
|
||||||
</UButton>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<div v-if="loadingEvents" class="text-center py-8">
|
|
||||||
<div
|
|
||||||
class="w-6 h-6 border-2 border-candlelight-500 border-t-transparent rounded-full animate-spin mx-auto"
|
|
||||||
></div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else-if="registeredEvents.length" class="space-y-4">
|
<div v-else-if="registeredEvents.length" class="event-list">
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
v-for="evt in registeredEvents"
|
v-for="evt in registeredEvents"
|
||||||
:key="evt._id"
|
:key="evt._id"
|
||||||
:to="`/events/${evt.slug || evt._id}`"
|
:to="`/events/${evt.slug || evt._id}`"
|
||||||
class="block p-4 border border-guild-700 hover:border-candlelight-500 transition-colors"
|
class="event-item"
|
||||||
>
|
>
|
||||||
<div class="flex items-start gap-4">
|
<span class="event-date">{{ formatEventDate(evt.startDate) }}</span>
|
||||||
<div
|
<span class="event-title">{{ evt.title }}</span>
|
||||||
v-if="
|
<CircleBadge v-if="evt.circle" :circle="evt.circle" />
|
||||||
evt.featureImage &&
|
|
||||||
(evt.featureImage.publicId || evt.featureImage.url)
|
|
||||||
"
|
|
||||||
class="flex-shrink-0 w-20 h-20 overflow-hidden"
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
:src="getEventImageUrl(evt.featureImage)"
|
|
||||||
:alt="evt.title"
|
|
||||||
class="w-full h-full object-cover"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
v-else
|
|
||||||
class="flex-shrink-0 w-20 h-20 bg-guild-800 border border-guild-600 flex items-center justify-center"
|
|
||||||
>
|
|
||||||
<Icon
|
|
||||||
name="heroicons:calendar-days"
|
|
||||||
class="w-8 h-8 text-candlelight-400"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="flex-1 min-w-0">
|
|
||||||
<h3 class="font-semibold text-guild-100 mb-1">
|
|
||||||
{{ evt.title }}
|
|
||||||
</h3>
|
|
||||||
<div class="flex items-center gap-4 text-ui-mono text-guild-400">
|
|
||||||
<span class="flex items-center gap-1">
|
|
||||||
<Icon name="heroicons:calendar" class="w-4 h-4" />
|
|
||||||
{{ formatEventDate(evt.startDate) }}
|
|
||||||
</span>
|
|
||||||
<span class="flex items-center gap-1">
|
|
||||||
<Icon name="heroicons:clock" class="w-4 h-4" />
|
|
||||||
{{ formatEventTime(evt.startDate) }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex-shrink-0">
|
|
||||||
<Icon
|
|
||||||
name="heroicons:chevron-right"
|
|
||||||
class="w-5 h-5 text-guild-500"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
|
|
||||||
|
<!-- Calendar subscription -->
|
||||||
|
<button class="calendar-btn" @click="copyCalendarLink">
|
||||||
|
{{ calendarLinkCopied ? 'Link copied!' : 'Subscribe to calendar' }}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="text-center py-8">
|
<div v-else class="empty-state">
|
||||||
<Icon
|
<p>You haven't registered for any upcoming events</p>
|
||||||
name="heroicons:calendar-days"
|
|
||||||
class="w-12 h-12 text-guild-600 mx-auto mb-3"
|
|
||||||
/>
|
|
||||||
<p class="text-guild-400 mb-4">
|
|
||||||
You haven't registered for any upcoming events
|
|
||||||
</p>
|
|
||||||
<UButton
|
|
||||||
to="/events"
|
|
||||||
size="sm"
|
|
||||||
class="border-guild-600 text-guild-200 hover:bg-guild-800 hover:border-candlelight-500"
|
|
||||||
>
|
|
||||||
Browse Events
|
|
||||||
</UButton>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<NuxtLink to="/events" class="section-link">Browse all events →</NuxtLink>
|
||||||
|
|
||||||
<!-- Calendar subscription instructions -->
|
<!-- Calendar subscription instructions -->
|
||||||
<div
|
<div v-if="registeredEvents.length > 0 && showCalendarInstructions" class="calendar-instructions">
|
||||||
v-if="registeredEvents.length > 0 && showCalendarInstructions"
|
<div class="ci-header">
|
||||||
class="mt-4 p-4 bg-guild-800 border border-guild-600"
|
<strong>How to Subscribe to Your Calendar</strong>
|
||||||
>
|
<button @click="showCalendarInstructions = false" class="ci-close">×</button>
|
||||||
<div class="flex items-start justify-between gap-4">
|
|
||||||
<div class="flex-1">
|
|
||||||
<h4 class="text-sm font-semibold text-guild-100 mb-2">
|
|
||||||
How to Subscribe to Your Calendar
|
|
||||||
</h4>
|
|
||||||
<ul
|
|
||||||
class="text-xs text-guild-300 space-y-1 list-disc list-inside"
|
|
||||||
>
|
|
||||||
<li>
|
|
||||||
<strong>Google Calendar:</strong> Click "+" → "From URL" →
|
|
||||||
Paste the link
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<strong>Apple Calendar:</strong> File → New Calendar
|
|
||||||
Subscription → Paste the link
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<strong>Outlook:</strong> Add Calendar → Subscribe from web
|
|
||||||
→ Paste the link
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
<p class="text-xs text-guild-400 mt-2">
|
|
||||||
Your calendar will automatically update when you register or
|
|
||||||
unregister from events.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
@click="showCalendarInstructions = false"
|
|
||||||
class="text-guild-400 hover:text-guild-200"
|
|
||||||
>
|
|
||||||
<Icon name="heroicons:x-mark" class="w-5 h-5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Google Calendar:</strong> Click "+" then "From URL" then paste the link</li>
|
||||||
|
<li><strong>Apple Calendar:</strong> File then New Calendar Subscription then paste the link</li>
|
||||||
|
<li><strong>Outlook:</strong> Add Calendar then Subscribe from web then paste the link</li>
|
||||||
|
</ul>
|
||||||
|
<p class="ci-note">Your calendar will automatically update when you register or unregister from events.</p>
|
||||||
</div>
|
</div>
|
||||||
</UCard>
|
</div>
|
||||||
|
|
||||||
|
<div class="content-block">
|
||||||
|
<div class="section-label">Quick Actions</div>
|
||||||
|
<NuxtLink
|
||||||
|
to="/members?peerSupport=true"
|
||||||
|
class="quick-action"
|
||||||
|
:class="{ disabled: !canPeerSupport }"
|
||||||
|
:title="!canPeerSupport ? 'Complete your membership to book peer sessions' : ''"
|
||||||
|
>
|
||||||
|
Book a peer session<span class="arrow">→</span>
|
||||||
|
</NuxtLink>
|
||||||
|
<NuxtLink to="/member/profile" class="quick-action">
|
||||||
|
Update your profile<span class="arrow">→</span>
|
||||||
|
</NuxtLink>
|
||||||
|
<a href="https://wiki.ghostguild.org" target="_blank" class="quick-action">
|
||||||
|
Browse the wiki<span class="arrow">→</span>
|
||||||
|
</a>
|
||||||
|
<NuxtLink to="/members" class="quick-action">
|
||||||
|
Browse members<span class="arrow">→</span>
|
||||||
|
</NuxtLink>
|
||||||
|
<NuxtLink to="/member/profile#account" class="quick-action">
|
||||||
|
Manage account<span class="arrow">→</span>
|
||||||
|
</NuxtLink>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</UContainer>
|
|
||||||
|
<!-- Membership Summary + Peer Support -->
|
||||||
|
<div class="content-row">
|
||||||
|
<div class="content-block">
|
||||||
|
<div class="section-label">Your Membership</div>
|
||||||
|
<div class="membership-row">
|
||||||
|
<span class="key">Circle</span>
|
||||||
|
<span class="val" :style="{ color: `var(--c-${memberData?.circle || 'community'})` }">
|
||||||
|
{{ memberData?.circle }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="membership-row">
|
||||||
|
<span class="key">Contribution</span>
|
||||||
|
<span class="val">${{ memberData?.contributionTier }} CAD/month</span>
|
||||||
|
</div>
|
||||||
|
<div class="membership-row">
|
||||||
|
<span class="key">Status</span>
|
||||||
|
<span class="val">
|
||||||
|
<span :class="isActive ? 'status-active' : ''">
|
||||||
|
{{ isActive ? 'Active' : statusConfig.label }}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="memberData?.createdAt" class="membership-row">
|
||||||
|
<span class="key">Member since</span>
|
||||||
|
<span class="val">{{ formatMemberSince(memberData.createdAt) }}</span>
|
||||||
|
</div>
|
||||||
|
<NuxtLink to="/member/profile#account" class="section-link">
|
||||||
|
Change circle or contribution →
|
||||||
|
</NuxtLink>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="content-block">
|
||||||
|
<div class="section-label">Peer Support</div>
|
||||||
|
<DashedBox>
|
||||||
|
<p class="peer-text">
|
||||||
|
Interested in offering peer support? Set up your profile to connect with other members who share your interests and experience.
|
||||||
|
</p>
|
||||||
|
<NuxtLink to="/member/profile" class="section-link">
|
||||||
|
Set up peer support →
|
||||||
|
</NuxtLink>
|
||||||
|
</DashedBox>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
@ -473,7 +280,6 @@ const formatEventDate = (dateString) => {
|
||||||
return new Intl.DateTimeFormat("en-US", {
|
return new Intl.DateTimeFormat("en-US", {
|
||||||
month: "short",
|
month: "short",
|
||||||
day: "numeric",
|
day: "numeric",
|
||||||
year: "numeric",
|
|
||||||
}).format(date);
|
}).format(date);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -485,6 +291,14 @@ const formatEventTime = (dateString) => {
|
||||||
}).format(date);
|
}).format(date);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const formatMemberSince = (dateString) => {
|
||||||
|
const date = new Date(dateString);
|
||||||
|
return new Intl.DateTimeFormat("en-US", {
|
||||||
|
month: "short",
|
||||||
|
year: "numeric",
|
||||||
|
}).format(date);
|
||||||
|
};
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadRegisteredEvents();
|
loadRegisteredEvents();
|
||||||
});
|
});
|
||||||
|
|
@ -493,6 +307,340 @@ onMounted(() => {
|
||||||
useHead({
|
useHead({
|
||||||
title: "Member Dashboard - Ghost Guild",
|
title: "Member Dashboard - Ghost Guild",
|
||||||
});
|
});
|
||||||
|
|
||||||
// Removed middleware - handling auth directly in the page component
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* ---- DASHBOARD LAYOUT ---- */
|
||||||
|
.dashboard {
|
||||||
|
max-width: 960px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- LOADING / UNAUTH STATES ---- */
|
||||||
|
.loading-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 80px 24px;
|
||||||
|
color: var(--text-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border: 2px dashed var(--candle);
|
||||||
|
border-top-color: transparent;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
margin: 0 auto 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner-sm {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-inline {
|
||||||
|
padding: 24px 0;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unauth-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 80px 24px;
|
||||||
|
max-width: 400px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unauth-state h2 {
|
||||||
|
font-family: 'Brygada 1918', serif;
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-bright);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unauth-state p {
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-size: 12px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- WELCOME HEADER ---- */
|
||||||
|
.welcome {
|
||||||
|
padding: 28px 28px;
|
||||||
|
border-bottom: 1px dashed var(--border);
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 16px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.welcome h1 {
|
||||||
|
font-family: 'Brygada 1918', serif;
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-bright);
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.welcome .meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- CONTENT GRID ---- */
|
||||||
|
.content-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
border-bottom: 1px dashed var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-block {
|
||||||
|
padding: 24px 28px;
|
||||||
|
border-right: 1px dashed var(--border);
|
||||||
|
min-width: 0;
|
||||||
|
overflow-wrap: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-block:last-child {
|
||||||
|
border-right: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- EVENT LIST ---- */
|
||||||
|
.event-list {
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-item {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 64px 1fr auto;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: baseline;
|
||||||
|
padding: 10px 0;
|
||||||
|
border-bottom: 1px dashed var(--border);
|
||||||
|
transition: padding-left 0.2s;
|
||||||
|
text-decoration: none;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-item:last-of-type {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-item:hover {
|
||||||
|
padding-left: 4px;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-date {
|
||||||
|
color: var(--text-faint);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-title {
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-item:hover .event-title {
|
||||||
|
color: var(--candle);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- CALENDAR BUTTON ---- */
|
||||||
|
.calendar-btn {
|
||||||
|
font-family: 'Commit Mono', monospace;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--candle-dim);
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 8px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-btn:hover {
|
||||||
|
color: var(--candle);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- CALENDAR INSTRUCTIONS ---- */
|
||||||
|
.calendar-instructions {
|
||||||
|
margin-top: 16px;
|
||||||
|
padding: 16px 20px;
|
||||||
|
border: 1px dashed var(--border);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ci-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
color: var(--text-bright);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ci-close {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-faint);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ci-close:hover {
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-instructions ul {
|
||||||
|
list-style: disc;
|
||||||
|
padding-left: 16px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ci-note {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-faint);
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- EMPTY STATE ---- */
|
||||||
|
.empty-state {
|
||||||
|
padding: 16px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state p {
|
||||||
|
color: var(--text-faint);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- SECTION LINK ---- */
|
||||||
|
.section-link {
|
||||||
|
display: inline-block;
|
||||||
|
margin-top: 16px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--candle);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-link:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- QUICK ACTIONS ---- */
|
||||||
|
.quick-action {
|
||||||
|
border: 1px dashed var(--border);
|
||||||
|
padding: 14px 20px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 13px;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-action:hover {
|
||||||
|
border-color: var(--candle-faint);
|
||||||
|
color: var(--candle);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-action.disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
pointer-events: none;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-action .arrow {
|
||||||
|
color: var(--text-faint);
|
||||||
|
margin-left: 8px;
|
||||||
|
transition: color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-action:hover .arrow {
|
||||||
|
color: var(--candle-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- MEMBERSHIP SUMMARY ---- */
|
||||||
|
.membership-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 8px 0;
|
||||||
|
border-bottom: 1px dashed var(--border);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.membership-row:last-of-type {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.membership-row .key {
|
||||||
|
color: var(--text-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.membership-row .val {
|
||||||
|
color: var(--text);
|
||||||
|
text-transform: capitalize;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-active {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-active::before {
|
||||||
|
content: '';
|
||||||
|
display: inline-block;
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--green);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- PEER SUPPORT ---- */
|
||||||
|
.peer-text {
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.65;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- RESPONSIVE ---- */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.content-row {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-block {
|
||||||
|
border-right: none;
|
||||||
|
border-bottom: 1px dashed var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-block:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.welcome {
|
||||||
|
padding: 24px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-block {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-item {
|
||||||
|
grid-template-columns: 56px 1fr auto;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -2,231 +2,580 @@
|
||||||
<div>
|
<div>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="My Updates"
|
title="My Updates"
|
||||||
subtitle="View and manage your updates"
|
subtitle="Your activity and milestones in the Guild"
|
||||||
size="medium"
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<section class="py-12 px-4">
|
<!-- Content Area: two-column with events mini sidebar -->
|
||||||
<UContainer class="px-4">
|
<div class="content-area">
|
||||||
<!-- Stats -->
|
|
||||||
<div v-if="isAuthenticated && !pending" class="mb-8 flex items-center justify-between">
|
<!-- Main Content -->
|
||||||
<div class="text-guild-300">
|
<div class="content-main">
|
||||||
<span class="text-2xl font-bold text-guild-100">{{ total }}</span>
|
|
||||||
{{ total === 1 ? "update" : "updates" }} posted
|
<!-- Stats + New Update row -->
|
||||||
</div>
|
<div v-if="isAuthenticated && !pending" class="stats-row">
|
||||||
<UButton to="/updates/new" icon="i-lucide-plus"> New Update </UButton>
|
<span class="stats-count">
|
||||||
|
<strong>{{ total }}</strong> {{ total === 1 ? 'update' : 'updates' }} posted
|
||||||
|
</span>
|
||||||
|
<NuxtLink to="/updates/new" class="btn btn-primary">+ New Update</NuxtLink>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Loading State -->
|
<!-- Loading State -->
|
||||||
<div
|
<div v-if="pending && !updates.length" class="state-box">
|
||||||
v-if="pending && !updates.length"
|
<div class="spinner"></div>
|
||||||
class="flex justify-center items-center py-20"
|
<p class="state-text">Loading your updates...</p>
|
||||||
>
|
|
||||||
<div class="text-center">
|
|
||||||
<div
|
|
||||||
class="w-8 h-8 border-4 border-guild-500 border-t-transparent rounded-full animate-spin mx-auto mb-4"
|
|
||||||
></div>
|
|
||||||
<p class="text-guild-400">Loading your updates...</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Unauthenticated State -->
|
<!-- Unauthenticated State -->
|
||||||
<div
|
<div v-else-if="!isAuthenticated" class="state-box">
|
||||||
v-else-if="!isAuthenticated"
|
<div class="state-icon">
|
||||||
class="flex justify-center items-center py-20"
|
<svg width="24" height="24" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
|
||||||
>
|
<path stroke-linecap="round" stroke-linejoin="round" d="M16.5 10.5V6.75a4.5 4.5 0 10-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 002.25-2.25v-6.75a2.25 2.25 0 00-2.25-2.25H6.75a2.25 2.25 0 00-2.25 2.25v6.75a2.25 2.25 0 002.25 2.25z" />
|
||||||
<div class="text-center max-w-md">
|
</svg>
|
||||||
<div class="w-16 h-16 bg-guild-800 border border-guild-600 rounded-full flex items-center justify-center mx-auto mb-4">
|
|
||||||
<Icon name="heroicons:lock-closed" class="w-8 h-8 text-guild-400" />
|
|
||||||
</div>
|
|
||||||
<h2 class="text-xl font-semibold text-guild-100 mb-2">Sign in required</h2>
|
|
||||||
<p class="text-guild-400 mb-6">Please sign in to view your updates.</p>
|
|
||||||
<UButton @click="openLoginModal({ title: 'Sign in to view your updates', description: 'Enter your email to access your updates' })">
|
|
||||||
Sign In
|
|
||||||
</UButton>
|
|
||||||
</div>
|
</div>
|
||||||
|
<h2 class="state-heading">Sign in required</h2>
|
||||||
|
<p class="state-text">Please sign in to view your updates.</p>
|
||||||
|
<button
|
||||||
|
class="btn btn-primary"
|
||||||
|
@click="openLoginModal({ title: 'Sign in to view your updates', description: 'Enter your email to access your updates' })"
|
||||||
|
>
|
||||||
|
Sign In
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Updates List -->
|
<!-- Updates Timeline -->
|
||||||
<div v-else-if="updates.length" class="space-y-6">
|
<div v-else-if="updates.length" class="timeline-wrap">
|
||||||
<UpdateCard
|
<div class="timeline">
|
||||||
v-for="update in updates"
|
<div
|
||||||
:key="update._id"
|
v-for="update in updates"
|
||||||
:update="update"
|
:key="update._id"
|
||||||
:show-preview="true"
|
class="tl-item"
|
||||||
@edit="handleEdit"
|
>
|
||||||
@delete="handleDelete"
|
<div class="tl-dot">✎</div>
|
||||||
/>
|
<div class="tl-time">{{ formatDate(update.createdAt) }}</div>
|
||||||
|
<div class="tl-text">
|
||||||
|
<NuxtLink :to="`/updates/${update._id}`" class="tl-title">
|
||||||
|
{{ getUpdateTitle(update) }}
|
||||||
|
</NuxtLink>
|
||||||
|
<span v-if="isEdited(update)" class="tl-edited">(edited)</span>
|
||||||
|
<span v-if="update.privacy === 'private'" class="badge">Private</span>
|
||||||
|
<span v-if="update.privacy === 'public'" class="badge">Public</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="getUpdatePreview(update)" class="tl-detail">
|
||||||
|
{{ getUpdatePreview(update) }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Images -->
|
||||||
|
<div v-if="update.images?.length" class="tl-images">
|
||||||
|
<img
|
||||||
|
v-for="(image, index) in update.images"
|
||||||
|
:key="index"
|
||||||
|
:src="image.url"
|
||||||
|
:alt="image.alt || 'Update image'"
|
||||||
|
class="tl-image"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Actions -->
|
||||||
|
<div v-if="isAuthor(update)" class="tl-actions">
|
||||||
|
<button class="tl-action-btn" @click="handleEdit(update)">Edit</button>
|
||||||
|
<span class="tl-action-sep">·</span>
|
||||||
|
<button class="tl-action-btn tl-action-danger" @click="handleDelete(update)">Delete</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Load More -->
|
<!-- Load More -->
|
||||||
<div v-if="hasMore" class="flex justify-center pt-4">
|
<div v-if="hasMore" class="load-more">
|
||||||
<UButton
|
<button
|
||||||
variant="outline"
|
class="btn"
|
||||||
color="neutral"
|
:disabled="loadingMore"
|
||||||
:loading="loadingMore"
|
|
||||||
@click="loadMore"
|
@click="loadMore"
|
||||||
>
|
>
|
||||||
Load More
|
{{ loadingMore ? 'Loading...' : 'Load More' }}
|
||||||
</UButton>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Empty State -->
|
<!-- Empty State -->
|
||||||
<div v-else class="text-center py-20">
|
<div v-else class="state-box">
|
||||||
<div class="w-16 h-16 mx-auto mb-4 opacity-50">
|
<div class="state-icon">
|
||||||
<svg
|
<svg width="24" height="24" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
|
||||||
fill="none"
|
<path stroke-linecap="round" stroke-linejoin="round" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
class="text-guild-600"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"
|
|
||||||
/>
|
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<h3 class="text-lg font-medium text-guild-300 mb-2">
|
<h2 class="state-heading">No updates yet</h2>
|
||||||
No updates yet
|
<p class="state-text">Share your first update with the community</p>
|
||||||
</h3>
|
<NuxtLink to="/updates/new" class="btn btn-primary">+ Post Your First Update</NuxtLink>
|
||||||
<p class="text-guild-400 mb-6">
|
|
||||||
Share your first update with the community
|
|
||||||
</p>
|
|
||||||
<UButton to="/updates/new" icon="i-lucide-plus">
|
|
||||||
Post Your First Update
|
|
||||||
</UButton>
|
|
||||||
</div>
|
</div>
|
||||||
</UContainer>
|
|
||||||
</section>
|
</div>
|
||||||
|
|
||||||
|
<!-- Events Mini Sidebar -->
|
||||||
|
<EventsMiniSidebar :events="upcomingEvents" />
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Delete Confirmation Modal -->
|
<!-- Delete Confirmation Modal -->
|
||||||
<UModal
|
<Teleport to="body">
|
||||||
v-model:open="showDeleteModal"
|
<div v-if="showDeleteModal" class="modal-overlay" @click.self="showDeleteModal = false">
|
||||||
title="Delete Update?"
|
<div class="modal-box">
|
||||||
description="Are you sure you want to delete this update? This action cannot be undone."
|
<h3 class="modal-heading">Delete Update?</h3>
|
||||||
>
|
<p class="modal-text">Are you sure you want to delete this update? This action cannot be undone.</p>
|
||||||
<template #footer>
|
<div class="modal-actions">
|
||||||
<div class="flex justify-end gap-3">
|
<button class="btn" @click="showDeleteModal = false">Cancel</button>
|
||||||
<UButton
|
<button class="btn btn-danger" :disabled="deleting" @click="confirmDelete">
|
||||||
variant="ghost"
|
{{ deleting ? 'Deleting...' : 'Delete' }}
|
||||||
color="neutral"
|
</button>
|
||||||
@click="showDeleteModal = false"
|
</div>
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</UButton>
|
|
||||||
<UButton color="red" :loading="deleting" @click="confirmDelete">
|
|
||||||
Delete
|
|
||||||
</UButton>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</div>
|
||||||
</UModal>
|
</Teleport>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
const { isAuthenticated, checkMemberStatus } = useAuth();
|
const { isAuthenticated, memberData, checkMemberStatus } = useAuth()
|
||||||
const { openLoginModal } = useLoginModal();
|
const { openLoginModal } = useLoginModal()
|
||||||
|
|
||||||
const updates = ref([]);
|
const updates = ref([])
|
||||||
const pending = ref(false);
|
const pending = ref(false)
|
||||||
const loadingMore = ref(false);
|
const loadingMore = ref(false)
|
||||||
const hasMore = ref(false);
|
const hasMore = ref(false)
|
||||||
const total = ref(0);
|
const total = ref(0)
|
||||||
|
|
||||||
const showDeleteModal = ref(false);
|
const showDeleteModal = ref(false)
|
||||||
const updateToDelete = ref(null);
|
const updateToDelete = ref(null)
|
||||||
const deleting = ref(false);
|
const deleting = ref(false)
|
||||||
|
|
||||||
|
const upcomingEvents = ref([])
|
||||||
|
|
||||||
|
// Check if current user is the author of an update
|
||||||
|
const isAuthor = (update) => {
|
||||||
|
return memberData.value && update.author?._id === memberData.value.id
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if update was edited
|
||||||
|
const isEdited = (update) => {
|
||||||
|
const created = new Date(update.createdAt).getTime()
|
||||||
|
const updated = new Date(update.updatedAt).getTime()
|
||||||
|
return updated - created > 1000
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract a title from update content (first line or first ~60 chars)
|
||||||
|
const getUpdateTitle = (update) => {
|
||||||
|
if (!update.content) return 'Untitled update'
|
||||||
|
const firstLine = update.content.split('\n')[0]
|
||||||
|
if (firstLine.length <= 80) return firstLine
|
||||||
|
return firstLine.substring(0, 80) + '...'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get a preview of the update content (after the first line)
|
||||||
|
const getUpdatePreview = (update) => {
|
||||||
|
if (!update.content) return ''
|
||||||
|
const lines = update.content.split('\n')
|
||||||
|
if (lines.length <= 1 && update.content.length <= 80) return ''
|
||||||
|
// If the first line was truncated, show the full content as preview
|
||||||
|
if (lines.length <= 1) return ''
|
||||||
|
const rest = lines.slice(1).join(' ').trim()
|
||||||
|
if (!rest) return ''
|
||||||
|
return rest.length > 200 ? rest.substring(0, 200) + '...' : rest
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format date with relative time
|
||||||
|
const formatDate = (date) => {
|
||||||
|
const now = new Date()
|
||||||
|
const updateDate = new Date(date)
|
||||||
|
const diffInSeconds = Math.floor((now - updateDate) / 1000)
|
||||||
|
|
||||||
|
if (diffInSeconds < 60) return 'just now'
|
||||||
|
if (diffInSeconds < 3600) return `${Math.floor(diffInSeconds / 60)} minutes ago`
|
||||||
|
if (diffInSeconds < 86400) return `${Math.floor(diffInSeconds / 3600)} hours ago`
|
||||||
|
if (diffInSeconds < 604800) return `${Math.floor(diffInSeconds / 86400)} days ago`
|
||||||
|
|
||||||
|
return updateDate.toLocaleDateString('en-US', {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
year: updateDate.getFullYear() !== now.getFullYear() ? 'numeric' : undefined,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// Check authentication
|
// Check authentication
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
if (!isAuthenticated.value) {
|
if (!isAuthenticated.value) {
|
||||||
const authenticated = await checkMemberStatus();
|
const authenticated = await checkMemberStatus()
|
||||||
if (!authenticated) {
|
if (!authenticated) {
|
||||||
// Show login modal instead of redirecting
|
|
||||||
openLoginModal({
|
openLoginModal({
|
||||||
title: "Sign in to view your updates",
|
title: 'Sign in to view your updates',
|
||||||
description: "Enter your email to access your updates",
|
description: 'Enter your email to access your updates',
|
||||||
});
|
})
|
||||||
return;
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await loadUpdates();
|
await Promise.all([loadUpdates(), loadUpcomingEvents()])
|
||||||
});
|
})
|
||||||
|
|
||||||
// Load updates
|
// Load updates
|
||||||
const loadUpdates = async () => {
|
const loadUpdates = async () => {
|
||||||
pending.value = true;
|
pending.value = true
|
||||||
try {
|
try {
|
||||||
const response = await $fetch("/api/updates/my-updates", {
|
const response = await $fetch('/api/updates/my-updates', {
|
||||||
params: { limit: 20, skip: 0 },
|
params: { limit: 20, skip: 0 },
|
||||||
});
|
})
|
||||||
updates.value = response.updates;
|
updates.value = response.updates
|
||||||
total.value = response.total;
|
total.value = response.total
|
||||||
hasMore.value = response.hasMore;
|
hasMore.value = response.hasMore
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to load updates:", error);
|
console.error('Failed to load updates:', error)
|
||||||
} finally {
|
} finally {
|
||||||
pending.value = false;
|
pending.value = false
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
|
// Load upcoming events for sidebar
|
||||||
|
const loadUpcomingEvents = async () => {
|
||||||
|
try {
|
||||||
|
const response = await $fetch('/api/events', {
|
||||||
|
params: { limit: 3, upcoming: true },
|
||||||
|
})
|
||||||
|
upcomingEvents.value = response.events || response || []
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load upcoming events:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Load more updates
|
// Load more updates
|
||||||
const loadMore = async () => {
|
const loadMore = async () => {
|
||||||
loadingMore.value = true;
|
loadingMore.value = true
|
||||||
try {
|
try {
|
||||||
const response = await $fetch("/api/updates/my-updates", {
|
const response = await $fetch('/api/updates/my-updates', {
|
||||||
params: { limit: 20, skip: updates.value.length },
|
params: { limit: 20, skip: updates.value.length },
|
||||||
});
|
})
|
||||||
updates.value.push(...response.updates);
|
updates.value.push(...response.updates)
|
||||||
hasMore.value = response.hasMore;
|
hasMore.value = response.hasMore
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to load more updates:", error);
|
console.error('Failed to load more updates:', error)
|
||||||
} finally {
|
} finally {
|
||||||
loadingMore.value = false;
|
loadingMore.value = false
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
// Handle edit
|
// Handle edit
|
||||||
const handleEdit = (update) => {
|
const handleEdit = (update) => {
|
||||||
navigateTo(`/updates/${update._id}/edit`);
|
navigateTo(`/updates/${update._id}/edit`)
|
||||||
};
|
}
|
||||||
|
|
||||||
// Handle delete
|
// Handle delete
|
||||||
const handleDelete = (update) => {
|
const handleDelete = (update) => {
|
||||||
updateToDelete.value = update;
|
updateToDelete.value = update
|
||||||
showDeleteModal.value = true;
|
showDeleteModal.value = true
|
||||||
};
|
}
|
||||||
|
|
||||||
// Confirm delete
|
// Confirm delete
|
||||||
const confirmDelete = async () => {
|
const confirmDelete = async () => {
|
||||||
if (!updateToDelete.value) return;
|
if (!updateToDelete.value) return
|
||||||
|
|
||||||
deleting.value = true;
|
deleting.value = true
|
||||||
try {
|
try {
|
||||||
await $fetch(`/api/updates/${updateToDelete.value._id}`, {
|
await $fetch(`/api/updates/${updateToDelete.value._id}`, {
|
||||||
method: "DELETE",
|
method: 'DELETE',
|
||||||
});
|
})
|
||||||
|
|
||||||
// Remove from list
|
// Remove from list
|
||||||
updates.value = updates.value.filter(
|
updates.value = updates.value.filter(
|
||||||
(u) => u._id !== updateToDelete.value._id,
|
(u) => u._id !== updateToDelete.value._id,
|
||||||
);
|
)
|
||||||
total.value--;
|
total.value--
|
||||||
|
|
||||||
showDeleteModal.value = false;
|
showDeleteModal.value = false
|
||||||
updateToDelete.value = null;
|
updateToDelete.value = null
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to delete update:", error);
|
console.error('Failed to delete update:', error)
|
||||||
alert("Failed to delete update. Please try again.");
|
alert('Failed to delete update. Please try again.')
|
||||||
} finally {
|
} finally {
|
||||||
deleting.value = false;
|
deleting.value = false
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
useHead({
|
useHead({
|
||||||
title: "My Updates - Ghost Guild",
|
title: 'My Updates - Ghost Guild',
|
||||||
});
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* ---- TWO-COLUMN LAYOUT ---- */
|
||||||
|
.content-area {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-main {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- STATS ROW ---- */
|
||||||
|
.stats-row {
|
||||||
|
padding: 16px 32px;
|
||||||
|
border-bottom: 1px dashed var(--border);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-count {
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-count strong {
|
||||||
|
color: var(--text-bright);
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- STATE BOXES (loading, empty, unauth) ---- */
|
||||||
|
.state-box {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 64px 32px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.state-icon {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
border: 1px dashed var(--border);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
color: var(--text-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.state-heading {
|
||||||
|
font-family: 'Brygada 1918', serif;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-bright);
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.state-text {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
margin-bottom: 20px;
|
||||||
|
max-width: 320px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border: 2px dashed var(--candle);
|
||||||
|
border-top-color: transparent;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- TIMELINE ---- */
|
||||||
|
.timeline-wrap {
|
||||||
|
padding: 24px 32px 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline {
|
||||||
|
position: relative;
|
||||||
|
padding-left: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 11px;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 1px;
|
||||||
|
border-left: 1px dashed var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tl-item {
|
||||||
|
position: relative;
|
||||||
|
padding: 0 0 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tl-item:last-child {
|
||||||
|
padding-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tl-dot {
|
||||||
|
position: absolute;
|
||||||
|
left: -32px;
|
||||||
|
top: 2px;
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px dashed var(--border);
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tl-time {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-faint);
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tl-text {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text);
|
||||||
|
line-height: 1.5;
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tl-title {
|
||||||
|
color: var(--text-bright);
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tl-title:hover {
|
||||||
|
color: var(--candle);
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tl-edited {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tl-detail {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
margin-top: 4px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-left: 2px solid var(--border);
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tl-images {
|
||||||
|
margin-top: 8px;
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tl-image {
|
||||||
|
max-width: 200px;
|
||||||
|
height: auto;
|
||||||
|
border: 1px dashed var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tl-actions {
|
||||||
|
margin-top: 6px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tl-action-btn {
|
||||||
|
font-family: 'Commit Mono', monospace;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-faint);
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
transition: color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tl-action-btn:hover {
|
||||||
|
color: var(--candle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tl-action-danger:hover {
|
||||||
|
color: var(--ember);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tl-action-sep {
|
||||||
|
color: var(--border);
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- LOAD MORE ---- */
|
||||||
|
.load-more {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- MODAL ---- */
|
||||||
|
.modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(42, 32, 21, 0.5);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-box {
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px dashed var(--border);
|
||||||
|
padding: 28px 32px;
|
||||||
|
max-width: 400px;
|
||||||
|
width: 90%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-heading {
|
||||||
|
font-family: 'Brygada 1918', serif;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-bright);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-text {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
line-height: 1.7;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- RESPONSIVE ---- */
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
.content-area {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.stats-row {
|
||||||
|
padding: 12px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-wrap {
|
||||||
|
padding: 20px 20px 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.state-box {
|
||||||
|
padding: 48px 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue