Compare commits
No commits in common. "ef6b82bb8b06d7e4fb74d80b7ad7e89b2acadc3d" and "a43fc6689cd604e26a88cf9e471360ef08f0bbf5" have entirely different histories.
ef6b82bb8b
...
a43fc6689c
4
package-lock.json
generated
4
package-lock.json
generated
@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "twitch-vod-manager",
|
"name": "twitch-vod-manager",
|
||||||
"version": "4.6.17",
|
"version": "4.6.16",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "twitch-vod-manager",
|
"name": "twitch-vod-manager",
|
||||||
"version": "4.6.17",
|
"version": "4.6.16",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"axios": "^1.6.0",
|
"axios": "^1.6.0",
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "twitch-vod-manager",
|
"name": "twitch-vod-manager",
|
||||||
"version": "4.6.17",
|
"version": "4.6.16",
|
||||||
"description": "Twitch VOD Manager - Download Twitch VODs easily",
|
"description": "Twitch VOD Manager - Download Twitch VODs easily",
|
||||||
"main": "dist/main.js",
|
"main": "dist/main.js",
|
||||||
"author": "xRangerDE",
|
"author": "xRangerDE",
|
||||||
|
|||||||
@ -280,7 +280,6 @@
|
|||||||
<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>
|
||||||
@ -841,7 +840,6 @@
|
|||||||
<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,213 +2291,6 @@ 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) {
|
||||||
@ -6743,10 +6536,6 @@ 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,7 +91,6 @@ 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,22 +235,6 @@ 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;
|
||||||
@ -331,7 +315,6 @@ 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,23 +316,6 @@ 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,23 +316,6 @@ 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}.',
|
||||||
|
|||||||
@ -1,195 +0,0 @@
|
|||||||
// 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,8 +494,6 @@ 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;
|
||||||
@ -594,8 +592,6 @@ 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>
|
||||||
@ -620,14 +616,6 @@ 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,279 +1940,3 @@ 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