147 Commits

Author SHA1 Message Date
a0051576c6 Remove PostHog analytics and update dependencies to latest minor versions
- Remove posthog-js and posthog-node dependencies
- Delete PostHogProvider component and lib/posthog.ts
- Remove PostHog configuration from environment files
- Remove PostHog rewrites from next.config.mjs
- Update all npm subdependencies to latest minor versions
- Update @types/react and @types/react-dom overrides to 19.2.9 and 19.2.3
2026-01-25 00:12:04 +00:00
52a00ca899 feat: Improve SortableQueueItem component with enhanced click handling and styling 2025-08-16 17:07:18 -05:00
7710bf3cc9 feat: Add keyboard shortcuts and queue management features
- Implement global keyboard shortcuts for playback controls, volume adjustments, and navigation.
- Introduce drag-and-drop functionality for queue reordering with visual feedback.
- Add context menus for tracks, albums, and artists with quick action options.
- Develop Spotlight Search feature with Last.fm integration for enhanced music discovery.
- Create GlobalSearchProvider for managing search state and keyboard shortcuts.
- Ensure accessibility and keyboard navigation support across all new features.
2025-08-12 13:09:33 +00:00
9427a2a237 feat: Add ListeningStreakCard component for tracking listening streaks
feat: Implement InfiniteScroll component for loading more items on scroll

feat: Create useListeningStreak hook to manage listening streak data and statistics

feat: Develop useProgressiveAlbumLoading hook for progressive loading of albums

feat: Implement background sync service worker for automatic data synchronization
2025-08-11 14:50:57 +00:00
1f6ebf18a3 feat: Move service worker registration to a dedicated component for improved client-side handling 2025-08-11 12:35:50 +00:00
c999c43288 feat: Refactor service worker registration and enhance offline download manager with client-side checks 2025-08-11 12:31:08 +00:00
a352021dbc feat: Enhance OfflineManagement component with improved card styling and layout 2025-08-11 05:05:00 +00:00
147602ad8c feat: Implement Auto-Tagging Settings and MusicBrainz integration
- Added AutoTaggingSettings component for configuring auto-tagging preferences.
- Integrated localStorage for saving user preferences and options.
- Developed useAutoTagging hook for fetching and applying metadata from MusicBrainz.
- Created MusicBrainz API client for searching and retrieving music metadata.
- Enhanced metadata structure with additional fields for tracks and albums.
- Implemented rate-limiting for MusicBrainz API requests.
- Added UI components for user interaction and feedback during the tagging process.
2025-08-10 15:02:49 +00:00
18f0811787 feat: Enhance audio settings with ReplayGain, crossfade, and equalizer presets; add AudioSettingsDialog component 2025-08-10 02:57:55 +00:00
7a1c7e1eae feat: Update cover art retrieval to use higher resolution images and enhance download manager with new features 2025-08-10 02:06:39 +00:00
7e6a28e4f4 feat: Enhance UI with Framer Motion animations for album artwork and artist icons 2025-08-08 21:38:58 +00:00
36c1edd01e feat: Add page transition animations and notification settings for audio playback 2025-08-08 21:29:01 +00:00
3839a1be2d feat: Implement offline library synchronization with IndexedDB
- Added `useOfflineLibrarySync` hook for managing offline library sync operations.
- Created `OfflineLibrarySync` component for UI integration.
- Developed `offlineLibraryDB` for IndexedDB interactions, including storing and retrieving albums, artists, songs, and playlists.
- Implemented sync operations for starred items, playlists, and scrobbling.
- Added auto-sync functionality when coming back online.
- Included metadata management for sync settings and statistics.
- Enhanced error handling and user feedback through toasts.
2025-08-08 20:04:06 +00:00
0a0feb3748 feat: Implement offline library management with IndexedDB support
- Added `useOfflineLibrary` hook for managing offline library state and synchronization.
- Created `OfflineLibraryManager` class for handling IndexedDB operations and syncing with Navidrome API.
- Implemented methods for retrieving and storing albums, artists, songs, and playlists.
- Added support for offline favorites management (star/unstar).
- Implemented playlist creation, updating, and deletion functionalities.
- Added search functionality for offline data.
- Created a manifest file for PWA support with icons and shortcuts.
- Added service worker file for caching and offline capabilities.
2025-08-07 22:07:53 +00:00
7ac5eb89ce style: update README formatting and improve content clarity 2025-08-06 02:15:29 +00:00
8886302809 Merge pull request #39 from sillyangel/dependabot/npm_and_yarn/dev-99ea30e4b7
chore(deps-dev): bump the dev group across 1 directory with 2 updates
2025-08-03 09:39:18 -05:00
dependabot[bot]
b5669cf831 chore(deps-dev): bump the dev group across 1 directory with 2 updates
Bumps the dev group with 2 updates in the / directory: [eslint-config-next](https://github.com/vercel/next.js/tree/HEAD/packages/eslint-config-next) and [typescript](https://github.com/microsoft/TypeScript).


Updates `eslint-config-next` from 15.4.4 to 15.4.5
- [Release notes](https://github.com/vercel/next.js/releases)
- [Changelog](https://github.com/vercel/next.js/blob/canary/release.js)
- [Commits](https://github.com/vercel/next.js/commits/v15.4.5/packages/eslint-config-next)

Updates `typescript` from 5.8.3 to 5.9.2
- [Release notes](https://github.com/microsoft/TypeScript/releases)
- [Changelog](https://github.com/microsoft/TypeScript/blob/main/azure-pipelines.release-publish.yml)
- [Commits](https://github.com/microsoft/TypeScript/compare/v5.8.3...v5.9.2)

---
updated-dependencies:
- dependency-name: eslint-config-next
  dependency-version: 15.4.5
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev
- dependency-name: typescript
  dependency-version: 5.9.2
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-08-01 18:32:37 +00:00
9cda875605 Merge pull request #31 from sillyangel/mobile-support
Some checks failed
Release Docker Image / push_to_registry (push) Failing after 7s
YAY
2025-08-01 13:30:28 -05:00
ca92ff717e Merge branch 'dev' into mobile-support 2025-08-01 13:27:49 -05:00
fd7690b725 Add debug logging for audio source handling and API initialization; improve error handling in AudioPlayer and AudioPlayerContext 2025-08-01 18:17:09 +00:00
be2266bf3a Update .env.local with new commit SHA; comment out 'Browse' heading in LibraryPage for future reference 2025-08-01 17:29:43 +00:00
462f04c208 Merge pull request #32 from sillyangel/dependabot/npm_and_yarn/dev-380966b8c2
chore(deps-dev): bump the dev group with 3 updates
2025-07-31 17:30:58 -05:00
71de7884c3 Merge branch 'dev' into dependabot/npm_and_yarn/dev-380966b8c2 2025-07-31 17:30:42 -05:00
346d70c85b Merge pull request #36 from sillyangel/dependabot/npm_and_yarn/hookform/resolvers-5.2.0
chore(deps): bump @hookform/resolvers from 3.10.0 to 5.2.0
2025-07-31 17:30:02 -05:00
4412c38d2e Merge pull request #35 from sillyangel/dependabot/npm_and_yarn/zod-4.0.10
chore(deps): bump zod from 4.0.5 to 4.0.10
2025-07-31 17:29:47 -05:00
d7cf308470 Merge pull request #34 from sillyangel/dependabot/npm_and_yarn/radix-ui/react-progress-1.1.7
chore(deps): bump @radix-ui/react-progress from 1.1.1 to 1.1.7
2025-07-31 17:29:40 -05:00
ee956ba2e4 Merge pull request #33 from sillyangel/dependabot/npm_and_yarn/axios-1.11.0
chore(deps): bump axios from 1.8.2 to 1.11.0
2025-07-31 17:29:30 -05:00
b6fa101816 Merge pull request #37 from sillyangel/dependabot/npm_and_yarn/npm_and_yarn-373e2693b3
chore(deps): bump @eslint/plugin-kit from 0.3.3 to 0.3.4 in the npm_and_yarn group
2025-07-31 17:28:56 -05:00
dependabot[bot]
a5fe3be9fd chore(deps): bump @eslint/plugin-kit in the npm_and_yarn group
Bumps the npm_and_yarn group with 1 update: [@eslint/plugin-kit](https://github.com/eslint/rewrite/tree/HEAD/packages/plugin-kit).


Updates `@eslint/plugin-kit` from 0.3.3 to 0.3.4
- [Release notes](https://github.com/eslint/rewrite/releases)
- [Changelog](https://github.com/eslint/rewrite/blob/main/packages/plugin-kit/CHANGELOG.md)
- [Commits](https://github.com/eslint/rewrite/commits/plugin-kit-v0.3.4/packages/plugin-kit)

---
updated-dependencies:
- dependency-name: "@eslint/plugin-kit"
  dependency-version: 0.3.4
  dependency-type: indirect
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-07-29 09:37:26 +00:00
0c32c056c6 Refactor FullScreenPlayer layout for improved readability; update button placement for favorites toggle; replace multiple divs with a single heading element; update album preview images 2025-07-28 23:28:46 +00:00
dependabot[bot]
fed1ce4ee8 chore(deps): bump @hookform/resolvers from 3.10.0 to 5.2.0
Bumps [@hookform/resolvers](https://github.com/react-hook-form/resolvers) from 3.10.0 to 5.2.0.
- [Release notes](https://github.com/react-hook-form/resolvers/releases)
- [Commits](https://github.com/react-hook-form/resolvers/compare/v3.10.0...v5.2.0)

---
updated-dependencies:
- dependency-name: "@hookform/resolvers"
  dependency-version: 5.2.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-07-28 00:27:32 +00:00
dependabot[bot]
d2a9c72f1f chore(deps): bump zod from 4.0.5 to 4.0.10
Bumps [zod](https://github.com/colinhacks/zod) from 4.0.5 to 4.0.10.
- [Release notes](https://github.com/colinhacks/zod/releases)
- [Commits](https://github.com/colinhacks/zod/compare/v4.0.5...v4.0.10)

---
updated-dependencies:
- dependency-name: zod
  dependency-version: 4.0.10
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-07-28 00:27:29 +00:00
dependabot[bot]
843db69df8 chore(deps): bump @radix-ui/react-progress from 1.1.1 to 1.1.7
Bumps [@radix-ui/react-progress](https://github.com/radix-ui/primitives) from 1.1.1 to 1.1.7.
- [Changelog](https://github.com/radix-ui/primitives/blob/main/release-process.md)
- [Commits](https://github.com/radix-ui/primitives/commits)

---
updated-dependencies:
- dependency-name: "@radix-ui/react-progress"
  dependency-version: 1.1.7
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-07-28 00:27:11 +00:00
dependabot[bot]
61a19d7914 chore(deps): bump axios from 1.8.2 to 1.11.0
Bumps [axios](https://github.com/axios/axios) from 1.8.2 to 1.11.0.
- [Release notes](https://github.com/axios/axios/releases)
- [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md)
- [Commits](https://github.com/axios/axios/compare/v1.8.2...v1.11.0)

---
updated-dependencies:
- dependency-name: axios
  dependency-version: 1.11.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-07-28 00:27:10 +00:00
dependabot[bot]
4a327b420c chore(deps-dev): bump the dev group with 3 updates
Bumps the dev group with 3 updates: [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node), [eslint](https://github.com/eslint/eslint) and [eslint-config-next](https://github.com/vercel/next.js/tree/HEAD/packages/eslint-config-next).


Updates `@types/node` from 24.0.15 to 24.1.0
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

Updates `eslint` from 9.31.0 to 9.32.0
- [Release notes](https://github.com/eslint/eslint/releases)
- [Changelog](https://github.com/eslint/eslint/blob/main/CHANGELOG.md)
- [Commits](https://github.com/eslint/eslint/compare/v9.31.0...v9.32.0)

Updates `eslint-config-next` from 15.4.2 to 15.4.4
- [Release notes](https://github.com/vercel/next.js/releases)
- [Changelog](https://github.com/vercel/next.js/blob/canary/release.js)
- [Commits](https://github.com/vercel/next.js/commits/v15.4.4/packages/eslint-config-next)

---
updated-dependencies:
- dependency-name: "@types/node"
  dependency-version: 24.1.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev
- dependency-name: eslint
  dependency-version: 9.32.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev
- dependency-name: eslint-config-next
  dependency-version: 15.4.4
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-07-28 00:26:47 +00:00
23d6f48ee1 Update AudioPlayer and FullScreenPlayer for improved mobile audio handling; refactor WhatsNewPopup for better dialog structure; clean up LoginForm by removing unused settings 2025-07-25 22:49:52 +00:00
1dfb86fa15 Refactor code structure for improved readability and maintainability 2025-07-25 18:45:46 +00:00
745f164482 Update UserProfile avatar size; add development-only debug tools in SettingsPage 2025-07-25 17:58:12 +00:00
25e9bd6912 Refactor UI components for improved spacing; add UserProfile component for user info display 2025-07-25 17:35:07 +00:00
74b9648eef Enhance FullScreenPlayer for improved iOS scrolling; add debug tools in SettingsPage for localStorage management 2025-07-25 15:46:38 +00:00
86e198aa24 Comment out year display in album cards for cleaner UI 2025-07-25 14:22:31 +00:00
284bb4b29f Remove star button from AlbumPage; conditionally render PopularSongs section in ArtistPage based on mobile view 2025-07-25 14:05:10 +00:00
f4c01e2d20 Update FullScreenPlayer for improved iOS scrolling compatibility; adjust LibraryPage layout for better spacing and Card component padding 2025-07-25 14:02:01 +00:00
a957398f63 Increase cover art resolution to 300px for improved image quality in AlbumPage and SongRecommendations components 2025-07-25 04:54:07 +00:00
fe40c0264c Refactor SongRecommendations and LibraryPage components for improved mobile responsiveness and code clarity 2025-07-25 04:52:46 +00:00
940ed94579 Update app version and changelog for July End of Month Update; remove unused code in entrypoint script 2025-07-25 04:39:30 +00:00
57c4070bed Implement code changes to enhance functionality and improve performance 2025-07-24 22:45:10 +00:00
a5e8cbf982 update package manager to pnpm@10.13.1 and add pnpm workspace configuration 2025-07-24 22:40:28 +00:00
a50be24cc2 Merge branch 'dev' into mobile-support 2025-07-24 17:32:24 -05:00
7412af6626 Merge pull request #24 from sillyangel/dependabot/npm_and_yarn/zod-4.0.5
chore(deps): bump zod from 3.25.70 to 4.0.5
2025-07-24 17:32:07 -05:00
7184f0f01d Merge pull request #25 from sillyangel/dependabot/npm_and_yarn/radix-ui/react-context-menu-2.2.15
chore(deps): bump @radix-ui/react-context-menu from 2.2.4 to 2.2.15
2025-07-24 17:32:03 -05:00
9b8b0df551 Merge pull request #26 from sillyangel/dependabot/npm_and_yarn/radix-ui/react-separator-1.1.7
chore(deps): bump @radix-ui/react-separator from 1.1.1 to 1.1.7
2025-07-24 17:31:58 -05:00
033a510b77 Merge pull request #27 from sillyangel/dependabot/npm_and_yarn/react-hook-form-7.60.0
chore(deps): bump react-hook-form from 7.54.2 to 7.60.0
2025-07-24 17:31:39 -05:00
d98486a9a6 Merge pull request #29 from sillyangel/dependabot/npm_and_yarn/dev-0a4cb714a2
chore(deps-dev): bump the dev group across 1 directory with 3 updates
2025-07-24 17:31:13 -05:00
d207da743f Merge pull request #30 from sillyangel/dependabot/npm_and_yarn/npm_and_yarn-e04d5d616f
chore(deps): bump form-data from 4.0.3 to 4.0.4 in the npm_and_yarn group
2025-07-24 17:31:01 -05:00
c8bc5e80d9 feat: enhance SongRecommendations component for mobile and desktop views, add Apple Touch Icons and viewport settings 2025-07-24 22:03:58 +00:00
abf29caacb feat: implement swipe gesture controls for mobile audio player and enhance theme color handling 2025-07-23 16:10:11 +00:00
abfe2bb3ef feat: optimize cover art URLs for songs and playlists with dynamic sizing 2025-07-23 15:56:27 +00:00
8906b2d81e feat: implement responsive album layout for mobile and desktop views, add debugging configurations for Next.js 2025-07-23 15:37:50 +00:00
3a3c065916 feat: add responsive image size hooks and utility functions for optimal image sizing 2025-07-23 15:23:09 +00:00
fccf3c5d13 feat: enhance FullScreenPlayer with improved lyric scrolling and background styling for mobile 2025-07-23 05:50:01 +00:00
31f8f5dbee feat: add iOS togglefavorite action and enhance mobile player layout with tab navigation 2025-07-23 05:13:06 +00:00
bbdee30f92 fix: update cover art URL size for improved image quality in audio player and playlist 2025-07-23 04:47:47 +00:00
463be90779 feat: enhance mobile audio player with initialization and styling improvements 2025-07-23 04:05:55 +00:00
dependabot[bot]
6953add0c2 chore(deps): bump form-data in the npm_and_yarn group
Bumps the npm_and_yarn group with 1 update: [form-data](https://github.com/form-data/form-data).


Updates `form-data` from 4.0.3 to 4.0.4
- [Release notes](https://github.com/form-data/form-data/releases)
- [Changelog](https://github.com/form-data/form-data/blob/master/CHANGELOG.md)
- [Commits](https://github.com/form-data/form-data/compare/v4.0.3...v4.0.4)

---
updated-dependencies:
- dependency-name: form-data
  dependency-version: 4.0.4
  dependency-type: indirect
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-07-22 11:10:09 +00:00
dependabot[bot]
599ce057c8 chore(deps-dev): bump the dev group across 1 directory with 3 updates
Bumps the dev group with 3 updates in the / directory: [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node), [eslint](https://github.com/eslint/eslint) and [eslint-config-next](https://github.com/vercel/next.js/tree/HEAD/packages/eslint-config-next).


Updates `@types/node` from 24.0.10 to 24.0.15
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

Updates `eslint` from 9.30.1 to 9.31.0
- [Release notes](https://github.com/eslint/eslint/releases)
- [Changelog](https://github.com/eslint/eslint/blob/main/CHANGELOG.md)
- [Commits](https://github.com/eslint/eslint/compare/v9.30.1...v9.31.0)

Updates `eslint-config-next` from 15.3.5 to 15.4.2
- [Release notes](https://github.com/vercel/next.js/releases)
- [Changelog](https://github.com/vercel/next.js/blob/canary/release.js)
- [Commits](https://github.com/vercel/next.js/commits/v15.4.2/packages/eslint-config-next)

---
updated-dependencies:
- dependency-name: "@types/node"
  dependency-version: 24.0.15
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev
- dependency-name: eslint
  dependency-version: 9.31.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev
- dependency-name: eslint-config-next
  dependency-version: 15.4.2
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-07-21 00:20:32 +00:00
a6e65888ab fix: correct image qualities configuration in next.config.mjs 2025-07-20 21:51:32 +00:00
f494ff57d4 fix: update NEXT_PUBLIC_COMMIT_SHA in environment file 2025-07-20 21:24:41 +00:00
ea21150112 Revert "feat: add BottomNavigation and page components"
This reverts commit f4ebfcf478.
2025-07-14 23:16:59 +00:00
f4ebfcf478 feat: add BottomNavigation and page components 2025-07-14 23:15:42 +00:00
dependabot[bot]
a9dd78b5a9 chore(deps): bump @radix-ui/react-separator from 1.1.1 to 1.1.7
Bumps [@radix-ui/react-separator](https://github.com/radix-ui/primitives) from 1.1.1 to 1.1.7.
- [Changelog](https://github.com/radix-ui/primitives/blob/main/release-process.md)
- [Commits](https://github.com/radix-ui/primitives/commits)

---
updated-dependencies:
- dependency-name: "@radix-ui/react-separator"
  dependency-version: 1.1.7
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-07-14 00:55:10 +00:00
dependabot[bot]
2c4798f63f chore(deps): bump react-hook-form from 7.54.2 to 7.60.0
Bumps [react-hook-form](https://github.com/react-hook-form/react-hook-form) from 7.54.2 to 7.60.0.
- [Release notes](https://github.com/react-hook-form/react-hook-form/releases)
- [Changelog](https://github.com/react-hook-form/react-hook-form/blob/master/CHANGELOG.md)
- [Commits](https://github.com/react-hook-form/react-hook-form/compare/v7.54.2...v7.60.0)

---
updated-dependencies:
- dependency-name: react-hook-form
  dependency-version: 7.60.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-07-14 00:55:10 +00:00
dependabot[bot]
69c271313b chore(deps): bump @radix-ui/react-context-menu from 2.2.4 to 2.2.15
Bumps [@radix-ui/react-context-menu](https://github.com/radix-ui/primitives) from 2.2.4 to 2.2.15.
- [Changelog](https://github.com/radix-ui/primitives/blob/main/release-process.md)
- [Commits](https://github.com/radix-ui/primitives/commits)

---
updated-dependencies:
- dependency-name: "@radix-ui/react-context-menu"
  dependency-version: 2.2.15
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-07-14 00:55:07 +00:00
dependabot[bot]
67f8458a99 chore(deps): bump zod from 3.25.70 to 4.0.5
Bumps [zod](https://github.com/colinhacks/zod) from 3.25.70 to 4.0.5.
- [Release notes](https://github.com/colinhacks/zod/releases)
- [Commits](https://github.com/colinhacks/zod/compare/v3.25.70...v4.0.5)

---
updated-dependencies:
- dependency-name: zod
  dependency-version: 4.0.5
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-07-14 00:54:18 +00:00
6e4a320097 fix: update mice service image version and port configuration in docker-compose 2025-07-12 18:27:00 +00:00
f09ac2a535 fix: update healthcheck URL for mice service in docker-compose 2025-07-12 18:26:53 +00:00
437640c9a9 feat: implement library page with recent albums and navigation links 2025-07-12 18:20:56 +00:00
3eb16a7b7a fix: adjust z-index values for fullscreen player elements to improve layering 2025-07-11 21:42:32 +00:00
c101ac79eb feat: implement bottom navigation for mobile and enhance audio player with media session support 2025-07-11 21:34:57 +00:00
14d5036e8b feat: enhance mobile experience with responsive audio player and navigation improvements 2025-07-11 20:47:56 +00:00
d8a853401f fix: update example environment variables for mice service in docker-compose 2025-07-11 05:11:36 +00:00
00bd099b26 fix: update mice service image version and comment out sensitive environment variables in docker-compose example 2025-07-11 05:08:11 +00:00
732b3a84c0 Merge pull request #23 from sillynano/dev
remove this worthless piece of junk
2025-07-10 23:27:54 -05:00
Sillynano
caac806baf remove this worthless piece of junk
sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma sigma
lebron
2025-07-10 23:26:14 -05:00
9f3fa3fccb Update release.yml 2025-07-10 19:02:53 -05:00
aa11307c43 Update release.yml 2025-07-10 18:56:00 -05:00
3bcfd58a1c fix: comment out Docker Hub Description step in nightly and release workflows 2025-07-10 23:41:27 +00:00
cd18a844b3 fix: update Docker Hub password reference to use DOCKERHUB_TOKEN instead of DOCKERHUB_PASSWORD 2025-07-10 23:38:33 +00:00
08416f1d3a fix: update Docker Hub username reference to use vars instead of secrets in nightly and release workflows 2025-07-10 23:32:31 +00:00
af218ee9d5 fix: update Dockerfile to use Node.js 20 Alpine and correct commit SHA in .env.local; enhance nightly and release workflows with Docker Hub description and caching improvements 2025-07-10 23:19:04 +00:00
35febc578f feat: update changelog for July Major Update with new features, fixes, and breaking changes 2025-07-10 21:19:39 +00:00
e27fd25d65 feat: update app version to 2025.07.10 and add changelog for July Major Update 2025-07-10 20:52:58 +00:00
20317afa74 feat: implement cache management system with statistics and clearing functionality 2025-07-10 20:51:22 +00:00
3c13c13143 feat: add song recommendations component with loading state and shuffle functionality 2025-07-10 19:55:02 +00:00
52e465d2cf fix: prevent card content from breaking inside on settings page 2025-07-10 19:34:14 +00:00
98f5198a20 feat: implement settings management component with import/export functionality 2025-07-10 19:26:32 +00:00
da58c49e6e fix: update layout settings and remove unused SidebarLayoutSettings component 2025-07-10 18:33:58 +00:00
3734f67100 feat: integrate dnd-kit for sidebar customization and reordering functionality
- Added dnd-kit dependencies to package.json and pnpm-lock.yaml.
- Implemented SidebarCustomization component using dnd-kit for drag-and-drop reordering of sidebar items.
- Created SortableItem component for individual sidebar items with visibility toggle.
- Enhanced SidebarCustomizer component with drag-and-drop support using react-beautiful-dnd.
- Updated sidebar-new component to include dynamic shortcuts for recently played albums and playlists.
- Improved user experience with import/export settings functionality and toast notifications.
2025-07-10 17:46:59 +00:00
59aae6ea31 feat: add sidebar layout settings with drag-and-drop functionality and import/export options 2025-07-10 16:56:00 +00:00
5653460e06 feat: implement recently played albums and sidebar shortcut preferences 2025-07-10 16:35:11 +00:00
31aec81e8e fix: remove unused Star button from AlbumPage component 2025-07-10 16:21:42 +00:00
13ec6a2e32 :3 2025-07-09 17:09:37 -05:00
7a1cb298bc feat: Add demo server connection functionality to LoginForm 2025-07-09 21:42:57 +00:00
4cc59b4c1f feat: Enhance sidebar functionality and favorite albums feature
- Updated GitHub workflows to include additional metadata in labels for Docker images.
- Modified Dockerfile to copy README.md into the app directory for documentation purposes.
- Added favorite albums functionality in the album page, allowing users to mark albums as favorites.
- Improved AudioPlayer component to save playback position more frequently.
- Refactored sidebar component to include a favorites section and improved navigation.
- Introduced useFavoriteAlbums hook to manage favorite albums state and local storage.
- Updated settings page to allow users to toggle sidebar visibility.
2025-07-09 21:39:16 +00:00
53bbbe1801 fix: update NEXT_PUBLIC_COMMIT_SHA and remove unused qualities from next.config.mjs; add empty chart component 2025-07-09 20:40:31 +00:00
2d88f6371e Merge pull request #20 from sillyangel/dependabot/npm_and_yarn/tailwind-merge-3.3.1
chore(deps): bump tailwind-merge from 2.6.0 to 3.3.1
2025-07-08 13:05:52 -05:00
fe152fdb03 Merge pull request #19 from sillyangel/dependabot/npm_and_yarn/radix-ui/react-select-2.2.5
chore(deps): bump @radix-ui/react-select from 2.1.4 to 2.2.5
2025-07-08 13:05:45 -05:00
acc8148e27 Merge pull request #18 from sillyangel/dependabot/npm_and_yarn/radix-ui/react-tabs-1.1.12
chore(deps): bump @radix-ui/react-tabs from 1.1.2 to 1.1.12
2025-07-08 13:05:36 -05:00
88bbf7df30 Merge pull request #16 from sillyangel/dependabot/npm_and_yarn/dev-c3327defff
chore(deps-dev): bump eslint-config-next from 15.3.4 to 15.3.5 in the dev group
2025-07-08 13:05:28 -05:00
562041da02 Merge pull request #17 from sillyangel/dependabot/npm_and_yarn/lucide-react-0.525.0
chore(deps): bump lucide-react from 0.469.0 to 0.525.0
2025-07-08 13:05:20 -05:00
dependabot[bot]
047a8d6ef8 chore(deps): bump tailwind-merge from 2.6.0 to 3.3.1
Bumps [tailwind-merge](https://github.com/dcastil/tailwind-merge) from 2.6.0 to 3.3.1.
- [Release notes](https://github.com/dcastil/tailwind-merge/releases)
- [Commits](https://github.com/dcastil/tailwind-merge/compare/v2.6.0...v3.3.1)

---
updated-dependencies:
- dependency-name: tailwind-merge
  dependency-version: 3.3.1
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-07-07 00:51:24 +00:00
dependabot[bot]
e4da8e48d4 chore(deps): bump @radix-ui/react-select from 2.1.4 to 2.2.5
Bumps [@radix-ui/react-select](https://github.com/radix-ui/primitives) from 2.1.4 to 2.2.5.
- [Changelog](https://github.com/radix-ui/primitives/blob/main/release-process.md)
- [Commits](https://github.com/radix-ui/primitives/commits)

---
updated-dependencies:
- dependency-name: "@radix-ui/react-select"
  dependency-version: 2.2.5
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-07-07 00:51:10 +00:00
dependabot[bot]
d8411ce53f chore(deps): bump @radix-ui/react-tabs from 1.1.2 to 1.1.12
Bumps [@radix-ui/react-tabs](https://github.com/radix-ui/primitives) from 1.1.2 to 1.1.12.
- [Changelog](https://github.com/radix-ui/primitives/blob/main/release-process.md)
- [Commits](https://github.com/radix-ui/primitives/commits)

---
updated-dependencies:
- dependency-name: "@radix-ui/react-tabs"
  dependency-version: 1.1.12
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-07-07 00:50:12 +00:00
dependabot[bot]
9d864d5ac6 chore(deps): bump lucide-react from 0.469.0 to 0.525.0
Bumps [lucide-react](https://github.com/lucide-icons/lucide/tree/HEAD/packages/lucide-react) from 0.469.0 to 0.525.0.
- [Release notes](https://github.com/lucide-icons/lucide/releases)
- [Commits](https://github.com/lucide-icons/lucide/commits/0.525.0/packages/lucide-react)

---
updated-dependencies:
- dependency-name: lucide-react
  dependency-version: 0.525.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-07-07 00:49:55 +00:00
dependabot[bot]
8fed126d35 chore(deps-dev): bump eslint-config-next in the dev group
Bumps the dev group with 1 update: [eslint-config-next](https://github.com/vercel/next.js/tree/HEAD/packages/eslint-config-next).


Updates `eslint-config-next` from 15.3.4 to 15.3.5
- [Release notes](https://github.com/vercel/next.js/releases)
- [Changelog](https://github.com/vercel/next.js/blob/canary/release.js)
- [Commits](https://github.com/vercel/next.js/commits/v15.3.5/packages/eslint-config-next)

---
updated-dependencies:
- dependency-name: eslint-config-next
  dependency-version: 15.3.5
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-07-07 00:49:36 +00:00
c1541e6a12 fix: update NEXT_PUBLIC_COMMIT_SHA and improve layout for Album and Menu components 2025-07-06 00:53:17 +00:00
e531544dea feat: more fonts 2025-07-03 21:01:04 -05:00
00db487253 style: update layout for Favorites page to ensure consistent container usage 2025-07-03 17:53:54 +00:00
35d336282e style: update button variants and layout for consistency across components 2025-07-03 17:36:34 +00:00
a8311fb4ef fix: update NEXT_PUBLIC_COMMIT_SHA and enhance layout for various pages 2025-07-03 17:03:43 +00:00
54a268f485 style: enhance layout and typography for Browse, Favorites, and Settings pages 2025-07-03 16:01:06 +00:00
7b622cb1ec feat: add Tooltip component and related hooks for improved UI interactions
- Implemented Tooltip component using Radix UI for better accessibility and customization.
- Created TooltipProvider, TooltipTrigger, and TooltipContent for modular usage.
- Added useIsMobile hook to detect mobile devices based on screen width.
- Updated themes with new color variables for better design consistency across the application.
2025-07-03 15:34:53 +00:00
f25b4dcac1 fix: update NEXT_PUBLIC_COMMIT_SHA in .env.local and add suppressHydrationWarning to html tag 2025-07-03 03:48:09 +00:00
579eb740c0 chore: update Tailwind CSS from 3.4.15, to 4.1.11
- Changed the PostCSS configuration to use '@tailwindcss/postcss' instead of 'tailwindcss'.
- Deleted the Tailwind configuration file as it is no longer needed.
2025-07-03 02:53:19 +00:00
95e3682228 refactor: remove artifact attestation step from nightly build workflow 2025-07-03 02:35:11 +00:00
8e49550561 fix: update Docker Hub login action to use vars for username 2025-07-03 02:32:37 +00:00
7b802feaa5 Merge pull request #12 from sillyangel/dependabot/npm_and_yarn/zod-3.25.70
chore(deps): bump zod from 3.24.1 to 3.25.70
2025-07-02 21:12:22 -05:00
6ae34fc7d9 Merge pull request #11 from sillyangel/dependabot/npm_and_yarn/dev-7e4a0c1930
chore(deps-dev): bump the dev group with 3 updates
2025-07-02 21:12:09 -05:00
dependabot[bot]
32cddd49a6 chore(deps-dev): bump the dev group with 3 updates
Bumps the dev group with 3 updates: [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node), [eslint](https://github.com/eslint/eslint) and [typescript](https://github.com/microsoft/TypeScript).


Updates `@types/node` from 22.10.5 to 24.0.10
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

Updates `eslint` from 9.17.0 to 9.30.1
- [Release notes](https://github.com/eslint/eslint/releases)
- [Changelog](https://github.com/eslint/eslint/blob/main/CHANGELOG.md)
- [Commits](https://github.com/eslint/eslint/compare/v9.17.0...v9.30.1)

Updates `typescript` from 5.7.3 to 5.8.3
- [Release notes](https://github.com/microsoft/TypeScript/releases)
- [Changelog](https://github.com/microsoft/TypeScript/blob/main/azure-pipelines.release-publish.yml)
- [Commits](https://github.com/microsoft/TypeScript/compare/v5.7.3...v5.8.3)

---
updated-dependencies:
- dependency-name: "@types/node"
  dependency-version: 24.0.10
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: dev
- dependency-name: eslint
  dependency-version: 9.30.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev
- dependency-name: typescript
  dependency-version: 5.8.3
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-07-03 01:58:32 +00:00
4ed738af39 Merge pull request #13 from sillyangel/dependabot/npm_and_yarn/postcss-8.5.6
chore(deps-dev): bump postcss from 8.4.49 to 8.5.6
2025-07-02 20:56:46 -05:00
043379f18d Merge pull request #15 from sillyangel/dependabot/npm_and_yarn/radix-ui/react-menubar-1.1.15
chore(deps): bump @radix-ui/react-menubar from 1.1.4 to 1.1.15
2025-07-02 20:56:30 -05:00
dependabot[bot]
cf38776623 chore(deps): bump @radix-ui/react-menubar from 1.1.4 to 1.1.15
Bumps [@radix-ui/react-menubar](https://github.com/radix-ui/primitives) from 1.1.4 to 1.1.15.
- [Changelog](https://github.com/radix-ui/primitives/blob/main/release-process.md)
- [Commits](https://github.com/radix-ui/primitives/commits)

---
updated-dependencies:
- dependency-name: "@radix-ui/react-menubar"
  dependency-version: 1.1.15
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-07-03 01:55:41 +00:00
dependabot[bot]
92c857b947 chore(deps-dev): bump postcss from 8.4.49 to 8.5.6
Bumps [postcss](https://github.com/postcss/postcss) from 8.4.49 to 8.5.6.
- [Release notes](https://github.com/postcss/postcss/releases)
- [Changelog](https://github.com/postcss/postcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/postcss/postcss/compare/8.4.49...8.5.6)

---
updated-dependencies:
- dependency-name: postcss
  dependency-version: 8.5.6
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-07-03 01:55:09 +00:00
dependabot[bot]
e585cb536b chore(deps): bump zod from 3.24.1 to 3.25.70
Bumps [zod](https://github.com/colinhacks/zod) from 3.24.1 to 3.25.70.
- [Release notes](https://github.com/colinhacks/zod/releases)
- [Commits](https://github.com/colinhacks/zod/compare/v3.24.1...v3.25.70)

---
updated-dependencies:
- dependency-name: zod
  dependency-version: 3.25.70
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-07-03 01:54:58 +00:00
c81de7ec7f fix: update dependabot configuration and add Docker Hub registry to nightly workflow 2025-07-03 01:53:04 +00:00
a2547a2639 Create dependabot.yml 2025-07-02 20:49:42 -05:00
3ffa68f31d Merge pull request #8 from sillyangel/dependabot/npm_and_yarn/npm_and_yarn-4692130362
chore(deps): bump axios from 1.7.9 to 1.8.2 in the npm_and_yarn group across 1 directory
2025-07-02 20:33:09 -05:00
dependabot[bot]
a5f85ac347 chore(deps): bump axios in the npm_and_yarn group across 1 directory
Bumps the npm_and_yarn group with 1 update in the / directory: [axios](https://github.com/axios/axios).


Updates `axios` from 1.7.9 to 1.8.2
- [Release notes](https://github.com/axios/axios/releases)
- [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md)
- [Commits](https://github.com/axios/axios/compare/v1.7.9...v1.8.2)

---
updated-dependencies:
- dependency-name: axios
  dependency-version: 1.8.2
  dependency-type: direct:production
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-07-03 01:32:19 +00:00
278a9d0aea Merge pull request #10 from sillyangel/dependabot/npm_and_yarn/npm_and_yarn-6ea9762674
chore(deps): bump brace-expansion from 1.1.11 to 1.1.12 in the npm_and_yarn group across 1 directory
2025-07-02 20:30:58 -05:00
70bfdc8406 fix: update apostrophe in message for Navidrome server prompt 2025-07-03 01:29:30 +00:00
bae415ddeb fix: rename workflow from Development to Release Docker Image 2025-07-03 01:26:45 +00:00
e9a5512490 feat: add nightly and release workflows for Docker image publishing 2025-07-03 01:24:11 +00:00
f490062ac8 feat: update Docker image references from GHCR to Docker Hub and enhance documentation 2025-07-03 01:04:04 +00:00
dependabot[bot]
332229b734 chore(deps): bump brace-expansion
Bumps the npm_and_yarn group with 1 update in the / directory: [brace-expansion](https://github.com/juliangruber/brace-expansion).


Updates `brace-expansion` from 1.1.11 to 1.1.12
- [Release notes](https://github.com/juliangruber/brace-expansion/releases)
- [Commits](https://github.com/juliangruber/brace-expansion/compare/1.1.11...v1.1.12)

---
updated-dependencies:
- dependency-name: brace-expansion
  dependency-version: 1.1.12
  dependency-type: indirect
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-07-03 00:50:28 +00:00
a29c19c6aa feat: add PWA shortcuts for music playback actions and update loading skeletons 2025-07-03 00:06:47 +00:00
a00bf3e365 Refactor code structure for improved readability and maintainability 2025-07-02 23:49:27 +00:00
b668c1b6fb Refactor code structure for improved readability and maintainability 2025-07-02 23:06:49 +00:00
166 changed files with 26660 additions and 4442 deletions

View File

@@ -2,7 +2,6 @@ node_modules
.next
.git
.gitignore
README.md
.env.local
.env.example
*.log

View File

@@ -11,10 +11,6 @@ PORT=3000
# NAVIDROME_USERNAME=your_username
# NAVIDROME_PASSWORD=your_password
# PostHog Analytics (optional)
POSTHOG_KEY=
POSTHOG_HOST=
# Example for external Navidrome server:
# NAVIDROME_URL=https://your-navidrome-server.com
# NAVIDROME_USERNAME=your_username

View File

@@ -3,15 +3,9 @@ NEXT_PUBLIC_NAVIDROME_URL=http://localhost:4533
NEXT_PUBLIC_NAVIDROME_USERNAME=your_username
NEXT_PUBLIC_NAVIDROME_PASSWORD=your_password
# PostHog Analytics (optional)
NEXT_PUBLIC_POSTHOG_KEY=your_posthog_key
NEXT_PUBLIC_POSTHOG_HOST=https://us.i.posthog.com
# For Docker deployment, use these variable names in your .env file:
# NAVIDROME_URL=https://your-navidrome-server.com
# NAVIDROME_USERNAME=your_username
# NAVIDROME_PASSWORD=your_password
# POSTHOG_KEY=your_posthog_key
# POSTHOG_HOST=https://us.i.posthog.com
# HOST_PORT=3000
# PORT=3000

View File

@@ -1 +1 @@
NEXT_PUBLIC_COMMIT_SHA=a854604
NEXT_PUBLIC_COMMIT_SHA=9427a2a

15
.github/dependabot.yml vendored Normal file
View File

@@ -0,0 +1,15 @@
version: 2
updates:
- package-ecosystem: "npm"
directory: "/"
schedule:
interval: "weekly"
groups:
runtime:
patterns:
- "!@types/*"
dev:
patterns:
- "@types/*"
- "eslint*"
- "typescript"

132
.github/workflows/nightly.yml vendored Normal file
View File

@@ -0,0 +1,132 @@
name: Development Docker Image (Nightly)
on:
schedule:
# Run every night at 5:00 UTC
- cron: '0 5 * * *'
push:
branches:
- dev
workflow_dispatch: # Allow manual triggering
env:
REGISTRY: docker.io
IMAGE_NAME: sillyangel/mice
jobs:
check_changes:
runs-on: ubuntu-latest
outputs:
should_build: ${{ steps.check.outputs.should_build }}
steps:
- name: Check out the repo
uses: actions/checkout@v4
with:
fetch-depth: 0 # Get full history to check for changes
ref: dev # Always checkout dev branch
- name: Check for changes since last nightly build
id: check
run: |
# Get the last commit hash from the previous nightly build
LAST_NIGHTLY_COMMIT=$(gh api repos/${{ github.repository }}/actions/runs \
--jq '.workflow_runs[] | select(.name == "Development Docker Image (Nightly)" and .conclusion == "success") | .head_sha' \
| head -1 || echo "")
CURRENT_COMMIT=${{ github.sha }}
echo "Last nightly commit: $LAST_NIGHTLY_COMMIT"
echo "Current commit: $CURRENT_COMMIT"
# If we don't have a previous commit or commits are different, we should build
if [ -z "$LAST_NIGHTLY_COMMIT" ] || [ "$LAST_NIGHTLY_COMMIT" != "$CURRENT_COMMIT" ]; then
echo "Changes detected or first nightly build"
echo "should_build=true" >> $GITHUB_OUTPUT
else
echo "No changes since last nightly build"
echo "should_build=false" >> $GITHUB_OUTPUT
fi
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
push_to_registry:
runs-on: ubuntu-latest
needs: check_changes
if: needs.check_changes.outputs.should_build == 'true' && github.ref_name == 'dev'
permissions:
packages: write
contents: read
attestations: write
id-token: write
steps:
- name: Check out the repo
uses: actions/checkout@v4
with:
ref: dev # Always checkout dev branch
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
- name: Get version from package.json
id: app_version
run: |
VERSION=$(node -p "require('./package.json').version")
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
- name: Docker metadata (tags, labels)
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.IMAGE_NAME }}
tags: |
type=raw,value=nightly
type=raw,value=dev-latest
type=sha,prefix=dev-
labels: |
org.opencontainers.image.created=${{ github.event.head_commit.timestamp }}
org.opencontainers.image.licenses=MIT
org.opencontainers.image.description=Nightly development build
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Setup Docker buildx
uses: docker/setup-buildx-action@v3
with:
driver-opts: |
network=host
- name: Build and push
id: build
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: |
${{ steps.meta.outputs.labels }}
org.opencontainers.image.description=$(cat README.md | head -20 | tr '\n' ' ')
org.opencontainers.image.documentation=https://github.com/sillyangel/stillnavidrome/blob/main/README.md
platforms: |
linux/amd64
linux/arm64/v8
cache-from: |
type=gha,scope=deps-only
cache-to: |
type=gha,mode=max,scope=deps-only
# - name: Docker Hub Description
# uses: peter-evans/dockerhub-description@v4
# with:
# username: ${{ vars.DOCKERHUB_USERNAME }}
# password: ${{ secrets.DOCKERHUB_TOKEN }}
# repository: sillyangel/mice

View File

@@ -1,77 +0,0 @@
name: Publish Docker Image
on:
push:
tags:
- '[0-9][0-9][0-9][0-9].[0-9]*.[0-9]*'
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
push_to_registry:
runs-on: ubuntu-latest
permissions:
packages: write
contents: read
attestations: write
id-token: write
steps:
- name: Check out the repo
uses: actions/checkout@v4
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
- name: Get version from package.json
id: app_version
run: |
VERSION=$(node -p "require('./package.json').version")
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
- name: Docker metadata (tags, labels)
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' || github.ref == 'refs/heads/master' || startsWith(github.ref, 'refs/tags/') }}
type=raw,value=${{ steps.app_version.outputs.version }}
type=raw,value=${{ github.sha }}
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Setup Docker buildx
uses: docker/setup-buildx-action@v3
- name: Build and push
id: build
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
platforms: |
linux/amd64
linux/arm64/v8
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Generate artifact attestation
uses: actions/attest-build-provenance@v1
with:
subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME}}
subject-digest: ${{ steps.build.outputs.digest }}
push-to-registry: true

90
.github/workflows/release.yml vendored Normal file
View File

@@ -0,0 +1,90 @@
name: Release Docker Image
on:
push:
tags:
- '[0-9][0-9][0-9][0-9].[0-9][0-9].[0-9][0-9]'
env:
REGISTRY: docker.io
IMAGE_NAME: sillyangel/mice
jobs:
push_to_registry:
runs-on: ubuntu-latest
permissions:
packages: write
contents: read
attestations: write
id-token: write
steps:
- name: Check out the repo
uses: actions/checkout@v4
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: sillyangel
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
- name: Get version from package.json
id: app_version
run: |
VERSION=$(node -p "require('./package.json').version")
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
- name: Docker metadata (tags, labels)
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.IMAGE_NAME }}
tags: |
type=raw,value=latest,enable=${{ github.ref_name == 'main' }}
type=raw,value=${{ steps.app_version.outputs.version }},enable=${{ startsWith(github.ref, 'refs/tags/') }}
type=raw,value=${{ github.ref_name }},enable=${{ startsWith(github.ref, 'refs/tags/') }}
type=sha,prefix=main-,enable=${{ github.ref_name == 'main' }}
labels: |
org.opencontainers.image.created=${{ github.event.head_commit.timestamp }}
org.opencontainers.image.licenses=MIT
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Setup Docker buildx
uses: docker/setup-buildx-action@v3
with:
driver-opts: |
network=host
- name: Build and push
id: build
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: |
${{ steps.meta.outputs.labels }}
org.opencontainers.image.description=$(cat README.md | head -20 | tr '\n' ' ')
org.opencontainers.image.documentation=https://github.com/sillyangel/stillnavidrome/blob/main/README.md
platforms: |
linux/amd64
linux/arm64/v8
cache-from: |
type=gha,scope=deps-only
cache-to: |
type=gha,mode=max,scope=deps-only
# - name: Docker Hub Description
# uses: peter-evans/dockerhub-description@v4
# with:
# username: ${{ vars.DOCKERHUB_USERNAME }}
# password: ${{ secrets.DOCKERHUB_TOKEN }}
# repository: sillyangel/mice

5
.gitignore vendored
View File

@@ -70,6 +70,11 @@ next-env.d.ts
# database
still-database/
# Debug related files
scripts/sleep-debug.js
.vscode/launch.json
source-map-support/
.next/
certificates
.vercel

85
.vscode/launch.json vendored Normal file
View File

@@ -0,0 +1,85 @@
{
"version": "0.2.0",
"configurations": [
{
"type": "node",
"request": "launch",
"name": "Merow",
"program": "${workspaceFolder}/scripts/sleep-debug.js",
"skipFiles": [
"<node_internals>/**"
],
"console": "integratedTerminal",
"sourceMaps": true,
"resolveSourceMapLocations": [
"${workspaceFolder}/**",
"!**/node_modules/**"
],
"trace": true
},
{
"name": "Debug: Next.js Development",
"type": "node",
"request": "launch",
"program": "${workspaceFolder}/node_modules/.bin/next",
"args": ["dev"],
"console": "integratedTerminal",
"env": {
"NODE_ENV": "development"
},
"runtimeExecutable": "pnpm",
"runtimeArgs": ["run", "dev"],
"skipFiles": ["<node_internals>/**"],
"resolveSourceMapLocations": [
"${workspaceFolder}/**",
"!**/node_modules/**"
],
"serverReadyAction": {
"action": "openExternally",
"pattern": "http://localhost:40625"
}
},
{
"name": "Debug: Development (Verbose)",
"type": "node",
"request": "launch",
"runtimeExecutable": "pnpm",
"runtimeArgs": ["run", "dev"],
"cwd": "${workspaceFolder}",
"env": {
"NODE_ENV": "development",
"DEBUG": "*",
"NEXT_TELEMETRY_DISABLED": "1"
},
"console": "integratedTerminal",
"skipFiles": ["<node_internals>/**"],
"resolveSourceMapLocations": [
"${workspaceFolder}/**",
"!**/node_modules/**"
],
"serverReadyAction": {
"action": "openExternally",
"pattern": "http://localhost:40625"
}
},
{
"name": "Debug: Next.js Production",
"type": "node",
"request": "launch",
"program": "${workspaceFolder}/node_modules/.bin/next",
"args": ["start"],
"console": "integratedTerminal",
"env": {
"NODE_ENV": "production"
},
"preLaunchTask": "Build: Production Build Only",
"runtimeExecutable": "pnpm",
"runtimeArgs": ["run", "start"],
"skipFiles": ["<node_internals>/**"],
"serverReadyAction": {
"action": "openExternally",
"pattern": "http://localhost:40625"
}
}
]
}

114
.vscode/tasks.json vendored Normal file
View File

@@ -0,0 +1,114 @@
{
"version": "2.0.0",
"tasks": [
{
"label": "Dev: Start Development Server",
"type": "shell",
"command": "pnpm",
"args": [
"run",
"dev"
],
"group": {
"kind": "build",
"isDefault": true
},
"isBackground": true,
"problemMatcher": [
"$tsc-watch"
],
"presentation": {
"echo": true,
"reveal": "always",
"focus": false,
"panel": "new",
"showReuseMessage": true,
"clear": false
},
"options": {
"env": {
"NODE_ENV": "development"
}
}
},
{
"label": "Prod: Build and Start Production",
"type": "shell",
"command": "bash",
"args": [
"-c",
"pnpm run build && pnpm run start"
],
"group": "build",
"presentation": {
"echo": true,
"reveal": "always",
"focus": true,
"panel": "new",
"showReuseMessage": true,
"clear": true
},
"options": {
"env": {
"NODE_ENV": "production"
}
},
"problemMatcher": ["$tsc"],
"dependsOrder": "sequence"
},
{
"label": "Debug: Development with Debug Info",
"type": "shell",
"command": "pnpm",
"args": [
"run",
"dev"
],
"group": {
"kind": "test",
"isDefault": false
},
"isBackground": true,
"presentation": {
"echo": true,
"reveal": "always",
"focus": false,
"panel": "new",
"showReuseMessage": true,
"clear": false
},
"options": {
"env": {
"NODE_ENV": "development",
"DEBUG": "*",
"NEXT_TELEMETRY_DISABLED": "1"
}
},
"problemMatcher": ["$tsc-watch"]
},
{
"label": "Build: Production Build Only",
"type": "shell",
"command": "pnpm",
"args": [
"run",
"build"
],
"group": "build",
"presentation": {
"echo": true,
"reveal": "always",
"focus": true,
"panel": "new",
"showReuseMessage": true,
"clear": true
},
"options": {
"env": {
"NODE_ENV": "production"
}
},
"problemMatcher": ["$tsc"]
}
]
}

View File

@@ -8,7 +8,7 @@ This application can be easily deployed using Docker with configurable environme
```bash
# Run using pre-built image (app will prompt for Navidrome config)
docker run -p 3000:3000 ghcr.io/sillyangel/mice:latest
docker run -p 3000:3000 sillyangel/mice:latest
# Or build locally
docker build -t mice .
@@ -20,7 +20,7 @@ docker run -p 3000:3000 \
-e NEXT_PUBLIC_NAVIDROME_USERNAME=your_username \
-e NEXT_PUBLIC_NAVIDROME_PASSWORD=your_password \
-e PORT=3000 \
ghcr.io/sillyangel/mice:latest
sillyangel/mice:latest
```
### Using Docker Compose
@@ -43,7 +43,7 @@ docker run -p 3000:3000 \
docker-compose up -d
```
**Note**: The default docker-compose.yml uses the pre-built image `ghcr.io/sillyangel/mice:latest`.
**Note**: The default docker-compose.yml uses the pre-built image `sillyangel/mice:latest`.
For local development, you can use the override example:
@@ -65,8 +65,6 @@ When running with Docker, use these variable names (without the `NEXT_PUBLIC_` p
- `NAVIDROME_PASSWORD`: Navidrome password (optional - app will prompt if not set)
- `PORT`: Port for the application to listen on (default: `3000`)
- `HOST_PORT`: Host port to map to container port (docker-compose only, default: `3000`)
- `POSTHOG_KEY`: PostHog analytics key (optional)
- `POSTHOG_HOST`: PostHog analytics host (optional)
### Development Environment Variables
@@ -75,8 +73,6 @@ For local development (non-Docker), use these variable names:
- `NEXT_PUBLIC_NAVIDROME_URL`: URL of your Navidrome server
- `NEXT_PUBLIC_NAVIDROME_USERNAME`: Navidrome username
- `NEXT_PUBLIC_NAVIDROME_PASSWORD`: Navidrome password
- `NEXT_PUBLIC_POSTHOG_KEY`: PostHog analytics key (optional)
- `NEXT_PUBLIC_POSTHOG_HOST`: PostHog analytics host (optional)
**Note**: Docker deployment uses a runtime replacement mechanism to inject environment variables, while development uses Next.js's built-in `NEXT_PUBLIC_` variables.
@@ -86,7 +82,7 @@ For local development (non-Docker), use these variable names:
```bash
# Using pre-built image - app will ask for Navidrome server details on first launch
docker run -p 3000:3000 ghcr.io/sillyangel/mice:latest
docker run -p 3000:3000 sillyangel/mice:latest
# Or build locally
docker build -t mice .
@@ -100,7 +96,7 @@ docker run -p 3000:3000 \
-e NEXT_PUBLIC_NAVIDROME_URL=http://localhost:4533 \
-e NEXT_PUBLIC_NAVIDROME_USERNAME=admin \
-e NEXT_PUBLIC_NAVIDROME_PASSWORD=admin \
ghcr.io/sillyangel/mice:latest
sillyangel/mice:latest
```
### Pre-configured Production Setup
@@ -112,7 +108,7 @@ docker run -p 80:3000 \
-e NEXT_PUBLIC_NAVIDROME_PASSWORD=your_secure_password \
-e PORT=3000 \
--restart unless-stopped \
ghcr.io/sillyangel/mice:latest
sillyangel/mice:latest
```
### Using Environment File

View File

@@ -1,5 +1,5 @@
# Use Node.js 22 Alpine for smaller image size
FROM node:22-alpine
# Use Node.js 20 Alpine for smaller image size
FROM node:20-alpine
# Install pnpm globally
RUN npm install -g pnpm@10.12.4
@@ -16,13 +16,14 @@ RUN pnpm install
# Copy source code
COPY . .
# Copy README.md to the app directory for documentation
COPY README.md /app/
# Set environment variable placeholders during build
# These will be replaced at runtime with actual values
ENV NEXT_PUBLIC_NAVIDROME_URL=NEXT_PUBLIC_NAVIDROME_URL
ENV NEXT_PUBLIC_NAVIDROME_USERNAME=NEXT_PUBLIC_NAVIDROME_USERNAME
ENV NEXT_PUBLIC_NAVIDROME_PASSWORD=NEXT_PUBLIC_NAVIDROME_PASSWORD
ENV NEXT_PUBLIC_POSTHOG_KEY=NEXT_PUBLIC_POSTHOG_KEY
ENV NEXT_PUBLIC_POSTHOG_HOST=NEXT_PUBLIC_POSTHOG_HOST
ENV PORT=3000
# Generate git commit hash for build info (fallback if not available)

View File

@@ -1,68 +0,0 @@
# GitHub Actions Docker Publishing Setup
This repository includes a GitHub Actions workflow that automatically builds and publishes Docker images to GitHub Container Registry (GHCR).
## Workflow Overview
The workflow (`/.github/workflows/publish-docker.yml`) automatically:
1. **Builds** the Docker image using multi-platform support (AMD64 and ARM64)
2. **Publishes** to `ghcr.io/sillyangel/mice`
3. **Tags** images appropriately based on git refs
4. **Caches** layers for faster subsequent builds
5. **Generates** build provenance attestations for security
## Trigger Conditions
The workflow runs on:
- **Push to main/master branch** → Creates `latest` tag
- **Push tags** (e.g., `2025.07.02`) → Creates date-based version tags
- **Pull requests** → Creates PR-specific tags for testing
- **Manual dispatch** → Can be triggered manually from GitHub UI
## Image Tags Generated
Based on different triggers, the workflow creates these tags:
### Main Branch Push
- `ghcr.io/sillyangel/mice:latest`
### Tag Push (e.g., `2025.07.02`)
- `ghcr.io/sillyangel/mice:2025.07.02`
- `ghcr.io/sillyangel/mice:latest`
### Pull Request
- `ghcr.io/sillyangel/mice:pr-123`
## Multi-Platform Support
The workflow builds for multiple architectures:
- `linux/amd64` (Intel/AMD 64-bit)
- `linux/arm64` (ARM 64-bit, Apple Silicon, etc.)
## Usage After Setup
Once the workflow is set up:
1. **Push to main** → New `latest` image published
2. **Create a release** → Versioned images published
3. **Users can pull**: `docker pull ghcr.io/sillyangel/mice:latest`
## Manual Image Building
You can also build and push manually:
```bash
# Build for multiple platforms
docker buildx build --platform linux/amd64,linux/arm64 \
-t ghcr.io/sillyangel/mice:latest \
--push .
# Login first (if needed)
echo $GITHUB_TOKEN | docker login ghcr.io -u USERNAME --password-stdin
```

121
KEYBOARD_SHORTCUTS.md Normal file
View File

@@ -0,0 +1,121 @@
# Keyboard Shortcuts & Queue Management Features
This document outlines the new keyboard shortcuts, queue management, and context menu features added to the music player.
## Keyboard Shortcuts
The following keyboard shortcuts work globally throughout the application:
### Playback Controls
- **Space** - Play/Pause current track
- **→ (Right Arrow)** - Skip to next track
- **← (Left Arrow)** - Skip to previous track
### Volume Controls
- **↑ (Up Arrow)** - Increase volume by 10%
- **↓ (Down Arrow)** - Decrease volume by 10%
- **M** - Toggle mute/unmute
### Navigation
- **/** - Quick search (navigates to search page and focuses input)
### Notes
- Keyboard shortcuts are disabled when typing in input fields
- When in fullscreen player mode, shortcuts are handled by the fullscreen player
- Volume changes are saved to localStorage
## Queue Management
### Drag and Drop Queue Reordering
- **Drag Handle**: Hover over queue items to reveal the grip handle (⋮⋮)
- **Reorder**: Click and drag the handle to reorder tracks in the queue
- **Visual Feedback**: Dragged items become semi-transparent during drag
- **Keyboard Support**: Use Tab to focus items, then Space + Arrow keys to reorder
### Queue Features
- Real-time visual feedback during drag operations
- Maintains playback order after reordering
- Works with both mouse and keyboard navigation
- Accessible drag and drop implementation
## Context Menus (Right-Click)
Right-click on tracks, albums, and artists to access quick actions:
### Track Context Menu
- **Play Now** - Immediately play the selected track
- **Play Next** - Add track to the beginning of the queue
- **Add to Queue** - Add track to the end of the queue
- **Add/Remove from Favorites** - Toggle favorite status
- **Go to Album** - Navigate to the track's album
- **Go to Artist** - Navigate to the track's artist
- **Track Info** - View detailed track information
- **Share** - Share the track
### Album Context Menu
- **Play Album** - Play the entire album from the beginning
- **Add Album to Queue** - Add all album tracks to queue
- **Play Album Next** - Add album tracks to beginning of queue
- **Add to Favorites** - Add album to favorites
- **Go to Artist** - Navigate to the album's artist
- **Album Info** - View detailed album information
- **Share Album** - Share the album
### Artist Context Menu
- **Play All Songs** - Play all songs by the artist
- **Add All to Queue** - Add all artist songs to queue
- **Play All Next** - Add all artist songs to beginning of queue
- **Add to Favorites** - Add artist to favorites
- **Artist Info** - View detailed artist information
- **Share Artist** - Share the artist
## Where to Find These Features
### Keyboard Shortcuts
- Available globally throughout the application
- Work in main player, fullscreen player, and all pages
- Search shortcut (/) works from any page
### Queue Management
- **Queue Page**: `/queue` - Full drag and drop interface
- **Mini Player**: Shows current track and basic controls
- **Fullscreen Player**: Queue management button available
### Context Menus
- **Search Results**: Right-click on any track, album, or artist
- **Album Pages**: Right-click on individual tracks
- **Artist Pages**: Right-click on tracks and albums
- **Queue Page**: Right-click on queued tracks
- **Library Browse**: Right-click on any item
## Technical Implementation
### Components Used
- `useKeyboardShortcuts` hook for global keyboard shortcuts
- `@dnd-kit` for drag and drop functionality
- `@radix-ui/react-context-menu` for context menus
- Custom context menu components for different content types
### Accessibility
- Full keyboard navigation support
- Screen reader compatible
- Focus management
- ARIA labels and descriptions
- High contrast support
## Tips for Users
1. **Keyboard Shortcuts**: Most shortcuts work anywhere in the app, just start typing
2. **Queue Reordering**: Hover over queue items to see the drag handle
3. **Context Menus**: Right-click almost anything to see available actions
4. **Quick Search**: Press `/` from anywhere to jump to search
5. **Volume Control**: Use arrow keys for precise volume adjustment
## Future Enhancements
Potential future additions:
- Custom keyboard shortcut configuration
- More queue management options (clear queue, save as playlist)
- Additional context menu actions (edit metadata, download)
- Gesture support for mobile devices
- Queue templates and smart playlists

168
OFFLINE_DOWNLOADS.md Normal file
View File

@@ -0,0 +1,168 @@
# Offline Downloads Feature
This document describes the offline downloads functionality implemented in the Mice Navidrome client.
## Overview
The offline downloads feature allows users to download music for offline listening using modern web technologies including Service Workers and Cache API, with localStorage fallback for browsers without Service Worker support.
## Components
### 1. Service Worker (`/public/sw.js`)
- Handles audio, image, and API caching
- Manages download operations in the background
- Provides offline audio playback capabilities
- Implements cache-first strategy for downloaded content
### 2. Download Manager Hook (`/hooks/use-offline-downloads.ts`)
- Provides React interface for download operations
- Manages download progress and status
- Handles Service Worker communication
- Provides localStorage fallback for metadata
### 3. Cache Management Component (`/app/components/CacheManagement.tsx`)
- Enhanced to show offline download statistics
- Displays download progress during operations
- Lists downloaded content with removal options
- Shows Service Worker support status
### 4. Offline Indicator Component (`/app/components/OfflineIndicator.tsx`)
- Shows download status for albums and songs
- Provides download/remove buttons
- Displays visual indicators on album artwork
## Features
### Download Capabilities
- **Album Downloads**: Download entire albums with all tracks and artwork
- **Individual Song Downloads**: Download single tracks
- **Progress Tracking**: Real-time download progress with track-by-track updates
- **Error Handling**: Graceful handling of failed downloads with retry options
### Visual Indicators
- **Album Artwork**: Small download icon in top-right corner of downloaded albums
- **Album Pages**: Download buttons and status indicators
- **Song Lists**: Individual download indicators for tracks
- **Library View**: Visual badges showing offline availability
### Offline Storage
- **Service Worker Cache**: True offline storage for audio files and images
- **localStorage Fallback**: Metadata-only storage for limited browser support
- **Progressive Enhancement**: Works in all browsers with varying capabilities
### Cache Management
- **Storage Statistics**: Shows total offline storage usage
- **Content Management**: List and remove downloaded content
- **Cache Cleanup**: Clear expired and unnecessary cache data
- **Progress Monitoring**: Real-time download progress display
## Usage
### Downloading Content
#### From Album Page
1. Navigate to any album page
2. Click the "Download" button (desktop) or small download button (mobile)
3. Monitor progress in the cache management section
4. Downloaded albums show indicators on artwork and in lists
#### From Settings Page
1. Go to Settings → Cache & Offline Downloads
2. View current download statistics
3. Monitor active downloads
4. Manage downloaded content
### Managing Downloads
#### Viewing Downloaded Content
- Settings page shows list of all downloaded albums and songs
- Album artwork displays download indicators
- Individual songs show download status
#### Removing Downloads
- Use the "X" button next to items in the cache management list
- Use "Remove Download" button on album pages
- Clear all cache to remove everything
## Technical Implementation
### Service Worker Features
- **Audio Caching**: Streams are cached using the Subsonic API
- **Image Caching**: Album artwork and avatars cached separately
- **API Caching**: Metadata cached with network-first strategy
- **Background Downloads**: Downloads continue even when page is closed
### Browser Compatibility
- **Full Support**: Modern browsers with Service Worker support
- **Limited Support**: Older browsers get metadata-only caching
- **Progressive Enhancement**: Features gracefully degrade
### Storage Strategy
- **Audio Cache**: Large files stored in Service Worker cache
- **Image Cache**: Artwork cached separately for optimization
- **Metadata Cache**: Song/album information in localStorage
- **Size Management**: Automatic cleanup of old cached content
## Configuration
### Environment Variables
The offline downloads use existing Navidrome configuration:
- `NEXT_PUBLIC_NAVIDROME_URL`
- `NEXT_PUBLIC_NAVIDROME_USERNAME`
- `NEXT_PUBLIC_NAVIDROME_PASSWORD`
### Cache Limits
- Default audio cache: No explicit limit (browser manages)
- Image cache: Optimized sizes based on display requirements
- Metadata: Stored in localStorage with cleanup
## Development Notes
### File Structure
```
hooks/
use-offline-downloads.ts # Main download hook
app/components/
CacheManagement.tsx # Enhanced cache UI
OfflineIndicator.tsx # Download status components
album-artwork.tsx # Updated with indicators
album/[id]/
page.tsx # Enhanced with download buttons
public/
sw.js # Service Worker implementation
```
### API Integration
- Uses existing Navidrome API endpoints
- Leverages Subsonic streaming URLs
- Integrates with current authentication system
- Compatible with existing cache infrastructure
## Future Enhancements
### Planned Features
- **Playlist Downloads**: Download entire playlists
- **Smart Sync**: Automatic download of favorites
- **Storage Limits**: User-configurable storage limits
- **Download Scheduling**: Queue downloads for later
- **Offline Mode Detection**: Automatic offline behavior
### Performance Optimizations
- **Compression**: Audio compression options
- **Quality Selection**: Choose download quality
- **Selective Sync**: Download only specific tracks
- **Background Sync**: Download during idle time
## Troubleshooting
### Common Issues
- **Service Worker not registering**: Check browser console
- **Downloads failing**: Verify Navidrome server connection
- **Storage full**: Clear cache or check browser storage limits
- **Slow downloads**: Check network connection and server performance
### Debug Information
- Browser Developer Tools → Application → Service Workers
- Cache storage inspection in DevTools
- Console logs for download progress and errors
- Network tab for failed requests

View File

@@ -1,50 +1,56 @@
![splash](https://github.com/sillyangel/mice/blob/main/4xnored.png?raw=true)
# mice (project still reworked)
> project still, now with navidrome
<p align="center">
<img src="https://github.com/sillyangel/mice/blob/main/public/icon-512.png?raw=true" alt="Mice Logo" width="120" />
</p>
<h1 align="center"><strong>Mice | Navidrome Client</strong></h1>
> project based on [shadcn/ui](https://github.com/shadcn-ui/ui)'s music template
> Project based on [shadcn/ui](https://github.com/shadcn-ui/ui)'s music template.
This is a modern music streaming web application built with [Next.js](https://nextjs.org/) and [shadcn/ui](https://ui.shadcn.com/), now powered by **Navidrome** for a complete self-hosted music streaming experience.
<!-- this looks like "ai" lol but its not -->
**✨ New**: Migrated from Firebase + static data to **Navidrome/Subsonic** integration for real music streaming!
This is a "Modern" Navidrome (or Subsonic) client built with [Next.js](https://nextjs.org/) and [shadcn/ui](https://ui.shadcn.com/). It creates a beautiful, responsive music streaming web application that connects to your Navidrome server, and fully able to self-host using docker!
### Features
## Features
- 🎵 **Real Music Streaming** via Navidrome/Subsonic API
- 📱 **Modern UI** with shadcn/ui components
- 🎨 **Dynamic Album Artwork** from your music library
- **Favorites** - Star albums, artists, and songs
- 📋 **Playlist Management** - Create and manage playlists
- 🔍 **Search** - Find music across your entire library
- 🎧 **Audio Player** with queue management
- 📊 **Scrobbling** - Track your listening history
- **Real Music Streaming** via Navidrome/Subsonic API
- **Modern UI** with shadcn/ui components
- **Dynamic Album Artwork** from your music library
- **Favorites** - Star albums, artists, and songs
- **Search** - Find music across your entire library
- **Audio Player** with queue management
- **Scrobbling** - Track your listening history
- **Playlist Management** - Create and manage playlists
### Preview
![preview](https://github.com/sillyangel/mice/blob/main/public/screen.png?raw=true)
![preview](https://github.com/sillyangel/mice/blob/main/public/home-preview.png?raw=true)
## Quick Start
### Prerequisites
- [Navidrome](https://www.navidrome.org/) server running
- Node.js 18+ and pnpm
- Node.js 18+
### Setup
1. **Clone and install**
1. **Clone and install the required dependencies**
```bash
git clone https://github.com/sillyangel/project-still.git
cd project-still/
pnpm install
# or npm
npm install
```
2. **Configure Navidrome connection**
## 2. **Configure the Navidrome connection**
First, copy the example environment file:
```bash
cp .env.example .env
```
Edit `.env` with your Navidrome server details:
Next, open the new `.env` file and update it with your Navidrome server credentials:
```env
NEXT_PUBLIC_NAVIDROME_URL=http://localhost:4533
@@ -54,10 +60,23 @@ NEXT_PUBLIC_POSTHOG_KEY=phc_XXXXXXXXXXXXXXXXXX
NEXT_PUBLIC_POSTHOG_HOST=https://us.i.posthog.com
```
> **Tip:** If you dont have your own Navidrome server yet, you can use the public demo credentials:
```env
NEXT_PUBLIC_NAVIDROME_URL=https://demo.navidrome.org
NEXT_PUBLIC_NAVIDROME_USERNAME=demo
NEXT_PUBLIC_NAVIDROME_PASSWORD=demo
```
3. **Run the development server**
```bash
pnpm dev
# or npm
npm run dev
```
Open [http://localhost:40625](http://localhost:40625) in your browser.
@@ -70,7 +89,7 @@ For easy deployment using Docker:
```bash
# Run using pre-built image (app will prompt for Navidrome configuration)
docker run -p 3000:3000 ghcr.io/sillyangel/mice:latest
docker run -p 3000:3000 sillyangel/mice:latest
# Or build locally
docker build -t mice .
@@ -93,14 +112,10 @@ docker run -p 3000:3000 \
-e NEXT_PUBLIC_NAVIDROME_URL=http://your-navidrome-server:4533 \
-e NEXT_PUBLIC_NAVIDROME_USERNAME=your_username \
-e NEXT_PUBLIC_NAVIDROME_PASSWORD=your_password \
ghcr.io/sillyangel/mice:latest
sillyangel/mice:latest
```
📖 **For detailed Docker configuration, environment variables, troubleshooting, and advanced setups, see [DOCKER.md](./DOCKER.md)**
## Migration from Firebase
This project was migrated from Firebase to Navidrome. See [NAVIDROME_MIGRATION.md](./NAVIDROME_MIGRATION.md) for detailed migration notes and troubleshooting.
**For detailed Docker configuration, environment variables, troubleshooting, and advanced setups, see [DOCKER.md](./DOCKER.md)**
## Tech Stack
@@ -110,14 +125,6 @@ This project was migrated from Firebase to Navidrome. See [NAVIDROME_MIGRATION.m
- **Audio**: Web Audio API with streaming
- **State**: React Context for global state management
## Contributing
1. Fork the repository
2. Create a feature branch
3. Make your changes
4. Test with your Navidrome server
5. Submit a pull request
## License
This project is licensed under the MIT License.

135
SPOTLIGHT_SEARCH.md Normal file
View File

@@ -0,0 +1,135 @@
# Spotlight Search Feature
## Overview
The Spotlight Search feature provides a macOS Spotlight-style search interface for your music library, enhanced with Last.fm metadata for rich music information.
## Features
### 🔍 **Instant Search**
- **Global Search**: Press `Cmd+K` (macOS) / `Ctrl+K` (Windows/Linux) from anywhere in the app
- **Real-time Results**: Search as you type with 300ms debouncing
- **Multiple Types**: Search across tracks, albums, and artists simultaneously
### ⌨️ **Keyboard Navigation**
- `↑`/`↓` arrows to navigate results
- `Enter` to select and play/view
- `Tab` to show detailed information
- `Esc` to close (or close details panel)
### 🎵 **Quick Actions**
- **Play Now**: Click on any result to play immediately
- **Play Next**: Add track to the beginning of queue
- **Add to Queue**: Add track to the end of queue
- **Show Details**: Get rich information from Last.fm
### 🌍 **Last.fm Integration**
When viewing details, you'll see:
- **Artist Biography**: Rich biographical information
- **Statistics**: Play counts and listener numbers
- **Tags**: Genre and style tags
- **Similar Artists**: Discover new music based on your selections
- **Album Art**: High-quality images
## Usage
### Opening Search
- **Keyboard**: Press `Cmd+K` (macOS) / `Ctrl+K` (Windows/Linux)
- **Mouse**: Click the search button in the top menu bar (desktop)
- **Mobile**: Tap the search icon in the bottom navigation
### Search Tips
- Type partial song names, artist names, or album titles
- Results appear in real-time as you type
- Use keyboard navigation for fastest access
- Press Tab to see detailed Last.fm information
### Quick Actions
- **Tracks**: Play, Play Next, Add to Queue
- **Albums**: View album page, Add entire album to queue
- **Artists**: View artist page, Play all songs
## Last.fm Data
The search integrates with Last.fm to provide:
### Artist Information
- **Bio**: Artist biography and background
- **Stats**: Total plays and listeners globally
- **Similar**: Artists with similar style
- **Tags**: Genre classification and style tags
### Enhanced Discovery
- Click on similar artists to search for them
- Explore tags to discover new genres
- View play statistics to understand popularity
## Keyboard Shortcuts Summary
| Shortcut | Action |
|----------|--------|
| `Cmd+K` / `Ctrl+K` | Open Spotlight Search |
| `↑` / `↓` | Navigate results |
| `Enter` | Select result |
| `Tab` | Show details |
| `Esc` | Close search/details |
| `Space` | Play/Pause (when not in search) |
| `←` / `→` | Previous/Next track |
| `↑` / `↓` | Volume up/down (when not in search) |
| `M` | Toggle mute |
## Implementation Details
### Architecture
- **Global Context**: `GlobalSearchProvider` manages search state
- **Component**: `SpotlightSearch` handles UI and interactions
- **Hooks**: `useKeyboardShortcuts` for global hotkeys
- **Integration**: Uses existing Navidrome search API + Last.fm API
### Performance
- **Debounced Search**: 300ms delay prevents excessive API calls
- **Keyboard Optimized**: All interactions available via keyboard
- **Lazy Loading**: Last.fm data loaded only when details are viewed
- **Caching**: Search results cached during session
### Accessibility
- **Keyboard Navigation**: Full keyboard support
- **Screen Reader**: Proper ARIA labels and descriptions
- **Focus Management**: Automatic focus on search input
- **Visual Feedback**: Clear hover and selection states
## Future Enhancements
### Planned Features
- **Search History**: Remember recent searches
- **Smart Suggestions**: AI-powered search suggestions
- **Scoped Search**: Filter by type (tracks only, albums only, etc.)
- **Advanced Filters**: Date ranges, genres, etc.
- **Playlist Integration**: Search within specific playlists
### Last.fm Enhancements
- **Track Information**: Individual track details from Last.fm
- **Album Reviews**: User reviews and ratings
- **Concert Information**: Upcoming shows and tour dates
- **Scrobbling Integration**: Enhanced scrobbling with search data
## Troubleshooting
### Search Not Working
1. Check Navidrome connection in settings
2. Verify network connectivity
3. Try refreshing the page
### Last.fm Data Missing
1. Last.fm API may be unavailable
2. Artist/album may not exist in Last.fm database
3. Network connectivity issues
### Keyboard Shortcuts Not Working
1. Ensure you're not in an input field
2. Check if fullscreen mode is interfering
3. Try clicking outside any input fields first
The Spotlight Search feature transforms how you discover and interact with your music library, making it faster and more intuitive than ever before!

View File

@@ -10,8 +10,12 @@ import Link from 'next/link';
import { useAudioPlayer } from '@/app/components/AudioPlayerContext'
import Loading from "@/app/components/loading";
import { Separator } from '@/components/ui/separator';
import { ScrollArea } from '@/components/ui/scroll-area';
import { getNavidromeAPI } from '@/lib/navidrome';
import { useFavoriteAlbums } from '@/hooks/use-favorite-albums';
import { useIsMobile } from '@/hooks/use-mobile';
import { OfflineIndicator, DownloadButton } from '@/app/components/OfflineIndicator';
import { useOfflineDownloads } from '@/hooks/use-offline-downloads';
import { useToast } from '@/hooks/use-toast';
export default function AlbumPage() {
const { id } = useParams();
@@ -22,7 +26,11 @@ export default function AlbumPage() {
const [starredSongs, setStarredSongs] = useState<Set<string>>(new Set());
const { getAlbum, starItem, unstarItem } = useNavidrome();
const { playTrack, addAlbumToQueue, playAlbum, playAlbumFromTrack, currentTrack } = useAudioPlayer();
const { isFavoriteAlbum, toggleFavoriteAlbum } = useFavoriteAlbums();
const isMobile = useIsMobile();
const api = getNavidromeAPI();
const { downloadAlbum, isSupported: isOfflineSupported } = useOfflineDownloads();
const { toast } = useToast();
useEffect(() => {
const fetchAlbum = async () => {
@@ -117,110 +125,233 @@ export default function AlbumPage() {
const seconds = duration % 60;
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
};
// Get cover art URL with proper fallback
const coverArtUrl = album.coverArt && api
? api.getCoverArtUrl(album.coverArt, 300)
: '/default-user.jpg';
const handleDownloadAlbum = async () => {
if (!album || !tracklist.length) return;
try {
toast({
title: "Download Started",
description: `Starting download of "${album.name}" by ${album.artist}`,
});
await downloadAlbum(album, tracklist);
toast({
title: "Download Complete",
description: `"${album.name}" has been downloaded for offline listening`,
});
} catch (error) {
console.error('Failed to download album:', error);
toast({
title: "Download Failed",
description: `Failed to download "${album.name}". Please try again.`,
variant: "destructive"
});
}
};
// Dynamic cover art URLs based on image size
const getMobileCoverArtUrl = () => {
return album.coverArt && api
? api.getCoverArtUrl(album.coverArt, 280)
: '/default-user.jpg';
};
const getDesktopCoverArtUrl = () => {
return album.coverArt && api
? api.getCoverArtUrl(album.coverArt, 300)
: '/default-user.jpg';
};
return (
<>
<div className="h-full px-4 py-6 lg:px-8">
<div className="space-y-4">
<div className="flex items-start gap-6">
<Image
src={coverArtUrl}
alt={album.name}
width={300}
height={300}
className="rounded-md"
/>
<div className="space-y-2">
<div className="flex items-center space-x-4">
<p className="text-3xl font-semibold tracking-tight">{album.name}</p>
<Button onClick={handleStar} variant="ghost">
<Heart className={isStarred ? 'text-primary' : 'text-gray-500'} fill={isStarred ? 'var(--primary)' : ""}/>
</Button>
{isMobile ? (
/* Mobile Layout */
<div className="space-y-6">
{/* Album Cover - Centered */}
<div className="flex justify-center">
<Image
src={getMobileCoverArtUrl()}
alt={album.name}
width={280}
height={280}
className="rounded-md shadow-lg"
/>
</div>
<Link href={`/artist/${album.artistId}`}>
<p className="text-xl text-primary mt-0 mb-4 underline">{album.artist}</p>
</Link>
<Button className="px-5" onClick={() => playAlbum(album.id)}>
<Play />
Play Album
</Button>
<div className="text-sm text-muted-foreground">
<p>{album.songCount} songs {album.year} {album.genre}</p>
<p>Duration: {formatDuration(album.duration)}</p>
{/* Album Info and Controls */}
<div className="flex justify-between items-start gap-4">
{/* Left side - Album Info */}
<div className="flex-1 space-y-1">
<h1 className="text-2xl font-bold text-left">{album.name}</h1>
<Link href={`/artist/${album.artistId}`}>
<p className="text-lg text-primary underline text-left">{album.artist}</p>
</Link>
<p className="text-sm text-muted-foreground text-left">{album.genre} {album.year}</p>
<p className="text-sm text-muted-foreground text-left">{album.songCount} songs, {formatDuration(album.duration)}</p>
{/* Offline indicator for mobile */}
<OfflineIndicator
id={album.id}
type="album"
showLabel
size="sm"
className="mt-2"
/>
</div>
{/* Right side - Controls */}
<div className="flex flex-col items-center gap-3">
<Button
className="w-12 h-12 rounded-full p-0"
onClick={() => playAlbum(album.id)}
title="Play Album"
>
<Play className="w-6 h-6" />
</Button>
{/* Download button for mobile */}
{isOfflineSupported && (
<DownloadButton
id={album.id}
type="album"
onDownload={handleDownloadAlbum}
size="sm"
variant="outline"
className="text-xs px-2 py-1 h-8"
/>
)}
</div>
</div>
</div>
</div>
) : (
/* Desktop Layout */
<div className="flex items-start gap-6">
<Image
src={getDesktopCoverArtUrl()}
alt={album.name}
width={300}
height={300}
className="rounded-md"
/>
<div className="space-y-2">
<div className="flex items-center space-x-4">
<p className="text-3xl font-semibold tracking-tight">{album.name}</p>
<Button onClick={handleStar} variant="ghost" title={isStarred ? "Unstar album" : "Star album"}>
<Heart className={isStarred ? 'text-primary' : 'text-gray-500'} fill={isStarred ? 'var(--primary)' : ""}/>
</Button>
</div>
<Link href={`/artist/${album.artistId}`}>
<p className="text-xl text-primary mt-0 mb-4 underline">{album.artist}</p>
</Link>
{/* Controls row */}
<div className="flex items-center gap-3">
<Button className="px-5" onClick={() => playAlbum(album.id)}>
Play
</Button>
{/* Download button for desktop */}
{isOfflineSupported && (
<DownloadButton
id={album.id}
type="album"
onDownload={handleDownloadAlbum}
variant="outline"
/>
)}
</div>
{/* Album info */}
<div className="text-sm text-muted-foreground">
<p>{album.genre} {album.year}</p>
<p>{album.songCount} songs, {formatDuration(album.duration)}</p>
{/* Offline indicator for desktop */}
<OfflineIndicator
id={album.id}
type="album"
showLabel
className="mt-2"
/>
</div>
</div>
</div>
)}
<div className="space-y-4">
<Separator />
<ScrollArea className="h-[calc(100vh-500px)]">
{tracklist.length === 0 ? (
<div className="text-center py-12">
<p className="text-muted-foreground">No tracks available.</p>
</div>
) : (
<div className="space-y-1">
{tracklist.map((song, index) => (
<div
key={song.id}
className={`group flex items-center p-3 rounded-lg hover:bg-accent/50 cursor-pointer transition-colors`}
onClick={() => handlePlayClick(song)}
>
{/* Track Number / Play Indicator */}
<div className="w-8 text-center text-sm text-muted-foreground mr-3">
<>
<span className="group-hover:hidden">{song.track || index + 1}</span>
<Play className="w-4 h-4 mx-auto hidden group-hover:block" />
</>
</div>
{tracklist.length === 0 ? (
<div className="text-center py-12">
<p className="text-muted-foreground">No tracks available.</p>
</div>
) : (
<div className="space-y-1 pb-32">
{tracklist.map((song, index) => (
<div
key={song.id}
className={`group flex items-center p-3 rounded-lg hover:bg-accent/50 cursor-pointer transition-colors`}
onClick={() => handlePlayClick(song)}
>
{/* Track Number / Play Indicator */}
<div className="w-8 text-center text-sm text-muted-foreground mr-3">
<>
<span className="group-hover:hidden">{song.track || index + 1}</span>
<Play className="w-4 h-4 mx-auto hidden group-hover:block" />
</>
</div>
{/* Song Info */}
<div className="flex-1 min-w-0 mr-4">
<div className="flex items-center gap-2 mb-1">
<p className={`font-semibold truncate ${
isCurrentlyPlaying(song) ? 'text-primary' : ''
}`}>
{song.title}
</p>
</div>
<div className="flex items-center text-sm text-muted-foreground">
<div className="flex items-center gap-1">
<span className="truncate">{song.artist}</span>
</div>
</div>
</div>
{/* Duration */}
<div className="flex items-center text-sm text-muted-foreground mr-4">
{formatDuration(song.duration)}
</div>
{/* Actions */}
<div className="flex items-center space-x-2 group-hover:opacity-100 transition-opacity">
<Button
variant="ghost"
{/* Song Info */}
<div className="flex-1 min-w-0 mr-4">
<div className="flex items-center gap-2 mb-1">
<p className={`font-semibold truncate ${
isCurrentlyPlaying(song) ? 'text-primary' : ''
}`}>
{song.title}
</p>
{/* Song offline indicator */}
<OfflineIndicator
id={song.id}
type="song"
size="sm"
onClick={(e) => {
e.stopPropagation();
handleSongStar(song);
}}
className="h-8 w-8 p-0"
>
<Heart
className={`w-4 h-4 ${starredSongs.has(song.id) ? 'text-primary' : 'text-gray-500'}`}
fill={starredSongs.has(song.id) ? 'var(--primary)' : 'none'}
/>
</Button>
/>
</div>
<div className="flex items-center text-sm text-muted-foreground">
<div className="flex items-center gap-1">
<span className="truncate">{song.artist}</span>
</div>
</div>
</div>
))}
</div>
)}
</ScrollArea>
{/* Duration */}
<div className="flex items-center text-sm text-muted-foreground mr-4">
{formatDuration(song.duration)}
</div>
{/* Actions */}
<div className="flex items-center space-x-2 group-hover:opacity-100 transition-opacity">
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation();
handleSongStar(song);
}}
className="h-8 w-8 p-0"
>
<Heart
className={`w-4 h-4 ${starredSongs.has(song.id) ? 'text-primary' : 'text-gray-500'}`}
fill={starredSongs.has(song.id) ? 'var(--primary)' : 'none'}
/>
</Button>
</div>
</div>
))}
</div>
)}
</div>
</div>
</div>

View File

@@ -15,6 +15,7 @@ import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area';
import Loading from '@/app/components/loading';
import { getNavidromeAPI } from '@/lib/navidrome';
import { useToast } from '@/hooks/use-toast';
import { useIsMobile } from '@/hooks/use-mobile';
export default function ArtistPage() {
const { artist: artistId } = useParams();
@@ -27,6 +28,7 @@ export default function ArtistPage() {
const { getArtist, starItem, unstarItem } = useNavidrome();
const { playArtist } = useAudioPlayer();
const { toast } = useToast();
const isMobile = useIsMobile();
const api = getNavidromeAPI();
useEffect(() => {
@@ -103,7 +105,7 @@ export default function ArtistPage() {
}
// Get artist image URL with proper fallback
const artistImageUrl = artist.coverArt && api
? api.getCoverArtUrl(artist.coverArt, 300)
? api.getCoverArtUrl(artist.coverArt, 1200)
: '/default-user.jpg';
return (
@@ -152,7 +154,7 @@ export default function ArtistPage() {
<ArtistBio artistName={artist.name} />
{/* Popular Songs Section */}
{popularSongs.length > 0 && (
{!isMobile && popularSongs.length > 0 && (
<PopularSongs songs={popularSongs} artistName={artist.name} />
)}

View File

@@ -1,105 +1,68 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
'use client';
import { useState, useEffect } from 'react';
import { useCallback, useEffect } from 'react';
import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area';
import { Separator } from '@/components/ui/separator';
import { Button } from '@/components/ui/button';
import { Tabs, TabsContent } from '@/components/ui/tabs';
import { AlbumArtwork } from '@/app/components/album-artwork';
import { ArtistIcon } from '@/app/components/artist-icon';
import { useNavidrome } from '@/app/components/NavidromeContext';
import { getNavidromeAPI, Album } from '@/lib/navidrome';
import { useAudioPlayer } from '@/app/components/AudioPlayerContext';
import { Shuffle } from 'lucide-react';
import { useProgressiveAlbumLoading } from '@/hooks/use-progressive-album-loading';
import {
Shuffle,
ArrowDown,
RefreshCcw,
Loader2
} from 'lucide-react';
import Loading from '@/app/components/loading';
import { useInView } from 'react-intersection-observer';
export default function BrowsePage() {
const { artists, isLoading: contextLoading } = useNavidrome();
const { shuffleAllAlbums } = useAudioPlayer();
const [albums, setAlbums] = useState<Album[]>([]);
const [currentPage, setCurrentPage] = useState(0);
const [isLoadingAlbums, setIsLoadingAlbums] = useState(false);
const [hasMoreAlbums, setHasMoreAlbums] = useState(true);
const albumsPerPage = 84;
const api = getNavidromeAPI();
const loadAlbums = async (page: number, append: boolean = false) => {
if (!api) {
console.error('Navidrome API not available');
return;
}
try {
setIsLoadingAlbums(true);
const offset = page * albumsPerPage;
// Use alphabeticalByName to get all albums in alphabetical order
const newAlbums = await api.getAlbums('alphabeticalByName', albumsPerPage, offset);
if (append) {
setAlbums(prev => [...prev, ...newAlbums]);
} else {
setAlbums(newAlbums);
}
// If we got fewer albums than requested, we've reached the end
setHasMoreAlbums(newAlbums.length === albumsPerPage);
} catch (error) {
console.error('Failed to load albums:', error);
} finally {
setIsLoadingAlbums(false);
}
};
// Use our progressive loading hook
const {
albums,
isLoading,
hasMore,
loadMoreAlbums,
refreshAlbums
} = useProgressiveAlbumLoading('alphabeticalByName');
// Infinite scroll with intersection observer
const { ref, inView } = useInView({
threshold: 0.1,
triggerOnce: false
});
// Load more albums when the load more sentinel comes into view
useEffect(() => {
loadAlbums(0);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Infinite scroll handler
useEffect(() => {
const handleScroll = (e: Event) => {
const target = e.target as HTMLElement;
if (!target || isLoadingAlbums || !hasMoreAlbums) return;
const { scrollTop, scrollHeight, clientHeight } = target;
const threshold = 200; // Load more when 200px from bottom
if (scrollHeight - scrollTop - clientHeight < threshold) {
loadMore();
}
};
const scrollArea = document.querySelector('[data-radix-scroll-area-viewport]');
if (scrollArea) {
scrollArea.addEventListener('scroll', handleScroll);
return () => scrollArea.removeEventListener('scroll', handleScroll);
if (inView && hasMore && !isLoading) {
loadMoreAlbums();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isLoadingAlbums, hasMoreAlbums, currentPage]);
const loadMore = () => {
if (isLoadingAlbums || !hasMoreAlbums) return;
const nextPage = currentPage + 1;
setCurrentPage(nextPage);
loadAlbums(nextPage, true);
};
}, [inView, hasMore, isLoading, loadMoreAlbums]);
// Pull-to-refresh simulation
const handleRefresh = useCallback(() => {
refreshAlbums();
}, [refreshAlbums]);
if (contextLoading) {
return <Loading />;
}
return (
<div className="h-full px-4 py-6 lg:px-8">
<>
<Tabs defaultValue="music" className="h-full flex flex-col space-y-6">
<TabsContent value="music" className="border-none p-0 outline-none flex flex-col flex-grow">
<div className="flex items-center justify-between">
<div className="space-y-1">
<p className="text-2xl font-semibold tracking-tight">
Artists
<div className="p-6 pb-24 w-full">
<div className="space-y-2">
<div className="h-full flex flex-col space-y-6">
<div className="border-none p-0 outline-hidden flex flex-col grow">
<div className="flex items-center justify-between">
<div className="space-y-1">
<p className="text-3xl font-semibold tracking-tight">
Browse Artists
</p>
<p className="text-sm text-muted-foreground">
the people who make the music
@@ -111,7 +74,7 @@ export default function BrowsePage() {
</Button>
</div>
<Separator className="my-4" />
<div className="relative flex-grow">
<div className="relative grow">
<div className="relative">
<ScrollArea>
<div className="flex space-x-4 pb-4">
@@ -119,7 +82,7 @@ export default function BrowsePage() {
<ArtistIcon
key={artist.id}
artist={artist}
className="flex-shrink-0 overflow-hidden"
className="shrink-0 overflow-hidden"
size={190}
/>
))}
@@ -130,16 +93,20 @@ export default function BrowsePage() {
</div>
<div className="flex items-center justify-between">
<div className="space-y-1">
<p className="text-2xl font-semibold tracking-tight">
<p className="text-3xl font-semibold tracking-tight">
Browse Albums
</p>
<p className="text-sm text-muted-foreground">
Browse the full collection of albums ({albums.length} loaded).
</p>
</div>
<Button onClick={handleRefresh} variant="outline" size="sm">
<RefreshCcw className="w-4 h-4 mr-2" />
Refresh
</Button>
</div>
<Separator className="my-4" />
<div className="relative flex-grow">
<div className="relative grow">
<ScrollArea className="h-full">
<div className="h-full overflow-y-auto">
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 2xl:grid-cols-7 gap-4 p-4 pb-8">
@@ -154,31 +121,54 @@ export default function BrowsePage() {
/>
))}
</div>
{hasMoreAlbums && (
<div className="flex justify-center p-4 pb-24">
{/* Load more sentinel */}
{hasMore && (
<div
ref={ref}
className="flex justify-center p-4 pb-24"
>
<Button
onClick={loadMore}
disabled={isLoadingAlbums}
variant="outline"
onClick={loadMoreAlbums}
disabled={isLoading}
variant="ghost"
className="flex flex-col items-center gap-2"
>
{isLoadingAlbums ? 'Loading...' : `Load More Albums (${albumsPerPage} more)`}
{isLoading ? (
<Loader2 className="h-6 w-6 animate-spin" />
) : (
<ArrowDown className="h-6 w-6" />
)}
<span className="text-sm">
{isLoading ? 'Loading...' : 'Load More Albums'}
</span>
</Button>
</div>
)}
{!hasMoreAlbums && albums.length > 0 && (
{!hasMore && albums.length > 0 && (
<div className="flex justify-center p-4 pb-24">
<p className="text-sm text-muted-foreground">
All albums loaded ({albums.length} total)
</p>
</div>
)}
{albums.length === 0 && !isLoading && (
<div className="flex flex-col items-center justify-center p-12">
<p className="text-lg font-medium mb-2">No albums found</p>
<p className="text-sm text-muted-foreground mb-4">
Try refreshing or check your connection
</p>
<Button onClick={handleRefresh}>Refresh</Button>
</div>
)}
</div>
<ScrollBar orientation="vertical" />
</ScrollArea>
</div>
</TabsContent>
</Tabs>
</>
</div>
</div>
</div>
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,9 +1,10 @@
'use client';
import React, { createContext, useContext, useState, useEffect, useMemo, useCallback } from 'react';
import { Song, Album, Artist } from '@/lib/navidrome';
import React, { createContext, useContext, useState, useEffect, useMemo, useCallback, useRef } from 'react';
import { Song } from '@/lib/navidrome';
import { getNavidromeAPI } from '@/lib/navidrome';
import { useToast } from "@/hooks/use-toast";
import { AudioEffects } from '@/lib/audio-effects';
export interface Track {
id: string;
@@ -15,8 +16,16 @@ export interface Track {
coverArt?: string;
albumId: string;
artistId: string;
autoPlay?: boolean; // Flag to control auto-play
starred?: boolean; // Flag for starred/favorited tracks
autoPlay?: boolean;
starred?: boolean;
replayGain?: number; // Added ReplayGain field
}
interface AudioSettings {
crossfadeDuration: number;
equalizer: string;
replayGainEnabled: boolean;
gaplessPlayback: boolean;
}
interface AudioPlayerContextProps {
@@ -24,12 +33,14 @@ interface AudioPlayerContextProps {
playTrack: (track: Track, autoPlay?: boolean) => void;
queue: Track[];
addToQueue: (track: Track) => void;
insertAtBeginningOfQueue: (track: Track) => void;
playNextTrack: () => void;
clearQueue: () => void;
addAlbumToQueue: (albumId: string) => Promise<void>;
playAlbum: (albumId: string) => Promise<void>;
playAlbumFromTrack: (albumId: string, startingSongId: string) => Promise<void>;
removeTrackFromQueue: (index: number) => void;
reorderQueue: (oldIndex: number, newIndex: number) => void;
skipToTrackInQueue: (index: number) => void;
addArtistToQueue: (artistId: string) => Promise<void>;
playPreviousTrack: () => void;
@@ -42,18 +53,47 @@ interface AudioPlayerContextProps {
clearHistory: () => void;
toggleCurrentTrackStar: () => Promise<void>;
updateTrackStarred: (trackId: string, starred: boolean) => void;
// Audio settings
audioSettings: AudioSettings;
updateAudioSettings: (settings: Partial<AudioSettings>) => void;
equalizerPreset: string;
setEqualizerPreset: (preset: string) => void;
audioEffects: AudioEffects | null;
// Playback state
isPlaying: boolean;
togglePlayPause: () => Promise<void>;
}
const AudioPlayerContext = createContext<AudioPlayerContextProps | undefined>(undefined);
export // Default audio settings
const DEFAULT_AUDIO_SETTINGS: AudioSettings = {
crossfadeDuration: 3,
equalizer: 'normal',
replayGainEnabled: true,
gaplessPlayback: true
};
export const AudioPlayerProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [currentTrack, setCurrentTrack] = useState<Track | null>(null);
const [queue, setQueue] = useState<Track[]>([]);
const [playedTracks, setPlayedTracks] = useState<Track[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [shuffle, setShuffle] = useState(false);
const [audioSettings, setAudioSettings] = useState<AudioSettings>(DEFAULT_AUDIO_SETTINGS);
const [equalizerPreset, setEqualizerPreset] = useState('normal');
const [audioEffects, setAudioEffects] = useState<AudioEffects | null>(null);
const audioRef = useRef<HTMLAudioElement | null>(null);
const { toast } = useToast();
const api = useMemo(() => getNavidromeAPI(), []);
const api = useMemo(() => {
const navidromeApi = getNavidromeAPI();
if (!navidromeApi) {
console.warn('⚠️ Navidrome API not configured');
} else {
console.log('✅ Navidrome API initialized');
}
return navidromeApi;
}, []);
useEffect(() => {
const savedQueue = localStorage.getItem('navidrome-audioQueue');
@@ -94,21 +134,93 @@ export const AudioPlayerProvider: React.FC<{ children: React.ReactNode }> = ({ c
}
}, [currentTrack]);
// Initialize audio effects when audio element is available
useEffect(() => {
const audioElement = audioRef.current;
if (audioElement && !audioEffects) {
const effects = new AudioEffects(audioElement);
setAudioEffects(effects);
// Load saved audio settings
const savedSettings = localStorage.getItem('navidrome-audio-settings');
if (savedSettings) {
try {
const settings = JSON.parse(savedSettings);
setAudioSettings(settings);
effects.setPreset(settings.equalizer);
setEqualizerPreset(settings.equalizer);
} catch (error) {
console.error('Failed to load audio settings:', error);
}
}
return () => {
effects.disconnect();
};
}
}, [audioEffects]);
// Save all audio-related settings
const saveSettings = useCallback(() => {
try {
// Save audio settings
localStorage.setItem('navidrome-audio-settings', JSON.stringify(audioSettings));
// Save equalizer preset
localStorage.setItem('navidrome-equalizer-preset', equalizerPreset);
// Save other playback settings
const playbackSettings = {
replayGainEnabled: audioSettings.replayGainEnabled,
gaplessPlayback: audioSettings.gaplessPlayback,
crossfadeDuration: audioSettings.crossfadeDuration,
volume: audioRef.current?.volume || 1,
lastPosition: audioRef.current?.currentTime || 0
};
localStorage.setItem('navidrome-playback-settings', JSON.stringify(playbackSettings));
} catch (error) {
console.error('Failed to save settings:', error);
}
}, [audioSettings, equalizerPreset]);
// Save settings whenever they change
useEffect(() => {
saveSettings();
}, [audioSettings, equalizerPreset, saveSettings]);
// Update equalizer when preset changes
useEffect(() => {
if (audioEffects) {
audioEffects.setPreset(equalizerPreset);
}
}, [equalizerPreset, audioEffects]);
const updateAudioSettings = useCallback((settings: Partial<AudioSettings>) => {
setAudioSettings(prev => {
const newSettings = { ...prev, ...settings };
localStorage.setItem('navidrome-audio-settings', JSON.stringify(newSettings));
return newSettings;
});
}, []);
const songToTrack = useMemo(() => (song: Song): Track => {
if (!api) {
throw new Error('Navidrome API not configured');
}
const streamUrl = api.getStreamUrl(song.id);
console.log('🎵 Creating track with stream URL:', streamUrl);
return {
id: song.id,
name: song.title,
url: api.getStreamUrl(song.id),
url: streamUrl,
artist: song.artist,
album: song.album,
duration: song.duration,
coverArt: song.coverArt ? api.getCoverArtUrl(song.coverArt, 300) : undefined,
coverArt: song.coverArt ? api.getCoverArtUrl(song.coverArt, 512) : undefined,
albumId: song.albumId,
artistId: song.artistId,
starred: !!song.starred
starred: !!song.starred,
replayGain: song.replayGain || 0 // Add ReplayGain support
};
}, [api]);
@@ -118,6 +230,40 @@ export const AudioPlayerProvider: React.FC<{ children: React.ReactNode }> = ({ c
if (currentTrack) {
setPlayedTracks((prev) => [...prev, currentTrack]);
// Record the play for listening streak
// This will store timestamp with the track play
try {
const today = new Date().toISOString().split('T')[0];
const streakData = localStorage.getItem('navidrome-streak-data');
if (streakData) {
const parsedData = JSON.parse(streakData);
const todayData = parsedData[today] || {
date: today,
tracks: 0,
uniqueArtists: [],
uniqueAlbums: [],
totalListeningTime: 0
};
// Update today's listening data
todayData.tracks += 1;
if (!todayData.uniqueArtists.includes(currentTrack.artistId)) {
todayData.uniqueArtists.push(currentTrack.artistId);
}
if (!todayData.uniqueAlbums.includes(currentTrack.albumId)) {
todayData.uniqueAlbums.push(currentTrack.albumId);
}
todayData.totalListeningTime += currentTrack.duration;
// Save updated data
parsedData[today] = todayData;
localStorage.setItem('navidrome-streak-data', JSON.stringify(parsedData));
}
} catch (error) {
console.error('Failed to update listening streak data:', error);
}
}
// Set autoPlay flag on the track
@@ -147,6 +293,10 @@ export const AudioPlayerProvider: React.FC<{ children: React.ReactNode }> = ({ c
});
}, [shuffle]);
const insertAtBeginningOfQueue = useCallback((track: Track) => {
setQueue((prevQueue) => [track, ...prevQueue]);
}, []);
const clearQueue = useCallback(() => {
setQueue([]);
}, []);
@@ -155,6 +305,15 @@ export const AudioPlayerProvider: React.FC<{ children: React.ReactNode }> = ({ c
setQueue((prevQueue) => prevQueue.filter((_, i) => i !== index));
}, []);
const reorderQueue = useCallback((oldIndex: number, newIndex: number) => {
setQueue((prevQueue) => {
const newQueue = [...prevQueue];
const [movedItem] = newQueue.splice(oldIndex, 1);
newQueue.splice(newIndex, 0, movedItem);
return newQueue;
});
}, []);
const playNextTrack = useCallback(() => {
// Clear saved timestamp when changing tracks
localStorage.removeItem('navidrome-current-track-time');
@@ -561,15 +720,43 @@ export const AudioPlayerProvider: React.FC<{ children: React.ReactNode }> = ({ c
}
}
}, []);
// Track playback state
const [isPlaying, setIsPlaying] = useState(false);
// Shared playback control function
const togglePlayPause = useCallback(async () => {
const audioElement = audioRef.current;
if (!audioElement || !currentTrack) return;
try {
if (isPlaying) {
audioElement.pause();
setIsPlaying(false);
} else {
await audioElement.play();
setIsPlaying(true);
}
} catch (error) {
console.error('Failed to toggle playback:', error);
toast({
variant: "destructive",
title: "Playback Error",
description: "Failed to control playback. Please try again.",
});
}
}, [currentTrack, isPlaying, toast]);
const contextValue = useMemo(() => ({
currentTrack,
playTrack,
queue,
addToQueue,
insertAtBeginningOfQueue,
playNextTrack,
clearQueue,
addAlbumToQueue,
removeTrackFromQueue,
reorderQueue,
addArtistToQueue,
playPreviousTrack,
isLoading,
@@ -582,6 +769,15 @@ export const AudioPlayerProvider: React.FC<{ children: React.ReactNode }> = ({ c
playArtist,
playedTracks,
clearHistory,
// Audio settings
audioSettings,
updateAudioSettings,
equalizerPreset,
setEqualizerPreset,
audioEffects,
// Playback state
isPlaying,
togglePlayPause,
toggleCurrentTrackStar: async () => {
if (!currentTrack || !api) {
toast({
@@ -656,10 +852,12 @@ export const AudioPlayerProvider: React.FC<{ children: React.ReactNode }> = ({ c
isLoading,
playTrack,
addToQueue,
insertAtBeginningOfQueue,
playNextTrack,
clearQueue,
addAlbumToQueue,
removeTrackFromQueue,
reorderQueue,
addArtistToQueue,
playPreviousTrack,
playAlbum,
@@ -672,7 +870,14 @@ export const AudioPlayerProvider: React.FC<{ children: React.ReactNode }> = ({ c
playedTracks,
clearHistory,
api,
toast
toast,
audioEffects,
audioSettings,
equalizerPreset,
updateAudioSettings,
setEqualizerPreset,
isPlaying,
togglePlayPause
]);
return (

View File

@@ -0,0 +1,102 @@
'use client';
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Slider } from "@/components/ui/slider";
import { Switch } from "@/components/ui/switch";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { useAudioPlayer } from "./AudioPlayerContext";
import { presets } from "@/lib/audio-effects";
interface AudioSettingsDialogProps {
isOpen: boolean;
onClose: () => void;
}
export function AudioSettingsDialog({ isOpen, onClose }: AudioSettingsDialogProps) {
const {
audioSettings,
updateAudioSettings,
equalizerPreset,
setEqualizerPreset,
} = useAudioPlayer();
const handleCrossfadeChange = (value: number[]) => {
updateAudioSettings({ crossfadeDuration: value[0] });
};
const handleReplayGainToggle = (enabled: boolean) => {
updateAudioSettings({ replayGainEnabled: enabled });
};
const handleGaplessToggle = (enabled: boolean) => {
updateAudioSettings({ gaplessPlayback: enabled });
};
const handleEqualizerPresetChange = (preset: string) => {
setEqualizerPreset(preset);
updateAudioSettings({ equalizer: preset });
};
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent>
<DialogHeader>
<DialogTitle>Audio Settings</DialogTitle>
<DialogDescription>
Configure playback settings and audio effects
</DialogDescription>
</DialogHeader>
<div className="space-y-6">
{/* Crossfade */}
<div className="space-y-2">
<Label>Crossfade Duration ({audioSettings.crossfadeDuration}s)</Label>
<Slider
value={[audioSettings.crossfadeDuration]}
onValueChange={handleCrossfadeChange}
min={0}
max={5}
step={0.5}
/>
</div>
{/* ReplayGain */}
<div className="flex items-center justify-between">
<Label>ReplayGain</Label>
<Switch
checked={audioSettings.replayGainEnabled}
onCheckedChange={handleReplayGainToggle}
/>
</div>
{/* Gapless Playback */}
<div className="flex items-center justify-between">
<Label>Gapless Playback</Label>
<Switch
checked={audioSettings.gaplessPlayback}
onCheckedChange={handleGaplessToggle}
/>
</div>
{/* Equalizer Presets */}
<div className="space-y-2">
<Label>Equalizer Preset</Label>
<div className="grid grid-cols-2 gap-2">
{Object.keys(presets).map((preset) => (
<Button
key={preset}
variant={preset === equalizerPreset ? "default" : "outline"}
onClick={() => handleEqualizerPresetChange(preset)}
className="w-full"
>
{presets[preset].name}
</Button>
))}
</div>
</div>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,73 @@
'use client';
import React, { useState } from 'react';
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger,
} from "@/components/ui/context-menu";
import { MusicIcon, TagIcon, InfoIcon } from 'lucide-react';
import { AutoTaggingDialog } from './AutoTaggingDialog';
interface AutoTagContextMenuProps {
children: React.ReactNode;
mode: 'track' | 'album' | 'artist';
itemId: string;
itemName: string;
artistName?: string;
}
export function AutoTagContextMenu({
children,
mode,
itemId,
itemName,
artistName
}: AutoTagContextMenuProps) {
const [isDialogOpen, setIsDialogOpen] = useState(false);
return (
<>
<ContextMenu>
<ContextMenuTrigger asChild>
{children}
</ContextMenuTrigger>
<ContextMenuContent className="w-56">
<ContextMenuItem
onClick={() => setIsDialogOpen(true)}
className="cursor-pointer"
>
<TagIcon className="mr-2 h-4 w-4" />
Auto-Tag {mode === 'track' ? 'Track' : mode === 'album' ? 'Album' : 'Artist'}
</ContextMenuItem>
{mode === 'track' && (
<>
<ContextMenuSeparator />
<ContextMenuItem className="cursor-pointer">
<InfoIcon className="mr-2 h-4 w-4" />
View Track Details
</ContextMenuItem>
<ContextMenuItem className="cursor-pointer">
<MusicIcon className="mr-2 h-4 w-4" />
Edit Track Metadata
</ContextMenuItem>
</>
)}
</ContextMenuContent>
</ContextMenu>
<AutoTaggingDialog
isOpen={isDialogOpen}
onClose={() => setIsDialogOpen(false)}
mode={mode}
itemId={itemId}
itemName={itemName}
artistName={artistName}
/>
</>
);
}
export default AutoTagContextMenu;

View File

@@ -0,0 +1,319 @@
'use client';
import React, { useState, useEffect } from 'react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@/components/ui/tabs";
import { Button } from "@/components/ui/button";
import { Progress } from "@/components/ui/progress";
import { useToast } from "@/hooks/use-toast";
import { useAutoTagging, EnhancedTrackMetadata, EnhancedAlbumMetadata } from "@/hooks/use-auto-tagging";
import { useIsMobile } from "@/hooks/use-mobile";
import {
MusicIcon,
AlbumIcon,
UsersIcon,
CheckCircle2Icon,
XCircleIcon,
AlertTriangleIcon,
InfoIcon
} from 'lucide-react';
import Image from 'next/image';
interface AutoTaggingDialogProps {
isOpen: boolean;
onClose: () => void;
mode: 'track' | 'album' | 'artist';
itemId: string;
itemName: string;
artistName?: string;
}
export const AutoTaggingDialog: React.FC<AutoTaggingDialogProps> = ({
isOpen,
onClose,
mode,
itemId,
itemName,
artistName
}) => {
const isMobile = useIsMobile();
const { toast } = useToast();
const [confidenceThreshold, setConfidenceThreshold] = useState(70);
const [activeTab, setActiveTab] = useState<'tracks' | 'albums'>('tracks');
const [isApplying, setIsApplying] = useState(false);
const {
isProcessing,
progress,
enhancedTracks,
enhancedAlbums,
startAutoTagging,
applyEnhancedMetadata
} = useAutoTagging();
// Start auto-tagging when the dialog is opened
useEffect(() => {
if (isOpen && itemId && !isProcessing && progress === 0) {
// Wrap in try/catch to handle any errors that might occur during auto-tagging
try {
startAutoTagging(mode, itemId, confidenceThreshold);
} catch (error) {
console.error('Failed to start auto-tagging:', error);
toast({
title: "Auto-Tagging Error",
description: error instanceof Error ? error.message : "Failed to start auto-tagging",
variant: "destructive",
});
onClose();
}
}
}, [isOpen, itemId, mode, isProcessing, progress, startAutoTagging, confidenceThreshold, toast, onClose]);
// Set the active tab based on the mode
useEffect(() => {
if (mode === 'track') {
setActiveTab('tracks');
} else if (mode === 'album' || mode === 'artist') {
setActiveTab('albums');
}
}, [mode]);
const handleApplyMetadata = async () => {
try {
setIsApplying(true);
await applyEnhancedMetadata(
enhancedTracks.filter(track => track.status === 'matched' && track.confidence >= confidenceThreshold),
enhancedAlbums.filter(album => album.status === 'matched' && album.confidence >= confidenceThreshold)
);
onClose();
} catch (error) {
console.error('Failed to apply metadata:', error);
toast({
title: "Error",
description: "Failed to apply metadata",
variant: "destructive",
});
} finally {
setIsApplying(false);
}
};
// Get match statistics
const matchedTracks = enhancedTracks.filter(track => track.status === 'matched' && track.confidence >= confidenceThreshold).length;
const totalTracks = enhancedTracks.length;
const matchedAlbums = enhancedAlbums.filter(album => album.status === 'matched' && album.confidence >= confidenceThreshold).length;
const totalAlbums = enhancedAlbums.length;
const getStatusIcon = (status: 'pending' | 'matched' | 'failed' | 'applied', confidence: number) => {
if (status === 'pending') return <AlertTriangleIcon className="w-4 h-4 text-yellow-500" />;
if (status === 'failed') return <XCircleIcon className="w-4 h-4 text-red-500" />;
if (status === 'matched' && confidence >= confidenceThreshold) return <CheckCircle2Icon className="w-4 h-4 text-green-500" />;
if (status === 'matched' && confidence < confidenceThreshold) return <InfoIcon className="w-4 h-4 text-yellow-500" />;
if (status === 'applied') return <CheckCircle2Icon className="w-4 h-4 text-blue-500" />;
return null;
};
const getConfidenceColor = (confidence: number) => {
if (confidence >= 90) return 'bg-green-500';
if (confidence >= 70) return 'bg-green-400';
if (confidence >= 50) return 'bg-yellow-500';
return 'bg-red-500';
};
// Render the appropriate dialog/sheet based on mobile status
const DialogComponent = isMobile ? Sheet : Dialog;
const DialogContentComponent = isMobile ? SheetContent : DialogContent;
const DialogHeaderComponent = isMobile ? SheetHeader : DialogHeader;
const DialogTitleComponent = isMobile ? SheetTitle : DialogTitle;
const DialogDescriptionComponent = isMobile ? SheetDescription : DialogDescription;
return (
<DialogComponent open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContentComponent className={isMobile ? "p-0 pt-8" : "max-w-3xl max-h-[90vh] overflow-hidden flex flex-col"}>
<DialogHeaderComponent className={isMobile ? "p-6 pb-2" : ""}>
<DialogTitleComponent>
Auto-Tagging {mode === 'track' ? 'Track' : mode === 'album' ? 'Album' : 'Artist'}
</DialogTitleComponent>
<DialogDescriptionComponent>
{isProcessing ? (
`Analyzing ${mode === 'track' ? 'track' : mode === 'album' ? 'album' : 'artist'} "${itemName}"`
) : (
`Found metadata for ${matchedTracks} of ${totalTracks} tracks${totalAlbums > 0 ? ` and ${matchedAlbums} of ${totalAlbums} albums` : ''}`
)}
</DialogDescriptionComponent>
{/* Progress bar */}
{(isProcessing || isApplying) && (
<div className="my-4">
<Progress value={progress} className="h-2" />
<p className="text-sm text-muted-foreground mt-2">
{isProcessing ? 'Analyzing metadata...' : 'Applying metadata...'}
</p>
</div>
)}
</DialogHeaderComponent>
{/* Tabs for tracks and albums */}
{!isProcessing && !isApplying && (
<div className={`flex-1 overflow-hidden flex flex-col ${isMobile ? "px-6" : ""}`}>
<Tabs value={activeTab} onValueChange={(value) => setActiveTab(value as 'tracks' | 'albums')} className="flex-1 flex flex-col">
<div className="flex justify-between items-center mb-2">
<TabsList>
<TabsTrigger value="tracks" disabled={totalTracks === 0}>
<MusicIcon className="w-4 h-4 mr-2" /> Tracks ({matchedTracks}/{totalTracks})
</TabsTrigger>
<TabsTrigger value="albums" disabled={totalAlbums === 0}>
<AlbumIcon className="w-4 h-4 mr-2" /> Albums ({matchedAlbums}/{totalAlbums})
</TabsTrigger>
</TabsList>
{/* Confidence threshold slider */}
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground whitespace-nowrap">Min. Confidence: {confidenceThreshold}%</span>
<input
type="range"
min="0"
max="100"
value={confidenceThreshold}
onChange={(e) => setConfidenceThreshold(parseInt(e.target.value))}
className="w-24"
/>
</div>
</div>
{/* Tracks tab content */}
<TabsContent value="tracks" className="flex-1 overflow-auto data-[state=active]:flex flex-col">
<div className="rounded-md border">
<div className="bg-muted p-2 grid grid-cols-12 gap-2 text-sm font-medium">
<div className="col-span-1"></div>
<div className="col-span-4">Title</div>
<div className="col-span-3">Artist</div>
<div className="col-span-2">Album</div>
<div className="col-span-2 text-right">Confidence</div>
</div>
<div className="divide-y max-h-[50vh] overflow-auto">
{enhancedTracks.map(track => (
<div key={track.id} className="grid grid-cols-12 gap-2 p-2 items-center">
<div className="col-span-1">
{getStatusIcon(track.status, track.confidence)}
</div>
<div className="col-span-4 truncate">
{track.title}
</div>
<div className="col-span-3 truncate">
{track.artist}
</div>
<div className="col-span-2 truncate">
{track.album}
</div>
<div className="col-span-2 flex justify-end items-center gap-2">
<div className="h-2 w-10 rounded-full bg-gray-200">
<div
className={`h-full rounded-full ${getConfidenceColor(track.confidence)}`}
style={{ width: `${track.confidence}%` }}
/>
</div>
<span className="text-xs">{track.confidence}%</span>
</div>
</div>
))}
</div>
</div>
</TabsContent>
{/* Albums tab content */}
<TabsContent value="albums" className="flex-1 overflow-auto data-[state=active]:flex flex-col">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 max-h-[50vh] overflow-auto p-1">
{enhancedAlbums.map(album => (
<div key={album.id} className="border rounded-lg overflow-hidden">
<div className="flex">
{/* Album cover */}
<div className="relative w-24 h-24">
{album.coverArtUrl ? (
<Image
src={album.coverArtUrl}
alt={album.name}
fill
className="object-cover"
/>
) : (
<div className="w-full h-full bg-muted flex items-center justify-center">
<AlbumIcon className="w-8 h-8 text-muted-foreground" />
</div>
)}
{/* Status badge */}
<div className="absolute top-1 left-1">
{getStatusIcon(album.status, album.confidence)}
</div>
</div>
{/* Album info */}
<div className="flex-1 p-3">
<h4 className="font-medium text-sm truncate">{album.name}</h4>
<p className="text-xs text-muted-foreground truncate">{album.artist}</p>
<div className="mt-2 flex items-center gap-2">
<div className="h-2 w-10 rounded-full bg-gray-200">
<div
className={`h-full rounded-full ${getConfidenceColor(album.confidence)}`}
style={{ width: `${album.confidence}%` }}
/>
</div>
<span className="text-xs">{album.confidence}%</span>
</div>
{album.year && (
<p className="text-xs mt-1">Year: {album.year}</p>
)}
</div>
</div>
</div>
))}
</div>
</TabsContent>
</Tabs>
</div>
)}
<DialogFooter className={`${isMobile ? "p-6 pt-4" : "mt-4"}`}>
<div className="w-full flex flex-col md:flex-row justify-end gap-2">
<Button
variant="outline"
onClick={onClose}
disabled={isProcessing || isApplying}
>
Cancel
</Button>
<Button
onClick={handleApplyMetadata}
disabled={
isProcessing ||
isApplying ||
(matchedTracks === 0 && matchedAlbums === 0)
}
>
Apply Metadata
</Button>
</div>
</DialogFooter>
</DialogContentComponent>
</DialogComponent>
);
};
export default AutoTaggingDialog;

View File

@@ -0,0 +1,221 @@
'use client';
import React, { useState, useEffect } from 'react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Switch } from '@/components/ui/switch';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Input } from '@/components/ui/input';
import { FaTags } from 'react-icons/fa';
import { useToast } from '@/hooks/use-toast';
import { AutoTaggingDialog } from './AutoTaggingDialog';
export const AutoTaggingSettings = () => {
const { toast } = useToast();
const [isClient, setIsClient] = useState(false);
const [autoTaggingEnabled, setAutoTaggingEnabled] = useState(false);
const [autoTagDialogOpen, setAutoTagDialogOpen] = useState(false);
const [selectedItem, setSelectedItem] = useState({
id: '',
name: 'Library',
mode: 'artist' as 'track' | 'album' | 'artist'
});
const [autoTagOptions, setAutoTagOptions] = useState({
rateLimit: 1000, // milliseconds between requests
autoProcess: false,
preferLocalMetadata: true,
tagsToUpdate: ['title', 'artist', 'album', 'year', 'genre'],
});
useEffect(() => {
setIsClient(true);
// Load saved preferences from localStorage
const savedAutoTagging = localStorage.getItem('auto-tagging-enabled');
if (savedAutoTagging !== null) {
setAutoTaggingEnabled(savedAutoTagging === 'true');
}
// Load saved auto-tag options
const savedOptions = localStorage.getItem('auto-tagging-options');
if (savedOptions !== null) {
try {
setAutoTagOptions(JSON.parse(savedOptions));
} catch (error) {
console.error('Failed to parse stored auto-tagging options:', error);
}
}
}, []);
const handleAutoTaggingToggle = (enabled: boolean) => {
setAutoTaggingEnabled(enabled);
if (isClient) {
localStorage.setItem('auto-tagging-enabled', enabled.toString());
}
toast({
title: enabled ? 'Auto-Tagging Enabled' : 'Auto-Tagging Disabled',
description: enabled
? 'Music will be automatically tagged with metadata from MusicBrainz'
: 'Auto-tagging has been disabled',
});
};
const handleOptionsChange = (key: string, value: unknown) => {
setAutoTagOptions(prev => {
const newOptions = { ...prev, [key]: value };
if (isClient) {
localStorage.setItem('auto-tagging-options', JSON.stringify(newOptions));
}
return newOptions;
});
};
const handleTagSelectionChange = (tag: string, isSelected: boolean) => {
setAutoTagOptions(prev => {
const currentTags = [...prev.tagsToUpdate];
const newTags = isSelected
? [...currentTags, tag]
: currentTags.filter(t => t !== tag);
const newOptions = { ...prev, tagsToUpdate: newTags };
if (isClient) {
localStorage.setItem('auto-tagging-options', JSON.stringify(newOptions));
}
return newOptions;
});
};
const isTagSelected = (tag: string) => {
return autoTagOptions.tagsToUpdate.includes(tag);
};
return (
<>
<Card className="mb-6 break-inside-avoid py-5">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<FaTags className="w-5 h-5" />
Auto-Tagging
</CardTitle>
<CardDescription>
Configure metadata auto-tagging with MusicBrainz
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="flex items-center justify-between">
<div>
<p className="font-medium">Enable Auto-Tagging</p>
<p className="text-sm text-muted-foreground">
Automatically fetch and apply metadata from MusicBrainz
</p>
</div>
<Switch
checked={autoTaggingEnabled}
onCheckedChange={handleAutoTaggingToggle}
/>
</div>
{autoTaggingEnabled && (
<>
<div className="space-y-2">
<Label htmlFor="rate-limit">API Rate Limit (ms)</Label>
<Input
id="rate-limit"
type="number"
min={500}
max={5000}
step={100}
value={autoTagOptions.rateLimit}
onChange={(e) => handleOptionsChange('rateLimit', Number(e.target.value))}
/>
<p className="text-xs text-muted-foreground">
Time between API requests in milliseconds (min: 500ms)
</p>
</div>
<div className="flex items-center justify-between">
<div>
<p className="font-medium">Auto Process Results</p>
<p className="text-sm text-muted-foreground">
Automatically apply best matches without confirmation
</p>
</div>
<Switch
checked={autoTagOptions.autoProcess}
onCheckedChange={(checked) => handleOptionsChange('autoProcess', checked)}
/>
</div>
<div className="flex items-center justify-between">
<div>
<p className="font-medium">Prefer Local Metadata</p>
<p className="text-sm text-muted-foreground">
Keep existing metadata when confidence is low
</p>
</div>
<Switch
checked={autoTagOptions.preferLocalMetadata}
onCheckedChange={(checked) => handleOptionsChange('preferLocalMetadata', checked)}
/>
</div>
<div className="space-y-2">
<Label>Tags to Update</Label>
<div className="grid grid-cols-2 gap-2">
{['title', 'artist', 'album', 'year', 'genre', 'albumArtist', 'trackNumber', 'discNumber'].map(tag => (
<div key={tag} className="flex items-center space-x-2">
<Switch
id={`tag-${tag}`}
checked={isTagSelected(tag)}
onCheckedChange={(checked) => handleTagSelectionChange(tag, checked)}
/>
<Label htmlFor={`tag-${tag}`} className="capitalize">
{tag === 'albumArtist' ? 'Album Artist' :
tag === 'trackNumber' ? 'Track Number' :
tag === 'discNumber' ? 'Disc Number' : tag}
</Label>
</div>
))}
</div>
</div>
<div className="pt-2">
<Button onClick={() => {
// Set selected item to represent the whole library
setSelectedItem({
id: 'library',
name: 'Full Library',
mode: 'artist'
});
setAutoTagDialogOpen(true);
}} variant="outline">
<FaTags className="w-4 h-4 mr-2" />
Open Auto-Tagging Tool
</Button>
</div>
</>
)}
<div className="text-sm text-muted-foreground space-y-2">
<p><strong>How it works:</strong></p>
<ul className="list-disc list-inside space-y-1 ml-2">
<li>Metadata is fetched from MusicBrainz when you play tracks</li>
<li>Tags can be applied automatically or manually reviewed</li>
<li>Right-click on tracks or albums to tag them manually</li>
<li>MusicBrainz API has rate limits, so don&apos;t set too fast</li>
</ul>
</div>
</CardContent>
</Card>
<AutoTaggingDialog
isOpen={autoTagDialogOpen}
onClose={() => setAutoTagDialogOpen(false)}
mode={selectedItem.mode}
itemId={selectedItem.id}
itemName={selectedItem.name}
/>
</>
);
};

View File

@@ -0,0 +1,89 @@
'use client';
import { useRouter, usePathname } from 'next/navigation';
import { Home, Search, Disc, Users, Music, Heart, List, Settings } from 'lucide-react';
import { cn } from '@/lib/utils';
import { motion, AnimatePresence } from 'framer-motion';
import { useGlobalSearch } from './GlobalSearchProvider';
interface NavItem {
href: string;
label: string;
icon: React.ComponentType<{ className?: string }>;
}
const navigationItems: NavItem[] = [
{ href: '/', label: 'Home', icon: Home },
{ href: '/search', label: 'Search', icon: Search },
{ href: '/library', label: 'Library', icon: Music },
{ href: '/queue', label: 'Queue', icon: List },
];
export function BottomNavigation() {
const router = useRouter();
const pathname = usePathname();
const { openSpotlight } = useGlobalSearch();
const handleNavigation = (href: string) => {
if (href === '/search') {
// Use spotlight search instead of navigating to search page
openSpotlight();
} else {
router.push(href);
}
};
const isActive = (href: string) => {
if (href === '/') {
return pathname === '/';
}
return pathname.startsWith(href);
};
return (
<div className="fixed bottom-0 left-0 right-0 z-[50] bg-background/95 backdrop-blur-sm border-t border-border">
<div className="flex items-center justify-around px-2 py-2 pb-safe mb-2">
{navigationItems.map((item) => {
const isItemActive = isActive(item.href);
const Icon = item.icon;
return (
<motion.button
key={item.href}
onClick={() => handleNavigation(item.href)}
className={cn(
"flex flex-col items-center justify-center p-2 rounded-lg transition-all duration-200 min-w-[60px] touch-manipulation",
"active:scale-95 active:bg-primary/20",
isItemActive
? "text-primary bg-primary/10"
: "text-muted-foreground hover:text-foreground hover:bg-muted/50"
)}
whileTap={{ scale: 0.95 }}
whileHover={{ y: -1 }}
>
<Icon className={cn("w-5 h-5 mb-1", isItemActive && "text-primary")} />
<span className={cn(
"text-xs font-medium",
isItemActive ? "text-primary" : "text-muted-foreground"
)}>
{item.label}
</span>
<AnimatePresence>
{isItemActive && (
<motion.div
layoutId="bottom-nav-underline"
className="h-0.5 w-6 bg-primary mt-1 rounded"
initial={{ opacity: 0, scaleX: 0.6 }}
animate={{ opacity: 1, scaleX: 1 }}
exit={{ opacity: 0, scaleX: 0.6 }}
transition={{ duration: 0.2 }}
/>
)}
</AnimatePresence>
</motion.button>
);
})}
</div>
</div>
);
}

View File

@@ -0,0 +1,71 @@
'use client';
import { useEffect, useState } from 'react';
import { useListeningStreak } from '@/hooks/use-listening-streak';
import { Card, CardContent } from '@/components/ui/card';
import { Flame } from 'lucide-react';
import { cn } from '@/lib/utils';
import { AnimatePresence, motion } from 'framer-motion';
export default function CompactListeningStreak() {
const { stats, hasListenedToday, getStreakEmoji } = useListeningStreak();
const [animate, setAnimate] = useState(false);
// Trigger animation when streak increases
useEffect(() => {
if (stats.currentStreak > 0) {
setAnimate(true);
const timer = setTimeout(() => setAnimate(false), 1000);
return () => clearTimeout(timer);
}
}, [stats.currentStreak]);
const hasCompletedToday = hasListenedToday();
const streakEmoji = getStreakEmoji();
// Only show if the streak is 3 days or more
if (stats.currentStreak < 3) {
return null;
}
return (
<Card className="mb-4">
<CardContent className="p-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Flame className={cn(
"w-5 h-5",
hasCompletedToday ? "text-amber-500" : "text-muted-foreground"
)} />
<AnimatePresence>
<motion.div
key={stats.currentStreak}
initial={{ scale: animate ? 0.8 : 1 }}
animate={{ scale: 1 }}
className="flex items-center"
>
<span className="text-xl font-bold">
{stats.currentStreak}
</span>
<span className="ml-1 text-sm text-muted-foreground">
day streak
</span>
{streakEmoji && (
<motion.span
className="ml-1 text-xl"
animate={{ rotate: animate ? [0, 15, -15, 0] : 0 }}
>
{streakEmoji}
</motion.span>
)}
</motion.div>
</AnimatePresence>
</div>
<div className="text-sm text-muted-foreground">
{hasCompletedToday ? "Today's goal complete!" : "Keep listening!"}
</div>
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,260 @@
'use client';
import React from 'react';
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger,
} from "@/components/ui/context-menu";
import {
Play,
Plus,
ListMusic,
Heart,
SkipForward,
UserIcon,
Disc3,
Star,
Share,
Info
} from 'lucide-react';
import { useAudioPlayer } from '@/app/components/AudioPlayerContext';
import { Track } from '@/app/components/AudioPlayerContext';
interface TrackContextMenuProps {
children: React.ReactNode;
track: Track;
showPlayOptions?: boolean;
showQueueOptions?: boolean;
showFavoriteOption?: boolean;
showAlbumArtistOptions?: boolean;
}
export function TrackContextMenu({
children,
track,
showPlayOptions = true,
showQueueOptions = true,
showFavoriteOption = true,
showAlbumArtistOptions = true
}: TrackContextMenuProps) {
const {
playTrack,
addToQueue,
insertAtBeginningOfQueue,
toggleCurrentTrackStar,
currentTrack,
queue
} = useAudioPlayer();
const handlePlayTrack = () => {
playTrack(track, true);
};
const handleAddToQueue = () => {
addToQueue(track);
};
const handlePlayNext = () => {
// Add track to the beginning of the queue to play next
insertAtBeginningOfQueue(track);
};
const handleToggleFavorite = () => {
if (currentTrack?.id === track.id) {
toggleCurrentTrackStar();
}
// For non-current tracks, we'd need a separate function to toggle favorites
};
return (
<ContextMenu>
<ContextMenuTrigger asChild>
{children}
</ContextMenuTrigger>
<ContextMenuContent className="w-56">
{showPlayOptions && (
<>
<ContextMenuItem onClick={handlePlayTrack} className="cursor-pointer">
<Play className="mr-2 h-4 w-4" />
Play Now
</ContextMenuItem>
<ContextMenuSeparator />
</>
)}
{showQueueOptions && (
<>
<ContextMenuItem onClick={handlePlayNext} className="cursor-pointer">
<SkipForward className="mr-2 h-4 w-4" />
Play Next
</ContextMenuItem>
<ContextMenuItem onClick={handleAddToQueue} className="cursor-pointer">
<Plus className="mr-2 h-4 w-4" />
Add to Queue
</ContextMenuItem>
<ContextMenuSeparator />
</>
)}
{showFavoriteOption && (
<>
<ContextMenuItem onClick={handleToggleFavorite} className="cursor-pointer">
<Heart className={`mr-2 h-4 w-4 ${track.starred ? 'fill-current text-red-500' : ''}`} />
{track.starred ? 'Remove from Favorites' : 'Add to Favorites'}
</ContextMenuItem>
<ContextMenuSeparator />
</>
)}
{showAlbumArtistOptions && (
<>
<ContextMenuItem className="cursor-pointer">
<Disc3 className="mr-2 h-4 w-4" />
Go to Album
</ContextMenuItem>
<ContextMenuItem className="cursor-pointer">
<UserIcon className="mr-2 h-4 w-4" />
Go to Artist
</ContextMenuItem>
<ContextMenuSeparator />
</>
)}
<ContextMenuItem className="cursor-pointer">
<Info className="mr-2 h-4 w-4" />
Track Info
</ContextMenuItem>
<ContextMenuItem className="cursor-pointer">
<Share className="mr-2 h-4 w-4" />
Share
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
);
}
// Additional context menus for albums and artists
interface AlbumContextMenuProps {
children: React.ReactNode;
albumId: string;
albumName: string;
}
export function AlbumContextMenu({
children,
albumId,
albumName
}: AlbumContextMenuProps) {
const { playAlbum, addAlbumToQueue } = useAudioPlayer();
const handlePlayAlbum = () => {
playAlbum(albumId);
};
const handleAddAlbumToQueue = () => {
addAlbumToQueue(albumId);
};
return (
<ContextMenu>
<ContextMenuTrigger asChild>
{children}
</ContextMenuTrigger>
<ContextMenuContent className="w-56">
<ContextMenuItem onClick={handlePlayAlbum} className="cursor-pointer">
<Play className="mr-2 h-4 w-4" />
Play Album
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem onClick={handleAddAlbumToQueue} className="cursor-pointer">
<Plus className="mr-2 h-4 w-4" />
Add Album to Queue
</ContextMenuItem>
<ContextMenuItem className="cursor-pointer">
<SkipForward className="mr-2 h-4 w-4" />
Play Album Next
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem className="cursor-pointer">
<Heart className="mr-2 h-4 w-4" />
Add to Favorites
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem className="cursor-pointer">
<UserIcon className="mr-2 h-4 w-4" />
Go to Artist
</ContextMenuItem>
<ContextMenuItem className="cursor-pointer">
<Info className="mr-2 h-4 w-4" />
Album Info
</ContextMenuItem>
<ContextMenuItem className="cursor-pointer">
<Share className="mr-2 h-4 w-4" />
Share Album
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
);
}
interface ArtistContextMenuProps {
children: React.ReactNode;
artistId: string;
artistName: string;
}
export function ArtistContextMenu({
children,
artistId,
artistName
}: ArtistContextMenuProps) {
const { playArtist, addArtistToQueue } = useAudioPlayer();
const handlePlayArtist = () => {
playArtist(artistId);
};
const handleAddArtistToQueue = () => {
addArtistToQueue(artistId);
};
return (
<ContextMenu>
<ContextMenuTrigger asChild>
{children}
</ContextMenuTrigger>
<ContextMenuContent className="w-56">
<ContextMenuItem onClick={handlePlayArtist} className="cursor-pointer">
<Play className="mr-2 h-4 w-4" />
Play All Songs
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem onClick={handleAddArtistToQueue} className="cursor-pointer">
<Plus className="mr-2 h-4 w-4" />
Add All to Queue
</ContextMenuItem>
<ContextMenuItem className="cursor-pointer">
<SkipForward className="mr-2 h-4 w-4" />
Play All Next
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem className="cursor-pointer">
<Heart className="mr-2 h-4 w-4" />
Add to Favorites
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem className="cursor-pointer">
<Info className="mr-2 h-4 w-4" />
Artist Info
</ContextMenuItem>
<ContextMenuItem className="cursor-pointer">
<Share className="mr-2 h-4 w-4" />
Share Artist
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
);
}

View File

@@ -0,0 +1,478 @@
'use client';
import React, { useRef, useState, useEffect, useCallback } from 'react';
import Image from 'next/image';
import { motion, PanInfo, AnimatePresence } from 'framer-motion';
import { useAudioPlayer, Track } from './AudioPlayerContext';
import { FaPlay, FaPause, FaExpand, FaForward, FaBackward, FaVolumeHigh, FaVolumeXmark } from 'react-icons/fa6';
import { Heart } from 'lucide-react';
import { constrain } from '@/lib/utils';
import { Progress } from '@/components/ui/progress';
import { extractDominantColor } from '@/lib/image-utils';
interface DraggableMiniPlayerProps {
onExpand: () => void;
}
export const DraggableMiniPlayer: React.FC<DraggableMiniPlayerProps> = ({ onExpand }) => {
const {
currentTrack,
playPreviousTrack,
playNextTrack,
toggleCurrentTrackStar,
isPlaying,
togglePlayPause
} = useAudioPlayer();
const [position, setPosition] = useState({ x: 0, y: 0 });
const [isDragging, setIsDragging] = useState(false);
const [dominantColor, setDominantColor] = useState<string | null>(null);
const [progress, setProgress] = useState(0);
const [showVolumeSlider, setShowVolumeSlider] = useState(false);
const [volume, setVolume] = useState(1);
const [clickCount, setClickCount] = useState(0);
const [clickTimer, setClickTimer] = useState<NodeJS.Timeout | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
const dragStartRef = useRef({ x: 0, y: 0 });
// Save position to localStorage when it changes
useEffect(() => {
if (!isDragging) {
localStorage.setItem('mini-player-position', JSON.stringify(position));
}
}, [position, isDragging]);
// Extract dominant color from album art
useEffect(() => {
if (!currentTrack?.coverArt) {
setDominantColor(null);
return;
}
extractDominantColor(currentTrack.coverArt)
.then(color => setDominantColor(color))
.catch(error => {
console.error('Failed to extract color:', error);
setDominantColor(null);
});
}, [currentTrack?.coverArt]);
// Track progress from main audio player
useEffect(() => {
const updateProgress = () => {
const audioElement = document.querySelector('audio') as HTMLAudioElement | null;
if (audioElement && audioElement.duration) {
setProgress((audioElement.currentTime / audioElement.duration) * 100);
}
};
const updateVolume = () => {
const audioElement = document.querySelector('audio') as HTMLAudioElement | null;
if (audioElement) {
setVolume(audioElement.volume);
}
};
const interval = setInterval(updateProgress, 250);
updateVolume(); // Initial volume
// Set up event listener for volume changes
const audioElement = document.querySelector('audio');
if (audioElement) {
audioElement.addEventListener('volumechange', updateVolume);
}
return () => {
clearInterval(interval);
if (audioElement) {
audioElement.removeEventListener('volumechange', updateVolume);
}
};
}, [currentTrack]);
// Detect double clicks for expanding
const handleContainerClick = useCallback(() => {
setClickCount(prev => prev + 1);
if (clickTimer) {
clearTimeout(clickTimer);
}
const timer = setTimeout(() => {
// If single click, do nothing
if (clickCount === 0) {
// Nothing
}
// If double click, expand
else if (clickCount === 1) {
onExpand();
}
setClickCount(0);
}, 300);
setClickTimer(timer as unknown as NodeJS.Timeout);
}, [clickCount, clickTimer, onExpand]);
// Handle seeking in track
const handleProgressClick = (e: React.MouseEvent<HTMLDivElement>) => {
e.stopPropagation();
const audioElement = document.querySelector('audio') as HTMLAudioElement | null;
if (!audioElement) return;
const rect = e.currentTarget.getBoundingClientRect();
const clickX = e.clientX - rect.left;
const percent = clickX / rect.width;
audioElement.currentTime = percent * audioElement.duration;
};
// Handle volume change
const handleVolumeChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const audioElement = document.querySelector('audio') as HTMLAudioElement | null;
if (!audioElement) return;
const newVolume = parseFloat(e.target.value);
audioElement.volume = newVolume;
setVolume(newVolume);
try {
localStorage.setItem('navidrome-volume', newVolume.toString());
} catch (error) {
console.error('Failed to save volume:', error);
}
};
// Keyboard controls for the mini player
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
// Only handle keyboard shortcuts if the mini player is focused
if (document.activeElement?.tagName === 'INPUT') return;
const step = e.shiftKey ? 100 : 10; // Larger steps with shift key
switch (e.key) {
case 'ArrowLeft':
setPosition(prev => ({
...prev,
x: constrain(
prev.x - step,
-(window.innerWidth - (containerRef.current?.offsetWidth || 0)) / 2 + 16,
(window.innerWidth - (containerRef.current?.offsetWidth || 0)) / 2 - 16
)
}));
break;
case 'ArrowRight':
setPosition(prev => ({
...prev,
x: constrain(
prev.x + step,
-(window.innerWidth - (containerRef.current?.offsetWidth || 0)) / 2 + 16,
(window.innerWidth - (containerRef.current?.offsetWidth || 0)) / 2 - 16
)
}));
break;
case 'ArrowUp':
setPosition(prev => ({
...prev,
y: constrain(
prev.y - step,
-(window.innerHeight - (containerRef.current?.offsetHeight || 0)) / 2 + 16,
(window.innerHeight - (containerRef.current?.offsetHeight || 0)) / 2 - 16
)
}));
break;
case 'ArrowDown':
setPosition(prev => ({
...prev,
y: constrain(
prev.y + step,
-(window.innerHeight - (containerRef.current?.offsetHeight || 0)) / 2 + 16,
(window.innerHeight - (containerRef.current?.offsetHeight || 0)) / 2 - 16
)
}));
break;
case 'Escape':
onExpand();
break;
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [onExpand]);
// Load saved position on mount
useEffect(() => {
const savedPosition = localStorage.getItem('mini-player-position');
if (savedPosition) {
try {
const pos = JSON.parse(savedPosition);
setPosition(pos);
} catch (error) {
console.error('Failed to parse saved mini player position:', error);
}
}
}, []);
// Ensure player stays within viewport bounds and implement edge snapping
useEffect(() => {
const constrainToViewport = () => {
if (!containerRef.current || isDragging) return;
const rect = containerRef.current.getBoundingClientRect();
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
// Add some padding from edges
const padding = 16;
// Calculate constrained position
let newX = constrain(
position.x,
-(viewportWidth - rect.width) / 2 + padding,
(viewportWidth - rect.width) / 2 - padding
);
let newY = constrain(
position.y,
-(viewportHeight - rect.height) / 2 + padding,
(viewportHeight - rect.height) / 2 - padding
);
// Edge snapping logic
const snapThreshold = 24; // Pixels from edge to trigger snap
const snapPositions = {
left: -(viewportWidth - rect.width) / 2 + padding,
right: (viewportWidth - rect.width) / 2 - padding,
top: -(viewportHeight - rect.height) / 2 + padding,
bottom: (viewportHeight - rect.height) / 2 - padding,
};
// Snap to left or right edge
if (Math.abs(newX - snapPositions.left) < snapThreshold) {
newX = snapPositions.left;
} else if (Math.abs(newX - snapPositions.right) < snapThreshold) {
newX = snapPositions.right;
}
// Snap to top or bottom edge
if (Math.abs(newY - snapPositions.top) < snapThreshold) {
newY = snapPositions.top;
} else if (Math.abs(newY - snapPositions.bottom) < snapThreshold) {
newY = snapPositions.bottom;
}
if (newX !== position.x || newY !== position.y) {
setPosition({ x: newX, y: newY });
}
};
constrainToViewport();
window.addEventListener('resize', constrainToViewport);
return () => window.removeEventListener('resize', constrainToViewport);
}, [position, isDragging]);
const handleDragStart = () => {
setIsDragging(true);
dragStartRef.current = position;
};
const handleDrag = (_: MouseEvent | TouchEvent | PointerEvent, info: PanInfo) => {
setPosition({
x: dragStartRef.current.x + info.offset.x,
y: dragStartRef.current.y + info.offset.y
});
};
const handleDragEnd = () => {
setIsDragging(false);
};
if (!currentTrack) return null;
return (
<AnimatePresence>
<motion.div
ref={containerRef}
drag
dragMomentum={false}
dragElastic={0}
onDragStart={handleDragStart}
onDrag={handleDrag}
onDragEnd={handleDragEnd}
animate={{
x: position.x + window.innerWidth / 2,
y: position.y + window.innerHeight / 2,
scale: isDragging ? 1.02 : 1,
opacity: isDragging ? 0.8 : 1
}}
transition={{ type: 'spring', damping: 20 }}
style={{
position: 'fixed',
zIndex: 100,
transform: `translate(-50%, -50%)`
}}
className="cursor-grab active:cursor-grabbing"
onClick={handleContainerClick}
>
<div
className="backdrop-blur-sm border rounded-lg shadow-xl hover:shadow-2xl transition-shadow p-3 w-[280px]"
style={{
backgroundColor: dominantColor
? `${dominantColor.replace('rgb', 'rgba').replace(')', ', 0.15)')}`
: 'var(--background-color, rgba(0, 0, 0, 0.8))',
borderColor: dominantColor
? `${dominantColor.replace('rgb', 'rgba').replace(')', ', 0.3)')}`
: 'var(--border-color, rgba(255, 255, 255, 0.1))'
}}
>
{/* Progress bar at the top */}
<div className="mb-3" onClick={handleProgressClick}>
<Progress
value={progress}
className="h-1 cursor-pointer"
style={{
backgroundColor: dominantColor
? `${dominantColor.replace('rgb', 'rgba').replace(')', ', 0.2)')}`
: undefined,
'--progress-color': dominantColor || undefined
} as React.CSSProperties}
/>
</div>
<div className="flex items-center gap-3">
{/* Album Art - Animated transition */}
<AnimatePresence mode="wait">
<motion.div
key={currentTrack.id}
className="relative w-12 h-12 shrink-0"
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.9 }}
transition={{ duration: 0.2 }}
>
<Image
src={currentTrack.coverArt || '/default-user.jpg'}
alt={currentTrack.name}
fill
className="rounded-md object-cover shadow-md"
sizes="48px"
priority
/>
</motion.div>
</AnimatePresence>
{/* Track Info */}
<div className="flex-1 min-w-0">
<p className="font-semibold text-sm truncate">{currentTrack.name}</p>
<p className="text-xs text-muted-foreground truncate">{currentTrack.artist}</p>
</div>
</div>
{/* Keyboard shortcut hint */}
<div className="text-xs text-muted-foreground text-center mt-2 px-2">
Double-click to expand Arrow keys to move
</div>
{/* Controls */}
<div className="flex items-center justify-between mt-2 px-2">
<button
onClick={(e) => {
e.stopPropagation();
toggleCurrentTrackStar();
}}
className="p-2 hover:bg-muted/50 rounded-full transition-colors"
title={currentTrack.starred ? 'Remove from favorites' : 'Add to favorites'}
>
<Heart
className={`w-4 h-4 ${currentTrack.starred ? 'text-primary fill-primary' : ''}`}
/>
</button>
<div className="flex items-center gap-2">
<button
onClick={(e) => {
e.stopPropagation();
playPreviousTrack();
}}
className="p-2 hover:bg-muted/50 rounded-full transition-colors"
>
<FaBackward className="w-3 h-3" />
</button>
<button
onClick={(e) => {
e.stopPropagation();
togglePlayPause();
}}
className="p-3 hover:bg-muted/50 rounded-full transition-colors"
>
{isPlaying ? (
<FaPause className="w-4 h-4" />
) : (
<FaPlay className="w-4 h-4" />
)}
</button>
<button
onClick={(e) => {
e.stopPropagation();
playNextTrack();
}}
className="p-2 hover:bg-muted/50 rounded-full transition-colors"
>
<FaForward className="w-3 h-3" />
</button>
</div>
<div className="relative">
<button
onClick={(e) => {
e.stopPropagation();
setShowVolumeSlider(prev => !prev);
}}
className="p-2 hover:bg-muted/50 rounded-full transition-colors"
title="Volume"
>
{volume === 0 ? (
<FaVolumeXmark className="w-4 h-4" />
) : (
<FaVolumeHigh className="w-4 h-4" />
)}
</button>
{/* Volume Slider */}
{showVolumeSlider && (
<div
className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 p-2 bg-background/95 backdrop-blur-sm border rounded-lg shadow-lg"
onClick={e => e.stopPropagation()}
>
<input
type="range"
min="0"
max="1"
step="0.01"
value={volume}
onChange={handleVolumeChange}
className="w-24 accent-foreground"
/>
</div>
)}
</div>
</div>
{/* Expand button in top-right corner */}
<button
onClick={(e) => {
e.stopPropagation();
onExpand();
}}
className="absolute top-2 right-2 p-1.5 hover:bg-muted/50 rounded-full transition-colors"
title="Expand"
>
<FaExpand className="w-3 h-3" />
</button>
</div>
</motion.div>
</AnimatePresence>
);
};

View File

@@ -0,0 +1,627 @@
'use client';
import React, { useState, useEffect } from 'react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle, CardFooter } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Progress } from '@/components/ui/progress';
import { Badge } from '@/components/ui/badge';
import { Separator } from '@/components/ui/separator';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { useToast } from '@/hooks/use-toast';
import { useOfflineLibrary } from '@/hooks/use-offline-library';
import { useNavidrome } from '@/app/components/NavidromeContext';
import {
Download,
Trash2,
RefreshCw,
Wifi,
WifiOff,
Database,
Clock,
AlertCircle,
CheckCircle,
Music,
User,
List,
HardDrive,
Disc,
Search,
Filter,
SlidersHorizontal
} from 'lucide-react';
import { Input } from '@/components/ui/input';
import { ScrollArea } from '@/components/ui/scroll-area';
import Image from 'next/image';
import { Album, Playlist } from '@/lib/navidrome';
import { Switch } from '@/components/ui/switch';
import { Label } from '@/components/ui/label';
import { OfflineManagement } from './OfflineManagement';
import { Skeleton } from '@/components/ui/skeleton';
// Helper functions
function formatBytes(bytes: number): string {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
function formatDate(date: Date | null): string {
if (!date) return 'Never';
return date.toLocaleDateString() + ' at ' + date.toLocaleTimeString();
}
// Album card for selection
function AlbumSelectionCard({
album,
isSelected,
onToggleSelection,
isDownloading,
downloadProgress,
estimatedSize
}: {
album: Album;
isSelected: boolean;
onToggleSelection: () => void;
isDownloading: boolean;
downloadProgress?: number;
estimatedSize: string;
}) {
const { api } = useNavidrome();
return (
<Card className={`mb-3 overflow-hidden transition-all ${isSelected ? 'border-primary' : ''}`}>
<div className="flex p-3">
<div className="shrink-0">
<Image
src={album.coverArt ? (api?.getCoverArtUrl(album.coverArt) || '/default-user.jpg') : '/default-user.jpg'}
alt={album.name}
width={60}
height={60}
className="rounded-md object-cover"
/>
</div>
<div className="ml-3 flex-1 overflow-hidden">
<h4 className="font-medium truncate">{album.name}</h4>
<p className="text-sm text-muted-foreground truncate">{album.artist}</p>
<div className="flex items-center justify-between mt-2">
<span className="text-xs text-muted-foreground">{album.songCount} songs {estimatedSize}</span>
<Switch
checked={isSelected}
onCheckedChange={onToggleSelection}
disabled={isDownloading}
/>
</div>
</div>
</div>
{isDownloading && downloadProgress !== undefined && (
<Progress value={downloadProgress} className="h-1 rounded-none mt-1" />
)}
</Card>
);
}
// Playlist selection card
function PlaylistSelectionCard({
playlist,
isSelected,
onToggleSelection,
isDownloading,
downloadProgress,
estimatedSize
}: {
playlist: Playlist;
isSelected: boolean;
onToggleSelection: () => void;
isDownloading: boolean;
downloadProgress?: number;
estimatedSize: string;
}) {
const { api } = useNavidrome();
return (
<Card className={`mb-3 overflow-hidden transition-all ${isSelected ? 'border-primary' : ''}`}>
<div className="flex p-3">
<div className="shrink-0">
<div className="w-[60px] h-[60px] rounded-md bg-accent flex items-center justify-center">
<List className="h-6 w-6 text-primary" />
</div>
</div>
<div className="ml-3 flex-1 overflow-hidden">
<h4 className="font-medium truncate">{playlist.name}</h4>
<p className="text-sm text-muted-foreground truncate">by {playlist.owner}</p>
<div className="flex items-center justify-between mt-2">
<span className="text-xs text-muted-foreground">{playlist.songCount} songs {estimatedSize}</span>
<Switch
checked={isSelected}
onCheckedChange={onToggleSelection}
disabled={isDownloading}
/>
</div>
</div>
</div>
{isDownloading && downloadProgress !== undefined && (
<Progress value={downloadProgress} className="h-1 rounded-none mt-1" />
)}
</Card>
);
}
export default function EnhancedOfflineManager() {
const { toast } = useToast();
const [activeTab, setActiveTab] = useState('overview');
const [albums, setAlbums] = useState<Album[]>([]);
const [playlists, setPlaylists] = useState<Playlist[]>([]);
const [loading, setLoading] = useState({
albums: false,
playlists: false
});
const [searchQuery, setSearchQuery] = useState('');
const [selectedAlbums, setSelectedAlbums] = useState<Set<string>>(new Set());
const [selectedPlaylists, setSelectedPlaylists] = useState<Set<string>>(new Set());
const [downloadingItems, setDownloadingItems] = useState<Map<string, number>>(new Map());
// Filter state
const [sortBy, setSortBy] = useState('recent');
const [filtersVisible, setFiltersVisible] = useState(false);
const offline = useOfflineLibrary();
const { api } = useNavidrome();
// Load albums and playlists
// ...existing code...
// ...existing code...
// Place useEffect after the first (and only) declarations of loadAlbums and loadPlaylists
// Load albums data
const loadAlbums = async () => {
setLoading(prev => ({ ...prev, albums: true }));
try {
const albumData = await offline.getAlbums();
setAlbums(albumData);
// Load previously selected albums from localStorage
const savedSelections = localStorage.getItem('navidrome-offline-albums');
if (savedSelections) {
setSelectedAlbums(new Set(JSON.parse(savedSelections)));
}
} catch (error) {
console.error('Failed to load albums:', error);
toast({
title: 'Error',
description: 'Failed to load albums. Please try again.',
variant: 'destructive'
});
} finally {
setLoading(prev => ({ ...prev, albums: false }));
}
};
// Load playlists data
const loadPlaylists = async () => {
setLoading(prev => ({ ...prev, playlists: true }));
try {
const playlistData = await offline.getPlaylists();
setPlaylists(playlistData);
// Load previously selected playlists from localStorage
const savedSelections = localStorage.getItem('navidrome-offline-playlists');
if (savedSelections) {
setSelectedPlaylists(new Set(JSON.parse(savedSelections)));
}
} catch (error) {
console.error('Failed to load playlists:', error);
toast({
title: 'Error',
description: 'Failed to load playlists. Please try again.',
variant: 'destructive'
});
} finally {
setLoading(prev => ({ ...prev, playlists: false }));
}
};
// Toggle album selection
const toggleAlbumSelection = (albumId: string) => {
setSelectedAlbums(prev => {
const newSelection = new Set(prev);
if (newSelection.has(albumId)) {
newSelection.delete(albumId);
} else {
newSelection.add(albumId);
}
// Save to localStorage
localStorage.setItem('navidrome-offline-albums', JSON.stringify([...newSelection]));
return newSelection;
});
};
// Toggle playlist selection
const togglePlaylistSelection = (playlistId: string) => {
setSelectedPlaylists(prev => {
const newSelection = new Set(prev);
if (newSelection.has(playlistId)) {
newSelection.delete(playlistId);
} else {
newSelection.add(playlistId);
}
// Save to localStorage
localStorage.setItem('navidrome-offline-playlists', JSON.stringify([...newSelection]));
return newSelection;
});
};
// Download selected items
const downloadSelected = async () => {
// Mock implementation - in a real implementation, you'd integrate with the download system
const selectedIds = [...selectedAlbums, ...selectedPlaylists];
if (selectedIds.length === 0) {
toast({
title: 'No items selected',
description: 'Please select albums or playlists to download.',
});
return;
}
toast({
title: 'Download Started',
description: `Downloading ${selectedIds.length} items for offline use.`,
});
// Mock download progress
const downloadMap = new Map<string, number>();
selectedIds.forEach(id => downloadMap.set(id, 0));
setDownloadingItems(downloadMap);
// Simulate download progress
const interval = setInterval(() => {
setDownloadingItems(prev => {
const updated = new Map(prev);
let allComplete = true;
for (const [id, progress] of prev.entries()) {
if (progress < 100) {
updated.set(id, Math.min(progress + Math.random() * 10, 100));
allComplete = false;
}
}
if (allComplete) {
clearInterval(interval);
toast({
title: 'Download Complete',
description: `${selectedIds.length} items are now available offline.`,
});
setTimeout(() => {
setDownloadingItems(new Map());
}, 1000);
}
return updated;
});
}, 500);
};
// Filter and sort albums
const filteredAlbums = albums
.filter(album => {
if (!searchQuery) return true;
return album.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
album.artist.toLowerCase().includes(searchQuery.toLowerCase());
})
.sort((a, b) => {
switch (sortBy) {
case 'recent':
return new Date(b.created || '').getTime() - new Date(a.created || '').getTime();
case 'name':
return a.name.localeCompare(b.name);
case 'artist':
return a.artist.localeCompare(b.artist);
default:
return 0;
}
});
// Filter and sort playlists
const filteredPlaylists = playlists
.filter(playlist => {
if (!searchQuery) return true;
return playlist.name.toLowerCase().includes(searchQuery.toLowerCase());
})
.sort((a, b) => {
switch (sortBy) {
case 'recent':
return new Date(b.changed || '').getTime() - new Date(a.changed || '').getTime();
case 'name':
return a.name.localeCompare(b.name);
default:
return 0;
}
});
// Estimate album size (mock implementation)
const estimateSize = (songCount: number) => {
const averageSongSizeMB = 8;
const totalSizeMB = songCount * averageSongSizeMB;
if (totalSizeMB > 1000) {
return `${(totalSizeMB / 1000).toFixed(1)} GB`;
}
return `${totalSizeMB.toFixed(0)} MB`;
};
return (
<Tabs
value={activeTab}
onValueChange={setActiveTab}
className="space-y-4"
>
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="albums">Albums</TabsTrigger>
<TabsTrigger value="playlists">Playlists</TabsTrigger>
</TabsList>
<TabsContent value="overview">
<OfflineManagement />
</TabsContent>
<TabsContent value="albums" className="space-y-4">
<Card className="mb-6 break-inside-avoid py-5">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Disc className="h-5 w-5" />
Select Albums
</CardTitle>
<CardDescription>
Choose albums to make available offline
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex flex-col sm:flex-row gap-2">
<div className="relative flex-1">
<Search className="absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="Search albums..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-8"
/>
</div>
<Button
variant="outline"
size="sm"
onClick={() => setFiltersVisible(!filtersVisible)}
>
<SlidersHorizontal className="h-4 w-4 mr-2" />
Filter
</Button>
</div>
{filtersVisible && (
<div className="p-3 border rounded-md bg-muted/30">
<div className="space-y-2">
<div className="text-sm font-medium">Sort By</div>
<div className="flex flex-wrap gap-2">
<Button
variant={sortBy === 'recent' ? 'default' : 'outline'}
size="sm"
onClick={() => setSortBy('recent')}
>
Recent
</Button>
<Button
variant={sortBy === 'name' ? 'default' : 'outline'}
size="sm"
onClick={() => setSortBy('name')}
>
Name
</Button>
<Button
variant={sortBy === 'artist' ? 'default' : 'outline'}
size="sm"
onClick={() => setSortBy('artist')}
>
Artist
</Button>
</div>
</div>
</div>
)}
<div className="flex items-center justify-between">
<div className="text-sm text-muted-foreground">
{selectedAlbums.size} album{selectedAlbums.size !== 1 ? 's' : ''} selected
</div>
<Button
variant="outline"
size="sm"
onClick={() => setSelectedAlbums(new Set())}
disabled={selectedAlbums.size === 0}
>
Clear Selection
</Button>
</div>
<ScrollArea className="h-[calc(100vh-350px)] pr-4 -mr-4">
{loading.albums ? (
// Loading skeletons
Array.from({ length: 5 }).map((_, i) => (
<Card key={i} className="mb-3">
<div className="flex p-3">
<Skeleton className="h-[60px] w-[60px] rounded-md" />
<div className="ml-3 flex-1">
<Skeleton className="h-5 w-2/3 mb-1" />
<Skeleton className="h-4 w-1/2 mb-2" />
<Skeleton className="h-4 w-3/4" />
</div>
</div>
</Card>
))
) : filteredAlbums.length > 0 ? (
filteredAlbums.map(album => (
<AlbumSelectionCard
key={album.id}
album={album}
isSelected={selectedAlbums.has(album.id)}
onToggleSelection={() => toggleAlbumSelection(album.id)}
isDownloading={downloadingItems.has(album.id)}
downloadProgress={downloadingItems.get(album.id)}
estimatedSize={estimateSize(album.songCount)}
/>
))
) : (
<div className="text-center py-8">
<Disc className="h-16 w-16 mx-auto text-muted-foreground mb-4" />
<p className="text-muted-foreground">
{searchQuery ? 'No albums found matching your search' : 'No albums available'}
</p>
</div>
)}
</ScrollArea>
</CardContent>
<CardFooter>
<Button
className="w-full"
onClick={downloadSelected}
disabled={selectedAlbums.size === 0 || downloadingItems.size > 0}
>
<Download className="h-4 w-4 mr-2" />
Download {selectedAlbums.size} Selected Album{selectedAlbums.size !== 1 ? 's' : ''}
</Button>
</CardFooter>
</Card>
</TabsContent>
<TabsContent value="playlists" className="space-y-4">
<Card className="mb-6 break-inside-avoid py-5">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<List className="h-5 w-5" />
Select Playlists
</CardTitle>
<CardDescription>
Choose playlists to make available offline
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex flex-col sm:flex-row gap-2">
<div className="relative flex-1">
<Search className="absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="Search playlists..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-8"
/>
</div>
<Button
variant="outline"
size="sm"
onClick={() => setFiltersVisible(!filtersVisible)}
>
<SlidersHorizontal className="h-4 w-4 mr-2" />
Filter
</Button>
</div>
{filtersVisible && (
<div className="p-3 border rounded-md bg-muted/30">
<div className="space-y-2">
<div className="text-sm font-medium">Sort By</div>
<div className="flex flex-wrap gap-2">
<Button
variant={sortBy === 'recent' ? 'default' : 'outline'}
size="sm"
onClick={() => setSortBy('recent')}
>
Recent
</Button>
<Button
variant={sortBy === 'name' ? 'default' : 'outline'}
size="sm"
onClick={() => setSortBy('name')}
>
Name
</Button>
</div>
</div>
</div>
)}
<div className="flex items-center justify-between">
<div className="text-sm text-muted-foreground">
{selectedPlaylists.size} playlist{selectedPlaylists.size !== 1 ? 's' : ''} selected
</div>
<Button
variant="outline"
size="sm"
onClick={() => setSelectedPlaylists(new Set())}
disabled={selectedPlaylists.size === 0}
>
Clear Selection
</Button>
</div>
<ScrollArea className="h-[calc(100vh-350px)] pr-4 -mr-4">
{loading.playlists ? (
// Loading skeletons
Array.from({ length: 5 }).map((_, i) => (
<Card key={i} className="mb-3">
<div className="flex p-3">
<Skeleton className="h-[60px] w-[60px] rounded-md" />
<div className="ml-3 flex-1">
<Skeleton className="h-5 w-2/3 mb-1" />
<Skeleton className="h-4 w-1/2 mb-2" />
<Skeleton className="h-4 w-3/4" />
</div>
</div>
</Card>
))
) : filteredPlaylists.length > 0 ? (
filteredPlaylists.map(playlist => (
<PlaylistSelectionCard
key={playlist.id}
playlist={playlist}
isSelected={selectedPlaylists.has(playlist.id)}
onToggleSelection={() => togglePlaylistSelection(playlist.id)}
isDownloading={downloadingItems.has(playlist.id)}
downloadProgress={downloadingItems.get(playlist.id)}
estimatedSize={estimateSize(playlist.songCount)}
/>
))
) : (
<div className="text-center py-8">
<List className="h-16 w-16 mx-auto text-muted-foreground mb-4" />
<p className="text-muted-foreground">
{searchQuery ? 'No playlists found matching your search' : 'No playlists available'}
</p>
</div>
)}
</ScrollArea>
</CardContent>
<CardFooter>
<Button
className="w-full"
onClick={downloadSelected}
disabled={selectedPlaylists.size === 0 || downloadingItems.size > 0}
>
<Download className="h-4 w-4 mr-2" />
Download {selectedPlaylists.size} Selected Playlist{selectedPlaylists.size !== 1 ? 's' : ''}
</Button>
</CardFooter>
</Card>
</TabsContent>
</Tabs>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,46 @@
'use client';
import React, { createContext, useContext, useState, useCallback } from 'react';
import { SpotlightSearch } from './SpotlightSearch';
interface GlobalSearchContextProps {
isSpotlightOpen: boolean;
openSpotlight: () => void;
closeSpotlight: () => void;
}
const GlobalSearchContext = createContext<GlobalSearchContextProps | undefined>(undefined);
export function GlobalSearchProvider({ children }: { children: React.ReactNode }) {
const [isSpotlightOpen, setIsSpotlightOpen] = useState(false);
const openSpotlight = useCallback(() => {
setIsSpotlightOpen(true);
}, []);
const closeSpotlight = useCallback(() => {
setIsSpotlightOpen(false);
}, []);
return (
<GlobalSearchContext.Provider value={{
isSpotlightOpen,
openSpotlight,
closeSpotlight
}}>
{children}
<SpotlightSearch
isOpen={isSpotlightOpen}
onClose={closeSpotlight}
/>
</GlobalSearchContext.Provider>
);
}
export function useGlobalSearch() {
const context = useContext(GlobalSearchContext);
if (!context) {
throw new Error('useGlobalSearch must be used within a GlobalSearchProvider');
}
return context;
}

View File

@@ -0,0 +1,153 @@
'use client';
import { useEffect, useState } from 'react';
import { useListeningStreak } from '@/hooks/use-listening-streak';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Progress } from '@/components/ui/progress';
import { Flame, Calendar, Clock, Music, Disc, User2 } from 'lucide-react';
import { AnimatePresence, motion } from 'framer-motion';
import { cn } from '@/lib/utils';
export default function ListeningStreakCard() {
const { stats, hasListenedToday, getStreakEmoji, getTodaySummary, streakThresholds } = useListeningStreak();
const [animate, setAnimate] = useState(false);
// Trigger animation when streak increases
useEffect(() => {
if (stats.currentStreak > 0) {
setAnimate(true);
const timer = setTimeout(() => setAnimate(false), 1000);
return () => clearTimeout(timer);
}
}, [stats.currentStreak]);
const todaySummary = getTodaySummary();
const hasCompletedToday = hasListenedToday();
// Calculate progress towards today's goal
const trackProgress = Math.min(100, (todaySummary.tracks / streakThresholds.tracks) * 100);
const timeInMinutes = parseInt(todaySummary.time.replace('m', ''), 10) || 0;
const timeThresholdMinutes = Math.floor(streakThresholds.time / 60);
const timeProgress = Math.min(100, (timeInMinutes / timeThresholdMinutes) * 100);
// Overall progress (highest of the two metrics)
const overallProgress = Math.max(trackProgress, timeProgress);
return (
<Card className="mb-6 break-inside-avoid py-5">
<CardHeader className="pb-2">
<CardTitle className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Flame className={cn(
"w-5 h-5 transition-all",
hasCompletedToday ? "text-amber-500" : "text-muted-foreground"
)} />
<span>Listening Streak</span>
</div>
<div className="flex items-center gap-2">
<Calendar className="w-4 h-4 text-muted-foreground" />
<span className="text-sm font-normal text-muted-foreground">
{stats.totalDaysListened} days
</span>
</div>
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-col items-center py-2">
<AnimatePresence>
<motion.div
key={stats.currentStreak}
initial={{ scale: animate ? 0.5 : 1 }}
animate={{ scale: 1 }}
exit={{ scale: 0.5 }}
className="relative mb-2"
>
<div className="text-5xl font-bold text-center">
{stats.currentStreak}
</div>
<div className="text-sm text-center text-muted-foreground">
day{stats.currentStreak !== 1 ? 's' : ''} streak
</div>
{getStreakEmoji() && (
<motion.div
className="absolute -top-2 -right-4 text-2xl"
animate={{ rotate: animate ? [0, 15, -15, 0] : 0 }}
transition={{ duration: 0.5 }}
>
{getStreakEmoji()}
</motion.div>
)}
</motion.div>
</AnimatePresence>
<div className="w-full mt-4">
<div className="flex justify-between items-center text-sm mb-1">
<span className="text-muted-foreground">Today&apos;s Progress</span>
<span className={cn(
hasCompletedToday ? "text-green-500 font-medium" : "text-muted-foreground"
)}>
{hasCompletedToday ? "Complete!" : "In progress..."}
</span>
</div>
<Progress
value={overallProgress}
className={cn(
"h-2",
hasCompletedToday ? "bg-green-500/20" : "",
hasCompletedToday ? "[&>div]:bg-green-500" : ""
)}
/>
</div>
<div className="grid grid-cols-2 gap-4 w-full mt-6">
<div className="flex flex-col items-center p-3 rounded-md bg-accent/30">
<div className="flex items-center gap-2 mb-1">
<Music className="w-4 h-4 text-muted-foreground" />
<span className="text-sm text-muted-foreground">Tracks</span>
</div>
<span className="text-xl font-semibold">{todaySummary.tracks}</span>
<span className="text-xs text-muted-foreground">
Goal: {streakThresholds.tracks}
</span>
</div>
<div className="flex flex-col items-center p-3 rounded-md bg-accent/30">
<div className="flex items-center gap-2 mb-1">
<Clock className="w-4 h-4 text-muted-foreground" />
<span className="text-sm text-muted-foreground">Time</span>
</div>
<span className="text-xl font-semibold">{todaySummary.time}</span>
<span className="text-xs text-muted-foreground">
Goal: {timeThresholdMinutes}m
</span>
</div>
</div>
<div className="grid grid-cols-2 gap-4 w-full mt-4">
<div className="flex flex-col items-center p-3 rounded-md bg-accent/20">
<div className="flex items-center gap-2 mb-1">
<User2 className="w-4 h-4 text-muted-foreground" />
<span className="text-sm text-muted-foreground">Artists</span>
</div>
<span className="text-xl font-semibold">{todaySummary.artists}</span>
</div>
<div className="flex flex-col items-center p-3 rounded-md bg-accent/20">
<div className="flex items-center gap-2 mb-1">
<Disc className="w-4 h-4 text-muted-foreground" />
<span className="text-sm text-muted-foreground">Albums</span>
</div>
<span className="text-xl font-semibold">{todaySummary.albums}</span>
</div>
</div>
<div className="mt-4 text-xs text-center text-muted-foreground">
{hasCompletedToday ? (
<span>You&#39;ve met your daily listening goal! 🎵</span>
) : (
<span>Listen to {streakThresholds.tracks} tracks or {timeThresholdMinutes} minutes to continue your streak!</span>
)}
</div>
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,226 @@
'use client';
import React, { useState, useEffect } from 'react';
import { Download, Check, X, Loader2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import { useOfflineDownloads } from '@/hooks/use-offline-downloads';
interface OfflineIndicatorProps {
id: string;
type: 'album' | 'song';
className?: string;
showLabel?: boolean;
size?: 'sm' | 'md' | 'lg';
}
export function OfflineIndicator({
id,
type,
className,
showLabel = false,
size = 'md'
}: OfflineIndicatorProps) {
const [isOffline, setIsOffline] = useState(false);
const [isChecking, setIsChecking] = useState(true);
const { checkOfflineStatus, isInitialized } = useOfflineDownloads();
useEffect(() => {
let mounted = true;
const checkStatus = async () => {
if (!isInitialized) return;
setIsChecking(true);
try {
const status = await checkOfflineStatus(id, type);
if (mounted) {
setIsOffline(status);
}
} catch (error) {
console.error('Failed to check offline status:', error);
if (mounted) {
setIsOffline(false);
}
} finally {
if (mounted) {
setIsChecking(false);
}
}
};
checkStatus();
return () => {
mounted = false;
};
}, [id, type, isInitialized, checkOfflineStatus]);
const iconSize = {
sm: 'h-3 w-3',
md: 'h-4 w-4',
lg: 'h-5 w-5'
}[size];
const textSize = {
sm: 'text-xs',
md: 'text-sm',
lg: 'text-base'
}[size];
if (isChecking) {
return (
<div className={cn('flex items-center gap-1 text-muted-foreground', className)}>
<Loader2 className={cn(iconSize, 'animate-spin')} />
{showLabel && <span className={textSize}>Checking...</span>}
</div>
);
}
if (!isOffline) {
return null; // Don't show anything if not downloaded
}
return (
<div className={cn('flex items-center gap-1 text-green-600', className)}>
<Download className={iconSize} />
{showLabel && (
<span className={textSize}>
{type === 'album' ? 'Album Downloaded' : 'Downloaded'}
</span>
)}
</div>
);
}
interface DownloadButtonProps {
id: string;
type: 'album' | 'song';
onDownload?: () => void;
className?: string;
size?: 'sm' | 'md' | 'lg';
variant?: 'default' | 'outline' | 'ghost';
children?: React.ReactNode;
}
export function DownloadButton({
id,
type,
onDownload,
className,
size = 'md',
variant = 'outline',
children
}: DownloadButtonProps) {
const [isOffline, setIsOffline] = useState(false);
const [isChecking, setIsChecking] = useState(true);
const {
checkOfflineStatus,
deleteOfflineContent,
isInitialized,
downloadProgress
} = useOfflineDownloads();
const isDownloading = downloadProgress.status === 'downloading' || downloadProgress.status === 'starting';
useEffect(() => {
let mounted = true;
const checkStatus = async () => {
if (!isInitialized) return;
setIsChecking(true);
try {
const status = await checkOfflineStatus(id, type);
if (mounted) {
setIsOffline(status);
}
} catch (error) {
console.error('Failed to check offline status:', error);
if (mounted) {
setIsOffline(false);
}
} finally {
if (mounted) {
setIsChecking(false);
}
}
};
checkStatus();
return () => {
mounted = false;
};
}, [id, type, isInitialized, checkOfflineStatus]);
const handleClick = async () => {
if (isOffline) {
// Remove from offline storage
try {
await deleteOfflineContent(id, type);
setIsOffline(false);
} catch (error) {
console.error('Failed to delete offline content:', error);
}
} else {
// Start download
if (onDownload) {
onDownload();
}
}
};
const buttonSize = {
sm: 'sm',
md: 'default',
lg: 'lg'
}[size] as 'sm' | 'default' | 'lg';
const iconSize = {
sm: 'h-3 w-3',
md: 'h-4 w-4',
lg: 'h-5 w-5'
}[size];
if (isChecking) {
return (
<Button
variant={variant}
size={buttonSize}
disabled
className={className}
>
<Loader2 className={cn(iconSize, 'animate-spin mr-2')} />
{children || 'Checking...'}
</Button>
);
}
return (
<Button
variant={variant}
size={buttonSize}
onClick={handleClick}
disabled={isDownloading}
className={className}
>
{isDownloading ? (
<>
<Loader2 className={cn(iconSize, 'animate-spin mr-2')} />
{children || 'Downloading...'}
</>
) : isOffline ? (
<>
<X className={cn(iconSize, 'mr-2')} />
{children || 'Remove Download'}
</>
) : (
<>
<Download className={cn(iconSize, 'mr-2')} />
{children || 'Download'}
</>
)}
</Button>
);
}

View File

View File

@@ -0,0 +1,395 @@
'use client';
import React, { useState, useEffect } from 'react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Progress } from '@/components/ui/progress';
import { Badge } from '@/components/ui/badge';
import { Separator } from '@/components/ui/separator';
import { useToast } from '@/hooks/use-toast';
import { useOfflineLibrary } from '@/hooks/use-offline-library';
import {
Download,
Trash2,
RefreshCw,
Wifi,
WifiOff,
Database,
Clock,
AlertCircle,
CheckCircle,
Music,
User,
List,
HardDrive
} from 'lucide-react';
function formatBytes(bytes: number): string {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
function formatDate(date: Date | null): string {
if (!date) return 'Never';
return date.toLocaleDateString() + ' at ' + date.toLocaleTimeString();
}
export function OfflineManagement() {
const { toast } = useToast();
const [isClearing, setIsClearing] = useState(false);
const {
isInitialized,
isOnline,
isSyncing,
lastSync,
stats,
syncProgress,
syncLibraryFromServer,
syncPendingOperations,
clearOfflineData,
refreshStats
} = useOfflineLibrary();
// Refresh stats periodically
useEffect(() => {
const interval = setInterval(() => {
if (isInitialized && !isSyncing) {
refreshStats();
}
}, 10000); // Every 10 seconds
return () => clearInterval(interval);
}, [isInitialized, isSyncing, refreshStats]);
const handleFullSync = async () => {
try {
await syncLibraryFromServer();
toast({
title: "Sync Complete",
description: "Your music library has been synced for offline use.",
});
} catch (error) {
console.error('Full sync failed:', error);
toast({
title: "Sync Failed",
description: "Failed to sync library. Check your connection and try again.",
variant: "destructive"
});
}
};
const handlePendingSync = async () => {
try {
await syncPendingOperations();
toast({
title: "Pending Operations Synced",
description: "All pending changes have been synced to the server.",
});
} catch (error) {
console.error('Pending sync failed:', error);
toast({
title: "Sync Failed",
description: "Failed to sync pending operations. Will retry automatically when online.",
variant: "destructive"
});
}
};
const handleClearData = async () => {
if (!confirm('Are you sure you want to clear all offline data? This cannot be undone.')) {
return;
}
setIsClearing(true);
try {
await clearOfflineData();
toast({
title: "Offline Data Cleared",
description: "All offline music data has been removed.",
});
} catch (error) {
console.error('Clear data failed:', error);
toast({
title: "Clear Failed",
description: "Failed to clear offline data. Please try again.",
variant: "destructive"
});
} finally {
setIsClearing(false);
}
};
if (!isInitialized) {
return (
<Card className="mb-6 break-inside-avoid py-5">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Database className="h-5 w-5" />
Offline Library
</CardTitle>
<CardDescription>
Setting up offline library...
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex items-center justify-center py-8">
<div className="text-center">
<Database className="h-12 w-12 mx-auto mb-4 text-muted-foreground animate-pulse" />
<p className="text-muted-foreground">Initializing offline storage...</p>
</div>
</div>
</CardContent>
</Card>
);
}
return (
<div className="space-y-6">
{/* Connection Status */}
<Card className="mb-6 break-inside-avoid py-5">
<CardHeader>
<CardTitle className="flex items-center gap-2">
{isOnline ? (
<Wifi className="h-5 w-5 text-green-500" />
) : (
<WifiOff className="h-5 w-5 text-red-500" />
)}
Connection Status
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Badge variant={isOnline ? "default" : "destructive"}>
{isOnline ? "Online" : "Offline"}
</Badge>
<span className="text-sm text-muted-foreground">
{isOnline ? "Connected to Navidrome server" : "Working offline"}
</span>
</div>
{stats.pendingOperations > 0 && (
<div className="flex items-center gap-2">
<AlertCircle className="h-4 w-4 text-yellow-500" />
<span className="text-sm text-yellow-600">
{stats.pendingOperations} pending operation{stats.pendingOperations !== 1 ? 's' : ''}
</span>
</div>
)}
</div>
</CardContent>
</Card>
{/* Sync Status */}
<Card className="mb-6 break-inside-avoid py-5">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<RefreshCw className="h-5 w-5" />
Library Sync
</CardTitle>
<CardDescription>
Keep your offline library up to date
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{isSyncing && syncProgress && (
<div className="space-y-2">
<div className="flex items-center justify-between text-sm">
<span>{syncProgress.stage}</span>
<span>{syncProgress.current}%</span>
</div>
<Progress value={syncProgress.current} className="w-full" />
</div>
)}
<div className="flex items-center justify-between">
<div className="space-y-1">
<p className="text-sm font-medium">Last Sync</p>
<p className="text-sm text-muted-foreground flex items-center gap-1">
<Clock className="h-3 w-3" />
{formatDate(lastSync)}
</p>
</div>
<div className="flex gap-2">
{stats.pendingOperations > 0 && isOnline && (
<Button
variant="outline"
size="sm"
onClick={handlePendingSync}
disabled={isSyncing}
>
<RefreshCw className="h-4 w-4 mr-1" />
Sync Pending ({stats.pendingOperations})
</Button>
)}
<Button
onClick={handleFullSync}
disabled={!isOnline || isSyncing}
size="sm"
>
<Download className="h-4 w-4 mr-1" />
{isSyncing ? 'Syncing...' : 'Full Sync'}
</Button>
</div>
</div>
</CardContent>
</Card>
{/* Library Statistics */}
<Card className="mb-6 break-inside-avoid py-5">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Database className="h-5 w-5" />
Offline Library Stats
</CardTitle>
<CardDescription>
Your offline music collection
</CardDescription>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="text-center space-y-2">
<div className="flex items-center justify-center">
<Music className="h-8 w-8 text-blue-500" />
</div>
<div>
<p className="text-2xl font-bold">{stats.albums.toLocaleString()}</p>
<p className="text-sm text-muted-foreground">Albums</p>
</div>
</div>
<div className="text-center space-y-2">
<div className="flex items-center justify-center">
<User className="h-8 w-8 text-green-500" />
</div>
<div>
<p className="text-2xl font-bold">{stats.artists.toLocaleString()}</p>
<p className="text-sm text-muted-foreground">Artists</p>
</div>
</div>
<div className="text-center space-y-2">
<div className="flex items-center justify-center">
<Music className="h-8 w-8 text-purple-500" />
</div>
<div>
<p className="text-2xl font-bold">{stats.songs.toLocaleString()}</p>
<p className="text-sm text-muted-foreground">Songs</p>
</div>
</div>
<div className="text-center space-y-2">
<div className="flex items-center justify-center">
<List className="h-8 w-8 text-orange-500" />
</div>
<div>
<p className="text-2xl font-bold">{stats.playlists.toLocaleString()}</p>
<p className="text-sm text-muted-foreground">Playlists</p>
</div>
</div>
</div>
<Separator className="my-4" />
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<HardDrive className="h-4 w-4" />
<span className="text-sm font-medium">Storage Used</span>
</div>
<span className="text-sm text-muted-foreground">
{formatBytes(stats.storageSize)}
</span>
</div>
</CardContent>
</Card>
{/* Offline Features */}
<Card className="mb-6 break-inside-avoid py-5">
<CardHeader>
<CardTitle className='flex items-center gap-2'>Offline Features</CardTitle>
<CardDescription>
What works when you&apos;re offline
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-3">
<div className="flex items-center gap-3">
<CheckCircle className="h-5 w-5 text-green-500" />
<div>
<p className="font-medium">Browse & Search</p>
<p className="text-sm text-muted-foreground">
Browse your synced albums, artists, and search offline
</p>
</div>
</div>
<div className="flex items-center gap-3">
<CheckCircle className="h-5 w-5 text-green-500" />
<div>
<p className="font-medium">Favorites & Playlists</p>
<p className="text-sm text-muted-foreground">
Star songs/albums and create playlists (syncs when online)
</p>
</div>
</div>
<div className="flex items-center gap-3">
<CheckCircle className="h-5 w-5 text-green-500" />
<div>
<p className="font-medium">Play Downloaded Music</p>
<p className="text-sm text-muted-foreground">
Play songs you&apos;ve downloaded for offline listening
</p>
</div>
</div>
<div className="flex items-center gap-3">
<CheckCircle className="h-5 w-5 text-green-500" />
<div>
<p className="font-medium">Auto-Sync</p>
<p className="text-sm text-muted-foreground">
Changes sync automatically when you reconnect
</p>
</div>
</div>
</div>
</CardContent>
</Card>
{/* Danger Zone */}
<Card className="mb-6 break-inside-avoid py-5 border-red-200">
<CardHeader>
<CardTitle className="text-red-600 flex items-center gap-2">Danger Zone</CardTitle>
<CardDescription>
Permanently delete all offline data
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between">
<div>
<p className="font-medium">Clear All Offline Data</p>
<p className="text-sm text-muted-foreground">
This will remove all synced library data and downloaded audio
</p>
</div>
<Button
variant="destructive"
onClick={handleClearData}
disabled={isClearing}
>
<Trash2 className="h-4 w-4 mr-1" />
{isClearing ? 'Clearing...' : 'Clear Data'}
</Button>
</div>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,367 @@
'use client';
import React, { createContext, useContext, useEffect, useState, ReactNode, useCallback } from 'react';
import { Album, Artist, Song, Playlist, AlbumInfo, ArtistInfo } from '@/lib/navidrome';
import { useNavidrome } from '@/app/components/NavidromeContext';
import { useOfflineLibrary } from '@/hooks/use-offline-library';
interface OfflineNavidromeContextType {
// Data (offline-first)
albums: Album[];
artists: Artist[];
playlists: Playlist[];
// Loading states
isLoading: boolean;
albumsLoading: boolean;
artistsLoading: boolean;
playlistsLoading: boolean;
// Connection state
isOnline: boolean;
isOfflineReady: boolean;
// Error states
error: string | null;
// Offline sync status
isSyncing: boolean;
lastSync: Date | null;
pendingOperations: number;
// Methods (offline-aware)
searchMusic: (query: string) => Promise<{ artists: Artist[]; albums: Album[]; songs: Song[] }>;
getAlbum: (albumId: string) => Promise<{ album: Album; songs: Song[] } | null>;
getArtist: (artistId: string) => Promise<{ artist: Artist; albums: Album[] } | null>;
getPlaylists: () => Promise<Playlist[]>;
refreshData: () => Promise<void>;
// Offline-capable operations
starItem: (id: string, type: 'song' | 'album' | 'artist') => Promise<void>;
unstarItem: (id: string, type: 'song' | 'album' | 'artist') => Promise<void>;
createPlaylist: (name: string, songIds?: string[]) => Promise<Playlist>;
scrobble: (songId: string) => Promise<void>;
// Sync management
syncLibrary: () => Promise<void>;
syncPendingOperations: () => Promise<void>;
clearOfflineData: () => Promise<void>;
}
const OfflineNavidromeContext = createContext<OfflineNavidromeContextType | undefined>(undefined);
interface OfflineNavidromeProviderProps {
children: ReactNode;
}
export const OfflineNavidromeProvider: React.FC<OfflineNavidromeProviderProps> = ({ children }) => {
const [albums, setAlbums] = useState<Album[]>([]);
const [artists, setArtists] = useState<Artist[]>([]);
const [playlists, setPlaylists] = useState<Playlist[]>([]);
const [albumsLoading, setAlbumsLoading] = useState(false);
const [artistsLoading, setArtistsLoading] = useState(false);
const [playlistsLoading, setPlaylistsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// Use the original Navidrome context for online operations
const originalNavidrome = useNavidrome();
// Use offline library for offline operations
const {
isInitialized: isOfflineReady,
isOnline,
isSyncing,
lastSync,
stats,
syncLibraryFromServer,
syncPendingOperations: syncPendingOps,
getAlbums: getAlbumsOffline,
getArtists: getArtistsOffline,
getAlbum: getAlbumOffline,
getPlaylists: getPlaylistsOffline,
searchOffline,
starOffline,
unstarOffline,
createPlaylistOffline,
scrobbleOffline,
clearOfflineData: clearOfflineDataInternal,
refreshStats
} = useOfflineLibrary();
const isLoading = albumsLoading || artistsLoading || playlistsLoading;
const pendingOperations = stats.pendingOperations;
// Load initial data (offline-first approach)
const loadAlbums = useCallback(async () => {
setAlbumsLoading(true);
setError(null);
try {
const albumData = await getAlbumsOffline();
setAlbums(albumData);
} catch (err) {
console.error('Failed to load albums:', err);
setError('Failed to load albums');
} finally {
setAlbumsLoading(false);
}
}, [getAlbumsOffline]);
const loadArtists = useCallback(async () => {
setArtistsLoading(true);
setError(null);
try {
const artistData = await getArtistsOffline();
setArtists(artistData);
} catch (err) {
console.error('Failed to load artists:', err);
setError('Failed to load artists');
} finally {
setArtistsLoading(false);
}
}, [getArtistsOffline]);
const loadPlaylists = useCallback(async () => {
setPlaylistsLoading(true);
setError(null);
try {
const playlistData = await getPlaylistsOffline();
setPlaylists(playlistData);
} catch (err) {
console.error('Failed to load playlists:', err);
setError('Failed to load playlists');
} finally {
setPlaylistsLoading(false);
}
}, [getPlaylistsOffline]);
const refreshData = useCallback(async () => {
await Promise.all([loadAlbums(), loadArtists(), loadPlaylists()]);
await refreshStats();
}, [loadAlbums, loadArtists, loadPlaylists, refreshStats]);
// Initialize data when offline library is ready
useEffect(() => {
if (isOfflineReady) {
refreshData();
}
}, [isOfflineReady, refreshData]);
// Auto-sync when coming back online
useEffect(() => {
if (isOnline && isOfflineReady && pendingOperations > 0) {
console.log('Back online with pending operations, starting sync...');
syncPendingOps();
}
}, [isOnline, isOfflineReady, pendingOperations, syncPendingOps]);
// Offline-first methods
const searchMusic = useCallback(async (query: string) => {
setError(null);
try {
return await searchOffline(query);
} catch (err) {
console.error('Search failed:', err);
setError('Search failed');
return { artists: [], albums: [], songs: [] };
}
}, [searchOffline]);
const getAlbum = useCallback(async (albumId: string) => {
setError(null);
try {
return await getAlbumOffline(albumId);
} catch (err) {
console.error('Failed to get album:', err);
setError('Failed to get album');
return null;
}
}, [getAlbumOffline]);
const getArtist = useCallback(async (artistId: string): Promise<{ artist: Artist; albums: Album[] } | null> => {
setError(null);
try {
// For now, use the original implementation if online, or search offline
if (isOnline && originalNavidrome.api) {
return await originalNavidrome.getArtist(artistId);
} else {
// Try to find artist in offline data
const allArtists = await getArtistsOffline();
const artist = allArtists.find(a => a.id === artistId);
if (!artist) return null;
const allAlbums = await getAlbumsOffline();
const artistAlbums = allAlbums.filter(a => a.artistId === artistId);
return { artist, albums: artistAlbums };
}
} catch (err) {
console.error('Failed to get artist:', err);
setError('Failed to get artist');
return null;
}
}, [isOnline, originalNavidrome, getArtistsOffline, getAlbumsOffline]);
const getPlaylistsWrapper = useCallback(async (): Promise<Playlist[]> => {
try {
return await getPlaylistsOffline();
} catch (err) {
console.error('Failed to get playlists:', err);
return [];
}
}, [getPlaylistsOffline]);
// Offline-capable operations
const starItem = useCallback(async (id: string, type: 'song' | 'album' | 'artist') => {
setError(null);
try {
await starOffline(id, type);
// Refresh relevant data
if (type === 'album') {
await loadAlbums();
} else if (type === 'artist') {
await loadArtists();
}
} catch (err) {
console.error('Failed to star item:', err);
setError('Failed to star item');
throw err;
}
}, [starOffline, loadAlbums, loadArtists]);
const unstarItem = useCallback(async (id: string, type: 'song' | 'album' | 'artist') => {
setError(null);
try {
await unstarOffline(id, type);
// Refresh relevant data
if (type === 'album') {
await loadAlbums();
} else if (type === 'artist') {
await loadArtists();
}
} catch (err) {
console.error('Failed to unstar item:', err);
setError('Failed to unstar item');
throw err;
}
}, [unstarOffline, loadAlbums, loadArtists]);
const createPlaylist = useCallback(async (name: string, songIds?: string[]): Promise<Playlist> => {
setError(null);
try {
const playlist = await createPlaylistOffline(name, songIds);
await loadPlaylists(); // Refresh playlists
return playlist;
} catch (err) {
console.error('Failed to create playlist:', err);
setError('Failed to create playlist');
throw err;
}
}, [createPlaylistOffline, loadPlaylists]);
const scrobble = useCallback(async (songId: string) => {
try {
await scrobbleOffline(songId);
} catch (err) {
console.error('Failed to scrobble:', err);
// Don't set error state for scrobbling failures as they're not critical
}
}, [scrobbleOffline]);
// Sync management
const syncLibrary = useCallback(async () => {
setError(null);
try {
await syncLibraryFromServer();
await refreshData(); // Refresh local state after sync
} catch (err) {
console.error('Library sync failed:', err);
setError('Library sync failed');
throw err;
}
}, [syncLibraryFromServer, refreshData]);
const syncPendingOperations = useCallback(async () => {
try {
await syncPendingOps();
await refreshStats();
} catch (err) {
console.error('Failed to sync pending operations:', err);
// Don't throw or set error for pending operations sync
}
}, [syncPendingOps, refreshStats]);
const clearOfflineData = useCallback(async () => {
try {
await clearOfflineDataInternal();
setAlbums([]);
setArtists([]);
setPlaylists([]);
} catch (err) {
console.error('Failed to clear offline data:', err);
setError('Failed to clear offline data');
throw err;
}
}, [clearOfflineDataInternal]);
const value: OfflineNavidromeContextType = {
// Data
albums,
artists,
playlists,
// Loading states
isLoading,
albumsLoading,
artistsLoading,
playlistsLoading,
// Connection state
isOnline,
isOfflineReady,
// Error state
error,
// Offline sync status
isSyncing,
lastSync,
pendingOperations,
// Methods
searchMusic,
getAlbum,
getArtist,
getPlaylists: getPlaylistsWrapper,
refreshData,
// Offline-capable operations
starItem,
unstarItem,
createPlaylist,
scrobble,
// Sync management
syncLibrary,
syncPendingOperations,
clearOfflineData
};
return (
<OfflineNavidromeContext.Provider value={value}>
{children}
</OfflineNavidromeContext.Provider>
);
};
export const useOfflineNavidrome = (): OfflineNavidromeContextType => {
const context = useContext(OfflineNavidromeContext);
if (context === undefined) {
throw new Error('useOfflineNavidrome must be used within an OfflineNavidromeProvider');
}
return context;
};

View File

@@ -0,0 +1,281 @@
'use client';
import React, { createContext, useContext, ReactNode } from 'react';
import { Album, Artist, Song, Playlist } from '@/lib/navidrome';
import { NavidromeProvider, useNavidrome } from '@/app/components/NavidromeContext';
import { useOfflineLibrary } from '@/hooks/use-offline-library';
interface OfflineNavidromeContextType {
// All the original NavidromeContext methods but with offline-first behavior
getAlbums: (starred?: boolean) => Promise<Album[]>;
getArtists: (starred?: boolean) => Promise<Artist[]>;
getSongs: (albumId?: string, artistId?: string) => Promise<Song[]>;
getPlaylists: () => Promise<Playlist[]>;
// Offline-aware operations
starItem: (id: string, type: 'song' | 'album' | 'artist') => Promise<void>;
unstarItem: (id: string, type: 'song' | 'album' | 'artist') => Promise<void>;
createPlaylist: (name: string, songIds?: string[]) => Promise<void>;
updatePlaylist: (id: string, name?: string, comment?: string, songIds?: string[]) => Promise<void>;
deletePlaylist: (id: string) => Promise<void>;
scrobble: (songId: string) => Promise<void>;
// Offline state
isOfflineMode: boolean;
hasPendingOperations: boolean;
lastSync: Date | null;
}
const OfflineNavidromeContext = createContext<OfflineNavidromeContextType | undefined>(undefined);
interface OfflineNavidromeProviderInnerProps {
children: ReactNode;
}
// Inner component that has access to both contexts
const OfflineNavidromeProviderInner: React.FC<OfflineNavidromeProviderInnerProps> = ({ children }) => {
const navidromeContext = useNavidrome();
const offlineLibrary = useOfflineLibrary();
// Offline-first data retrieval methods
const getAlbums = async (starred?: boolean): Promise<Album[]> => {
if (!offlineLibrary.isOnline || !navidromeContext.api) {
// Offline mode - get from IndexedDB
return await offlineLibrary.getAlbums(starred);
}
try {
// Online mode - try server first, fallback to offline
const albums = starred
? await navidromeContext.api.getAlbums('starred', 1000)
: await navidromeContext.api.getAlbums('alphabeticalByName', 1000);
return albums;
} catch (error) {
console.warn('Server request failed, falling back to offline data:', error);
return await offlineLibrary.getAlbums(starred);
}
};
const getArtists = async (starred?: boolean): Promise<Artist[]> => {
if (!offlineLibrary.isOnline || !navidromeContext.api) {
return await offlineLibrary.getArtists(starred);
}
try {
const artists = await navidromeContext.api.getArtists();
if (starred) {
// Filter starred artists from the full list
const starredData = await navidromeContext.api.getStarred2();
const starredArtistIds = new Set(starredData.starred2.artist?.map(a => a.id) || []);
return artists.filter(artist => starredArtistIds.has(artist.id));
}
return artists;
} catch (error) {
console.warn('Server request failed, falling back to offline data:', error);
return await offlineLibrary.getArtists(starred);
}
};
const getSongs = async (albumId?: string, artistId?: string): Promise<Song[]> => {
if (!offlineLibrary.isOnline || !navidromeContext.api) {
return await offlineLibrary.getSongs(albumId, artistId);
}
try {
if (albumId) {
const { songs } = await navidromeContext.api.getAlbum(albumId);
return songs;
} else if (artistId) {
const { albums } = await navidromeContext.api.getArtist(artistId);
const allSongs: Song[] = [];
for (const album of albums) {
const { songs } = await navidromeContext.api.getAlbum(album.id);
allSongs.push(...songs);
}
return allSongs;
} else {
return await navidromeContext.getAllSongs();
}
} catch (error) {
console.warn('Server request failed, falling back to offline data:', error);
return await offlineLibrary.getSongs(albumId, artistId);
}
};
const getPlaylists = async (): Promise<Playlist[]> => {
if (!offlineLibrary.isOnline || !navidromeContext.api) {
return await offlineLibrary.getPlaylists();
}
try {
return await navidromeContext.api.getPlaylists();
} catch (error) {
console.warn('Server request failed, falling back to offline data:', error);
return await offlineLibrary.getPlaylists();
}
};
// Offline-aware operations (queue for sync when offline)
const starItem = async (id: string, type: 'song' | 'album' | 'artist'): Promise<void> => {
if (offlineLibrary.isOnline && navidromeContext.api) {
try {
await navidromeContext.starItem(id, type);
// Update offline data immediately
await offlineLibrary.starOffline(id, type);
return;
} catch (error) {
console.warn('Server star failed, queuing for sync:', error);
}
}
// Queue for sync when back online
await offlineLibrary.starOffline(id, type);
await offlineLibrary.queueSyncOperation({
type: 'star',
entityType: type,
entityId: id,
data: {}
});
};
const unstarItem = async (id: string, type: 'song' | 'album' | 'artist'): Promise<void> => {
if (offlineLibrary.isOnline && navidromeContext.api) {
try {
await navidromeContext.unstarItem(id, type);
await offlineLibrary.unstarOffline(id, type);
return;
} catch (error) {
console.warn('Server unstar failed, queuing for sync:', error);
}
}
await offlineLibrary.unstarOffline(id, type);
await offlineLibrary.queueSyncOperation({
type: 'unstar',
entityType: type,
entityId: id,
data: {}
});
};
const createPlaylist = async (name: string, songIds?: string[]): Promise<void> => {
if (offlineLibrary.isOnline && navidromeContext.api) {
try {
const playlist = await navidromeContext.createPlaylist(name, songIds);
await offlineLibrary.createPlaylistOffline(name, songIds || []);
return;
} catch (error) {
console.warn('Server playlist creation failed, queuing for sync:', error);
}
}
// Create offline
await offlineLibrary.createPlaylistOffline(name, songIds || []);
await offlineLibrary.queueSyncOperation({
type: 'create_playlist',
entityType: 'playlist',
entityId: 'temp-' + Date.now(),
data: { name, songIds: songIds || [] }
});
};
const updatePlaylist = async (id: string, name?: string, comment?: string, songIds?: string[]): Promise<void> => {
if (offlineLibrary.isOnline && navidromeContext.api) {
try {
await navidromeContext.updatePlaylist(id, name, comment, songIds);
await offlineLibrary.updatePlaylistOffline(id, name, comment, songIds);
return;
} catch (error) {
console.warn('Server playlist update failed, queuing for sync:', error);
}
}
await offlineLibrary.updatePlaylistOffline(id, name, comment, songIds);
await offlineLibrary.queueSyncOperation({
type: 'update_playlist',
entityType: 'playlist',
entityId: id,
data: { name, comment, songIds }
});
};
const deletePlaylist = async (id: string): Promise<void> => {
if (offlineLibrary.isOnline && navidromeContext.api) {
try {
await navidromeContext.deletePlaylist(id);
await offlineLibrary.deletePlaylistOffline(id);
return;
} catch (error) {
console.warn('Server playlist deletion failed, queuing for sync:', error);
}
}
await offlineLibrary.deletePlaylistOffline(id);
await offlineLibrary.queueSyncOperation({
type: 'delete_playlist',
entityType: 'playlist',
entityId: id,
data: {}
});
};
const scrobble = async (songId: string): Promise<void> => {
if (offlineLibrary.isOnline && navidromeContext.api) {
try {
await navidromeContext.scrobble(songId);
return;
} catch (error) {
console.warn('Server scrobble failed, queuing for sync:', error);
}
}
await offlineLibrary.queueSyncOperation({
type: 'scrobble',
entityType: 'song',
entityId: songId,
data: { timestamp: Date.now() }
});
};
const contextValue: OfflineNavidromeContextType = {
getAlbums,
getArtists,
getSongs,
getPlaylists,
starItem,
unstarItem,
createPlaylist,
updatePlaylist,
deletePlaylist,
scrobble,
isOfflineMode: !offlineLibrary.isOnline,
hasPendingOperations: offlineLibrary.stats.pendingOperations > 0,
lastSync: offlineLibrary.lastSync
};
return (
<OfflineNavidromeContext.Provider value={contextValue}>
{children}
</OfflineNavidromeContext.Provider>
);
};
// Main provider component
export const OfflineNavidromeProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
return (
<NavidromeProvider>
<OfflineNavidromeProviderInner>
{children}
</OfflineNavidromeProviderInner>
</NavidromeProvider>
);
};
// Hook to use the offline-aware Navidrome context
export const useOfflineNavidrome = (): OfflineNavidromeContextType => {
const context = useContext(OfflineNavidromeContext);
if (!context) {
throw new Error('useOfflineNavidrome must be used within an OfflineNavidromeProvider');
}
return context;
};

View File

@@ -0,0 +1,65 @@
'use client';
import React from 'react';
import { Badge } from '@/components/ui/badge';
import { useOfflineLibrary } from '@/hooks/use-offline-library';
import { Wifi, WifiOff, Download, Clock } from 'lucide-react';
export function OfflineStatusIndicator() {
const { isOnline, stats, isSyncing, lastSync } = useOfflineLibrary();
if (!isOnline) {
return (
<Badge variant="secondary" className="flex items-center gap-1">
<WifiOff size={12} />
Offline Mode
</Badge>
);
}
if (isSyncing) {
return (
<Badge variant="default" className="flex items-center gap-1">
<Download size={12} className="animate-bounce" />
Syncing...
</Badge>
);
}
if (stats.pendingOperations > 0) {
return (
<Badge variant="outline" className="flex items-center gap-1">
<Clock size={12} />
{stats.pendingOperations} pending
</Badge>
);
}
return (
<Badge variant="default" className="flex items-center gap-1">
<Wifi size={12} />
Online
</Badge>
);
}
export function OfflineLibraryStats() {
const { stats, lastSync } = useOfflineLibrary();
if (!stats.albums && !stats.songs && !stats.artists) {
return null;
}
return (
<div className="text-xs text-muted-foreground space-y-1">
<div>
📀 {stats.albums} albums 🎵 {stats.songs} songs 👤 {stats.artists} artists
</div>
{lastSync && (
<div>
Last sync: {lastSync.toLocaleDateString()} at {lastSync.toLocaleTimeString()}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,23 @@
"use client";
import React from "react";
import { AnimatePresence, motion } from "framer-motion";
import { usePathname } from "next/navigation";
export default function PageTransition({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
return (
<AnimatePresence mode="wait" initial={false}>
<motion.div
key={pathname}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.18, ease: "easeInOut" }}
className="contents"
>
{children}
</motion.div>
</AnimatePresence>
);
}

View File

@@ -36,7 +36,7 @@ export function PopularSongs({ songs, artistName }: PopularSongsProps) {
artist: song.artist,
album: song.album,
duration: song.duration,
coverArt: song.coverArt ? api.getCoverArtUrl(song.coverArt, 300) : undefined,
coverArt: song.coverArt ? api.getCoverArtUrl(song.coverArt, 1200) : undefined,
albumId: song.albumId,
artistId: song.artistId,
starred: !!song.starred
@@ -92,10 +92,10 @@ export function PopularSongs({ songs, artistName }: PopularSongsProps) {
</div>
{/* Album Art */}
<div className="relative w-12 h-12 bg-muted rounded-md overflow-hidden flex-shrink-0">
<div className="relative w-12 h-12 bg-muted rounded-md overflow-hidden shrink-0">
{song.coverArt && api && (
<Image
src={api.getCoverArtUrl(song.coverArt, 96)}
src={api.getCoverArtUrl(song.coverArt, 300)}
alt={song.album}
width={48}
height={48}

View File

@@ -1,50 +0,0 @@
"use client"
import posthog from "posthog-js"
import { PostHogProvider as PHProvider, usePostHog } from "posthog-js/react"
import { Suspense, useEffect } from "react"
import { usePathname, useSearchParams } from "next/navigation"
function PathnameTracker() {
const posthogClient = usePostHog()
const pathname = usePathname()
const searchParams = useSearchParams()
useEffect(() => {
if (posthogClient) {
posthogClient.capture('$pageview', {
path: pathname + (searchParams.toString() ? `?${searchParams.toString()}` : ''),
})
}
}, [posthogClient, pathname, searchParams])
return null
}
function SuspendedPostHogPageView() {
return (
<Suspense fallback={null}>
<PathnameTracker />
</Suspense>
)
}
export function PostHogProvider({ children }: { children: React.ReactNode }) {
useEffect(() => {
posthog.init(process.env.NEXT_PUBLIC_POSTHOG_KEY!, {
api_host: "/ingest",
ui_host: "https://us.posthog.com",
capture_pageview: 'history_change',
capture_pageleave: true,
capture_exceptions: true,
debug: process.env.NODE_ENV === "development",
})
}, [])
return (
<PHProvider client={posthog}>
<SuspendedPostHogPageView />
{children}
</PHProvider>
)
}

View File

@@ -1,28 +1,53 @@
"use client";
import React from "react";
import React, { useEffect } from "react";
import { AudioPlayerProvider } from "../components/AudioPlayerContext";
import { NavidromeProvider, useNavidrome } from "../components/NavidromeContext";
import { OfflineNavidromeProvider, useOfflineNavidrome } from "../components/OfflineNavidromeProvider";
import { NavidromeConfigProvider } from "../components/NavidromeConfigContext";
import { ThemeProvider } from "../components/ThemeProvider";
import { PostHogProvider } from "../components/PostHogProvider";
import { WhatsNewPopup } from "../components/WhatsNewPopup";
import Ihateserverside from "./ihateserverside";
import DynamicViewportTheme from "./DynamicViewportTheme";
import ThemeColorHandler from "./ThemeColorHandler";
import { useViewportThemeColor } from "@/hooks/use-viewport-theme-color";
import { LoginForm } from "./start-screen";
import Image from "next/image";
import PageTransition from "./PageTransition";
import { GlobalSearchProvider } from "./GlobalSearchProvider";
// ServiceWorkerRegistration component to handle registration
function ServiceWorkerRegistration() {
useEffect(() => {
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js')
.then((registration) => {
console.log('Service Worker registered successfully:', registration);
})
.catch((error) => {
console.error('Service Worker registration failed:', error);
});
}
}, []);
return null;
}
function NavidromeErrorBoundary({ children }: { children: React.ReactNode }) {
const { error } = useNavidrome();
// For now, since we're switching to offline-first, we'll handle errors differently
// The offline provider will handle connectivity issues automatically
const [isClient, setIsClient] = React.useState(false);
const [hasCompletedOnboarding, setHasCompletedOnboarding] = React.useState(true); // Default to true to prevent flash
// Check if this is a first-time user
const hasCompletedOnboarding = typeof window !== 'undefined'
? localStorage.getItem('onboarding-completed')
: false;
// Client-side hydration
React.useEffect(() => {
setIsClient(true);
const onboardingStatus = localStorage.getItem('onboarding-completed');
setHasCompletedOnboarding(!!onboardingStatus);
}, []);
// Simple check: has config in localStorage or environment
const hasAnyConfig = React.useMemo(() => {
if (typeof window === 'undefined') return false;
if (!isClient) return true; // Assume config exists during SSR to prevent flash
// Check localStorage config
const savedConfig = localStorage.getItem('navidrome-config');
@@ -45,12 +70,16 @@ function NavidromeErrorBoundary({ children }: { children: React.ReactNode }) {
}
return false;
}, []);
}, [isClient]);
// Show start screen ONLY if:
// 1. First-time user (no onboarding completed), OR
// 2. User has completed onboarding BUT there's an error AND no config exists
const shouldShowStartScreen = !hasCompletedOnboarding || (hasCompletedOnboarding && error && !hasAnyConfig);
// Don't show anything until client-side hydration is complete
if (!isClient) {
return <>{children}</>;
}
// Show start screen ONLY if first-time user (no onboarding completed)
// In offline-first mode, we don't need to check for errors since the app works offline
const shouldShowStartScreen = !hasCompletedOnboarding;
if (shouldShowStartScreen) {
return (
@@ -71,22 +100,24 @@ function NavidromeErrorBoundary({ children }: { children: React.ReactNode }) {
export default function RootLayoutClient({ children }: { children: React.ReactNode }) {
return (
<PostHogProvider>
<ThemeProvider>
<DynamicViewportTheme />
<NavidromeConfigProvider>
<NavidromeProvider>
<NavidromeErrorBoundary>
<AudioPlayerProvider>
<ThemeProvider>
<DynamicViewportTheme />
<ThemeColorHandler />
<ServiceWorkerRegistration />
<NavidromeConfigProvider>
<OfflineNavidromeProvider>
<NavidromeErrorBoundary>
<AudioPlayerProvider>
<GlobalSearchProvider>
<Ihateserverside>
{children}
<PageTransition>{children}</PageTransition>
</Ihateserverside>
<WhatsNewPopup />
</AudioPlayerProvider>
</NavidromeErrorBoundary>
</NavidromeProvider>
</NavidromeConfigProvider>
</ThemeProvider>
</PostHogProvider>
</GlobalSearchProvider>
</AudioPlayerProvider>
</NavidromeErrorBoundary>
</OfflineNavidromeProvider>
</NavidromeConfigProvider>
</ThemeProvider>
);
}

View File

@@ -0,0 +1,105 @@
'use client';
import React, { useState } from 'react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import {
Download,
Upload,
RotateCcw,
Settings
} from 'lucide-react';
import { useSidebarLayout } from '@/hooks/use-sidebar-layout';
export function SettingsManagement() {
const { exportSettings, importSettings, resetToDefaults } = useSidebarLayout();
const [importFile, setImportFile] = useState<File | null>(null);
const [importing, setImporting] = useState(false);
const [importError, setImportError] = useState<string | null>(null);
const handleImportFile = async () => {
if (!importFile) return;
setImporting(true);
setImportError(null);
try {
await importSettings(importFile);
setImportFile(null);
// Reset file input
const fileInput = document.getElementById('settings-import') as HTMLInputElement;
if (fileInput) fileInput.value = '';
} catch (error) {
setImportError(error instanceof Error ? error.message : 'Failed to import settings');
} finally {
setImporting(false);
}
};
return (
<Card className="py-5">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Settings className="h-5 w-5" />
Settings Management
</CardTitle>
<CardDescription>
Export, import, or reset your application settings
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex flex-wrap gap-2">
<Button onClick={exportSettings} variant="outline">
<Download className="h-4 w-4 mr-2" />
Export Settings
</Button>
<div className="flex items-center gap-2">
<Input
id="settings-import"
type="file"
accept=".json"
onChange={(e) => setImportFile(e.target.files?.[0] || null)}
className="hidden"
/>
<Button
variant="outline"
onClick={() => document.getElementById('settings-import')?.click()}
>
<Upload className="h-4 w-4 mr-2" />
Select File
</Button>
{importFile && (
<Button
onClick={handleImportFile}
disabled={importing}
variant="default"
>
{importing ? 'Importing...' : 'Import'}
</Button>
)}
</div>
<Button onClick={resetToDefaults} variant="outline">
<RotateCcw className="h-4 w-4 mr-2" />
Reset to Default
</Button>
</div>
{importFile && (
<div className="text-sm text-muted-foreground">
Selected: {importFile.name}
</div>
)}
{importError && (
<div className="text-sm text-destructive">
Error: {importError}
</div>
)}
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,244 @@
'use client';
import React from 'react';
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
DragEndEvent,
} from '@dnd-kit/core';
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
verticalListSortingStrategy,
} from '@dnd-kit/sortable';
import {
useSortable,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Label } from '@/components/ui/label';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import { Switch } from '@/components/ui/switch';
import {
GripVertical,
Eye,
EyeOff,
Search,
Home,
List,
Radio,
Users,
Disc,
Music,
Heart,
Grid3X3,
Clock,
Settings
} from 'lucide-react';
import { useSidebarLayout, SidebarItem, SidebarItemType } from '@/hooks/use-sidebar-layout';
// Icon mapping
const iconMap: Record<string, React.ReactNode> = {
search: <Search className="h-4 w-4" />,
home: <Home className="h-4 w-4" />,
queue: <List className="h-4 w-4" />,
radio: <Radio className="h-4 w-4" />,
artists: <Users className="h-4 w-4" />,
albums: <Disc className="h-4 w-4" />,
playlists: <Music className="h-4 w-4" />,
favorites: <Heart className="h-4 w-4" />,
browse: <Grid3X3 className="h-4 w-4" />,
songs: <Music className="h-4 w-4" />,
history: <Clock className="h-4 w-4" />,
settings: <Settings className="h-4 w-4" />,
};
interface SortableItemProps {
item: SidebarItem;
onToggleVisibility: (id: SidebarItemType) => void;
showIcons: boolean;
}
function SortableItem({ item, onToggleVisibility, showIcons }: SortableItemProps) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: item.id });
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
};
return (
<div
ref={setNodeRef}
style={style}
className="flex items-center justify-between p-3 bg-secondary/50 rounded-lg border"
>
<div className="flex items-center gap-3">
<div
className="cursor-grab hover:cursor-grabbing text-muted-foreground"
{...attributes}
{...listeners}
>
<GripVertical className="h-4 w-4" />
</div>
{showIcons && (
<div className="text-muted-foreground">
{iconMap[item.icon] || <div className="h-4 w-4" />}
</div>
)}
<span className={`font-medium ${!item.visible ? 'text-muted-foreground line-through' : ''}`}>
{item.label}
</span>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => onToggleVisibility(item.id)}
className="h-8 w-8 p-0"
>
{item.visible ? (
<Eye className="h-4 w-4" />
) : (
<EyeOff className="h-4 w-4" />
)}
</Button>
</div>
);
}
export function SidebarCustomization() {
const {
settings,
hasUnsavedChanges,
reorderItems,
toggleItemVisibility,
updateShortcuts,
updateShowIcons,
applyChanges,
discardChanges,
} = useSidebarLayout();
const sensors = useSensors(
useSensor(PointerSensor),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
);
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (over && active.id !== over.id) {
reorderItems(active.id as string, over.id as string);
}
};
return (
<Card className="py-5">
<CardHeader>
<CardTitle>Sidebar Customization</CardTitle>
<CardDescription>
Customize the sidebar layout, reorder items, and manage visibility settings.
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* Show Icons Toggle */}
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label>Show Icons</Label>
<div className="text-sm text-muted-foreground">
Display icons next to navigation items
</div>
</div>
<Switch
checked={settings.showIcons}
onCheckedChange={updateShowIcons}
/>
</div>
{/* Shortcut Type */}
<div className="space-y-3">
<Label>Sidebar Shortcuts</Label>
<RadioGroup
value={settings.shortcuts}
onValueChange={(value: 'albums' | 'playlists' | 'both') => updateShortcuts(value)}
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="albums" id="shortcuts-albums" />
<Label htmlFor="shortcuts-albums">Albums only</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="playlists" id="shortcuts-playlists" />
<Label htmlFor="shortcuts-playlists">Playlists only</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="both" id="shortcuts-both" />
<Label htmlFor="shortcuts-both">Both albums and playlists</Label>
</div>
</RadioGroup>
</div>
{/* Navigation Items Order */}
<div className="space-y-3">
<Label>Navigation Items</Label>
<div className="text-sm text-muted-foreground mb-3">
Drag to reorder, click the eye icon to show/hide items
</div>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext items={settings.items.map(item => item.id)} strategy={verticalListSortingStrategy}>
<div className="space-y-2">
{settings.items.map((item) => (
<SortableItem
key={item.id}
item={item}
onToggleVisibility={toggleItemVisibility}
showIcons={settings.showIcons}
/>
))}
</div>
</SortableContext>
</DndContext>
</div>
{/* Apply/Discard Changes */}
{hasUnsavedChanges() && (
<div className="space-y-3 pt-4 border-t">
<Label>Unsaved Changes</Label>
<div className="flex gap-2">
<Button onClick={applyChanges} variant="default">
Apply Changes
</Button>
<Button onClick={discardChanges} variant="outline">
Discard Changes
</Button>
</div>
</div>
)}
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,321 @@
'use client';
import React, { useState } from 'react';
import { DragDropContext, Droppable, Draggable, DropResult } from 'react-beautiful-dnd';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Switch } from '@/components/ui/switch';
import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge';
import { GripVertical, Eye, EyeOff, Download, Upload, RotateCcw } from 'lucide-react';
import { useSidebarLayout, SidebarItem } from '@/hooks/use-sidebar-layout';
import { Input } from '@/components/ui/input';
import { useToast } from '@/hooks/use-toast';
export function SidebarCustomizer() {
const {
settings,
updateItemOrder,
toggleItemVisibility,
updateShortcuts,
updateShowIcons,
exportSettings,
importSettings,
resetToDefaults
} = useSidebarLayout();
const { toast } = useToast();
const [dragEnabled, setDragEnabled] = useState(false);
const handleDragEnd = (result: DropResult) => {
if (!result.destination) return;
const items = Array.from(settings.items);
const [reorderedItem] = items.splice(result.source.index, 1);
items.splice(result.destination.index, 0, reorderedItem);
updateItemOrder(items);
};
const handleFileImport = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
try {
await importSettings(file);
toast({
title: "Settings imported",
description: "Your sidebar settings have been imported successfully.",
});
} catch (error) {
toast({
title: "Import failed",
description: "Failed to import settings. Please check the file format.",
variant: "destructive",
});
}
// Reset the input
event.target.value = '';
};
const handleExport = () => {
exportSettings();
toast({
title: "Settings exported",
description: "Your settings have been downloaded as a JSON file.",
});
};
const handleReset = () => {
resetToDefaults();
toast({
title: "Settings reset",
description: "Sidebar settings have been reset to defaults.",
});
};
const getSidebarIcon = (iconId: string) => {
const iconMap: Record<string, React.ReactElement> = {
search: (
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="11" cy="11" r="8" />
<path d="m21 21-4.35-4.35" />
</svg>
),
home: (
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="12" cy="12" r="10" />
<polygon points="10 8 16 12 10 16 10 8" />
</svg>
),
queue: (
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M3 6h18M3 12h18M3 18h18" />
</svg>
),
radio: (
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M4.9 19.1C1 15.2 1 8.8 4.9 4.9"/>
<circle cx="12" cy="12" r="2"/>
</svg>
),
artists: (
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="m12 8-9.04 9.06a2.82 2.82 0 1 0 3.98 3.98L16 12" />
<circle cx="17" cy="7" r="5" />
</svg>
),
albums: (
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="m16 6 4 14" />
<path d="M12 6v14" />
<path d="M8 8v12" />
<path d="M4 4v16" />
</svg>
),
playlists: (
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M21 15V6" />
<path d="M18.5 18a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5Z" />
<path d="M12 12H3" />
</svg>
),
favorites: (
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z" />
</svg>
),
browse: (
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<rect width="7" height="7" x="3" y="3" rx="1" />
<rect width="7" height="7" x="14" y="3" rx="1" />
</svg>
),
songs: (
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="8" cy="18" r="4" />
<path d="M12 18V2l7 4" />
</svg>
),
history: (
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M12 2C6.48 2 2 6.48 2 12c0 5.52 4.48 10 10 10 5.52 0 10-4.48 10-10 0-5.52-4.48-10-10-10Z" />
<path d="M12 8v4l4 2" />
</svg>
),
settings: (
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z" />
<circle cx="12" cy="12" r="3" />
</svg>
),
};
return iconMap[iconId] || iconMap.home;
};
return (
<div className="space-y-6">
{/* Global Settings */}
<Card>
<CardHeader>
<CardTitle>Sidebar Settings</CardTitle>
<CardDescription>
Customize your sidebar appearance and behavior
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center space-x-2">
<Switch
id="show-icons"
checked={settings.showIcons}
onCheckedChange={updateShowIcons}
/>
<Label htmlFor="show-icons">Show icons</Label>
</div>
<div className="space-y-2">
<Label>Sidebar shortcuts</Label>
<div className="flex gap-2">
<Button
variant={settings.shortcuts === 'both' ? 'default' : 'outline'}
size="sm"
onClick={() => updateShortcuts('both')}
>
Both
</Button>
<Button
variant={settings.shortcuts === 'albums' ? 'default' : 'outline'}
size="sm"
onClick={() => updateShortcuts('albums')}
>
Albums only
</Button>
<Button
variant={settings.shortcuts === 'playlists' ? 'default' : 'outline'}
size="sm"
onClick={() => updateShortcuts('playlists')}
>
Playlists only
</Button>
</div>
</div>
</CardContent>
</Card>
{/* Item Management */}
<Card>
<CardHeader>
<CardTitle>Sidebar Items</CardTitle>
<CardDescription>
Drag to reorder items and toggle visibility
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex items-center space-x-2 mb-4">
<Switch
id="drag-enabled"
checked={dragEnabled}
onCheckedChange={setDragEnabled}
/>
<Label htmlFor="drag-enabled">Enable drag to reorder</Label>
</div>
<DragDropContext onDragEnd={handleDragEnd}>
<Droppable droppableId="sidebar-items" isDropDisabled={!dragEnabled}>
{(provided) => (
<div
{...provided.droppableProps}
ref={provided.innerRef}
className="space-y-2"
>
{settings.items.map((item, index) => (
<Draggable
key={item.id}
draggableId={item.id}
index={index}
isDragDisabled={!dragEnabled}
>
{(provided, snapshot) => (
<div
ref={provided.innerRef}
{...provided.draggableProps}
className={`flex items-center justify-between p-3 border rounded-lg ${
snapshot.isDragging ? 'bg-accent' : 'bg-background'
} ${!item.visible ? 'opacity-50' : ''}`}
>
<div className="flex items-center space-x-3">
<div
{...provided.dragHandleProps}
className={`${dragEnabled ? 'cursor-grab' : 'cursor-default'}`}
>
<GripVertical className="h-4 w-4 text-muted-foreground" />
</div>
{settings.showIcons && getSidebarIcon(item.icon)}
<span className="font-medium">{item.label}</span>
{!item.visible && <Badge variant="secondary">Hidden</Badge>}
</div>
<Button
variant="ghost"
size="sm"
onClick={() => toggleItemVisibility(item.id)}
>
{item.visible ? (
<Eye className="h-4 w-4" />
) : (
<EyeOff className="h-4 w-4" />
)}
</Button>
</div>
)}
</Draggable>
))}
{provided.placeholder}
</div>
)}
</Droppable>
</DragDropContext>
</CardContent>
</Card>
{/* Import/Export */}
<Card>
<CardHeader>
<CardTitle>Settings Management</CardTitle>
<CardDescription>
Export, import, or reset your settings
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex gap-2">
<Button onClick={handleExport} variant="outline">
<Download className="h-4 w-4 mr-2" />
Export Settings
</Button>
<div>
<Input
type="file"
accept=".json"
onChange={handleFileImport}
className="hidden"
id="import-settings"
/>
<Label htmlFor="import-settings">
<Button variant="outline" asChild>
<span>
<Upload className="h-4 w-4 mr-2" />
Import Settings
</span>
</Button>
</Label>
</div>
<Button onClick={handleReset} variant="destructive">
<RotateCcw className="h-4 w-4 mr-2" />
Reset to Defaults
</Button>
</div>
</CardContent>
</Card>
</div>
);
}

View File

@@ -68,7 +68,7 @@ export function SimilarArtists({ artistName }: SimilarArtistsProps) {
<Link
key={artist.name}
href={`/artist/${encodeURIComponent(artist.name)}`}
className="flex-shrink-0"
className="shrink-0"
>
<div className="w-32 space-y-2 group cursor-pointer">
<div className="relative w-32 h-32 bg-muted rounded-full overflow-hidden">

View File

@@ -0,0 +1,371 @@
'use client';
import React, { useState, useEffect, useMemo, useCallback } from 'react';
import { Song, Album, getNavidromeAPI } from '@/lib/navidrome';
import { useOfflineNavidrome } from '@/app/components/OfflineNavidromeProvider';
import { useAudioPlayer } from '@/app/components/AudioPlayerContext';
import { useIsMobile } from '@/hooks/use-mobile';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { Play, Heart, Music, Shuffle } from 'lucide-react';
import Image from 'next/image';
import Link from 'next/link';
import { UserProfile } from './UserProfile';
interface SongRecommendationsProps {
userName?: string;
}
export function SongRecommendations({ userName }: SongRecommendationsProps) {
const offline = useOfflineNavidrome();
const { playTrack, shuffle, toggleShuffle } = useAudioPlayer();
const isMobile = useIsMobile();
const [recommendedSongs, setRecommendedSongs] = useState<Song[]>([]);
const [recommendedAlbums, setRecommendedAlbums] = useState<Album[]>([]);
const [loading, setLoading] = useState(true);
const [songStates, setSongStates] = useState<Record<string, boolean>>({});
// Memoize the greeting to prevent recalculation
const greeting = useMemo(() => {
const hour = new Date().getHours();
return hour < 12 ? 'Good morning' : hour < 18 ? 'Good afternoon' : 'Good evening';
}, []);
// Memoized callbacks to prevent re-renders
const handleImageLoad = useCallback(() => {
// Image loaded - no state update needed to prevent re-renders
}, []);
const handleImageError = useCallback(() => {
// Image error - no state update needed to prevent re-renders
}, []);
useEffect(() => {
const loadRecommendations = async () => {
setLoading(true);
try {
const api = getNavidromeAPI();
const isOnline = !offline.isOfflineMode && !!api;
if (isOnline && api) {
// Online: use server-side recommendations
const randomAlbums = await api.getAlbums('random', 10);
if (isMobile) {
setRecommendedAlbums(randomAlbums.slice(0, 6));
} else {
const allSongs: Song[] = [];
for (let i = 0; i < Math.min(3, randomAlbums.length); i++) {
try {
const albumSongs = await api.getAlbumSongs(randomAlbums[i].id);
allSongs.push(...albumSongs);
} catch (error) {
console.error('Failed to get album songs:', error);
}
}
const shuffled = allSongs.sort(() => Math.random() - 0.5);
const recommendations = shuffled.slice(0, 6);
setRecommendedSongs(recommendations);
const states: Record<string, boolean> = {};
recommendations.forEach((song: Song) => { states[song.id] = !!song.starred; });
setSongStates(states);
}
} else {
// Offline: use cached library
const albums = await offline.getAlbums(false);
const shuffledAlbums = [...(albums || [])].sort(() => Math.random() - 0.5);
if (isMobile) {
setRecommendedAlbums(shuffledAlbums.slice(0, 6));
} else {
const pick = shuffledAlbums.slice(0, 3);
const allSongs: Song[] = [];
for (const a of pick) {
try {
const songs = await offline.getSongs(a.id);
allSongs.push(...songs);
} catch (e) {
// ignore per-album errors
}
}
const recommendations = allSongs.sort(() => Math.random() - 0.5).slice(0, 6);
setRecommendedSongs(recommendations);
const states: Record<string, boolean> = {};
recommendations.forEach((song: Song) => { states[song.id] = !!song.starred; });
setSongStates(states);
}
}
} catch (error) {
console.error('Failed to load recommendations:', error);
setRecommendedAlbums([]);
setRecommendedSongs([]);
} finally {
setLoading(false);
}
};
loadRecommendations();
}, [offline, isMobile]);
const handlePlaySong = async (song: Song) => {
try {
const api = getNavidromeAPI();
const url = api ? api.getStreamUrl(song.id) : `offline-song-${song.id}`;
const coverArt = song.coverArt && api ? api.getCoverArtUrl(song.coverArt, 300) : undefined;
const track = {
id: song.id,
name: song.title,
url,
artist: song.artist || 'Unknown Artist',
artistId: song.artistId || '',
album: song.album || 'Unknown Album',
albumId: song.albumId || '',
duration: song.duration || 0,
coverArt,
starred: !!song.starred
};
await playTrack(track, true);
} catch (error) {
console.error('Failed to play song:', error);
}
};
const handlePlayAlbum = async (album: Album) => {
try {
const api = getNavidromeAPI();
let albumSongs: Song[] = [];
if (api) {
albumSongs = await api.getAlbumSongs(album.id);
} else {
albumSongs = await offline.getSongs(album.id);
}
if (albumSongs.length > 0) {
const first = albumSongs[0];
const url = api ? api.getStreamUrl(first.id) : `offline-song-${first.id}`;
const coverArt = first.coverArt && api ? api.getCoverArtUrl(first.coverArt, 300) : undefined;
const track = {
id: first.id,
name: first.title,
url,
artist: first.artist || 'Unknown Artist',
artistId: first.artistId || '',
album: first.album || 'Unknown Album',
albumId: first.albumId || '',
duration: first.duration || 0,
coverArt,
starred: !!first.starred
};
await playTrack(track, true);
}
} catch (error) {
console.error('Failed to play album:', error);
}
};
const handleShuffleAll = async () => {
if (isMobile && recommendedAlbums.length === 0) return;
if (!isMobile && recommendedSongs.length === 0) return;
// Enable shuffle if not already on
if (!shuffle) {
toggleShuffle();
}
if (isMobile) {
// Play a random album
const randomAlbum = recommendedAlbums[Math.floor(Math.random() * recommendedAlbums.length)];
await handlePlayAlbum(randomAlbum);
} else {
// Play a random song from recommendations
const randomSong = recommendedSongs[Math.floor(Math.random() * recommendedSongs.length)];
await handlePlaySong(randomSong);
}
};
const formatDuration = (duration: number): string => {
const minutes = Math.floor(duration / 60);
const seconds = duration % 60;
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
};
if (loading) {
return (
<div className="space-y-4">
<div className="space-y-2">
<div className="h-8 w-48 bg-muted animate-pulse rounded" />
<div className="h-4 w-64 bg-muted animate-pulse rounded" />
</div>
{isMobile ? (
<div className="grid grid-cols-3 gap-3">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="aspect-square bg-muted animate-pulse rounded" />
))}
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="h-16 bg-muted animate-pulse rounded" />
))}
</div>
)}
</div>
);
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h2 className="text-2xl font-bold">
{greeting}{userName ? `, ${userName}` : ''}!
</h2>
<p className="text-muted-foreground">
{isMobile ? 'Here are some albums you might enjoy' : 'Here are some songs you might enjoy'}
</p>
</div>
<div className="flex items-center gap-3">
{/* Mobile User Profile */}
{isMobile && <UserProfile variant="mobile" />}
{/* Shuffle All Button (Desktop only) */}
{(isMobile ? recommendedAlbums.length > 0 : recommendedSongs.length > 0) && !isMobile && (
<Button onClick={handleShuffleAll} variant="outline" size="sm">
<Shuffle className="w-4 h-4 mr-2" />
Shuffle All
</Button>
)}
</div>
</div>
{isMobile ? (
/* Mobile: Show albums in 3x2 grid */
recommendedAlbums.length > 0 ? (
<div className="grid grid-cols-3 gap-3">
{recommendedAlbums.map((album) => (
<div key={album.id} className="space-y-2">
<Link
href={`/album/${album.id}`}
className="group cursor-pointer block"
>
<div className="relative aspect-square rounded-lg overflow-hidden bg-muted">
{album.coverArt && !offline.isOfflineMode && getNavidromeAPI() ? (
<Image
src={getNavidromeAPI()!.getCoverArtUrl(album.coverArt, 300)}
alt={album.name}
width={600}
height={600}
className="object-cover"
sizes="(max-width: 768px) 33vw, 200px"
onLoad={handleImageLoad}
onError={handleImageError}
loading="lazy"
/>
) : (
<div className="w-full h-full flex items-center justify-center">
<Music className="w-8 h-8 text-muted-foreground" />
</div>
)}
</div>
</Link>
<div className="space-y-1">
<Link
href={`/album/${album.id}`}
className="font-medium text-sm truncate hover:underline block"
>
{album.name}
</Link>
<Link
href={`/artist/${album.artistId || album.artist}`}
className="text-xs text-muted-foreground truncate hover:underline block"
>
{album.artist}
</Link>
</div>
</div>
))}
</div>
) : (
<Card>
<CardContent className="p-6 text-center">
<Music className="w-12 h-12 mx-auto mb-4 text-muted-foreground" />
<p className="text-muted-foreground">
No albums available for recommendations
</p>
</CardContent>
</Card>
)
) : (
/* Desktop: Show songs in original format */
recommendedSongs.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{recommendedSongs.map((song) => (
<Card
key={song.id}
className="group cursor-pointer hover:bg-accent/50 transition-colors py-2"
onClick={() => handlePlaySong(song)}
>
<CardContent className="px-2">
<div className="flex items-center gap-3">
<div className="relative w-12 h-12 rounded overflow-hidden bg-muted flex-shrink-0">
{song.coverArt && !offline.isOfflineMode && getNavidromeAPI() ? (
<>
<Image
src={getNavidromeAPI()!.getCoverArtUrl(song.coverArt, 48)}
alt={song.title}
fill
className="object-cover"
sizes="48px"
onLoad={handleImageLoad}
onError={handleImageError}
loading="lazy"
/>
<div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
<Play className="w-4 h-4 text-white" />
</div>
</>
) : (
<div className="w-full h-full flex items-center justify-center">
<Music className="w-6 h-6 text-muted-foreground" />
</div>
)}
</div>
<div className="flex-1 min-w-0">
<p className="font-medium truncate">{song.title}</p>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Link
href={`/artist/${song.artistId}`}
className="hover:underline truncate"
onClick={(e) => e.stopPropagation()}
>
{song.artist}
</Link>
{song.duration && (
<>
<span></span>
<span>{formatDuration(song.duration)}</span>
</>
)}
</div>
</div>
{songStates[song.id] && (
<Heart className="w-4 h-4 text-primary flex-shrink-0" fill="currentColor" />
)}
</div>
</CardContent>
</Card>
))}
</div>
) : (
<Card>
<CardContent className="p-6 text-center">
<Music className="w-12 h-12 mx-auto mb-4 text-muted-foreground" />
<p className="text-muted-foreground">
No songs available for recommendations
</p>
</CardContent>
</Card>
)
)}
</div>
);
}

View File

@@ -0,0 +1,653 @@
'use client';
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { AnimatePresence, motion } from 'framer-motion';
import { useRouter } from 'next/navigation';
import {
Search,
X,
Music,
Disc,
User,
Clock,
Heart,
Play,
Plus,
ExternalLink,
Info,
Star,
TrendingUp,
Users,
Calendar,
Globe
} from 'lucide-react';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Separator } from '@/components/ui/separator';
import { useNavidrome } from '@/app/components/NavidromeContext';
import { useAudioPlayer } from '@/app/components/AudioPlayerContext';
import { lastFmAPI } from '@/lib/lastfm-api';
import { Song, Album, Artist, getNavidromeAPI } from '@/lib/navidrome';
import { useKeyboardShortcuts } from '@/hooks/use-keyboard-shortcuts';
import Image from 'next/image';
interface SpotlightSearchProps {
isOpen: boolean;
onClose: () => void;
}
interface LastFmTrackInfo {
name: string;
artist: {
name: string;
url: string;
};
album?: {
title: string;
image?: string;
};
wiki?: {
summary: string;
content: string;
};
duration?: string;
playcount?: string;
listeners?: string;
tags?: Array<{
name: string;
url: string;
}>;
}
interface LastFmTag {
name: string;
url: string;
}
interface LastFmArtist {
name: string;
url: string;
image?: Array<{ '#text': string; size: string }>;
}
interface LastFmBio {
summary: string;
content: string;
}
interface LastFmStats {
listeners: string;
playcount: string;
}
interface LastFmArtistInfo {
name: string;
bio?: LastFmBio;
stats?: LastFmStats;
tags?: {
tag: LastFmTag[];
};
similar?: {
artist: LastFmArtist[];
};
image?: Array<{ '#text': string; size: string }>;
}
interface SearchResult {
type: 'track' | 'album' | 'artist';
id: string;
title: string;
subtitle: string;
image?: string;
data: Song | Album | Artist;
lastFmData?: LastFmArtistInfo;
}
export function SpotlightSearch({ isOpen, onClose }: SpotlightSearchProps) {
const [query, setQuery] = useState('');
const [results, setResults] = useState<SearchResult[]>([]);
const [selectedIndex, setSelectedIndex] = useState(0);
const [isLoading, setIsLoading] = useState(false);
const [showDetails, setShowDetails] = useState(false);
const [selectedResult, setSelectedResult] = useState<SearchResult | null>(null);
const [lastFmDetails, setLastFmDetails] = useState<LastFmArtistInfo | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
const resultsRef = useRef<HTMLDivElement>(null);
const router = useRouter();
const api = getNavidromeAPI();
const { search2 } = useNavidrome();
const { playTrack, addToQueue, insertAtBeginningOfQueue } = useAudioPlayer();
// Convert Song to Track with proper URL generation
const songToTrack = useCallback((song: Song) => {
if (!api) {
throw new Error('Navidrome API not configured');
}
return {
id: song.id,
name: song.title,
url: api.getStreamUrl(song.id),
artist: song.artist,
album: song.album,
duration: song.duration,
coverArt: song.coverArt ? api.getCoverArtUrl(song.coverArt, 512) : undefined,
albumId: song.albumId,
artistId: song.artistId,
starred: !!song.starred,
replayGain: song.replayGain || 0
};
}, [api]);
// Focus input when opened
useEffect(() => {
if (isOpen && inputRef.current) {
inputRef.current.focus();
}
}, [isOpen]);
// Close on escape
useKeyboardShortcuts({
disabled: !isOpen
});
// Handle keyboard navigation
useEffect(() => {
if (!isOpen) return;
const handleKeyDown = (e: KeyboardEvent) => {
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
setSelectedIndex(prev => Math.min(prev + 1, results.length - 1));
break;
case 'ArrowUp':
e.preventDefault();
setSelectedIndex(prev => Math.max(prev - 1, 0));
break;
case 'Enter':
e.preventDefault();
if (results[selectedIndex]) {
handleResultSelect(results[selectedIndex]);
}
break;
case 'Escape':
e.preventDefault();
if (showDetails) {
setShowDetails(false);
setSelectedResult(null);
} else {
onClose();
}
break;
case 'Tab':
e.preventDefault();
if (results[selectedIndex]) {
handleShowDetails(results[selectedIndex]);
}
break;
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [isOpen, results, selectedIndex, showDetails, onClose]);
// Search function with debouncing
const performSearch = useCallback(async (searchQuery: string) => {
if (!searchQuery.trim()) {
setResults([]);
return;
}
setIsLoading(true);
try {
const searchResults = await search2(searchQuery);
const formattedResults: SearchResult[] = [];
// Add tracks
searchResults.songs?.forEach(song => {
formattedResults.push({
type: 'track',
id: song.id,
title: song.title,
subtitle: `${song.artist}${song.album}`,
image: song.coverArt && api ? api.getCoverArtUrl(song.coverArt, 256) : undefined,
data: song
});
});
// Add albums
searchResults.albums?.forEach(album => {
formattedResults.push({
type: 'album',
id: album.id,
title: album.name,
subtitle: `${album.artist}${album.songCount} tracks`,
image: album.coverArt && api ? api.getCoverArtUrl(album.coverArt, 256) : undefined,
data: album
});
});
// Add artists
searchResults.artists?.forEach(artist => {
formattedResults.push({
type: 'artist',
id: artist.id,
title: artist.name,
subtitle: `${artist.albumCount} albums`,
image: artist.coverArt && api ? api.getCoverArtUrl(artist.coverArt, 256) : undefined,
data: artist
});
});
setResults(formattedResults);
setSelectedIndex(0);
} catch (error) {
console.error('Search failed:', error);
setResults([]);
} finally {
setIsLoading(false);
}
}, [search2]);
// Debounced search
useEffect(() => {
const timeoutId = setTimeout(() => {
performSearch(query);
}, 300);
return () => clearTimeout(timeoutId);
}, [query, performSearch]);
const handleResultSelect = (result: SearchResult) => {
switch (result.type) {
case 'track':
const songData = result.data as Song;
const track = songToTrack(songData);
playTrack(track, true);
onClose();
break;
case 'album':
router.push(`/album/${result.id}`);
onClose();
break;
case 'artist':
router.push(`/artist/${result.id}`);
onClose();
break;
}
};
const handleShowDetails = async (result: SearchResult) => {
setSelectedResult(result);
setShowDetails(true);
setLastFmDetails(null);
// Fetch Last.fm data
try {
let lastFmData = null;
if (result.type === 'artist') {
const artistData = result.data as Artist;
lastFmData = await lastFmAPI.getArtistInfo(artistData.name);
} else if (result.type === 'album') {
// For albums, get artist info as Last.fm album info is limited
const albumData = result.data as Album;
lastFmData = await lastFmAPI.getArtistInfo(albumData.artist);
} else if (result.type === 'track') {
// For tracks, get artist info
const songData = result.data as Song;
lastFmData = await lastFmAPI.getArtistInfo(songData.artist);
}
setLastFmDetails(lastFmData);
} catch (error) {
console.error('Failed to fetch Last.fm data:', error);
}
};
const handlePlayNext = (result: SearchResult) => {
if (result.type === 'track') {
const songData = result.data as Song;
const track = songToTrack(songData);
insertAtBeginningOfQueue(track);
}
};
const handleAddToQueue = (result: SearchResult) => {
if (result.type === 'track') {
const songData = result.data as Song;
const track = songToTrack(songData);
addToQueue(track);
}
};
const getResultIcon = (type: string) => {
switch (type) {
case 'track': return <Music className="w-4 h-4" />;
case 'album': return <Disc className="w-4 h-4" />;
case 'artist': return <User className="w-4 h-4" />;
default: return <Search className="w-4 h-4" />;
}
};
if (!isOpen) return null;
return (
<AnimatePresence>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-50 bg-black/50 backdrop-blur-sm"
onClick={onClose}
>
<div className="flex items-start justify-center pt-[10vh] px-4">
<motion.div
initial={{ opacity: 0, scale: 0.95, y: -20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: -20 }}
transition={{ type: "spring", duration: 0.4 }}
className="w-full max-w-2xl bg-background border border-border rounded-lg shadow-2xl overflow-hidden"
onClick={(e) => e.stopPropagation()}
>
{/* Search Input */}
<div className="flex items-center px-4 py-3 border-b border-border">
<Search className="w-5 h-5 text-muted-foreground mr-3" />
<Input
ref={inputRef}
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search tracks, albums, artists..."
className="border-0 focus-visible:ring-0 text-lg bg-transparent"
/>
{query && (
<Button
variant="ghost"
size="sm"
onClick={() => setQuery('')}
className="p-1 h-auto"
>
<X className="w-4 h-4" />
</Button>
)}
</div>
{/* Results */}
<div className="max-h-96 overflow-hidden">
{isLoading ? (
<div className="flex items-center justify-center py-8">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary"></div>
<span className="ml-2 text-muted-foreground">Searching...</span>
</div>
) : results.length > 0 ? (
<ScrollArea className="max-h-96" ref={resultsRef}>
<div className="py-2">
{results.map((result, index) => (
<div
key={`${result.type}-${result.id}`}
className={`flex items-center px-4 py-3 cursor-pointer transition-colors ${
index === selectedIndex ? 'bg-accent' : 'hover:bg-accent/50'
}`}
onClick={() => handleResultSelect(result)}
onMouseEnter={() => setSelectedIndex(index)}
>
<div className="flex items-center space-x-3 flex-1 min-w-0">
{result.image ? (
<Image
src={result.image}
alt={result.title}
width={40}
height={40}
className="w-10 h-10 rounded object-cover"
/>
) : (
<div className="w-10 h-10 rounded bg-muted flex items-center justify-center">
{getResultIcon(result.type)}
</div>
)}
<div className="flex-1 min-w-0">
<div className="font-medium truncate">{result.title}</div>
<div className="text-sm text-muted-foreground truncate">
{result.subtitle}
</div>
</div>
<Badge variant="secondary" className="capitalize">
{result.type}
</Badge>
</div>
{/* Quick Actions */}
<div className="flex items-center space-x-1 ml-3">
{result.type === 'track' && (
<>
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation();
handlePlayNext(result);
}}
className="h-8 w-8 p-0"
title="Play Next"
>
<Play className="w-3 h-3" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation();
handleAddToQueue(result);
}}
className="h-8 w-8 p-0"
title="Add to Queue"
>
<Plus className="w-3 h-3" />
</Button>
</>
)}
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation();
handleShowDetails(result);
}}
className="h-8 w-8 p-0"
title="Show Details (Tab)"
>
<Info className="w-3 h-3" />
</Button>
</div>
</div>
))}
</div>
</ScrollArea>
) : query.trim() ? (
<div className="flex flex-col items-center justify-center py-8 text-muted-foreground">
<Search className="w-8 h-8 mb-2" />
<p>No results found for &ldquo;{query}&rdquo;</p>
</div>
) : (
<div className="flex flex-col items-center justify-center py-8 text-muted-foreground">
<Music className="w-8 h-8 mb-2" />
<p>Start typing to search your music library</p>
<div className="text-xs mt-2 space-y-1">
<p> Use to navigate Enter to select</p>
<p> Tab for details Esc to close</p>
</div>
</div>
)}
</div>
</motion.div>
</div>
{/* Details Panel */}
<AnimatePresence>
{showDetails && selectedResult && (
<motion.div
initial={{ opacity: 0, x: 400 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 400 }}
transition={{ type: "spring", duration: 0.4 }}
className="fixed right-4 top-[10vh] bottom-4 w-80 bg-background border border-border rounded-lg shadow-2xl overflow-hidden"
>
<div className="flex items-center justify-between p-4 border-b border-border">
<h3 className="font-semibold">Details</h3>
<Button
variant="ghost"
size="sm"
onClick={() => setShowDetails(false)}
className="h-8 w-8 p-0"
>
<X className="w-4 h-4" />
</Button>
</div>
<ScrollArea className="h-full">
<div className="p-4 space-y-4">
{/* Basic Info */}
<div className="space-y-3">
{selectedResult.image && (
<Image
src={selectedResult.image}
alt={selectedResult.title}
width={200}
height={200}
className="w-full aspect-square rounded object-cover"
/>
)}
<div>
<h4 className="font-semibold text-lg">{selectedResult.title}</h4>
<p className="text-muted-foreground">{selectedResult.subtitle}</p>
<Badge variant="secondary" className="mt-1 capitalize">
{selectedResult.type}
</Badge>
</div>
</div>
{/* Last.fm Data */}
{lastFmDetails && (
<>
<Separator />
<div className="space-y-3">
<div className="flex items-center space-x-2">
<ExternalLink className="w-4 h-4" />
<span className="font-medium">Last.fm Info</span>
</div>
{/* Stats */}
{lastFmDetails.stats && (
<div className="grid grid-cols-2 gap-3">
<div className="text-center p-2 bg-muted rounded">
<div className="flex items-center justify-center space-x-1 mb-1">
<Users className="w-3 h-3" />
<span className="text-xs font-medium">Listeners</span>
</div>
<div className="text-sm font-semibold">
{parseInt(lastFmDetails.stats.listeners).toLocaleString()}
</div>
</div>
<div className="text-center p-2 bg-muted rounded">
<div className="flex items-center justify-center space-x-1 mb-1">
<TrendingUp className="w-3 h-3" />
<span className="text-xs font-medium">Plays</span>
</div>
<div className="text-sm font-semibold">
{parseInt(lastFmDetails.stats.playcount).toLocaleString()}
</div>
</div>
</div>
)}
{/* Bio */}
{lastFmDetails.bio?.summary && (
<div>
<h5 className="font-medium mb-2">Biography</h5>
<p className="text-sm text-muted-foreground leading-relaxed">
{lastFmDetails.bio.summary.replace(/<[^>]*>/g, '').split('\n')[0]}
</p>
</div>
)}
{/* Tags */}
{lastFmDetails.tags?.tag && (
<div>
<h5 className="font-medium mb-2">Tags</h5>
<div className="flex flex-wrap gap-1">
{lastFmDetails.tags.tag.slice(0, 6).map((tag: LastFmTag, index: number) => (
<Badge key={index} variant="outline" className="text-xs">
{tag.name}
</Badge>
))}
</div>
</div>
)}
{/* Similar Artists */}
{lastFmDetails.similar?.artist && (
<div>
<h5 className="font-medium mb-2">Similar Artists</h5>
<div className="space-y-2">
{lastFmDetails.similar.artist.slice(0, 4).map((artist: LastFmArtist, index: number) => (
<div key={index} className="flex items-center space-x-2">
<div className="w-8 h-8 bg-muted rounded flex items-center justify-center">
<User className="w-3 h-3" />
</div>
<span className="text-sm">{artist.name}</span>
</div>
))}
</div>
</div>
)}
</div>
</>
)}
{/* Actions */}
<Separator />
<div className="space-y-2">
<Button
onClick={() => handleResultSelect(selectedResult)}
className="w-full"
>
<Play className="w-4 h-4 mr-2" />
{selectedResult.type === 'track' ? 'Play Track' :
selectedResult.type === 'album' ? 'View Album' : 'View Artist'}
</Button>
{selectedResult.type === 'track' && (
<>
<Button
variant="outline"
onClick={() => handlePlayNext(selectedResult)}
className="w-full"
>
<Play className="w-4 h-4 mr-2" />
Play Next
</Button>
<Button
variant="outline"
onClick={() => handleAddToQueue(selectedResult)}
className="w-full"
>
<Plus className="w-4 h-4 mr-2" />
Add to Queue
</Button>
</>
)}
</div>
</div>
</ScrollArea>
</motion.div>
)}
</AnimatePresence>
</motion.div>
</AnimatePresence>
);
}

View File

@@ -0,0 +1,8 @@
'use client';
import { useViewportThemeColor } from '@/hooks/use-viewport-theme-color';
export default function ThemeColorHandler() {
useViewportThemeColor();
return null;
}

View File

@@ -2,12 +2,14 @@
import React, { createContext, useContext, useEffect, useState } from 'react';
type Theme = 'blue' | 'violet' | 'red' | 'rose' | 'orange' | 'green' | 'yellow';
type Theme = 'default' | 'blue' | 'violet' | 'red' | 'rose' | 'orange' | 'green' | 'yellow';
type Mode = 'light' | 'dark' | 'system';
interface ThemeContextType {
theme: Theme;
mode: Mode;
setTheme: (theme: Theme) => void;
setMode: (mode: Mode) => void;
}
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
@@ -25,18 +27,25 @@ interface ThemeProviderProps {
}
export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children }) => {
const [theme, setTheme] = useState<Theme>('blue');
const [theme, setTheme] = useState<Theme>('default');
const [mode, setMode] = useState<Mode>('system');
const [mounted, setMounted] = useState(false);
// Load theme settings from localStorage on component mount
useEffect(() => {
setMounted(true);
const savedTheme = localStorage.getItem('theme');
const validThemes: Theme[] = ['blue', 'violet', 'red', 'rose', 'orange', 'green', 'yellow'];
const savedMode = localStorage.getItem('theme-mode');
const validThemes: Theme[] = ['default', 'blue', 'violet', 'red', 'rose', 'orange', 'green', 'yellow'];
const validModes: Mode[] = ['light', 'dark', 'system'];
if (savedTheme && validThemes.includes(savedTheme as Theme)) {
setTheme(savedTheme as Theme);
}
if (savedMode && validModes.includes(savedMode as Mode)) {
setMode(savedMode as Mode);
}
}, []);
// Apply theme changes
@@ -46,35 +55,54 @@ export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children }) => {
const root = document.documentElement;
// Remove existing theme classes
root.classList.remove('theme-blue', 'theme-violet', 'theme-red', 'theme-rose', 'theme-orange', 'theme-green', 'theme-yellow', 'dark');
root.classList.remove('theme-default', 'theme-blue', 'theme-violet', 'theme-red', 'theme-rose', 'theme-orange', 'theme-green', 'theme-yellow', 'dark');
// Add new theme class
root.classList.add(`theme-${theme}`);
// Always follow system preference for dark mode
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
const applySystemTheme = () => {
root.classList.toggle('dark', mediaQuery.matches);
// Apply dark/light mode
const applyMode = () => {
if (mode === 'dark') {
root.classList.add('dark');
} else if (mode === 'light') {
root.classList.remove('dark');
} else { // system
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
root.classList.toggle('dark', mediaQuery.matches);
}
};
applySystemTheme();
mediaQuery.addEventListener('change', applySystemTheme);
applyMode();
// Save theme to localStorage
// Listen for system preference changes only if mode is 'system'
let mediaQuery: MediaQueryList | null = null;
if (mode === 'system') {
mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
mediaQuery.addEventListener('change', applyMode);
}
// Save settings to localStorage
localStorage.setItem('theme', theme);
localStorage.setItem('theme-mode', mode);
// Cleanup listener
return () => mediaQuery.removeEventListener('change', applySystemTheme);
}, [theme, mounted]);
return () => {
if (mediaQuery) {
mediaQuery.removeEventListener('change', applyMode);
}
};
}, [theme, mode, mounted]);
return (
<ThemeContext.Provider
value={{
theme,
mode,
setTheme,
setMode,
}}
>
<div className={`theme-${theme}`}>
<div>
{children}
</div>
</ThemeContext.Provider>

View File

@@ -0,0 +1,210 @@
'use client';
import React, { useState, useEffect } from 'react';
import Image from 'next/image';
import Link from 'next/link';
import { User, ChevronDown, Settings, LogOut } from 'lucide-react';
import { useNavidrome } from '@/app/components/NavidromeContext';
import { getGravatarUrl } from '@/lib/gravatar';
import { User as NavidromeUser } from '@/lib/navidrome';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Button } from '@/components/ui/button';
interface UserProfileProps {
variant?: 'desktop' | 'mobile';
}
export function UserProfile({ variant = 'desktop' }: UserProfileProps) {
const { api, isConnected } = useNavidrome();
const [userInfo, setUserInfo] = useState<NavidromeUser | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const fetchUserInfo = async () => {
if (!api || !isConnected) {
setLoading(false);
return;
}
try {
const user = await api.getUserInfo();
setUserInfo(user);
} catch (error) {
console.error('Failed to fetch user info:', error);
} finally {
setLoading(false);
}
};
fetchUserInfo();
}, [api, isConnected]);
const handleLogout = () => {
// Clear Navidrome config and reload
localStorage.removeItem('navidrome-config');
window.location.reload();
};
if (!userInfo) {
if (variant === 'desktop') {
return (
<Link href="/settings">
<Button variant="ghost" size="sm" className="gap-2">
<User className="w-4 h-4" />
</Button>
</Link>
);
} else {
return (
<Link href="/settings">
<Button variant="ghost" size="sm" className="gap-2">
<User className="w-4 h-4" />
</Button>
</Link>
);
}
}
const gravatarUrl = userInfo.email
? getGravatarUrl(userInfo.email, variant === 'desktop' ? 32 : 48, 'identicon')
: null;
if (variant === 'desktop') {
// Desktop: Only show profile icon
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="flex items-center gap-1 h-auto p-2">
{gravatarUrl ? (
<Image
src={gravatarUrl}
alt={`${userInfo.username}'s avatar`}
width={16}
height={16}
className="rounded-full"
onError={(e) => {
const target = e.target as HTMLImageElement;
target.style.display = 'none';
}}
/>
) : (
<div className="w-4 h-4 bg-primary/10 rounded-full flex items-center justify-center">
<User className="w-2 h-2 text-primary" />
</div>
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<div className="flex items-center gap-2 p-2">
{gravatarUrl ? (
<Image
src={gravatarUrl}
alt={`${userInfo.username}'s avatar`}
width={32}
height={32}
className="rounded-full"
/>
) : (
<div className="w-10 h-10 bg-primary/10 rounded-full flex items-center justify-center">
<User className="w-5 h-5 text-primary" />
</div>
)}
<div>
<p className="text-sm font-medium">{userInfo.username}</p>
{userInfo.email && (
<p className="text-xs text-muted-foreground">{userInfo.email}</p>
)}
</div>
</div>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link href="/settings" className="flex items-center gap-2">
<Settings className="w-4 h-4" />
Settings
</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={handleLogout}
className="flex items-center gap-2 text-red-600 focus:text-red-600"
>
<LogOut className="w-4 h-4" />
Logout
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
} else {
// Mobile: Show only icon with dropdown
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="flex items-center gap-1 h-auto p-2">
{gravatarUrl ? (
<Image
src={gravatarUrl}
alt={`${userInfo.username}'s avatar`}
width={32}
height={32}
className="rounded-full"
onError={(e) => {
const target = e.target as HTMLImageElement;
target.style.display = 'none';
}}
/>
) : (
<div className="w-8 h-8 bg-primary/10 rounded-full flex items-center justify-center">
<User className="w-4 h-4 text-primary" />
</div>
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<div className="flex items-center gap-2 p-2">
{gravatarUrl ? (
<Image
src={gravatarUrl}
alt={`${userInfo.username}'s avatar`}
width={32}
height={32}
className="rounded-full"
/>
) : (
<div className="w-10 h-10 bg-primary/10 rounded-full flex items-center justify-center">
<User className="w-5 h-5 text-primary" />
</div>
)}
<div>
<p className="text-sm font-medium">{userInfo.username}</p>
{userInfo.email && (
<p className="text-xs text-muted-foreground">{userInfo.email}</p>
)}
</div>
</div>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link href="/settings" className="flex items-center gap-2">
<Settings className="w-4 h-4" />
Settings
</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={handleLogout}
className="flex items-center gap-2 text-red-600 focus:text-red-600"
>
<LogOut className="w-4 h-4" />
Logout
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
}

View File

@@ -1,17 +1,69 @@
'use client';
import { useState, useEffect } from 'react';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { ScrollArea } from '@/components/ui/scroll-area';
// Current app version from package.json
const APP_VERSION = '2025.07.02';
const APP_VERSION = '2025.07.31';
// Changelog data - add new versions at the top
const CHANGELOG = [
{
{
version: '2025.07.31',
title: 'July End of Month Update',
changes: [
'Native support for moblie devices (using pwa)',
],
fixes: [
'Fixed issue with mobile navigation bar not displaying correctly',
'Improved performance on mobile devices',
'Resolved layout issues on smaller screens',
'Fixed audio player controls not responding on mobile',
'Improved touch interactions for better usability',
'Fixed issue with album artwork not loading on mobile',
'Resolved bug with search functionality on mobile devices',
'Improved caching for faster load times on mobile',
],
breaking: [
]
},
{
version: '2025.07.10',
title: 'July Major Update',
changes: [
// New Features
'Support for Rich PWA Installs',
'Added right-click shortcuts to the PWA icon',
'Onboarding now suggests Navidrome\'s Demo Server',
'User can export settings as a downloadable JSON',
'New sidebar layout (compact design)',
'New masonry-style grid in the settings page',
'New options in settings to customize appearance',
'Added 5 recently played albums and playlists created',
'New loading screen',
'New recommended songs section',
'Enhanced playlist page',
'Enhanced Home page layout and content',
'Themes updated to use OKLCH (from HSL)',
'All themes updated (light themes look similar)',
'Skeleton loading added across all pages'
],
fixes: [
'Fixed skeleton loader on the Home screen',
'Fixed album page not showing correct album art',
'Fixed album page not showing correct artist',
'Fixed album page not showing correct song count',
'Fixed flash of onboarding when already onboarded',
'Fixed issue with audio player not resuming playback after pause',
'Resolved bug with search results not displaying correctly'
],
breaking: [
// Technically not breaking, but notable:
'Removed extended sidebar layout for a cleaner look'
]
},
{
version: '2025.07.02',
title: 'July Mini Update',
changes: [
@@ -153,65 +205,86 @@ export function WhatsNewPopup() {
);
return (
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="max-w-2xl max-h-[80vh]">
<DialogHeader className="flex flex-row items-center justify-between space-y-0 pb-4">
<div>
<DialogTitle className="text-2xl font-bold flex items-center gap-2">
What&apos;s New in Mice
<Badge variant="outline">
{tab === 'latest' ? currentVersionChangelog.version : archiveChangelog?.version}
</Badge>
</DialogTitle>
<>
{isOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center">
{/* Backdrop */}
<div
className="fixed inset-0 bg-black/50"
onClick={handleClose}
/>
{/* Dialog content */}
<div className="relative bg-background rounded-lg shadow-lg max-w-2xl w-full mx-4 max-h-[80vh] flex flex-col">
{/* Header */}
<div className="flex flex-row items-center justify-between space-y-0 p-6 pb-4 shrink-0">
<div>
<h2 className="text-2xl font-bold flex items-center gap-2">
What&apos;s New in Mice
<Badge variant="outline">
{tab === 'latest' ? currentVersionChangelog.version : archiveChangelog?.version}
</Badge>
</h2>
</div>
<button
onClick={handleClose}
className="text-muted-foreground hover:text-foreground transition-colors"
>
</button>
</div>
{/* Tabs */}
<div className="flex gap-2 px-6 pt-4 shrink-0">
<Button
variant={tab === 'latest' ? 'default' : 'outline'}
size="sm"
onClick={() => setTab('latest')}
>
Latest
</Button>
<Button
variant={tab === 'archive' ? 'default' : 'outline'}
size="sm"
onClick={() => setTab('archive')}
disabled={archiveChangelogs.length === 0}
>
Archive
</Button>
{tab === 'archive' && archiveChangelogs.length > 0 && (
<select
className="ml-2 border rounded px-2 py-1 text-sm bg-background"
value={selectedArchive}
onChange={e => setSelectedArchive(e.target.value)}
>
{archiveChangelogs.map(entry => (
<option key={entry.version} value={entry.version}>
{entry.version}
</option>
))}
</select>
)}
</div>
{/* Scrollable content */}
<div className="flex-1 overflow-y-auto px-6 py-4 min-h-0">
<div className="space-y-6">
{tab === 'latest'
? renderChangelog(currentVersionChangelog)
: archiveChangelog && renderChangelog(archiveChangelog)}
</div>
</div>
{/* Footer button */}
<div className="flex justify-center p-6 pt-4 shrink-0">
<Button onClick={handleClose}>
Got it!
</Button>
</div>
</div>
</DialogHeader>
{/* Tabs */}
<>
<div className="flex gap-2 mb-4">
<Button
variant={tab === 'latest' ? 'default' : 'outline'}
size="sm"
onClick={() => setTab('latest')}
>
Latest
</Button>
<Button
variant={tab === 'archive' ? 'default' : 'outline'}
size="sm"
onClick={() => setTab('archive')}
disabled={archiveChangelogs.length === 0}
>
Archive
</Button>
{tab === 'archive' && archiveChangelogs.length > 0 && (
<select
className="ml-2 border rounded px-2 py-1 text-sm"
value={selectedArchive}
onChange={e => setSelectedArchive(e.target.value)}
>
{archiveChangelogs.map(entry => (
<option key={entry.version} value={entry.version}>
{entry.version}
</option>
))}
</select>
)}
</div>
<ScrollArea className="max-h-[60vh] pr-4">
{tab === 'latest'
? renderChangelog(currentVersionChangelog)
: archiveChangelog && renderChangelog(archiveChangelog)}
</ScrollArea>
<div className="flex justify-center pt-4">
<Button onClick={handleClose}>
Got it!
</Button>
</div>
</>
</DialogContent>
</Dialog>
)}
</>
);
}

View File

@@ -3,6 +3,7 @@
import Image from "next/image"
import { PlusCircledIcon } from "@radix-ui/react-icons"
import { useRouter } from 'next/navigation';
import { motion } from 'framer-motion';
import { cn } from "@/lib/utils"
import {
@@ -17,17 +18,22 @@ import {
} from "../../components/ui/context-menu"
import { useNavidrome } from "./NavidromeContext"
import { useOfflineNavidrome } from "./OfflineNavidromeProvider"
import Link from "next/link";
import { useAudioPlayer, Track } from "@/app/components/AudioPlayerContext";
import { getNavidromeAPI } from "@/lib/navidrome";
import React, { useState, useEffect } from 'react';
import React, { useState, useEffect, useMemo, useCallback } from 'react';
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { ArtistIcon } from "@/app/components/artist-icon";
import { Heart, Music, Disc, Mic, Play } from "lucide-react";
import { Heart, Music, Disc, Mic, Play, Download } from "lucide-react";
import { Album, Artist, Song } from "@/lib/navidrome";
import { OfflineIndicator } from "@/app/components/OfflineIndicator";
interface AlbumArtworkProps extends React.HTMLAttributes<HTMLDivElement> {
interface AlbumArtworkProps extends Omit<
React.HTMLAttributes<HTMLDivElement>,
'onDrag' | 'onDragStart' | 'onDragEnd' | 'onDragOver' | 'onDragEnter' | 'onDragLeave' | 'onDrop'
> {
album: Album
aspectRatio?: "portrait" | "square"
width?: number
@@ -43,14 +49,44 @@ export function AlbumArtwork({
...props
}: AlbumArtworkProps) {
const { api, isConnected } = useNavidrome();
const offline = useOfflineNavidrome();
const router = useRouter();
const { addAlbumToQueue, playTrack, addToQueue } = useAudioPlayer();
const { playlists, starItem, unstarItem } = useNavidrome();
// Memoize cover art URL with dynamic sizing
const coverArtUrl = useMemo(() => {
if (!api || !album.coverArt) return '/default-user.jpg';
// Use width prop or default size for optimization
const imageSize = width || height || 300;
return api.getCoverArtUrl(album.coverArt, imageSize);
}, [api, album.coverArt, width, height]);
// Use callback to prevent function recreation on every render
const handleImageLoad = useCallback(() => {
// Image loaded successfully - no state update needed
}, []);
const handleImageError = useCallback(() => {
// Image failed to load - could set error state if needed
}, []);
const handleClick = () => {
router.push(`/album/${album.id}`);
};
const handlePrefetch = () => {
try {
// Next.js App Router will prefetch on hover when using Link with prefetch
// but we also call router.prefetch to ensure programmatic prefetch when present.
const r = router as unknown as { prefetch?: (href: string) => Promise<void> | void };
if (r && typeof r.prefetch === 'function') {
r.prefetch(`/album/${album.id}`);
}
} catch {}
};
const handleAddToQueue = () => {
addAlbumToQueue(album.id);
};
@@ -78,7 +114,7 @@ export function AlbumArtwork({
artistId: song.artistId,
url: api.getStreamUrl(song.id),
duration: song.duration,
coverArt: song.coverArt ? api.getCoverArtUrl(song.coverArt) : undefined,
coverArt: song.coverArt ? api.getCoverArtUrl(song.coverArt, 1200) : undefined,
starred: !!song.starred
}));
@@ -103,42 +139,61 @@ export function AlbumArtwork({
console.error('Failed to toggle favorite:', error);
}
};
// Get cover art URL with proper fallback
const coverArtUrl = album.coverArt && api
? api.getCoverArtUrl(album.coverArt, 300)
: '/default-user.jpg';
return (
<div className={cn("space-y-3", className)} {...props}>
<motion.div
initial={{ opacity: 0, y: 10 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, amount: 0.15 }}
transition={{ duration: 0.2 }}
whileHover={{ y: -2 }}
>
<ContextMenu>
<ContextMenuTrigger>
<Card key={album.id} className="overflow-hidden cursor-pointer" onClick={() => handleClick()}>
<div className="aspect-square relative group">
{album.coverArt && api ? (
<Image
src={api.getCoverArtUrl(album.coverArt)}
alt={album.name}
fill
className="w-full h-full object-cover"
sizes="(max-width: 768px) 100vw, 300px"
/>
) : (
<div className="w-full h-full bg-muted rounded flex items-center justify-center">
<Disc className="w-12 h-12 text-muted-foreground" />
</div>
)}
<div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-2">
<Play className="w-6 h-6 mx-auto hidden group-hover:block" onClick={() => handlePlayAlbum(album)}/>
</div>
</div>
<CardContent className="p-4">
<h3 className="font-semibold truncate">{album.name}</h3>
<p className="text-sm text-muted-foreground truncate " onClick={() => router.push(album.artistId)}>{album.artist}</p>
<p className="text-xs text-muted-foreground mt-1">
{album.songCount} songs {Math.floor(album.duration / 60)} min
</p>
</CardContent>
</Card>
<Card key={album.id} className="overflow-hidden cursor-pointer px-0 py-0 gap-0" onClick={() => handleClick()} onMouseEnter={handlePrefetch} onFocus={handlePrefetch}>
<div className="aspect-square relative group">
{album.coverArt && api && !offline.isOfflineMode ? (
<Image
src={coverArtUrl}
alt={album.name}
fill
className="w-full h-full object-cover transition-all"
sizes="(max-width: 768px) 100vw, 300px"
onLoad={handleImageLoad}
onError={handleImageError}
priority={false}
loading="lazy"
/>
) : (
<div className="w-full h-full bg-muted rounded flex items-center justify-center">
<Disc className="w-12 h-12 text-muted-foreground" />
</div>
)}
<div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-2">
<Play className="w-6 h-6 mx-auto hidden group-hover:block" onClick={() => handlePlayAlbum(album)}/>
</div>
{/* Offline indicator in top-right corner */}
<div className="absolute top-2 right-2">
<OfflineIndicator
id={album.id}
type="album"
size="sm"
className="bg-black/60 text-white rounded-full p-1"
/>
</div>
</div>
<CardContent className="p-4">
<h3 className="font-semibold truncate">
<Link href={`/album/${album.id}`} prefetch>{album.name}</Link>
</h3>
<p className="text-sm text-muted-foreground truncate " onClick={() => router.push(album.artistId)}>{album.artist}</p>
<p className="text-xs text-muted-foreground mt-1">
{album.songCount} songs {Math.floor(album.duration / 60)} min
</p>
</CardContent>
</Card>
{/* <div onClick={handleClick} className="overflow-hidden rounded-md">
<Image
src={coverArtUrl}
@@ -148,7 +203,7 @@ export function AlbumArtwork({
className={cn(
"w-full h-full object-cover transition-all hover:scale-105",
aspectRatio === "portrait" ? "aspect-[3/4]" : "aspect-square"
aspectRatio === "portrait" ? "aspect-3/4" : "aspect-square"
)}
/>
</div> */}
@@ -195,6 +250,7 @@ export function AlbumArtwork({
<ContextMenuItem>Share</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
</motion.div>
</div>
)
}

View File

@@ -4,6 +4,7 @@ import Image from "next/image"
import { PlusCircledIcon } from "@radix-ui/react-icons"
import { useRouter } from 'next/navigation';
import { cn } from "@/lib/utils"
import { motion } from 'framer-motion'
import {
ContextMenu,
ContextMenuContent,
@@ -25,12 +26,14 @@ interface ArtistIconProps extends React.HTMLAttributes<HTMLDivElement> {
artist: Artist
size?: number
imageOnly?: boolean
responsive?: boolean
}
export function ArtistIcon({
artist,
size = 150,
imageOnly = false,
responsive = false,
className,
...props
}: ArtistIconProps) {
@@ -54,16 +57,16 @@ export function ArtistIcon({
starItem(artist.id, 'artist');
}
};
// Get cover art URL with proper fallback
// Get cover art URL with proper fallback - use higher resolution for better quality
const artistImageUrl = artist.coverArt && api
? api.getCoverArtUrl(artist.coverArt, 200)
? api.getCoverArtUrl(artist.coverArt, 320)
: '/default-user.jpg';
// If imageOnly is true, return just the image without context menu or text
if (imageOnly) {
return (
<div
className={cn("overflow-hidden rounded-full cursor-pointer flex-shrink-0", className)}
className={cn("overflow-hidden rounded-full cursor-pointer shrink-0", className)}
onClick={handleClick}
style={{ width: size, height: size }}
{...props}
@@ -79,22 +82,40 @@ export function ArtistIcon({
);
}
// Determine if we should use responsive layout
const isResponsive = responsive;
return (
<div className={cn("space-y-3", className)} {...props}>
<ContextMenu>
<ContextMenuTrigger>
<Card key={artist.id} className="overflow-hidden cursor-pointer" onClick={() => handleClick()}>
<motion.div
initial={{ opacity: 0, y: 8 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, amount: 0.2 }}
transition={{ duration: 0.2 }}
whileHover={{ y: -2 }}
>
<Card key={artist.id} className="overflow-hidden cursor-pointer px-0 py-0 gap-0" onClick={() => handleClick()}>
<div
className="aspect-square relative group"
style={{ width: size, height: size }}
style={!isResponsive ? { width: size, height: size } : undefined}
>
<div className="w-full h-full">
<Image
src={artist.coverArt && api ? api.getCoverArtUrl(artist.coverArt, 200) : '/placeholder-artist.png'}
src={artist.coverArt && api ? api.getCoverArtUrl(artist.coverArt, 600) : '/placeholder-artist.png'}
alt={artist.name}
width={size}
height={size}
className="object-cover w-full h-full"
{...(isResponsive
? {
fill: true,
sizes: "(max-width: 768px) 33vw, (max-width: 1024px) 25vw, 16vw"
}
: {
width: size,
height: size
}
)}
className={isResponsive ? "object-cover" : "object-cover w-full h-full"}
/>
</div>
</div>
@@ -105,19 +126,7 @@ export function ArtistIcon({
</p>
</CardContent>
</Card>
{/* <div
className="overflow-hidden rounded-full cursor-pointer flex-shrink-0"
onClick={handleClick}
style={{ width: size, height: size }}
>
<Image
src={artistImageUrl}
alt={artist.name}
width={size}
height={size}
className="w-full h-full object-cover transition-all hover:scale-105"
/>
</div> */}
</motion.div>
</ContextMenuTrigger>
<ContextMenuContent className="w-40">
<ContextMenuItem onClick={handleStar}>

View File

@@ -5,7 +5,9 @@ import { Menu } from "@/app/components/menu";
import { Sidebar } from "@/app/components/sidebar";
import { useNavidrome } from "@/app/components/NavidromeContext";
import { AudioPlayer } from "./AudioPlayer";
import { Toaster } from "@/components/ui/toaster"
import { BottomNavigation } from './BottomNavigation';
import { Toaster } from "@/components/ui/toaster";
import { useFavoriteAlbums } from "@/hooks/use-favorite-albums";
interface IhateserversideProps {
children: React.ReactNode;
@@ -18,12 +20,15 @@ const Ihateserverside: React.FC<IhateserversideProps> = ({ children }) => {
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false);
const [isClient, setIsClient] = useState(false);
const { playlists } = useNavidrome();
const { favoriteAlbums, removeFavoriteAlbum } = useFavoriteAlbums();
// Handle client-side hydration
useEffect(() => {
setIsClient(true);
const savedCollapsed = localStorage.getItem('sidebar-collapsed') === 'true';
const savedVisible = localStorage.getItem('sidebar-visible') !== 'false'; // Default to true
setIsSidebarCollapsed(savedCollapsed);
setIsSidebarVisible(savedVisible);
}, []);
const toggleSidebarCollapse = () => {
@@ -34,6 +39,14 @@ const Ihateserverside: React.FC<IhateserversideProps> = ({ children }) => {
}
};
const toggleSidebarVisibility = () => {
const newVisible = !isSidebarVisible;
setIsSidebarVisible(newVisible);
if (typeof window !== 'undefined') {
localStorage.setItem('sidebar-visible', newVisible.toString());
}
};
const handleTransitionEnd = () => {
if (!isSidebarVisible) {
setIsSidebarHidden(true); // This will fully hide the sidebar after transition
@@ -43,17 +56,17 @@ const Ihateserverside: React.FC<IhateserversideProps> = ({ children }) => {
if (!isClient) {
// Return a basic layout during SSR to match initial client render
return (
<div className="hidden md:flex md:flex-col md:h-screen">
<div className="hidden md:flex md:flex-col md:h-screen md:w-screen md:overflow-hidden">
{/* Top Menu */}
<div
className="sticky z-10 bg-background border-b"
className="sticky z-10 bg-background border-b w-full"
style={{
left: 'env(titlebar-area-x, 0)',
top: 'env(titlebar-area-y, 0)',
}}
>
<Menu
toggleSidebar={() => setIsSidebarVisible(!isSidebarVisible)}
toggleSidebar={toggleSidebarVisibility}
isSidebarVisible={isSidebarVisible}
toggleStatusBar={() => setIsStatusBarVisible(!isStatusBarVisible)}
isStatusBarVisible={isStatusBarVisible}
@@ -61,17 +74,19 @@ const Ihateserverside: React.FC<IhateserversideProps> = ({ children }) => {
</div>
{/* Main Content Area */}
<div className="flex-1 flex overflow-hidden">
<div className="w-64 flex-shrink-0 border-r transition-all duration-200">
<Sidebar
playlists={playlists}
className="h-full overflow-y-auto"
collapsed={false}
onToggle={toggleSidebarCollapse}
onTransitionEnd={handleTransitionEnd}
/>
</div>
<div className="flex-1 overflow-y-auto">
<div className="flex-1 flex overflow-hidden w-full">
{isSidebarVisible && (
<div className="w-16 shrink-0 border-r transition-all duration-200">
<Sidebar
playlists={playlists}
className="h-full overflow-y-auto"
visible={isSidebarVisible}
favoriteAlbums={favoriteAlbums}
onRemoveFavoriteAlbum={removeFavoriteAlbum}
/>
</div>
)}
<div className="flex-1 overflow-y-auto min-w-0">
<div>{children}</div>
</div>
</div>
@@ -82,48 +97,74 @@ const Ihateserverside: React.FC<IhateserversideProps> = ({ children }) => {
</div>
);
}
return (
<div className="hidden md:flex md:flex-col md:h-screen">
{/* Top Menu */}
<div
className="sticky z-10 bg-background border-b"
style={{
left: 'env(titlebar-area-x, 0)',
top: 'env(titlebar-area-y, 0)',
}}
>
<Menu
toggleSidebar={() => setIsSidebarVisible(!isSidebarVisible)}
isSidebarVisible={isSidebarVisible}
toggleStatusBar={() => setIsStatusBarVisible(!isStatusBarVisible)}
isStatusBarVisible={isStatusBarVisible}
/>
</div>
<>
{/* Mobile Layout */}
<div className="flex md:hidden flex-col h-screen w-screen overflow-hidden">
{/* Top Menu */}
{/* <div className="shrink-0 bg-background border-b w-full">
<Menu
toggleSidebar={toggleSidebarVisibility}
isSidebarVisible={isSidebarVisible}
toggleStatusBar={() => setIsStatusBarVisible(!isStatusBarVisible)}
isStatusBarVisible={isStatusBarVisible}
/>
</div> */}
{/* Main Content Area */}
<div className="flex-1 flex overflow-hidden">
{isSidebarVisible && (
<div className={`${isSidebarCollapsed ? 'w-16' : 'w-64'} flex-shrink-0 border-r transition-all duration-200`}>
<Sidebar
playlists={playlists}
className="h-full overflow-y-auto"
collapsed={isSidebarCollapsed}
onToggle={toggleSidebarCollapse}
onTransitionEnd={handleTransitionEnd}
/>
</div>
)}
<div className="flex-1 overflow-y-auto">
{/* Main Content Area with bottom padding for audio player and bottom nav */}
<div className="flex-1 overflow-y-auto pb-40">
<div>{children}</div>
</div>
{/* Bottom Navigation for Mobile */}
<BottomNavigation />
<Toaster />
</div>
{/* Floating Audio Player */}
{isStatusBarVisible && (
<AudioPlayer />
)}
<Toaster />
</div>
{/* Desktop Layout */}
<div className="hidden md:flex md:flex-col md:h-screen md:w-screen md:overflow-hidden">
{/* Top Menu */}
<div
className="sticky z-10 bg-background border-b w-full"
style={{
left: 'env(titlebar-area-x, 0)',
top: 'env(titlebar-area-y, 0)',
}}
>
<Menu
toggleSidebar={toggleSidebarVisibility}
isSidebarVisible={isSidebarVisible}
toggleStatusBar={() => setIsStatusBarVisible(!isStatusBarVisible)}
isStatusBarVisible={isStatusBarVisible}
/>
</div>
{/* Main Content Area */}
<div className="flex-1 flex overflow-hidden w-full">
{isSidebarVisible && (
<div className="w-16 shrink-0 border-r transition-all duration-200">
<Sidebar
playlists={playlists}
className="h-full overflow-y-auto"
visible={isSidebarVisible}
favoriteAlbums={favoriteAlbums}
onRemoveFavoriteAlbum={removeFavoriteAlbum}
/>
</div>
)}
<div className="flex-1 overflow-y-auto min-w-0">
<div>{children}</div>
</div>
</div>
<Toaster />
</div>
{/* Single Shared Audio Player - shows on all layouts */}
<AudioPlayer />
</>
);
};

View File

@@ -1,7 +1,9 @@
import { useCallback } from "react";
import { useRouter } from 'next/navigation';
import Image from "next/image";
import { Github, Mail } from "lucide-react"
import { Github, Mail, Menu as MenuIcon, X } from "lucide-react"
import { UserProfile } from "@/app/components/UserProfile";
import { useGlobalSearch } from "./GlobalSearchProvider";
import {
Menubar,
MenubarCheckboxItem,
@@ -28,9 +30,35 @@ import {
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
} from "@/components/ui/dialog"
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "@/components/ui/drawer"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { useIsMobile } from "@/hooks/use-mobile"
import Link from "next/link"
import {
Search,
Home,
List,
Radio,
Users,
Disc,
Music,
Heart,
Grid3X3,
Clock,
Settings,
Circle
} from "lucide-react";
interface MenuProps {
toggleSidebar: () => void;
@@ -43,9 +71,28 @@ export function Menu({ toggleSidebar, isSidebarVisible, toggleStatusBar, isStatu
const [isFullScreen, setIsFullScreen] = useState(false)
const router = useRouter();
const [open, setOpen] = useState(false);
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const { isConnected } = useNavidrome();
const [isClient, setIsClient] = useState(false);
const [navidromeUrl, setNavidromeUrl] = useState<string | null>(null);
const isMobile = useIsMobile();
const { openSpotlight } = useGlobalSearch();
// Navigation items for mobile menu
const navigationItems = [
{ href: '/', label: 'Home', icon: Home },
{ href: '/search', label: 'Search', icon: Search },
{ href: '/library/albums', label: 'Albums', icon: Disc },
{ href: '/library/artists', label: 'Artists', icon: Users },
{ href: '/library/songs', label: 'Songs', icon: Circle },
{ href: '/library/playlists', label: 'Playlists', icon: Music },
{ href: '/favorites', label: 'Favorites', icon: Heart },
{ href: '/queue', label: 'Queue', icon: List },
{ href: '/radio', label: 'Radio', icon: Radio },
{ href: '/browse', label: 'Browse', icon: Grid3X3 },
{ href: '/history', label: 'History', icon: Clock },
{ href: '/settings', label: 'Settings', icon: Settings },
];
// For this demo, we'll show connection status instead of user auth
const connectionStatus = isConnected ? "Connected to Navidrome" : "Not connected";
@@ -111,30 +158,36 @@ export function Menu({ toggleSidebar, isSidebarVisible, toggleStatusBar, isStatu
return (
<>
<div className="flex items-center justify-between w-full ml-2">
<Menubar
className="rounded-none border-b border-none px-0 lg:px-0 flex-1"
style={{
minWidth: 0,
WebkitAppRegion: "drag"
} as React.CSSProperties}
>
<div style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties} className="flex items-center gap-2">
<MenubarMenu>
<MenubarTrigger className="font-bold">mice</MenubarTrigger>
<MenubarContent>
<MenubarItem onClick={() => setOpen(true)}>About Music</MenubarItem>
<MenubarSeparator />
<MenubarItem onClick={() => router.push('/settings')}>
Preferences <MenubarShortcut>,</MenubarShortcut>
</MenubarItem>
<MenubarSeparator />
<MenubarItem onClick={() => isClient && window.close()}>
Quit Music <MenubarShortcut>Q</MenubarShortcut>
</MenubarItem>
</MenubarContent>
</MenubarMenu>
<div className="border-r-4 w-0"><p className="invisible">j</p></div>
<div className="flex items-center justify-between w-full">
{/* Mobile Top Bar - Simplified since navigation is now at bottom */}
{isMobile ? (
// hey bear!
// nothing
null
) : (
/* Desktop Navigation */
<Menubar
className="rounded-none border-b border-none px-2 lg:px-2 flex-1 min-w-0"
style={{
minWidth: 0,
WebkitAppRegion: "drag"
} as React.CSSProperties}
>
<div style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties} className="flex items-center gap-2">
<MenubarMenu>
<MenubarTrigger className="font-bold">mice</MenubarTrigger>
<MenubarContent>
<MenubarItem onClick={() => setOpen(true)}>About Music</MenubarItem>
<MenubarSeparator />
<MenubarItem onClick={() => router.push('/settings')}>
Preferences <MenubarShortcut>,</MenubarShortcut>
</MenubarItem>
<MenubarSeparator />
<MenubarItem onClick={() => isClient && window.close()}>
Quit Music <MenubarShortcut>Q</MenubarShortcut>
</MenubarItem>
</MenubarContent>
</MenubarMenu>
<MenubarMenu>
<MenubarTrigger className="relative">File</MenubarTrigger>
<MenubarContent>
@@ -280,6 +333,24 @@ export function Menu({ toggleSidebar, isSidebarVisible, toggleStatusBar, isStatu
</MenubarMenu>
</div>
</Menubar>
)}
{/* User Profile and Search - Desktop only */}
{!isMobile && (
<div className="ml-auto flex items-center space-x-2">
<Button
variant="ghost"
size="sm"
onClick={openSpotlight}
className="flex items-center space-x-2"
title="Search (/ or ⌘K)"
>
<Search className="w-4 h-4" />
<span className="hidden lg:inline">Search</span>
</Button>
<UserProfile variant="desktop" />
</div>
)}
</div>

View File

@@ -6,348 +6,238 @@ import { usePathname } from 'next/navigation';
import { Button } from "../../components/ui/button";
import { ScrollArea } from "../../components/ui/scroll-area";
import Link from "next/link";
import { Playlist } from "@/lib/navidrome";
import { ChevronLeft, ChevronRight } from "lucide-react";
import Image from "next/image";
import { Playlist, Album } from "@/lib/navidrome";
import {
Search,
Home,
List,
Radio,
Users,
Disc,
Music,
Heart,
Grid3X3,
Clock,
Settings,
Circle
} from "lucide-react";
import { useNavidrome } from "./NavidromeContext";
import { useRecentlyPlayedAlbums } from "@/hooks/use-recently-played-albums";
import { useSidebarShortcuts } from "@/hooks/use-sidebar-shortcuts";
import { useSidebarLayout, SidebarItem } from "@/hooks/use-sidebar-layout";
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuTrigger,
} from "@/components/ui/context-menu";
// Icon mapping for sidebar items
const iconMap: Record<string, React.ReactNode> = {
search: <Search className="h-4 w-4" />,
home: <Home className="h-4 w-4" />,
queue: <List className="h-4 w-4" />,
radio: <Radio className="h-4 w-4" />,
artists: <Users className="h-4 w-4" />,
albums: <Disc className="h-4 w-4" />,
playlists: <Music className="h-4 w-4" />,
favorites: <Heart className="h-4 w-4" />,
browse: <Grid3X3 className="h-4 w-4" />,
songs: <Circle className="h-4 w-4" />,
history: <Clock className="h-4 w-4" />,
settings: <Settings className="h-4 w-4" />,
};
interface SidebarProps extends React.HTMLAttributes<HTMLDivElement> {
playlists: Playlist[];
collapsed?: boolean;
onToggle?: () => void;
visible?: boolean;
favoriteAlbums?: Array<{id: string, name: string, artist: string, coverArt?: string}>;
onRemoveFavoriteAlbum?: (albumId: string) => void;
}
export function Sidebar({ className, playlists, collapsed = false, onToggle }: SidebarProps) {
export function Sidebar({ className, playlists, visible = true, favoriteAlbums = [], onRemoveFavoriteAlbum }: SidebarProps) {
const pathname = usePathname();
// Define all routes and their active states
const routes = {
isRoot: pathname === "/",
isBrowse: pathname === "/browse",
isSearch: pathname === "/search",
isQueue: pathname === "/queue",
isRadio: pathname === "/radio",
isPlaylists: pathname === "/library/playlists",
isSongs: pathname === "/library/songs",
isArtists: pathname === "/library/artists",
isAlbums: pathname === "/library/albums",
isHistory: pathname === "/history",
isFavorites: pathname === "/favorites",
isSettings: pathname === "/settings",
// Handle dynamic routes
isAlbumPage: pathname.startsWith("/album/"),
isArtistPage: pathname.startsWith("/artist/"),
isPlaylistPage: pathname.startsWith("/playlist/"),
isNewPage: pathname === "/new",
const { api } = useNavidrome();
const { recentAlbums } = useRecentlyPlayedAlbums();
const { shortcutType } = useSidebarShortcuts();
const { settings } = useSidebarLayout();
if (!visible) {
return null;
}
// Check if a route is active
const isRouteActive = (href: string): boolean => {
if (href === '/') return pathname === '/';
return pathname.startsWith(href);
};
// Helper function to determine if any sidebar route is active
// This prevents highlights on pages not defined in sidebar
const isAnySidebarRouteActive = Object.values(routes).some(Boolean);
// Get visible navigation items
const visibleItems = settings.items.filter(item => item.visible);
return (
<div className={cn("pb-23 relative", className)}>
{/* Collapse/Expand Button */}
<Button
variant="ghost"
size="sm"
onClick={onToggle}
className="absolute top-2 right-2 z-10 h-6 w-6 p-0"
>
{collapsed ? <ChevronRight className="h-4 w-4" /> : <ChevronLeft className="h-4 w-4" />}
</Button>
<div className={cn("pb-23 relative w-16", className)}>
<div className="space-y-4 py-4 pt-6">
<div className="px-3 py-2">
<p className={cn("mb-2 px-4 text-lg font-semibold tracking-tight", collapsed && "sr-only")}>
Discover
</p>
<div className="space-y-1">
<Link href="/">
<Button
variant={routes.isRoot ? "secondary" : "ghost"}
className={cn("w-full justify-start mb-2", collapsed && "justify-center px-2")}
title={collapsed ? "Listen Now" : undefined}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={cn("h-4 w-4", !collapsed && "mr-2")}
>
<circle cx="12" cy="12" r="10" />
<polygon points="10 8 16 12 10 16 10 8" />
</svg>
{!collapsed && "Listen Now"}
</Button>
</Link>
<Link href="/browse">
<Button
variant={routes.isBrowse ? "secondary" : "ghost"}
className={cn("w-full justify-start mb-2", collapsed && "justify-center px-2")}
title={collapsed ? "Browse" : undefined}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={cn("h-4 w-4", !collapsed && "mr-2")}
>
<rect width="7" height="7" x="3" y="3" rx="1" />
<rect width="7" height="7" x="14" y="3" rx="1" />
<rect width="7" height="7" x="14" y="14" rx="1" />
<rect width="7" height="7" x="3" y="14" rx="1" />
</svg>
{!collapsed && "Browse"}
</Button>
</Link>
<Link href="/search">
<Button
variant={routes.isSearch ? "secondary" : "ghost"}
className={cn("w-full justify-start mb-2", collapsed && "justify-center px-2")}
title={collapsed ? "Search" : undefined}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={cn("h-4 w-4", !collapsed && "mr-2")}
>
<circle cx="11" cy="11" r="8" />
<path d="m21 21-4.35-4.35" />
</svg>
{!collapsed && "Search"}
</Button>
</Link>
<Link href="/queue">
<Button
variant={routes.isQueue ? "secondary" : "ghost"}
className={cn("w-full justify-start mb-2", collapsed && "justify-center px-2")}
title={collapsed ? "Queue" : undefined}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={cn("h-4 w-4", !collapsed && "mr-2")}
>
<path d="M3 6h18M3 12h18M3 18h18" />
</svg>
{!collapsed && "Queue"}
</Button>
</Link>
<Link href="/radio">
<Button
variant={routes.isRadio ? "secondary" : "ghost"}
className={cn("w-full justify-start mb-2", collapsed && "justify-center px-2")}
title={collapsed ? "Radio" : undefined}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={cn("h-4 w-4", !collapsed && "mr-2")}
>
<path d="M4.9 19.1C1 15.2 1 8.8 4.9 4.9"/>
<path d="M7.8 16.2c-2.3-2.3-2.3-6.1 0-8.5"/>
<circle cx="12" cy="12" r="2"/>
<path d="M16.2 7.8c2.3 2.3 2.3 6.1 0 8.5"/>
<path d="M19.1 4.9C23 8.8 23 15.2 19.1 19.1"/>
</svg>
{!collapsed && "Radio"}
</Button>
</Link>
</div>
</div>
<div>
<div className="px-3 py-0 pt-0">
<p className={cn("mb-2 px-4 text-lg font-semibold tracking-tight", collapsed && "sr-only")}>
Library
</p>
<div className="space-y-1">
<Link href="/library/playlists">
{/* Main Navigation Items */}
{visibleItems.map((item) => (
<Link key={item.id} href={item.href}>
<Button
variant={routes.isPlaylists ? "secondary" : "ghost"}
className={cn("w-full justify-start mb-1", collapsed && "justify-center px-2")}
title={collapsed ? "Playlists" : undefined}
variant={isRouteActive(item.href) ? "secondary" : "ghost"}
className="w-full justify-center px-2"
title={item.label}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={cn("h-4 w-4", !collapsed && "mr-2")}
>
<path d="M21 15V6" />
<path d="M18.5 18a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5Z" />
<path d="M12 12H3" />
<path d="M16 6H3" />
<path d="M12 18H3" />
</svg>
{!collapsed && "Playlists"}
</Button>
</Link>
<Link href="/library/songs">
<Button
variant={routes.isSongs ? "secondary" : "ghost"}
className={cn("w-full justify-start mb-2", collapsed && "justify-center px-2")}
title={collapsed ? "Songs" : undefined}
>
<svg
className={cn("h-4 w-4", !collapsed && "mr-2")}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<circle cx="8" cy="18" r="4" />
<path d="M12 18V2l7 4" />
</svg>
{!collapsed && "Songs"}
</Button>
</Link>
<Link href="/library/artists">
<Button
variant={routes.isArtists ? "secondary" : "ghost"}
className={cn("w-full justify-start mb-2", collapsed && "justify-center px-2")}
title={collapsed ? "Artists" : undefined}
>
<svg
className={cn("h-4 w-4", !collapsed && "mr-2")}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="m12 8-9.04 9.06a2.82 2.82 0 1 0 3.98 3.98L16 12" />
<circle cx="17" cy="7" r="5" />
</svg>
{!collapsed && "Artists"}
</Button>
</Link>
<Link href="/library/albums">
<Button
variant={routes.isAlbums ? "secondary" : "ghost"}
className={cn("w-full justify-start mb-2", collapsed && "justify-center px-2")}
title={collapsed ? "Albums" : undefined}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={cn("h-4 w-4", !collapsed && "mr-2")}
>
<path d="m16 6 4 14" />
<path d="M12 6v14" />
<path d="M8 8v12" />
<path d="M4 4v16" />
</svg>
{!collapsed && "Albums"}
</Button>
</Link>
<Link href="/history">
<Button
variant={routes.isHistory ? "secondary" : "ghost"}
className={cn("w-full justify-start mb-2", collapsed && "justify-center px-2")}
title={collapsed ? "History" : undefined}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={cn("h-4 w-4", !collapsed && "mr-2")}
>
<path d="M12 2C6.48 2 2 6.48 2 12c0 5.52 4.48 10 10 10 5.52 0 10-4.48 10-10 0-5.52-4.48-10-10-10Z" />
<path d="M12 8v4l4 2" />
</svg>
{!collapsed && "History"}
</Button>
</Link>
<Link href="/favorites">
<Button
variant={routes.isFavorites ? "secondary" : "ghost"}
className={cn("w-full justify-start mb-2", collapsed && "justify-center px-2")}
title={collapsed ? "Favorites" : undefined}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={cn("h-4 w-4", !collapsed && "mr-2")}
>
<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z" />
</svg>
{!collapsed && "Favorites"}
</Button>
</Link>
</div>
</div>
</div>
<div className="px-3">
<div className="space-y-0">
<Link href="/settings">
<Button
variant={routes.isSettings ? "secondary" : "ghost"}
className={cn("w-full justify-start mb-2", collapsed && "justify-center px-2")}
title={collapsed ? "Settings" : undefined}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={cn("h-4 w-4", !collapsed && "mr-2")}
>
<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z" />
<circle cx="12" cy="12" r="3" />
</svg>
{!collapsed && "Settings"}
{settings.showIcons && (iconMap[item.icon] || <div className="h-4 w-4" />)}
</Button>
</Link>
</div>
))}
{/* Dynamic Shortcuts Section */}
{(shortcutType === 'albums' || shortcutType === 'both') && favoriteAlbums.length > 0 && (
<>
<div className="border-t my-2"></div>
{favoriteAlbums.slice(0, 5).map((album) => (
<ContextMenu key={album.id}>
<ContextMenuTrigger>
<Link href={`/album/${album.id}`}>
<Button
variant="ghost"
className="w-full justify-center px-2"
title={`${album.name} by ${album.artist}`}
>
{album.coverArt && api ? (
<Image
src={api.getCoverArtUrl(album.coverArt, 150)}
alt={album.name}
width={16}
height={16}
className="rounded"
/>
) : (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="h-4 w-4"
>
<path d="m16 6 4 14" />
<path d="M12 6v14" />
<path d="M8 8v12" />
<path d="M4 4v16" />
</svg>
)}
</Button>
</Link>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onRemoveFavoriteAlbum?.(album.id);
}}
className="text-destructive focus:text-destructive"
>
Remove from favorites
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
))}
</>
)}
{/* Recently Played Albums */}
{(shortcutType === 'albums' || shortcutType === 'both') && recentAlbums.length > 0 && (
<>
<div className="border-t my-2"></div>
{recentAlbums.slice(0, 5).map((album) => (
<Link key={album.id} href={`/album/${album.id}`}>
<Button
variant="ghost"
className="w-full justify-center px-2"
title={`${album.name} by ${album.artist} (Recently Played)`}
>
{album.coverArt && api ? (
<Image
src={api.getCoverArtUrl(album.coverArt, 150)}
alt={album.name}
width={16}
height={16}
className="rounded opacity-70"
/>
) : (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="h-4 w-4 opacity-70"
>
<path d="m16 6 4 14" />
<path d="M12 6v14" />
<path d="M8 8v12" />
<path d="M4 4v16" />
</svg>
)}
</Button>
</Link>
))}
</>
)}
{/* Playlists Section */}
{(shortcutType === 'playlists' || shortcutType === 'both') && playlists.length > 0 && (
<>
<div className="border-t my-2"></div>
{playlists.slice(0, 5).map((playlist) => (
<Link key={playlist.id} href={`/playlist/${playlist.id}`}>
<Button
variant="ghost"
className="w-full justify-center px-2"
title={`${playlist.name} by ${playlist.owner} - ${playlist.songCount} songs`}
>
{playlist.coverArt && api ? (
<Image
src={api.getCoverArtUrl(playlist.coverArt, 32)}
alt={playlist.name}
width={16}
height={16}
className="rounded"
/>
) : (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="h-4 w-4"
>
<path d="M21 15V6" />
<path d="M18.5 18a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5Z" />
<path d="M12 12H3" />
<path d="M16 6H3" />
<path d="M12 18H3" />
</svg>
)}
</Button>
</Link>
))}
</>
)}
</div>
</div>
</div>
</div>
);

View File

@@ -17,7 +17,7 @@ import { Badge } from '@/components/ui/badge';
import { useNavidromeConfig } from '@/app/components/NavidromeConfigContext';
import { useTheme } from '@/app/components/ThemeProvider';
import { useToast } from '@/hooks/use-toast';
import { FaServer, FaUser, FaLock, FaCheck, FaTimes, FaPalette, FaLastfm, FaBars } from 'react-icons/fa';
import { FaServer, FaUser, FaLock, FaCheck, FaTimes, FaPalette, FaLastfm } from 'react-icons/fa';
export function LoginForm({
className,
@@ -45,20 +45,7 @@ export function LoginForm({
return true;
});
// New settings
const [sidebarCollapsed, setSidebarCollapsed] = useState(() => {
if (typeof window !== 'undefined') {
return localStorage.getItem('sidebar-collapsed') === 'true';
}
return false;
});
const [standaloneLastfmEnabled, setStandaloneLastfmEnabled] = useState(() => {
if (typeof window !== 'undefined') {
return localStorage.getItem('standalone-lastfm-enabled') === 'true';
}
return false;
});
// New settings - removed sidebar and standalone lastfm options
// Check if Navidrome is configured via environment variables
const hasEnvConfig = React.useMemo(() => {
@@ -187,8 +174,6 @@ export function LoginForm({
const handleFinishSetup = () => {
// Save all settings
localStorage.setItem('lastfm-scrobbling-enabled', scrobblingEnabled.toString());
localStorage.setItem('sidebar-collapsed', sidebarCollapsed.toString());
localStorage.setItem('standalone-lastfm-enabled', standaloneLastfmEnabled.toString());
// Mark onboarding as complete
localStorage.setItem('onboarding-completed', '1.1.0');
@@ -206,10 +191,53 @@ export function LoginForm({
setScrobblingEnabled(enabled);
};
const handleDemoSetup = async () => {
const demoCredentials = {
serverUrl: 'https://demo.navidrome.org',
username: 'demo',
password: 'demo'
};
// Set form data
setFormData(demoCredentials);
setIsTesting(true);
try {
const success = await testConnection(demoCredentials);
if (success) {
// Save the config
updateConfig(demoCredentials);
toast({
title: "Demo Server Connected",
description: "Successfully connected to the Navidrome demo server! Let's configure your preferences.",
});
// Move to settings step
setStep('settings');
} else {
toast({
title: "Demo Server Unavailable",
description: "The demo server is currently unavailable. Please try again later or enter your own server details.",
variant: "destructive"
});
}
} catch (error) {
toast({
title: "Connection Error",
description: "Could not connect to the demo server. Please check your internet connection.",
variant: "destructive"
});
} finally {
setIsTesting(false);
}
};
if (step === 'settings') {
return (
<div className={cn("flex flex-col gap-6", className)} {...props}>
<Card>
<Card className='py-5'>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<FaPalette className="w-5 h-5" />
@@ -243,29 +271,6 @@ export function LoginForm({
</Select>
</div>
{/* Sidebar Settings */}
<div className="grid gap-3">
<Label className="flex items-center gap-2">
<FaBars className="w-4 h-4" />
Sidebar Layout
</Label>
<Select
value={sidebarCollapsed ? "collapsed" : "expanded"}
onValueChange={(value) => setSidebarCollapsed(value === "collapsed")}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="expanded">Expanded (with labels)</SelectItem>
<SelectItem value="collapsed">Collapsed (icons only)</SelectItem>
</SelectContent>
</Select>
<p className="text-sm text-muted-foreground">
You can always toggle this later using the button in the sidebar
</p>
</div>
{/* Last.fm Scrobbling */}
<div className="grid gap-3">
<Label className="flex items-center gap-2">
@@ -291,31 +296,6 @@ export function LoginForm({
</p>
</div>
{/* Standalone Last.fm */}
<div className="grid gap-3">
<Label className="flex items-center gap-2">
<FaLastfm className="w-4 h-4" />
Standalone Last.fm (Advanced)
</Label>
<Select
value={standaloneLastfmEnabled ? "enabled" : "disabled"}
onValueChange={(value) => setStandaloneLastfmEnabled(value === "enabled")}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="enabled">Enabled</SelectItem>
<SelectItem value="disabled">Disabled</SelectItem>
</SelectContent>
</Select>
<p className="text-sm text-muted-foreground">
{standaloneLastfmEnabled
? "Direct Last.fm API integration (configure in Settings later)"
: "Use only Navidrome's Last.fm integration"}
</p>
</div>
<div className="flex flex-col gap-3">
<Button onClick={handleFinishSetup} className="w-full">
<FaCheck className="w-4 h-4 mr-2" />
@@ -340,7 +320,7 @@ export function LoginForm({
return (
<div className={cn("flex flex-col gap-6", className)} {...props}>
<Card>
<Card className="py-5">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<FaServer className="w-5 h-5" />
@@ -399,6 +379,56 @@ export function LoginForm({
required
/>
</div>
{/* Demo Server Setup */}
<div className="bg-blue-50 dark:bg-blue-950/30 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
<div className="flex items-start gap-3">
<div className="text-blue-600 dark:text-blue-400 mt-0.5">
💡
</div>
<div className="flex-1 text-sm">
<p className="font-medium text-blue-900 dark:text-blue-100 mb-1">
Don&apos;t have a Navidrome server?
</p>
<p className="text-blue-700 dark:text-blue-200 mb-3">
Try the demo server to explore mice with one click:
</p>
<Button
type="button"
variant="secondary"
size="sm"
className="w-full bg-blue-100 hover:bg-blue-200 text-blue-900 dark:bg-blue-900/50 dark:hover:bg-blue-800/50 dark:text-blue-100"
onClick={handleDemoSetup}
disabled={isTesting}
>
{isTesting ? (
<>
<div className="w-4 h-4 mr-2 animate-spin rounded-full border-2 border-transparent border-t-current" />
Connecting to Demo...
</>
) : (
<>
<FaServer className="w-4 h-4 mr-2" />
Connect to Demo Server
</>
)}
</Button>
<div className="mt-2 text-xs text-blue-600 dark:text-blue-300">
This will automatically connect to: demo.navidrome.org
</div>
<details className="mt-3">
<summary className="text-xs text-blue-600 dark:text-blue-300 cursor-pointer hover:text-blue-800 dark:hover:text-blue-100">
Or enter demo credentials manually
</summary>
<div className="mt-2 bg-blue-100 dark:bg-blue-900/50 rounded p-2 font-mono text-xs">
<div><strong>URL:</strong> https://demo.navidrome.org</div>
<div><strong>Username:</strong> demo</div>
<div><strong>Password:</strong> demo</div>
</div>
</details>
</div>
</div>
</div>
<div className="flex flex-col gap-3">
<Button type="submit" className="w-full" disabled={isTesting}>

View File

@@ -3,7 +3,6 @@
import React, { useState, useEffect } from 'react';
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { useNavidrome } from "@/app/components/NavidromeContext";
import { AlbumArtwork } from "@/app/components/album-artwork";
import { ArtistIcon } from "@/app/components/artist-icon";
@@ -59,7 +58,7 @@ const FavoritesPage = () => {
artistId: song.artistId,
url: api?.getStreamUrl(song.id) || '',
duration: song.duration,
coverArt: song.coverArt ? api?.getCoverArtUrl(song.coverArt) : undefined,
coverArt: song.coverArt ? api?.getCoverArtUrl(song.coverArt, 1200) : undefined,
starred: !!song.starred
});
};
@@ -79,7 +78,7 @@ const FavoritesPage = () => {
artistId: song.artistId,
url: api.getStreamUrl(song.id),
duration: song.duration,
coverArt: song.coverArt ? api.getCoverArtUrl(song.coverArt) : undefined,
coverArt: song.coverArt ? api.getCoverArtUrl(song.coverArt, 1200) : undefined,
starred: !!song.starred
}));
@@ -119,24 +118,25 @@ const FavoritesPage = () => {
if (!isConnected) {
return (
<div className="container mx-auto p-6">
<div className="text-center">
<p className="text-muted-foreground">Please connect to your Navidrome server to view favorites.</p>
<div className="container mx-auto p-6 pb-24 max-w-none">
<div className="space-y-6">
<div className="text-left">
<h1 className="text-3xl font-semibold tracking-tight">Favorites</h1>
<p className="text-muted-foreground">Please connect to your Navidrome server to view favorites.</p>
</div>
</div>
</div>
);
}
return (
<div className="container mx-auto p-6 pb-24">
<div className="container mx-auto p-6 pb-24 max-w-none">
<div className="space-y-6">
<div className="flex items-center gap-3">
<div>
<h1 className="text-3xl font-semibold tracking-tight">Favorites</h1>
<p className="text-muted-foreground">Your starred albums, songs, and artists</p>
</div>
<div className="text-left">
<h1 className="text-3xl font-semibold tracking-tight">Favorites</h1>
<p className="text-muted-foreground">Your starred albums, songs, and artists</p>
</div>
<div className="space-y-6">
<Tabs defaultValue="albums" className="space-y-6">
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="albums" className="flex items-center gap-2">
@@ -167,33 +167,14 @@ const FavoritesPage = () => {
) : (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6">
{favoriteAlbums.map((album) => (
<Card key={album.id} className="overflow-hidden">
<div className="aspect-square relative group">
{album.coverArt && api ? (
<Image
src={api.getCoverArtUrl(album.coverArt)}
alt={album.name}
fill
className="w-full h-full object-cover rounded"
sizes="(max-width: 768px) 100vw, 300px"
/>
) : (
<div className="w-full h-full bg-muted rounded flex items-center justify-center">
<Disc className="w-12 h-12 text-muted-foreground" />
</div>
)}
<div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-2">
<Play className="w-12 h-12 mx-auto hidden group-hover:block" onClick={() => handlePlayAlbum(album)}/>
</div>
</div>
<CardContent className="p-4">
<h3 className="font-semibold truncate">{album.name}</h3>
<p className="text-sm text-muted-foreground truncate">{album.artist}</p>
<p className="text-xs text-muted-foreground mt-1">
{album.songCount} songs {Math.floor(album.duration / 60)} min
</p>
</CardContent>
</Card>
<AlbumArtwork
key={album.id}
album={album}
className="w-full"
aspectRatio="square"
width={200}
height={200}
/>
))}
</div>
)}
@@ -217,10 +198,10 @@ const FavoritesPage = () => {
<div className="w-8 text-sm text-muted-foreground text-center">
{index + 1}
</div>
<div className="w-12 h-12 relative flex-shrink-0">
<div className="w-12 h-12 relative shrink-0">
{song.coverArt && api ? (
<Image
src={api.getCoverArtUrl(song.coverArt)}
src={api.getCoverArtUrl(song.coverArt, 1200)}
alt={song.album}
fill
className="rounded object-cover"
@@ -271,28 +252,13 @@ const FavoritesPage = () => {
) : (
<div className="grid gap-4 md:grid-cols-3 lg:grid-cols-5 xl:grid-cols-7">
{favoriteArtists.map((artist) => (
<Card key={artist.id} className="overflow-hidden">
<CardContent className="p-3 text-center">
<div className="w-24 h-24 mx-auto mb-4">
<Image
src={artist.coverArt && api ? api.getCoverArtUrl(artist.coverArt, 200) : '/placeholder-artist.png'}
alt={artist.name}
width={250}
height={250}
className="object-cover w-full h-full"
/>
</div>
<h3 className="font-semibold truncate">{artist.name}</h3>
<p className="text-sm text-muted-foreground">
{artist.albumCount} albums
</p>
</CardContent>
</Card>
<ArtistIcon key={artist.id} artist={artist} responsive />
))}
</div>
)}
</TabsContent>
</Tabs>
</div>
</div>
</div>
);

Binary file not shown.

Binary file not shown.

Binary file not shown.

File diff suppressed because it is too large Load Diff

View File

@@ -10,6 +10,7 @@ import { Tabs, TabsContent } from '@/components/ui/tabs';
import { useAudioPlayer } from '@/app/components/AudioPlayerContext';
import { getNavidromeAPI } from '@/lib/navidrome';
import { Play, Plus, User, Disc, History, Trash2 } from 'lucide-react';
import ListeningStreakCard from '@/app/components/ListeningStreakCard';
import {
AlertDialog,
AlertDialogAction,
@@ -78,8 +79,12 @@ export default function HistoryPage() {
return (
<div className="h-full px-4 py-6 lg:px-8">
<div className="mb-6">
<ListeningStreakCard />
</div>
<Tabs defaultValue="music" className="h-full space-y-6">
<TabsContent value="music" className="border-none p-0 outline-none">
<TabsContent value="music" className="border-none p-0 outline-hidden">
<div className="flex items-center justify-between">
<div className="space-y-1">
<div className="flex items-center gap-2">
@@ -155,7 +160,7 @@ export default function HistoryPage() {
</div>
{/* Album Art */}
<div className="w-12 h-12 mr-4 flex-shrink-0">
<div className="w-12 h-12 mr-4 shrink-0">
<Image
src={track.coverArt || '/default-user.jpg'}
alt={track.album}

View File

@@ -26,6 +26,36 @@ export const metadata = {
'max-snippet': -1,
},
},
appleWebApp: {
capable: true,
statusBarStyle: 'black-translucent',
title: isDev && shortCommit ? `mice (dev: ${shortCommit})` : 'mice',
},
formatDetection: {
telephone: false,
},
other: {
'apple-mobile-web-app-capable': 'yes',
'apple-mobile-web-app-status-bar-style': 'black-translucent',
'format-detection': 'telephone=no',
},
icons: {
icon: [
{ url: '/favicon.ico', sizes: '48x48' },
{ url: '/icon-192.png', sizes: '192x192', type: 'image/png' },
{ url: '/icon-512.png', sizes: '512x512', type: 'image/png' },
],
apple: [
{ url: '/apple-touch-icon-precomposed.png', sizes: '180x180', type: 'image/png' },
],
},
};
export const viewport = {
width: 'device-width',
initialScale: 1,
maximumScale: 1,
userScalable: false,
};
const geistSans = localFont({
@@ -45,8 +75,9 @@ interface LayoutProps {
export default function Layout({ children }: LayoutProps) {
return (
<html lang="en">
<html lang="en" suppressHydrationWarning>
<head>
<link rel="manifest" href="/manifest.json" />
<script
dangerouslySetInnerHTML={{
__html: `

View File

@@ -109,7 +109,7 @@ export default function AlbumsPage() {
return (
<div className="h-full px-4 py-6 lg:px-8">
<Tabs defaultValue="music" className="h-full flex flex-col space-y-6">
<TabsContent value="music" className="border-none p-0 outline-none flex flex-col flex-grow">
<TabsContent value="music" className="border-none p-0 outline-hidden flex flex-col grow">
<div className="flex items-center justify-between mb-4">
<div className="space-y-1">
<p className="text-2xl font-semibold tracking-tight">
@@ -150,7 +150,7 @@ export default function AlbumsPage() {
<Separator className="my-4" />
<div className="relative flex-grow">
<div className="relative grow">
<ScrollArea className="h-full">
<div className="h-full overflow-y-auto">
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 2xl:grid-cols-7 gap-4 p-4 pb-8">

View File

@@ -7,13 +7,11 @@ import { Separator } from "@/components/ui/separator";
import { Tabs, TabsContent } from "@/components/ui/tabs";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { ArtistIcon } from '@/app/components/artist-icon';
import { useNavidrome } from '@/app/components/NavidromeContext';
import { Artist } from '@/lib/navidrome';
import Loading from '@/app/components/loading';
import { Search, Heart } from 'lucide-react';
import { Search } from 'lucide-react';
import { useRouter } from 'next/navigation';
import Image from 'next/image';
@@ -67,7 +65,7 @@ export default function ArtistPage() {
return (
<div className="h-full px-4 py-6 lg:px-8 mb-24">
<Tabs defaultValue="music" className="h-full space-y-6">
<TabsContent value="music" className="border-none p-0 outline-none">
<TabsContent value="music" className="border-none p-0 outline-hidden">
<div className="flex items-center justify-between">
<div className="space-y-1">
<p className="text-2xl font-semibold tracking-tight">
@@ -106,27 +104,7 @@ export default function ArtistPage() {
<ScrollArea>
<div className="grid gap-4 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-6 cursor-pointer">
{filteredArtists.map((artist) => (
<Card key={artist.id} className="overflow-hidden">
<div className="aspect-square relative group cursor-pointer" onClick={() => handleViewArtist(artist)}>
<div className="w-full h-full">
<Image
src={artist.coverArt && api ? api.getCoverArtUrl(artist.coverArt, 200) : '/placeholder-artist.png'}
alt={artist.name}
width={290}
height={290}
className="object-cover w-full h-full"
/>
</div>
<div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-2">
</div>
</div>
<CardContent className="p-4">
<h3 className="font-semibold truncate">{artist.name}</h3>
<p className="text-sm text-muted-foreground">
{artist.albumCount} albums
</p>
</CardContent>
</Card>
<ArtistIcon key={artist.id} artist={artist} responsive />
))}
</div>
<ScrollBar orientation="horizontal" />

244
app/library/page.tsx Normal file
View File

@@ -0,0 +1,244 @@
'use client';
import React, { useEffect, useState } from 'react';
import Link from 'next/link';
import Image from 'next/image';
import { Music, Users, Disc, ListMusic, Heart, Play } from 'lucide-react';
import { Card, CardContent } from '@/components/ui/card';
import { getNavidromeAPI } from '@/lib/navidrome';
import NavidromeAPI from '@/lib/navidrome';
import { useAudioPlayer } from '@/app/components/AudioPlayerContext';
import { useIsMobile } from '@/hooks/use-mobile';
interface Album {
id: string;
name: string;
artist: string;
artistId?: string;
coverArt?: string;
year?: number;
songCount: number;
}
interface LibraryStats {
albums: number;
artists: number;
songs: number;
playlists: number;
}
export default function LibraryPage() {
const [recentAlbums, setRecentAlbums] = useState<Album[]>([]);
const [stats, setStats] = useState<LibraryStats>({ albums: 0, artists: 0, songs: 0, playlists: 0 });
const [loading, setLoading] = useState(true);
const [api, setApi] = useState<NavidromeAPI | null>(null);
const { playAlbum } = useAudioPlayer();
const isMobile = useIsMobile();
useEffect(() => {
const loadLibraryData = async () => {
try {
const navidromeApi = getNavidromeAPI();
if (!navidromeApi) {
console.error('Navidrome API not available');
return;
}
setApi(navidromeApi);
// Load recent albums
const albumsData = await navidromeApi.getAlbums('newest', 4, 0);
setRecentAlbums(albumsData || []);
// Load library stats
const [allAlbums, allArtists, allPlaylists] = await Promise.all([
navidromeApi.getAlbums('alphabeticalByName', 1, 0), // Just to get count
navidromeApi.getArtists(),
navidromeApi.getPlaylists()
]);
setStats({
albums: allAlbums?.length || 0,
artists: allArtists?.length || 0,
songs: 0, // We don't have a direct method for this
playlists: allPlaylists?.length || 0
});
} catch (error) {
console.error('Failed to load library data:', error);
} finally {
setLoading(false);
}
};
loadLibraryData();
}, []);
const handlePlayAlbum = async (album: Album) => {
try {
await playAlbum(album.id);
} catch (error) {
console.error('Failed to play album:', error);
}
};
const libraryLinks = [
{
href: '/library/albums',
label: 'Albums',
icon: Disc,
description: 'Browse all albums',
count: stats.albums
},
{
href: '/library/artists',
label: 'Artists',
icon: Users,
description: 'Discover artists',
count: stats.artists
},
{
href: '/library/songs',
label: 'Songs',
icon: Music,
description: 'All your music',
count: stats.songs
},
{
href: '/library/playlists',
label: 'Playlists',
icon: ListMusic,
description: 'Your playlists',
count: stats.playlists
},
{
href: '/favorites',
label: 'Favorites',
icon: Heart,
description: 'Starred music',
count: 0
}
];
if (loading) {
return (
<div className="p-4 pb-20 space-y-6">
<div className="space-y-4">
<h1 className="text-2xl font-bold">Your Library</h1>
{/* Loading skeleton for library links */}
<div>
<h2 className="text-lg font-semibold mb-3">Browse</h2>
<div className="space-y-3">
{[...Array(5)].map((_, i) => (
<div key={i} className="animate-pulse">
<div className="bg-muted rounded-lg h-16"></div>
</div>
))}
</div>
</div>
{/* Loading skeleton for recent albums */}
<div>
<h2 className="text-lg font-semibold mb-3">Recently Added</h2>
<div className="grid grid-cols-2 gap-4">
{[...Array(4)].map((_, i) => (
<div key={i} className="animate-pulse">
<div className="bg-muted rounded-lg aspect-square mb-2"></div>
<div className="bg-muted h-4 rounded mb-1"></div>
<div className="bg-muted h-3 rounded w-3/4"></div>
</div>
))}
</div>
</div>
</div>
</div>
);
}
return (
<div className="p-4 pb-20 space-y-6">
<div className="space-y-4">
<h1 className="text-2xl font-bold">Your Library</h1>
{/* Library Navigation - Always at top */}
<div>
{/* <h2 className="text-lg font-semibold mb-3">Browse</h2> */}
<div className="flex flex-col gap-2">
{libraryLinks.map((link) => {
const Icon = link.icon;
return (
<Link key={link.href} href={link.href}>
<Card className="hover:bg-muted/50 transition-colors cursor-pointer">
<CardContent className="p-2">
<div className="flex items-center space-x-4">
<div className="p-2 bg-primary/10 rounded-lg">
<Icon className="w-6 h-6 text-primary" />
</div>
<div className="flex-1">
<h3 className="font-medium">{link.label}</h3>
<p className="text-sm text-muted-foreground">{link.description}</p>
</div>
{link.count > 0 && (
<div className="text-sm text-muted-foreground">
{link.count}
</div>
)}
</div>
</CardContent>
</Card>
</Link>
);
})}
</div>
</div>
{/* Recently Added Albums - At bottom on mobile, after Browse on desktop */}
<div>
<h2 className="text-lg font-semibold mb-3">Recently Added</h2>
<div className="grid grid-cols-2 gap-4">
{recentAlbums.map((album) => (
<Card key={album.id} className="group cursor-pointer hover:bg-muted/50 transition-colors">
<CardContent className="p-3">
<Link href={`/album/${album.id}`}>
<div className="relative aspect-square mb-2">
<Image
src={album.coverArt && api ? api.getCoverArtUrl(album.coverArt, 300) : '/default-user.jpg'}
alt={album.name}
width={600}
height={600}
className="object-cover rounded-lg"
sizes="(max-width: 768px) 50vw, 200px"
/>
{!isMobile && (
<button
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handlePlayAlbum(album);
}}
className="absolute inset-0 bg-black/20 opacity-0 group-hover:opacity-100 transition-opacity rounded-lg flex items-center justify-center"
>
<Play className="w-8 h-8 text-white fill-white" />
</button>
)}
</div>
<h3 className="font-medium text-sm truncate hover:underline">{album.name}</h3>
<Link
href={`/artist/${album.artistId || album.artist}`}
className="text-xs text-muted-foreground truncate hover:underline block"
onClick={(e) => e.stopPropagation()}
>
{album.artist}
</Link>
{/* {album.year && (
<p className="text-xs text-muted-foreground">{album.year}</p>
)} */}
</Link>
</CardContent>
</Card>
))}
</div>
</div>
</div>
</div>
);
}

View File

@@ -31,9 +31,9 @@ const PlaylistsPage: React.FC = () => {
}
return (
<div className="h-full px-4 py-6 lg:px-8">
<div className="p-6 pb-24 w-full">
<Tabs defaultValue="music" className="h-full space-y-6">
<TabsContent value="music" className="border-none p-0 outline-none">
<TabsContent value="music" className="border-none p-0 outline-hidden">
<div className="flex items-center justify-between">
<div className="space-y-1">
<p className="text-2xl font-semibold tracking-tight">
@@ -53,14 +53,14 @@ const PlaylistsPage: React.FC = () => {
<ScrollArea>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 pb-4"> {playlists.map((playlist) => {
const playlistCoverUrl = playlist.coverArt && api
? api.getCoverArtUrl(playlist.coverArt, 200)
? api.getCoverArtUrl(playlist.coverArt, 600)
: '/default-user.jpg';
return (
<Link key={playlist.id} href={`/playlist/${playlist.id}`}>
<div className="p-4 rounded-lg border border-border hover:bg-accent hover:text-accent-foreground transition-colors cursor-pointer h-32">
<div className="flex items-center space-x-4 h-full">
<div className="w-12 h-12 bg-muted rounded-md overflow-hidden flex-shrink-0">
<div className="w-12 h-12 bg-muted rounded-md overflow-hidden shrink-0">
<Image
src={playlistCoverUrl}
alt={playlist.name}

View File

@@ -101,7 +101,7 @@ export default function SongsPage() {
setFilteredSongs(filtered);
}, [songs, searchQuery, sortBy, sortDirection]);
const handlePlaySong = (song: Song) => {
const handlePlayClick = (song: Song) => {
if (!api) {
console.error('Navidrome API not available');
return;
@@ -114,7 +114,7 @@ export default function SongsPage() {
artist: song.artist,
album: song.album,
duration: song.duration,
coverArt: song.coverArt ? api.getCoverArtUrl(song.coverArt, 300) : undefined,
coverArt: song.coverArt ? api.getCoverArtUrl(song.coverArt, 300) : undefined,
albumId: song.albumId,
artistId: song.artistId,
starred: !!song.starred
@@ -135,7 +135,7 @@ export default function SongsPage() {
artist: song.artist,
album: song.album,
duration: song.duration,
coverArt: song.coverArt ? api.getCoverArtUrl(song.coverArt, 300) : undefined,
coverArt: song.coverArt ? api.getCoverArtUrl(song.coverArt, 300) : undefined,
albumId: song.albumId,
artistId: song.artistId,
starred: !!song.starred
@@ -222,7 +222,7 @@ export default function SongsPage() {
className={`group flex items-center p-3 rounded-lg hover:bg-accent/50 cursor-pointer transition-colors ${
isCurrentlyPlaying(song) ? 'bg-accent/50 border-l-4 border-primary' : ''
}`}
onClick={() => handlePlaySong(song)}
onClick={() => handlePlayClick(song)}
>
{/* Track Number / Play Indicator */}
<div className="w-8 text-center text-sm text-muted-foreground mr-3">
@@ -239,8 +239,8 @@ export default function SongsPage() {
</div>
{/* Album Art */}
<div className="w-12 h-12 mr-4 flex-shrink-0"> <Image
src={song.coverArt && api ? api.getCoverArtUrl(song.coverArt, 100) : '/default-user.jpg'}
<div className="w-12 h-12 mr-4 shrink-0"> <Image
src={song.coverArt && api ? api.getCoverArtUrl(song.coverArt, 48) : '/default-user.jpg'}
alt={song.album}
width={48}
height={48}

View File

@@ -1,44 +0,0 @@
import type { MetadataRoute } from 'next'
export default function manifest(): MetadataRoute.Manifest {
return {
name: 'mice',
short_name: 'Offbrand',
description: 'a very mice clone',
start_url: '/',
categories: ["music", "entertainment"],
display_override: ['window-controls-overlay'],
display: 'standalone',
background_color: '#0f0f0f',
theme_color: '#0f0f0f',
icons: [
{
src: '/favicon.ico',
type: 'image/x-icon',
sizes: '16x16 32x32'
},
{
src: '/icon-192.png',
type: 'image/png',
sizes: '192x192'
},
{
src: '/icon-512.png',
type: 'image/png',
sizes: '512x512'
},
{
src: '/icon-192-maskable.png',
type: 'image/png',
sizes: '192x192',
purpose: 'maskable'
},
{
src: './icon-512-maskable.png',
type: 'image/png',
sizes: '512x512',
purpose: 'maskable'
}
],
}
}

View File

@@ -4,59 +4,191 @@ import { ScrollArea, ScrollBar } from '../components/ui/scroll-area';
import { Separator } from '../components/ui/separator';
import { Tabs, TabsContent } from '../components/ui/tabs';
import { AlbumArtwork } from './components/album-artwork';
import { useNavidrome } from './components/NavidromeContext';
import { useEffect, useState } from 'react';
import { Album } from '@/lib/navidrome';
import { useOfflineNavidrome } from './components/OfflineNavidromeProvider';
import { useEffect, useState, Suspense } from 'react';
import { Album, Song, getNavidromeAPI } from '@/lib/navidrome';
import { useNavidromeConfig } from './components/NavidromeConfigContext';
import { useSearchParams } from 'next/navigation';
import { useAudioPlayer } from './components/AudioPlayerContext';
import { SongRecommendations } from './components/SongRecommendations';
import { Skeleton } from '@/components/ui/skeleton';
import { useIsMobile } from '@/hooks/use-mobile';
import { UserProfile } from './components/UserProfile';
import { OfflineStatusIndicator } from './components/OfflineStatusIndicator';
import CompactListeningStreak from './components/CompactListeningStreak';
type TimeOfDay = 'morning' | 'afternoon' | 'evening';
export default function MusicPage() {
const { albums, isLoading, api, isConnected } = useNavidrome();
function MusicPageContent() {
// Offline-first provider (falls back to offline data when not connected)
const offline = useOfflineNavidrome();
const { playAlbum, playTrack, shuffle, toggleShuffle, addToQueue } = useAudioPlayer();
const searchParams = useSearchParams();
const [allAlbums, setAllAlbums] = useState<Album[]>([]);
const [recentAlbums, setRecentAlbums] = useState<Album[]>([]);
const [newestAlbums, setNewestAlbums] = useState<Album[]>([]);
const [favoriteAlbums, setFavoriteAlbums] = useState<Album[]>([]);
const [albumsLoading, setAlbumsLoading] = useState(true);
const [favoritesLoading, setFavoritesLoading] = useState(true);
const [shortcutProcessed, setShortcutProcessed] = useState(false);
const isMobile = useIsMobile();
// Load albums (offline-first)
useEffect(() => {
let mounted = true;
const load = async () => {
setAlbumsLoading(true);
try {
const list = await offline.getAlbums(false);
if (!mounted) return;
setAllAlbums(list || []);
// Split albums into two sections
const recent = list.slice(0, Math.ceil(list.length / 2));
const newest = list.slice(Math.ceil(list.length / 2));
setRecentAlbums(recent);
setNewestAlbums(newest);
} catch (e) {
console.error('Failed to load albums (offline-first):', e);
if (mounted) {
setAllAlbums([]);
setRecentAlbums([]);
setNewestAlbums([]);
}
} finally {
if (mounted) setAlbumsLoading(false);
}
};
load();
return () => { mounted = false; };
}, [offline]);
useEffect(() => {
if (albums.length > 0) {
// Split albums into recent and newest for display
const recent = albums.slice(0, Math.ceil(albums.length / 2));
const newest = albums.slice(Math.ceil(albums.length / 2));
setRecentAlbums(recent);
setNewestAlbums(newest);
}
}, [albums]);
useEffect(() => {
let mounted = true;
const loadFavoriteAlbums = async () => {
if (!api || !isConnected) return;
setFavoritesLoading(true);
try {
const starredAlbums = await api.getAlbums('starred', 20); // Limit to 20 for homepage
setFavoriteAlbums(starredAlbums);
const starred = await offline.getAlbums(true);
if (mounted) setFavoriteAlbums((starred || []).slice(0, 20));
} catch (error) {
console.error('Failed to load favorite albums:', error);
console.error('Failed to load favorite albums (offline-first):', error);
if (mounted) setFavoriteAlbums([]);
} finally {
setFavoritesLoading(false);
if (mounted) setFavoritesLoading(false);
}
};
loadFavoriteAlbums();
return () => { mounted = false; };
}, [offline]);
// Handle PWA shortcuts
useEffect(() => {
const action = searchParams.get('action');
if (!action || shortcutProcessed) return;
const handleShortcuts = async () => {
try {
switch (action) {
case 'resume':
// Try to resume from localStorage or play a recent track
const lastTrack = localStorage.getItem('lastPlayedTrack');
if (lastTrack) {
const trackData = JSON.parse(lastTrack);
await playTrack(trackData);
} else if (recentAlbums.length > 0) {
// Fallback: play first track from most recent album
await playAlbum(recentAlbums[0].id);
}
break;
case 'recent':
if (recentAlbums.length > 0) {
// Get the 10 most recent albums and shuffle them
const tenRecentAlbums = recentAlbums.slice(0, 10);
const shuffledAlbums = [...tenRecentAlbums].sort(() => Math.random() - 0.5);
// Enable shuffle if not already on
if (!shuffle) {
toggleShuffle();
}
// Play first album and add remaining albums to queue
await playAlbum(shuffledAlbums[0].id);
// Add remaining albums to queue
for (let i = 1; i < shuffledAlbums.length; i++) {
try {
const songs = await offline.getSongs(shuffledAlbums[i].id);
const api = getNavidromeAPI();
songs.forEach((song: Song) => {
addToQueue({
id: song.id,
name: song.title,
url: api ? api.getStreamUrl(song.id) : `offline-song-${song.id}`,
artist: song.artist || 'Unknown Artist',
artistId: song.artistId || '',
album: song.album || 'Unknown Album',
albumId: song.parent,
duration: song.duration || 0,
coverArt: song.coverArt,
starred: !!song.starred
});
});
} catch (error) {
console.error('Failed to load album tracks (offline-first):', error);
}
}
}
break;
case 'shuffle-favorites':
if (favoriteAlbums.length > 0) {
// Shuffle all favorite albums
const shuffledFavorites = [...favoriteAlbums].sort(() => Math.random() - 0.5);
// Enable shuffle if not already on
if (!shuffle) {
toggleShuffle();
}
// Play first album and add remaining albums to queue
await playAlbum(shuffledFavorites[0].id);
// Add remaining albums to queue
for (let i = 1; i < shuffledFavorites.length; i++) {
try {
const songs = await offline.getSongs(shuffledFavorites[i].id);
const api = getNavidromeAPI();
songs.forEach((song: Song) => {
addToQueue({
id: song.id,
name: song.title,
url: api ? api.getStreamUrl(song.id) : `offline-song-${song.id}`,
artist: song.artist || 'Unknown Artist',
artistId: song.artistId || '',
album: song.album || 'Unknown Album',
albumId: song.parent,
duration: song.duration || 0,
coverArt: song.coverArt,
starred: !!song.starred
});
});
} catch (error) {
console.error('Failed to load album tracks (offline-first):', error);
}
}
}
break;
}
setShortcutProcessed(true);
} catch (error) {
console.error('Failed to handle PWA shortcut:', error);
}
};
loadFavoriteAlbums();
}, [api, isConnected]);
// Get greeting and time of day
const hour = new Date().getHours();
const greeting = hour < 12 ? 'Good morning' : 'Good afternoon';
let timeOfDay: TimeOfDay;
if (hour >= 5 && hour < 12) {
timeOfDay = 'morning';
} else if (hour >= 12 && hour < 18) {
timeOfDay = 'afternoon';
} else {
timeOfDay = 'evening';
}
// Delay to ensure data is loaded
const timeout = setTimeout(handleShortcuts, 1000);
return () => clearTimeout(timeout);
}, [searchParams, recentAlbums, favoriteAlbums, shortcutProcessed, playAlbum, playTrack, shuffle, toggleShuffle, addToQueue, offline]);
// Try to get user name from navidrome context, fallback to 'user'
let userName = '';
@@ -68,29 +200,34 @@ export default function MusicPage() {
return (
<div className="h-full px-4 py-6 lg:px-8 pb-24">
<div className="relative rounded-lg p-8">
<div className="relative rounded-sm p-10">
<div
className="absolute inset-0 bg-center bg-cover bg-no-repeat blur-xl bg-gradient-to-r from-primary to-secondary"
style={{
backgroundImage:
timeOfDay === 'morning'
? 'linear-gradient(to right, #ff9a9e, #fad0c4, #fad0c4)' // Warm tones for morning
: timeOfDay === 'evening'
? 'linear-gradient(to right, #a18cd1, #fbc2eb)' // Cool tones for evening
: 'linear-gradient(to right, #a8edea, #fed6e3)', // Default/afternoon colors
}} />
<div className="relative z-10 flex items-center space-x-6">
<div className="flex-1">
<h1 className="text-3xl font-bold mb-4">{greeting}{userName ? `, ${userName}` : ''}!</h1>
</div>
</div>
<div className="p-6 pb-24 w-full">
{/* Connection status (offline indicator) */}
{!offline.isOfflineMode ? null : (
<div className="mb-4">
<OfflineStatusIndicator />
</div>
</div>
)}
{/* Offline empty state when nothing is cached */}
{offline.isOfflineMode && !albumsLoading && recentAlbums.length === 0 && newestAlbums.length === 0 && favoriteAlbums.length === 0 && (
<div className="mb-6 p-4 border rounded-lg bg-muted/30">
<p className="text-sm text-muted-foreground">
You are offline and no albums are cached yet. Download albums for offline use from an album page, or open Settings Offline Library to sync your library.
</p>
</div>
)}
{/* Song Recommendations Section */}
<div className="mb-8">
<SongRecommendations userName={userName} />
</div>
{/* Listening Streak Section - Only shown when 3+ days streak */}
<div className="mb-6">
<CompactListeningStreak />
</div>
<>
<Tabs defaultValue="music" className="h-full space-y-6">
<TabsContent value="music" className="border-none p-0 outline-none">
<TabsContent value="music" className="border-none p-0 outline-hidden">
<div className="flex items-center justify-between">
<div className="space-y-1">
<p className="text-2xl font-semibold tracking-tight">
@@ -105,17 +242,24 @@ export default function MusicPage() {
<div className="relative">
<ScrollArea>
<div className="flex space-x-4 pb-4">
{isLoading ? (
{albumsLoading ? (
// Loading skeletons
Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="w-[220px] h-[320px] bg-muted animate-pulse rounded-md flex-shrink-0" />
Array.from({ length: 10 }).map((_, i) => (
<div key={i} className="w-[220px] shrink-0 space-y-3">
<Skeleton className="aspect-square w-full" />
<div className="space-y-2 p-1">
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-3 w-1/2" />
<Skeleton className="h-3 w-2/3" />
</div>
</div>
))
) : (
recentAlbums.map((album) => (
<AlbumArtwork
key={album.id}
album={album}
className="w-[220px] flex-shrink-0"
className="w-[220px] shrink-0"
aspectRatio="square"
width={220}
height={220}
@@ -144,15 +288,22 @@ export default function MusicPage() {
<div className="flex space-x-4 pb-4">
{favoritesLoading ? (
// Loading skeletons
Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="w-[220px] h-[320px] bg-muted animate-pulse rounded-md flex-shrink-0" />
Array.from({ length: 10 }).map((_, i) => (
<div key={i} className="w-[220px] shrink-0 space-y-3">
<Skeleton className="aspect-square w-full" />
<div className="space-y-2 p-1">
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-3 w-1/2" />
<Skeleton className="h-3 w-2/3" />
</div>
</div>
))
) : (
favoriteAlbums.map((album) => (
<AlbumArtwork
key={album.id}
album={album}
className="w-[220px] flex-shrink-0"
className="w-[220px] shrink-0"
aspectRatio="square"
width={220}
height={220}
@@ -178,17 +329,24 @@ export default function MusicPage() {
<div className="relative">
<ScrollArea>
<div className="flex space-x-4 pb-4">
{isLoading ? (
{albumsLoading ? (
// Loading skeletons
Array.from({ length: 10 }).map((_, i) => (
<div key={i} className="w-[220px] h-[320px] bg-muted animate-pulse rounded-md flex-shrink-0" />
<div key={i} className="w-[220px] shrink-0 space-y-3">
<Skeleton className="aspect-square w-full" />
<div className="space-y-2 p-1">
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-3 w-1/2" />
<Skeleton className="h-3 w-2/3" />
</div>
</div>
))
) : (
newestAlbums.map((album) => (
<AlbumArtwork
key={album.id}
album={album}
className="w-[220px] flex-shrink-0"
className="w-[220px] shrink-0"
aspectRatio="square"
width={220}
height={220}
@@ -204,4 +362,12 @@ export default function MusicPage() {
</>
</div>
);
}
export default function MusicPage() {
return (
<Suspense fallback={<div className="p-6">Loading...</div>}>
<MusicPageContent />
</Suspense>
);
}

View File

@@ -57,7 +57,7 @@ export default function PlaylistPage() {
artist: song.artist,
album: song.album,
duration: song.duration,
coverArt: song.coverArt ? api.getCoverArtUrl(song.coverArt, 300) : undefined,
coverArt: song.coverArt ? api.getCoverArtUrl(song.coverArt, 300) : undefined,
albumId: song.albumId,
artistId: song.artistId,
starred: !!song.starred
@@ -77,7 +77,7 @@ export default function PlaylistPage() {
artist: song.artist,
album: song.album,
duration: song.duration,
coverArt: song.coverArt ? api.getCoverArtUrl(song.coverArt, 300) : undefined,
coverArt: song.coverArt ? api.getCoverArtUrl(song.coverArt, 300) : undefined,
albumId: song.albumId,
artistId: song.artistId,
starred: !!song.starred
@@ -98,7 +98,7 @@ export default function PlaylistPage() {
artist: song.artist,
album: song.album,
duration: song.duration,
coverArt: song.coverArt ? api.getCoverArtUrl(song.coverArt, 300) : undefined,
coverArt: song.coverArt ? api.getCoverArtUrl(song.coverArt, 300) : undefined,
albumId: song.albumId,
artistId: song.artistId,
starred: !!song.starred
@@ -208,8 +208,8 @@ export default function PlaylistPage() {
</div>
{/* Album Art */}
<div className="w-12 h-12 mr-4 flex-shrink-0"> <Image
src={song.coverArt && api ? api.getCoverArtUrl(song.coverArt, 100) : '/default-user.jpg'}
<div className="w-12 h-12 mr-4 shrink-0"> <Image
src={song.coverArt && api ? api.getCoverArtUrl(song.coverArt, 48) : '/default-user.jpg'}
alt={song.album}
width={48}
height={48}

View File

@@ -3,14 +3,161 @@
import React from 'react';
import Image from 'next/image';
import Link from 'next/link';
import { useAudioPlayer } from '@/app/components/AudioPlayerContext';
import { useAudioPlayer, Track } from '@/app/components/AudioPlayerContext';
import { Button } from '@/components/ui/button';
import { Separator } from '@/components/ui/separator';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Play, X, Disc, Trash2, SkipForward } from 'lucide-react';
import { Play, X, Disc, Trash2, SkipForward, GripVertical } from 'lucide-react';
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
DragEndEvent,
} from '@dnd-kit/core';
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
verticalListSortingStrategy,
useSortable,
} from '@dnd-kit/sortable';
import {
CSS,
} from '@dnd-kit/utilities';
interface SortableQueueItemProps {
track: Track;
index: number;
onPlay: () => void;
onRemove: () => void;
formatDuration: (seconds: number) => string;
}
function SortableQueueItem({ track, index, onPlay, onRemove, formatDuration }: SortableQueueItemProps) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: `${track.id}-${index}` });
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
};
return (
<div
ref={setNodeRef}
style={style}
className={`group flex items-center p-3 rounded-lg hover:bg-accent/50 transition-colors ${
isDragging ? 'bg-accent' : ''
}`}
>
{/* Drag Handle */}
<div
className="mr-3 opacity-60 group-hover:opacity-100 transition-opacity cursor-grab active:cursor-grabbing p-1 -m-1 hover:bg-accent rounded"
{...attributes}
{...listeners}
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
>
<GripVertical className="w-4 h-4 text-muted-foreground" />
</div>
{/* Clickable content area for play */}
<div
className="flex items-center flex-1 cursor-pointer"
onClick={onPlay}
>
{/* Album Art with Play Indicator */}
<div className="w-12 h-12 mr-4 shrink-0 relative">
<Image
src={track.coverArt || '/default-user.jpg'}
alt={track.album}
width={48}
height={48}
className="w-full h-full object-cover rounded-md"
/>
<div className="absolute inset-0 bg-black/50 rounded-md opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
<Play className="w-5 h-5 text-white" />
</div>
</div>
{/* Song Info */}
<div className="flex-1 min-w-0 mr-4">
<div className="flex items-center gap-2 mb-1">
<p className="font-semibold truncate">{track.name}</p>
</div>
<div className="flex items-center text-sm text-muted-foreground space-x-4">
<div className="flex items-center gap-1">
<Link
href={`/artist/${track.artistId}`}
className="truncate hover:text-primary hover:underline"
onClick={(e) => e.stopPropagation()}
>
{track.artist}
</Link>
</div>
</div>
</div>
{/* Duration */}
<div className="flex items-center text-sm text-muted-foreground mr-4">
{formatDuration(track.duration)}
</div>
</div>
{/* Actions */}
<div className="flex items-center space-x-2 opacity-0 group-hover:opacity-100 transition-opacity">
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation();
onRemove();
}}
className="h-8 w-8 p-0 hover:bg-destructive hover:text-destructive-foreground"
>
<X className="w-4 h-4" />
</Button>
</div>
</div>
);
}
const QueuePage: React.FC = () => {
const { queue, currentTrack, removeTrackFromQueue, clearQueue, skipToTrackInQueue } = useAudioPlayer();
const { queue, currentTrack, removeTrackFromQueue, clearQueue, skipToTrackInQueue, reorderQueue } = useAudioPlayer();
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: 8, // Require 8px of movement before starting drag
},
}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
);
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (over && active.id !== over.id) {
const oldIndex = queue.findIndex((track, index) => `${track.id}-${index}` === active.id);
const newIndex = queue.findIndex((track, index) => `${track.id}-${index}` === over.id);
if (oldIndex !== -1 && newIndex !== -1) {
reorderQueue(oldIndex, newIndex);
}
}
};
const formatDuration = (seconds: number): string => {
const minutes = Math.floor(seconds / 60);
@@ -19,7 +166,7 @@ const QueuePage: React.FC = () => {
};
return (
<div className="h-full px-4 py-6 lg:px-8 pb-24">
<div className="p-6 pb-24 w-full">
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
@@ -49,7 +196,7 @@ const QueuePage: React.FC = () => {
<div className="p-4 bg-accent/30 rounded-lg">
<div className="flex items-center">
{/* Album Art */}
<div className="w-16 h-16 mr-4 flex-shrink-0">
<div className="w-16 h-16 mr-4 shrink-0">
<Image
src={currentTrack.coverArt || '/default-user.jpg'}
alt={currentTrack.album}
@@ -107,67 +254,29 @@ const QueuePage: React.FC = () => {
</p>
</div>
) : (
<div className="space-y-1">
{queue.map((track, index) => (
<div
key={`${track.id}-${index}`}
className="group flex items-center p-3 rounded-lg hover:bg-accent/50 cursor-pointer transition-colors"
onClick={() => skipToTrackInQueue(index)}
>
{/* Album Art with Play Indicator */}
<div className="w-12 h-12 mr-4 flex-shrink-0 relative">
<Image
src={track.coverArt || '/default-user.jpg'}
alt={track.album}
width={48}
height={48}
className="w-full h-full object-cover rounded-md"
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={queue.map((track, index) => `${track.id}-${index}`)}
strategy={verticalListSortingStrategy}
>
<div className="space-y-1">
{queue.map((track, index) => (
<SortableQueueItem
key={`${track.id}-${index}`}
track={track}
index={index}
onPlay={() => skipToTrackInQueue(index)}
onRemove={() => removeTrackFromQueue(index)}
formatDuration={formatDuration}
/>
<div className="absolute inset-0 bg-black/50 rounded-md opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
<Play className="w-5 h-5 text-white" />
</div>
</div>
{/* Song Info */}
<div className="flex-1 min-w-0 mr-4">
<div className="flex items-center gap-2 mb-1">
<p className="font-semibold truncate">{track.name}</p>
</div>
<div className="flex items-center text-sm text-muted-foreground space-x-4">
<div className="flex items-center gap-1">
<Link
href={`/artist/${track.artistId}`}
className="truncate hover:text-primary hover:underline"
onClick={(e) => e.stopPropagation()}
>
{track.artist}
</Link>
</div>
</div>
</div>
{/* Duration */}
<div className="flex items-center text-sm text-muted-foreground mr-4">
{formatDuration(track.duration)}
</div>
{/* Actions */}
<div className="flex items-center space-x-2 opacity-0 group-hover:opacity-100 transition-opacity">
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation();
removeTrackFromQueue(index);
}}
className="h-8 w-8 p-0 hover:bg-destructive hover:text-destructive-foreground"
>
<X className="w-4 h-4" />
</Button>
</div>
))}
</div>
))}
</div>
</SortableContext>
</DndContext>
)}
</ScrollArea>
</div>

View File

@@ -128,24 +128,22 @@ const RadioStationsPage = () => {
if (isLoading) {
return (
<div className="container mx-auto p-6 max-w-4xl">
<div className="p-6 w-full max-w-4xl">
<div className="text-center">Loading radio stations...</div>
</div>
);
}
return (
<div className="container mx-auto p-6 max-w-4xl">
<div className="space-y-6">
<div className="flex items-center justify-between">
<div className="p-6 pb-24 w-full">
<div className="space-y-2">
<div className="flex items-center justify-between border-b pb-4 mb-4">
<div>
<h1 className="text-3xl font-semibold tracking-tight flex items-center gap-2">
<FaWifi className="w-8 h-8" />
Radio Stations
</h1>
<p className="text-muted-foreground">Listen to internet radio streams</p>
<h1 className="text-3xl font-bold">Radio Stations</h1>
<p className="text-muted-foreground text-sm">
Listen to internet radio stations.
</p>
</div>
<Dialog open={isAddDialogOpen} onOpenChange={setIsAddDialogOpen}>
<DialogTrigger asChild>
<Button>

View File

@@ -11,6 +11,7 @@ import { ArtistIcon } from '@/app/components/artist-icon';
import { useNavidrome } from '@/app/components/NavidromeContext';
import { getNavidromeAPI, Artist, Album, Song } from '@/lib/navidrome';
import { useAudioPlayer } from '@/app/components/AudioPlayerContext';
import { TrackContextMenu, AlbumContextMenu, ArtistContextMenu } from '@/app/components/ContextMenus';
import { Search, Play, Plus } from 'lucide-react';
export default function SearchPage() {
@@ -51,6 +52,31 @@ export default function SearchPage() {
return () => clearTimeout(timeoutId);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchQuery]);
// Focus search input when component mounts (for keyboard shortcut navigation)
useEffect(() => {
const searchInput = document.querySelector('input[type="text"]') as HTMLInputElement;
if (searchInput) {
searchInput.focus();
}
}, []);
const createTrackFromSong = (song: Song) => {
if (!api) return null;
return {
id: song.id,
name: song.title,
url: api.getStreamUrl(song.id),
artist: song.artist,
album: song.album,
duration: song.duration,
coverArt: song.coverArt ? api.getCoverArtUrl(song.coverArt, 300) : undefined,
albumId: song.albumId,
artistId: song.artistId,
starred: !!song.starred
};
};
const handlePlaySong = (song: Song) => {
if (!api) {
console.error('Navidrome API not available');
@@ -101,7 +127,7 @@ export default function SearchPage() {
};
return (
<div className="h-full px-4 py-6 lg:px-8">
<div className="p-6 pb-32 w-full">
<div className="space-y-6">
{/* Header */}
<div className="space-y-1">
@@ -142,7 +168,17 @@ export default function SearchPage() {
<ScrollArea className="w-full">
<div className="flex space-x-4 pb-4">
{searchResults.artists.map((artist) => (
<ArtistIcon key={artist.id} artist={artist} className="flex-shrink-0" />
<ArtistContextMenu
key={artist.id}
artistId={artist.id}
artistName={artist.name}
>
<ArtistIcon
artist={artist}
className="shrink-0 overflow-hidden"
size={190}
/>
</ArtistContextMenu>
))}
</div>
<ScrollBar orientation="horizontal" />
@@ -157,14 +193,19 @@ export default function SearchPage() {
<ScrollArea className="w-full">
<div className="flex space-x-4 pb-4">
{searchResults.albums.map((album) => (
<AlbumArtwork
key={album.id}
album={album}
className="flex-shrink-0 w-48"
aspectRatio="square"
width={192}
height={192}
/>
<AlbumContextMenu
key={album.id}
albumId={album.id}
albumName={album.name}
>
<AlbumArtwork
album={album}
className="shrink-0 w-48"
aspectRatio="square"
width={192}
height={192}
/>
</AlbumContextMenu>
))}
</div>
<ScrollBar orientation="horizontal" />
@@ -177,54 +218,62 @@ export default function SearchPage() {
<div>
<h2 className="text-2xl font-bold mb-4">Songs</h2>
<div className="space-y-2">
{searchResults.songs.slice(0, 10).map((song, index) => (
<div key={song.id} className="group flex items-center space-x-3 p-3 hover:bg-accent rounded-lg transition-colors">
<div className="w-8 text-center text-sm text-muted-foreground">
<span className="group-hover:hidden">{index + 1}</span>
<Button
variant="ghost"
size="sm"
onClick={() => handlePlaySong(song)}
className="hidden group-hover:flex h-8 w-8 p-0"
>
<Play className="w-4 h-4" />
</Button>
</div>
{/* Song Cover */}
<div className="flex-shrink-0"> <Image
src={song.coverArt && api ? api.getCoverArtUrl(song.coverArt, 64) : '/default-user.jpg'}
alt={song.album}
width={48}
height={48}
className="w-12 h-12 rounded-md object-cover"
/>
</div>
{/* Song Info */}
<div className="flex-1 min-w-0">
<p className="font-medium truncate">{song.title}</p>
<p className="text-sm text-muted-foreground truncate">{song.artist} {song.album}</p>
</div>
{/* Duration */}
<div className="text-sm text-muted-foreground">
{formatDuration(song.duration)}
</div>
{/* Actions */}
<div className="flex items-center space-x-2 opacity-0 group-hover:opacity-100 transition-opacity">
<Button
variant="ghost"
size="sm"
onClick={() => handleAddToQueue(song)}
className="h-8 w-8 p-0"
>
<Plus className="w-4 h-4" />
</Button>
</div>
</div>
))}
{searchResults.songs.slice(0, 10).map((song, index) => {
const track = createTrackFromSong(song);
if (!track) return null;
return (
<TrackContextMenu key={song.id} track={track}>
<div className="group flex items-center space-x-3 p-3 hover:bg-accent rounded-lg transition-colors cursor-pointer">
<div className="w-8 text-center text-sm text-muted-foreground">
<span className="group-hover:hidden">{index + 1}</span>
<Button
variant="ghost"
size="sm"
onClick={() => handlePlaySong(song)}
className="hidden group-hover:flex h-8 w-8 p-0"
>
<Play className="w-4 h-4" />
</Button>
</div>
{/* Song Cover */}
<div className="shrink-0">
<Image
src={song.coverArt && api ? api.getCoverArtUrl(song.coverArt, 300) : '/default-user.jpg'}
alt={song.album}
width={48}
height={48}
className="w-12 h-12 rounded-md object-cover"
/>
</div>
{/* Song Info */}
<div className="flex-1 min-w-0">
<p className="font-medium truncate">{song.title}</p>
<p className="text-sm text-muted-foreground truncate">{song.artist} {song.album}</p>
</div>
{/* Duration */}
<div className="text-sm text-muted-foreground">
{formatDuration(song.duration)}
</div>
{/* Actions */}
<div className="flex items-center space-x-2 opacity-0 group-hover:opacity-100 transition-opacity">
<Button
variant="ghost"
size="sm"
onClick={() => handleAddToQueue(song)}
className="h-8 w-8 p-0"
>
<Plus className="w-4 h-4" />
</Button>
</div>
</div>
</TrackContextMenu>
);
})}
{searchResults.songs.length > 10 && (
<div className="text-center pt-4">

View File

@@ -4,44 +4,43 @@ import React, { useState, useEffect } from 'react';
import { Label } from '@/components/ui/label';
import { Select, SelectTrigger, SelectContent, SelectItem, SelectValue } from '@/components/ui/select';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { useAudioPlayer } from '@/app/components/AudioPlayerContext';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { useTheme } from '@/app/components/ThemeProvider';
import { useNavidromeConfig } from '@/app/components/NavidromeConfigContext';
import { useToast } from '@/hooks/use-toast';
import { useStandaloneLastFm } from '@/hooks/use-standalone-lastfm';
import { FaServer, FaUser, FaLock, FaCheck, FaTimes, FaLastfm, FaCog } from 'react-icons/fa';
import { Settings, ExternalLink } from 'lucide-react';
import { useSidebarShortcuts, SidebarShortcutType } from '@/hooks/use-sidebar-shortcuts';
import { SidebarCustomization } from '@/app/components/SidebarCustomization';
import { SettingsManagement } from '@/app/components/SettingsManagement';
import EnhancedOfflineManager from '@/app/components/EnhancedOfflineManager';
import { AutoTaggingSettings } from '@/app/components/AutoTaggingSettings';
import { FaServer, FaUser, FaLock, FaCheck, FaTimes, FaLastfm, FaCog, FaTags } from 'react-icons/fa';
import { Settings, ExternalLink, Tag } from 'lucide-react';
import { Switch } from '@/components/ui/switch';
const SettingsPage = () => {
const { theme, setTheme } = useTheme();
const { theme, setTheme, mode, setMode } = useTheme();
const { config, updateConfig, isConnected, testConnection, clearConfig } = useNavidromeConfig();
const { toast } = useToast();
const { isEnabled: isStandaloneLastFmEnabled, getCredentials, getAuthUrl, getSessionKey } = useStandaloneLastFm();
const { shortcutType, updateShortcutType } = useSidebarShortcuts();
const audioPlayer = useAudioPlayer();
const [formData, setFormData] = useState({
serverUrl: config.serverUrl,
username: config.username,
password: config.password
serverUrl: '',
username: '',
password: ''
});
const [isTesting, setIsTesting] = useState(false);
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
// Last.fm scrobbling settings (Navidrome integration)
const [scrobblingEnabled, setScrobblingEnabled] = useState(() => {
if (typeof window !== 'undefined') {
return localStorage.getItem('lastfm-scrobbling-enabled') === 'true';
}
return true;
});
const [scrobblingEnabled, setScrobblingEnabled] = useState(true);
// Standalone Last.fm settings
const [standaloneLastFmEnabled, setStandaloneLastFmEnabled] = useState(() => {
if (typeof window !== 'undefined') {
return localStorage.getItem('standalone-lastfm-enabled') === 'true';
}
return false;
});
const [standaloneLastFmEnabled, setStandaloneLastFmEnabled] = useState(false);
const [lastFmCredentials, setLastFmCredentials] = useState({
apiKey: '',
@@ -50,6 +49,9 @@ const SettingsPage = () => {
username: ''
});
// Client-side hydration state
const [isClient, setIsClient] = useState(false);
// Check if Navidrome is configured via environment variables
const hasEnvConfig = React.useMemo(() => {
return !!(process.env.NEXT_PUBLIC_NAVIDROME_URL &&
@@ -58,25 +60,66 @@ const SettingsPage = () => {
}, []);
// Sidebar settings
const [sidebarCollapsed, setSidebarCollapsed] = useState(() => {
if (typeof window !== 'undefined') {
return localStorage.getItem('sidebar-collapsed') === 'true';
}
return false;
});
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const [sidebarVisible, setSidebarVisible] = useState(true);
const [notifyNowPlaying, setNotifyNowPlaying] = useState(false);
// Load Last.fm credentials on mount
// Initialize client-side state after hydration
useEffect(() => {
const credentials = getCredentials();
if (credentials) {
setLastFmCredentials({
apiKey: credentials.apiKey,
apiSecret: credentials.apiSecret,
sessionKey: credentials.sessionKey || '',
username: credentials.username || ''
});
setIsClient(true);
// Initialize form data with config values
setFormData({
serverUrl: config.serverUrl || '',
username: config.username || '',
password: config.password || ''
});
// Load saved preferences from localStorage
const savedScrobbling = localStorage.getItem('lastfm-scrobbling-enabled');
if (savedScrobbling !== null) {
setScrobblingEnabled(savedScrobbling === 'true');
}
}, [getCredentials]);
const savedStandaloneLastFm = localStorage.getItem('standalone-lastfm-enabled');
if (savedStandaloneLastFm !== null) {
setStandaloneLastFmEnabled(savedStandaloneLastFm === 'true');
}
const savedSidebarCollapsed = localStorage.getItem('sidebar-collapsed');
if (savedSidebarCollapsed !== null) {
setSidebarCollapsed(savedSidebarCollapsed === 'true');
}
const savedSidebarVisible = localStorage.getItem('sidebar-visible');
if (savedSidebarVisible !== null) {
setSidebarVisible(savedSidebarVisible === 'true');
} else {
setSidebarVisible(true); // Default to visible
}
// Notifications preference
const savedNotify = localStorage.getItem('playback-notifications-enabled');
if (savedNotify !== null) {
setNotifyNowPlaying(savedNotify === 'true');
}
// Load Last.fm credentials
const storedCredentials = localStorage.getItem('lastfm-credentials');
if (storedCredentials) {
try {
const credentials = JSON.parse(storedCredentials);
setLastFmCredentials({
apiKey: credentials.apiKey || '',
apiSecret: credentials.apiSecret || '',
sessionKey: credentials.sessionKey || '',
username: credentials.username || ''
});
} catch (error) {
console.error('Failed to parse stored Last.fm credentials:', error);
}
}
}, [config.serverUrl, config.username, config.password]);
const handleInputChange = (field: string, value: string) => {
setFormData(prev => ({ ...prev, [field]: value }));
@@ -171,7 +214,9 @@ const SettingsPage = () => {
const handleScrobblingToggle = (enabled: boolean) => {
setScrobblingEnabled(enabled);
localStorage.setItem('lastfm-scrobbling-enabled', enabled.toString());
if (isClient) {
localStorage.setItem('lastfm-scrobbling-enabled', enabled.toString());
}
toast({
title: enabled ? "Scrobbling Enabled" : "Scrobbling Disabled",
description: enabled
@@ -182,7 +227,9 @@ const SettingsPage = () => {
const handleStandaloneLastFmToggle = (enabled: boolean) => {
setStandaloneLastFmEnabled(enabled);
localStorage.setItem('standalone-lastfm-enabled', enabled.toString());
if (isClient) {
localStorage.setItem('standalone-lastfm-enabled', enabled.toString());
}
toast({
title: enabled ? "Standalone Last.fm Enabled" : "Standalone Last.fm Disabled",
description: enabled
@@ -193,7 +240,9 @@ const SettingsPage = () => {
const handleSidebarToggle = (collapsed: boolean) => {
setSidebarCollapsed(collapsed);
localStorage.setItem('sidebar-collapsed', collapsed.toString());
if (isClient) {
localStorage.setItem('sidebar-collapsed', collapsed.toString());
}
toast({
title: collapsed ? "Sidebar Collapsed" : "Sidebar Expanded",
description: collapsed
@@ -202,7 +251,64 @@ const SettingsPage = () => {
});
// Trigger a custom event to notify the sidebar component
window.dispatchEvent(new CustomEvent('sidebar-toggle', { detail: { collapsed } }));
if (typeof window !== 'undefined') {
window.dispatchEvent(new CustomEvent('sidebar-toggle', { detail: { collapsed } }));
}
};
const handleSidebarVisibilityToggle = (visible: boolean) => {
setSidebarVisible(visible);
if (isClient) {
localStorage.setItem('sidebar-visible', visible.toString());
}
toast({
title: visible ? "Sidebar Shown" : "Sidebar Hidden",
description: visible
? "Sidebar is now visible"
: "Sidebar is now hidden",
});
// Trigger a custom event to notify the sidebar component
if (typeof window !== 'undefined') {
window.dispatchEvent(new CustomEvent('sidebar-visibility-toggle', { detail: { visible } }));
}
};
const handleNotifyToggle = async (enabled: boolean) => {
setNotifyNowPlaying(enabled);
if (isClient) {
localStorage.setItem('playback-notifications-enabled', enabled.toString());
}
if (enabled && typeof window !== 'undefined' && 'Notification' in window) {
try {
if (Notification.permission === 'default') {
await Notification.requestPermission();
}
} catch {}
}
toast({
title: enabled ? 'Notifications Enabled' : 'Notifications Disabled',
description: enabled ? 'You will be notified when a new song starts.' : 'Now playing notifications are off.',
});
};
const handleTestNotification = () => {
if (typeof window === 'undefined') return;
if (!('Notification' in window)) {
toast({ title: 'Not supported', description: 'Browser does not support notifications.', variant: 'destructive' });
return;
}
if (Notification.permission === 'denied') {
toast({ title: 'Permission denied', description: 'Enable notifications in your browser settings.', variant: 'destructive' });
return;
}
const title = 'mice Test Notification';
const body = 'This is how a now playing notification will look.';
try {
new Notification(title, { body, icon: '/icon-192.png', badge: '/icon-192.png' });
} catch {
toast({ title: 'Test Notification', description: body });
}
};
const handleLastFmAuth = () => {
@@ -234,7 +340,9 @@ const SettingsPage = () => {
return;
}
localStorage.setItem('lastfm-credentials', JSON.stringify(lastFmCredentials));
if (isClient) {
localStorage.setItem('lastfm-credentials', JSON.stringify(lastFmCredentials));
}
toast({
title: "Credentials Saved",
description: "Last.fm credentials have been saved locally.",
@@ -256,7 +364,9 @@ const SettingsPage = () => {
};
setLastFmCredentials(updatedCredentials);
localStorage.setItem('lastfm-credentials', JSON.stringify(updatedCredentials));
if (isClient) {
localStorage.setItem('lastfm-credentials', JSON.stringify(updatedCredentials));
}
toast({
title: "Last.fm Authentication Complete",
@@ -272,15 +382,26 @@ const SettingsPage = () => {
};
return (
<div className="container mx-auto p-6 pb-24 max-w-2xl">
<div className="space-y-6">
<div>
<h1 className="text-3xl font-semibold tracking-tight">Settings</h1>
<p className="text-muted-foreground">Customize your music experience</p>
<div className="p-6 pb-24 w-full">
{!isClient ? (
<div className="space-y-6 max-w-2xl mx-auto">
<div>
<h1 className="text-3xl font-semibold tracking-tight">Settings</h1>
<p className="text-muted-foreground">Loading...</p>
</div>
</div>
) : (
<div className="space-y-6">
<div className="text-left">
<h1 className="text-3xl font-semibold tracking-tight">Settings</h1>
<p className="text-muted-foreground">Customize your music experience</p>
</div>
<div className="columns-1 lg:columns-2 xl:columns-3 gap-6 space-y-6"
style={{ columnFill: 'balance' }}>
{!hasEnvConfig && (
<Card>
<Card className="mb-6 break-inside-avoid py-5">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<FaServer className="w-5 h-5" />
@@ -369,7 +490,7 @@ const SettingsPage = () => {
)}
{hasEnvConfig && (
<Card>
<Card className="mb-6 break-inside-avoid py-5">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<FaServer className="w-5 h-5" />
@@ -396,7 +517,30 @@ const SettingsPage = () => {
</Card>
)}
<Card>
{/* Notifications */}
<Card className="mb-6 break-inside-avoid py-5">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Settings className="w-5 h-5" />
Notifications
</CardTitle>
<CardDescription>Control now playing notifications</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<div>
<p className="font-medium">Now playing notifications</p>
<p className="text-sm text-muted-foreground">Show a notification when a new song starts</p>
</div>
<Switch checked={notifyNowPlaying} onCheckedChange={handleNotifyToggle} />
</div>
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={handleTestNotification}>Test notification</Button>
</div>
</CardContent>
</Card>
<Card className="mb-6 break-inside-avoid py-5">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<FaLastfm className="w-5 h-5" />
@@ -474,7 +618,7 @@ const SettingsPage = () => {
</CardContent>
</Card> */}
<Card>
<Card className="mb-6 break-inside-avoid py-5">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Settings className="w-5 h-5" />
@@ -486,30 +630,50 @@ const SettingsPage = () => {
</CardHeader>
<CardContent className="space-y-6">
<div className="space-y-2">
<Label htmlFor="sidebar-mode">Sidebar Mode</Label>
<Label htmlFor="sidebar-visibility">Sidebar Visibility</Label>
<Select
value={sidebarCollapsed ? "collapsed" : "expanded"}
onValueChange={(value) => handleSidebarToggle(value === "collapsed")}
value={sidebarVisible ? "visible" : "hidden"}
onValueChange={(value) => handleSidebarVisibilityToggle(value === "visible")}
>
<SelectTrigger id="sidebar-mode">
<SelectTrigger id="sidebar-visibility">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="expanded">Expanded (with labels)</SelectItem>
<SelectItem value="collapsed">Collapsed (icons only)</SelectItem>
<SelectItem value="visible">Visible</SelectItem>
<SelectItem value="hidden">Hidden</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="sidebar-shortcuts">Sidebar Shortcuts</Label>
<Select
value={shortcutType}
onValueChange={(value: SidebarShortcutType) => updateShortcutType(value)}
>
<SelectTrigger id="sidebar-shortcuts">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="both">Albums & Playlists</SelectItem>
<SelectItem value="albums">Albums Only</SelectItem>
<SelectItem value="playlists">Playlists Only</SelectItem>
</SelectContent>
</Select>
</div>
<div className="text-sm text-muted-foreground space-y-2">
<p><strong>Expanded:</strong> Shows full navigation labels</p>
<p><strong>Collapsed:</strong> Shows only icons with tooltips</p>
<p className="mt-3"><strong>Note:</strong> You can also toggle the sidebar using the collapse button in the sidebar.</p>
<p><strong>Visible:</strong> Sidebar is always shown with icon navigation</p>
<p><strong>Hidden:</strong> Sidebar is completely hidden for maximum space</p>
<p><strong>Albums & Playlists:</strong> Show both favorite albums, recently played albums, and playlists as shortcuts</p>
<p><strong>Albums Only:</strong> Show only favorite and recently played albums as shortcuts</p>
<p><strong>Playlists Only:</strong> Show only playlists as shortcuts</p>
<p className="mt-3"><strong>Note:</strong> The sidebar now shows only icons with tooltips on hover for a cleaner interface.</p>
</div>
</CardContent>
</Card>
<Card>
{/* <Card className="mb-6 break-inside-avoid py-5">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<FaLastfm className="w-5 h-5" />
@@ -602,9 +766,29 @@ const SettingsPage = () => {
</>
)}
</CardContent>
</Card>
</Card> */}
<Card>
{/* Sidebar Customization */}
<div className="break-inside-avoid mb-6">
<SidebarCustomization />
</div>
{/* Settings Management */}
<div className="break-inside-avoid mb-6">
<SettingsManagement />
</div>
{/* Offline Library Management */}
<div className="break-inside-avoid mb-6">
<EnhancedOfflineManager />
</div>
{/* Auto-Tagging Settings */}
<div className="break-inside-avoid mb-6">
<AutoTaggingSettings />
</div>
<Card className="mb-6 break-inside-avoid py-5">
<CardHeader>
<CardTitle>Appearance</CardTitle>
<CardDescription>
@@ -619,6 +803,7 @@ const SettingsPage = () => {
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="default">Default</SelectItem>
<SelectItem value="blue">Blue</SelectItem>
<SelectItem value="violet">Violet</SelectItem>
<SelectItem value="red">Red</SelectItem>
@@ -630,15 +815,110 @@ const SettingsPage = () => {
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="mode-select">Display Mode</Label>
<Select value={mode} onValueChange={setMode}>
<SelectTrigger id="mode-select">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="light">Light</SelectItem>
<SelectItem value="dark">Dark</SelectItem>
<SelectItem value="system">System</SelectItem>
</SelectContent>
</Select>
</div>
<div className="text-sm text-muted-foreground">
<p><strong>Theme:</strong> Choose between blue and violet color schemes</p>
<p><strong>Dark Mode:</strong> Automatically follows your system preferences</p>
<p><strong>Theme:</strong> Choose from multiple color schemes including default (white)</p>
<p><strong>Display Mode:</strong> Choose light, dark, or system (follows your device preferences)</p>
</div>
</CardContent>
</Card>
{/* Theme Preview */}
<Card>
<Card className="mb-6 break-inside-avoid py-5">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<FaCog className="w-5 h-5" />
Audio Settings
</CardTitle>
<CardDescription>
Configure playback and audio effects
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* Crossfade */}
<div className="space-y-2">
<Label htmlFor="crossfade-duration">Crossfade Duration</Label>
<Select
value={String(audioPlayer.audioSettings.crossfadeDuration)}
onValueChange={(value) => audioPlayer.updateAudioSettings({ crossfadeDuration: Number(value) })}
>
<SelectTrigger id="crossfade-duration">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="0">Off</SelectItem>
<SelectItem value="2">2 seconds</SelectItem>
<SelectItem value="3">3 seconds</SelectItem>
<SelectItem value="4">4 seconds</SelectItem>
<SelectItem value="5">5 seconds</SelectItem>
</SelectContent>
</Select>
</div>
{/* Equalizer Preset */}
<div className="space-y-2">
<Label htmlFor="equalizer-preset">Equalizer Preset</Label>
<Select
value={audioPlayer.equalizerPreset}
onValueChange={audioPlayer.setEqualizerPreset}
>
<SelectTrigger id="equalizer-preset">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="normal">Normal</SelectItem>
<SelectItem value="bassBoost">Bass Boost</SelectItem>
<SelectItem value="trebleBoost">Treble Boost</SelectItem>
<SelectItem value="vocalBoost">Vocal Boost</SelectItem>
</SelectContent>
</Select>
</div>
{/* ReplayGain */}
<div className="flex items-center justify-between">
<div>
<Label>ReplayGain</Label>
<p className="text-sm text-muted-foreground">Normalize volume across tracks</p>
</div>
<Switch
checked={audioPlayer.audioSettings.replayGainEnabled}
onCheckedChange={(checked) => audioPlayer.updateAudioSettings({ replayGainEnabled: checked })}
/>
</div>
{/* Gapless Playback */}
<div className="flex items-center justify-between">
<div>
<Label>Gapless Playback</Label>
<p className="text-sm text-muted-foreground">Seamless transitions between tracks</p>
</div>
<Switch
checked={audioPlayer.audioSettings.gaplessPlayback}
onCheckedChange={(checked) => audioPlayer.updateAudioSettings({ gaplessPlayback: checked })}
/>
</div> <div className="text-sm text-muted-foreground space-y-2">
<p><strong>Crossfade:</strong> Smooth fade between tracks (2-5 seconds)</p>
<p><strong>Equalizer:</strong> Preset frequency adjustments for different music styles</p>
<p><strong>ReplayGain:</strong> Consistent volume across all tracks in your library</p>
<p><strong>Gapless:</strong> Perfect for live albums and continuous DJ mixes</p>
</div>
</CardContent>
</Card>
<Card className="mb-6 break-inside-avoid py-5">
<CardHeader>
<CardTitle>Preview</CardTitle>
<CardDescription>
@@ -666,7 +946,50 @@ const SettingsPage = () => {
</div>
</CardContent>
</Card>
</div>
{/* Debug Section - Development Only */}
{process.env.NODE_ENV === 'development' && (
<Card className="mb-6 break-inside-avoid py-5 border-orange-200 bg-orange-50/50">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-orange-700">
<Settings className="w-5 h-5" />
Debug Tools
</CardTitle>
<CardDescription className="text-orange-600">
Development-only debugging utilities
</CardDescription>
</CardHeader>
<CardContent>
<Button
onClick={() => {
// Save Navidrome config before clearing
const navidromeConfig = localStorage.getItem('navidrome-config');
// Clear all localStorage
localStorage.clear();
// Restore Navidrome config
if (navidromeConfig) {
localStorage.setItem('navidrome-config', navidromeConfig);
}
// Reload page to reset state
window.location.reload();
}}
variant="outline"
className="w-full bg-orange-100 border-orange-300 text-orange-700 hover:bg-orange-200"
>
Clear All Data (Keep Navidrome Config)
</Button>
<p className="text-xs text-orange-600 mt-2">
This will clear all localStorage data except your Navidrome server configuration, then reload the page.
</p>
</CardContent>
</Card>
)}
</div>
</div>
)}
</div>
);
};

View File

@@ -0,0 +1,66 @@
"use client"
import * as React from "react"
import * as AccordionPrimitive from "@radix-ui/react-accordion"
import { ChevronDownIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Accordion({
...props
}: React.ComponentProps<typeof AccordionPrimitive.Root>) {
return <AccordionPrimitive.Root data-slot="accordion" {...props} />
}
function AccordionItem({
className,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Item>) {
return (
<AccordionPrimitive.Item
data-slot="accordion-item"
className={cn("border-b last:border-b-0", className)}
{...props}
/>
)
}
function AccordionTrigger({
className,
children,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Trigger>) {
return (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
data-slot="accordion-trigger"
className={cn(
"focus-visible:border-ring focus-visible:ring-ring/50 flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180",
className
)}
{...props}
>
{children}
<ChevronDownIcon className="text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
)
}
function AccordionContent({
className,
children,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Content>) {
return (
<AccordionPrimitive.Content
data-slot="accordion-content"
className="data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down overflow-hidden text-sm"
{...props}
>
<div className={cn("pt-0 pb-4", className)}>{children}</div>
</AccordionPrimitive.Content>
)
}
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }

View File

@@ -0,0 +1,11 @@
"use client"
import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio"
function AspectRatio({
...props
}: React.ComponentProps<typeof AspectRatioPrimitive.Root>) {
return <AspectRatioPrimitive.Root data-slot="aspect-ratio" {...props} />
}
export { AspectRatio }

View File

@@ -4,7 +4,7 @@ import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {

View File

@@ -0,0 +1,109 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { ChevronRight, MoreHorizontal } from "lucide-react"
import { cn } from "@/lib/utils"
function Breadcrumb({ ...props }: React.ComponentProps<"nav">) {
return <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} />
}
function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
return (
<ol
data-slot="breadcrumb-list"
className={cn(
"text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm break-words sm:gap-2.5",
className
)}
{...props}
/>
)
}
function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
return (
<li
data-slot="breadcrumb-item"
className={cn("inline-flex items-center gap-1.5", className)}
{...props}
/>
)
}
function BreadcrumbLink({
asChild,
className,
...props
}: React.ComponentProps<"a"> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot : "a"
return (
<Comp
data-slot="breadcrumb-link"
className={cn("hover:text-foreground transition-colors", className)}
{...props}
/>
)
}
function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="breadcrumb-page"
role="link"
aria-disabled="true"
aria-current="page"
className={cn("text-foreground font-normal", className)}
{...props}
/>
)
}
function BreadcrumbSeparator({
children,
className,
...props
}: React.ComponentProps<"li">) {
return (
<li
data-slot="breadcrumb-separator"
role="presentation"
aria-hidden="true"
className={cn("[&>svg]:size-3.5", className)}
{...props}
>
{children ?? <ChevronRight />}
</li>
)
}
function BreadcrumbEllipsis({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="breadcrumb-ellipsis"
role="presentation"
aria-hidden="true"
className={cn("flex size-9 items-center justify-center", className)}
{...props}
>
<MoreHorizontal className="size-4" />
<span className="sr-only">More</span>
</span>
)
}
export {
Breadcrumb,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbPage,
BreadcrumbSeparator,
BreadcrumbEllipsis,
}

View File

@@ -5,18 +5,18 @@ import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
"bg-primary text-primary-foreground shadow-sm hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
"bg-destructive text-destructive-foreground shadow-xs hover:bg-destructive/90",
outline:
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
"border border-input bg-background shadow-xs hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},

210
components/ui/calendar.tsx Normal file
View File

@@ -0,0 +1,210 @@
"use client"
import * as React from "react"
import {
ChevronDownIcon,
ChevronLeftIcon,
ChevronRightIcon,
} from "lucide-react"
import { DayButton, DayPicker, getDefaultClassNames } from "react-day-picker"
import { cn } from "@/lib/utils"
import { Button, buttonVariants } from "@/components/ui/button"
function Calendar({
className,
classNames,
showOutsideDays = true,
captionLayout = "label",
buttonVariant = "ghost",
formatters,
components,
...props
}: React.ComponentProps<typeof DayPicker> & {
buttonVariant?: React.ComponentProps<typeof Button>["variant"]
}) {
const defaultClassNames = getDefaultClassNames()
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn(
"bg-background group/calendar p-3 [--cell-size:--spacing(8)] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent",
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
className
)}
captionLayout={captionLayout}
formatters={{
formatMonthDropdown: (date) =>
date.toLocaleString("default", { month: "short" }),
...formatters,
}}
classNames={{
root: cn("w-fit", defaultClassNames.root),
months: cn(
"flex gap-4 flex-col md:flex-row relative",
defaultClassNames.months
),
month: cn("flex flex-col w-full gap-4", defaultClassNames.month),
nav: cn(
"flex items-center gap-1 w-full absolute top-0 inset-x-0 justify-between",
defaultClassNames.nav
),
button_previous: cn(
buttonVariants({ variant: buttonVariant }),
"size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
defaultClassNames.button_previous
),
button_next: cn(
buttonVariants({ variant: buttonVariant }),
"size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
defaultClassNames.button_next
),
month_caption: cn(
"flex items-center justify-center h-(--cell-size) w-full px-(--cell-size)",
defaultClassNames.month_caption
),
dropdowns: cn(
"w-full flex items-center text-sm font-medium justify-center h-(--cell-size) gap-1.5",
defaultClassNames.dropdowns
),
dropdown_root: cn(
"relative has-focus:border-ring border border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] rounded-md",
defaultClassNames.dropdown_root
),
dropdown: cn("absolute inset-0 opacity-0", defaultClassNames.dropdown),
caption_label: cn(
"select-none font-medium",
captionLayout === "label"
? "text-sm"
: "rounded-md pl-2 pr-1 flex items-center gap-1 text-sm h-8 [&>svg]:text-muted-foreground [&>svg]:size-3.5",
defaultClassNames.caption_label
),
table: "w-full border-collapse",
weekdays: cn("flex", defaultClassNames.weekdays),
weekday: cn(
"text-muted-foreground rounded-md flex-1 font-normal text-[0.8rem] select-none",
defaultClassNames.weekday
),
week: cn("flex w-full mt-2", defaultClassNames.week),
week_number_header: cn(
"select-none w-(--cell-size)",
defaultClassNames.week_number_header
),
week_number: cn(
"text-[0.8rem] select-none text-muted-foreground",
defaultClassNames.week_number
),
day: cn(
"relative w-full h-full p-0 text-center [&:first-child[data-selected=true]_button]:rounded-l-md [&:last-child[data-selected=true]_button]:rounded-r-md group/day aspect-square select-none",
defaultClassNames.day
),
range_start: cn(
"rounded-l-md bg-accent",
defaultClassNames.range_start
),
range_middle: cn("rounded-none", defaultClassNames.range_middle),
range_end: cn("rounded-r-md bg-accent", defaultClassNames.range_end),
today: cn(
"bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none",
defaultClassNames.today
),
outside: cn(
"text-muted-foreground aria-selected:text-muted-foreground",
defaultClassNames.outside
),
disabled: cn(
"text-muted-foreground opacity-50",
defaultClassNames.disabled
),
hidden: cn("invisible", defaultClassNames.hidden),
...classNames,
}}
components={{
Root: ({ className, rootRef, ...props }) => {
return (
<div
data-slot="calendar"
ref={rootRef}
className={cn(className)}
{...props}
/>
)
},
Chevron: ({ className, orientation, ...props }) => {
if (orientation === "left") {
return (
<ChevronLeftIcon className={cn("size-4", className)} {...props} />
)
}
if (orientation === "right") {
return (
<ChevronRightIcon
className={cn("size-4", className)}
{...props}
/>
)
}
return (
<ChevronDownIcon className={cn("size-4", className)} {...props} />
)
},
DayButton: CalendarDayButton,
WeekNumber: ({ children, ...props }) => {
return (
<td {...props}>
<div className="flex size-(--cell-size) items-center justify-center text-center">
{children}
</div>
</td>
)
},
...components,
}}
{...props}
/>
)
}
function CalendarDayButton({
className,
day,
modifiers,
...props
}: React.ComponentProps<typeof DayButton>) {
const defaultClassNames = getDefaultClassNames()
const ref = React.useRef<HTMLButtonElement>(null)
React.useEffect(() => {
if (modifiers.focused) ref.current?.focus()
}, [modifiers.focused])
return (
<Button
ref={ref}
variant="ghost"
size="icon"
data-day={day.date.toLocaleDateString()}
data-selected-single={
modifiers.selected &&
!modifiers.range_start &&
!modifiers.range_end &&
!modifiers.range_middle
}
data-range-start={modifiers.range_start}
data-range-end={modifiers.range_end}
data-range-middle={modifiers.range_middle}
className={cn(
"data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 dark:hover:text-accent-foreground flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] data-[range-end=true]:rounded-md data-[range-end=true]:rounded-r-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md data-[range-start=true]:rounded-l-md [&>span]:text-xs [&>span]:opacity-70",
defaultClassNames.day,
className
)}
{...props}
/>
)
}
export { Calendar, CalendarDayButton }

View File

@@ -2,75 +2,91 @@ import * as React from "react"
import { cn } from "@/lib/utils"
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-xl border bg-card text-card-foreground shadow",
className
)}
{...props}
/>
))
Card.displayName = "Card"
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-0 shadow-sm",
className
)}
{...props}
/>
)
}
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
))
CardHeader.displayName = "CardHeader"
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className
)}
{...props}
/>
)
}
const CardTitle = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("font-semibold leading-none tracking-tight", className)}
{...props}
/>
))
CardTitle.displayName = "CardTitle"
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props}
/>
)
}
const CardDescription = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
CardDescription.displayName = "CardDescription"
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
))
CardContent.displayName = "CardContent"
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props}
/>
))
CardFooter.displayName = "CardFooter"
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
{...props}
/>
)
}
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}

241
components/ui/carousel.tsx Normal file
View File

@@ -0,0 +1,241 @@
"use client"
import * as React from "react"
import useEmblaCarousel, {
type UseEmblaCarouselType,
} from "embla-carousel-react"
import { ArrowLeft, ArrowRight } from "lucide-react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
type CarouselApi = UseEmblaCarouselType[1]
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>
type CarouselOptions = UseCarouselParameters[0]
type CarouselPlugin = UseCarouselParameters[1]
type CarouselProps = {
opts?: CarouselOptions
plugins?: CarouselPlugin
orientation?: "horizontal" | "vertical"
setApi?: (api: CarouselApi) => void
}
type CarouselContextProps = {
carouselRef: ReturnType<typeof useEmblaCarousel>[0]
api: ReturnType<typeof useEmblaCarousel>[1]
scrollPrev: () => void
scrollNext: () => void
canScrollPrev: boolean
canScrollNext: boolean
} & CarouselProps
const CarouselContext = React.createContext<CarouselContextProps | null>(null)
function useCarousel() {
const context = React.useContext(CarouselContext)
if (!context) {
throw new Error("useCarousel must be used within a <Carousel />")
}
return context
}
function Carousel({
orientation = "horizontal",
opts,
setApi,
plugins,
className,
children,
...props
}: React.ComponentProps<"div"> & CarouselProps) {
const [carouselRef, api] = useEmblaCarousel(
{
...opts,
axis: orientation === "horizontal" ? "x" : "y",
},
plugins
)
const [canScrollPrev, setCanScrollPrev] = React.useState(false)
const [canScrollNext, setCanScrollNext] = React.useState(false)
const onSelect = React.useCallback((api: CarouselApi) => {
if (!api) return
setCanScrollPrev(api.canScrollPrev())
setCanScrollNext(api.canScrollNext())
}, [])
const scrollPrev = React.useCallback(() => {
api?.scrollPrev()
}, [api])
const scrollNext = React.useCallback(() => {
api?.scrollNext()
}, [api])
const handleKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key === "ArrowLeft") {
event.preventDefault()
scrollPrev()
} else if (event.key === "ArrowRight") {
event.preventDefault()
scrollNext()
}
},
[scrollPrev, scrollNext]
)
React.useEffect(() => {
if (!api || !setApi) return
setApi(api)
}, [api, setApi])
React.useEffect(() => {
if (!api) return
onSelect(api)
api.on("reInit", onSelect)
api.on("select", onSelect)
return () => {
api?.off("select", onSelect)
}
}, [api, onSelect])
return (
<CarouselContext.Provider
value={{
carouselRef,
api: api,
opts,
orientation:
orientation || (opts?.axis === "y" ? "vertical" : "horizontal"),
scrollPrev,
scrollNext,
canScrollPrev,
canScrollNext,
}}
>
<div
onKeyDownCapture={handleKeyDown}
className={cn("relative", className)}
role="region"
aria-roledescription="carousel"
data-slot="carousel"
{...props}
>
{children}
</div>
</CarouselContext.Provider>
)
}
function CarouselContent({ className, ...props }: React.ComponentProps<"div">) {
const { carouselRef, orientation } = useCarousel()
return (
<div
ref={carouselRef}
className="overflow-hidden"
data-slot="carousel-content"
>
<div
className={cn(
"flex",
orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col",
className
)}
{...props}
/>
</div>
)
}
function CarouselItem({ className, ...props }: React.ComponentProps<"div">) {
const { orientation } = useCarousel()
return (
<div
role="group"
aria-roledescription="slide"
data-slot="carousel-item"
className={cn(
"min-w-0 shrink-0 grow-0 basis-full",
orientation === "horizontal" ? "pl-4" : "pt-4",
className
)}
{...props}
/>
)
}
function CarouselPrevious({
className,
variant = "outline",
size = "icon",
...props
}: React.ComponentProps<typeof Button>) {
const { orientation, scrollPrev, canScrollPrev } = useCarousel()
return (
<Button
data-slot="carousel-previous"
variant={variant}
size={size}
className={cn(
"absolute size-8 rounded-full",
orientation === "horizontal"
? "top-1/2 -left-12 -translate-y-1/2"
: "-top-12 left-1/2 -translate-x-1/2 rotate-90",
className
)}
disabled={!canScrollPrev}
onClick={scrollPrev}
{...props}
>
<ArrowLeft />
<span className="sr-only">Previous slide</span>
</Button>
)
}
function CarouselNext({
className,
variant = "outline",
size = "icon",
...props
}: React.ComponentProps<typeof Button>) {
const { orientation, scrollNext, canScrollNext } = useCarousel()
return (
<Button
data-slot="carousel-next"
variant={variant}
size={size}
className={cn(
"absolute size-8 rounded-full",
orientation === "horizontal"
? "top-1/2 -right-12 -translate-y-1/2"
: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
className
)}
disabled={!canScrollNext}
onClick={scrollNext}
{...props}
>
<ArrowRight />
<span className="sr-only">Next slide</span>
</Button>
)
}
export {
type CarouselApi,
Carousel,
CarouselContent,
CarouselItem,
CarouselPrevious,
CarouselNext,
}

0
components/ui/chart.tsx Normal file
View File

View File

@@ -0,0 +1,32 @@
"use client"
import * as React from "react"
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
import { CheckIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Checkbox({
className,
...props
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="flex items-center justify-center text-current transition-none"
>
<CheckIcon className="size-3.5" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
)
}
export { Checkbox }

View File

@@ -0,0 +1,33 @@
"use client"
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
function Collapsible({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
}
function CollapsibleTrigger({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
return (
<CollapsiblePrimitive.CollapsibleTrigger
data-slot="collapsible-trigger"
{...props}
/>
)
}
function CollapsibleContent({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
return (
<CollapsiblePrimitive.CollapsibleContent
data-slot="collapsible-content"
{...props}
/>
)
}
export { Collapsible, CollapsibleTrigger, CollapsibleContent }

184
components/ui/command.tsx Normal file
View File

@@ -0,0 +1,184 @@
"use client"
import * as React from "react"
import { Command as CommandPrimitive } from "cmdk"
import { SearchIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
function Command({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive>) {
return (
<CommandPrimitive
data-slot="command"
className={cn(
"bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md",
className
)}
{...props}
/>
)
}
function CommandDialog({
title = "Command Palette",
description = "Search for a command to run...",
children,
className,
showCloseButton = true,
...props
}: React.ComponentProps<typeof Dialog> & {
title?: string
description?: string
className?: string
showCloseButton?: boolean
}) {
return (
<Dialog {...props}>
<DialogHeader className="sr-only">
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<DialogContent
className={cn("overflow-hidden p-0", className)}
hideCloseButton={!showCloseButton}
>
<Command className="[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
{children}
</Command>
</DialogContent>
</Dialog>
)
}
function CommandInput({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
return (
<div
data-slot="command-input-wrapper"
className="flex h-9 items-center gap-2 border-b px-3"
>
<SearchIcon className="size-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
data-slot="command-input"
className={cn(
"placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
/>
</div>
)
}
function CommandList({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.List>) {
return (
<CommandPrimitive.List
data-slot="command-list"
className={cn(
"max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto",
className
)}
{...props}
/>
)
}
function CommandEmpty({
...props
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
return (
<CommandPrimitive.Empty
data-slot="command-empty"
className="py-6 text-center text-sm"
{...props}
/>
)
}
function CommandGroup({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
return (
<CommandPrimitive.Group
data-slot="command-group"
className={cn(
"text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium",
className
)}
{...props}
/>
)
}
function CommandSeparator({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
return (
<CommandPrimitive.Separator
data-slot="command-separator"
className={cn("bg-border -mx-1 h-px", className)}
{...props}
/>
)
}
function CommandItem({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
return (
<CommandPrimitive.Item
data-slot="command-item"
className={cn(
"data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function CommandShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="command-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className
)}
{...props}
/>
)
}
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
}

View File

@@ -27,7 +27,7 @@ const ContextMenuSubTrigger = React.forwardRef<
<ContextMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-hidden focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
inset && "pl-8",
className
)}
@@ -46,7 +46,7 @@ const ContextMenuSubContent = React.forwardRef<
<ContextMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
"z-50 min-w-32 overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
@@ -62,7 +62,7 @@ const ContextMenuContent = React.forwardRef<
<ContextMenuPrimitive.Content
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
"z-50 min-w-32 overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
@@ -80,7 +80,7 @@ const ContextMenuItem = React.forwardRef<
<ContextMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-hidden focus:bg-accent focus:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50",
inset && "pl-8",
className
)}
@@ -96,7 +96,7 @@ const ContextMenuCheckboxItem = React.forwardRef<
<ContextMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-hidden focus:bg-accent focus:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50",
className
)}
checked={checked}
@@ -120,7 +120,7 @@ const ContextMenuRadioItem = React.forwardRef<
<ContextMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-hidden focus:bg-accent focus:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50",
className
)}
{...props}

View File

@@ -47,7 +47,7 @@ const DialogContent = React.forwardRef<
>
{children}
{!hideCloseButton && (
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>

135
components/ui/drawer.tsx Normal file
View File

@@ -0,0 +1,135 @@
"use client"
import * as React from "react"
import { Drawer as DrawerPrimitive } from "vaul"
import { cn } from "@/lib/utils"
function Drawer({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Root>) {
return <DrawerPrimitive.Root data-slot="drawer" {...props} />
}
function DrawerTrigger({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Trigger>) {
return <DrawerPrimitive.Trigger data-slot="drawer-trigger" {...props} />
}
function DrawerPortal({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Portal>) {
return <DrawerPrimitive.Portal data-slot="drawer-portal" {...props} />
}
function DrawerClose({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Close>) {
return <DrawerPrimitive.Close data-slot="drawer-close" {...props} />
}
function DrawerOverlay({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Overlay>) {
return (
<DrawerPrimitive.Overlay
data-slot="drawer-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
)
}
function DrawerContent({
className,
children,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Content>) {
return (
<DrawerPortal data-slot="drawer-portal">
<DrawerOverlay />
<DrawerPrimitive.Content
data-slot="drawer-content"
className={cn(
"group/drawer-content bg-background fixed z-50 flex h-auto flex-col",
"data-[vaul-drawer-direction=top]:inset-x-0 data-[vaul-drawer-direction=top]:top-0 data-[vaul-drawer-direction=top]:mb-24 data-[vaul-drawer-direction=top]:max-h-[80vh] data-[vaul-drawer-direction=top]:rounded-b-lg data-[vaul-drawer-direction=top]:border-b",
"data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:max-h-[80vh] data-[vaul-drawer-direction=bottom]:rounded-t-lg data-[vaul-drawer-direction=bottom]:border-t",
"data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=right]:right-0 data-[vaul-drawer-direction=right]:w-3/4 data-[vaul-drawer-direction=right]:border-l data-[vaul-drawer-direction=right]:sm:max-w-sm",
"data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=left]:left-0 data-[vaul-drawer-direction=left]:w-3/4 data-[vaul-drawer-direction=left]:border-r data-[vaul-drawer-direction=left]:sm:max-w-sm",
className
)}
{...props}
>
<div className="bg-muted mx-auto mt-4 hidden h-2 w-[100px] shrink-0 rounded-full group-data-[vaul-drawer-direction=bottom]/drawer-content:block" />
{children}
</DrawerPrimitive.Content>
</DrawerPortal>
)
}
function DrawerHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="drawer-header"
className={cn(
"flex flex-col gap-0.5 p-4 group-data-[vaul-drawer-direction=bottom]/drawer-content:text-center group-data-[vaul-drawer-direction=top]/drawer-content:text-center md:gap-1.5 md:text-left",
className
)}
{...props}
/>
)
}
function DrawerFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="drawer-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
}
function DrawerTitle({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Title>) {
return (
<DrawerPrimitive.Title
data-slot="drawer-title"
className={cn("text-foreground font-semibold", className)}
{...props}
/>
)
}
function DrawerDescription({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Description>) {
return (
<DrawerPrimitive.Description
data-slot="drawer-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
export {
Drawer,
DrawerPortal,
DrawerOverlay,
DrawerTrigger,
DrawerClose,
DrawerContent,
DrawerHeader,
DrawerFooter,
DrawerTitle,
DrawerDescription,
}

View File

@@ -0,0 +1,257 @@
"use client"
import * as React from "react"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function DropdownMenu({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
}
function DropdownMenuPortal({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return (
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
)
}
function DropdownMenuTrigger({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return (
<DropdownMenuPrimitive.Trigger
data-slot="dropdown-menu-trigger"
{...props}
/>
)
}
function DropdownMenuContent({
className,
sideOffset = 4,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
)
}
function DropdownMenuGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return (
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
)
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<DropdownMenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
)
}
function DropdownMenuRadioGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return (
<DropdownMenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
)
}
function DropdownMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
return (
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
)
}
function DropdownMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
className
)}
{...props}
/>
)
}
function DropdownMenuSeparator({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props}
/>
)
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className
)}
{...props}
/>
)
}
function DropdownMenuSub({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.SubTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger>
)
}
function DropdownMenuSubContent({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
className
)}
{...props}
/>
)
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
}

View File

@@ -0,0 +1,44 @@
"use client"
import * as React from "react"
import * as HoverCardPrimitive from "@radix-ui/react-hover-card"
import { cn } from "@/lib/utils"
function HoverCard({
...props
}: React.ComponentProps<typeof HoverCardPrimitive.Root>) {
return <HoverCardPrimitive.Root data-slot="hover-card" {...props} />
}
function HoverCardTrigger({
...props
}: React.ComponentProps<typeof HoverCardPrimitive.Trigger>) {
return (
<HoverCardPrimitive.Trigger data-slot="hover-card-trigger" {...props} />
)
}
function HoverCardContent({
className,
align = "center",
sideOffset = 4,
...props
}: React.ComponentProps<typeof HoverCardPrimitive.Content>) {
return (
<HoverCardPrimitive.Portal data-slot="hover-card-portal">
<HoverCardPrimitive.Content
data-slot="hover-card-content"
align={align}
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-64 origin-(--radix-hover-card-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
className
)}
{...props}
/>
</HoverCardPrimitive.Portal>
)
}
export { HoverCard, HoverCardTrigger, HoverCardContent }

View File

@@ -0,0 +1,56 @@
'use client';
import { useEffect } from 'react';
import { useInView } from 'react-intersection-observer';
import { Loader2 } from 'lucide-react';
import { cn } from '@/lib/utils';
interface InfiniteScrollProps {
onLoadMore: () => void;
hasMore: boolean;
isLoading: boolean;
loadingText?: string;
endMessage?: string;
className?: string;
}
export function InfiniteScroll({
onLoadMore,
hasMore,
isLoading,
loadingText = 'Loading more items...',
endMessage = 'No more items to load',
className
}: InfiniteScrollProps) {
const { ref, inView } = useInView({
threshold: 0,
rootMargin: '100px 0px',
});
useEffect(() => {
if (inView && hasMore && !isLoading) {
onLoadMore();
}
}, [inView, hasMore, isLoading, onLoadMore]);
return (
<div
ref={ref}
className={cn(
'py-4 flex flex-col items-center justify-center w-full',
className
)}
>
{isLoading && (
<div className="flex items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin" />
<p className="text-sm text-muted-foreground">{loadingText}</p>
</div>
)}
{!hasMore && !isLoading && (
<p className="text-sm text-muted-foreground">{endMessage}</p>
)}
</div>
);
}

View File

@@ -0,0 +1,77 @@
"use client"
import * as React from "react"
import { OTPInput, OTPInputContext } from "input-otp"
import { MinusIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function InputOTP({
className,
containerClassName,
...props
}: React.ComponentProps<typeof OTPInput> & {
containerClassName?: string
}) {
return (
<OTPInput
data-slot="input-otp"
containerClassName={cn(
"flex items-center gap-2 has-disabled:opacity-50",
containerClassName
)}
className={cn("disabled:cursor-not-allowed", className)}
{...props}
/>
)
}
function InputOTPGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="input-otp-group"
className={cn("flex items-center", className)}
{...props}
/>
)
}
function InputOTPSlot({
index,
className,
...props
}: React.ComponentProps<"div"> & {
index: number
}) {
const inputOTPContext = React.useContext(OTPInputContext)
const { char, hasFakeCaret, isActive } = inputOTPContext?.slots[index] ?? {}
return (
<div
data-slot="input-otp-slot"
data-active={isActive}
className={cn(
"data-[active=true]:border-ring data-[active=true]:ring-ring/50 data-[active=true]:aria-invalid:ring-destructive/20 dark:data-[active=true]:aria-invalid:ring-destructive/40 aria-invalid:border-destructive data-[active=true]:aria-invalid:border-destructive dark:bg-input/30 border-input relative flex h-9 w-9 items-center justify-center border-y border-r text-sm shadow-xs transition-all outline-none first:rounded-l-md first:border-l last:rounded-r-md data-[active=true]:z-10 data-[active=true]:ring-[3px]",
className
)}
{...props}
>
{char}
{hasFakeCaret && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<div className="animate-caret-blink bg-foreground h-4 w-px duration-1000" />
</div>
)}
</div>
)
}
function InputOTPSeparator({ ...props }: React.ComponentProps<"div">) {
return (
<div data-slot="input-otp-separator" role="separator" {...props}>
<MinusIcon />
</div>
)
}
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator }

View File

@@ -8,7 +8,7 @@ const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
<input
type={type}
className={cn(
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-xs transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
ref={ref}

Some files were not shown because too many files have changed in this diff Show More