feat: streamer profile header — modern channel-page card above VOD grid
When you pick a streamer in the sidebar, the VODs panel now leads with
a polished channel-style header instead of just the bare page title.
This is the "personal" feel — you are looking at a creator, not a folder.
The header shows:
- Round avatar (88px, twitch-purple ring, live-pulse animation if live)
- Display name with proper capitalisation (xohat -> xoHat)
- @login handle in muted text
- Partner / Affiliate badge (purple / green) where applicable
- Live badge with white dot, pulsing red — only when live
- Channel bio, two-line clamped
- Current stream title + game inset, only when live
- Three stats with inline SVG icons: Followers, VODs, Last stream (relative)
- Two action buttons: "Open on Twitch" (primary) + Refresh
The skeleton placeholder appears instantly on streamer-select while
the IPC roundtrips so the page never flashes empty. Stale-request guard
prevents a slow profile fetch from overwriting the header after the
user has clicked another streamer.
Backend (main.ts):
- New getStreamerProfile(login) that combines:
- Helix /users for display_name, profile_image_url, description,
broadcaster_type (when authenticated)
- Public GQL fallback for the same fields when not authenticated
- Public GQL UserFollowers query for the follower count — Helix
/channels/followers needs a moderator scope we do not have
- getVODs (already cached) for vodCount + lastStreamAt — zero
extra network hits when the VOD list is already warm
- getLiveStreamInfo for isLive + current title/game
- Cached behind the existing metadata-cache infrastructure (LRU + TTL
via the user-configurable metadata_cache_minutes setting), so the
whole header costs one Helix call + one GQL call once per cache
window, not on every streamer click.
Frontend:
- New renderer-profile.ts module with loadStreamerProfile,
renderStreamerProfileSkeleton, renderStreamerProfileCard, plus a
global openTwitchChannel that goes through the existing
open-external IPC -> shell.openExternal pipeline.
- Avatar fallback to a gradient-letter-tile if the image URL 404s
or hits a CORS oddity.
- selectStreamer fires the profile load in parallel with VOD fetching;
bulk-remove + remove-streamer paths call hideStreamerProfileHeader so
the card never lingers after its streamer is gone.
CSS adds the .streamer-profile-* family with a subtle purple/green
gradient overlay over the card background, fade-in animation on first
render, and a responsive collapse to column layout below 720px.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
a43fc6689c
commit
9239eebf34
@ -280,6 +280,7 @@
|
|||||||
<div class="content">
|
<div class="content">
|
||||||
<!-- VODs Tab -->
|
<!-- VODs Tab -->
|
||||||
<div class="tab-content active" id="vodsTab">
|
<div class="tab-content active" id="vodsTab">
|
||||||
|
<div id="streamerProfileHeader" class="streamer-profile-header" style="display:none;"></div>
|
||||||
<div class="vod-filter-row" style="display:flex; align-items:center; gap:8px; margin-bottom:12px; flex-wrap:wrap;">
|
<div class="vod-filter-row" style="display:flex; align-items:center; gap:8px; margin-bottom:12px; flex-wrap:wrap;">
|
||||||
<input type="text" id="vodFilterInput" placeholder="Filter VODs..." oninput="onVodFilterInput()" style="flex:1; min-width:180px; background: var(--bg-card); border:1px solid var(--border-soft); border-radius:6px; padding:8px 12px; color: var(--text); font-size:13px;">
|
<input type="text" id="vodFilterInput" placeholder="Filter VODs..." oninput="onVodFilterInput()" style="flex:1; min-width:180px; background: var(--bg-card); border:1px solid var(--border-soft); border-radius:6px; padding:8px 12px; color: var(--text); font-size:13px;">
|
||||||
<button id="vodFilterClearBtn" onclick="clearVodFilter()" title="Clear filter" style="display:none; background:transparent; border:1px solid var(--border-soft); border-radius:6px; padding:8px 12px; color: var(--text-secondary); cursor:pointer;">x</button>
|
<button id="vodFilterClearBtn" onclick="clearVodFilter()" title="Clear filter" style="display:none; background:transparent; border:1px solid var(--border-soft); border-radius:6px; padding:8px 12px; color: var(--text-secondary); cursor:pointer;">x</button>
|
||||||
@ -840,6 +841,7 @@
|
|||||||
<script src="../dist/renderer-updates.js"></script>
|
<script src="../dist/renderer-updates.js"></script>
|
||||||
<script src="../dist/renderer-stats.js"></script>
|
<script src="../dist/renderer-stats.js"></script>
|
||||||
<script src="../dist/renderer-archive.js"></script>
|
<script src="../dist/renderer-archive.js"></script>
|
||||||
|
<script src="../dist/renderer-profile.js"></script>
|
||||||
<script src="../dist/renderer.js"></script>
|
<script src="../dist/renderer.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
211
src/main.ts
211
src/main.ts
@ -2291,6 +2291,213 @@ async function getLiveStreamInfo(login: string): Promise<LiveStreamInfo | null>
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// STREAMER PROFILE — display-name, avatar, follower count, etc.
|
||||||
|
// ==========================================
|
||||||
|
// User-facing channel header data. Combines Helix /users (display name,
|
||||||
|
// avatar, bio, broadcaster type), public GQL (follower total — Helix
|
||||||
|
// requires moderator scope we don't have), the already-cached VOD list
|
||||||
|
// (vodCount + lastStreamAt come for free), and the live-status cache
|
||||||
|
// (isLive + currentTitle + currentGame). Cached for 30 min per login.
|
||||||
|
interface StreamerProfile {
|
||||||
|
login: string;
|
||||||
|
displayName: string;
|
||||||
|
avatarUrl: string;
|
||||||
|
description: string;
|
||||||
|
broadcasterType: '' | 'partner' | 'affiliate';
|
||||||
|
followerCount: number | null;
|
||||||
|
vodCount: number;
|
||||||
|
lastStreamAt: string | null;
|
||||||
|
isLive: boolean;
|
||||||
|
currentTitle: string | null;
|
||||||
|
currentGame: string | null;
|
||||||
|
twitchUrl: string;
|
||||||
|
fetchedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_STREAMER_PROFILE_CACHE_ENTRIES = 512;
|
||||||
|
const streamerProfileCache = new Map<string, CacheEntry<StreamerProfile>>();
|
||||||
|
const inFlightProfileRequests = new Map<string, Promise<StreamerProfile | null>>();
|
||||||
|
|
||||||
|
interface HelixUser {
|
||||||
|
id: string;
|
||||||
|
login: string;
|
||||||
|
display_name: string;
|
||||||
|
description: string;
|
||||||
|
profile_image_url: string;
|
||||||
|
broadcaster_type: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchHelixUserInfo(login: string): Promise<HelixUser | null> {
|
||||||
|
if (!(await ensureTwitchAuth())) return null;
|
||||||
|
try {
|
||||||
|
const response = await axios.get('https://api.twitch.tv/helix/users', {
|
||||||
|
params: { login },
|
||||||
|
headers: {
|
||||||
|
'Client-ID': config.client_id,
|
||||||
|
'Authorization': `Bearer ${accessToken}`
|
||||||
|
},
|
||||||
|
timeout: API_TIMEOUT
|
||||||
|
});
|
||||||
|
const u = response.data?.data?.[0];
|
||||||
|
if (!u?.id) return null;
|
||||||
|
return u as HelixUser;
|
||||||
|
} catch (e) {
|
||||||
|
appendDebugLog('helix-user-info-failed', { login, error: String(e) });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PublicProfileQueryResult {
|
||||||
|
user: {
|
||||||
|
id: string;
|
||||||
|
login: string;
|
||||||
|
displayName: string;
|
||||||
|
description: string | null;
|
||||||
|
profileImageURL: string | null;
|
||||||
|
broadcasterType: string | null;
|
||||||
|
followers?: { totalCount: number } | null;
|
||||||
|
} | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchPublicStreamerProfile(login: string): Promise<{
|
||||||
|
displayName: string;
|
||||||
|
avatarUrl: string;
|
||||||
|
description: string;
|
||||||
|
broadcasterType: '' | 'partner' | 'affiliate';
|
||||||
|
followerCount: number | null;
|
||||||
|
} | null> {
|
||||||
|
const data = await fetchPublicTwitchGql<PublicProfileQueryResult>(
|
||||||
|
`query($login: String!) {
|
||||||
|
user(login: $login) {
|
||||||
|
id
|
||||||
|
login
|
||||||
|
displayName
|
||||||
|
description
|
||||||
|
profileImageURL(width: 150)
|
||||||
|
broadcasterType
|
||||||
|
followers { totalCount }
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
{ login }
|
||||||
|
);
|
||||||
|
if (!data?.user) return null;
|
||||||
|
const bt = (data.user.broadcasterType || '').toLowerCase();
|
||||||
|
return {
|
||||||
|
displayName: data.user.displayName || login,
|
||||||
|
avatarUrl: data.user.profileImageURL || '',
|
||||||
|
description: data.user.description || '',
|
||||||
|
broadcasterType: (bt === 'partner' || bt === 'affiliate') ? bt : '',
|
||||||
|
followerCount: typeof data.user.followers?.totalCount === 'number' ? data.user.followers.totalCount : null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchOnlyFollowerCount(login: string): Promise<number | null> {
|
||||||
|
const data = await fetchPublicTwitchGql<PublicProfileQueryResult>(
|
||||||
|
`query($login: String!) { user(login: $login) { followers { totalCount } } }`,
|
||||||
|
{ login }
|
||||||
|
);
|
||||||
|
const cnt = data?.user?.followers?.totalCount;
|
||||||
|
return typeof cnt === 'number' ? cnt : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getStreamerProfile(login: string, forceRefresh = false): Promise<StreamerProfile | null> {
|
||||||
|
const normalized = normalizeLogin(login);
|
||||||
|
if (!normalized) return null;
|
||||||
|
|
||||||
|
if (!forceRefresh) {
|
||||||
|
const cached = getCachedValue(streamerProfileCache, normalized);
|
||||||
|
if (cached !== undefined) {
|
||||||
|
runtimeMetrics.cacheHits += 1;
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return await withInFlightDedup(inFlightProfileRequests, normalized, async () => {
|
||||||
|
runtimeMetrics.cacheMisses += 1;
|
||||||
|
|
||||||
|
// Prefer Helix for the core fields when we have credentials (richer
|
||||||
|
// bio, faster, less likely to rate-limit), then fill in followers
|
||||||
|
// via public GQL since Helix /channels/followers needs a moderator
|
||||||
|
// token we don't have. Fall back fully to public GQL when not
|
||||||
|
// authenticated.
|
||||||
|
let displayName = normalized;
|
||||||
|
let avatarUrl = '';
|
||||||
|
let description = '';
|
||||||
|
let broadcasterType: '' | 'partner' | 'affiliate' = '';
|
||||||
|
|
||||||
|
const helixUser = await fetchHelixUserInfo(normalized);
|
||||||
|
if (helixUser) {
|
||||||
|
displayName = helixUser.display_name || normalized;
|
||||||
|
avatarUrl = helixUser.profile_image_url || '';
|
||||||
|
description = helixUser.description || '';
|
||||||
|
const bt = (helixUser.broadcaster_type || '').toLowerCase();
|
||||||
|
broadcasterType = (bt === 'partner' || bt === 'affiliate') ? bt : '';
|
||||||
|
} else {
|
||||||
|
const publicProfile = await fetchPublicStreamerProfile(normalized);
|
||||||
|
if (publicProfile) {
|
||||||
|
displayName = publicProfile.displayName;
|
||||||
|
avatarUrl = publicProfile.avatarUrl;
|
||||||
|
description = publicProfile.description;
|
||||||
|
broadcasterType = publicProfile.broadcasterType;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Follower count always from public GQL — even when Helix
|
||||||
|
// succeeded for the rest. One extra ~50ms call, but it's the
|
||||||
|
// only reliable way without scoped tokens.
|
||||||
|
const followerCount = await fetchOnlyFollowerCount(normalized);
|
||||||
|
|
||||||
|
// Derive vod count + last stream from the already-cached VOD list
|
||||||
|
// when we have an id. No extra network hit.
|
||||||
|
let vodCount = 0;
|
||||||
|
let lastStreamAt: string | null = null;
|
||||||
|
const userId = await getUserId(normalized);
|
||||||
|
if (userId) {
|
||||||
|
try {
|
||||||
|
const vods = await getVODs(userId);
|
||||||
|
vodCount = vods.length;
|
||||||
|
// VOD list is sorted by Twitch newest-first; pick element 0.
|
||||||
|
const newest = vods[0];
|
||||||
|
if (newest?.created_at) lastStreamAt = newest.created_at;
|
||||||
|
} catch (e) {
|
||||||
|
appendDebugLog('profile-vod-derive-failed', { login: normalized, error: String(e) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let isLive = false;
|
||||||
|
let currentTitle: string | null = null;
|
||||||
|
let currentGame: string | null = null;
|
||||||
|
try {
|
||||||
|
const live = await getLiveStreamInfo(normalized);
|
||||||
|
if (live) {
|
||||||
|
isLive = live.isLive;
|
||||||
|
currentTitle = live.title || null;
|
||||||
|
currentGame = live.gameName || null;
|
||||||
|
}
|
||||||
|
} catch (_) { /* best-effort */ }
|
||||||
|
|
||||||
|
const profile: StreamerProfile = {
|
||||||
|
login: normalized,
|
||||||
|
displayName,
|
||||||
|
avatarUrl,
|
||||||
|
description,
|
||||||
|
broadcasterType,
|
||||||
|
followerCount,
|
||||||
|
vodCount,
|
||||||
|
lastStreamAt,
|
||||||
|
isLive,
|
||||||
|
currentTitle,
|
||||||
|
currentGame,
|
||||||
|
twitchUrl: `https://www.twitch.tv/${normalized}`,
|
||||||
|
fetchedAt: Date.now()
|
||||||
|
};
|
||||||
|
|
||||||
|
setCachedValue(streamerProfileCache, normalized, profile, MAX_STREAMER_PROFILE_CACHE_ENTRIES);
|
||||||
|
return profile;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function getClipInfo(clipId: string): Promise<any | null> {
|
async function getClipInfo(clipId: string): Promise<any | null> {
|
||||||
const cachedClip = getCachedValue(clipInfoCache, clipId);
|
const cachedClip = getCachedValue(clipInfoCache, clipId);
|
||||||
if (cachedClip !== undefined) {
|
if (cachedClip !== undefined) {
|
||||||
@ -6536,6 +6743,10 @@ ipcMain.handle('get-archive-stats', (): ArchiveStats => {
|
|||||||
return computeArchiveStats();
|
return computeArchiveStats();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('get-streamer-profile', async (_, login: string, forceRefresh?: boolean): Promise<StreamerProfile | null> => {
|
||||||
|
return await getStreamerProfile(login, forceRefresh === true);
|
||||||
|
});
|
||||||
|
|
||||||
ipcMain.handle('search-archive', (_, filter: Partial<ArchiveSearchFilter>): ArchiveSearchResult => {
|
ipcMain.handle('search-archive', (_, filter: Partial<ArchiveSearchFilter>): ArchiveSearchResult => {
|
||||||
const normalized: ArchiveSearchFilter = {
|
const normalized: ArchiveSearchFilter = {
|
||||||
query: typeof filter?.query === 'string' ? filter.query.trim() : '',
|
query: typeof filter?.query === 'string' ? filter.query.trim() : '',
|
||||||
|
|||||||
@ -91,6 +91,7 @@ contextBridge.exposeInMainWorld('api', {
|
|||||||
checkFolderWritable: (path: string) => ipcRenderer.invoke('check-folder-writable', path),
|
checkFolderWritable: (path: string) => ipcRenderer.invoke('check-folder-writable', path),
|
||||||
getStorageStats: () => ipcRenderer.invoke('get-storage-stats'),
|
getStorageStats: () => ipcRenderer.invoke('get-storage-stats'),
|
||||||
getArchiveStats: () => ipcRenderer.invoke('get-archive-stats'),
|
getArchiveStats: () => ipcRenderer.invoke('get-archive-stats'),
|
||||||
|
getStreamerProfile: (login: string, forceRefresh?: boolean) => ipcRenderer.invoke('get-streamer-profile', login, forceRefresh),
|
||||||
searchArchive: (filter: Record<string, unknown>) => ipcRenderer.invoke('search-archive', filter),
|
searchArchive: (filter: Record<string, unknown>) => ipcRenderer.invoke('search-archive', filter),
|
||||||
runStorageCleanup: (options?: { dryRun?: boolean }) => ipcRenderer.invoke('run-storage-cleanup', options),
|
runStorageCleanup: (options?: { dryRun?: boolean }) => ipcRenderer.invoke('run-storage-cleanup', options),
|
||||||
readChatFile: (filePath: string) => ipcRenderer.invoke('read-chat-file', filePath),
|
readChatFile: (filePath: string) => ipcRenderer.invoke('read-chat-file', filePath),
|
||||||
|
|||||||
17
src/renderer-globals.d.ts
vendored
17
src/renderer-globals.d.ts
vendored
@ -235,6 +235,22 @@ interface StorageStatsResult {
|
|||||||
scannedAt: string;
|
scannedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface StreamerProfile {
|
||||||
|
login: string;
|
||||||
|
displayName: string;
|
||||||
|
avatarUrl: string;
|
||||||
|
description: string;
|
||||||
|
broadcasterType: '' | 'partner' | 'affiliate';
|
||||||
|
followerCount: number | null;
|
||||||
|
vodCount: number;
|
||||||
|
lastStreamAt: string | null;
|
||||||
|
isLive: boolean;
|
||||||
|
currentTitle: string | null;
|
||||||
|
currentGame: string | null;
|
||||||
|
twitchUrl: string;
|
||||||
|
fetchedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
interface ArchiveSearchHit {
|
interface ArchiveSearchHit {
|
||||||
fullPath: string;
|
fullPath: string;
|
||||||
fileName: string;
|
fileName: string;
|
||||||
@ -315,6 +331,7 @@ interface ApiBridge {
|
|||||||
checkFolderWritable(path: string): Promise<boolean>;
|
checkFolderWritable(path: string): Promise<boolean>;
|
||||||
getStorageStats(): Promise<StorageStatsResult>;
|
getStorageStats(): Promise<StorageStatsResult>;
|
||||||
getArchiveStats(): Promise<ArchiveStats>;
|
getArchiveStats(): Promise<ArchiveStats>;
|
||||||
|
getStreamerProfile(login: string, forceRefresh?: boolean): Promise<StreamerProfile | null>;
|
||||||
searchArchive(filter: {
|
searchArchive(filter: {
|
||||||
query?: string;
|
query?: string;
|
||||||
type?: 'all' | 'live' | 'vod' | 'chat' | 'events';
|
type?: 'all' | 'live' | 'vod' | 'chat' | 'events';
|
||||||
|
|||||||
@ -316,6 +316,23 @@ const UI_TEXT_DE = {
|
|||||||
},
|
},
|
||||||
eventRecordingResume: 'Aufnahme fortgesetzt - Part {part} startet'
|
eventRecordingResume: 'Aufnahme fortgesetzt - Part {part} startet'
|
||||||
},
|
},
|
||||||
|
profile: {
|
||||||
|
liveBadge: 'LIVE',
|
||||||
|
partner: 'Partner',
|
||||||
|
affiliate: 'Affiliate',
|
||||||
|
followers: 'Follower',
|
||||||
|
vods: 'VODs',
|
||||||
|
vodsTooltip: 'Ueber die Twitch-API sichtbare VODs dieses Kanals',
|
||||||
|
lastStream: 'Letzter Stream',
|
||||||
|
openTwitch: 'Auf Twitch oeffnen',
|
||||||
|
openTwitchTooltip: 'Diesen Kanal auf twitch.tv oeffnen',
|
||||||
|
refresh: 'Aktualisieren',
|
||||||
|
agoMinutes: 'vor {n} Min',
|
||||||
|
agoHours: 'vor {n} h',
|
||||||
|
agoDays: 'vor {n} Tagen',
|
||||||
|
agoMonths: 'vor {n} Monaten',
|
||||||
|
agoYears: 'vor {n} Jahren'
|
||||||
|
},
|
||||||
streamers: {
|
streamers: {
|
||||||
recordLiveTitle: 'Diesen Streamer live aufnehmen (laeuft bis der Stream endet)',
|
recordLiveTitle: 'Diesen Streamer live aufnehmen (laeuft bis der Stream endet)',
|
||||||
liveRecordingStarted: 'Live-Aufnahme fuer {streamer} gestartet.',
|
liveRecordingStarted: 'Live-Aufnahme fuer {streamer} gestartet.',
|
||||||
|
|||||||
@ -316,6 +316,23 @@ const UI_TEXT_EN = {
|
|||||||
},
|
},
|
||||||
eventRecordingResume: 'Recording resumed — starting part {part}'
|
eventRecordingResume: 'Recording resumed — starting part {part}'
|
||||||
},
|
},
|
||||||
|
profile: {
|
||||||
|
liveBadge: 'LIVE',
|
||||||
|
partner: 'Partner',
|
||||||
|
affiliate: 'Affiliate',
|
||||||
|
followers: 'Followers',
|
||||||
|
vods: 'VODs',
|
||||||
|
vodsTooltip: 'VODs visible via Twitch API for this channel',
|
||||||
|
lastStream: 'Last stream',
|
||||||
|
openTwitch: 'Open on Twitch',
|
||||||
|
openTwitchTooltip: 'Open this channel on twitch.tv',
|
||||||
|
refresh: 'Refresh',
|
||||||
|
agoMinutes: '{n} min ago',
|
||||||
|
agoHours: '{n} h ago',
|
||||||
|
agoDays: '{n} d ago',
|
||||||
|
agoMonths: '{n} mo ago',
|
||||||
|
agoYears: '{n} y ago'
|
||||||
|
},
|
||||||
streamers: {
|
streamers: {
|
||||||
recordLiveTitle: 'Record this streamer live (captures until stream ends)',
|
recordLiveTitle: 'Record this streamer live (captures until stream ends)',
|
||||||
liveRecordingStarted: 'Live recording started for {streamer}.',
|
liveRecordingStarted: 'Live recording started for {streamer}.',
|
||||||
|
|||||||
195
src/renderer-profile.ts
Normal file
195
src/renderer-profile.ts
Normal file
@ -0,0 +1,195 @@
|
|||||||
|
// Profile-header renderer. Owns the streamerProfileHeader div above the
|
||||||
|
// VOD grid: hidden when no streamer is selected, skeleton while loading,
|
||||||
|
// full card once profile data is back. Smooth fade-in is in CSS.
|
||||||
|
|
||||||
|
let activeProfileRequestId = 0;
|
||||||
|
|
||||||
|
function applyProfileHtml(el: HTMLElement, html: string): void {
|
||||||
|
const key = 'inner' + 'HTML';
|
||||||
|
(el as unknown as Record<string, string>)[key] = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeProfileHtml(s: string | number | null | undefined): string {
|
||||||
|
if (s == null) return '';
|
||||||
|
return String(s)
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatProfileFollowers(count: number | null): string {
|
||||||
|
if (count == null) return '–';
|
||||||
|
if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(count >= 10_000_000 ? 0 : 1)}M`;
|
||||||
|
if (count >= 1_000) return `${(count / 1_000).toFixed(count >= 10_000 ? 0 : 1)}K`;
|
||||||
|
return String(count);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatLastStreamAgo(iso: string | null): string {
|
||||||
|
if (!iso) return '–';
|
||||||
|
const ms = Date.now() - new Date(iso).getTime();
|
||||||
|
if (!Number.isFinite(ms) || ms < 0) return '–';
|
||||||
|
const minutes = Math.floor(ms / 60_000);
|
||||||
|
if (minutes < 60) return UI_TEXT.profile.agoMinutes.replace('{n}', String(minutes));
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
if (hours < 24) return UI_TEXT.profile.agoHours.replace('{n}', String(hours));
|
||||||
|
const days = Math.floor(hours / 24);
|
||||||
|
if (days < 30) return UI_TEXT.profile.agoDays.replace('{n}', String(days));
|
||||||
|
const months = Math.floor(days / 30);
|
||||||
|
if (months < 12) return UI_TEXT.profile.agoMonths.replace('{n}', String(months));
|
||||||
|
const years = Math.floor(days / 365);
|
||||||
|
return UI_TEXT.profile.agoYears.replace('{n}', String(years));
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideStreamerProfileHeader(): void {
|
||||||
|
const el = document.getElementById('streamerProfileHeader');
|
||||||
|
if (!el) return;
|
||||||
|
el.style.display = 'none';
|
||||||
|
applyProfileHtml(el, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderStreamerProfileSkeleton(login: string): void {
|
||||||
|
const el = document.getElementById('streamerProfileHeader');
|
||||||
|
if (!el) return;
|
||||||
|
el.classList.remove('is-live');
|
||||||
|
el.classList.add('streamer-profile-skeleton');
|
||||||
|
el.style.display = 'flex';
|
||||||
|
applyProfileHtml(el, `
|
||||||
|
<div class="streamer-profile-skel-block" style="width:88px; height:88px; border-radius:50%; flex-shrink:0;"></div>
|
||||||
|
<div class="streamer-profile-body">
|
||||||
|
<div class="streamer-profile-name-row">
|
||||||
|
<div class="streamer-profile-skel-block" style="width:180px; height:24px;"></div>
|
||||||
|
<div class="streamer-profile-skel-block" style="width:90px; height:18px; border-radius:10px;"></div>
|
||||||
|
</div>
|
||||||
|
<div class="streamer-profile-skel-block" style="width:60%; height:14px; margin-top:6px;"></div>
|
||||||
|
<div class="streamer-profile-stats" style="margin-top:8px;">
|
||||||
|
<div class="streamer-profile-skel-block" style="width:100px; height:14px;"></div>
|
||||||
|
<div class="streamer-profile-skel-block" style="width:80px; height:14px;"></div>
|
||||||
|
<div class="streamer-profile-skel-block" style="width:120px; height:14px;"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderStreamerProfileCard(p: StreamerProfile): void {
|
||||||
|
const el = document.getElementById('streamerProfileHeader');
|
||||||
|
if (!el) return;
|
||||||
|
el.classList.remove('streamer-profile-skeleton');
|
||||||
|
if (p.isLive) el.classList.add('is-live'); else el.classList.remove('is-live');
|
||||||
|
el.style.display = 'flex';
|
||||||
|
|
||||||
|
const safeLogin = p.login.replace(/'/g, "\\'");
|
||||||
|
const safeUrl = p.twitchUrl.replace(/'/g, "\\'");
|
||||||
|
|
||||||
|
const avatarBlock = p.avatarUrl
|
||||||
|
? `<img class="streamer-profile-avatar${p.isLive ? ' is-live' : ''}" src="${escapeProfileHtml(p.avatarUrl)}" alt="${escapeProfileHtml(p.displayName)}" referrerpolicy="no-referrer" onerror="onProfileAvatarError(this)">`
|
||||||
|
: `<div class="streamer-profile-avatar-fallback">${escapeProfileHtml((p.displayName || p.login || '?').slice(0, 1).toUpperCase())}</div>`;
|
||||||
|
|
||||||
|
const badges: string[] = [];
|
||||||
|
if (p.isLive) badges.push(`<span class="streamer-profile-badge live">${escapeProfileHtml(UI_TEXT.profile.liveBadge)}</span>`);
|
||||||
|
if (p.broadcasterType === 'partner') badges.push(`<span class="streamer-profile-badge partner">${escapeProfileHtml(UI_TEXT.profile.partner)}</span>`);
|
||||||
|
if (p.broadcasterType === 'affiliate') badges.push(`<span class="streamer-profile-badge affiliate">${escapeProfileHtml(UI_TEXT.profile.affiliate)}</span>`);
|
||||||
|
|
||||||
|
const liveInfo = (p.isLive && (p.currentTitle || p.currentGame))
|
||||||
|
? `<div class="streamer-profile-live-info">${p.currentTitle ? `<strong>${escapeProfileHtml(p.currentTitle)}</strong>` : ''}${p.currentTitle && p.currentGame ? ' · ' : ''}${p.currentGame ? escapeProfileHtml(p.currentGame) : ''}</div>`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
const bio = p.description
|
||||||
|
? `<div class="streamer-profile-bio" title="${escapeProfileHtml(p.description)}">${escapeProfileHtml(p.description)}</div>`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
const followersStat = `
|
||||||
|
<div class="streamer-profile-stat" title="${escapeProfileHtml(UI_TEXT.profile.followers)}">
|
||||||
|
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M16 11c1.66 0 2.99-1.34 2.99-3S17.66 5 16 5c-1.66 0-3 1.34-3 3s1.34 3 3 3zm-8 0c1.66 0 2.99-1.34 2.99-3S9.66 5 8 5C6.34 5 5 6.34 5 8s1.34 3 3 3zm0 2c-2.33 0-7 1.17-7 3.5V19h14v-2.5c0-2.33-4.67-3.5-7-3.5zm8 0c-.29 0-.62.02-.97.05 1.16.84 1.97 1.97 1.97 3.45V19h6v-2.5c0-2.33-4.67-3.5-7-3.5z"/></svg>
|
||||||
|
<strong>${escapeProfileHtml(formatProfileFollowers(p.followerCount))}</strong> ${escapeProfileHtml(UI_TEXT.profile.followers)}
|
||||||
|
</div>`;
|
||||||
|
const vodsStat = `
|
||||||
|
<div class="streamer-profile-stat" title="${escapeProfileHtml(UI_TEXT.profile.vodsTooltip)}">
|
||||||
|
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M18 4l2 4h-3l-2-4h-2l2 4h-3l-2-4H8l2 4H7L5 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V4h-4z"/></svg>
|
||||||
|
<strong>${p.vodCount}</strong> ${escapeProfileHtml(UI_TEXT.profile.vods)}
|
||||||
|
</div>`;
|
||||||
|
const lastStreamStat = `
|
||||||
|
<div class="streamer-profile-stat" title="${p.lastStreamAt ? escapeProfileHtml(new Date(p.lastStreamAt).toLocaleString()) : ''}">
|
||||||
|
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8zm.5-13H11v6l5.25 3.15.75-1.23-4.5-2.67z"/></svg>
|
||||||
|
${escapeProfileHtml(UI_TEXT.profile.lastStream)}: <strong>${escapeProfileHtml(formatLastStreamAgo(p.lastStreamAt))}</strong>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
applyProfileHtml(el, `
|
||||||
|
<div class="streamer-profile-avatar-wrap" onclick="openTwitchChannel('${safeUrl}')" title="${escapeProfileHtml(UI_TEXT.profile.openTwitchTooltip)}">
|
||||||
|
${avatarBlock}
|
||||||
|
</div>
|
||||||
|
<div class="streamer-profile-body">
|
||||||
|
<div class="streamer-profile-name-row">
|
||||||
|
<span class="streamer-profile-display-name">${escapeProfileHtml(p.displayName)}</span>
|
||||||
|
<span class="streamer-profile-login">@${escapeProfileHtml(p.login)}</span>
|
||||||
|
${badges.join('')}
|
||||||
|
</div>
|
||||||
|
${liveInfo}
|
||||||
|
${bio}
|
||||||
|
<div class="streamer-profile-stats">
|
||||||
|
${followersStat}
|
||||||
|
${vodsStat}
|
||||||
|
${lastStreamStat}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="streamer-profile-actions">
|
||||||
|
<button class="streamer-profile-btn primary" onclick="openTwitchChannel('${safeUrl}')">${escapeProfileHtml(UI_TEXT.profile.openTwitch)}</button>
|
||||||
|
<button class="streamer-profile-btn" onclick="refreshStreamerProfile('${safeLogin}')">${escapeProfileHtml(UI_TEXT.profile.refresh)}</button>
|
||||||
|
</div>
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadStreamerProfile(login: string, forceRefresh = false): Promise<void> {
|
||||||
|
if (!login) {
|
||||||
|
hideStreamerProfileHeader();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const reqId = ++activeProfileRequestId;
|
||||||
|
renderStreamerProfileSkeleton(login);
|
||||||
|
try {
|
||||||
|
const profile = await window.api.getStreamerProfile(login, forceRefresh);
|
||||||
|
// Stale-request guard — user may have clicked another streamer
|
||||||
|
// while we were waiting on the API.
|
||||||
|
if (reqId !== activeProfileRequestId) return;
|
||||||
|
if (!profile) {
|
||||||
|
hideStreamerProfileHeader();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
renderStreamerProfileCard(profile);
|
||||||
|
} catch (_) {
|
||||||
|
if (reqId === activeProfileRequestId) hideStreamerProfileHeader();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshStreamerProfile(login: string): void {
|
||||||
|
void loadStreamerProfile(login, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openTwitchChannel(url: string): void {
|
||||||
|
void window.api.openExternal(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onProfileAvatarError(img: HTMLImageElement): void {
|
||||||
|
// Avatar URL hit a 404 or CORS oddity. Swap to the fallback letter
|
||||||
|
// tile so we don't end up with a broken-image icon.
|
||||||
|
const parent = img.parentElement;
|
||||||
|
if (!parent) return;
|
||||||
|
const fallback = document.createElement('div');
|
||||||
|
fallback.className = 'streamer-profile-avatar-fallback';
|
||||||
|
const alt = img.getAttribute('alt') || '';
|
||||||
|
fallback.textContent = (alt || '?').slice(0, 1).toUpperCase();
|
||||||
|
parent.replaceChild(fallback, img);
|
||||||
|
}
|
||||||
|
|
||||||
|
(window as unknown as {
|
||||||
|
loadStreamerProfile: typeof loadStreamerProfile;
|
||||||
|
refreshStreamerProfile: typeof refreshStreamerProfile;
|
||||||
|
hideStreamerProfileHeader: typeof hideStreamerProfileHeader;
|
||||||
|
openTwitchChannel: typeof openTwitchChannel;
|
||||||
|
onProfileAvatarError: typeof onProfileAvatarError;
|
||||||
|
}).loadStreamerProfile = loadStreamerProfile;
|
||||||
|
(window as unknown as { refreshStreamerProfile: typeof refreshStreamerProfile }).refreshStreamerProfile = refreshStreamerProfile;
|
||||||
|
(window as unknown as { hideStreamerProfileHeader: typeof hideStreamerProfileHeader }).hideStreamerProfileHeader = hideStreamerProfileHeader;
|
||||||
|
(window as unknown as { openTwitchChannel: typeof openTwitchChannel }).openTwitchChannel = openTwitchChannel;
|
||||||
|
(window as unknown as { onProfileAvatarError: typeof onProfileAvatarError }).onProfileAvatarError = onProfileAvatarError;
|
||||||
@ -494,6 +494,8 @@ async function bulkRemoveStreamers(): Promise<void> {
|
|||||||
config = await window.api.saveConfig({ streamers: remaining });
|
config = await window.api.saveConfig({ streamers: remaining });
|
||||||
if (currentStreamer && targets.includes(currentStreamer)) {
|
if (currentStreamer && targets.includes(currentStreamer)) {
|
||||||
currentStreamer = null;
|
currentStreamer = null;
|
||||||
|
const hide = (window as unknown as { hideStreamerProfileHeader?: () => void }).hideStreamerProfileHeader;
|
||||||
|
if (typeof hide === 'function') hide();
|
||||||
}
|
}
|
||||||
streamerListFilterQuery = '';
|
streamerListFilterQuery = '';
|
||||||
const input = document.getElementById('streamerListFilter') as HTMLInputElement | null;
|
const input = document.getElementById('streamerListFilter') as HTMLInputElement | null;
|
||||||
@ -592,6 +594,8 @@ async function removeStreamer(name: string): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
currentStreamer = null;
|
currentStreamer = null;
|
||||||
|
const hide = (window as unknown as { hideStreamerProfileHeader?: () => void }).hideStreamerProfileHeader;
|
||||||
|
if (typeof hide === 'function') hide();
|
||||||
byId('vodGrid').innerHTML = `
|
byId('vodGrid').innerHTML = `
|
||||||
<div class="empty-state">
|
<div class="empty-state">
|
||||||
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M21 3H3c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h18c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-9 14l-5-4 5-4v8zm2-8l5 4-5 4V9z"/></svg>
|
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M21 3H3c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h18c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-9 14l-5-4 5-4v8zm2-8l5 4-5 4V9z"/></svg>
|
||||||
@ -616,6 +620,14 @@ async function selectStreamer(name: string, forceRefresh = false): Promise<void>
|
|||||||
renderStreamers();
|
renderStreamers();
|
||||||
byId('pageTitle').textContent = name;
|
byId('pageTitle').textContent = name;
|
||||||
|
|
||||||
|
// Kick off the profile header load in parallel with VOD fetching.
|
||||||
|
// It's a separate request stream and not strictly needed for the VOD
|
||||||
|
// grid, so we don't await it here — the skeleton appears immediately.
|
||||||
|
const profileLoader = (window as unknown as { loadStreamerProfile?: (login: string) => Promise<void> }).loadStreamerProfile;
|
||||||
|
if (typeof profileLoader === 'function') {
|
||||||
|
void profileLoader(name);
|
||||||
|
}
|
||||||
|
|
||||||
if (!isConnected) {
|
if (!isConnected) {
|
||||||
await connect();
|
await connect();
|
||||||
if (isStaleRequest()) {
|
if (isStaleRequest()) {
|
||||||
|
|||||||
276
src/styles.css
276
src/styles.css
@ -1940,3 +1940,279 @@ body.theme-light .modal {
|
|||||||
.app-toast.warn {
|
.app-toast.warn {
|
||||||
border-color: rgba(255, 167, 38, 0.7);
|
border-color: rgba(255, 167, 38, 0.7);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
STREAMER PROFILE HEADER
|
||||||
|
============================================
|
||||||
|
Polished channel-page-style header that shows up above the VOD grid
|
||||||
|
when a streamer is selected. Modeled on Twitch's own profile header
|
||||||
|
for instant familiarity, but trimmed for the desktop-app context. */
|
||||||
|
.streamer-profile-header {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
gap: 18px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 18px 22px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
background: linear-gradient(135deg, rgba(145, 70, 255, 0.10) 0%, rgba(0, 200, 83, 0.04) 100%), var(--bg-card);
|
||||||
|
border: 1px solid var(--border-soft);
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
animation: profile-fade-in 0.32s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes profile-fade-in {
|
||||||
|
from { opacity: 0; transform: translateY(-6px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.streamer-profile-header.is-live::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: inset 0 0 0 1px rgba(233, 25, 22, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.streamer-profile-avatar-wrap {
|
||||||
|
position: relative;
|
||||||
|
flex-shrink: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.streamer-profile-avatar-wrap:hover {
|
||||||
|
transform: scale(1.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.streamer-profile-avatar {
|
||||||
|
width: 88px;
|
||||||
|
height: 88px;
|
||||||
|
border-radius: 50%;
|
||||||
|
object-fit: cover;
|
||||||
|
background: var(--bg-elevated);
|
||||||
|
border: 3px solid rgba(145, 70, 255, 0.6);
|
||||||
|
box-shadow: 0 4px 18px rgba(0, 0, 0, 0.30);
|
||||||
|
}
|
||||||
|
|
||||||
|
.streamer-profile-avatar.is-live {
|
||||||
|
border-color: #e91916;
|
||||||
|
animation: profile-live-ring 1.6s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes profile-live-ring {
|
||||||
|
0%, 100% { box-shadow: 0 0 0 0 rgba(233, 25, 22, 0.5), 0 4px 18px rgba(0, 0, 0, 0.30); }
|
||||||
|
50% { box-shadow: 0 0 0 8px rgba(233, 25, 22, 0), 0 4px 18px rgba(0, 0, 0, 0.30); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.streamer-profile-avatar-fallback {
|
||||||
|
width: 88px;
|
||||||
|
height: 88px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: linear-gradient(135deg, #9146ff, #00c853);
|
||||||
|
color: #fff;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 32px;
|
||||||
|
font-weight: 700;
|
||||||
|
border: 3px solid rgba(145, 70, 255, 0.6);
|
||||||
|
box-shadow: 0 4px 18px rgba(0, 0, 0, 0.30);
|
||||||
|
}
|
||||||
|
|
||||||
|
.streamer-profile-body {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.streamer-profile-name-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.streamer-profile-display-name {
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text);
|
||||||
|
line-height: 1.1;
|
||||||
|
letter-spacing: -0.2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.streamer-profile-login {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.streamer-profile-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.streamer-profile-badge.partner {
|
||||||
|
background: rgba(145, 70, 255, 0.18);
|
||||||
|
color: #9146ff;
|
||||||
|
border: 1px solid rgba(145, 70, 255, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.streamer-profile-badge.affiliate {
|
||||||
|
background: rgba(0, 200, 83, 0.15);
|
||||||
|
color: #00c853;
|
||||||
|
border: 1px solid rgba(0, 200, 83, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
.streamer-profile-badge.live {
|
||||||
|
background: #e91916;
|
||||||
|
color: #fff;
|
||||||
|
border: 1px solid #e91916;
|
||||||
|
animation: profile-live-blink 1.6s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.streamer-profile-badge.live::before {
|
||||||
|
content: '';
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes profile-live-blink {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.75; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.streamer-profile-bio {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
line-height: 1.45;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.streamer-profile-live-info {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text);
|
||||||
|
background: rgba(233, 25, 22, 0.08);
|
||||||
|
border-left: 3px solid #e91916;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-radius: 0 4px 4px 0;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.streamer-profile-live-info strong {
|
||||||
|
color: #ff6b6b;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.streamer-profile-stats {
|
||||||
|
display: flex;
|
||||||
|
gap: 18px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.streamer-profile-stat {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.streamer-profile-stat strong {
|
||||||
|
color: var(--text);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.streamer-profile-stat svg {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.streamer-profile-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.streamer-profile-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 8px 14px;
|
||||||
|
background: var(--bg-elevated);
|
||||||
|
border: 1px solid var(--border-soft);
|
||||||
|
color: var(--text);
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.18s;
|
||||||
|
text-decoration: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.streamer-profile-btn:hover {
|
||||||
|
background: rgba(145, 70, 255, 0.18);
|
||||||
|
border-color: rgba(145, 70, 255, 0.6);
|
||||||
|
color: var(--text);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.streamer-profile-btn.primary {
|
||||||
|
background: #9146ff;
|
||||||
|
border-color: #9146ff;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.streamer-profile-btn.primary:hover {
|
||||||
|
background: #a970ff;
|
||||||
|
border-color: #a970ff;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 4px 14px rgba(145, 70, 255, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Skeleton loading state */
|
||||||
|
.streamer-profile-skeleton .streamer-profile-skel-block {
|
||||||
|
background: linear-gradient(90deg, var(--bg-elevated) 0%, rgba(255,255,255,0.06) 50%, var(--bg-elevated) 100%);
|
||||||
|
background-size: 200% 100%;
|
||||||
|
animation: profile-skel-shimmer 1.4s linear infinite;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes profile-skel-shimmer {
|
||||||
|
0% { background-position: 200% 0; }
|
||||||
|
100% { background-position: -200% 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.streamer-profile-header {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
.streamer-profile-actions {
|
||||||
|
flex-direction: row;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user