This commit is contained in:
doomtube 2025-08-10 07:55:39 -04:00
parent 4c23ab840a
commit e8864cc853
15 changed files with 4004 additions and 1593 deletions

View file

@ -95,6 +95,48 @@ function createAuthStore() {
return { success: false, error: data.error || 'Registration failed' };
},
async updateColor(color) {
const token = localStorage.getItem('auth_token');
const response = await fetch('/api/user/color', {
method: 'PUT',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ color })
});
const data = await response.json();
if (response.ok && data.success) {
// IMPORTANT: Store the new token that includes the updated color
if (data.token) {
localStorage.setItem('auth_token', data.token);
// Update the store with new token and user data
update(state => ({
...state,
token: data.token,
user: {
...state.user,
userColor: data.color,
colorCode: data.color // Make sure both fields are updated
}
}));
} else {
// Fallback if no new token (shouldn't happen with current backend)
update(state => ({
...state,
user: { ...state.user, userColor: data.color, colorCode: data.color }
}));
}
return { success: true, color: data.color };
}
return { success: false, error: data.error || 'Failed to update color' };
},
updateUser(userData) {
update(state => ({
...state,
@ -125,4 +167,9 @@ export const isAdmin = derived(
export const isStreamer = derived(
auth,
$auth => $auth.user?.isStreamer || false
);
export const userColor = derived(
auth,
$auth => $auth.user?.colorCode || '#561D5E'
);

View file

@ -0,0 +1,93 @@
import { writable, derived } from 'svelte/store';
import { browser } from '$app/environment';
function createUserStore() {
// Initialize from localStorage if in browser
const initialUser = browser ? JSON.parse(localStorage.getItem('user') || 'null') : null;
const { subscribe, set, update } = writable(initialUser);
return {
subscribe,
set: (user) => {
if (browser && user) {
localStorage.setItem('user', JSON.stringify(user));
} else if (browser) {
localStorage.removeItem('user');
}
set(user);
},
update: (fn) => {
update(currentUser => {
const newUser = fn(currentUser);
if (browser && newUser) {
localStorage.setItem('user', JSON.stringify(newUser));
}
return newUser;
});
},
updateColor: async (newColor) => {
const token = browser ? localStorage.getItem('token') : null;
if (!token) return false;
try {
const response = await fetch('/api/user/color', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({ color: newColor })
});
const data = await response.json();
if (data.success) {
// Update the store with new user data
if (data.user) {
// Full user data returned
set(data.user);
} else {
// Only color returned, update existing user
update(u => u ? { ...u, userColor: data.color } : null);
}
return true;
}
return false;
} catch (error) {
console.error('Failed to update color:', error);
return false;
}
},
refresh: async () => {
const token = browser ? localStorage.getItem('token') : null;
if (!token) return;
try {
const response = await fetch('/api/user/me', {
headers: {
'Authorization': `Bearer ${token}`
}
});
if (response.ok) {
const data = await response.json();
if (data.success && data.user) {
set(data.user);
return data.user;
}
}
} catch (error) {
console.error('Failed to refresh user:', error);
}
return null;
}
};
}
export const userStore = createUserStore();
// Derived store for just the color
export const userColor = derived(
userStore,
$user => $user?.userColor || '#561D5E'
);

View file

@ -1,6 +1,6 @@
<script>
import { onMount } from 'svelte';
import { auth, isAuthenticated, isAdmin, isStreamer } from '$lib/stores/auth';
import { auth, isAuthenticated, isAdmin, isStreamer, userColor } from '$lib/stores/auth';
import { page } from '$app/stores';
import '../app.css';
@ -75,33 +75,47 @@
position: relative;
}
.user-avatar-btn {
width: 40px;
height: 40px;
border-radius: 50%;
background: var(--gray);
border: 2px solid transparent;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
color: var(--white);
font-weight: 600;
transition: border-color 0.2s;
padding: 0;
overflow: hidden;
}
.user-avatar-btn:hover {
border-color: var(--primary);
.user-avatar-btn {
width: 40px;
height: 40px;
border-radius: 50%;
background: var(--gray);
border: 2px solid transparent;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
color: var(--white);
font-weight: 600;
transition: all 0.2s;
padding: 0;
overflow: hidden;
position: relative;
}
.user-avatar-btn img {
width: 100%;
height: 100%;
object-fit: cover;
border: none;
}
.user-avatar-btn.has-color {
background: var(--user-color);
}
.user-avatar-btn.has-color.with-image {
border-color: var(--user-color);
border-width: 3px;
}
.user-avatar-btn:hover {
transform: scale(1.05);
}
.user-avatar-btn.has-color:hover {
box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.2);
}
.user-avatar-btn img {
width: 100%;
height: 100%;
object-fit: cover;
border: none;
}
.dropdown {
position: absolute;
@ -126,7 +140,8 @@
margin-bottom: 0.25rem;
color: var(--white);
text-decoration: none;
display: block;
display: flex;
align-items: center;
transition: color 0.2s;
}
@ -134,6 +149,15 @@
color: var(--primary);
}
.user-color-dot {
width: 12px;
height: 12px;
border-radius: 50%;
display: inline-block;
margin-right: 0.5rem;
border: 1px solid rgba(255, 255, 255, 0.2);
}
.dropdown-role {
font-size: 0.85rem;
color: var(--gray);
@ -169,7 +193,13 @@
{#if !$auth.loading}
{#if $isAuthenticated}
<div class="user-menu">
<button class="user-avatar-btn" on:click={toggleDropdown}>
<button
class="user-avatar-btn"
class:has-color={$userColor}
class:with-image={$auth.user.avatarUrl}
style="--user-color: {$userColor}"
on:click={toggleDropdown}
>
{#if $auth.user.avatarUrl}
<img src={$auth.user.avatarUrl} alt={$auth.user.username} />
{:else}
@ -181,6 +211,10 @@
<div class="dropdown">
<div class="dropdown-header">
<a href="/profile/{$auth.user.username}" class="dropdown-username">
<span
class="user-color-dot"
style="background: {$userColor}"
></span>
{$auth.user.username}
</a>
<div class="dropdown-role">

View file

@ -407,6 +407,22 @@
overflow: hidden;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5);
margin-bottom: 1rem;
position: relative;
}
.player-wrapper::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 4px;
background: linear-gradient(90deg,
var(--user-color, var(--primary)) 0%,
var(--user-color, var(--primary)) 50%,
rgba(255, 255, 255, 0.1) 100%
);
z-index: 1;
}
.player-area {
@ -438,10 +454,24 @@
border: 1px solid var(--border);
border-radius: 8px;
padding: 1.5rem;
position: relative;
overflow: hidden;
}
.stream-info-section::before {
content: '';
position: absolute;
top: 0;
left: 0;
width: 4px;
height: 100%;
background: var(--user-color, var(--primary));
opacity: 0.6;
}
.stream-header {
margin-bottom: 1.5rem;
padding-left: 1rem;
}
.stream-header h1 {
@ -462,11 +492,38 @@
height: 48px;
border-radius: 50%;
background: var(--gray);
position: relative;
overflow: hidden;
border: 2px solid var(--border);
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
color: var(--white);
transition: all 0.3s ease;
}
.streamer-avatar.has-color {
background: var(--user-color);
border-color: var(--user-color);
border-width: 3px;
box-shadow: 0 0 15px rgba(0, 0, 0, 0.3);
}
.streamer-avatar.has-color.with-image {
border-width: 3px;
}
.streamer-avatar img {
width: 100%;
height: 100%;
object-fit: cover;
}
.streamer-name {
font-weight: 600;
color: var(--white);
font-size: 1.1rem;
}
.viewer-count {
@ -566,6 +623,39 @@
padding: 4rem 2rem;
color: var(--gray);
}
/* Color accent for chat/info sections */
.color-accent-bar {
height: 3px;
background: var(--user-color, var(--primary));
margin: 0 0 1rem 0;
border-radius: 2px;
opacity: 0.8;
}
/* Pulse animation for live indicator */
@keyframes pulse {
0% {
opacity: 1;
}
50% {
opacity: 0.6;
}
100% {
opacity: 1;
}
}
.status-indicator.active::before {
content: '';
display: inline-block;
width: 8px;
height: 8px;
background: #ff0000;
border-radius: 50%;
margin-right: 0.5rem;
animation: pulse 2s infinite;
}
</style>
{#if loading}
@ -581,7 +671,7 @@
{:else if realm}
<div class="stream-container">
<div class="player-section">
<div class="player-wrapper">
<div class="player-wrapper" style="--user-color: {realm.colorCode || '#561D5E'}">
<div class="player-area">
<div class="dummy-player"></div>
<div class="player-container">
@ -590,15 +680,22 @@
</div>
</div>
<div class="stream-info-section">
<div class="stream-info-section" style="--user-color: {realm.colorCode || '#561D5E'}">
<div class="stream-header">
<h1>{realm.name}</h1>
<div class="streamer-info">
{#if realm.avatarUrl}
<img src={realm.avatarUrl} alt={realm.username} class="streamer-avatar" />
{:else}
<div class="streamer-avatar"></div>
{/if}
<div
class="streamer-avatar"
class:has-color={realm.colorCode}
class:with-image={realm.avatarUrl}
style="--user-color: {realm.colorCode || '#561D5E'}"
>
{#if realm.avatarUrl}
<img src={realm.avatarUrl} alt={realm.username} />
{:else}
{realm.username?.charAt(0).toUpperCase() || '?'}
{/if}
</div>
<div>
<div class="streamer-name">{realm.username}</div>
<div class="viewer-count">
@ -612,13 +709,14 @@
<div class="sidebar">
<div class="stats-section">
<div class="color-accent-bar" style="--user-color: {realm.colorCode || '#561D5E'}"></div>
<h3>Stream Stats</h3>
<div class="status-indicator" class:active={stats.isLive} class:inactive={!stats.isLive}>
{#if stats.isLive}
<span></span> Live
Live
{:else}
<span></span> Offline
Offline
{/if}
</div>
@ -644,7 +742,7 @@
<span class="stat-value">{stats.fps.toFixed(1)} fps</span>
</div>
{/if}
{#if stats.codec}
{#if stats.codec && stats.codec !== 'N/A'}
<div class="stat-item">
<span class="stat-label">Codec</span>
<span class="stat-value">{stats.codec}</span>

View file

@ -227,7 +227,24 @@ function formatBitrate(bitrate) {
display: grid;
gap: 2rem;
}
.realm-owner-badge {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.25rem 0.75rem;
background: rgba(255, 255, 255, 0.1);
border-radius: 20px;
font-size: 0.85rem;
margin-left: 1rem;
}
.realm-owner-color {
width: 16px;
height: 16px;
border-radius: 50%;
border: 2px solid var(--white);
}
.realm-card {
background: #111;
border: 1px solid var(--border);

View file

@ -112,6 +112,19 @@
font-weight: 600;
color: var(--white);
overflow: hidden;
border: 3px solid var(--border);
position: relative;
transition: all 0.3s ease;
}
.profile-avatar.has-color {
background: var(--user-color);
border-color: var(--user-color);
box-shadow: 0 0 20px rgba(0, 0, 0, 0.3);
}
.profile-avatar.has-color.with-image {
border-width: 4px;
}
.profile-avatar img {
@ -129,6 +142,26 @@
font-size: 0.9rem;
}
.color-badge {
display: inline-flex;
align-items: center;
gap: 0.5rem;
background: rgba(255, 255, 255, 0.05);
padding: 0.25rem 0.75rem;
border-radius: 20px;
font-size: 0.85rem;
margin-top: 0.5rem;
border: 1px solid rgba(255, 255, 255, 0.1);
}
.color-dot {
width: 16px;
height: 16px;
border-radius: 50%;
border: 2px solid rgba(255, 255, 255, 0.3);
box-shadow: 0 0 4px rgba(0, 0, 0, 0.2);
}
.pgp-only-badge {
display: inline-flex;
align-items: center;
@ -176,6 +209,19 @@
background: #111;
border: 1px solid var(--border);
border-radius: 8px;
position: relative;
overflow: hidden;
}
.bio-section::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 3px;
background: var(--user-color, var(--primary));
opacity: 0.6;
}
.bio-section h3 {
@ -327,7 +373,12 @@
<div class="error">{error}</div>
{:else if profile}
<div class="profile-header">
<div class="profile-avatar">
<div
class="profile-avatar"
class:has-color={profile.colorCode}
class:with-image={profile.avatarUrl}
style="--user-color: {profile.colorCode || '#561D5E'}"
>
{#if profile.avatarUrl}
<img src={profile.avatarUrl} alt="{profile.username}" />
{:else}
@ -340,9 +391,13 @@
<p class="member-since">
Member since {new Date(profile.createdAt).toLocaleDateString()}
</p>
<div class="color-badge">
<span class="color-dot" style="background: {profile.colorCode || '#561D5E'}"></span>
<span style="font-family: monospace;">{profile.colorCode || '#561D5E'}</span>
</div>
{#if profile.isPgpOnly}
<div class="pgp-only-badge">
<span>🔒</span>
<span>🔐</span>
<span>PGP-Only Authentication</span>
</div>
{/if}
@ -367,7 +422,7 @@
</div>
{#if activeTab === 'bio'}
<div class="bio-section">
<div class="bio-section" style="--user-color: {profile.colorCode || '#561D5E'}">
<h3>About</h3>
{#if profile.bio}
<p>{profile.bio}</p>

View file

@ -22,6 +22,24 @@
let newPassword = '';
let confirmPassword = '';
// Color picker
let userColor = '#561D5E';
let newColor = '';
let showColorPicker = false;
let colorLoading = false;
let colorError = '';
let colorMessage = '';
// Popular colors to choose from
const suggestedColors = [
'#FF6B6B', '#4ECDC4', '#45B7D1', '#96CEB4', '#FFEAA7', '#FD79A8',
'#A29BFE', '#6C5CE7', '#FAB1A0', '#74B9FF', '#A8E6CF', '#FFD3B6',
'#FF8CC3', '#00B894', '#00CEC9', '#0984E3', '#6C5CE7', '#E17055',
'#FDCB6E', '#55A3FF', '#FD79A8', '#BADC58', '#F8B739', '#FA8231',
'#EB3B5A', '#FC5C65', '#45AAF2', '#4B7BEC', '#A55EEA', '#D63031',
'#74B9FF', '#A29BFE', '#FD79A8', '#E17055', '#00B894', '#00CEC9'
];
// PGP
let pgpKeys = [];
let pgpOnly = false;
@ -67,6 +85,8 @@
pgpOnly = data.user.isPgpOnly === true;
pgpOnlyEnabledAt = data.user.pgpOnlyEnabledAt || '';
avatarPreview = data.user.avatarUrl || '';
userColor = data.user.colorCode || '#561D5E';
newColor = userColor;
currentUser = data.user;
// Update auth store with fresh data
@ -81,6 +101,8 @@
pgpOnly = $auth.user.isPgpOnly === true;
pgpOnlyEnabledAt = $auth.user.pgpOnlyEnabledAt || '';
avatarPreview = $auth.user.avatarUrl || '';
userColor = $auth.user.colorCode || '#561D5E';
newColor = userColor;
currentUser = $auth.user;
}
}
@ -244,6 +266,55 @@
loading = false;
}
async function updateColor() {
if (!newColor || newColor === userColor) {
colorError = 'Please select a different color';
return;
}
colorLoading = true;
colorError = '';
colorMessage = '';
const result = await auth.updateColor(newColor);
if (result.success) {
userColor = result.color;
colorMessage = 'Color updated successfully!';
showColorPicker = false;
// Refresh the current user data to ensure consistency
if (currentUser) {
currentUser = { ...currentUser, userColor: result.color, colorCode: result.color };
}
// Clear message after 3 seconds
setTimeout(() => { colorMessage = ''; }, 3000);
} else {
colorError = result.error || 'Failed to update color';
}
colorLoading = false;
}
function selectSuggestedColor(color) {
newColor = color;
}
function handleColorInput(event) {
const value = event.target.value;
// Ensure it starts with # and is uppercase
if (value.length > 0 && value[0] !== '#') {
newColor = '#' + value;
} else {
newColor = value.toUpperCase();
}
}
function isValidColor(color) {
return /^#[0-9A-F]{6}$/i.test(color);
}
// Fixed: Handle checkbox click to show warning
function handlePgpCheckboxClick(event) {
if (pgpOnly) {
@ -777,6 +848,68 @@
align-items: center;
gap: 0.5rem;
}
/* Color picker styles */
.color-picker-container {
display: flex;
align-items: center;
gap: 1rem;
}
.color-input-wrapper {
position: relative;
display: flex;
align-items: center;
gap: 0.5rem;
}
.color-preview {
width: 40px;
height: 40px;
border-radius: 8px;
border: 2px solid var(--border);
cursor: pointer;
transition: transform 0.2s;
}
.color-preview:hover {
transform: scale(1.05);
}
.color-input {
padding: 0.75rem;
font-family: monospace;
text-transform: uppercase;
}
.color-grid {
display: grid;
grid-template-columns: repeat(8, 1fr);
gap: 0.5rem;
margin-top: 1rem;
padding: 1rem;
background: rgba(0, 0, 0, 0.3);
border-radius: 8px;
}
.color-option {
width: 40px;
height: 40px;
border-radius: 8px;
border: 2px solid transparent;
cursor: pointer;
transition: all 0.2s;
}
.color-option:hover {
transform: scale(1.1);
border-color: var(--white);
}
.color-option.selected {
border-color: var(--white);
box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.2);
}
</style>
<div class="container">
@ -790,6 +923,13 @@
>
Profile
</button>
<button
class="tab-button"
class:active={activeTab === 'appearance'}
on:click={() => activeTab = 'appearance'}
>
Appearance
</button>
<button
class="tab-button"
class:active={activeTab === 'password'}
@ -884,6 +1024,131 @@
{loading ? 'Saving...' : 'Save Profile'}
</button>
</div>
{:else if activeTab === 'appearance'}
<div class="card">
<h2>Appearance Settings</h2>
{#if colorMessage}
<div class="success" style="margin-bottom: 1rem;">{colorMessage}</div>
{/if}
{#if colorError}
<div class="error" style="margin-bottom: 1rem;">{colorError}</div>
{/if}
<div class="form-group">
<label>Profile Color</label>
<p style="color: var(--gray); font-size: 0.9rem; margin-bottom: 1rem;">
Your unique color appears next to your name across the platform
</p>
<div class="color-picker-container">
<div style="display: flex; align-items: center; gap: 1rem;">
<div
class="color-preview"
style="background: {userColor};"
title="Current color"
></div>
<div>
<div style="font-family: monospace; font-size: 1.1rem;">
{userColor}
</div>
<div style="font-size: 0.85rem; color: var(--gray);">
Current color
</div>
</div>
</div>
<button
class="btn btn-secondary"
on:click={() => { showColorPicker = !showColorPicker; newColor = userColor; }}
>
{showColorPicker ? 'Cancel' : 'Change Color'}
</button>
</div>
</div>
{#if showColorPicker}
<div style="margin-top: 2rem; padding: 1.5rem; background: rgba(86, 29, 94, 0.1); border: 1px solid rgba(86, 29, 94, 0.3); border-radius: 8px;">
<h3 style="margin-bottom: 1rem;">Choose a new color</h3>
<div class="form-group">
<label for="color-input">Custom Color</label>
<div class="color-input-wrapper">
<div
class="color-preview"
style="background: {isValidColor(newColor) ? newColor : '#000'};"
></div>
<input
id="color-input"
type="text"
class="color-input"
value={newColor}
on:input={handleColorInput}
placeholder="#000000"
maxlength="7"
pattern="^#[0-9A-Fa-f]{6}$"
/>
<input
type="color"
value={newColor}
on:input={(e) => newColor = e.target.value.toUpperCase()}
style="width: 50px; height: 40px; cursor: pointer; border: 1px solid var(--border); border-radius: 4px;"
/>
</div>
<small style="color: var(--gray);">
Enter a hex color code (e.g., #FF6B6B)
</small>
</div>
<div class="form-group">
<label>Suggested Colors</label>
<div class="color-grid">
{#each suggestedColors as color}
<button
class="color-option"
class:selected={newColor === color}
style="background: {color};"
on:click={() => selectSuggestedColor(color)}
title={color}
></button>
{/each}
</div>
</div>
<div style="display: flex; gap: 1rem; margin-top: 1.5rem;">
<button
on:click={updateColor}
disabled={colorLoading || !isValidColor(newColor) || newColor === userColor}
>
{colorLoading ? 'Updating...' : 'Save Color'}
</button>
<button
class="btn btn-secondary"
on:click={() => { showColorPicker = false; newColor = userColor; }}
disabled={colorLoading}
>
Cancel
</button>
</div>
{#if newColor && newColor !== userColor}
<div style="margin-top: 1rem; padding: 1rem; background: rgba(0, 0, 0, 0.3); border-radius: 4px;">
<div style="display: flex; align-items: center; gap: 1rem;">
<div
class="color-preview"
style="background: {newColor};"
></div>
<div>
<div style="font-size: 0.85rem; color: var(--gray);">Preview</div>
<div style="font-family: monospace;">{newColor}</div>
</div>
</div>
</div>
{/if}
</div>
{/if}
</div>
{:else if activeTab === 'password'}
<div class="card">
<h2>Change Password</h2>
@ -891,7 +1156,7 @@
{#if pgpOnly}
<div class="pgp-enabled-info" style="margin-bottom: 2rem;">
<p>
<span class="check-icon">🔒</span>
<span class="check-icon">🔐</span>
<strong>PGP-only mode is enabled</strong>
</p>
<p style="font-size: 0.9rem; margin-bottom: 0;">