64 Commits

Author SHA1 Message Date
7e8a601fb6 Merge pull request #22 from sillyangel/dev
Update release.yml
2025-07-10 18:56:28 -05:00
aa11307c43 Update release.yml 2025-07-10 18:56:00 -05:00
1bd2846617 Merge pull request #21 from sillyangel/dev
:3
2025-07-10 18:43:02 -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
112 changed files with 11094 additions and 2813 deletions

View File

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

View File

@@ -1 +1 @@
NEXT_PUBLIC_COMMIT_SHA=a854604
NEXT_PUBLIC_COMMIT_SHA=35febc5

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

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

@@ -0,0 +1,96 @@
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: Generate artifact attestation
uses: actions/attest-build-provenance@v1
with:
subject-name: ${{ env.IMAGE_NAME }}
subject-digest: ${{ steps.build.outputs.digest }}
push-to-registry: true
# - name: Docker Hub Description
# uses: peter-evans/dockerhub-description@v4
# with:
# username: ${{ vars.DOCKERHUB_USERNAME }}
# password: ${{ secrets.DOCKERHUB_TOKEN }}
# repository: sillyangel/mice

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:
@@ -86,7 +86,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 +100,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 +112,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,6 +16,9 @@ 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

View File

@@ -1,13 +1,13 @@
# GitHub Actions Docker Publishing Setup
This repository includes a GitHub Actions workflow that automatically builds and publishes Docker images to GitHub Container Registry (GHCR).
This repository includes a GitHub Actions workflow that automatically builds and publishes Docker images to Docker Hub.
## 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`
2. **Publishes** to `sillyangel/mice`
3. **Tags** images appropriately based on git refs
4. **Caches** layers for faster subsequent builds
5. **Generates** build provenance attestations for security
@@ -27,16 +27,16 @@ Based on different triggers, the workflow creates these tags:
### Main Branch Push
- `ghcr.io/sillyangel/mice:latest`
- `sillyangel/mice:latest`
### Tag Push (e.g., `2025.07.02`)
- `ghcr.io/sillyangel/mice:2025.07.02`
- `ghcr.io/sillyangel/mice:latest`
- `sillyangel/mice:2025.07.02`
- `sillyangel/mice:latest`
### Pull Request
- `ghcr.io/sillyangel/mice:pr-123`
- `sillyangel/mice:pr-123`
## Multi-Platform Support
@@ -51,7 +51,7 @@ 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`
3. **Users can pull**: `docker pull sillyangel/mice:latest`
## Manual Image Building
@@ -60,9 +60,9 @@ 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 \
-t sillyangel/mice:latest \
--push .
# Login first (if needed)
echo $GITHUB_TOKEN | docker login ghcr.io -u USERNAME --password-stdin
echo $DOCKERHUB_TOKEN | docker login -u USERNAME --password-stdin
```

View File

@@ -1,50 +1,58 @@
![splash](https://github.com/sillyangel/mice/blob/main/4xnored.png?raw=true)
# mice (project still reworked)
> project still, now with navidrome
<p align="left" style="display: flex; align-items: center; gap: 12px;">
<img src="https://github.com/sillyangel/mice/blob/main/public/icon-512.png?raw=true" alt="Mice Logo" width="64" style="border-radius: 12px;" />
<strong style="font-size: 2rem;">Mice | Navidrome Client</strong>
</p>
> 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.
> Project based on [shadcn/ui](https://github.com/shadcn-ui/ui)'s music template.
**✨ New**: Migrated from Firebase + static data to **Navidrome/Subsonic** integration for real music streaming!
<!-- This is a 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. -->
### Features
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.
- 🎵 **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
## 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
- **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 +62,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 +91,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,15 +114,11 @@ 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.
## Tech Stack
- **Frontend**: Next.js 15, React 19, TypeScript
@@ -110,14 +127,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.

View File

@@ -12,6 +12,7 @@ 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';
export default function AlbumPage() {
const { id } = useParams();
@@ -22,6 +23,7 @@ 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 api = getNavidromeAPI();
useEffect(() => {
@@ -137,7 +139,7 @@ export default function AlbumPage() {
<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">
<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>
@@ -145,13 +147,13 @@ export default function AlbumPage() {
<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
Play
</Button>
<div className="text-sm text-muted-foreground">
<p>{album.songCount} songs {album.year} {album.genre}</p>
<p>Duration: {formatDuration(album.duration)}</p>
</div>
<p>{album.genre} {album.year}</p>
<p>{album.songCount} songs, {formatDuration(album.duration)}</p>
</div>
</div>
</div>
<div className="space-y-4">

View File

@@ -91,15 +91,15 @@ export default function BrowsePage() {
}
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 +111,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 +119,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,7 +130,7 @@ 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">
@@ -139,7 +139,7 @@ export default function BrowsePage() {
</div>
</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">
@@ -176,9 +176,9 @@ export default function BrowsePage() {
<ScrollBar orientation="vertical" />
</ScrollArea>
</div>
</TabsContent>
</Tabs>
</>
</div>
</div>
</div>
</div>
);
}

View File

@@ -116,7 +116,7 @@ export const AudioPlayer: React.FC = () => {
useEffect(() => {
const audioCurrent = audioRef.current;
return () => {
if (audioCurrent && currentTrack && audioCurrent.currentTime > 10) {
if (audioCurrent && currentTrack && audioCurrent.currentTime > 5) {
localStorage.setItem('navidrome-current-track-time', audioCurrent.currentTime.toString());
}
};
@@ -134,12 +134,12 @@ export const AudioPlayer: React.FC = () => {
// Notify scrobbler about new track
onTrackStart(currentTrack);
// Check for saved timestamp (only restore if more than 10 seconds in)
// Check for saved timestamp (only restore if more than 5 seconds in)
const savedTime = localStorage.getItem('navidrome-current-track-time');
if (savedTime) {
const time = parseFloat(savedTime);
// Only restore if we were at least 10 seconds in and not near the end
if (time > 10 && time < (currentTrack.duration - 30)) {
// Only restore if we were at least 5 seconds in and not near the end
if (time > 5 && time < (currentTrack.duration - 15)) {
const restorePosition = () => {
if (audioCurrent.readyState >= 2) { // HAVE_CURRENT_DATA
audioCurrent.currentTime = time;
@@ -181,9 +181,9 @@ export const AudioPlayer: React.FC = () => {
if (audioCurrent && currentTrack) {
setProgress((audioCurrent.currentTime / audioCurrent.duration) * 100);
// Save current time every 30 seconds, but only if we've moved forward significantly
// Save current time every 10 seconds, but only if we've moved forward significantly
const currentTime = audioCurrent.currentTime;
if (Math.abs(currentTime - lastSavedTime) >= 30 && currentTime > 10) {
if (Math.abs(currentTime - lastSavedTime) >= 10 && currentTime > 5) {
localStorage.setItem('navidrome-current-track-time', currentTime.toString());
lastSavedTime = currentTime;
}
@@ -359,7 +359,7 @@ export const AudioPlayer: React.FC = () => {
return (
<div className="fixed bottom-4 left-4 z-50">
<div
className="bg-background/95 backdrop-blur-sm border rounded-lg shadow-lg cursor-pointer hover:scale-[1.02] transition-transform w-80"
className="bg-background/95 backdrop-blur-xs border rounded-lg shadow-lg cursor-pointer hover:scale-[1.02] transition-transform w-80"
onClick={() => setIsMinimized(false)}
>
<div className="flex items-center p-3">
@@ -368,7 +368,7 @@ export const AudioPlayer: React.FC = () => {
alt={currentTrack.name}
width={40}
height={40}
className="w-10 h-10 rounded-md flex-shrink-0"
className="w-10 h-10 rounded-md shrink-0"
/>
<div className="flex-1 min-w-0 mx-3">
<div className="overflow-hidden">
@@ -413,16 +413,21 @@ export const AudioPlayer: React.FC = () => {
// Compact floating player (default state)
return (
<div className="fixed bottom-4 left-4 right-4 z-50">
<div className="bg-background/95 backdrop-blur-sm border rounded-lg shadow-lg p-3 cursor-pointer hover:scale-[1.01] transition-transform">
<div className="bg-background/95 backdrop-blur-xs border rounded-lg shadow-lg p-3 cursor-pointer hover:scale-[1.01] transition-transform">
<div className="flex items-center">
{/* Track info */}
<div className="flex items-center flex-1 min-w-0">
<Image
src={currentTrack.coverArt || '/default-user.jpg'}
src={
currentTrack.coverArt &&
(currentTrack.coverArt.startsWith('http') || currentTrack.coverArt.startsWith('/'))
? currentTrack.coverArt
: '/default-user.jpg'
}
alt={currentTrack.name}
width={48}
height={48}
className="w-12 h-12 rounded-md mr-4 flex-shrink-0"
className="w-12 h-12 rounded-md mr-4 shrink-0"
/>
<div className="flex-1 min-w-0">
<p className="font-semibold truncate text-base">{currentTrack.name}</p>

View File

@@ -0,0 +1,223 @@
'use client';
import React, { useState, useEffect } from 'react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Label } from '@/components/ui/label';
import {
Database,
Trash2,
RefreshCw,
HardDrive
} from 'lucide-react';
import { CacheManager } from '@/lib/cache';
export function CacheManagement() {
const [cacheStats, setCacheStats] = useState({
total: 0,
expired: 0,
size: '0 B'
});
const [isClearing, setIsClearing] = useState(false);
const [lastCleared, setLastCleared] = useState<string | null>(null);
const loadCacheStats = () => {
if (typeof window === 'undefined') return;
let total = 0;
let expired = 0;
let totalSize = 0;
const now = Date.now();
// Check localStorage for cache entries
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key && (key.startsWith('cache-') || key.startsWith('navidrome-cache-') || key.startsWith('library-cache-'))) {
total++;
const value = localStorage.getItem(key);
if (value) {
totalSize += key.length + value.length;
try {
const parsed = JSON.parse(value);
if (parsed.expiresAt && now > parsed.expiresAt) {
expired++;
}
} catch (error) {
expired++;
}
}
}
}
// Convert bytes to human readable format
const formatSize = (bytes: number): string => {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
};
setCacheStats({
total,
expired,
size: formatSize(totalSize * 2) // *2 for UTF-16 encoding
});
};
useEffect(() => {
loadCacheStats();
// Check if there's a last cleared timestamp
const lastClearedTime = localStorage.getItem('cache-last-cleared');
if (lastClearedTime) {
setLastCleared(new Date(parseInt(lastClearedTime)).toLocaleString());
}
}, []);
const handleClearCache = async () => {
setIsClearing(true);
try {
// Clear all cache using the CacheManager
CacheManager.clearAll();
// Also clear any other cache-related localStorage items
if (typeof window !== 'undefined') {
const keys = Object.keys(localStorage);
keys.forEach(key => {
if (key.startsWith('cache-') ||
key.startsWith('navidrome-cache-') ||
key.startsWith('library-cache-') ||
key.includes('album') ||
key.includes('artist') ||
key.includes('song')) {
localStorage.removeItem(key);
}
});
// Set last cleared timestamp
localStorage.setItem('cache-last-cleared', Date.now().toString());
}
// Update stats
loadCacheStats();
setLastCleared(new Date().toLocaleString());
// Show success feedback
setTimeout(() => {
setIsClearing(false);
}, 1000);
} catch (error) {
console.error('Failed to clear cache:', error);
setIsClearing(false);
}
};
const handleCleanExpired = () => {
if (typeof window === 'undefined') return;
const now = Date.now();
const keysToRemove: string[] = [];
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key && (key.startsWith('cache-') || key.startsWith('navidrome-cache-') || key.startsWith('library-cache-'))) {
try {
const value = localStorage.getItem(key);
if (value) {
const parsed = JSON.parse(value);
if (parsed.expiresAt && now > parsed.expiresAt) {
keysToRemove.push(key);
}
}
} catch (error) {
// Invalid cache item, remove it
keysToRemove.push(key);
}
}
}
keysToRemove.forEach(key => localStorage.removeItem(key));
loadCacheStats();
};
return (
<Card className="break-inside-avoid">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Database className="h-5 w-5" />
Cache Management
</CardTitle>
<CardDescription>
Manage application cache to improve performance and free up storage
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{/* Cache Statistics */}
<div className="grid grid-cols-3 gap-4 text-center">
<div className="space-y-1">
<p className="text-2xl font-bold">{cacheStats.total}</p>
<p className="text-xs text-muted-foreground">Total Items</p>
</div>
<div className="space-y-1">
<p className="text-2xl font-bold">{cacheStats.expired}</p>
<p className="text-xs text-muted-foreground">Expired</p>
</div>
<div className="space-y-1">
<p className="text-2xl font-bold">{cacheStats.size}</p>
<p className="text-xs text-muted-foreground">Storage Used</p>
</div>
</div>
{/* Cache Actions */}
<div className="space-y-2">
<div className="flex gap-2">
<Button
onClick={handleClearCache}
disabled={isClearing}
variant="destructive"
size="sm"
className="flex-1"
>
{isClearing ? (
<RefreshCw className="h-4 w-4 mr-2 animate-spin" />
) : (
<Trash2 className="h-4 w-4 mr-2" />
)}
{isClearing ? 'Clearing...' : 'Clear All Cache'}
</Button>
<Button
onClick={handleCleanExpired}
variant="outline"
size="sm"
className="flex-1"
>
<HardDrive className="h-4 w-4 mr-2" />
Clean Expired
</Button>
</div>
<Button
onClick={loadCacheStats}
variant="ghost"
size="sm"
className="w-full"
>
<RefreshCw className="h-4 w-4 mr-2" />
Refresh Stats
</Button>
</div>
{/* Cache Info */}
<div className="text-sm text-muted-foreground space-y-1">
<p>Cache includes albums, artists, songs, and image URLs to improve loading times.</p>
{lastCleared && (
<p>Last cleared: {lastCleared}</p>
)}
</div>
</CardContent>
</Card>
);
}

View File

@@ -21,14 +21,7 @@ import {
FaListUl
} from "react-icons/fa6";
import { Heart } from 'lucide-react';
import { Card, CardContent } from '@/components/ui/card';
import { ScrollArea } from '@/components/ui/scroll-area';
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuTrigger,
} from "@/components/ui/context-menu";
interface LyricLine {
time: number;
@@ -294,10 +287,9 @@ export const FullScreenPlayer: React.FC<FullScreenPlayerProps> = ({ isOpen, onCl
{/* Overlay for better contrast */}
<div className="absolute inset-0 bg-black/50" />
<div className="relative h-full w-full flex flex-col">
{/* Header */}
<div className="flex items-center justify-between p-4 lg:p-6 flex-shrink-0">
<h2 className="text-lg lg:text-xl font-semibold text-white"></h2>
<div className="relative h-full w-full">
{/* Floating Header */}
<div className="absolute top-0 right-0 z-50 p-4 lg:p-6">
<div className="flex items-center gap-2">
{onOpenQueue && (
<button
@@ -319,15 +311,11 @@ export const FullScreenPlayer: React.FC<FullScreenPlayerProps> = ({ isOpen, onCl
</div>
{/* Main Content */}
<div className="flex-1 flex flex-col lg:flex-row gap-4 lg:gap-8 p-4 lg:p-6 pt-0 overflow-hidden min-h-0">
<div className="h-full flex flex-col lg:flex-row gap-4 lg:gap-8 p-4 lg:p-6 overflow-hidden">
{/* Left Side - Album Art and Controls */}
<div className={`flex flex-col items-center min-h-0 flex-1 min-w-0 ${
showLyrics && lyrics.length > 0
? 'justify-center lg:justify-start'
: 'justify-center'
}`}>
<div className="flex flex-col items-center justify-center min-h-0 flex-1 min-w-0">
{/* Album Art */}
<div className="relative mb-4 lg:mb-6 flex-shrink-0">
<div className="relative mb-4 lg:mb-6 shrink-0">
<Image
src={currentTrack.coverArt || '/default-album.png'}
alt={currentTrack.album}
@@ -339,7 +327,7 @@ export const FullScreenPlayer: React.FC<FullScreenPlayerProps> = ({ isOpen, onCl
</div>
{/* Track Info */}
<div className="text-center mb-4 lg:mb-6 px-4 flex-shrink-0 max-w-full">
<div className="text-center mb-4 lg:mb-6 px-4 shrink-0 max-w-full">
<h1 className="text-lg sm:text-xl lg:text-3xl font-bold text-foreground mb-2 line-clamp-2 leading-tight">
{currentTrack.name}
</h1>
@@ -352,7 +340,7 @@ export const FullScreenPlayer: React.FC<FullScreenPlayerProps> = ({ isOpen, onCl
</div>
{/* Progress */}
<div className="w-full max-w-sm lg:max-w-md mb-4 lg:mb-6 px-4 flex-shrink-0">
<div className="w-full max-w-sm lg:max-w-md mb-4 lg:mb-6 px-4 shrink-0">
<div className="w-full" onClick={handleSeek}>
<Progress value={progress} className="h-2 cursor-pointer" />
</div>
@@ -363,7 +351,7 @@ export const FullScreenPlayer: React.FC<FullScreenPlayerProps> = ({ isOpen, onCl
</div>
{/* Controls */}
<div className="flex items-center gap-3 sm:gap-4 lg:gap-6 mb-4 lg:mb-6 flex-shrink-0">
<div className="flex items-center gap-3 sm:gap-4 lg:gap-6 mb-4 lg:mb-6 shrink-0">
<button
onClick={toggleShuffle}
className={`p-2 hover:bg-gray-700/50 rounded-full transition-colors ${
@@ -411,7 +399,7 @@ export const FullScreenPlayer: React.FC<FullScreenPlayerProps> = ({ isOpen, onCl
</div>
{/* Volume and Lyrics Toggle */}
<div className="flex items-center gap-3 flex-shrink-0 justify-center">
<div className="flex items-center gap-3 shrink-0 justify-center">
<button
onMouseEnter={() => setShowVolumeSlider(true)}
className="p-2 hover:bg-gray-700/50 rounded-full transition-colors">
@@ -457,15 +445,15 @@ export const FullScreenPlayer: React.FC<FullScreenPlayerProps> = ({ isOpen, onCl
<div className="flex-1 min-w-0 min-h-0 flex flex-col" ref={lyricsRef}>
<div className="h-full flex flex-col">
<ScrollArea className="flex-1 min-h-0">
<div className="space-y-3 sm:space-y-4 pr-4 px-2 py-4">
<div className="space-y-2 sm:space-y-3 pl-4 pr-4 py-4">
{lyrics.map((line, index) => (
<div
key={index}
data-lyric-index={index}
onClick={() => handleLyricClick(line.time)}
className={`text-sm sm:text-base lg:text-base leading-relaxed transition-all duration-300 break-words cursor-pointer hover:text-foreground hover:scale-102 ${
className={`text-sm sm:text-base lg:text-base leading-relaxed transition-all duration-300 break-words cursor-pointer hover:text-foreground ${
index === currentLyricIndex
? 'text-foreground font-semibold text-base sm:text-lg lg:text-xl scale-105'
? 'text-foreground font-bold text-2xl'
: index < currentLyricIndex
? 'text-foreground/60'
: 'text-foreground/40'
@@ -474,7 +462,7 @@ export const FullScreenPlayer: React.FC<FullScreenPlayerProps> = ({ isOpen, onCl
wordWrap: 'break-word',
overflowWrap: 'break-word',
hyphens: 'auto',
paddingBottom: '6px',
paddingBottom: '4px',
paddingLeft: '8px'
}}
title={`Click to jump to ${formatTime(line.time)}`}

View File

@@ -92,7 +92,7 @@ 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)}

View File

@@ -14,15 +14,19 @@ import Image from "next/image";
function NavidromeErrorBoundary({ children }: { children: React.ReactNode }) {
const { error } = useNavidrome();
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,7 +49,12 @@ function NavidromeErrorBoundary({ children }: { children: React.ReactNode }) {
}
return false;
}, []);
}, [isClient]);
// Don't show anything until client-side hydration is complete
if (!isClient) {
return <>{children}</>;
}
// Show start screen ONLY if:
// 1. First-time user (no onboarding completed), OR

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>
<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>
<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,230 @@
'use client';
import React, { useState, useEffect } from 'react';
import { Song } from '@/lib/navidrome';
import { useNavidrome } from '@/app/components/NavidromeContext';
import { useAudioPlayer } from '@/app/components/AudioPlayerContext';
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';
interface SongRecommendationsProps {
userName?: string;
}
export function SongRecommendations({ userName }: SongRecommendationsProps) {
const { api, isConnected } = useNavidrome();
const { playTrack, shuffle, toggleShuffle } = useAudioPlayer();
const [recommendedSongs, setRecommendedSongs] = useState<Song[]>([]);
const [loading, setLoading] = useState(true);
const [songStates, setSongStates] = useState<Record<string, boolean>>({});
const [imageLoadingStates, setImageLoadingStates] = useState<Record<string, boolean>>({});
// Get greeting based on time of day
const hour = new Date().getHours();
const greeting = hour < 12 ? 'Good morning' : hour < 18 ? 'Good afternoon' : 'Good evening';
useEffect(() => {
const loadRecommendations = async () => {
if (!api || !isConnected) return;
setLoading(true);
try {
// Get random albums and extract songs from them
const randomAlbums = await api.getAlbums('random', 10); // Get 10 random albums
const allSongs: Song[] = [];
// Get songs from first few albums
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);
}
}
// Shuffle and limit to 6 songs
const shuffled = allSongs.sort(() => Math.random() - 0.5);
const recommendations = shuffled.slice(0, 6);
setRecommendedSongs(recommendations);
// Initialize starred states and image loading states
const states: Record<string, boolean> = {};
const imageStates: Record<string, boolean> = {};
recommendations.forEach((song: Song) => {
states[song.id] = !!song.starred;
imageStates[song.id] = true; // Start with loading state
});
setSongStates(states);
setImageLoadingStates(imageStates);
} catch (error) {
console.error('Failed to load song recommendations:', error);
} finally {
setLoading(false);
}
};
loadRecommendations();
}, [api, isConnected]);
const handlePlaySong = async (song: Song) => {
if (!api) return;
try {
const track = {
id: song.id,
name: song.title,
url: api.getStreamUrl(song.id),
artist: song.artist || 'Unknown Artist',
artistId: song.artistId || '',
album: song.album || 'Unknown Album',
albumId: song.albumId || '',
duration: song.duration || 0,
coverArt: song.coverArt ? api.getCoverArtUrl(song.coverArt, 300) : undefined,
starred: !!song.starred
};
await playTrack(track, true);
} catch (error) {
console.error('Failed to play song:', error);
}
};
const handleShuffleAll = async () => {
if (recommendedSongs.length === 0) return;
// Enable shuffle if not already on
if (!shuffle) {
toggleShuffle();
}
// 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>
<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">
Here are some songs you might enjoy
</p>
</div>
{recommendedSongs.length > 0 && (
<Button onClick={handleShuffleAll} variant="outline" size="sm">
<Shuffle className="w-4 h-4 mr-2" />
Shuffle All
</Button>
)}
</div>
{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 && api ? (
<>
{imageLoadingStates[song.id] && (
<div className="absolute inset-0 bg-muted flex items-center justify-center">
<Music className="w-6 h-6 text-muted-foreground animate-pulse" />
</div>
)}
<Image
src={api.getCoverArtUrl(song.coverArt, 100)}
alt={song.title}
fill
className={`object-cover transition-opacity duration-300 ${
imageLoadingStates[song.id] ? 'opacity-0' : 'opacity-100'
}`}
sizes="48px"
onLoad={() => setImageLoadingStates(prev => ({ ...prev, [song.id]: false }))}
onError={() => setImageLoadingStates(prev => ({ ...prev, [song.id]: false }))}
/>
</>
) : (
<div className="w-full h-full flex items-center justify-center">
<Music className="w-6 h-6 text-muted-foreground" />
</div>
)}
{!imageLoadingStates[song.id] && (
<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>
<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

@@ -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

@@ -7,11 +7,47 @@ 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.10';
// Changelog data - add new versions at the top
const CHANGELOG = [
{
{
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)',
'Caching system added (incomplete)',
'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: [

View File

@@ -46,6 +46,8 @@ export function AlbumArtwork({
const router = useRouter();
const { addAlbumToQueue, playTrack, addToQueue } = useAudioPlayer();
const { playlists, starItem, unstarItem } = useNavidrome();
const [imageLoading, setImageLoading] = useState(true);
const [imageError, setImageError] = useState(false);
const handleClick = () => {
router.push(`/album/${album.id}`);
@@ -112,31 +114,57 @@ export function AlbumArtwork({
<div className={cn("space-y-3", className)} {...props}>
<ContextMenu>
<ContextMenuTrigger>
<Card key={album.id} className="overflow-hidden cursor-pointer" onClick={() => handleClick()}>
<Card key={album.id} className="overflow-hidden cursor-pointer px-0 py-0 gap-0" 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"
/>
<>
{imageLoading && (
<div className="absolute inset-0 bg-muted animate-pulse rounded flex items-center justify-center">
<Disc className="w-12 h-12 text-muted-foreground animate-spin" />
</div>
)}
<Image
src={api.getCoverArtUrl(album.coverArt)}
alt={album.name}
fill
className={`w-full h-full object-cover transition-opacity duration-300 ${
imageLoading ? 'opacity-0' : 'opacity-100'
}`}
sizes="(max-width: 768px) 100vw, 300px"
onLoad={() => setImageLoading(false)}
onError={() => {
setImageLoading(false);
setImageError(true);
}}
/>
</>
) : (
<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>
{!imageLoading && (
<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>
{imageLoading ? (
<>
<div className="h-5 w-3/4 bg-muted animate-pulse rounded mb-2" />
<div className="h-4 w-1/2 bg-muted animate-pulse rounded mb-1" />
<div className="h-3 w-2/3 bg-muted animate-pulse rounded" />
</>
) : (
<>
<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>
{/* <div onClick={handleClick} className="overflow-hidden rounded-md">
@@ -148,7 +176,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> */}

View File

@@ -25,12 +25,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 +56,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 +81,33 @@ 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()}>
<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 +118,6 @@ 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> */}
</ContextMenuTrigger>
<ContextMenuContent className="w-40">
<ContextMenuItem onClick={handleStar}>

View File

@@ -5,7 +5,8 @@ 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 { Toaster } from "@/components/ui/toaster";
import { useFavoriteAlbums } from "@/hooks/use-favorite-albums";
interface IhateserversideProps {
children: React.ReactNode;
@@ -18,12 +19,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 +38,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 +55,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 +73,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>
@@ -83,17 +97,17 @@ const Ihateserverside: React.FC<IhateserversideProps> = ({ children }) => {
);
}
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}
@@ -101,22 +115,22 @@ const Ihateserverside: React.FC<IhateserversideProps> = ({ children }) => {
</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 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 className="flex-1 overflow-y-auto">
<div>{children}</div>
</div>
</div>
{/* Floating Audio Player */}
{isStatusBarVisible && (

View File

@@ -111,9 +111,9 @@ export function Menu({ toggleSidebar, isSidebarVisible, toggleStatusBar, isStatu
return (
<>
<div className="flex items-center justify-between w-full ml-2">
<div className="flex items-center justify-between w-full">
<Menubar
className="rounded-none border-b border-none px-0 lg:px-0 flex-1"
className="rounded-none border-b border-none px-2 lg:px-2 flex-1 min-w-0"
style={{
minWidth: 0,
WebkitAppRegion: "drag"
@@ -134,7 +134,6 @@ export function Menu({ toggleSidebar, isSidebarVisible, toggleStatusBar, isStatu
</MenubarItem>
</MenubarContent>
</MenubarMenu>
<div className="border-r-4 w-0"><p className="invisible">j</p></div>
<MenubarMenu>
<MenubarTrigger className="relative">File</MenubarTrigger>
<MenubarContent>

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, 32)}
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, 32)}
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

@@ -206,6 +206,49 @@ 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}>
@@ -399,6 +442,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";
@@ -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,7 +198,7 @@ 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)}
@@ -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

@@ -79,7 +79,7 @@ export default function HistoryPage() {
return (
<div className="h-full px-4 py-6 lg:px-8">
<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 +155,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

@@ -45,7 +45,7 @@ interface LayoutProps {
export default function Layout({ children }: LayoutProps) {
return (
<html lang="en">
<html lang="en" suppressHydrationWarning>
<head>
<script
dangerouslySetInnerHTML={{

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" />

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">
@@ -60,7 +60,7 @@ const PlaylistsPage: React.FC = () => {
<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

@@ -239,7 +239,7 @@ export default function SongsPage() {
</div>
{/* Album Art */}
<div className="w-12 h-12 mr-4 flex-shrink-0"> <Image
<div className="w-12 h-12 mr-4 shrink-0"> <Image
src={song.coverArt && api ? api.getCoverArtUrl(song.coverArt, 100) : '/default-user.jpg'}
alt={song.album}
width={48}

View File

@@ -2,9 +2,9 @@ import type { MetadataRoute } from 'next'
export default function manifest(): MetadataRoute.Manifest {
return {
name: 'mice',
short_name: 'Offbrand',
description: 'a very mice clone',
name: 'Mice',
short_name: 'Mice',
description: 'a very awesome navidrome client',
start_url: '/',
categories: ["music", "entertainment"],
display_override: ['window-controls-overlay'],
@@ -15,7 +15,7 @@ export default function manifest(): MetadataRoute.Manifest {
{
src: '/favicon.ico',
type: 'image/x-icon',
sizes: '16x16 32x32'
sizes: '48x48'
},
{
src: '/icon-192.png',
@@ -40,5 +40,76 @@ export default function manifest(): MetadataRoute.Manifest {
purpose: 'maskable'
}
],
screenshots: [
{
src: '/home-preview.png',
sizes: '1920x1020',
type: 'image/png',
label: 'Home Preview',
form_factor: 'wide'
},
{
src: '/browse-preview.png',
sizes: '1920x1020',
type: 'image/png',
label: 'Browse Preview',
form_factor: 'wide'
},
{
src: '/album-preview.png',
sizes: '1920x1020',
type: 'image/png',
label: 'Album Preview',
form_factor: 'wide'
},
{
src: '/fullscreen-preview.png',
sizes: '1920x1020',
type: 'image/png',
label: 'Fullscreen Preview',
form_factor: 'wide'
}
],
shortcuts: [
{
name: 'Resume Song',
short_name: 'Resume',
description: 'Resume the last played song',
url: '/?action=resume',
icons: [
{
src: '/icon-192.png',
sizes: '192x192',
type: 'image/png'
}
]
},
{
name: 'Play Recent Albums',
short_name: 'Recent',
description: 'Play from recently added albums',
url: '/?action=recent',
icons: [
{
src: '/icon-192.png',
sizes: '192x192',
type: 'image/png'
}
]
},
{
name: 'Shuffle Favorites',
short_name: 'Shuffle',
description: 'Shuffle songs from favorite artists',
url: '/?action=shuffle-favorites',
icons: [
{
src: '/icon-192.png',
sizes: '192x192',
type: 'image/png'
}
]
}
]
}
}

View File

@@ -5,17 +5,25 @@ 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 { useEffect, useState, Suspense } from 'react';
import { Album } 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';
type TimeOfDay = 'morning' | 'afternoon' | 'evening';
export default function MusicPage() {
function MusicPageContent() {
const { albums, isLoading, api, isConnected } = useNavidrome();
const { playAlbum, playTrack, shuffle, toggleShuffle, addToQueue } = useAudioPlayer();
const searchParams = useSearchParams();
const [recentAlbums, setRecentAlbums] = useState<Album[]>([]);
const [newestAlbums, setNewestAlbums] = useState<Album[]>([]);
const [favoriteAlbums, setFavoriteAlbums] = useState<Album[]>([]);
const [favoritesLoading, setFavoritesLoading] = useState(true);
const [shortcutProcessed, setShortcutProcessed] = useState(false);
useEffect(() => {
if (albums.length > 0) {
@@ -45,18 +53,113 @@ export default function MusicPage() {
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';
}
// Handle PWA shortcuts
useEffect(() => {
const action = searchParams.get('action');
if (!action || shortcutProcessed || !api || !isConnected) 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 albumSongs = await api.getAlbumSongs(shuffledAlbums[i].id);
albumSongs.forEach(song => {
addToQueue({
id: song.id,
name: song.title,
url: api.getStreamUrl(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:', 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 albumSongs = await api.getAlbumSongs(shuffledFavorites[i].id);
albumSongs.forEach(song => {
addToQueue({
id: song.id,
name: song.title,
url: api.getStreamUrl(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:', error);
}
}
}
break;
}
setShortcutProcessed(true);
} catch (error) {
console.error('Failed to handle PWA shortcut:', error);
}
};
// Delay to ensure data is loaded
const timeout = setTimeout(handleShortcuts, 1000);
return () => clearTimeout(timeout);
}, [searchParams, api, isConnected, recentAlbums, favoriteAlbums, shortcutProcessed, playAlbum, playTrack, shuffle, toggleShuffle, addToQueue]);
// Try to get user name from navidrome context, fallback to 'user'
let userName = '';
@@ -68,29 +171,15 @@ 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>
</div>
<div className="p-6 pb-24 w-full">
{/* Song Recommendations Section */}
<div className="mb-8">
<SongRecommendations userName={userName} />
</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">
@@ -107,15 +196,22 @@ export default function MusicPage() {
<div className="flex space-x-4 pb-4">
{isLoading ? (
// 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 +240,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}
@@ -181,14 +284,21 @@ export default function MusicPage() {
{isLoading ? (
// 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 +314,12 @@ export default function MusicPage() {
</>
</div>
);
}
export default function MusicPage() {
return (
<Suspense fallback={<div className="p-6">Loading...</div>}>
<MusicPageContent />
</Suspense>
);
}

View File

@@ -208,7 +208,7 @@ export default function PlaylistPage() {
</div>
{/* Album Art */}
<div className="w-12 h-12 mr-4 flex-shrink-0"> <Image
<div className="w-12 h-12 mr-4 shrink-0"> <Image
src={song.coverArt && api ? api.getCoverArtUrl(song.coverArt, 100) : '/default-user.jpg'}
alt={song.album}
width={48}

View File

@@ -19,7 +19,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 +49,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}
@@ -115,7 +115,7 @@ const QueuePage: React.FC = () => {
onClick={() => skipToTrackInQueue(index)}
>
{/* Album Art with Play Indicator */}
<div className="w-12 h-12 mr-4 flex-shrink-0 relative">
<div className="w-12 h-12 mr-4 shrink-0 relative">
<Image
src={track.coverArt || '/default-user.jpg'}
alt={track.album}

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

@@ -101,7 +101,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">
@@ -136,19 +136,25 @@ export default function SearchPage() {
)}
{/* Artists */}
{searchResults.artists.length > 0 && (
{/* {searchResults.artists.length > 0 && (
<div>
<h2 className="text-2xl font-bold mb-4">Artists</h2>
<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" />
<ArtistIcon
key={artist.id}
artist={artist}
className="shrink-0 overflow-hidden"
size={190}
/>
))}
</div>
<ScrollBar orientation="horizontal" />
</ScrollArea>
</div>
)}
)} */}
{/* broken for now */}
{/* Albums */}
{searchResults.albums.length > 0 && (
@@ -160,7 +166,7 @@ export default function SearchPage() {
<AlbumArtwork
key={album.id}
album={album}
className="flex-shrink-0 w-48"
className="shrink-0 w-48"
aspectRatio="square"
width={192}
height={192}
@@ -192,7 +198,7 @@ export default function SearchPage() {
</div>
{/* Song Cover */}
<div className="flex-shrink-0"> <Image
<div className="shrink-0"> <Image
src={song.coverArt && api ? api.getCoverArtUrl(song.coverArt, 64) : '/default-user.jpg'}
alt={song.album}
width={48}

View File

@@ -10,38 +10,33 @@ 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 { useSidebarShortcuts, SidebarShortcutType } from '@/hooks/use-sidebar-shortcuts';
import { SidebarCustomization } from '@/app/components/SidebarCustomization';
import { SettingsManagement } from '@/app/components/SettingsManagement';
import { CacheManagement } from '@/app/components/CacheManagement';
import { FaServer, FaUser, FaLock, FaCheck, FaTimes, FaLastfm, FaCog } from 'react-icons/fa';
import { Settings, ExternalLink } from 'lucide-react';
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 [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 +45,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 +56,59 @@ 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);
// 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
}
// 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 +203,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 +216,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 +229,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 +240,27 @@ 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 handleLastFmAuth = () => {
@@ -234,7 +292,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 +316,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 +334,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">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<FaServer className="w-5 h-5" />
@@ -369,7 +442,7 @@ const SettingsPage = () => {
)}
{hasEnvConfig && (
<Card>
<Card className="mb-6 break-inside-avoid">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<FaServer className="w-5 h-5" />
@@ -396,7 +469,7 @@ const SettingsPage = () => {
</Card>
)}
<Card>
<Card className="mb-6 break-inside-avoid">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<FaLastfm className="w-5 h-5" />
@@ -474,7 +547,7 @@ const SettingsPage = () => {
</CardContent>
</Card> */}
<Card>
<Card className="mb-6 break-inside-avoid">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Settings className="w-5 h-5" />
@@ -486,30 +559,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">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<FaLastfm className="w-5 h-5" />
@@ -604,7 +697,22 @@ const SettingsPage = () => {
</CardContent>
</Card>
<Card>
{/* Sidebar Customization */}
<div className="break-inside-avoid mb-6">
<SidebarCustomization />
</div>
{/* Settings Management */}
<div className="break-inside-avoid mb-6">
<SettingsManagement />
</div>
{/* Cache Management */}
<div className="break-inside-avoid mb-6">
<CacheManagement />
</div>
<Card className="mb-6 break-inside-avoid">
<CardHeader>
<CardTitle>Appearance</CardTitle>
<CardDescription>
@@ -619,6 +727,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 +739,29 @@ 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">
<CardHeader>
<CardTitle>Preview</CardTitle>
<CardDescription>
@@ -666,7 +789,9 @@ const SettingsPage = () => {
</div>
</CardContent>
</Card>
</div>
</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-6 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,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}

View File

@@ -23,7 +23,7 @@ const Menubar = React.forwardRef<
<MenubarPrimitive.Root
ref={ref}
className={cn(
"flex h-9 items-center space-x-1 rounded-md border bg-background p-1 shadow-sm",
"flex h-9 items-center space-x-1 rounded-md border bg-background p-1 shadow-xs",
className
)}
{...props}
@@ -38,7 +38,7 @@ const MenubarTrigger = React.forwardRef<
<MenubarPrimitive.Trigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center rounded-sm px-3 py-1 text-sm font-medium 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-3 py-1 text-sm font-medium outline-hidden focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
className
)}
{...props}
@@ -55,7 +55,7 @@ const MenubarSubTrigger = React.forwardRef<
<MenubarPrimitive.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
)}
@@ -74,7 +74,7 @@ const MenubarSubContent = React.forwardRef<
<MenubarPrimitive.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}
@@ -97,7 +97,7 @@ const MenubarContent = React.forwardRef<
alignOffset={alignOffset}
sideOffset={sideOffset}
className={cn(
"z-50 min-w-[12rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in 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-48 overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in 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}
@@ -116,7 +116,7 @@ const MenubarItem = React.forwardRef<
<MenubarPrimitive.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
)}
@@ -132,7 +132,7 @@ const MenubarCheckboxItem = React.forwardRef<
<MenubarPrimitive.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}
@@ -155,7 +155,7 @@ const MenubarRadioItem = React.forwardRef<
<MenubarPrimitive.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

@@ -0,0 +1,168 @@
import * as React from "react"
import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu"
import { cva } from "class-variance-authority"
import { ChevronDownIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function NavigationMenu({
className,
children,
viewport = true,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Root> & {
viewport?: boolean
}) {
return (
<NavigationMenuPrimitive.Root
data-slot="navigation-menu"
data-viewport={viewport}
className={cn(
"group/navigation-menu relative flex max-w-max flex-1 items-center justify-center",
className
)}
{...props}
>
{children}
{viewport && <NavigationMenuViewport />}
</NavigationMenuPrimitive.Root>
)
}
function NavigationMenuList({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.List>) {
return (
<NavigationMenuPrimitive.List
data-slot="navigation-menu-list"
className={cn(
"group flex flex-1 list-none items-center justify-center gap-1",
className
)}
{...props}
/>
)
}
function NavigationMenuItem({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Item>) {
return (
<NavigationMenuPrimitive.Item
data-slot="navigation-menu-item"
className={cn("relative", className)}
{...props}
/>
)
}
const navigationMenuTriggerStyle = cva(
"group inline-flex h-9 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=open]:hover:bg-accent data-[state=open]:text-accent-foreground data-[state=open]:focus:bg-accent data-[state=open]:bg-accent/50 focus-visible:ring-ring/50 outline-none transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1"
)
function NavigationMenuTrigger({
className,
children,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Trigger>) {
return (
<NavigationMenuPrimitive.Trigger
data-slot="navigation-menu-trigger"
className={cn(navigationMenuTriggerStyle(), "group", className)}
{...props}
>
{children}{" "}
<ChevronDownIcon
className="relative top-[1px] ml-1 size-3 transition duration-300 group-data-[state=open]:rotate-180"
aria-hidden="true"
/>
</NavigationMenuPrimitive.Trigger>
)
}
function NavigationMenuContent({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Content>) {
return (
<NavigationMenuPrimitive.Content
data-slot="navigation-menu-content"
className={cn(
"data-[motion^=from-]:animate-in data-[motion^=to-]:animate-out data-[motion^=from-]:fade-in data-[motion^=to-]:fade-out data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 top-0 left-0 w-full p-2 pr-2.5 md:absolute md:w-auto",
"group-data-[viewport=false]/navigation-menu:bg-popover group-data-[viewport=false]/navigation-menu:text-popover-foreground group-data-[viewport=false]/navigation-menu:data-[state=open]:animate-in group-data-[viewport=false]/navigation-menu:data-[state=closed]:animate-out group-data-[viewport=false]/navigation-menu:data-[state=closed]:zoom-out-95 group-data-[viewport=false]/navigation-menu:data-[state=open]:zoom-in-95 group-data-[viewport=false]/navigation-menu:data-[state=open]:fade-in-0 group-data-[viewport=false]/navigation-menu:data-[state=closed]:fade-out-0 group-data-[viewport=false]/navigation-menu:top-full group-data-[viewport=false]/navigation-menu:mt-1.5 group-data-[viewport=false]/navigation-menu:overflow-hidden group-data-[viewport=false]/navigation-menu:rounded-md group-data-[viewport=false]/navigation-menu:border group-data-[viewport=false]/navigation-menu:shadow group-data-[viewport=false]/navigation-menu:duration-200 **:data-[slot=navigation-menu-link]:focus:ring-0 **:data-[slot=navigation-menu-link]:focus:outline-none",
className
)}
{...props}
/>
)
}
function NavigationMenuViewport({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Viewport>) {
return (
<div
className={cn(
"absolute top-full left-0 isolate z-50 flex justify-center"
)}
>
<NavigationMenuPrimitive.Viewport
data-slot="navigation-menu-viewport"
className={cn(
"origin-top-center bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 relative mt-1.5 h-[var(--radix-navigation-menu-viewport-height)] w-full overflow-hidden rounded-md border shadow md:w-[var(--radix-navigation-menu-viewport-width)]",
className
)}
{...props}
/>
</div>
)
}
function NavigationMenuLink({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Link>) {
return (
<NavigationMenuPrimitive.Link
data-slot="navigation-menu-link"
className={cn(
"data-[active=true]:focus:bg-accent data-[active=true]:hover:bg-accent data-[active=true]:bg-accent/50 data-[active=true]:text-accent-foreground hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus-visible:ring-ring/50 [&_svg:not([class*='text-'])]:text-muted-foreground flex flex-col gap-1 rounded-sm p-2 text-sm transition-all outline-none focus-visible:ring-[3px] focus-visible:outline-1 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function NavigationMenuIndicator({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Indicator>) {
return (
<NavigationMenuPrimitive.Indicator
data-slot="navigation-menu-indicator"
className={cn(
"data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in top-full z-[1] flex h-1.5 items-end justify-center overflow-hidden",
className
)}
{...props}
>
<div className="bg-border relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm shadow-md" />
</NavigationMenuPrimitive.Indicator>
)
}
export {
NavigationMenu,
NavigationMenuList,
NavigationMenuItem,
NavigationMenuContent,
NavigationMenuTrigger,
NavigationMenuLink,
NavigationMenuIndicator,
NavigationMenuViewport,
navigationMenuTriggerStyle,
}

View File

@@ -0,0 +1,127 @@
import * as React from "react"
import {
ChevronLeftIcon,
ChevronRightIcon,
MoreHorizontalIcon,
} from "lucide-react"
import { cn } from "@/lib/utils"
import { Button, buttonVariants } from "@/components/ui/button"
function Pagination({ className, ...props }: React.ComponentProps<"nav">) {
return (
<nav
role="navigation"
aria-label="pagination"
data-slot="pagination"
className={cn("mx-auto flex w-full justify-center", className)}
{...props}
/>
)
}
function PaginationContent({
className,
...props
}: React.ComponentProps<"ul">) {
return (
<ul
data-slot="pagination-content"
className={cn("flex flex-row items-center gap-1", className)}
{...props}
/>
)
}
function PaginationItem({ ...props }: React.ComponentProps<"li">) {
return <li data-slot="pagination-item" {...props} />
}
type PaginationLinkProps = {
isActive?: boolean
} & Pick<React.ComponentProps<typeof Button>, "size"> &
React.ComponentProps<"a">
function PaginationLink({
className,
isActive,
size = "icon",
...props
}: PaginationLinkProps) {
return (
<a
aria-current={isActive ? "page" : undefined}
data-slot="pagination-link"
data-active={isActive}
className={cn(
buttonVariants({
variant: isActive ? "outline" : "ghost",
size,
}),
className
)}
{...props}
/>
)
}
function PaginationPrevious({
className,
...props
}: React.ComponentProps<typeof PaginationLink>) {
return (
<PaginationLink
aria-label="Go to previous page"
size="default"
className={cn("gap-1 px-2.5 sm:pl-2.5", className)}
{...props}
>
<ChevronLeftIcon />
<span className="hidden sm:block">Previous</span>
</PaginationLink>
)
}
function PaginationNext({
className,
...props
}: React.ComponentProps<typeof PaginationLink>) {
return (
<PaginationLink
aria-label="Go to next page"
size="default"
className={cn("gap-1 px-2.5 sm:pr-2.5", className)}
{...props}
>
<span className="hidden sm:block">Next</span>
<ChevronRightIcon />
</PaginationLink>
)
}
function PaginationEllipsis({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
aria-hidden
data-slot="pagination-ellipsis"
className={cn("flex size-9 items-center justify-center", className)}
{...props}
>
<MoreHorizontalIcon className="size-4" />
<span className="sr-only">More pages</span>
</span>
)
}
export {
Pagination,
PaginationContent,
PaginationLink,
PaginationItem,
PaginationPrevious,
PaginationNext,
PaginationEllipsis,
}

48
components/ui/popover.tsx Normal file
View File

@@ -0,0 +1,48 @@
"use client"
import * as React from "react"
import * as PopoverPrimitive from "@radix-ui/react-popover"
import { cn } from "@/lib/utils"
function Popover({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
return <PopoverPrimitive.Root data-slot="popover" {...props} />
}
function PopoverTrigger({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
}
function PopoverContent({
className,
align = "center",
sideOffset = 4,
...props
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
data-slot="popover-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-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
)
}
function PopoverAnchor({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
}
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }

View File

@@ -0,0 +1,45 @@
"use client"
import * as React from "react"
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"
import { CircleIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function RadioGroup({
className,
...props
}: React.ComponentProps<typeof RadioGroupPrimitive.Root>) {
return (
<RadioGroupPrimitive.Root
data-slot="radio-group"
className={cn("grid gap-3", className)}
{...props}
/>
)
}
function RadioGroupItem({
className,
...props
}: React.ComponentProps<typeof RadioGroupPrimitive.Item>) {
return (
<RadioGroupPrimitive.Item
data-slot="radio-group-item"
className={cn(
"border-input text-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 dark:bg-input/30 aspect-square size-4 shrink-0 rounded-full border shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<RadioGroupPrimitive.Indicator
data-slot="radio-group-indicator"
className="relative flex items-center justify-center"
>
<CircleIcon className="fill-primary absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2" />
</RadioGroupPrimitive.Indicator>
</RadioGroupPrimitive.Item>
)
}
export { RadioGroup, RadioGroupItem }

View File

@@ -0,0 +1,56 @@
"use client"
import * as React from "react"
import { GripVerticalIcon } from "lucide-react"
import * as ResizablePrimitive from "react-resizable-panels"
import { cn } from "@/lib/utils"
function ResizablePanelGroup({
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelGroup>) {
return (
<ResizablePrimitive.PanelGroup
data-slot="resizable-panel-group"
className={cn(
"flex h-full w-full data-[panel-group-direction=vertical]:flex-col",
className
)}
{...props}
/>
)
}
function ResizablePanel({
...props
}: React.ComponentProps<typeof ResizablePrimitive.Panel>) {
return <ResizablePrimitive.Panel data-slot="resizable-panel" {...props} />
}
function ResizableHandle({
withHandle,
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & {
withHandle?: boolean
}) {
return (
<ResizablePrimitive.PanelResizeHandle
data-slot="resizable-handle"
className={cn(
"bg-border focus-visible:ring-ring relative flex w-px items-center justify-center after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:ring-1 focus-visible:ring-offset-1 focus-visible:outline-hidden data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:translate-x-0 data-[panel-group-direction=vertical]:after:-translate-y-1/2 [&[data-panel-group-direction=vertical]>div]:rotate-90",
className
)}
{...props}
>
{withHandle && (
<div className="bg-border z-10 flex h-4 w-3 items-center justify-center rounded-xs border">
<GripVerticalIcon className="size-2.5" />
</div>
)}
</ResizablePrimitive.PanelResizeHandle>
)
}
export { ResizablePanelGroup, ResizablePanel, ResizableHandle }

View File

@@ -33,9 +33,9 @@ const ScrollBar = React.forwardRef<
className={cn(
"flex touch-none select-none transition-colors",
orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent p-[1px]",
"h-full w-2.5 border-l border-l-transparent p-px",
orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent p-[1px]",
"h-2.5 flex-col border-t border-t-transparent p-px",
className
)}
{...props}

View File

@@ -19,7 +19,7 @@ const SelectTrigger = React.forwardRef<
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
"flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-xs ring-offset-background placeholder:text-muted-foreground focus:outline-hidden focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
className
)}
{...props}
@@ -75,7 +75,7 @@ const SelectContent = React.forwardRef<
<SelectPrimitive.Content
ref={ref}
className={cn(
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover 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",
"relative z-50 max-h-96 min-w-32 overflow-hidden rounded-md border bg-popover 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",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
@@ -88,7 +88,7 @@ const SelectContent = React.forwardRef<
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
"h-(--radix-select-trigger-height) w-full min-w-(--radix-select-trigger-width)"
)}
>
{children}
@@ -118,7 +118,7 @@ const SelectItem = React.forwardRef<
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-hidden focus:bg-accent focus:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50",
className
)}
{...props}

View File

@@ -19,7 +19,7 @@ const Separator = React.forwardRef<
orientation={orientation}
className={cn(
"shrink-0 bg-border",
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
orientation === "horizontal" ? "h-px w-full" : "h-full w-px",
className
)}
{...props}

139
components/ui/sheet.tsx Normal file
View File

@@ -0,0 +1,139 @@
"use client"
import * as React from "react"
import * as SheetPrimitive from "@radix-ui/react-dialog"
import { XIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />
}
function SheetTrigger({
...props
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
}
function SheetClose({
...props
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
}
function SheetPortal({
...props
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
}
function SheetOverlay({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
return (
<SheetPrimitive.Overlay
data-slot="sheet-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 SheetContent({
className,
children,
side = "right",
...props
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
side?: "top" | "right" | "bottom" | "left"
}) {
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
data-slot="sheet-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
side === "right" &&
"data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm",
side === "left" &&
"data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm",
side === "top" &&
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
side === "bottom" &&
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
className
)}
{...props}
>
{children}
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
<XIcon className="size-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
</SheetPrimitive.Content>
</SheetPortal>
)
}
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-header"
className={cn("flex flex-col gap-1.5 p-4", className)}
{...props}
/>
)
}
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
}
function SheetTitle({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
return (
<SheetPrimitive.Title
data-slot="sheet-title"
className={cn("text-foreground font-semibold", className)}
{...props}
/>
)
}
function SheetDescription({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
return (
<SheetPrimitive.Description
data-slot="sheet-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
export {
Sheet,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
}

726
components/ui/sidebar.tsx Normal file
View File

@@ -0,0 +1,726 @@
"use client"
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, VariantProps } from "class-variance-authority"
import { PanelLeftIcon } from "lucide-react"
import { useIsMobile } from "@/hooks/use-mobile"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Separator } from "@/components/ui/separator"
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet"
import { Skeleton } from "@/components/ui/skeleton"
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip"
const SIDEBAR_COOKIE_NAME = "sidebar_state"
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
const SIDEBAR_WIDTH = "16rem"
const SIDEBAR_WIDTH_MOBILE = "18rem"
const SIDEBAR_WIDTH_ICON = "3rem"
const SIDEBAR_KEYBOARD_SHORTCUT = "b"
type SidebarContextProps = {
state: "expanded" | "collapsed"
open: boolean
setOpen: (open: boolean) => void
openMobile: boolean
setOpenMobile: (open: boolean) => void
isMobile: boolean
toggleSidebar: () => void
}
const SidebarContext = React.createContext<SidebarContextProps | null>(null)
function useSidebar() {
const context = React.useContext(SidebarContext)
if (!context) {
throw new Error("useSidebar must be used within a SidebarProvider.")
}
return context
}
function SidebarProvider({
defaultOpen = true,
open: openProp,
onOpenChange: setOpenProp,
className,
style,
children,
...props
}: React.ComponentProps<"div"> & {
defaultOpen?: boolean
open?: boolean
onOpenChange?: (open: boolean) => void
}) {
const isMobile = useIsMobile()
const [openMobile, setOpenMobile] = React.useState(false)
// This is the internal state of the sidebar.
// We use openProp and setOpenProp for control from outside the component.
const [_open, _setOpen] = React.useState(defaultOpen)
const open = openProp ?? _open
const setOpen = React.useCallback(
(value: boolean | ((value: boolean) => boolean)) => {
const openState = typeof value === "function" ? value(open) : value
if (setOpenProp) {
setOpenProp(openState)
} else {
_setOpen(openState)
}
// This sets the cookie to keep the sidebar state.
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`
},
[setOpenProp, open]
)
// Helper to toggle the sidebar.
const toggleSidebar = React.useCallback(() => {
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open)
}, [isMobile, setOpen, setOpenMobile])
// Adds a keyboard shortcut to toggle the sidebar.
React.useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
(event.metaKey || event.ctrlKey)
) {
event.preventDefault()
toggleSidebar()
}
}
window.addEventListener("keydown", handleKeyDown)
return () => window.removeEventListener("keydown", handleKeyDown)
}, [toggleSidebar])
// We add a state so that we can do data-state="expanded" or "collapsed".
// This makes it easier to style the sidebar with Tailwind classes.
const state = open ? "expanded" : "collapsed"
const contextValue = React.useMemo<SidebarContextProps>(
() => ({
state,
open,
setOpen,
isMobile,
openMobile,
setOpenMobile,
toggleSidebar,
}),
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]
)
return (
<SidebarContext.Provider value={contextValue}>
<TooltipProvider delayDuration={0}>
<div
data-slot="sidebar-wrapper"
style={
{
"--sidebar-width": SIDEBAR_WIDTH,
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
...style,
} as React.CSSProperties
}
className={cn(
"group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full",
className
)}
{...props}
>
{children}
</div>
</TooltipProvider>
</SidebarContext.Provider>
)
}
function Sidebar({
side = "left",
variant = "sidebar",
collapsible = "offcanvas",
className,
children,
...props
}: React.ComponentProps<"div"> & {
side?: "left" | "right"
variant?: "sidebar" | "floating" | "inset"
collapsible?: "offcanvas" | "icon" | "none"
}) {
const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
if (collapsible === "none") {
return (
<div
data-slot="sidebar"
className={cn(
"bg-sidebar text-sidebar-foreground flex h-full w-(--sidebar-width) flex-col",
className
)}
{...props}
>
{children}
</div>
)
}
if (isMobile) {
return (
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
<SheetContent
data-sidebar="sidebar"
data-slot="sidebar"
data-mobile="true"
className="bg-sidebar text-sidebar-foreground w-(--sidebar-width) p-0 [&>button]:hidden"
style={
{
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
} as React.CSSProperties
}
side={side}
>
<SheetHeader className="sr-only">
<SheetTitle>Sidebar</SheetTitle>
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
</SheetHeader>
<div className="flex h-full w-full flex-col">{children}</div>
</SheetContent>
</Sheet>
)
}
return (
<div
className="group peer text-sidebar-foreground hidden md:block"
data-state={state}
data-collapsible={state === "collapsed" ? collapsible : ""}
data-variant={variant}
data-side={side}
data-slot="sidebar"
>
{/* This is what handles the sidebar gap on desktop */}
<div
data-slot="sidebar-gap"
className={cn(
"relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear",
"group-data-[collapsible=offcanvas]:w-0",
"group-data-[side=right]:rotate-180",
variant === "floating" || variant === "inset"
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon)"
)}
/>
<div
data-slot="sidebar-container"
className={cn(
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex",
side === "left"
? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
: "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
// Adjust the padding for floating and inset variants.
variant === "floating" || variant === "inset"
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l",
className
)}
{...props}
>
<div
data-sidebar="sidebar"
data-slot="sidebar-inner"
className="bg-sidebar group-data-[variant=floating]:border-sidebar-border flex h-full w-full flex-col group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:shadow-sm"
>
{children}
</div>
</div>
</div>
)
}
function SidebarTrigger({
className,
onClick,
...props
}: React.ComponentProps<typeof Button>) {
const { toggleSidebar } = useSidebar()
return (
<Button
data-sidebar="trigger"
data-slot="sidebar-trigger"
variant="ghost"
size="icon"
className={cn("size-7", className)}
onClick={(event) => {
onClick?.(event)
toggleSidebar()
}}
{...props}
>
<PanelLeftIcon />
<span className="sr-only">Toggle Sidebar</span>
</Button>
)
}
function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
const { toggleSidebar } = useSidebar()
return (
<button
data-sidebar="rail"
data-slot="sidebar-rail"
aria-label="Toggle Sidebar"
tabIndex={-1}
onClick={toggleSidebar}
title="Toggle Sidebar"
className={cn(
"hover:after:bg-sidebar-border absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] sm:flex",
"in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
"hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full",
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
className
)}
{...props}
/>
)
}
function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
return (
<main
data-slot="sidebar-inset"
className={cn(
"bg-background relative flex w-full flex-1 flex-col",
"md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2",
className
)}
{...props}
/>
)
}
function SidebarInput({
className,
...props
}: React.ComponentProps<typeof Input>) {
return (
<Input
data-slot="sidebar-input"
data-sidebar="input"
className={cn("bg-background h-8 w-full shadow-none", className)}
{...props}
/>
)
}
function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-header"
data-sidebar="header"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
)
}
function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-footer"
data-sidebar="footer"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
)
}
function SidebarSeparator({
className,
...props
}: React.ComponentProps<typeof Separator>) {
return (
<Separator
data-slot="sidebar-separator"
data-sidebar="separator"
className={cn("bg-sidebar-border mx-2 w-auto", className)}
{...props}
/>
)
}
function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-content"
data-sidebar="content"
className={cn(
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
className
)}
{...props}
/>
)
}
function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-group"
data-sidebar="group"
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
{...props}
/>
)
}
function SidebarGroupLabel({
className,
asChild = false,
...props
}: React.ComponentProps<"div"> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "div"
return (
<Comp
data-slot="sidebar-group-label"
data-sidebar="group-label"
className={cn(
"text-sidebar-foreground/70 ring-sidebar-ring flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium outline-hidden transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
className
)}
{...props}
/>
)
}
function SidebarGroupAction({
className,
asChild = false,
...props
}: React.ComponentProps<"button"> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "button"
return (
<Comp
data-slot="sidebar-group-action"
data-sidebar="group-action"
className={cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 md:after:hidden",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
)
}
function SidebarGroupContent({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-group-content"
data-sidebar="group-content"
className={cn("w-full text-sm", className)}
{...props}
/>
)
}
function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
return (
<ul
data-slot="sidebar-menu"
data-sidebar="menu"
className={cn("flex w-full min-w-0 flex-col gap-1", className)}
{...props}
/>
)
}
function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
return (
<li
data-slot="sidebar-menu-item"
data-sidebar="menu-item"
className={cn("group/menu-item relative", className)}
{...props}
/>
)
}
const sidebarMenuButtonVariants = cva(
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-hidden ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
{
variants: {
variant: {
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
outline:
"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
},
size: {
default: "h-8 text-sm",
sm: "h-7 text-xs",
lg: "h-12 text-sm group-data-[collapsible=icon]:p-0!",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function SidebarMenuButton({
asChild = false,
isActive = false,
variant = "default",
size = "default",
tooltip,
className,
...props
}: React.ComponentProps<"button"> & {
asChild?: boolean
isActive?: boolean
tooltip?: string | React.ComponentProps<typeof TooltipContent>
} & VariantProps<typeof sidebarMenuButtonVariants>) {
const Comp = asChild ? Slot : "button"
const { isMobile, state } = useSidebar()
const button = (
<Comp
data-slot="sidebar-menu-button"
data-sidebar="menu-button"
data-size={size}
data-active={isActive}
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
{...props}
/>
)
if (!tooltip) {
return button
}
if (typeof tooltip === "string") {
tooltip = {
children: tooltip,
}
}
return (
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent
side="right"
align="center"
hidden={state !== "collapsed" || isMobile}
{...tooltip}
/>
</Tooltip>
)
}
function SidebarMenuAction({
className,
asChild = false,
showOnHover = false,
...props
}: React.ComponentProps<"button"> & {
asChild?: boolean
showOnHover?: boolean
}) {
const Comp = asChild ? Slot : "button"
return (
<Comp
data-slot="sidebar-menu-action"
data-sidebar="menu-action"
className={cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground peer-hover/menu-button:text-sidebar-accent-foreground absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 md:after:hidden",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
showOnHover &&
"peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0",
className
)}
{...props}
/>
)
}
function SidebarMenuBadge({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-menu-badge"
data-sidebar="menu-badge"
className={cn(
"text-sidebar-foreground pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums select-none",
"peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
)
}
function SidebarMenuSkeleton({
className,
showIcon = false,
...props
}: React.ComponentProps<"div"> & {
showIcon?: boolean
}) {
// Random width between 50 to 90%.
const width = React.useMemo(() => {
return `${Math.floor(Math.random() * 40) + 50}%`
}, [])
return (
<div
data-slot="sidebar-menu-skeleton"
data-sidebar="menu-skeleton"
className={cn("flex h-8 items-center gap-2 rounded-md px-2", className)}
{...props}
>
{showIcon && (
<Skeleton
className="size-4 rounded-md"
data-sidebar="menu-skeleton-icon"
/>
)}
<Skeleton
className="h-4 max-w-(--skeleton-width) flex-1"
data-sidebar="menu-skeleton-text"
style={
{
"--skeleton-width": width,
} as React.CSSProperties
}
/>
</div>
)
}
function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
return (
<ul
data-slot="sidebar-menu-sub"
data-sidebar="menu-sub"
className={cn(
"border-sidebar-border mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l px-2.5 py-0.5",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
)
}
function SidebarMenuSubItem({
className,
...props
}: React.ComponentProps<"li">) {
return (
<li
data-slot="sidebar-menu-sub-item"
data-sidebar="menu-sub-item"
className={cn("group/menu-sub-item relative", className)}
{...props}
/>
)
}
function SidebarMenuSubButton({
asChild = false,
size = "md",
isActive = false,
className,
...props
}: React.ComponentProps<"a"> & {
asChild?: boolean
size?: "sm" | "md"
isActive?: boolean
}) {
const Comp = asChild ? Slot : "a"
return (
<Comp
data-slot="sidebar-menu-sub-button"
data-sidebar="menu-sub-button"
data-size={size}
data-active={isActive}
className={cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground [&>svg]:text-sidebar-accent-foreground flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 outline-hidden focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
size === "sm" && "text-xs",
size === "md" && "text-sm",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
)
}
export {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupAction,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarInput,
SidebarInset,
SidebarMenu,
SidebarMenuAction,
SidebarMenuBadge,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSkeleton,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
SidebarProvider,
SidebarRail,
SidebarSeparator,
SidebarTrigger,
useSidebar,
}

View File

@@ -0,0 +1,13 @@
import { cn } from "@/lib/utils"
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="skeleton"
className={cn("bg-accent animate-pulse rounded-md", className)}
{...props}
/>
)
}
export { Skeleton }

63
components/ui/slider.tsx Normal file
View File

@@ -0,0 +1,63 @@
"use client"
import * as React from "react"
import * as SliderPrimitive from "@radix-ui/react-slider"
import { cn } from "@/lib/utils"
function Slider({
className,
defaultValue,
value,
min = 0,
max = 100,
...props
}: React.ComponentProps<typeof SliderPrimitive.Root>) {
const _values = React.useMemo(
() =>
Array.isArray(value)
? value
: Array.isArray(defaultValue)
? defaultValue
: [min, max],
[value, defaultValue, min, max]
)
return (
<SliderPrimitive.Root
data-slot="slider"
defaultValue={defaultValue}
value={value}
min={min}
max={max}
className={cn(
"relative flex w-full touch-none items-center select-none data-[disabled]:opacity-50 data-[orientation=vertical]:h-full data-[orientation=vertical]:min-h-44 data-[orientation=vertical]:w-auto data-[orientation=vertical]:flex-col",
className
)}
{...props}
>
<SliderPrimitive.Track
data-slot="slider-track"
className={cn(
"bg-muted relative grow overflow-hidden rounded-full data-[orientation=horizontal]:h-1.5 data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-1.5"
)}
>
<SliderPrimitive.Range
data-slot="slider-range"
className={cn(
"bg-primary absolute data-[orientation=horizontal]:h-full data-[orientation=vertical]:w-full"
)}
/>
</SliderPrimitive.Track>
{Array.from({ length: _values.length }, (_, index) => (
<SliderPrimitive.Thumb
data-slot="slider-thumb"
key={index}
className="border-primary bg-background ring-ring/50 block size-4 shrink-0 rounded-full border shadow-sm transition-[color,box-shadow] hover:ring-4 focus-visible:ring-4 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50"
/>
))}
</SliderPrimitive.Root>
)
}
export { Slider }

25
components/ui/sonner.tsx Normal file
View File

@@ -0,0 +1,25 @@
"use client"
import { useTheme } from "next-themes"
import { Toaster as Sonner, ToasterProps } from "sonner"
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme()
return (
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
style={
{
"--normal-bg": "var(--popover)",
"--normal-text": "var(--popover-foreground)",
"--normal-border": "var(--border)",
} as React.CSSProperties
}
{...props}
/>
)
}
export { Toaster }

31
components/ui/switch.tsx Normal file
View File

@@ -0,0 +1,31 @@
"use client"
import * as React from "react"
import * as SwitchPrimitive from "@radix-ui/react-switch"
import { cn } from "@/lib/utils"
function Switch({
className,
...props
}: React.ComponentProps<typeof SwitchPrimitive.Root>) {
return (
<SwitchPrimitive.Root
data-slot="switch"
className={cn(
"peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<SwitchPrimitive.Thumb
data-slot="switch-thumb"
className={cn(
"bg-background dark:data-[state=unchecked]:bg-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block size-4 rounded-full ring-0 transition-transform data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0"
)}
/>
</SwitchPrimitive.Root>
)
}
export { Switch }

116
components/ui/table.tsx Normal file
View File

@@ -0,0 +1,116 @@
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
function Table({ className, ...props }: React.ComponentProps<"table">) {
return (
<div
data-slot="table-container"
className="relative w-full overflow-x-auto"
>
<table
data-slot="table"
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
)
}
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
return (
<thead
data-slot="table-header"
className={cn("[&_tr]:border-b", className)}
{...props}
/>
)
}
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
return (
<tbody
data-slot="table-body"
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
)
}
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
return (
<tfoot
data-slot="table-footer"
className={cn(
"bg-muted/50 border-t font-medium [&>tr]:last:border-b-0",
className
)}
{...props}
/>
)
}
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
return (
<tr
data-slot="table-row"
className={cn(
"hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors",
className
)}
{...props}
/>
)
}
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
return (
<th
data-slot="table-head"
className={cn(
"text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
)
}
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
return (
<td
data-slot="table-cell"
className={cn(
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
)
}
function TableCaption({
className,
...props
}: React.ComponentProps<"caption">) {
return (
<caption
data-slot="table-caption"
className={cn("text-muted-foreground mt-4 text-sm", className)}
{...props}
/>
)
}
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}

View File

@@ -29,7 +29,7 @@ const TabsTrigger = React.forwardRef<
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow",
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
className
)}
{...props}
@@ -44,7 +44,7 @@ const TabsContent = React.forwardRef<
<TabsPrimitive.Content
ref={ref}
className={cn(
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
"mt-2 ring-offset-background focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
className
)}
{...props}

View File

@@ -0,0 +1,18 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
{...props}
/>
)
}
export { Textarea }

View File

@@ -16,7 +16,7 @@ const ToastViewport = React.forwardRef<
<ToastPrimitives.Viewport
ref={ref}
className={cn(
"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
"fixed top-0 z-100 flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
className
)}
{...props}
@@ -25,7 +25,7 @@ const ToastViewport = React.forwardRef<
ToastViewport.displayName = ToastPrimitives.Viewport.displayName
const toastVariants = cva(
"group pointer-events-auto relative flex w-full items-center justify-between space-x-2 overflow-hidden rounded-md border p-4 pr-6 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
"group pointer-events-auto relative flex w-full items-center justify-between space-x-2 overflow-hidden rounded-md border p-4 pr-6 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-(--radix-toast-swipe-end-x) data-[swipe=move]:translate-x-(--radix-toast-swipe-move-x) data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
{
variants: {
variant: {
@@ -62,7 +62,7 @@ const ToastAction = React.forwardRef<
<ToastPrimitives.Action
ref={ref}
className={cn(
"inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium transition-colors hover:bg-secondary focus:outline-none focus:ring-1 focus:ring-ring disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive",
"inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium transition-colors hover:bg-secondary focus:outline-hidden focus:ring-1 focus:ring-ring disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 hover:group-[.destructive]:border-destructive/30 hover:group-[.destructive]:bg-destructive hover:group-[.destructive]:text-destructive-foreground focus:group-[.destructive]:ring-destructive",
className
)}
{...props}
@@ -77,7 +77,7 @@ const ToastClose = React.forwardRef<
<ToastPrimitives.Close
ref={ref}
className={cn(
"absolute right-1 top-1 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-1 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600",
"absolute right-1 top-1 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-hidden focus:ring-1 group-hover:opacity-100 group-[.destructive]:text-red-300 hover:group-[.destructive]:text-red-50 focus:group-[.destructive]:ring-red-400 focus:group-[.destructive]:ring-offset-red-600",
className
)}
toast-close=""

View File

@@ -0,0 +1,73 @@
"use client"
import * as React from "react"
import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group"
import { type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { toggleVariants } from "@/components/ui/toggle"
const ToggleGroupContext = React.createContext<
VariantProps<typeof toggleVariants>
>({
size: "default",
variant: "default",
})
function ToggleGroup({
className,
variant,
size,
children,
...props
}: React.ComponentProps<typeof ToggleGroupPrimitive.Root> &
VariantProps<typeof toggleVariants>) {
return (
<ToggleGroupPrimitive.Root
data-slot="toggle-group"
data-variant={variant}
data-size={size}
className={cn(
"group/toggle-group flex w-fit items-center rounded-md data-[variant=outline]:shadow-xs",
className
)}
{...props}
>
<ToggleGroupContext.Provider value={{ variant, size }}>
{children}
</ToggleGroupContext.Provider>
</ToggleGroupPrimitive.Root>
)
}
function ToggleGroupItem({
className,
children,
variant,
size,
...props
}: React.ComponentProps<typeof ToggleGroupPrimitive.Item> &
VariantProps<typeof toggleVariants>) {
const context = React.useContext(ToggleGroupContext)
return (
<ToggleGroupPrimitive.Item
data-slot="toggle-group-item"
data-variant={context.variant || variant}
data-size={context.size || size}
className={cn(
toggleVariants({
variant: context.variant || variant,
size: context.size || size,
}),
"min-w-0 flex-1 shrink-0 rounded-none shadow-none first:rounded-l-md last:rounded-r-md focus:z-10 focus-visible:z-10 data-[variant=outline]:border-l-0 data-[variant=outline]:first:border-l",
className
)}
{...props}
>
{children}
</ToggleGroupPrimitive.Item>
)
}
export { ToggleGroup, ToggleGroupItem }

47
components/ui/toggle.tsx Normal file
View File

@@ -0,0 +1,47 @@
"use client"
import * as React from "react"
import * as TogglePrimitive from "@radix-ui/react-toggle"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const toggleVariants = cva(
"inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium hover:bg-muted hover:text-muted-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] outline-none transition-[color,box-shadow] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive whitespace-nowrap",
{
variants: {
variant: {
default: "bg-transparent",
outline:
"border border-input bg-transparent shadow-xs hover:bg-accent hover:text-accent-foreground",
},
size: {
default: "h-9 px-2 min-w-9",
sm: "h-8 px-1.5 min-w-8",
lg: "h-10 px-2.5 min-w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Toggle({
className,
variant,
size,
...props
}: React.ComponentProps<typeof TogglePrimitive.Root> &
VariantProps<typeof toggleVariants>) {
return (
<TogglePrimitive.Root
data-slot="toggle"
className={cn(toggleVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Toggle, toggleVariants }

61
components/ui/tooltip.tsx Normal file
View File

@@ -0,0 +1,61 @@
"use client"
import * as React from "react"
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
import { cn } from "@/lib/utils"
function TooltipProvider({
delayDuration = 0,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delayDuration={delayDuration}
{...props}
/>
)
}
function Tooltip({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return (
<TooltipProvider>
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
</TooltipProvider>
)
}
function TooltipTrigger({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
}
function TooltipContent({
className,
sideOffset = 0,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
"bg-primary text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-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-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
className
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="bg-primary fill-primary z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
)
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }

View File

@@ -3,7 +3,7 @@ version: '3.8'
services:
mice:
container_name: mice-public
image: ghcr.io/sillyangel/mice:latest
image: sillyangel/mice:latest
ports:
- "40625:40625"
environment:

View File

@@ -2,7 +2,7 @@ version: '3.8'
services:
mice:
image: ghcr.io/sillyangel/mice:latest
image: sillyangel/mice:latest
ports:
- "${HOST_PORT:-3000}:${PORT:-3000}"
environment:

View File

View File

@@ -0,0 +1,79 @@
'use client';
import { useState, useEffect } from 'react';
import { useNavidrome } from '@/app/components/NavidromeContext';
export interface FavoriteAlbum {
id: string;
name: string;
artist: string;
coverArt?: string;
}
export function useFavoriteAlbums() {
const [favoriteAlbums, setFavoriteAlbums] = useState<FavoriteAlbum[]>([]);
const { api } = useNavidrome();
// Load favorite albums from localStorage
useEffect(() => {
const saved = localStorage.getItem('favorite-albums');
if (saved) {
try {
setFavoriteAlbums(JSON.parse(saved));
} catch (error) {
console.error('Failed to parse favorite albums:', error);
}
}
}, []);
// Save to localStorage when favorites change
useEffect(() => {
localStorage.setItem('favorite-albums', JSON.stringify(favoriteAlbums));
}, [favoriteAlbums]);
const addFavoriteAlbum = (album: FavoriteAlbum) => {
setFavoriteAlbums(prev => {
const exists = prev.some(fav => fav.id === album.id);
if (exists) return prev;
return [...prev, album].slice(0, 10); // Keep only 10 favorites
});
};
const removeFavoriteAlbum = (albumId: string) => {
setFavoriteAlbums(prev => prev.filter(fav => fav.id !== albumId));
};
const isFavoriteAlbum = (albumId: string) => {
return favoriteAlbums.some(fav => fav.id === albumId);
};
const toggleFavoriteAlbum = async (albumId: string) => {
if (!api) return;
try {
if (isFavoriteAlbum(albumId)) {
removeFavoriteAlbum(albumId);
} else {
// Fetch album details to add to favorites
const { album } = await api.getAlbum(albumId);
const favoriteAlbum: FavoriteAlbum = {
id: album.id,
name: album.name,
artist: album.artist,
coverArt: album.coverArt ? api.getCoverArtUrl(album.coverArt, 64) : undefined
};
addFavoriteAlbum(favoriteAlbum);
}
} catch (error) {
console.error('Failed to toggle favorite album:', error);
}
};
return {
favoriteAlbums,
addFavoriteAlbum,
removeFavoriteAlbum,
isFavoriteAlbum,
toggleFavoriteAlbum
};
}

110
hooks/use-library-cache.ts Normal file
View File

@@ -0,0 +1,110 @@
'use client';
import { useState, useEffect } from 'react';
interface LibraryCacheItem<T> {
data: T;
timestamp: number;
expiresAt: number;
}
export function useLibraryCache<T>(
key: string,
fetcher: () => Promise<T>,
ttl: number = 30 * 60 * 1000 // 30 minutes default
) {
const [data, setData] = useState<T | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
const getCacheKey = (key: string) => `library-cache-${key}`;
const getFromCache = (key: string): T | null => {
if (typeof window === 'undefined') return null;
try {
const cached = localStorage.getItem(getCacheKey(key));
if (!cached) return null;
const item: LibraryCacheItem<T> = JSON.parse(cached);
// Check if expired
if (Date.now() > item.expiresAt) {
localStorage.removeItem(getCacheKey(key));
return null;
}
return item.data;
} catch (error) {
console.warn('Failed to get cached data:', error);
localStorage.removeItem(getCacheKey(key));
return null;
}
};
const setToCache = (key: string, data: T, ttl: number) => {
if (typeof window === 'undefined') return;
const item: LibraryCacheItem<T> = {
data,
timestamp: Date.now(),
expiresAt: Date.now() + ttl
};
try {
localStorage.setItem(getCacheKey(key), JSON.stringify(item));
} catch (error) {
console.warn('Failed to cache data:', error);
}
};
useEffect(() => {
const loadData = async () => {
setLoading(true);
setError(null);
// Check cache first
const cached = getFromCache(key);
if (cached) {
setData(cached);
setLoading(false);
return;
}
// Fetch fresh data
try {
const result = await fetcher();
setData(result);
setToCache(key, result, ttl);
} catch (err) {
setError(err as Error);
} finally {
setLoading(false);
}
};
loadData();
}, [key, ttl]);
const refresh = async () => {
setLoading(true);
setError(null);
try {
const result = await fetcher();
setData(result);
setToCache(key, result, ttl);
} catch (err) {
setError(err as Error);
} finally {
setLoading(false);
}
};
const clearCache = () => {
if (typeof window === 'undefined') return;
localStorage.removeItem(getCacheKey(key));
};
return { data, loading, error, refresh, clearCache };
}

19
hooks/use-mobile.ts Normal file
View File

@@ -0,0 +1,19 @@
import * as React from "react"
const MOBILE_BREAKPOINT = 768
export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
React.useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
const onChange = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
}
mql.addEventListener("change", onChange)
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
return () => mql.removeEventListener("change", onChange)
}, [])
return !!isMobile
}

View File

@@ -0,0 +1,36 @@
'use client';
import { useState, useEffect } from 'react';
import { Album } from '@/lib/navidrome';
import { useNavidrome } from '@/app/components/NavidromeContext';
export function useRecentlyPlayedAlbums() {
const [recentAlbums, setRecentAlbums] = useState<Album[]>([]);
const [loading, setLoading] = useState(true);
const { api } = useNavidrome();
const fetchRecentAlbums = async () => {
if (!api) return;
try {
setLoading(true);
const albums = await api.getAlbums('recent', 5, 0);
setRecentAlbums(albums);
} catch (error) {
console.error('Failed to fetch recent albums:', error);
setRecentAlbums([]);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchRecentAlbums();
}, [api]);
return {
recentAlbums,
loading,
refetch: fetchRecentAlbums
};
}

230
hooks/use-sidebar-layout.ts Normal file
View File

@@ -0,0 +1,230 @@
'use client';
import { useState, useEffect } from 'react';
export type SidebarItemType =
| 'search'
| 'home'
| 'queue'
| 'radio'
| 'artists'
| 'albums'
| 'playlists'
| 'favorites'
| 'browse'
| 'songs'
| 'history'
| 'settings';
export interface SidebarItem {
id: SidebarItemType;
label: string;
visible: boolean;
icon: string; // We'll use this for icon identification
href: string; // Navigation path
}
export interface SidebarLayoutSettings {
items: SidebarItem[];
shortcuts: 'albums' | 'playlists' | 'both';
showIcons: boolean;
}
const defaultSidebarItems: SidebarItem[] = [
{ id: 'search', label: 'Search', visible: true, icon: 'search', href: '/search' },
{ id: 'home', label: 'Home', visible: true, icon: 'home', href: '/' },
{ id: 'queue', label: 'Queue', visible: true, icon: 'queue', href: '/queue' },
{ id: 'radio', label: 'Radio', visible: true, icon: 'radio', href: '/radio' },
{ id: 'artists', label: 'Artists', visible: true, icon: 'artists', href: '/library/artists' },
{ id: 'albums', label: 'Albums', visible: true, icon: 'albums', href: '/library/albums' },
{ id: 'playlists', label: 'Playlists', visible: true, icon: 'playlists', href: '/library/playlists' },
{ id: 'favorites', label: 'Favorites', visible: true, icon: 'favorites', href: '/favorites' },
{ id: 'browse', label: 'Browse', visible: true, icon: 'browse', href: '/browse' },
{ id: 'songs', label: 'Songs', visible: true, icon: 'songs', href: '/library/songs' },
{ id: 'history', label: 'History', visible: true, icon: 'history', href: '/history' },
{ id: 'settings', label: 'Settings', visible: true, icon: 'settings', href: '/settings' },
];
const defaultSettings: SidebarLayoutSettings = {
items: defaultSidebarItems,
shortcuts: 'both',
showIcons: true,
};
export function useSidebarLayout() {
const [settings, setSettings] = useState<SidebarLayoutSettings>(defaultSettings);
const [pendingSettings, setPendingSettings] = useState<SidebarLayoutSettings | null>(null);
// Load settings from localStorage on mount
useEffect(() => {
const saved = localStorage.getItem('sidebar-layout-settings');
if (saved) {
try {
const parsed = JSON.parse(saved);
// Merge with defaults to ensure all items are present
const mergedItems = defaultSidebarItems.map(defaultItem => {
const savedItem = parsed.items?.find((item: SidebarItem) => item.id === defaultItem.id);
return savedItem ? { ...defaultItem, ...savedItem } : defaultItem;
});
const loadedSettings = {
items: mergedItems,
shortcuts: parsed.shortcuts || defaultSettings.shortcuts,
showIcons: parsed.showIcons !== undefined ? parsed.showIcons : defaultSettings.showIcons,
};
setSettings(loadedSettings);
} catch (error) {
console.error('Failed to parse sidebar layout settings:', error);
}
}
}, []);
const saveSettings = (newSettings: SidebarLayoutSettings) => {
setSettings(newSettings);
setPendingSettings(null);
localStorage.setItem('sidebar-layout-settings', JSON.stringify(newSettings));
};
const updatePendingSettings = (newSettings: SidebarLayoutSettings) => {
setPendingSettings(newSettings);
};
const getCurrentSettings = () => pendingSettings || settings;
const hasUnsavedChanges = () => pendingSettings !== null;
const reorderItems = (activeId: string, overId: string) => {
const currentSettings = getCurrentSettings();
const activeIndex = currentSettings.items.findIndex(item => item.id === activeId);
const overIndex = currentSettings.items.findIndex(item => item.id === overId);
if (activeIndex !== -1 && overIndex !== -1) {
const newItems = [...currentSettings.items];
const [removed] = newItems.splice(activeIndex, 1);
newItems.splice(overIndex, 0, removed);
const newSettings = { ...currentSettings, items: newItems };
updatePendingSettings(newSettings);
}
};
const updateItemOrder = (newItems: SidebarItem[]) => {
const currentSettings = getCurrentSettings();
const newSettings = { ...currentSettings, items: newItems };
updatePendingSettings(newSettings);
};
const toggleItemVisibility = (itemId: SidebarItemType) => {
const currentSettings = getCurrentSettings();
const newItems = currentSettings.items.map(item =>
item.id === itemId ? { ...item, visible: !item.visible } : item
);
const newSettings = { ...currentSettings, items: newItems };
updatePendingSettings(newSettings);
};
const updateShortcuts = (shortcuts: 'albums' | 'playlists' | 'both') => {
const currentSettings = getCurrentSettings();
const newSettings = { ...currentSettings, shortcuts };
updatePendingSettings(newSettings);
};
const updateShowIcons = (showIcons: boolean) => {
const currentSettings = getCurrentSettings();
const newSettings = { ...currentSettings, showIcons };
updatePendingSettings(newSettings);
};
const applyChanges = () => {
if (pendingSettings) {
saveSettings(pendingSettings);
}
};
const discardChanges = () => {
setPendingSettings(null);
};
const resetToDefaults = () => {
saveSettings(defaultSettings);
};
const exportSettings = () => {
const allSettings = {
sidebarLayout: settings,
sidebarVisible: localStorage.getItem('sidebar-visible'),
theme: localStorage.getItem('theme'),
// Add other settings as needed
};
const blob = new Blob([JSON.stringify(allSettings, null, 2)], {
type: 'application/json'
});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `stillnavidrome-settings-${new Date().toISOString().split('T')[0]}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
};
const importSettings = (file: File) => {
return new Promise<void>((resolve, reject) => {
const reader = new FileReader();
reader.onload = (e) => {
try {
const imported = JSON.parse(e.target?.result as string);
if (imported.sidebarLayout) {
const mergedItems = defaultSidebarItems.map(defaultItem => {
const importedItem = imported.sidebarLayout.items?.find(
(item: SidebarItem) => item.id === defaultItem.id
);
return importedItem ? { ...defaultItem, ...importedItem } : defaultItem;
});
setSettings({
items: mergedItems,
shortcuts: imported.sidebarLayout.shortcuts || defaultSettings.shortcuts,
showIcons: imported.sidebarLayout.showIcons !== undefined
? imported.sidebarLayout.showIcons
: defaultSettings.showIcons,
});
}
if (imported.sidebarVisible !== undefined) {
localStorage.setItem('sidebar-visible', imported.sidebarVisible);
}
if (imported.theme) {
localStorage.setItem('theme', imported.theme);
}
resolve();
} catch (error) {
reject(error);
}
};
reader.onerror = () => reject(new Error('Failed to read file'));
reader.readAsText(file);
});
};
return {
settings: getCurrentSettings(),
hasUnsavedChanges,
updateItemOrder,
reorderItems,
toggleItemVisibility,
updateShortcuts,
updateShowIcons,
applyChanges,
discardChanges,
exportSettings,
importSettings,
resetToDefaults,
};
}

View File

@@ -0,0 +1,29 @@
'use client';
import { useState, useEffect } from 'react';
export type SidebarShortcutType = 'playlists' | 'albums' | 'both';
export function useSidebarShortcuts() {
const [shortcutType, setShortcutType] = useState<SidebarShortcutType>('both');
useEffect(() => {
// Load preference from localStorage
const savedType = localStorage.getItem('sidebar-shortcut-type');
if (savedType && ['playlists', 'albums', 'both'].includes(savedType)) {
setShortcutType(savedType as SidebarShortcutType);
}
}, []);
const updateShortcutType = (type: SidebarShortcutType) => {
setShortcutType(type);
localStorage.setItem('sidebar-shortcut-type', type);
};
return {
shortcutType,
updateShortcutType,
showPlaylists: shortcutType === 'playlists' || shortcutType === 'both',
showAlbums: shortcutType === 'albums' || shortcutType === 'both'
};
}

258
lib/cache.ts Normal file
View File

@@ -0,0 +1,258 @@
'use client';
// Types for caching (simplified versions to avoid circular imports)
interface Album {
id: string;
name: string;
artist: string;
artistId: string;
coverArt?: string;
songCount: number;
duration: number;
playCount?: number;
created: string;
starred?: string;
year?: number;
genre?: string;
}
interface Artist {
id: string;
name: string;
albumCount: number;
starred?: string;
coverArt?: string;
}
interface Song {
id: string;
parent: string;
isDir: boolean;
title: string;
artist?: string;
artistId?: string;
album?: string;
albumId?: string;
year?: number;
genre?: string;
coverArt?: string;
size?: number;
contentType?: string;
suffix?: string;
starred?: string;
duration?: number;
bitRate?: number;
path?: string;
playCount?: number;
created: string;
}
export interface CacheItem<T> {
data: T;
timestamp: number;
expiresAt: number;
}
export interface CacheConfig {
defaultTTL: number; // Time to live in milliseconds
maxSize: number; // Maximum number of items in cache
}
class Cache<T> {
private cache = new Map<string, CacheItem<T>>();
private config: CacheConfig;
constructor(config: CacheConfig = { defaultTTL: 24 * 60 * 60 * 1000, maxSize: 1000 }) {
this.config = config;
}
set(key: string, data: T, ttl?: number): void {
const now = Date.now();
const expiresAt = now + (ttl || this.config.defaultTTL);
// Remove expired items before adding new one
this.cleanup();
// If cache is at max size, remove oldest item
if (this.cache.size >= this.config.maxSize) {
const oldestKey = this.cache.keys().next().value;
if (oldestKey) {
this.cache.delete(oldestKey);
}
}
this.cache.set(key, {
data,
timestamp: now,
expiresAt
});
}
get(key: string): T | null {
const item = this.cache.get(key);
if (!item) return null;
// Check if item has expired
if (Date.now() > item.expiresAt) {
this.cache.delete(key);
return null;
}
return item.data;
}
has(key: string): boolean {
return this.get(key) !== null;
}
delete(key: string): boolean {
return this.cache.delete(key);
}
clear(): void {
this.cache.clear();
}
size(): number {
this.cleanup();
return this.cache.size;
}
keys(): string[] {
this.cleanup();
return Array.from(this.cache.keys());
}
private cleanup(): void {
const now = Date.now();
for (const [key, item] of this.cache.entries()) {
if (now > item.expiresAt) {
this.cache.delete(key);
}
}
}
// Get cache statistics
getStats() {
this.cleanup();
const items = Array.from(this.cache.values());
const totalSize = items.length;
const oldestItem = items.reduce((oldest, item) =>
!oldest || item.timestamp < oldest.timestamp ? item : oldest, null as CacheItem<T> | null);
const newestItem = items.reduce((newest, item) =>
!newest || item.timestamp > newest.timestamp ? item : newest, null as CacheItem<T> | null);
return {
size: totalSize,
maxSize: this.config.maxSize,
oldestTimestamp: oldestItem?.timestamp,
newestTimestamp: newestItem?.timestamp,
defaultTTL: this.config.defaultTTL
};
}
}
// Specific cache instances
export const albumCache = new Cache<Album[]>({ defaultTTL: 24 * 60 * 60 * 1000, maxSize: 500 }); // 24 hours
export const artistCache = new Cache<Artist[]>({ defaultTTL: 24 * 60 * 60 * 1000, maxSize: 200 }); // 24 hours
export const songCache = new Cache<Song[]>({ defaultTTL: 12 * 60 * 60 * 1000, maxSize: 1000 }); // 12 hours
export const imageCache = new Cache<string>({ defaultTTL: 7 * 24 * 60 * 60 * 1000, maxSize: 1000 }); // 7 days for image URLs
// Cache management utilities
export const CacheManager = {
clearAll() {
albumCache.clear();
artistCache.clear();
songCache.clear();
imageCache.clear();
// Also clear localStorage cache data
if (typeof window !== 'undefined') {
const keys = Object.keys(localStorage);
keys.forEach(key => {
if (key.startsWith('cache-') || key.startsWith('library-cache-')) {
localStorage.removeItem(key);
}
});
}
},
getStats() {
return {
albums: albumCache.getStats(),
artists: artistCache.getStats(),
songs: songCache.getStats(),
images: imageCache.getStats()
};
},
getCacheSizeBytes() {
if (typeof window === 'undefined') return 0;
let size = 0;
const keys = Object.keys(localStorage);
keys.forEach(key => {
if (key.startsWith('cache-') || key.startsWith('library-cache-')) {
size += localStorage.getItem(key)?.length || 0;
}
});
return size;
}
};
// Persistent cache for localStorage
export const PersistentCache = {
set<T>(key: string, data: T, ttl: number = 24 * 60 * 60 * 1000): void {
if (typeof window === 'undefined') return;
const item: CacheItem<T> = {
data,
timestamp: Date.now(),
expiresAt: Date.now() + ttl
};
try {
localStorage.setItem(`cache-${key}`, JSON.stringify(item));
} catch (error) {
console.warn('Failed to store in localStorage cache:', error);
}
},
get<T>(key: string): T | null {
if (typeof window === 'undefined') return null;
try {
const stored = localStorage.getItem(`cache-${key}`);
if (!stored) return null;
const item: CacheItem<T> = JSON.parse(stored);
// Check if expired
if (Date.now() > item.expiresAt) {
localStorage.removeItem(`cache-${key}`);
return null;
}
return item.data;
} catch (error) {
console.warn('Failed to read from localStorage cache:', error);
return null;
}
},
delete(key: string): void {
if (typeof window === 'undefined') return;
localStorage.removeItem(`cache-${key}`);
},
clear(): void {
if (typeof window === 'undefined') return;
const keys = Object.keys(localStorage);
keys.forEach(key => {
if (key.startsWith('cache-')) {
localStorage.removeItem(key);
}
});
}
};

View File

@@ -1,4 +1,5 @@
import crypto from 'crypto';
import { albumCache, artistCache, songCache, imageCache, PersistentCache } from './cache';
export interface NavidromeConfig {
serverUrl: string;

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