From 41ae30f05d6121c16343d0c1df9f99dbbdd440ba Mon Sep 17 00:00:00 2001 From: ImAsra Date: Sun, 5 Jul 2026 18:26:49 +0200 Subject: [PATCH] feat(discord): add dismissible community banner --- .dependency-cruiser-known-violations.json | 54 +++++++++++ CHANGELOG.md | 7 ++ src/app/AppShell.tsx | 3 + src/config/settingsCredits.ts | 1 + src/features/discordBanner/DiscordBanner.tsx | 50 ++++++++++ src/features/discordBanner/index.ts | 2 + .../discordBanner/useAccumulatedUsage.test.ts | 46 +++++++++ .../discordBanner/useAccumulatedUsage.ts | 32 +++++++ .../discordBanner/useDiscordBanner.test.ts | 54 +++++++++++ .../discordBanner/useDiscordBanner.ts | 25 +++++ src/locales/bg/discordBanner.ts | 7 ++ src/locales/bg/index.ts | 2 + src/locales/de/discordBanner.ts | 7 ++ src/locales/de/index.ts | 2 + src/locales/en/discordBanner.ts | 7 ++ src/locales/en/index.ts | 2 + src/locales/es/discordBanner.ts | 7 ++ src/locales/es/index.ts | 2 + src/locales/fr/discordBanner.ts | 7 ++ src/locales/fr/index.ts | 2 + src/locales/hu/discordBanner.ts | 7 ++ src/locales/hu/index.ts | 2 + src/locales/ja/discordBanner.ts | 7 ++ src/locales/ja/index.ts | 2 + src/locales/nb/discordBanner.ts | 7 ++ src/locales/nb/index.ts | 2 + src/locales/nl/discordBanner.ts | 7 ++ src/locales/nl/index.ts | 2 + src/locales/pl/discordBanner.ts | 7 ++ src/locales/pl/index.ts | 2 + src/locales/ro/discordBanner.ts | 7 ++ src/locales/ro/index.ts | 2 + src/locales/ru/discordBanner.ts | 7 ++ src/locales/ru/index.ts | 2 + src/locales/zh/discordBanner.ts | 7 ++ src/locales/zh/index.ts | 2 + src/store/authDiscordBannerActions.ts | 20 ++++ src/store/authStore.ts | 6 +- src/store/authStoreTypes.ts | 6 ++ src/styles/layout/discord-banner.css | 93 +++++++++++++++++++ src/styles/layout/index.css | 1 + 41 files changed, 515 insertions(+), 2 deletions(-) create mode 100644 src/features/discordBanner/DiscordBanner.tsx create mode 100644 src/features/discordBanner/index.ts create mode 100644 src/features/discordBanner/useAccumulatedUsage.test.ts create mode 100644 src/features/discordBanner/useAccumulatedUsage.ts create mode 100644 src/features/discordBanner/useDiscordBanner.test.ts create mode 100644 src/features/discordBanner/useDiscordBanner.ts create mode 100644 src/locales/bg/discordBanner.ts create mode 100644 src/locales/de/discordBanner.ts create mode 100644 src/locales/en/discordBanner.ts create mode 100644 src/locales/es/discordBanner.ts create mode 100644 src/locales/fr/discordBanner.ts create mode 100644 src/locales/hu/discordBanner.ts create mode 100644 src/locales/ja/discordBanner.ts create mode 100644 src/locales/nb/discordBanner.ts create mode 100644 src/locales/nl/discordBanner.ts create mode 100644 src/locales/pl/discordBanner.ts create mode 100644 src/locales/ro/discordBanner.ts create mode 100644 src/locales/ru/discordBanner.ts create mode 100644 src/locales/zh/discordBanner.ts create mode 100644 src/store/authDiscordBannerActions.ts create mode 100644 src/styles/layout/discord-banner.css diff --git a/.dependency-cruiser-known-violations.json b/.dependency-cruiser-known-violations.json index f7b23281..0936dfe3 100644 --- a/.dependency-cruiser-known-violations.json +++ b/.dependency-cruiser-known-violations.json @@ -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", diff --git a/CHANGELOG.md b/CHANGELOG.md index e2d18aa7..bfc96bba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/app/AppShell.tsx b/src/app/AppShell.tsx index ba0dacaf..b4e2e7af 100644 --- a/src/app/AppShell.tsx +++ b/src/app/AppShell.tsx @@ -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() { )} + {connStatus === 'disconnected' && ( )} diff --git a/src/config/settingsCredits.ts b/src/config/settingsCredits.ts index 5d77b9cd..32e0f677 100644 --- a/src/config/settingsCredits.ts +++ b/src/config/settingsCredits.ts @@ -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)', ], }, { diff --git a/src/features/discordBanner/DiscordBanner.tsx b/src/features/discordBanner/DiscordBanner.tsx new file mode 100644 index 00000000..431736e0 --- /dev/null +++ b/src/features/discordBanner/DiscordBanner.tsx @@ -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 ( +
+
+ + {t('discordBanner.message')} + +
+ +
+ + +
+
+ ); +} diff --git a/src/features/discordBanner/index.ts b/src/features/discordBanner/index.ts new file mode 100644 index 00000000..494ba675 --- /dev/null +++ b/src/features/discordBanner/index.ts @@ -0,0 +1,2 @@ +export { default as DiscordBanner } from './DiscordBanner'; +export { useAccumulatedUsage } from './useAccumulatedUsage'; diff --git a/src/features/discordBanner/useAccumulatedUsage.test.ts b/src/features/discordBanner/useAccumulatedUsage.test.ts new file mode 100644 index 00000000..6eef145b --- /dev/null +++ b/src/features/discordBanner/useAccumulatedUsage.test.ts @@ -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); + }); +}); diff --git a/src/features/discordBanner/useAccumulatedUsage.ts b/src/features/discordBanner/useAccumulatedUsage.ts new file mode 100644 index 00000000..601ba7ca --- /dev/null +++ b/src/features/discordBanner/useAccumulatedUsage.ts @@ -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(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]); +} diff --git a/src/features/discordBanner/useDiscordBanner.test.ts b/src/features/discordBanner/useDiscordBanner.test.ts new file mode 100644 index 00000000..1a0818ee --- /dev/null +++ b/src/features/discordBanner/useDiscordBanner.test.ts @@ -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); + }); +}); diff --git a/src/features/discordBanner/useDiscordBanner.ts b/src/features/discordBanner/useDiscordBanner.ts new file mode 100644 index 00000000..c5d70d53 --- /dev/null +++ b/src/features/discordBanner/useDiscordBanner.ts @@ -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 }; +} diff --git a/src/locales/bg/discordBanner.ts b/src/locales/bg/discordBanner.ts new file mode 100644 index 00000000..b731e125 --- /dev/null +++ b/src/locales/bg/discordBanner.ts @@ -0,0 +1,7 @@ +export const discordBanner = { + ariaLabel: 'Присъединете се към Psysonic в Discord', + message: 'Присъединете се към общността на Psysonic в Discord', + join: 'Присъединяване', + neverShow: 'Да не се показва повече', + close: 'Скриване', +}; diff --git a/src/locales/bg/index.ts b/src/locales/bg/index.ts index 91b78076..6f6872ec 100644 --- a/src/locales/bg/index.ts +++ b/src/locales/bg/index.ts @@ -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, }; diff --git a/src/locales/de/discordBanner.ts b/src/locales/de/discordBanner.ts new file mode 100644 index 00000000..685f4325 --- /dev/null +++ b/src/locales/de/discordBanner.ts @@ -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', +}; \ No newline at end of file diff --git a/src/locales/de/index.ts b/src/locales/de/index.ts index 328ae299..f2c3c368 100644 --- a/src/locales/de/index.ts +++ b/src/locales/de/index.ts @@ -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, }; diff --git a/src/locales/en/discordBanner.ts b/src/locales/en/discordBanner.ts new file mode 100644 index 00000000..e6be6d7d --- /dev/null +++ b/src/locales/en/discordBanner.ts @@ -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', +}; \ No newline at end of file diff --git a/src/locales/en/index.ts b/src/locales/en/index.ts index 99723a5c..058ff3a5 100644 --- a/src/locales/en/index.ts +++ b/src/locales/en/index.ts @@ -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, }; diff --git a/src/locales/es/discordBanner.ts b/src/locales/es/discordBanner.ts new file mode 100644 index 00000000..db8606f0 --- /dev/null +++ b/src/locales/es/discordBanner.ts @@ -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', +}; \ No newline at end of file diff --git a/src/locales/es/index.ts b/src/locales/es/index.ts index 1760f48e..97e352ce 100644 --- a/src/locales/es/index.ts +++ b/src/locales/es/index.ts @@ -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, }; diff --git a/src/locales/fr/discordBanner.ts b/src/locales/fr/discordBanner.ts new file mode 100644 index 00000000..fbf3a43d --- /dev/null +++ b/src/locales/fr/discordBanner.ts @@ -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', +}; \ No newline at end of file diff --git a/src/locales/fr/index.ts b/src/locales/fr/index.ts index c169ef42..805a7ade 100644 --- a/src/locales/fr/index.ts +++ b/src/locales/fr/index.ts @@ -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, }; diff --git a/src/locales/hu/discordBanner.ts b/src/locales/hu/discordBanner.ts new file mode 100644 index 00000000..536f5f93 --- /dev/null +++ b/src/locales/hu/discordBanner.ts @@ -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', +}; \ No newline at end of file diff --git a/src/locales/hu/index.ts b/src/locales/hu/index.ts index c13b2dca..2afc08a6 100644 --- a/src/locales/hu/index.ts +++ b/src/locales/hu/index.ts @@ -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, }; diff --git a/src/locales/ja/discordBanner.ts b/src/locales/ja/discordBanner.ts new file mode 100644 index 00000000..2916556b --- /dev/null +++ b/src/locales/ja/discordBanner.ts @@ -0,0 +1,7 @@ +export const discordBanner = { + ariaLabel: 'DiscordでPsysonicに参加', + message: 'DiscordのPsysonicコミュニティに参加しよう', + join: '参加する', + neverShow: '二度と表示しない', + close: '閉じる', +}; \ No newline at end of file diff --git a/src/locales/ja/index.ts b/src/locales/ja/index.ts index af7402bb..ff40ef49 100644 --- a/src/locales/ja/index.ts +++ b/src/locales/ja/index.ts @@ -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, }; diff --git a/src/locales/nb/discordBanner.ts b/src/locales/nb/discordBanner.ts new file mode 100644 index 00000000..2b5b1049 --- /dev/null +++ b/src/locales/nb/discordBanner.ts @@ -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', +}; \ No newline at end of file diff --git a/src/locales/nb/index.ts b/src/locales/nb/index.ts index 83dde7b3..91f58703 100644 --- a/src/locales/nb/index.ts +++ b/src/locales/nb/index.ts @@ -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, }; diff --git a/src/locales/nl/discordBanner.ts b/src/locales/nl/discordBanner.ts new file mode 100644 index 00000000..2e3e2d32 --- /dev/null +++ b/src/locales/nl/discordBanner.ts @@ -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', +}; \ No newline at end of file diff --git a/src/locales/nl/index.ts b/src/locales/nl/index.ts index d64dd563..712ef103 100644 --- a/src/locales/nl/index.ts +++ b/src/locales/nl/index.ts @@ -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, }; diff --git a/src/locales/pl/discordBanner.ts b/src/locales/pl/discordBanner.ts new file mode 100644 index 00000000..a99f50f4 --- /dev/null +++ b/src/locales/pl/discordBanner.ts @@ -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ć', +}; \ No newline at end of file diff --git a/src/locales/pl/index.ts b/src/locales/pl/index.ts index e8dbb2e6..4decd1c5 100644 --- a/src/locales/pl/index.ts +++ b/src/locales/pl/index.ts @@ -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, }; diff --git a/src/locales/ro/discordBanner.ts b/src/locales/ro/discordBanner.ts new file mode 100644 index 00000000..e1491ace --- /dev/null +++ b/src/locales/ro/discordBanner.ts @@ -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', +}; \ No newline at end of file diff --git a/src/locales/ro/index.ts b/src/locales/ro/index.ts index e338807b..47eb83c0 100644 --- a/src/locales/ro/index.ts +++ b/src/locales/ro/index.ts @@ -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, }; diff --git a/src/locales/ru/discordBanner.ts b/src/locales/ru/discordBanner.ts new file mode 100644 index 00000000..8048f16b --- /dev/null +++ b/src/locales/ru/discordBanner.ts @@ -0,0 +1,7 @@ +export const discordBanner = { + ariaLabel: 'Присоединяйтесь к Psysonic в Discord', + message: 'Присоединяйтесь к сообществу Psysonic в Discord', + join: 'Присоединиться', + neverShow: 'Больше не показывать', + close: 'Скрыть', +}; \ No newline at end of file diff --git a/src/locales/ru/index.ts b/src/locales/ru/index.ts index f992c658..fec2cb3d 100644 --- a/src/locales/ru/index.ts +++ b/src/locales/ru/index.ts @@ -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, }; diff --git a/src/locales/zh/discordBanner.ts b/src/locales/zh/discordBanner.ts new file mode 100644 index 00000000..dcc3f533 --- /dev/null +++ b/src/locales/zh/discordBanner.ts @@ -0,0 +1,7 @@ +export const discordBanner = { + ariaLabel: '在 Discord 上加入 Psysonic', + message: '加入 Discord 上的 Psysonic 社区', + join: '加入', + neverShow: '不再显示', + close: '忽略', +}; \ No newline at end of file diff --git a/src/locales/zh/index.ts b/src/locales/zh/index.ts index 26d8a4b1..1a64737e 100644 --- a/src/locales/zh/index.ts +++ b/src/locales/zh/index.ts @@ -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, }; diff --git a/src/store/authDiscordBannerActions.ts b/src/store/authDiscordBannerActions.ts new file mode 100644 index 00000000..d25aeee5 --- /dev/null +++ b/src/store/authDiscordBannerActions.ts @@ -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) => void +): DiscordBannerActions => ({ + dismissDiscordBanner: () => + set(() => ({ discordBannerDismissed: true })), + + addDiscordBannerUsageMs: deltaMs => + set(state => ({ + discordBannerAccumulatedUsageMs: state.discordBannerAccumulatedUsageMs + deltaMs, + })), +}); diff --git a/src/store/authStore.ts b/src/store/authStore.ts index c647f8f3..55c6e405 100644 --- a/src/store/authStore.ts +++ b/src/store/authStore.ts @@ -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()( persist( (set, get) => ({ + discordBannerDismissed: false, + discordBannerAccumulatedUsageMs: 0, servers: [], activeServerId: null, musicNetworkAccounts: [], @@ -148,6 +149,7 @@ export const useAuthStore = create()( ...createSkipStarActions(set, get), ...createMusicLibraryActions(set, get), ...createPerServerCapabilityActions(set), + ...createDiscordBannerActions(set), getBaseUrl: () => { const s = get(); diff --git a/src/store/authStoreTypes.ts b/src/store/authStoreTypes.ts index 6f142175..5221391e 100644 --- a/src/store/authStoreTypes.ts +++ b/src/store/authStoreTypes.ts @@ -109,6 +109,12 @@ export type TrackPreviewLocation = export type TrackPreviewLocations = Record; export interface AuthState { + // DiscordBanner + discordBannerDismissed: boolean; + discordBannerAccumulatedUsageMs: number; + dismissDiscordBanner: () => void; + addDiscordBannerUsageMs: (deltaMs: number) => void; + // Multi-server servers: ServerProfile[]; activeServerId: string | null; diff --git a/src/styles/layout/discord-banner.css b/src/styles/layout/discord-banner.css new file mode 100644 index 00000000..b96e028f --- /dev/null +++ b/src/styles/layout/discord-banner.css @@ -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); +} diff --git a/src/styles/layout/index.css b/src/styles/layout/index.css index 7e88200a..097eccf6 100644 --- a/src/styles/layout/index.css +++ b/src/styles/layout/index.css @@ -29,3 +29,4 @@ @import './empty-state.css'; @import './utility-footer.css'; @import './sidebar-expandable-playlists-section.css'; +@import './discord-banner.css';