mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 15:25:46 +00:00
feat(discord): add dismissible community banner
This commit is contained in:
@@ -41527,6 +41527,60 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "cycle",
|
||||
"from": "src/store/authDiscordBannerActions.ts",
|
||||
"to": "src/store/authStoreTypes.ts",
|
||||
"unresolvedTo": "./authStoreTypes",
|
||||
"dependencyTypes": [
|
||||
"local",
|
||||
"type-only",
|
||||
"import"
|
||||
],
|
||||
"rule": {
|
||||
"severity": "error",
|
||||
"name": "no-circular"
|
||||
},
|
||||
"cycle": [
|
||||
{
|
||||
"name": "src/store/authStoreTypes.ts",
|
||||
"dependencyTypes": [
|
||||
"local",
|
||||
"type-only",
|
||||
"import"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "src/music-network/index.ts",
|
||||
"dependencyTypes": [
|
||||
"local",
|
||||
"type-only",
|
||||
"import"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "src/music-network/ui/useEnrichmentPrimary.ts",
|
||||
"dependencyTypes": [
|
||||
"local",
|
||||
"export"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "src/store/authStore.ts",
|
||||
"dependencyTypes": [
|
||||
"local",
|
||||
"import"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "src/store/authDiscordBannerActions.ts",
|
||||
"dependencyTypes": [
|
||||
"local",
|
||||
"import"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "cycle",
|
||||
"from": "src/store/authDiscordSettingsActions.ts",
|
||||
|
||||
@@ -40,6 +40,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
* Full Bulgarian (Български) UI translation — selectable from the language picker on the Settings and Login screens.
|
||||
|
||||
|
||||
### Discord community banner
|
||||
|
||||
**By [@ImAsra](https://github.com/ImAsra), PR [#1222](https://github.com/Psychotoxical/psysonic/pull/1222)**
|
||||
|
||||
* A dismissible banner inviting you to join the Psysonic community on Discord appears after 20 hours of accumulated app use. **Join** opens the invite; dismiss it for the session, or choose **Never show again** to hide it permanently.
|
||||
|
||||
|
||||
## Changed
|
||||
|
||||
### Frontend restructure — feature-folder architecture and hardening
|
||||
|
||||
@@ -57,6 +57,7 @@ import { AppShellQueueResizerSeam } from '@/app/AppShellQueueResizerSeam';
|
||||
import { IS_LINUX, IS_MACOS } from '@/lib/util/platform';
|
||||
import { useConnectionStatus } from '@/lib/hooks/useConnectionStatus';
|
||||
import { useIdlePlayQueuePull } from '@/app/hooks/useIdlePlayQueuePull';
|
||||
import { DiscordBanner, useAccumulatedUsage } from '@/features/discordBanner';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { usePlayerStore } from '@/features/playback/store/playerStore';
|
||||
import '@/features/playback/store/previewPlayerVolumeSync';
|
||||
@@ -92,6 +93,7 @@ export function AppShell() {
|
||||
usePlaybackRateOrbitSync();
|
||||
useTrayMenuI18n();
|
||||
useServerCapabilitiesProbe();
|
||||
useAccumulatedUsage();
|
||||
const isFullscreenOpen = usePlayerStore(s => s.isFullscreenOpen);
|
||||
const toggleFullscreen = usePlayerStore(s => s.toggleFullscreen);
|
||||
const isQueueVisible = usePlayerStore(s => s.isQueueVisible);
|
||||
@@ -282,6 +284,7 @@ export function AppShell() {
|
||||
)}
|
||||
</header>
|
||||
<OrbitSessionBar />
|
||||
<DiscordBanner/>
|
||||
{connStatus === 'disconnected' && (
|
||||
<OfflineBanner onRetry={connRetry} isChecking={connRetrying} showSettingsLink={!hasOfflineBrowse} serverName={serverName} />
|
||||
)}
|
||||
|
||||
@@ -411,6 +411,7 @@ const CONTRIBUTOR_ENTRIES = [
|
||||
contributions: [
|
||||
'Long-press album Play to shuffle with hold progress animation (PR #888)',
|
||||
'WinGet release automation — GitHub Actions workflow that opens manifest PRs on each release (PR #1077)',
|
||||
'Dismissible Discord community banner (PR #1222)',
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { X } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { open } from '@tauri-apps/plugin-shell';
|
||||
import { useDiscordBanner } from './useDiscordBanner';
|
||||
|
||||
const DISCORD_INVITE = 'https://discord.gg/AMnDRErm4u';
|
||||
|
||||
export default function DiscordBanner() {
|
||||
const { t } = useTranslation();
|
||||
const { visible, dismiss } = useDiscordBanner();
|
||||
|
||||
if (!visible) return null;
|
||||
|
||||
const handleJoin = (): void => {
|
||||
void open(DISCORD_INVITE);
|
||||
dismiss(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='discord-banner' role='region' aria-label={t('discordBanner.ariaLabel')}>
|
||||
<div className='discord-banner-left'>
|
||||
<svg className='discord-banner-icon' viewBox='0 0 24 24' fill='currentColor' aria-hidden='true'>
|
||||
<path d='M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z' />
|
||||
</svg>
|
||||
<span className='discord-banner-text'>{t('discordBanner.message')}</span>
|
||||
<button type='button' className='discord-banner-join' onClick={handleJoin}>
|
||||
{t('discordBanner.join')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className='discord-banner-right'>
|
||||
<button
|
||||
type='button'
|
||||
className='discord-banner-never'
|
||||
onClick={() => dismiss(true)}
|
||||
>
|
||||
{t('discordBanner.neverShow')}
|
||||
</button>
|
||||
<button
|
||||
type='button'
|
||||
className='discord-banner-close'
|
||||
onClick={() => dismiss(false)}
|
||||
aria-label={t('discordBanner.close')}
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default as DiscordBanner } from './DiscordBanner';
|
||||
export { useAccumulatedUsage } from './useAccumulatedUsage';
|
||||
@@ -0,0 +1,46 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import { useAccumulatedUsage } from './useAccumulatedUsage';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { resetAuthStore } from '@/test/helpers/storeReset';
|
||||
|
||||
describe('useAccumulatedUsage', () => {
|
||||
beforeEach(() => {
|
||||
resetAuthStore();
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
resetAuthStore();
|
||||
});
|
||||
|
||||
it('accumulates elapsed time into the auth store on each tick', () => {
|
||||
renderHook(() => useAccumulatedUsage());
|
||||
expect(useAuthStore.getState().discordBannerAccumulatedUsageMs).toBe(0);
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(30_000);
|
||||
});
|
||||
expect(useAuthStore.getState().discordBannerAccumulatedUsageMs).toBe(30_000);
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(30_000);
|
||||
});
|
||||
expect(useAuthStore.getState().discordBannerAccumulatedUsageMs).toBe(60_000);
|
||||
});
|
||||
|
||||
it('stops accumulating after unmount', () => {
|
||||
const { unmount } = renderHook(() => useAccumulatedUsage());
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(30_000);
|
||||
});
|
||||
const afterFirstTick = useAuthStore.getState().discordBannerAccumulatedUsageMs;
|
||||
|
||||
unmount();
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(60_000);
|
||||
});
|
||||
|
||||
expect(useAuthStore.getState().discordBannerAccumulatedUsageMs).toBe(afterFirstTick);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
|
||||
const TICK_MS = 30_000; // flush every 30s
|
||||
|
||||
/**
|
||||
* Tracks total accumulated app usage time across sessions, persisted via
|
||||
* authStore (zustand persist → localStorage). Counts time for as long as
|
||||
* the app process is running, regardless of window focus/visibility.
|
||||
* Mount once near the app root (e.g. AppShell).
|
||||
*/
|
||||
export function useAccumulatedUsage(): void {
|
||||
const lastTickRef = useRef<number | null>(null);
|
||||
const addUsageMs = useAuthStore(s => s.addDiscordBannerUsageMs);
|
||||
|
||||
useEffect(() => {
|
||||
lastTickRef.current = Date.now();
|
||||
|
||||
const flush = (): void => {
|
||||
const now = Date.now();
|
||||
const delta = now - (lastTickRef.current ?? now);
|
||||
lastTickRef.current = now;
|
||||
addUsageMs(delta);
|
||||
};
|
||||
|
||||
const interval = window.setInterval(flush, TICK_MS);
|
||||
|
||||
return () => {
|
||||
window.clearInterval(interval);
|
||||
};
|
||||
}, [addUsageMs]);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import { useDiscordBanner } from './useDiscordBanner';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { resetAuthStore } from '@/test/helpers/storeReset';
|
||||
|
||||
const THRESHOLD_MS = 20 * 60 * 60 * 1000; // matches useDiscordBanner
|
||||
|
||||
describe('useDiscordBanner', () => {
|
||||
beforeEach(resetAuthStore);
|
||||
afterEach(resetAuthStore);
|
||||
|
||||
it('stays hidden until accumulated usage reaches the threshold', () => {
|
||||
useAuthStore.setState({ discordBannerAccumulatedUsageMs: THRESHOLD_MS - 1 });
|
||||
const { result } = renderHook(() => useDiscordBanner());
|
||||
expect(result.current.visible).toBe(false);
|
||||
});
|
||||
|
||||
it('becomes visible at the threshold when never dismissed', () => {
|
||||
useAuthStore.setState({ discordBannerAccumulatedUsageMs: THRESHOLD_MS });
|
||||
const { result } = renderHook(() => useDiscordBanner());
|
||||
expect(result.current.visible).toBe(true);
|
||||
});
|
||||
|
||||
it('stays hidden when permanently dismissed even far past the threshold', () => {
|
||||
useAuthStore.setState({
|
||||
discordBannerAccumulatedUsageMs: THRESHOLD_MS * 2,
|
||||
discordBannerDismissed: true,
|
||||
});
|
||||
const { result } = renderHook(() => useDiscordBanner());
|
||||
expect(result.current.visible).toBe(false);
|
||||
});
|
||||
|
||||
it('hides for the session on a non-permanent dismiss without persisting', () => {
|
||||
useAuthStore.setState({ discordBannerAccumulatedUsageMs: THRESHOLD_MS });
|
||||
const { result } = renderHook(() => useDiscordBanner());
|
||||
expect(result.current.visible).toBe(true);
|
||||
|
||||
act(() => result.current.dismiss(false));
|
||||
|
||||
expect(result.current.visible).toBe(false);
|
||||
expect(useAuthStore.getState().discordBannerDismissed).toBe(false);
|
||||
});
|
||||
|
||||
it('persists a permanent dismiss to the auth store', () => {
|
||||
useAuthStore.setState({ discordBannerAccumulatedUsageMs: THRESHOLD_MS });
|
||||
const { result } = renderHook(() => useDiscordBanner());
|
||||
|
||||
act(() => result.current.dismiss(true));
|
||||
|
||||
expect(result.current.visible).toBe(false);
|
||||
expect(useAuthStore.getState().discordBannerDismissed).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import { useState } from 'react';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
|
||||
const USAGE_THRESHOLD_MS = 20 * 60 * 60 * 1000; // 20 hours
|
||||
|
||||
interface UseDiscordBannerReturn {
|
||||
visible: boolean;
|
||||
dismiss: (permanent: boolean) => void;
|
||||
}
|
||||
|
||||
export function useDiscordBanner(): UseDiscordBannerReturn {
|
||||
const dismissedPermanently = useAuthStore(s => s.discordBannerDismissed);
|
||||
const usageMs = useAuthStore(s => s.discordBannerAccumulatedUsageMs);
|
||||
const dismissDiscordBanner = useAuthStore(s => s.dismissDiscordBanner);
|
||||
const [sessionDismissed, setSessionDismissed] = useState(false);
|
||||
|
||||
const visible = !dismissedPermanently && !sessionDismissed && usageMs >= USAGE_THRESHOLD_MS;
|
||||
|
||||
const dismiss = (permanent: boolean): void => {
|
||||
if (permanent) dismissDiscordBanner();
|
||||
setSessionDismissed(true);
|
||||
};
|
||||
|
||||
return { visible, dismiss };
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export const discordBanner = {
|
||||
ariaLabel: 'Присъединете се към Psysonic в Discord',
|
||||
message: 'Присъединете се към общността на Psysonic в Discord',
|
||||
join: 'Присъединяване',
|
||||
neverShow: 'Да не се показва повече',
|
||||
close: 'Скриване',
|
||||
};
|
||||
@@ -44,6 +44,7 @@ import { orbit } from './orbit';
|
||||
import { tray } from './tray';
|
||||
import { licenses } from './licenses';
|
||||
import { migration } from './migration';
|
||||
import { discordBanner } from './discordBanner';
|
||||
|
||||
export const bgTranslation = {
|
||||
sidebar,
|
||||
@@ -92,4 +93,5 @@ export const bgTranslation = {
|
||||
tray,
|
||||
licenses,
|
||||
migration,
|
||||
discordBanner,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
export const discordBanner = {
|
||||
ariaLabel: 'Tritt Psysonic auf Discord bei',
|
||||
message: 'Tritt der Psysonic-Community auf Discord bei',
|
||||
join: 'Beitreten',
|
||||
neverShow: 'Nie wieder anzeigen',
|
||||
close: 'Verwerfen',
|
||||
};
|
||||
@@ -44,6 +44,7 @@ import { orbit } from './orbit';
|
||||
import { tray } from './tray';
|
||||
import { licenses } from './licenses';
|
||||
import { migration } from './migration';
|
||||
import { discordBanner } from './discordBanner';
|
||||
|
||||
export const deTranslation = {
|
||||
sidebar,
|
||||
@@ -92,4 +93,5 @@ export const deTranslation = {
|
||||
tray,
|
||||
licenses,
|
||||
migration,
|
||||
discordBanner,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
export const discordBanner = {
|
||||
ariaLabel: 'Join Psysonic on Discord',
|
||||
message: 'Join the Psysonic community on Discord',
|
||||
join: 'Join',
|
||||
neverShow: 'Never show again',
|
||||
close: 'Dismiss',
|
||||
};
|
||||
@@ -44,6 +44,7 @@ import { orbit } from './orbit';
|
||||
import { tray } from './tray';
|
||||
import { licenses } from './licenses';
|
||||
import { migration } from './migration';
|
||||
import { discordBanner } from './discordBanner';
|
||||
|
||||
export const enTranslation = {
|
||||
sidebar,
|
||||
@@ -92,4 +93,5 @@ export const enTranslation = {
|
||||
tray,
|
||||
licenses,
|
||||
migration,
|
||||
discordBanner,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
export const discordBanner = {
|
||||
ariaLabel: 'Únete a Psysonic en Discord',
|
||||
message: 'Únete a la comunidad de Psysonic en Discord',
|
||||
join: 'Unirse',
|
||||
neverShow: 'No volver a mostrar',
|
||||
close: 'Descartar',
|
||||
};
|
||||
@@ -44,6 +44,7 @@ import { orbit } from './orbit';
|
||||
import { tray } from './tray';
|
||||
import { licenses } from './licenses';
|
||||
import { migration } from './migration';
|
||||
import { discordBanner } from './discordBanner';
|
||||
|
||||
export const esTranslation = {
|
||||
sidebar,
|
||||
@@ -92,4 +93,5 @@ export const esTranslation = {
|
||||
tray,
|
||||
licenses,
|
||||
migration,
|
||||
discordBanner,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
export const discordBanner = {
|
||||
ariaLabel: 'Rejoindre Psysonic sur Discord',
|
||||
message: 'Rejoignez la communauté Psysonic sur Discord',
|
||||
join: 'Rejoindre',
|
||||
neverShow: 'Ne plus afficher',
|
||||
close: 'Ignorer',
|
||||
};
|
||||
@@ -44,6 +44,7 @@ import { orbit } from './orbit';
|
||||
import { tray } from './tray';
|
||||
import { licenses } from './licenses';
|
||||
import { migration } from './migration';
|
||||
import { discordBanner } from './discordBanner';
|
||||
|
||||
export const frTranslation = {
|
||||
sidebar,
|
||||
@@ -92,4 +93,5 @@ export const frTranslation = {
|
||||
tray,
|
||||
licenses,
|
||||
migration,
|
||||
discordBanner,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
export const discordBanner = {
|
||||
ariaLabel: 'Csatlakozz a Psysonic-hoz Discordon',
|
||||
message: 'Csatlakozz a Psysonic közösséghez Discordon',
|
||||
join: 'Csatlakozás',
|
||||
neverShow: 'Soha többé ne mutasd',
|
||||
close: 'Elvetés',
|
||||
};
|
||||
@@ -44,6 +44,7 @@ import { orbit } from './orbit';
|
||||
import { tray } from './tray';
|
||||
import { licenses } from './licenses';
|
||||
import { migration } from './migration';
|
||||
import { discordBanner } from './discordBanner';
|
||||
|
||||
export const huTranslation = {
|
||||
sidebar,
|
||||
@@ -92,4 +93,5 @@ export const huTranslation = {
|
||||
tray,
|
||||
licenses,
|
||||
migration,
|
||||
discordBanner,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
export const discordBanner = {
|
||||
ariaLabel: 'DiscordでPsysonicに参加',
|
||||
message: 'DiscordのPsysonicコミュニティに参加しよう',
|
||||
join: '参加する',
|
||||
neverShow: '二度と表示しない',
|
||||
close: '閉じる',
|
||||
};
|
||||
@@ -44,6 +44,7 @@ import { orbit } from './orbit';
|
||||
import { tray } from './tray';
|
||||
import { licenses } from './licenses';
|
||||
import { migration } from './migration';
|
||||
import { discordBanner } from './discordBanner';
|
||||
|
||||
export const jaTranslation = {
|
||||
sidebar,
|
||||
@@ -92,4 +93,5 @@ export const jaTranslation = {
|
||||
tray,
|
||||
licenses,
|
||||
migration,
|
||||
discordBanner,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
export const discordBanner = {
|
||||
ariaLabel: 'Bli med Psysonic på Discord',
|
||||
message: 'Bli med i Psysonic-samfunnet på Discord',
|
||||
join: 'Bli med',
|
||||
neverShow: 'Aldri vis igjen',
|
||||
close: 'Avvis',
|
||||
};
|
||||
@@ -44,6 +44,7 @@ import { orbit } from './orbit';
|
||||
import { tray } from './tray';
|
||||
import { licenses } from './licenses';
|
||||
import { migration } from './migration';
|
||||
import { discordBanner } from './discordBanner';
|
||||
|
||||
export const nbTranslation = {
|
||||
sidebar,
|
||||
@@ -92,4 +93,5 @@ export const nbTranslation = {
|
||||
tray,
|
||||
licenses,
|
||||
migration,
|
||||
discordBanner,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
export const discordBanner = {
|
||||
ariaLabel: 'Word lid van Psysonic op Discord',
|
||||
message: 'Word lid van de Psysonic-community op Discord',
|
||||
join: 'Deelnemen',
|
||||
neverShow: 'Nooit meer tonen',
|
||||
close: 'Negeren',
|
||||
};
|
||||
@@ -44,6 +44,7 @@ import { orbit } from './orbit';
|
||||
import { tray } from './tray';
|
||||
import { licenses } from './licenses';
|
||||
import { migration } from './migration';
|
||||
import { discordBanner } from './discordBanner';
|
||||
|
||||
export const nlTranslation = {
|
||||
sidebar,
|
||||
@@ -92,4 +93,5 @@ export const nlTranslation = {
|
||||
tray,
|
||||
licenses,
|
||||
migration,
|
||||
discordBanner,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
export const discordBanner = {
|
||||
ariaLabel: 'Dołącz do Psysonic na Discordzie',
|
||||
message: 'Dołącz do społeczności Psysonic na Discordzie',
|
||||
join: 'Dołącz',
|
||||
neverShow: 'Nie pokazuj ponownie',
|
||||
close: 'Odrzuć',
|
||||
};
|
||||
@@ -44,6 +44,7 @@ import { orbit } from './orbit';
|
||||
import { tray } from './tray';
|
||||
import { licenses } from './licenses';
|
||||
import { migration } from './migration';
|
||||
import { discordBanner } from './discordBanner';
|
||||
|
||||
export const plTranslation = {
|
||||
sidebar,
|
||||
@@ -92,4 +93,5 @@ export const plTranslation = {
|
||||
tray,
|
||||
licenses,
|
||||
migration,
|
||||
discordBanner,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
export const discordBanner = {
|
||||
ariaLabel: 'Alătură-te lui Psysonic pe Discord',
|
||||
message: 'Alătură-te comunității Psysonic pe Discord',
|
||||
join: 'Alătură-te',
|
||||
neverShow: 'Nu mai arăta niciodată',
|
||||
close: 'Omite',
|
||||
};
|
||||
@@ -44,6 +44,7 @@ import { orbit } from './orbit';
|
||||
import { tray } from './tray';
|
||||
import { licenses } from './licenses';
|
||||
import { migration } from './migration';
|
||||
import { discordBanner } from './discordBanner';
|
||||
|
||||
export const roTranslation = {
|
||||
sidebar,
|
||||
@@ -92,4 +93,5 @@ export const roTranslation = {
|
||||
tray,
|
||||
licenses,
|
||||
migration,
|
||||
discordBanner,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
export const discordBanner = {
|
||||
ariaLabel: 'Присоединяйтесь к Psysonic в Discord',
|
||||
message: 'Присоединяйтесь к сообществу Psysonic в Discord',
|
||||
join: 'Присоединиться',
|
||||
neverShow: 'Больше не показывать',
|
||||
close: 'Скрыть',
|
||||
};
|
||||
@@ -44,6 +44,7 @@ import { orbit } from './orbit';
|
||||
import { tray } from './tray';
|
||||
import { licenses } from './licenses';
|
||||
import { migration } from './migration';
|
||||
import { discordBanner } from './discordBanner';
|
||||
|
||||
export const ruTranslation = {
|
||||
sidebar,
|
||||
@@ -92,4 +93,5 @@ export const ruTranslation = {
|
||||
tray,
|
||||
licenses,
|
||||
migration,
|
||||
discordBanner,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
export const discordBanner = {
|
||||
ariaLabel: '在 Discord 上加入 Psysonic',
|
||||
message: '加入 Discord 上的 Psysonic 社区',
|
||||
join: '加入',
|
||||
neverShow: '不再显示',
|
||||
close: '忽略',
|
||||
};
|
||||
@@ -44,6 +44,7 @@ import { orbit } from './orbit';
|
||||
import { tray } from './tray';
|
||||
import { licenses } from './licenses';
|
||||
import { migration } from './migration';
|
||||
import { discordBanner } from './discordBanner';
|
||||
|
||||
export const zhTranslation = {
|
||||
sidebar,
|
||||
@@ -92,4 +93,5 @@ export const zhTranslation = {
|
||||
tray,
|
||||
licenses,
|
||||
migration,
|
||||
discordBanner,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
// src/store/authDiscordBannerActions.ts
|
||||
|
||||
import type { AuthState } from './authStoreTypes';
|
||||
|
||||
export interface DiscordBannerActions {
|
||||
dismissDiscordBanner: () => void;
|
||||
addDiscordBannerUsageMs: (deltaMs: number) => void;
|
||||
}
|
||||
|
||||
export const createDiscordBannerActions = (
|
||||
set: (fn: (state: AuthState) => Partial<AuthState>) => void
|
||||
): DiscordBannerActions => ({
|
||||
dismissDiscordBanner: () =>
|
||||
set(() => ({ discordBannerDismissed: true })),
|
||||
|
||||
addDiscordBannerUsageMs: deltaMs =>
|
||||
set(state => ({
|
||||
discordBannerAccumulatedUsageMs: state.discordBannerAccumulatedUsageMs + deltaMs,
|
||||
})),
|
||||
});
|
||||
@@ -24,12 +24,13 @@ import { syncAllServerHttpContexts } from '@/lib/server/syncServerHttpContext';
|
||||
import type { AuthState } from './authStoreTypes';
|
||||
import { getCachedConnectBaseUrl } from '@/lib/server/serverEndpoint';
|
||||
import { serverProfileBaseUrl } from '@/lib/server/serverBaseUrl';
|
||||
|
||||
|
||||
import { createDiscordBannerActions } from './authDiscordBannerActions';
|
||||
|
||||
export const useAuthStore = create<AuthState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
discordBannerDismissed: false,
|
||||
discordBannerAccumulatedUsageMs: 0,
|
||||
servers: [],
|
||||
activeServerId: null,
|
||||
musicNetworkAccounts: [],
|
||||
@@ -148,6 +149,7 @@ export const useAuthStore = create<AuthState>()(
|
||||
...createSkipStarActions(set, get),
|
||||
...createMusicLibraryActions(set, get),
|
||||
...createPerServerCapabilityActions(set),
|
||||
...createDiscordBannerActions(set),
|
||||
|
||||
getBaseUrl: () => {
|
||||
const s = get();
|
||||
|
||||
@@ -109,6 +109,12 @@ export type TrackPreviewLocation =
|
||||
export type TrackPreviewLocations = Record<TrackPreviewLocation, boolean>;
|
||||
|
||||
export interface AuthState {
|
||||
// DiscordBanner
|
||||
discordBannerDismissed: boolean;
|
||||
discordBannerAccumulatedUsageMs: number;
|
||||
dismissDiscordBanner: () => void;
|
||||
addDiscordBannerUsageMs: (deltaMs: number) => void;
|
||||
|
||||
// Multi-server
|
||||
servers: ServerProfile[];
|
||||
activeServerId: string | null;
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
/* src/styles/layout/discord-banner.css */
|
||||
|
||||
.discord-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 0 16px;
|
||||
height: 36px;
|
||||
background: #5865f2;
|
||||
color: #fff;
|
||||
font-size: 13px;
|
||||
flex-shrink: 0;
|
||||
z-index: 50;
|
||||
}
|
||||
|
||||
.discord-banner-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.discord-banner-text {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.discord-banner-join {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px 12px;
|
||||
border-radius: 4px;
|
||||
background: rgba(255, 255, 255, 0.18);
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
border: 1px solid rgba(255, 255, 255, 0.35);
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.discord-banner-join:hover {
|
||||
background: rgba(255, 255, 255, 0.28);
|
||||
}
|
||||
|
||||
.discord-banner-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.discord-banner-never {
|
||||
padding: 2px 10px;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
border: 1px solid rgba(255, 255, 255, 0.25);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: color 0.15s, background 0.15s;
|
||||
}
|
||||
|
||||
.discord-banner-never:hover {
|
||||
color: #fff;
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
.discord-banner-close {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: color 0.15s, background 0.15s;
|
||||
}
|
||||
|
||||
.discord-banner-close:hover {
|
||||
color: #fff;
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
@@ -29,3 +29,4 @@
|
||||
@import './empty-state.css';
|
||||
@import './utility-footer.css';
|
||||
@import './sidebar-expandable-playlists-section.css';
|
||||
@import './discord-banner.css';
|
||||
|
||||
Reference in New Issue
Block a user