├── .env.sample ├── .eslintrc.json ├── .github └── workflows │ ├── pr-release.yml │ └── release.yml ├── .gitignore ├── .releaserc ├── CONTRIBUTING.md ├── Dockerfile ├── README.md ├── bun.lockb ├── components.json ├── docker-compose-all-immich.yml ├── docker-compose.build.yml ├── docker-compose.yml ├── drizzle.config.json ├── next.config.mjs ├── package-lock.json ├── package.json ├── postcss.config.mjs ├── public ├── android-chrome-192x192.png ├── android-chrome-512x512.png ├── apple-touch-icon.png ├── audio │ └── rewind.mp3 ├── favicon-16x16.png ├── favicon-32x32.png ├── favicon.ico ├── favicon.png └── site.webmanifest ├── remotion ├── Composition.tsx ├── Root.tsx ├── index.ts └── rewind │ ├── AlbumScene.tsx │ ├── CountryScene.tsx │ ├── FriendsScene.tsx │ ├── MemoriesScene.tsx │ ├── Powertoolslogo.tsx │ └── WelcomeScene.tsx ├── screenshots ├── screenshot-1.png └── screenshot-infra.jpg ├── src ├── components │ ├── albums │ │ ├── AlbumSelectorDialog.tsx │ │ ├── info │ │ │ ├── AlbumImages.tsx │ │ │ └── AlbumPeople.tsx │ │ ├── list │ │ │ └── AlbumThumbnail.tsx │ │ ├── potential-albums │ │ │ ├── PotentialAlbumsAssets.tsx │ │ │ ├── PotentialAlbumsDates.tsx │ │ │ └── PotentialDateItem.tsx │ │ └── share │ │ │ └── AlbumShareDialog.tsx │ ├── analytics │ │ └── exif │ │ │ ├── AssetHeatMap.tsx │ │ │ └── EXIFDistribution.tsx │ ├── assets │ │ ├── assets-options │ │ │ └── AssetOffsetDialog.tsx │ │ └── missing-location │ │ │ ├── MissingLocationAssets.tsx │ │ │ ├── MissingLocationDateItem.tsx │ │ │ ├── MissingLocationDates.tsx │ │ │ └── TagMissingLocationDialog │ │ │ ├── CustomMarker.tsx │ │ │ ├── Map.tsx │ │ │ ├── TagMissingLocationDialog.tsx │ │ │ ├── TagMissingLocationOSMSearchAndAdd.tsx │ │ │ ├── TagMissingLocationSearchAndAdd.tsx │ │ │ └── TagMissingLocationSearchLatLong.tsx │ ├── auth │ │ └── LoginForm.tsx │ ├── find │ │ ├── FindInput.tsx │ │ ├── findInputStyle.ts │ │ └── findMentionStyle.ts │ ├── layouts │ │ ├── PageLayout.tsx │ │ └── RootLayout.tsx │ ├── people │ │ ├── PeopleFilters.tsx │ │ ├── PeopleList.tsx │ │ ├── PersonBirthdayCell.tsx │ │ ├── PersonHideCell.tsx │ │ ├── PersonItem.tsx │ │ ├── PersonMergeDropdown.tsx │ │ ├── info │ │ │ ├── PersonAlbumList.tsx │ │ │ └── PersonCityList.tsx │ │ └── merge │ │ │ └── FaceThumbnail.tsx │ ├── shared │ │ ├── AlbumDropdown.tsx │ │ ├── AssetGrid.tsx │ │ ├── AssetsBulkDeleteButton.tsx │ │ ├── ErrorBlock.tsx │ │ ├── FloatingBar.tsx │ │ ├── Header.tsx │ │ ├── PeopleDropdown.tsx │ │ ├── PeopleList.tsx │ │ ├── ProfileInfo.tsx │ │ ├── ShareAssetsTrigger.tsx │ │ ├── Sidebar.tsx │ │ └── ThemeSwitcher.tsx │ └── ui │ │ ├── accordion.tsx │ │ ├── alert-dialog.tsx │ │ ├── autocomplete.tsx │ │ ├── avatar.tsx │ │ ├── badge.tsx │ │ ├── button.tsx │ │ ├── calendar.tsx │ │ ├── card.tsx │ │ ├── chart.tsx │ │ ├── checkbox.tsx │ │ ├── combobox.tsx │ │ ├── command.tsx │ │ ├── datepicker.tsx │ │ ├── dialog.tsx │ │ ├── dropdown-menu.tsx │ │ ├── hover-card.tsx │ │ ├── input.tsx │ │ ├── label.tsx │ │ ├── lazy-grid-image.tsx │ │ ├── lazy-image.tsx │ │ ├── linechardots.tsx │ │ ├── loader.tsx │ │ ├── pagination.tsx │ │ ├── pie-chart.tsx │ │ ├── popover.tsx │ │ ├── radarchartdpts.tsx │ │ ├── scroll-area.tsx │ │ ├── select.tsx │ │ ├── sheet.tsx │ │ ├── skeleton.tsx │ │ ├── switch.tsx │ │ ├── table.tsx │ │ ├── tabs.tsx │ │ ├── toast.tsx │ │ ├── tooltip.tsx │ │ └── use-toast.ts ├── config │ ├── app.config.ts │ ├── constants │ │ ├── chart.constant.ts │ │ └── sidebarNavs.tsx │ ├── db.ts │ ├── environment.ts │ ├── queryClient.ts │ └── routes.ts ├── contexts │ ├── ConfigContext.tsx │ ├── CurrentUserContext.tsx │ ├── ExifFiltersContext.tsx │ ├── PeopleFilterContext.ts │ └── PhotoSelectionContext.tsx ├── handlers │ ├── api │ │ ├── album.handler.ts │ │ ├── analytics.handler.ts │ │ ├── asset.handler.ts │ │ ├── common.handler.ts │ │ ├── people.handler.ts │ │ ├── person.handler.ts │ │ ├── shareLink.handler.ts │ │ └── user.handler.ts │ └── serverUtils │ │ └── user.utils.ts ├── helpers │ ├── asset.helper.ts │ ├── data.helper.ts │ ├── date.helper.ts │ ├── gemini.helper.ts │ ├── person.helper.ts │ ├── string.helper.ts │ └── user.helper.ts ├── lib │ ├── api.ts │ ├── cookie.ts │ └── utils.ts ├── pages │ ├── 404.tsx │ ├── _app.tsx │ ├── _document.tsx │ ├── albums │ │ ├── [albumId].tsx │ │ ├── index.tsx │ │ └── potential-albums.tsx │ ├── analytics │ │ └── exif.tsx │ ├── api │ │ ├── albums │ │ │ ├── [id] │ │ │ │ ├── assets.ts │ │ │ │ ├── info.ts │ │ │ │ ├── people.ts │ │ │ │ └── public-info.ts │ │ │ ├── delete.ts │ │ │ ├── list.ts │ │ │ ├── potential-albums-assets.ts │ │ │ ├── potential-albums-dates.ts │ │ │ └── share.ts │ │ ├── analytics │ │ │ ├── exif │ │ │ │ ├── [property].ts │ │ │ │ └── storage.ts │ │ │ └── statistics │ │ │ │ ├── heatmap.tsx │ │ │ │ └── livephoto.tsx │ │ ├── assets │ │ │ ├── geo-heatmap.ts │ │ │ ├── missing-location-albums.ts │ │ │ ├── missing-location-assets.ts │ │ │ └── missing-location-dates.ts │ │ ├── filters │ │ │ └── asset-filters.ts │ │ ├── find │ │ │ └── search.ts │ │ ├── health.ts │ │ ├── immich-proxy │ │ │ ├── [...path].ts │ │ │ ├── asset │ │ │ │ ├── share-thumbnail │ │ │ │ │ └── [id].ts │ │ │ │ ├── thumbnail │ │ │ │ │ └── [id].ts │ │ │ │ └── video │ │ │ │ │ └── [id].ts │ │ │ └── thumbnail │ │ │ │ └── [id].ts │ │ ├── people │ │ │ ├── [id] │ │ │ │ ├── info.ts │ │ │ │ └── similar-faces.ts │ │ │ └── list.ts │ │ ├── rewind │ │ │ └── stats.ts │ │ ├── share-link │ │ │ ├── [token] │ │ │ │ ├── assets.ts │ │ │ │ ├── download.ts │ │ │ │ ├── index.ts │ │ │ │ └── people.ts │ │ │ └── generate.ts │ │ └── users │ │ │ ├── login.ts │ │ │ ├── logout.ts │ │ │ └── me.ts │ ├── assets │ │ ├── geo-heatmap.tsx │ │ └── missing-locations.tsx │ ├── find │ │ └── index.tsx │ ├── index.tsx │ ├── people │ │ └── [personId].tsx │ ├── rewind.tsx │ └── s │ │ └── [token].tsx ├── schema │ ├── albumAssetsAssets.schema.ts │ ├── albums.schema.ts │ ├── assetFaces.schema.ts │ ├── assets.schema.ts │ ├── exif.schema.ts │ ├── faceSearch.schema.ts │ ├── index.ts │ ├── person.schema.ts │ ├── relationships.ts │ └── users.schema.ts ├── styles │ └── globals.scss └── types │ ├── album.d.ts │ ├── asset.d.ts │ ├── common.d.ts │ ├── person.d.ts │ ├── shareLink.d.ts │ └── user.d.ts ├── tailwind.config.ts └── tsconfig.json /.env.sample: -------------------------------------------------------------------------------- 1 | IMMICH_URL="" # Immich URL 2 | IMMICH_API_KEY="" # Immich API Key 3 | DB_USERNAME="" # Postgress Database Username 4 | DB_PASSWORD="" # Postgres Database Password 5 | DB_HOST="" # Postgres Host (IP address or hostname of the database) 6 | DB_PORT="" # Postgres Port number (Default: 5432) 7 | DB_DATABASE_NAME="" # Name of the database 8 | 9 | # Additional configuration 10 | SECURE_COOKIE=false # Set to true to enable secure cookies 11 | 12 | # Optional 13 | GOOGLE_MAPS_API_KEY="" # Google Maps API Key for heatmap 14 | GEMINI_API_KEY="" # Gemini API Key for parsing search query in "Find" 15 | 16 | # Immich Share Link 17 | IMMICH_SHARE_LINK_KEY="" # Share link key for Immich 18 | POWER_TOOLS_ENDPOINT_URL="" # URL of the Power Tools endpoint (Used for share links) 19 | JWT_SECRET="" # JWT Secret for authentication 20 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals", 3 | "plugins": ["@remotion"], 4 | "overrides": [ 5 | { 6 | "files": ["remotion/*.{ts,tsx}"], 7 | "extends": ["plugin:@remotion/recommended"] 8 | } 9 | ], 10 | "rules": { 11 | "react-hooks/exhaustive-deps": "off", 12 | "@next/next/no-img-element": "off" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /.github/workflows/pr-release.yml: -------------------------------------------------------------------------------- 1 | name: Build and Tag Docker Image on PR 2 | 3 | on: 4 | pull_request: 5 | types: [opened, synchronize] 6 | 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | permissions: 11 | contents: read 12 | packages: write 13 | 14 | steps: 15 | - uses: actions/checkout@v3 16 | 17 | - uses: docker/setup-buildx-action@v3 18 | 19 | - uses: docker/login-action@v3 20 | with: 21 | registry: ghcr.io 22 | username: ${{ github.actor }} 23 | password: ${{ secrets.GITHUB_TOKEN }} 24 | 25 | - name: Extract PR number 26 | id: pr-number 27 | run: echo "PR_NUMBER=${{ github.event.number }}" >> $GITHUB_ENV 28 | 29 | - name: Build and push Docker image 30 | run: | 31 | docker build --build-arg VERSION=pr-${{ env.PR_NUMBER }} \ 32 | -t ghcr.io/${{ github.repository }}:pr-${{ env.PR_NUMBER }} . 33 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | release: 10 | # Only run on the original repository, not forks 11 | if: ${{ github.repository_owner == 'varun-raj' }} 12 | runs-on: ubuntu-latest 13 | permissions: 14 | contents: write 15 | outputs: 16 | new-release-published: ${{ steps.release.outputs.new-release-published }} 17 | new-release-version: ${{ steps.release.outputs.new-release-version }} 18 | steps: 19 | - uses: actions/checkout@v3 20 | - uses: actions/setup-node@v3 21 | with: 22 | node-version: 20 23 | - name: Run semantic-release 24 | env: 25 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 26 | id: release 27 | run: | 28 | npm install 29 | npx semantic-release 30 | 31 | build: 32 | runs-on: ubuntu-latest 33 | permissions: 34 | contents: read 35 | packages: write 36 | needs: release 37 | if: always() && !failure() 38 | steps: 39 | - uses: actions/checkout@v3 40 | - uses: docker/metadata-action@v5 41 | name: Docker meta 42 | id: meta 43 | with: 44 | images: | 45 | ghcr.io/${{ github.repository }} 46 | tags: | 47 | type=raw,value=latest,enable=${{ github.repository_owner != 'varun-raj' || needs.release.outputs.new-release-published == 'true' }} 48 | type=semver,pattern={{major}}.{{minor}}.{{patch}},enable=${{ github.repository_owner == 'varun-raj' && needs.release.outputs.new-release-published == 'true' }} 49 | type=raw,value=${{ needs.release.outputs.new-release-version }},enable=${{ needs.release.outputs.new-release-version != '' }} 50 | - uses: docker/setup-buildx-action@v3 51 | - uses: docker/login-action@v3 52 | with: 53 | registry: ghcr.io 54 | username: ${{ github.actor }} 55 | password: ${{ secrets.GITHUB_TOKEN }} 56 | - name: Build and push 57 | id: docker-build 58 | uses: docker/build-push-action@v6 59 | with: 60 | context: . 61 | platforms: linux/amd64,linux/arm64 62 | push: true 63 | tags: ${{ steps.meta.outputs.tags }} 64 | labels: ${{ steps.meta.outputs.labels }} 65 | cache-from: type=gha 66 | cache-to: type=gha,mode=max 67 | build-args: | 68 | VERSION=${{ needs.release.outputs.new-release-version || 'dev' }} -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | .yarn/install-state.gz 8 | 9 | # testing 10 | /coverage 11 | 12 | # next.js 13 | /.next/ 14 | /out/ 15 | 16 | # production 17 | /build 18 | 19 | # misc 20 | .DS_Store 21 | *.pem 22 | 23 | # debug 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | 28 | # local env files 29 | .env*.local 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | next-env.d.ts 37 | 38 | .env -------------------------------------------------------------------------------- /.releaserc: -------------------------------------------------------------------------------- 1 | { 2 | "branches": ["main"], 3 | "plugins": [ 4 | [ 5 | "@semantic-release/commit-analyzer", 6 | { 7 | "preset": "angular", 8 | "releaseRules": [ 9 | { "type": "docs", "release": false }, 10 | { "type": "refactor", "release": false}, 11 | { "type": "feat", "release": "minor" }, 12 | { "type": "fix", "release": "patch" }, 13 | { "type": "perf", "release": "patch" }, 14 | { "type": "chore", "release": false } 15 | ] 16 | } 17 | ], 18 | [ 19 | "@semantic-release/release-notes-generator", 20 | { 21 | "preset": "angular", 22 | "writerOpts":{ 23 | "headerPartial":"## {{#if isPatch~}} {{~/if~}} 🚀 **Immich Power Tools** {{version}}{{~#if isPatch~}} {{~/if}}" 24 | } 25 | } 26 | ], 27 | [ 28 | "@semantic-release/npm", 29 | { 30 | "npmPublish": false 31 | } 32 | ], 33 | [ 34 | "@semantic-release/github", 35 | { 36 | "assets": [] 37 | } 38 | ], 39 | [ 40 | "@semantic-release/git", 41 | { 42 | "assets": ["package.json"], 43 | "message": "chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}" 44 | } 45 | ], 46 | ["semantic-release-export-data"] 47 | 48 | ] 49 | } 50 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to Immich Power Tools 2 | 3 | The code is pretty straight forward and easy to understand. Please just make sure you follow the below guidelines so that its easy for me to review and merge your PR. 4 | 5 | 6 | ### Structure 7 | 8 | - `components` - All the UI components 9 | - `config` - All the configuration files and constants 10 | - `config/routes.ts` - All the routes for the application 11 | - `helpers` - All the helper functions for both data and modules 12 | - `lib` - All the utility functions 13 | - `pages` - All the pages based on [Next.js](https://nextjs.org/)'s page based routing 14 | - `styles` - All the global styles (We don't touch this much as we are using Tailwind CSS) 15 | - `types` - All the typescript types and interfaces (Make sure you create `.d.ts` files for each module) 16 | 17 | ### API Infrastructure 18 | 19 | Since Immich API needs to send auth token in the headers, I've created a proxy api that matches the Immich's APIs path to forward the request to Immich's API with it's token (Which is stored in the `.env` file). This is done to avoid CORS issues and Security issues. 20 | 21 | You can find the API route here `pages/api/immich-proxy/[...path].ts` 22 | 23 | ![Immich Proxy API](./screenshots/screenshot-infra.jpg) 24 | 25 | 26 | ### Thubmnails 27 | 28 | Since the `thumbnailPath` of Immich API doesnt give us a direct link to the image, I've created a proxy api that fetches the image and sends it back as a response. 29 | 30 | **For Person's Thumbnail** 31 | You can find the API route here `pages/api/immich-proxy/thumbnail/[id].ts` -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Source: https://github.com/vercel/next.js/blob/canary/examples/with-docker/README.md 2 | 3 | # Install dependencies only when needed 4 | FROM node:18-alpine AS deps 5 | # Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed. 6 | 7 | RUN apk add --no-cache libc6-compat 8 | WORKDIR /app 9 | COPY package.json ./ 10 | RUN npm install --frozen-lockfile 11 | 12 | # Rebuild the source code only when needed 13 | FROM node:18-alpine AS builder 14 | 15 | WORKDIR /app 16 | COPY --from=deps /app/node_modules ./node_modules 17 | COPY . . 18 | RUN npm run build 19 | 20 | # Production image, copy all the files and run next 21 | FROM node:18-alpine AS runner 22 | WORKDIR /app 23 | # Define a build argument 24 | ARG VERSION=dev 25 | 26 | # Set the build argument as an environment variable 27 | ENV VERSION=$VERSION 28 | 29 | ENV NODE_ENV=production 30 | 31 | RUN addgroup -g 1001 -S nodejs 32 | RUN adduser -S nextjs -u 1001 33 | 34 | # You only need to copy next.config.js if you are NOT using the default configuration 35 | # COPY --from=builder /app/next.config.js ./ 36 | COPY --from=builder /app/public ./public 37 | COPY --from=builder /app/package.json ./package.json 38 | 39 | # Automatically leverage output traces to reduce image size 40 | # https://nextjs.org/docs/advanced-features/output-file-tracing 41 | COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ 42 | COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static 43 | 44 | USER nextjs 45 | 46 | EXPOSE 3000 47 | 48 | ENV PORT=3000 49 | 50 | # Next.js collects completely anonymous telemetry data about general usage. 51 | # Learn more here: https://nextjs.org/telemetry 52 | # Uncomment the following line in case you want to disable telemetry. 53 | ENV NEXT_TELEMETRY_DISABLED=1 54 | 55 | CMD ["node", "server.js"] -------------------------------------------------------------------------------- /bun.lockb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/varun-raj/immich-power-tools/c8f773a10f5f88ab0c0db78157a39cb70695b5bf/bun.lockb -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "new-york", 4 | "rsc": false, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.ts", 8 | "css": "src/styles/globals.css", 9 | "baseColor": "neutral", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils" 16 | } 17 | } -------------------------------------------------------------------------------- /docker-compose-all-immich.yml: -------------------------------------------------------------------------------- 1 | name: immich 2 | 3 | services: 4 | immich-server: 5 | container_name: immich_server 6 | image: ghcr.io/immich-app/immich-server:${IMMICH_VERSION:-release} 7 | volumes: 8 | - ${UPLOAD_LOCATION}:/usr/src/app/upload 9 | - /etc/localtime:/etc/localtime:ro 10 | env_file: 11 | - .env 12 | ports: 13 | - 2283:3001 14 | depends_on: 15 | - redis 16 | - database 17 | restart: always 18 | 19 | immich-machine-learning: 20 | container_name: immich_machine_learning 21 | # For hardware acceleration, add one of -[armnn, cuda, openvino] to the image tag. 22 | # Example tag: ${IMMICH_VERSION:-release}-cuda 23 | image: ghcr.io/immich-app/immich-machine-learning:${IMMICH_VERSION:-release} 24 | # extends: # uncomment this section for hardware acceleration - see https://immich.app/docs/features/ml-hardware-acceleration 25 | # file: hwaccel.ml.yml 26 | # service: cpu # set to one of [armnn, cuda, openvino, openvino-wsl] for accelerated inference - use the `-wsl` version for WSL2 where applicable 27 | volumes: 28 | - model-cache:/cache 29 | env_file: 30 | - .env 31 | restart: always 32 | 33 | redis: 34 | container_name: immich_redis 35 | image: registry.hub.docker.com/library/redis:6.2-alpine@sha256:84882e87b54734154586e5f8abd4dce69fe7311315e2fc6d67c29614c8de2672 36 | restart: always 37 | 38 | database: 39 | container_name: immich_postgres 40 | image: registry.hub.docker.com/tensorchord/pgvecto-rs:pg14-v0.2.0@sha256:90724186f0a3517cf6914295b5ab410db9ce23190a2d9d0b9dd6463e3fa298f0 41 | environment: 42 | POSTGRES_PASSWORD: ${DB_PASSWORD} 43 | POSTGRES_USER: ${DB_USERNAME} 44 | POSTGRES_DB: ${DB_DATABASE_NAME} 45 | POSTGRES_INITDB_ARGS: '--data-checksums' 46 | ports: 47 | - 5432:5432 48 | volumes: 49 | - ${DB_DATA_LOCATION}:/var/lib/postgresql/data 50 | restart: always 51 | command: ["postgres", "-c" ,"shared_preload_libraries=vectors.so", "-c", 'search_path="$$user", public, vectors', "-c", "logging_collector=on", "-c", "max_wal_size=2GB", "-c", "shared_buffers=512MB", "-c", "wal_compression=on"] 52 | 53 | power-tools: 54 | container_name: immich_power_tools 55 | image: ghcr.io/varun-raj/immich-power-tools:latest 56 | ports: 57 | - "8001:3000" 58 | env_file: 59 | - .env 60 | healthcheck: 61 | test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3000/api/health"] 62 | interval: 30s 63 | timeout: 10s 64 | retries: 3 65 | start_period: 40s 66 | 67 | volumes: 68 | model-cache: 69 | 70 | networks: 71 | hexabase-network: 72 | external: true 73 | -------------------------------------------------------------------------------- /docker-compose.build.yml: -------------------------------------------------------------------------------- 1 | version: '3.8' 2 | 3 | services: 4 | immich_power_tools: 5 | container_name: immich_power_tools 6 | build: 7 | context: . 8 | dockerfile: Dockerfile 9 | ports: 10 | - "3000:3000" 11 | env_file: 12 | - .env 13 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.8' 2 | 3 | services: 4 | immich_power_tools: 5 | container_name: immich_power_tools 6 | image: ghcr.io/varun-raj/immich-power-tools:latest 7 | ports: 8 | - "3000:3000" 9 | env_file: 10 | - .env 11 | healthcheck: 12 | test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3000/api/health"] 13 | interval: 30s 14 | timeout: 10s 15 | retries: 3 16 | start_period: 40s 17 | -------------------------------------------------------------------------------- /drizzle.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "dialect": "postgresql", 3 | "schema": "./src/schema", 4 | "breakpoints": false, 5 | "url": "postgresql://postgres:postgres-aedf4b0c1d50@192.168.0.200:5432/immich", 6 | "dbCredentials": { 7 | "user": "postgres", 8 | "password": "postgres-aedf4b0c1d50", 9 | "host": "192.168.0.200" 10 | } 11 | } -------------------------------------------------------------------------------- /next.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | 3 | const nextConfig = { 4 | reactStrictMode: false, 5 | output: 'standalone', 6 | images: { 7 | unoptimized: true, 8 | }, 9 | env: { 10 | VERSION: process.env.VERSION, 11 | }, 12 | }; 13 | 14 | export default nextConfig; 15 | -------------------------------------------------------------------------------- /postcss.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('postcss-load-config').Config} */ 2 | const config = { 3 | plugins: { 4 | tailwindcss: {}, 5 | autoprefixer: {}, 6 | }, 7 | }; 8 | 9 | export default config; 10 | -------------------------------------------------------------------------------- /public/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/varun-raj/immich-power-tools/c8f773a10f5f88ab0c0db78157a39cb70695b5bf/public/android-chrome-192x192.png -------------------------------------------------------------------------------- /public/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/varun-raj/immich-power-tools/c8f773a10f5f88ab0c0db78157a39cb70695b5bf/public/android-chrome-512x512.png -------------------------------------------------------------------------------- /public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/varun-raj/immich-power-tools/c8f773a10f5f88ab0c0db78157a39cb70695b5bf/public/apple-touch-icon.png -------------------------------------------------------------------------------- /public/audio/rewind.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/varun-raj/immich-power-tools/c8f773a10f5f88ab0c0db78157a39cb70695b5bf/public/audio/rewind.mp3 -------------------------------------------------------------------------------- /public/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/varun-raj/immich-power-tools/c8f773a10f5f88ab0c0db78157a39cb70695b5bf/public/favicon-16x16.png -------------------------------------------------------------------------------- /public/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/varun-raj/immich-power-tools/c8f773a10f5f88ab0c0db78157a39cb70695b5bf/public/favicon-32x32.png -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/varun-raj/immich-power-tools/c8f773a10f5f88ab0c0db78157a39cb70695b5bf/public/favicon.ico -------------------------------------------------------------------------------- /public/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/varun-raj/immich-power-tools/c8f773a10f5f88ab0c0db78157a39cb70695b5bf/public/favicon.png -------------------------------------------------------------------------------- /public/site.webmanifest: -------------------------------------------------------------------------------- 1 | {"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"} -------------------------------------------------------------------------------- /remotion/Composition.tsx: -------------------------------------------------------------------------------- 1 | import { AbsoluteFill, useCurrentFrame, interpolate, staticFile, Audio, Sequence } from "remotion"; 2 | import WelcomeScene from "./rewind/WelcomeScene"; 3 | import CountryScene from "./rewind/CountryScene"; 4 | import MemoriesScene from "./rewind/MemoriesScene"; 5 | import AlbumsScene from "./rewind/AlbumScene"; 6 | import FriendsScene from "./rewind/FriendsScene"; 7 | import PowertoolsLogo from "./rewind/Powertoolslogo"; 8 | 9 | export interface Scene { 10 | message: string; 11 | type: string; 12 | emoji: string; 13 | data: any; 14 | } 15 | export const IntroComposition = ({ scenes }: { scenes: Scene[] }) => { 16 | const frame = useCurrentFrame(); 17 | const screenDuration = 120; 18 | 19 | const renderScene = (scene: Scene) => { 20 | switch (scene.type) { 21 | case "WELCOME": 22 | return ; 23 | case "COUNTRY": 24 | return ; 25 | case "MEMORY": 26 | return ; 27 | case "ALBUM": 28 | return ; 29 | case "FRIEND": 30 | return ; 31 | default: 32 | return ; 33 | } 34 | }; 35 | 36 | return ( 37 | 46 | 47 |
54 | 55 |
56 |
57 | 58 | {scenes.map((scene, index) => ( 59 | 65 | {renderScene(scene)} 66 | 67 | ))} 68 | 69 |
71 | ); 72 | }; -------------------------------------------------------------------------------- /remotion/Root.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Composition } from 'remotion'; 3 | import { IntroComposition, Scene } from './Composition'; 4 | 5 | export const scenes: Scene[] = [ 6 | { message: "Welcome Varun!", 7 | type: "WELCOME", 8 | emoji: "👋", 9 | data: {}, 10 | 11 | }, 12 | { 13 | message: "2024! So much memories!", 14 | type: "TITLE", 15 | emoji: "📅", 16 | data: { 17 | photos: 100, 18 | }, 19 | 20 | }, 21 | { 22 | message: "You've been to 10 new countries!", type: "COUNTRY", emoji: "🌍", data: { 23 | countries: ["India", "USA", "Canada", "UK", "Australia", "New Zealand", "Singapore", "Malaysia", "Thailand", "Japan", "South Korea"], 24 | }, 25 | 26 | }, 27 | { 28 | message: "And 25 new cities!", type: "CITY", emoji: "🏙️", data: { 29 | cities: 25, 30 | }, 31 | 32 | }, 33 | { 34 | message: "Here are some of my favorite memories from this year!", type: "MEMORY", emoji: "🎉", data: { 35 | photos: 100, 36 | images: [ 37 | "https://picsum.photos/id/237/200/300", 38 | "https://picsum.photos/id/238/200/300", 39 | "https://picsum.photos/id/239/200/300", 40 | "https://picsum.photos/id/240/200/300", 41 | "https://picsum.photos/id/241/200/300", 42 | ], 43 | }, 44 | 45 | }, 46 | { 47 | message: "The most memory filled album of all time!", type: "ALBUM", emoji: "📸", data: { 48 | photos: 100, 49 | albums: [ 50 | { 51 | name: "Ladakh 2024", 52 | cover: "https://picsum.photos/id/400/200/300", 53 | }, 54 | { 55 | name: "Kerala 2024", 56 | cover: "https://picsum.photos/id/401/200/300", 57 | }, 58 | { 59 | name: "Mumbai 2024", 60 | cover: "https://picsum.photos/id/402/200/300", 61 | }, 62 | { 63 | name: "Kerala 2023", 64 | cover: "https://picsum.photos/id/403/200/300", 65 | }, 66 | ], 67 | }, 68 | 69 | }, 70 | { 71 | message: "You made so much memory with Soundarapandian", type: "FRIEND", emoji: "👯", data: { 72 | friends: [ 73 | { 74 | name: "Soundarapandian", 75 | cover: "https://i.pravatar.cc/150?img=64", 76 | }, 77 | { 78 | name: "Varun", 79 | cover: "https://i.pravatar.cc/150?img=65", 80 | }, 81 | ], 82 | }, 83 | }, 84 | { 85 | message: "Lets see what 2025 has in store!", type: "END", emoji: "🚀", data: { 86 | 87 | }, 88 | }, 89 | ] 90 | export const RemotionRoot: React.FC = () => { 91 | return ( 92 | <> 93 | 102 | 103 | ); 104 | }; -------------------------------------------------------------------------------- /remotion/index.ts: -------------------------------------------------------------------------------- 1 | import { registerRoot } from "remotion"; 2 | import { RemotionRoot } from "./Root"; 3 | 4 | registerRoot(RemotionRoot); 5 | -------------------------------------------------------------------------------- /remotion/rewind/AlbumScene.tsx: -------------------------------------------------------------------------------- 1 | import React, { useMemo } from 'react'; 2 | import { Img, spring, useCurrentFrame, useVideoConfig } from 'remotion'; 3 | 4 | interface AlbumSceneProps { 5 | message: string; 6 | emoji: string; 7 | data: { 8 | photos: number; 9 | albums: { 10 | name: string; 11 | cover: string; 12 | }[]; 13 | }; 14 | } 15 | 16 | export default function AlbumScene({ message, emoji, data }: AlbumSceneProps) { 17 | const frame = useCurrentFrame(); 18 | const { fps } = useVideoConfig(); 19 | 20 | const albums = useMemo(() => data.albums, [data.albums]); 21 | 22 | return ( 23 |
32 |
38 | {emoji} 39 |

46 | {message} 47 |

48 |
49 | 50 |
57 | {albums.map((album, index) => { 58 | const delay = index * 5; 59 | const scale = spring({ 60 | frame: frame - delay, 61 | fps, 62 | from: 0, 63 | to: 1, 64 | config: { 65 | damping: 12, 66 | }, 67 | }); 68 | 69 | return ( 70 |
77 | {album.name} 88 |

95 | {album.name} 96 |

97 |
98 | ); 99 | })} 100 |
101 |
102 | ); 103 | } 104 | -------------------------------------------------------------------------------- /remotion/rewind/CountryScene.tsx: -------------------------------------------------------------------------------- 1 | import React, { useMemo } from 'react'; 2 | import { spring, useCurrentFrame, useVideoConfig } from 'remotion'; 3 | 4 | interface CountrySceneProps { 5 | message: string; 6 | emoji: string; 7 | data: { 8 | countries: string[]; 9 | }; 10 | } 11 | 12 | export default function CountryScene({ message, emoji, data }: CountrySceneProps) { 13 | const frame = useCurrentFrame(); 14 | const { fps } = useVideoConfig(); 15 | 16 | const countries = useMemo(() => data.countries, [data.countries]); 17 | 18 | return ( 19 |
27 |
33 | 36 | {emoji} 37 | 38 |

44 | {message} 45 |

46 |
47 | 48 |
55 | {countries.map((country, index) => { 56 | const delay = index * 5; 57 | const scale = spring({ 58 | frame: frame - delay, 59 | fps, 60 | from: 0, 61 | to: 1, 62 | config: { 63 | damping: 12, 64 | }, 65 | }); 66 | 67 | return ( 68 |
78 | {country} 79 |
80 | ); 81 | })} 82 |
83 |
84 | ); 85 | } 86 | -------------------------------------------------------------------------------- /remotion/rewind/WelcomeScene.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { spring, useCurrentFrame, useVideoConfig, interpolate } from 'remotion' 3 | 4 | interface WelcomeSceneProps { 5 | message: string; 6 | emoji: string; 7 | } 8 | 9 | export default function WelcomeScene({ message, emoji }: WelcomeSceneProps) { 10 | const frame = useCurrentFrame(); 11 | const { fps } = useVideoConfig(); 12 | 13 | const scale = spring({ 14 | frame, 15 | fps, 16 | from: 0.5, 17 | to: 1, 18 | config: { damping: 12 } 19 | }); 20 | 21 | const opacity = spring({ 22 | frame, 23 | fps, 24 | from: 0, 25 | to: 1, 26 | config: { damping: 12 } 27 | }); 28 | 29 | const rotation = spring({ 30 | frame, 31 | fps, 32 | from: -5, 33 | to: 0, 34 | config: { damping: 12 } 35 | }); 36 | 37 | const hue = interpolate( 38 | frame, 39 | [0, 60], 40 | [180, 240], 41 | { extrapolateRight: 'clamp' } 42 | ); 43 | 44 | return ( 45 |
56 |

64 | {emoji} 65 |

66 |

77 | {message} 78 |

79 |
80 | ) 81 | } 82 | -------------------------------------------------------------------------------- /screenshots/screenshot-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/varun-raj/immich-power-tools/c8f773a10f5f88ab0c0db78157a39cb70695b5bf/screenshots/screenshot-1.png -------------------------------------------------------------------------------- /screenshots/screenshot-infra.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/varun-raj/immich-power-tools/c8f773a10f5f88ab0c0db78157a39cb70695b5bf/screenshots/screenshot-infra.jpg -------------------------------------------------------------------------------- /src/components/albums/potential-albums/PotentialDateItem.tsx: -------------------------------------------------------------------------------- 1 | import { usePhotoSelectionContext } from "@/contexts/PhotoSelectionContext"; 2 | import { IPotentialAlbumsDatesResponse } from "@/handlers/api/album.handler"; 3 | import { formatDate } from "@/helpers/date.helper"; 4 | import { parseDate } from "@/helpers/date.helper"; 5 | import { cn } from "@/lib/utils"; 6 | import React, { useEffect, useMemo, useRef } from "react"; 7 | 8 | interface IProps { 9 | record: IPotentialAlbumsDatesResponse; 10 | onSelect: (date: string) => void; 11 | } 12 | export default function PotentialDateItem({ record, onSelect }: IProps) { 13 | const { config } = usePhotoSelectionContext(); 14 | const { startDate } = config; 15 | 16 | const itemsRef = useRef(null); 17 | 18 | useEffect(() => { 19 | if (startDate === record.date) { 20 | itemsRef.current?.scrollIntoView({ 21 | behavior: "auto", 22 | block: "nearest", 23 | }); 24 | } 25 | }, [startDate, record.date]); 26 | 27 | const dateLabel = useMemo(() => { 28 | try { 29 | return formatDate(parseDate(record.date, "yyyy-MM-dd").toISOString(), "do MMM yyyy") 30 | } catch (e) { 31 | console.error("Error formatting date label:", e); 32 | return record.date; 33 | } 34 | }, [record.date]) 35 | return ( 36 |
onSelect(record.date)} 39 | key={record.date} 40 | ref={itemsRef} 41 | className={ 42 | cn("flex gap-1 flex-col p-2 py-1 rounded-lg hover:dark:bg-zinc-800 border border-transparent hover:bg-zinc-100", 43 | startDate === record.date ? "bg-zinc-100 dark:bg-zinc-800 border-gray-300 dark:border-zinc-700" : "") 44 | } 45 | > 46 |

{dateLabel}

47 |

{record.asset_count} Orphan Assets

48 |
49 | ); 50 | } 51 | -------------------------------------------------------------------------------- /src/components/analytics/exif/EXIFDistribution.tsx: -------------------------------------------------------------------------------- 1 | import { Card } from "@/components/ui/card"; 2 | import PieChart, { IPieChartData } from "@/components/ui/pie-chart"; 3 | import { getExifDistribution, ISupportedEXIFColumns } from "@/handlers/api/analytics.handler"; 4 | import React, { useEffect, useState } from "react"; 5 | import { ValueType } from "recharts/types/component/DefaultTooltipContent"; 6 | 7 | export interface IEXIFDistributionProps { 8 | column: ISupportedEXIFColumns; 9 | title: string; 10 | description: string; 11 | tooltipValueFormatter?: (value?: number | string | undefined | ValueType) => string; 12 | } 13 | 14 | export default function EXIFDistribution( 15 | { column, title, description, tooltipValueFormatter }: IEXIFDistributionProps 16 | ) { 17 | const [chartData, setChartData] = useState([]); 18 | const [loading, setLoading] = useState(false); 19 | const [errorMessage, setErrorMessage] = useState(null); 20 | 21 | const fetchData = async () => { 22 | return getExifDistribution(column) 23 | .then(setChartData) 24 | .finally(() => setLoading(false)); 25 | }; 26 | 27 | useEffect(() => { 28 | fetchData(); 29 | }, [column]); 30 | 31 | return ( 32 | 36 | 37 | 38 | ); 39 | } 40 | -------------------------------------------------------------------------------- /src/components/assets/missing-location/MissingLocationDateItem.tsx: -------------------------------------------------------------------------------- 1 | import { usePhotoSelectionContext } from "@/contexts/PhotoSelectionContext"; 2 | 3 | import { IMissingLocationDatesResponse } from "@/handlers/api/asset.handler"; 4 | import { parseDate, formatDate } from "@/helpers/date.helper"; 5 | import { cn } from "@/lib/utils"; 6 | import React, { useEffect, useMemo, useRef } from "react"; 7 | 8 | interface IProps { 9 | record: IMissingLocationDatesResponse; 10 | onSelect: (date: string) => void; 11 | groupBy: "date" | "album"; 12 | } 13 | 14 | export default function MissingLocationDateItem({ record, onSelect, groupBy }: IProps) { 15 | const { config } = usePhotoSelectionContext(); 16 | const { startDate, albumId } = config; 17 | 18 | const itemsRef = useRef(null); 19 | 20 | useEffect(() => { 21 | const currentIdentifier = groupBy === "album" ? albumId : startDate; 22 | if (currentIdentifier === record.value) { 23 | itemsRef.current?.scrollIntoView({ 24 | behavior: "auto", 25 | block: "center", 26 | }); 27 | } 28 | }, [startDate, albumId, groupBy, record.value]); 29 | 30 | const displayLabel = useMemo(() => { 31 | if (groupBy === "album") { 32 | return record.label; 33 | } 34 | if (!record.label) return "Unknown Date"; 35 | try { 36 | return formatDate(parseDate(record.label, "yyyy-MM-dd").toISOString(), "do MMM yyyy"); 37 | } catch (e) { 38 | console.error("Error formatting date label:", e); 39 | return record.label; 40 | } 41 | }, [record.label, groupBy]); 42 | 43 | const isSelected = useMemo(() => { 44 | return groupBy === "album" ? albumId === record.value : startDate === record.value; 45 | }, [groupBy, albumId, startDate, record.value]); 46 | 47 | return ( 48 |
onSelect(record.value)} 51 | key={record.value} 52 | className={ 53 | cn("flex gap-1 flex-col p-1 rounded-lg hover:dark:bg-zinc-800 border border-transparent hover:bg-zinc-100", 54 | isSelected ? "bg-zinc-100 dark:bg-zinc-800 border-gray-300 dark:border-zinc-700" : "") 55 | } 56 | ref={itemsRef} 57 | > 58 |

{displayLabel}

59 |

{record.asset_count} Assets

60 |
61 | ); 62 | } 63 | -------------------------------------------------------------------------------- /src/components/assets/missing-location/TagMissingLocationDialog/CustomMarker.tsx: -------------------------------------------------------------------------------- 1 | import L, { Marker as LeafletMarker } from 'leaflet'; 2 | import { Marker, Popup } from 'react-leaflet'; 3 | import { useRef } from 'react'; 4 | import { LatLngExpression } from 'leaflet'; 5 | 6 | 7 | interface CustomMarkerProps { 8 | position: LatLngExpression; 9 | } 10 | 11 | export default function CustomMarker({ position } : CustomMarkerProps) { 12 | const markerRef = useRef(null); 13 | 14 | return ( 15 | { 20 | if (markerRef.current) { 21 | markerRef.current.openPopup(); 22 | } 23 | }, 24 | mouseout: () => { 25 | if (markerRef.current) { 26 | markerRef.current.closePopup(); 27 | } 28 | }, 29 | }} 30 | > 31 | 32 | Position: {L.latLng(position).toString()} 33 | 34 | 35 | ); 36 | } -------------------------------------------------------------------------------- /src/components/assets/missing-location/TagMissingLocationDialog/Map.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | // IMPORTANT: the order matters! 4 | import "leaflet/dist/leaflet.css"; 5 | import L, { LatLngExpression } from 'leaflet'; 6 | 7 | import 'leaflet-defaulticon-compatibility/dist/leaflet-defaulticon-compatibility.webpack.css'; // Re-uses images from ~leaflet package 8 | import 'leaflet-defaulticon-compatibility'; 9 | 10 | import { MapContainer, TileLayer, useMapEvents } from "react-leaflet"; 11 | import { useState } from "react"; 12 | import { IPlace } from "@/types/common"; 13 | import CustomMarker from "./CustomMarker"; 14 | 15 | interface MapComponentProps { 16 | location: IPlace; 17 | onLocationChange: (place: IPlace) => void; 18 | } 19 | 20 | interface MapMouseHandlerProps { 21 | onMouseDown: (coords: LatLngExpression) => void; 22 | } 23 | 24 | const MapMouseHandler = ({ onMouseDown }: MapMouseHandlerProps) => { 25 | useMapEvents({ 26 | // Handle mouse events 27 | mousedown: (e) => { 28 | onMouseDown(e.latlng); 29 | }, 30 | }); 31 | return null; 32 | }; 33 | 34 | export default function Map({ location, onLocationChange }: MapComponentProps) { 35 | const [position, setPosition] = useState([location.latitude, location.longitude]); 36 | 37 | const handleClick = (coords: LatLngExpression) => { 38 | const normalized = L.latLng(coords); 39 | 40 | setPosition(coords); 41 | onLocationChange({ 42 | latitude: normalized.lat, 43 | longitude: normalized.lng, 44 | name: "" 45 | }); 46 | }; 47 | 48 | 49 | return ( 50 | 56 | 59 | 60 | 61 | 62 | ); 63 | } -------------------------------------------------------------------------------- /src/components/find/FindInput.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react' 2 | import inputStyle from './findInputStyle' 3 | import { MentionsInput, Mention } from 'react-mentions' 4 | import { searchPeople } from '@/handlers/api/people.handler'; 5 | import mentionStyle from './findMentionStyle'; 6 | 7 | interface FindInputProps { 8 | onSearch: (query: string) => void; 9 | value: string; 10 | onChange: (value: string) => void; 11 | } 12 | 13 | export default function FindInput({ onSearch, value, onChange }: FindInputProps) { 14 | 15 | const handleSearchPeople = async (e: any, callback: any) => { 16 | if (!e.length) return []; 17 | return searchPeople(e).then((people) => people.map((person: any) => ({ 18 | id: person.id, 19 | display: person.name, 20 | }))).then((people) => callback(people)); 21 | } 22 | 23 | return ( 24 | { 30 | if (e.key === 'Enter') { 31 | onSearch(value); 32 | } 33 | }} 34 | onChange={(e) => onChange(e.target.value)}> 35 | ( 43 |
44 | {highlightedDisplay.display} 45 |
46 | )} 47 | /> 48 |
49 | ) 50 | } 51 | -------------------------------------------------------------------------------- /src/components/find/findInputStyle.ts: -------------------------------------------------------------------------------- 1 | const findInputStyle = { 2 | control: { 3 | backgroundColor: 'hsl(var(--input))', 4 | fontSize: 20, 5 | fontWeight: 'normal', 6 | outline: 'none', 7 | borderRadius: '15px', 8 | 9 | }, 10 | 11 | '&singleLine': { 12 | // display: 'inline-block', 13 | width: '100%', 14 | 15 | highlighter: { 16 | padding: '10px 15px', 17 | border: 'none', 18 | outline: 'none', 19 | }, 20 | input: { 21 | padding: '10px 15px', 22 | border: 'none', 23 | outline: 'none', 24 | }, 25 | }, 26 | 27 | suggestions: { 28 | 29 | list: { 30 | backgroundColor: 'hsl(var(--background))', 31 | fontSize: 14, 32 | border: '1px solid hsl(var(--border))', 33 | overflow: 'hidden', 34 | }, 35 | item: { 36 | padding: '5px 15px', 37 | border: 'none', 38 | '&focused': { 39 | backgroundColor: 'hsl(var(--chart-2))', 40 | }, 41 | }, 42 | }, 43 | } 44 | 45 | export default findInputStyle; -------------------------------------------------------------------------------- /src/components/find/findMentionStyle.ts: -------------------------------------------------------------------------------- 1 | const mentionStyle: React.CSSProperties = { 2 | backgroundColor: 'hsla(var(--chart-2) / 0.3)', 3 | position: 'relative', 4 | } 5 | 6 | export default mentionStyle; -------------------------------------------------------------------------------- /src/components/layouts/PageLayout.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import Header from "../shared/Header"; 3 | import { cn } from "@/lib/utils"; 4 | 5 | interface IProps { 6 | children: React.ReactNode[] | React.ReactNode; 7 | className?: string; 8 | title?: string; 9 | } 10 | export default function PageLayout({ children, className, title }: IProps) { 11 | const header = Array.isArray(children) 12 | ? children.find( 13 | (child) => React.isValidElement(child) && child.type === Header 14 | ) 15 | : null; 16 | 17 | const childrenWithoutHeader = Array.isArray(children) 18 | ? children.filter((child) => { 19 | return React.isValidElement(child) && child.type !== Header; 20 | }) 21 | : children; 22 | 23 | return ( 24 |
25 | {header} 26 |
35 | {childrenWithoutHeader} 36 |
37 |
38 | ); 39 | } 40 | -------------------------------------------------------------------------------- /src/components/people/PeopleList.tsx: -------------------------------------------------------------------------------- 1 | import { IPersonListFilters, listPeople } from "@/handlers/api/people.handler"; 2 | import { IPerson } from "@/types/person"; 3 | import React, { useEffect, useState } from "react"; 4 | import Loader from "../ui/loader"; 5 | import PersonItem from "./PersonItem"; 6 | import { PeopleFilters } from "./PeopleFilters"; 7 | import { useRouter } from "next/router"; 8 | import PeopleFilterContext from "@/contexts/PeopleFilterContext"; 9 | import PageLayout from "../layouts/PageLayout"; 10 | import Header from "../shared/Header"; 11 | 12 | export default function PeopleList() { 13 | const router = useRouter(); 14 | const [people, setPeople] = useState([]); 15 | const [count, setCount] = useState(0); 16 | const [loading, setLoading] = useState(false); 17 | const [errorMessage, setErrorMessage] = useState(null); 18 | const [filters, setFilters] = useState({ 19 | ...router.query, 20 | page: 1, 21 | }); 22 | 23 | 24 | const fetchData = async () => { 25 | setLoading(true); 26 | setErrorMessage(null); 27 | return listPeople(filters) 28 | .then((response) => { 29 | setPeople(response.people); 30 | setCount(response.total); 31 | }) 32 | .catch((error) => { 33 | setErrorMessage(error.message); 34 | }) 35 | .finally(() => { 36 | setLoading(false); 37 | }); 38 | }; 39 | 40 | const handleRemove = (person: IPerson) => { 41 | setPeople((prev) => prev.filter((p) => { 42 | return p.id !== person.id 43 | })); 44 | } 45 | 46 | useEffect(() => { 47 | if (!router.isReady) return; 48 | fetchData(); 49 | }, [filters]); 50 | 51 | 52 | const renderContent = () => { 53 | if (loading) return ; 54 | if (errorMessage) return
{errorMessage}
; 55 | 56 | return ( 57 |
58 | {people.map((person) => ( 59 | 60 | ))} 61 |
62 | ); 63 | }; 64 | return ( 65 | 69 | setFilters((prev) => ({ ...prev, ...newConfig })), 70 | }} 71 | > 72 | 73 |
} 76 | /> 77 | {renderContent()} 78 | 79 | 80 | ); 81 | } 82 | -------------------------------------------------------------------------------- /src/components/people/PersonBirthdayCell.tsx: -------------------------------------------------------------------------------- 1 | import { IPerson } from '@/types/person' 2 | import React, { useState } from 'react' 3 | import { DatePicker } from '../ui/datepicker' 4 | import { updatePerson } from '@/handlers/api/people.handler'; 5 | import { formatDate } from '@/helpers/date.helper'; 6 | import { Input } from '../ui/input'; 7 | import { useToast } from '../ui/use-toast'; 8 | // @ts-ignore 9 | import chrono from 'chrono-node' 10 | 11 | 12 | 13 | interface IProps { 14 | person: IPerson 15 | } 16 | 17 | export default function PersonBirthdayCell( 18 | { person }: IProps 19 | ) { 20 | const [loading, setLoading] = useState(false); 21 | const { toast } = useToast(); 22 | const [errorMessage, setErrorMessage] = useState(null); 23 | const [textDate, setTextDate] = useState(person.birthDate ? formatDate(person.birthDate?.toString(), 'PPP'): ""); 24 | 25 | const handleEdit = (date?: Date | null) => { 26 | const formatedDate = date ? formatDate(date.toString(), 'yyyy-MM-dd') : null; 27 | setLoading(true); 28 | return updatePerson(person.id, { 29 | birthDate: formatedDate, 30 | }) 31 | .then(() => { 32 | toast({ 33 | title: "Success", 34 | description: "Person updated successfully", 35 | }) 36 | }) 37 | .catch(() => {}) 38 | .finally(() => { 39 | setLoading(false); 40 | }); 41 | } 42 | 43 | 44 | 45 | return ( 46 |
47 | { 52 | if (e.key === 'Enter') { 53 | const parsedDate = chrono.parseDate(textDate || ""); 54 | if (parsedDate) { 55 | handleEdit(parsedDate); 56 | setTextDate(formatDate(parsedDate.toString(), 'PPP')); 57 | } else { 58 | handleEdit(null); 59 | } 60 | } 61 | }} 62 | disabled={loading} 63 | onChange={(e) => setTextDate(e.target.value)} 64 | /> 65 | 66 |
67 | ) 68 | } 69 | -------------------------------------------------------------------------------- /src/components/people/PersonHideCell.tsx: -------------------------------------------------------------------------------- 1 | import { IPerson } from "@/types/person"; 2 | import React, { useState } from "react"; 3 | import { updatePerson } from "@/handlers/api/people.handler"; 4 | 5 | interface IProps { 6 | person: IPerson; 7 | onUpdate?: (person: IPerson) => any; 8 | } 9 | 10 | export default function PersonHideCell({ person, onUpdate }: IProps) { 11 | const [loading, setLoading] = useState(false); 12 | const [hidden, setHidden] = useState(person.isHidden); 13 | 14 | const handleEdit = (hidden: boolean) => { 15 | setLoading(true); 16 | return updatePerson(person.id, { 17 | isHidden: hidden, 18 | }) 19 | .then(() => { 20 | setHidden(hidden); 21 | onUpdate?.({ 22 | ...person, 23 | isHidden: hidden, 24 | }) 25 | }) 26 | .catch(() => {}) 27 | .finally(() => { 28 | setLoading(false); 29 | }); 30 | }; 31 | 32 | return ( 33 | 39 | ); 40 | } 41 | -------------------------------------------------------------------------------- /src/components/people/info/PersonAlbumList.tsx: -------------------------------------------------------------------------------- 1 | import { ASSET_THUMBNAIL_PATH } from '@/config/routes'; 2 | import { useConfig } from '@/contexts/ConfigContext'; 3 | import Image from 'next/image'; 4 | import Link from 'next/link'; 5 | import React, { useMemo } from 'react' 6 | 7 | interface PersonAlbumListProps { 8 | albums: { 9 | id: string; 10 | name: string; 11 | thumbnail: string; 12 | assetCount: number; 13 | lastAssetYear: number; 14 | }[] 15 | personId: string; 16 | } 17 | 18 | export const PersonAlbum = ({ album, personId }: { album: PersonAlbumListProps['albums'][number], personId: string }) => { 19 | const { exImmichUrl } = useConfig(); 20 | return ( 21 |
22 |
23 | {album.name} 28 |
29 | {album.name} 30 | {album.assetCount} Occurences 31 |
32 | ) 33 | } 34 | export default function PersonAlbumList({ albums, personId }: PersonAlbumListProps) { 35 | 36 | const groupedAlbums = useMemo(() => { 37 | const years: { 38 | label: number; 39 | albums: { 40 | id: string; 41 | name: string; 42 | thumbnail: string; 43 | assetCount: number; 44 | lastAssetYear: number; 45 | }[]; 46 | }[] = []; 47 | const uniqueYears = albums.map((album) => album.lastAssetYear).filter((year, index, self) => self.indexOf(year) === index); 48 | uniqueYears.forEach((year) => { 49 | years.push({ label: year, albums: albums.filter((album) => album.lastAssetYear === year) }); 50 | }); 51 | return years.sort((a, b) => b.label - a.label); 52 | }, [albums]); 53 | 54 | return ( 55 |
56 | {groupedAlbums.map((year) => ( 57 |
58 | {/* Country */} 59 | {year.label} 60 | {/* Cities */} 61 |
62 | {year.albums.map((album) => ( 63 | 64 | ))} 65 |
66 |
67 | ))} 68 |
69 | ) 70 | } 71 | -------------------------------------------------------------------------------- /src/components/people/info/PersonCityList.tsx: -------------------------------------------------------------------------------- 1 | import { useConfig } from '@/contexts/ConfigContext'; 2 | import { ExternalLink } from 'lucide-react'; 3 | import Link from 'next/link'; 4 | import React, { useMemo } from 'react' 5 | 6 | interface PersonCityListProps { 7 | cities: { 8 | city: string; 9 | country: string; 10 | count: number; 11 | }[] 12 | personId: string; 13 | } 14 | 15 | export const PersonCity = ({ city, count, personId }: { city: string, count: number, personId: string }) => { 16 | const { exImmichUrl } = useConfig(); 17 | const url = `${exImmichUrl}/search?query=${JSON.stringify({ city, personIds: [personId] })}`; 18 | return ( 19 |
20 | {city} 21 | {count} occurrences 22 | 23 | 24 | 25 |
26 | ) 27 | } 28 | export default function PersonCityList({ cities, personId }: PersonCityListProps) { 29 | 30 | const groupedCities = useMemo(() => { 31 | const countries: { 32 | label: string; 33 | cities: { 34 | city: string; 35 | count: number; 36 | }[]; 37 | }[] = []; 38 | const uniqueCountries = cities.map((city) => city.country).filter((country, index, self) => self.indexOf(country) === index); 39 | uniqueCountries.forEach((country) => { 40 | countries.push({ label: country, cities: cities.filter((city) => city.country === country) }); 41 | }); 42 | return countries; 43 | }, [cities]); 44 | 45 | return ( 46 |
47 | {groupedCities.map((country) => ( 48 |
49 | {/* Country */} 50 | {country.label} 51 | {/* Cities */} 52 |
53 | {country.cities.map((city) => ( 54 | 55 | ))} 56 |
57 |
58 | ))} 59 |
60 | ) 61 | } 62 | -------------------------------------------------------------------------------- /src/components/people/merge/FaceThumbnail.tsx: -------------------------------------------------------------------------------- 1 | import { Avatar } from "@/components/ui/avatar"; 2 | import { cn } from "@/lib/utils"; 3 | import { IPerson } from "@/types/person"; 4 | import { useConfig } from "@/contexts/ConfigContext"; 5 | 6 | interface FaceThumbnailProps { 7 | person: IPerson; 8 | onSelect: (person: IPerson) => void; 9 | selected?: boolean; 10 | } 11 | const FaceThumbnail = ({ person, onSelect, selected }: FaceThumbnailProps) => { 12 | const { exImmichUrl } = useConfig(); 13 | return ( 14 |
onSelect(person)} 22 | > 23 |
24 | 30 | 31 |

32 | {person.similarity ? `${Math.round(person.similarity * 100)}%` : ""} 33 |

34 |
35 | 36 | {/* Make name or "Untagged Person" clickable to open the person's page */} 37 | 43 | {person.name ? person.name : Untagged Person} 44 | 45 |
46 | ); 47 | }; 48 | 49 | export default FaceThumbnail; 50 | -------------------------------------------------------------------------------- /src/components/shared/AlbumDropdown.tsx: -------------------------------------------------------------------------------- 1 | import { listAlbums } from '@/handlers/api/album.handler'; 2 | import { IAlbum } from '@/types/album'; 3 | import React, { useEffect, useState } from 'react' 4 | import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select'; 5 | 6 | interface IAlbumDropdownProps { 7 | albumIds?: string[]; 8 | onChange: (albumIds: string[]) => void; 9 | } 10 | export default function AlbumDropdown({ albumIds, onChange }: IAlbumDropdownProps) { 11 | const [albums, setAlbums] = useState([]); 12 | const [loading, setLoading] = useState(false); 13 | const [error, setError] = useState(null); 14 | const [selectedAlbumIds, setSelectedAlbumIds] = useState(albumIds); 15 | 16 | const fetchAlbums = async () => { 17 | setLoading(true); 18 | return listAlbums({ 19 | sortBy: "createdAt", 20 | sortOrder: "desc", 21 | }).then((albums) => setAlbums(albums)).catch((error) => setError(error)).finally(() => setLoading(false)) ; 22 | } 23 | 24 | useEffect(() => { 25 | fetchAlbums(); 26 | }, []); 27 | 28 | 29 | useEffect(() => { 30 | if (albumIds) { 31 | setSelectedAlbumIds(albumIds); 32 | } else { 33 | setSelectedAlbumIds([]); 34 | } 35 | }, [albumIds]); 36 | 37 | return ( 38 |
39 | 60 |
61 | ) 62 | } -------------------------------------------------------------------------------- /src/components/shared/AssetsBulkDeleteButton.tsx: -------------------------------------------------------------------------------- 1 | import React, { useMemo } from 'react' 2 | import { Button } from '../ui/button'; 3 | import { AlertDialog } from '../ui/alert-dialog'; 4 | import { deleteAssets } from '@/handlers/api/asset.handler'; 5 | 6 | interface AssetsBulkDeleteButtonProps { 7 | selectedIds: string[]; 8 | onDelete: (ids: string[]) => void; 9 | } 10 | 11 | export default function AssetsBulkDeleteButton({ selectedIds, onDelete }: AssetsBulkDeleteButtonProps) { 12 | 13 | const ids = useMemo(() => { 14 | return selectedIds 15 | }, [selectedIds]); 16 | 17 | 18 | const handleDelete = () => { 19 | return deleteAssets(ids).then(() => { 20 | onDelete(ids); 21 | }) 22 | } 23 | 24 | return ( 25 | 31 | 34 | 35 | ) 36 | } 37 | -------------------------------------------------------------------------------- /src/components/shared/ErrorBlock.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | interface ErrorBlockProps { 4 | icon?: React.ReactNode; 5 | title?: string; 6 | description?: string; 7 | action?: React.ReactNode; 8 | } 9 | export default function ErrorBlock( 10 | { icon, title, description, action }: ErrorBlockProps 11 | ) { 12 | return ( 13 |
14 | {icon} 15 | {title &&

{title}

} 16 | {description &&

{description}

} 17 | {action || null} 18 |
19 | ) 20 | } 21 | -------------------------------------------------------------------------------- /src/components/shared/FloatingBar.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | interface FloatingBarProps { 4 | children: React.ReactNode; 5 | } 6 | 7 | export default function FloatingBar({ children }: FloatingBarProps) { 8 | return ( 9 |
10 |
11 | {children} 12 |
13 |
14 | ) 15 | } 16 | -------------------------------------------------------------------------------- /src/components/shared/Header.tsx: -------------------------------------------------------------------------------- 1 | import React, { useMemo } from "react"; 2 | import Link from "next/link"; 3 | import { Menu } from "lucide-react"; 4 | import { Button } from "@/components/ui/button"; 5 | import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet"; 6 | import Head from "next/head"; 7 | import { sidebarNavs } from "@/config/constants/sidebarNavs"; 8 | import { cn } from "@/lib/utils"; 9 | import { useRouter } from "next/router"; 10 | import clsx from "clsx"; 11 | 12 | interface IProps { 13 | leftComponent?: React.ReactNode | string; 14 | rightComponent?: React.ReactNode | string; 15 | title?: string; 16 | } 17 | export default function Header({ leftComponent, rightComponent, title }: IProps) { 18 | const { pathname } = useRouter(); 19 | const pageTitle = useMemo(() => { 20 | if (title && typeof title === "string") { 21 | return title; 22 | } 23 | if (typeof leftComponent === "string") { 24 | return leftComponent; 25 | } 26 | return ""; 27 | }, [title, leftComponent]); 28 | 29 | const renderLeftComponent = () => { 30 | if (typeof leftComponent === "string") { 31 | return

{leftComponent}

; 32 | } 33 | return leftComponent; 34 | }; 35 | 36 | const renderRightComponent = () => { 37 | if (typeof rightComponent === "string") { 38 | return

{rightComponent}

; 39 | } 40 | return rightComponent; 41 | } 42 | 43 | return ( 44 | <> 45 | {!!pageTitle && ( 46 | 47 | {pageTitle} - Immich Power Tools 48 | 49 | )} 50 |
53 | 54 | 55 | 59 | 60 | 61 | 79 | 80 | 81 | 82 |
83 | {renderLeftComponent()} 84 |
85 | {renderRightComponent()} 86 |
87 | 88 | ); 89 | } 90 | -------------------------------------------------------------------------------- /src/components/shared/PeopleDropdown.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react' 2 | import { IPerson } from '@/types/person'; 3 | import { IPersonListFilters, listPeople } from '@/handlers/api/people.handler'; 4 | import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; 5 | 6 | interface IPeopleDropdownProps { 7 | peopleIds?: string[]; 8 | onChange: (peopleIds: string[]) => void; 9 | } 10 | export default function PeopleDropdown({ peopleIds, onChange }: IPeopleDropdownProps) { 11 | const [people, setPeople] = useState([]); 12 | const [loading, setLoading] = useState(false); 13 | const [error, setError] = useState(null); 14 | const [selectedPeopleIds, setSelectedPeopleIds] = useState(peopleIds); 15 | const [filters, setFilters] = useState({ 16 | page: 1, 17 | perPage: 10000, 18 | sort: "createdAt", 19 | sortOrder: "desc", 20 | visibility: "visible", 21 | type: "named", 22 | }); 23 | 24 | const fetchPeople = async () => { 25 | setLoading(true); 26 | return listPeople(filters).then((people) => setPeople(people.people)).catch((error) => setError(error)).finally(() => setLoading(false)); 27 | } 28 | 29 | useEffect(() => { 30 | fetchPeople(); 31 | }, [filters]); 32 | 33 | useEffect(() => { 34 | if (peopleIds) { 35 | setSelectedPeopleIds(peopleIds); 36 | } else { 37 | setSelectedPeopleIds([]); 38 | } 39 | }, [peopleIds]); 40 | 41 | return ( 42 |
43 | 61 |
62 | ) 63 | } -------------------------------------------------------------------------------- /src/components/shared/PeopleList.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback, useMemo, useState } from 'react' 2 | import { IPerson } from '@/types/person'; 3 | import { cn } from '@/lib/utils'; 4 | import LazyImage from '../ui/lazy-image'; 5 | import { Input } from '../ui/input'; 6 | 7 | interface PeopleListProps { 8 | people: IPerson[]; 9 | onSelect: (person: IPerson) => void; 10 | selectedIds?: string[]; 11 | } 12 | 13 | export default function PeopleList({ people, onSelect, selectedIds }: PeopleListProps) { 14 | const [searchQuery, setSearchQuery] = useState("") 15 | 16 | const filteredPeople = useMemo(() => { 17 | return people.filter((person) => person.name.toLowerCase().includes(searchQuery.toLowerCase())) 18 | }, [people, searchQuery]) 19 | 20 | const isSelected = useCallback((person: IPerson) => { 21 | return selectedIds?.includes(person.id) 22 | }, [selectedIds]) 23 | 24 | return ( 25 |
26 | setSearchQuery(e.target.value)} 31 | /> 32 | {filteredPeople.map((person) => ( 33 |
onSelect(person)} 40 | > 41 | 49 |
50 |

{person.name || "No Name"}

51 |

{person.assetCount} photos

54 |
55 |
56 | ))} 57 |
58 | ) 59 | } 60 | -------------------------------------------------------------------------------- /src/components/shared/ProfileInfo.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Button } from "@/components/ui/button" 3 | import Link from "next/link"; 4 | import { useConfig } from "@/contexts/ConfigContext"; 5 | import { logoutUser } from "@/handlers/api/user.handler" 6 | import { useCurrentUser } from "@/contexts/CurrentUserContext"; 7 | import { useToast } from "../ui/use-toast"; 8 | import { HandshakeIcon } from "lucide-react"; 9 | 10 | export default function ProfileInfo() { 11 | const { updateContext, ...user } = useCurrentUser() 12 | const { immichURL, exImmichUrl, version } = useConfig(); 13 | const toast = useToast(); 14 | 15 | const handleLogout = () => logoutUser() 16 | .then(() => { 17 | updateContext(null) 18 | }).catch((error) => { 19 | toast.toast({ 20 | title: "Error", 21 | description: error.message, 22 | }) 23 | }) 24 | 25 | 26 | return ( 27 | <> 28 |
29 |

Connected to (External)

30 | 31 | {exImmichUrl} 32 | 33 |

Connected to (Internal)

34 | 35 | {immichURL} 36 | 37 | {user && ( 38 | <> 39 |

{user?.name}

40 |
41 | {!user.isUsingAPIKey && ( 42 | 45 | )} 46 | 52 |
53 | 54 | )} 55 |
56 |
57 |

58 | Made with by{" "} 59 | 64 | @zathvarun 65 | 66 |

67 | 68 | v{version} 69 | 70 |
71 | 72 | ); 73 | } 74 | -------------------------------------------------------------------------------- /src/components/shared/Sidebar.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | 3 | import { sidebarNavs } from "@/config/constants/sidebarNavs"; 4 | import { cn } from "@/lib/utils"; 5 | import { useRouter } from "next/router"; 6 | 7 | import dynamic from "next/dynamic"; 8 | import ProfileInfo from "./ProfileInfo"; 9 | 10 | const ThemeSwitcher = dynamic(() => import("@/components/shared/ThemeSwitcher"), { 11 | ssr: false, 12 | }); 13 | 14 | export default function Sidebar() { 15 | const router = useRouter(); 16 | const { pathname } = router; 17 | 18 | return ( 19 |
20 |
21 |
22 | 23 | Immich Power Tools 30 | Immich Power Tools 31 | 32 | 33 |
34 |
35 | 52 |
53 |
54 | 55 |
56 |
57 |
58 | ); 59 | } 60 | -------------------------------------------------------------------------------- /src/components/shared/ThemeSwitcher.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { useTheme } from "next-themes"; 3 | import { Moon, Sun } from "lucide-react"; 4 | import { cn } from "@/lib/utils"; 5 | 6 | export default function ThemeSwitcher() { 7 | const { theme, setTheme } = useTheme(); 8 | 9 | const toggleTheme = () => { 10 | setTheme(theme === "light" ? "dark" : "light"); 11 | }; 12 | 13 | return ( 14 | 22 | ); 23 | } 24 | -------------------------------------------------------------------------------- /src/components/ui/accordion.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import * as AccordionPrimitive from "@radix-ui/react-accordion" 3 | import { cn } from "@/lib/utils" 4 | import { ChevronDownIcon } from "@radix-ui/react-icons" 5 | 6 | const Accordion = AccordionPrimitive.Root 7 | 8 | const AccordionItem = React.forwardRef< 9 | React.ElementRef, 10 | React.ComponentPropsWithoutRef 11 | >(({ className, ...props }, ref) => ( 12 | 17 | )) 18 | AccordionItem.displayName = "AccordionItem" 19 | 20 | const AccordionTrigger = React.forwardRef< 21 | React.ElementRef, 22 | React.ComponentPropsWithoutRef & { 23 | noIcon?: boolean 24 | } 25 | >(({ className, children, noIcon, ...props }, ref) => ( 26 | 27 | svg]:rotate-180", 31 | className 32 | )} 33 | {...props} 34 | > 35 | {children} 36 | {!noIcon && } 37 | 38 | 39 | )) 40 | AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName 41 | 42 | const AccordionContent = React.forwardRef< 43 | React.ElementRef, 44 | React.ComponentPropsWithoutRef 45 | >(({ className, children, ...props }, ref) => ( 46 | 51 |
{children}
52 |
53 | )) 54 | AccordionContent.displayName = AccordionPrimitive.Content.displayName 55 | 56 | export { Accordion, AccordionItem, AccordionTrigger, AccordionContent } 57 | -------------------------------------------------------------------------------- /src/components/ui/avatar.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import * as AvatarPrimitive from "@radix-ui/react-avatar" 3 | 4 | import { cn } from "@/lib/utils" 5 | 6 | const AvatarRoot = React.forwardRef< 7 | React.ElementRef, 8 | React.ComponentPropsWithoutRef 9 | >(({ className, ...props }, ref) => ( 10 | 18 | )) 19 | AvatarRoot.displayName = AvatarPrimitive.Root.displayName 20 | 21 | const AvatarImage = React.forwardRef< 22 | React.ElementRef, 23 | React.ComponentPropsWithoutRef 24 | >(({ className, ...props }, ref) => ( 25 | 30 | )) 31 | AvatarImage.displayName = AvatarPrimitive.Image.displayName 32 | 33 | const AvatarFallback = React.forwardRef< 34 | React.ElementRef, 35 | React.ComponentPropsWithoutRef 36 | >(({ className, ...props }, ref) => ( 37 | 45 | )) 46 | AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName 47 | 48 | interface AvatarProps { 49 | src: string 50 | alt: string 51 | className?: string 52 | } 53 | 54 | const Avatar = (props: AvatarProps) => { 55 | const { src, alt } = props 56 | return ( 57 | 58 | {src && } 59 | {alt && !src && {alt[0]}} 60 | 61 | ) 62 | } 63 | export { Avatar, AvatarRoot, AvatarImage, AvatarFallback } 64 | -------------------------------------------------------------------------------- /src/components/ui/badge.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { cva, type VariantProps } from "class-variance-authority" 3 | 4 | import { cn } from "@/lib/utils" 5 | 6 | const badgeVariants = cva( 7 | "inline-flex items-center rounded-md 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", 8 | { 9 | variants: { 10 | variant: { 11 | default: 12 | "border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80", 13 | secondary: 14 | "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", 15 | destructive: 16 | "border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80", 17 | outline: "text-foreground", 18 | }, 19 | }, 20 | defaultVariants: { 21 | variant: "default", 22 | }, 23 | } 24 | ) 25 | 26 | export interface BadgeProps 27 | extends React.HTMLAttributes, 28 | VariantProps {} 29 | 30 | function Badge({ className, variant, ...props }: BadgeProps) { 31 | return ( 32 |
33 | ) 34 | } 35 | 36 | export { Badge, badgeVariants } 37 | -------------------------------------------------------------------------------- /src/components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { Slot } from "@radix-ui/react-slot" 3 | import { cva, type VariantProps } from "class-variance-authority" 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | const buttonVariants = cva( 8 | "inline-flex items-center justify-center 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", 9 | { 10 | variants: { 11 | variant: { 12 | default: 13 | "bg-primary text-primary-foreground shadow hover:bg-primary/90", 14 | destructive: 15 | "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90", 16 | outline: 17 | "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground dark:bg-secondary dark:border-secondary dark:text-secondary-foreground", 18 | secondary: 19 | "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80", 20 | ghost: "hover:bg-accent hover:text-accent-foreground", 21 | link: "text-primary underline-offset-4 hover:underline", 22 | }, 23 | size: { 24 | default: "h-9 px-4 py-2", 25 | sm: "h-8 rounded-md px-3 text-xs", 26 | lg: "h-10 rounded-md px-8", 27 | icon: "h-9 w-9", 28 | }, 29 | }, 30 | defaultVariants: { 31 | variant: "default", 32 | size: "default", 33 | }, 34 | } 35 | ) 36 | 37 | export interface ButtonProps 38 | extends React.ButtonHTMLAttributes, 39 | VariantProps { 40 | asChild?: boolean 41 | } 42 | 43 | const Button = React.forwardRef( 44 | ({ className, variant, size, asChild = false, ...props }, ref) => { 45 | const Comp = asChild ? Slot : "button" 46 | return ( 47 | 52 | ) 53 | } 54 | ) 55 | Button.displayName = "Button" 56 | 57 | export { Button, buttonVariants } 58 | -------------------------------------------------------------------------------- /src/components/ui/calendar.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { DayPicker } from "react-day-picker" 3 | import "react-day-picker/dist/style.css"; 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | export type CalendarProps = React.ComponentProps 8 | 9 | function Calendar({ 10 | className, 11 | classNames, 12 | showOutsideDays = true, 13 | ...props 14 | }: CalendarProps) { 15 | return ( 16 | 22 | ) 23 | } 24 | Calendar.displayName = "Calendar" 25 | 26 | export { Calendar } 27 | -------------------------------------------------------------------------------- /src/components/ui/card.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import { cn } from "@/lib/utils" 4 | 5 | const CardRoot = React.forwardRef< 6 | HTMLDivElement, 7 | React.HTMLAttributes 8 | >(({ className, ...props }, ref) => ( 9 |
17 | )) 18 | CardRoot.displayName = "CardRoot" 19 | 20 | const CardHeader = React.forwardRef< 21 | HTMLDivElement, 22 | React.HTMLAttributes 23 | >(({ className, ...props }, ref) => ( 24 |
29 | )) 30 | CardHeader.displayName = "CardHeader" 31 | 32 | const CardTitle = React.forwardRef< 33 | HTMLParagraphElement, 34 | React.HTMLAttributes 35 | >(({ className, ...props }, ref) => ( 36 |

41 | )) 42 | CardTitle.displayName = "CardTitle" 43 | 44 | const CardDescription = React.forwardRef< 45 | HTMLParagraphElement, 46 | React.HTMLAttributes 47 | >(({ className, ...props }, ref) => ( 48 |

53 | )) 54 | CardDescription.displayName = "CardDescription" 55 | 56 | const CardContent = React.forwardRef< 57 | HTMLDivElement, 58 | React.HTMLAttributes 59 | >(({ className, ...props }, ref) => ( 60 |

61 | )) 62 | CardContent.displayName = "CardContent" 63 | 64 | const CardFooter = React.forwardRef< 65 | HTMLDivElement, 66 | React.HTMLAttributes 67 | >(({ className, ...props }, ref) => ( 68 |
73 | )) 74 | CardFooter.displayName = "CardFooter" 75 | 76 | interface CardProps { 77 | title?: string 78 | description?: string 79 | className?: string 80 | children: React.ReactNode 81 | } 82 | const Card = ({ children, title, description, className }: CardProps) => { 83 | return ( 84 | 85 | {!!(title || description) && ( 86 | 87 | {title && {title}} 88 | {description && {description}} 89 | 90 | )} 91 | {children} 92 | 93 | ) 94 | } 95 | export { Card, CardRoot, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } 96 | -------------------------------------------------------------------------------- /src/components/ui/checkbox.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import * as CheckboxPrimitive from "@radix-ui/react-checkbox" 3 | import { cn } from "@/lib/utils" 4 | import { CheckIcon } from "@radix-ui/react-icons" 5 | 6 | const Checkbox = React.forwardRef< 7 | React.ElementRef, 8 | React.ComponentPropsWithoutRef 9 | >(({ className, ...props }, ref) => ( 10 | 18 | 21 | 22 | 23 | 24 | )) 25 | Checkbox.displayName = CheckboxPrimitive.Root.displayName 26 | 27 | export { Checkbox } 28 | -------------------------------------------------------------------------------- /src/components/ui/combobox.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import { Check, Filter } from "lucide-react" 5 | 6 | import { cn } from "@/lib/utils" 7 | import { 8 | Command, 9 | CommandEmpty, 10 | CommandGroup, 11 | CommandInput, 12 | CommandItem, 13 | CommandList, 14 | } from "@/components/ui/command" 15 | import { 16 | Popover, 17 | PopoverContent, 18 | PopoverTrigger, 19 | } from "@/components/ui/popover" 20 | 21 | 22 | export interface IComboBoxOption { label: string; value: string } 23 | 24 | export interface IComboBoxProps { 25 | options: IComboBoxOption[] 26 | onSelect?: (value: string) => void 27 | onOpenChange?: (open: boolean) => void 28 | closeOnSelect?: boolean 29 | label?: string 30 | onTextChange?: (text: string) => void 31 | } 32 | 33 | export function Combobox( 34 | { options, onSelect, onOpenChange, closeOnSelect, label, onTextChange }: IComboBoxProps 35 | ) { 36 | const [open, setOpen] = React.useState(false) 37 | const [value, setValue] = React.useState("") 38 | 39 | 40 | return ( 41 | { 42 | setOpen(openState) 43 | onOpenChange?.(openState) 44 | }}> 45 | 46 |
47 | 48 | {label} 49 |
50 |
51 | 52 | 53 | 54 | 55 | No framework found. 56 | 57 | {options.map((option) => ( 58 | { 62 | setValue(currentValue === value ? "" : currentValue) 63 | onSelect?.(currentValue) 64 | if (closeOnSelect) { 65 | setOpen(false) 66 | } 67 | }} 68 | > 69 | 75 | {option.label.trim()} 76 | 77 | ))} 78 | 79 | 80 | 81 | 82 |
83 | ) 84 | } 85 | -------------------------------------------------------------------------------- /src/components/ui/datepicker.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import { format } from "date-fns" 5 | import { Calendar as CalendarIcon } from "lucide-react" 6 | 7 | import { cn } from "@/lib/utils" 8 | import { Button } from "@/components/ui/button" 9 | import { Calendar } from "@/components/ui/calendar" 10 | import { 11 | Popover, 12 | PopoverContent, 13 | PopoverTrigger, 14 | } from "@/components/ui/popover" 15 | 16 | interface IProps { 17 | date?: Date | null 18 | onSelect?: (date?: Date | null) => any 19 | iconOnly?: boolean 20 | } 21 | export function DatePicker( 22 | { date: _date, onSelect, iconOnly }: IProps 23 | ) { 24 | const [date, setDate] = React.useState(_date || null) 25 | 26 | const handleSelect = (date?: Date) => { 27 | setDate(date || null) 28 | onSelect?.(date) 29 | } 30 | return ( 31 | 32 | 33 | 43 | 44 | 45 | 50 | 51 | 52 | ) 53 | } 54 | -------------------------------------------------------------------------------- /src/components/ui/hover-card.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import * as HoverCardPrimitive from "@radix-ui/react-hover-card" 3 | 4 | import { cn } from "@/lib/utils" 5 | 6 | const HoverCard = HoverCardPrimitive.Root 7 | 8 | const HoverCardTrigger = HoverCardPrimitive.Trigger 9 | 10 | const HoverCardContent = React.forwardRef< 11 | React.ElementRef, 12 | React.ComponentPropsWithoutRef 13 | >(({ className, align = "center", sideOffset = 4, ...props }, ref) => ( 14 | 24 | )) 25 | HoverCardContent.displayName = HoverCardPrimitive.Content.displayName 26 | 27 | export { HoverCard, HoverCardTrigger, HoverCardContent } 28 | -------------------------------------------------------------------------------- /src/components/ui/input.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import { cn } from "@/lib/utils" 4 | 5 | export interface InputProps 6 | extends React.InputHTMLAttributes {} 7 | 8 | const Input = React.forwardRef( 9 | ({ className, type, ...props }, ref) => { 10 | return ( 11 | 20 | ) 21 | } 22 | ) 23 | Input.displayName = "Input" 24 | 25 | export { Input } 26 | -------------------------------------------------------------------------------- /src/components/ui/label.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import * as LabelPrimitive from "@radix-ui/react-label" 3 | import { cva, type VariantProps } from "class-variance-authority" 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | const labelVariants = cva( 8 | "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" 9 | ) 10 | 11 | const Label = React.forwardRef< 12 | React.ElementRef, 13 | React.ComponentPropsWithoutRef & 14 | VariantProps 15 | >(({ className, ...props }, ref) => ( 16 | 21 | )) 22 | Label.displayName = LabelPrimitive.Root.displayName 23 | 24 | export { Label } 25 | -------------------------------------------------------------------------------- /src/components/ui/lazy-grid-image.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable @next/next/no-img-element */ 2 | import { humanizeDuration } from '@/helpers/string.helper' 3 | import { PlayIcon } from '@radix-ui/react-icons' 4 | import React, { useEffect } from 'react' 5 | import { Image, ImageExtended, ThumbnailImageProps } from 'react-grid-gallery' 6 | 7 | interface LazyImageProps extends ThumbnailImageProps> {} 8 | 9 | 10 | export default function LazyGridImage( 11 | props: LazyImageProps 12 | ) { 13 | const [isVisible, setIsVisible] = React.useState(false) 14 | const imageRef = React.useRef(null) 15 | 16 | const setupObserver = () => { 17 | const observer = new IntersectionObserver((entries) => { 18 | 19 | entries.forEach((entry) => { 20 | if (entry.isIntersecting) { 21 | setIsVisible(true) 22 | observer.disconnect() 23 | } 24 | }) 25 | }) 26 | 27 | if (imageRef.current) { 28 | observer.observe(imageRef.current) 29 | } 30 | return observer 31 | } 32 | 33 | useEffect(() => { 34 | const observer = setupObserver() 35 | return () => { 36 | observer?.disconnect() 37 | } 38 | }, []) 39 | 40 | if (!isVisible) return ( 41 |
42 | ) 43 | 44 | return ( 45 |
46 | {props.imageProps.alt 47 | {props.item.isVideo &&
48 | 49 | {!!props.item.duration && {humanizeDuration(props.item.duration)}} 50 |
} 51 |
52 | ) 53 | } 54 | -------------------------------------------------------------------------------- /src/components/ui/lazy-image.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable @next/next/no-img-element */ 2 | import { ImageProps } from 'next/image' 3 | import React, { useEffect } from 'react' 4 | 5 | interface LazyImageProps extends ImageProps {} 6 | 7 | export default function LazyImage( 8 | props: LazyImageProps 9 | ) { 10 | const [isVisible, setIsVisible] = React.useState(false) 11 | const imageRef = React.useRef(null) 12 | 13 | const setupObserver = () => { 14 | const observer = new IntersectionObserver((entries) => { 15 | 16 | entries.forEach((entry) => { 17 | if (entry.isIntersecting) { 18 | setIsVisible(true) 19 | observer.disconnect() 20 | } 21 | }) 22 | }) 23 | 24 | if (imageRef.current) { 25 | observer.observe(imageRef.current) 26 | } 27 | return observer 28 | } 29 | 30 | useEffect(() => { 31 | const observer = setupObserver() 32 | return () => { 33 | observer?.disconnect() 34 | } 35 | }, []) 36 | 37 | if (!isVisible) return ( 38 |
39 | ) 40 | 41 | return {props.alt 42 | } 43 | -------------------------------------------------------------------------------- /src/components/ui/linechardots.tsx: -------------------------------------------------------------------------------- 1 | import { CartesianGrid, LabelList, Line, LineChart, XAxis, YAxis } from "recharts"; 2 | 3 | import { 4 | ChartConfig, 5 | ChartContainer, 6 | ChartTooltip, 7 | ChartTooltipContent, 8 | } from "@/components/ui/chart"; 9 | import { useMemo } from "react"; 10 | import { CHART_COLORS } from "@/config/constants/chart.constant"; 11 | 12 | export interface ILineChartData { 13 | label: string; 14 | value: number; 15 | } 16 | 17 | interface ChartProps { 18 | data: ILineChartData[]; 19 | topLabel?: string; 20 | loading?: boolean; 21 | errorMessage?: string | null; 22 | } 23 | 24 | export function LineChartDots({ 25 | data: _data, 26 | topLabel, 27 | loading, 28 | errorMessage, 29 | }: ChartProps) { 30 | const data = useMemo( 31 | () => 32 | _data.map((item, index) => ({ 33 | ...item, 34 | fill: CHART_COLORS[index] || "#000000", 35 | })), 36 | [_data] 37 | ); 38 | 39 | const chartConfig = useMemo(() => { 40 | let config: ChartConfig = {}; 41 | data.map((data) => { 42 | config[data.label as string] = { 43 | label: data.label, 44 | color: "hsl(var(--chart-1))", 45 | }; 46 | }); 47 | return config; 48 | }, [data]); 49 | 50 | if (loading) { 51 | return

Loading...

; 52 | } 53 | 54 | if (errorMessage) { 55 | return

{errorMessage}

; 56 | } 57 | 58 | return ( 59 | 60 | 65 | 66 | {/* Added YAxis for the count */} 67 | 68 | 72 | } 73 | /> 74 | 86 | 93 | chartConfig[value]?.label 94 | } 95 | /> 96 | 97 | 98 | 99 | ); 100 | } 101 | -------------------------------------------------------------------------------- /src/components/ui/loader.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | export default function Loader() { 4 | return ( 5 |
6 |
7 | 23 | Loading... 24 |
25 |
26 | ); 27 | } 28 | -------------------------------------------------------------------------------- /src/components/ui/popover.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import * as PopoverPrimitive from "@radix-ui/react-popover" 3 | 4 | import { cn } from "@/lib/utils" 5 | 6 | const Popover = PopoverPrimitive.Root 7 | 8 | const PopoverTrigger = PopoverPrimitive.Trigger 9 | 10 | const PopoverAnchor = PopoverPrimitive.Anchor 11 | 12 | const PopoverContent = React.forwardRef< 13 | React.ElementRef, 14 | React.ComponentPropsWithoutRef 15 | >(({ className, align = "center", sideOffset = 4, ...props }, ref) => ( 16 | 17 | 27 | 28 | )) 29 | PopoverContent.displayName = PopoverPrimitive.Content.displayName 30 | 31 | export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor } 32 | -------------------------------------------------------------------------------- /src/components/ui/radarchartdpts.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { TrendingUp } from "lucide-react" 4 | import { PolarAngleAxis, PolarGrid, Radar, RadarChart } from "recharts" 5 | 6 | import { 7 | Card, 8 | CardContent, 9 | CardDescription, 10 | CardFooter, 11 | CardHeader, 12 | CardTitle, 13 | } from "@/components/ui/card" 14 | import { 15 | ChartConfig, 16 | ChartContainer, 17 | ChartTooltip, 18 | ChartTooltipContent, 19 | } from "@/components/ui/chart" 20 | import { useMemo } from "react" 21 | import { CHART_COLORS } from "@/config/constants/chart.constant" 22 | 23 | export const description = "A radar chart with dots" 24 | export interface IRadarChartData { 25 | label: string; 26 | value: number; 27 | color?: string; // Add color property here 28 | } 29 | 30 | interface ChartProps { 31 | data: IRadarChartData[]; 32 | topLabel?: string; 33 | loading?: boolean; 34 | errorMessage?: string | null; 35 | } 36 | 37 | const chartConfig = { 38 | label: { 39 | label: "label", 40 | color: "#FF0000", 41 | }, 42 | } satisfies ChartConfig 43 | 44 | export default function RadarChartDots({ data: _data, topLabel, loading, errorMessage }: ChartProps) { 45 | const chartData = _data 46 | 47 | return ( 48 | 49 | 50 | 51 | 55 | 56 | } /> 57 | 58 | {/* Set grid color to white */} 59 | ( 64 | 71 | )} 72 | /> 73 | 74 | 75 | 76 | 77 | ) 78 | } 79 | -------------------------------------------------------------------------------- /src/components/ui/scroll-area.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area" 3 | 4 | import { cn } from "@/lib/utils" 5 | 6 | const ScrollArea = React.forwardRef< 7 | React.ElementRef, 8 | React.ComponentPropsWithoutRef 9 | >(({ className, children, ...props }, ref) => ( 10 | 15 | 16 | {children} 17 | 18 | 19 | 20 | 21 | )) 22 | ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName 23 | 24 | const ScrollBar = React.forwardRef< 25 | React.ElementRef, 26 | React.ComponentPropsWithoutRef 27 | >(({ className, orientation = "vertical", ...props }, ref) => ( 28 | 41 | 42 | 43 | )) 44 | ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName 45 | 46 | export { ScrollArea, ScrollBar } 47 | -------------------------------------------------------------------------------- /src/components/ui/skeleton.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "@/lib/utils" 2 | 3 | function Skeleton({ 4 | className, 5 | ...props 6 | }: React.HTMLAttributes) { 7 | return ( 8 |
12 | ) 13 | } 14 | 15 | export { Skeleton } 16 | -------------------------------------------------------------------------------- /src/components/ui/switch.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import * as SwitchPrimitives from "@radix-ui/react-switch" 3 | 4 | import { cn } from "@/lib/utils" 5 | 6 | const Switch = React.forwardRef< 7 | React.ElementRef, 8 | React.ComponentPropsWithoutRef 9 | >(({ className, ...props }, ref) => ( 10 | 18 | 23 | 24 | )) 25 | Switch.displayName = SwitchPrimitives.Root.displayName 26 | 27 | export { Switch } 28 | -------------------------------------------------------------------------------- /src/components/ui/tabs.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import * as TabsPrimitive from "@radix-ui/react-tabs" 3 | 4 | import { cn } from "@/lib/utils" 5 | 6 | const Tabs = TabsPrimitive.Root 7 | 8 | const TabsList = React.forwardRef< 9 | React.ElementRef, 10 | React.ComponentPropsWithoutRef 11 | >(({ className, ...props }, ref) => ( 12 | 20 | )) 21 | TabsList.displayName = TabsPrimitive.List.displayName 22 | 23 | const TabsTrigger = React.forwardRef< 24 | React.ElementRef, 25 | React.ComponentPropsWithoutRef 26 | >(({ className, ...props }, ref) => ( 27 | 35 | )) 36 | TabsTrigger.displayName = TabsPrimitive.Trigger.displayName 37 | 38 | const TabsContent = React.forwardRef< 39 | React.ElementRef, 40 | React.ComponentPropsWithoutRef 41 | >(({ className, ...props }, ref) => ( 42 | 50 | )) 51 | TabsContent.displayName = TabsPrimitive.Content.displayName 52 | 53 | export { Tabs, TabsList, TabsTrigger, TabsContent } 54 | -------------------------------------------------------------------------------- /src/components/ui/tooltip.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import * as TooltipPrimitive from "@radix-ui/react-tooltip" 3 | 4 | import { cn } from "@/lib/utils" 5 | 6 | const TooltipProvider = TooltipPrimitive.Provider 7 | 8 | const TooltipRoot = TooltipPrimitive.Root 9 | 10 | const TooltipTrigger = TooltipPrimitive.Trigger 11 | 12 | const TooltipContent = React.forwardRef< 13 | React.ElementRef, 14 | React.ComponentPropsWithoutRef 15 | >(({ className, sideOffset = 4, ...props }, ref) => ( 16 | 25 | )) 26 | TooltipContent.displayName = TooltipPrimitive.Content.displayName 27 | 28 | 29 | export interface ITooltipProps extends React.ComponentProps { 30 | content: string 31 | } 32 | const Tooltip: React.FC = ({ 33 | children, 34 | content, 35 | ...props 36 | }) => ( 37 | 38 | 39 | 40 | {children} 41 | 42 | 43 |

{content}

44 |
45 |
46 |
47 | ) 48 | 49 | export { Tooltip, TooltipRoot, TooltipTrigger, TooltipContent, TooltipProvider } 50 | -------------------------------------------------------------------------------- /src/config/app.config.ts: -------------------------------------------------------------------------------- 1 | import { ENV } from "./environment"; 2 | 3 | export const appConfig = { 4 | sessionCookieName: 'power-tools-session', 5 | jwtToken: ENV.JWT_SECRET || "jwt-secret", // This is a fallback value, it should be replaced with a proper secret 6 | } -------------------------------------------------------------------------------- /src/config/constants/chart.constant.ts: -------------------------------------------------------------------------------- 1 | export const CHART_COLORS = [ 2 | "#FF6B6B", 3 | "#4ECDC4", 4 | "#45B7D1", 5 | "#FFA07A", 6 | "#98D8C8", 7 | "#F7DC6F", 8 | "#BB8FCE", 9 | "#F1948A", 10 | "#85C1E9", 11 | "#82E0AA", 12 | "#F8C471", 13 | "#D7BDE2", 14 | "#F1C40F", 15 | "#3498DB", 16 | "#E74C3C", 17 | "#2ECC71", 18 | "#9B59B6", 19 | "#1ABC9C", 20 | "#D35400", 21 | "#34495E" 22 | ] 23 | 24 | export const PIE_CONFIG = { 25 | innerRadius: 60, 26 | radius: 10, 27 | strokeWidth: 5 28 | } 29 | -------------------------------------------------------------------------------- /src/config/constants/sidebarNavs.tsx: -------------------------------------------------------------------------------- 1 | import { GalleryHorizontal, GalleryVerticalEnd, Image as ImageIcon, MapPin, MapPinX, Rewind, Search, User } from "lucide-react"; 2 | 3 | export const sidebarNavs = [ 4 | { 5 | title: "Rewind 2024", 6 | link: "/rewind", 7 | icon: , 8 | }, 9 | { 10 | title: "Find Assets", 11 | link: "/find", 12 | icon: , 13 | }, 14 | { 15 | title: "Manage People", 16 | link: "/", 17 | icon: , 18 | }, 19 | { 20 | title: "Analytics", 21 | link: "/analytics/exif", 22 | icon: , 23 | }, 24 | { 25 | title: "Potential Albums", 26 | link: "/albums/potential-albums", 27 | icon: , 28 | }, 29 | { 30 | title: "Missing Locations", 31 | link: "/assets/missing-locations", 32 | icon: , 33 | }, 34 | { 35 | title: "Manage Albums", 36 | link: "/albums", 37 | icon: , 38 | }, 39 | 40 | { 41 | title: "Geo Heatmap", 42 | link: "/assets/geo-heatmap", 43 | icon: , 44 | } 45 | 46 | ]; 47 | -------------------------------------------------------------------------------- /src/config/db.ts: -------------------------------------------------------------------------------- 1 | import { drizzle, NodePgDatabase } from 'drizzle-orm/node-postgres'; 2 | 3 | import { Client, Pool } from "pg"; 4 | import { ENV } from "./environment"; 5 | import * as schema from "@/schema"; 6 | import { sql } from 'drizzle-orm'; 7 | import { findMissingKeys } from '@/helpers/data.helper'; 8 | import { APIError } from '@/lib/api'; 9 | 10 | const pool = ENV.DATABASE_URL ? new Pool({ 11 | connectionString: ENV.DATABASE_URL, 12 | keepAlive: true, 13 | }) : new Pool({ 14 | user: ENV.DB_USERNAME, 15 | password: ENV.DB_PASSWORD, 16 | host: ENV.DB_HOST, 17 | port: parseInt(ENV.DB_PORT), 18 | database: ENV.DB_DATABASE_NAME, 19 | }); 20 | 21 | class DatabaseConnectionError extends Error { 22 | error: string; 23 | constructor(message: string, error: string) { 24 | super(message); 25 | this.name = "DatabaseConnectionError"; 26 | this.error = error; 27 | } 28 | } 29 | export const connectDB = async (db: NodePgDatabase) => { 30 | try { 31 | const missingKeys = findMissingKeys(ENV, ['DB_USERNAME', 'DB_PASSWORD', 'DB_HOST', 'DB_PORT', 'DB_DATABASE_NAME']); 32 | if (!ENV.DATABASE_URL && missingKeys.length > 0) { 33 | throw new APIError({ 34 | message: `Some database credentials are missing: ${missingKeys.join(', ')}. Please add them to the .env file`, 35 | status: 500, 36 | }); 37 | } else { 38 | return await db.execute(sql`SELECT 1`); // Execute a simple query 39 | } 40 | } catch (error: any) { 41 | throw new DatabaseConnectionError(error.message, "Database connection failed"); 42 | } 43 | } 44 | 45 | export const db = drizzle(pool, { 46 | schema 47 | }); 48 | -------------------------------------------------------------------------------- /src/config/environment.ts: -------------------------------------------------------------------------------- 1 | export const ENV = { 2 | IMMICH_URL: (process.env.IMMICH_URL || 'http://immich_server:2283') as string, 3 | EXTERNAL_IMMICH_URL: (process.env.EXTERNAL_IMMICH_URL || process.env.IMMICH_URL) as string, 4 | IMMICH_API_KEY: process.env.IMMICH_API_KEY as string, 5 | DATABASE_URL: process.env.DATABASE_URL as string, 6 | DB_USERNAME: process.env.DB_USERNAME as string, 7 | DB_PASSWORD: process.env.DB_PASSWORD as string, 8 | DB_HOST: process.env.DB_HOST as string, 9 | DB_PORT: process.env.DB_PORT as string, 10 | DB_DATABASE_NAME: process.env.DB_DATABASE_NAME as string, 11 | DB_SCHEMA: (process.env.DB_SCHEMA as string) || 'public', 12 | JWT_SECRET: process.env.JWT_SECRET as string, 13 | SECURE_COOKIE: process.env.SECURE_COOKIE === 'true', 14 | VERSION: process.env.VERSION, 15 | GEMINI_API_KEY: process.env.GEMINI_API_KEY as string, 16 | GOOGLE_MAPS_API_KEY: process.env.GOOGLE_MAPS_API_KEY as string, 17 | IMMICH_SHARE_LINK_KEY: process.env.IMMICH_SHARE_LINK_KEY as string, 18 | POWER_TOOLS_ENDPOINT_URL: process.env.POWER_TOOLS_ENDPOINT_URL as string, 19 | }; 20 | 21 | 22 | -------------------------------------------------------------------------------- /src/config/queryClient.ts: -------------------------------------------------------------------------------- 1 | import { QueryClient } from "@tanstack/react-query"; 2 | 3 | export const queryClient = new QueryClient(); -------------------------------------------------------------------------------- /src/contexts/ConfigContext.tsx: -------------------------------------------------------------------------------- 1 | import { createContext, useContext } from "react"; 2 | 3 | export interface ConfigContextType { 4 | immichURL: string; 5 | exImmichUrl: string; 6 | version?: string; 7 | geminiEnabled: boolean; 8 | googleMapsApiKey: string; 9 | } 10 | 11 | const ConfigContext = createContext({ 12 | immichURL: "", 13 | exImmichUrl: "", 14 | version: "", 15 | geminiEnabled: false, 16 | googleMapsApiKey: "", 17 | }); 18 | 19 | export default ConfigContext; 20 | 21 | export const useConfig = () => { 22 | const context = useContext(ConfigContext) as ConfigContextType; 23 | return context; 24 | } -------------------------------------------------------------------------------- /src/contexts/CurrentUserContext.tsx: -------------------------------------------------------------------------------- 1 | import { IUser } from "@/types/user"; 2 | import { createContext, useContext } from "react"; 3 | 4 | interface IUserContextType extends IUser { 5 | updateContext: (user: IUser | null) => void; 6 | } 7 | 8 | const UserContext = createContext(null); 9 | 10 | export default UserContext; 11 | 12 | export const useCurrentUser = () => { 13 | const context = useContext(UserContext) as IUserContextType; 14 | return context; 15 | } -------------------------------------------------------------------------------- /src/contexts/ExifFiltersContext.tsx: -------------------------------------------------------------------------------- 1 | import { createContext, useContext } from "react"; 2 | 3 | type FilterValue = string | string[] | number | number[] | undefined; 4 | 5 | interface ExifFiltersType { 6 | updateContext: (key: string, value: FilterValue) => void; 7 | [key: string]: FilterValue | ((key: string, value: FilterValue) => void); 8 | } 9 | 10 | const ExifFilters = createContext({ 11 | updateContext: () => {}, 12 | }); 13 | 14 | export default ExifFilters; 15 | 16 | export const useExifFilters = () => { 17 | const context = useContext(ExifFilters) as ExifFiltersType; 18 | return context; 19 | } -------------------------------------------------------------------------------- /src/contexts/PeopleFilterContext.ts: -------------------------------------------------------------------------------- 1 | import { IPersonListFilters } from "@/handlers/api/people.handler"; 2 | import { createContext, useContext } from "react"; 3 | 4 | interface IPeopleFilterContext extends IPersonListFilters { 5 | updateContext: (newConfig: Partial) => void; 6 | } 7 | const PeopleFilterContext = createContext({ 8 | page: 1, 9 | updateContext: () => { }, 10 | }) 11 | 12 | export default PeopleFilterContext; 13 | 14 | export const usePeopleFilterContext = () => { 15 | return useContext(PeopleFilterContext) as IPeopleFilterContext; 16 | } -------------------------------------------------------------------------------- /src/contexts/PhotoSelectionContext.tsx: -------------------------------------------------------------------------------- 1 | import { IMissingLocationDatesResponse } from "@/handlers/api/asset.handler"; 2 | import { IAsset } from "@/types/asset"; 3 | import { createContext, useContext } from "react"; 4 | 5 | // Interface for specific configuration options 6 | export interface IPhotoSelectionConfig { 7 | // Common config potentially used by multiple features 8 | startDate?: string; 9 | 10 | // MissingLocation specific config 11 | albumId?: string; 12 | sort?: string; // More generic sort field 13 | sortOrder?: "asc" | "desc"; 14 | dates?: IMissingLocationDatesResponse[]; // Specific to Missing Location 15 | 16 | // PotentialAlbum specific config 17 | minAssets?: number; 18 | } 19 | 20 | // Interface for the main context 21 | export interface IPhotoSelectionContext { 22 | selectedIds: string[]; 23 | assets: IAsset[]; 24 | config: IPhotoSelectionConfig; 25 | updateContext: (newConfig: Partial) => void; 26 | } 27 | 28 | // Default values for the context 29 | const defaultPhotoSelectionContext: IPhotoSelectionContext = { 30 | selectedIds: [], 31 | assets: [], 32 | config: { 33 | // Sensible defaults for config fields 34 | startDate: undefined, 35 | albumId: undefined, 36 | sort: "fileOriginalDate", // Default sort for MissingLocation, can be overridden 37 | sortOrder: "asc", 38 | dates: [], 39 | minAssets: 1, // Default for PotentialAlbum 40 | }, 41 | updateContext: () => {}, 42 | }; 43 | 44 | // Create the context 45 | const PhotoSelectionContext = createContext(defaultPhotoSelectionContext); 46 | 47 | export default PhotoSelectionContext; 48 | 49 | // Hook to use the context 50 | const usePhotoSelectionContext = () => { 51 | const context = useContext(PhotoSelectionContext); 52 | if (context === undefined) { 53 | throw new Error("usePhotoSelectionContext must be used within a PhotoSelectionProvider"); 54 | // Consider implementing a Provider component if you haven't already 55 | } 56 | return context; 57 | }; 58 | 59 | export { usePhotoSelectionContext }; -------------------------------------------------------------------------------- /src/handlers/api/album.handler.ts: -------------------------------------------------------------------------------- 1 | import { ADD_ASSETS_ALBUMS_PATH, ALBUM_ASSETS_PATH, ALBUM_INFO_PATH, ALBUM_PEOPLE_PATH, CREATE_ALBUM_PATH, DELETE_ALBUMS_PATH, LIST_ALBUMS_PATH, LIST_POTENTIAL_ALBUMS_ASSETS_PATH, LIST_POTENTIAL_ALBUMS_DATES_PATH, SHARE_ALBUMS_PATH } from "@/config/routes"; 2 | import { cleanUpAsset } from "@/helpers/asset.helper"; 3 | import API from "@/lib/api"; 4 | import { IAlbumCreate } from "@/types/album"; 5 | import { IAsset } from "@/types/asset"; 6 | 7 | interface IPotentialAlbumsDatesFilters { 8 | startDate?: string; 9 | endDate?: string; 10 | sortBy?: string; 11 | sortOrder?: string; 12 | minAssets?: number; 13 | } 14 | export interface IPotentialAlbumsDatesResponse { 15 | date: string; 16 | asset_count: number; 17 | } 18 | 19 | export const listPotentialAlbumsDates = async (filters: IPotentialAlbumsDatesFilters): Promise => { 20 | return API.get(LIST_POTENTIAL_ALBUMS_DATES_PATH, filters); 21 | } 22 | 23 | export const listPotentialAlbumsAssets = async (filters: IPotentialAlbumsDatesFilters): Promise => { 24 | return API.get(LIST_POTENTIAL_ALBUMS_ASSETS_PATH, filters).then((assets) => assets.map(cleanUpAsset)); 25 | } 26 | 27 | export const listAlbums = async (filters?: { sortBy?: string, sortOrder?: string }) => { 28 | return API.get(LIST_ALBUMS_PATH, filters); 29 | } 30 | export const getAlbumInfo = async (id: string) => { 31 | return API.get(ALBUM_INFO_PATH(id)); 32 | } 33 | 34 | export const getAlbumPeople = async (id: string) => { 35 | return API.get(ALBUM_PEOPLE_PATH(id)); 36 | } 37 | 38 | export const listAlbumAssets = async (id: string, filters: { faceId?: string }) => { 39 | return API.get(ALBUM_ASSETS_PATH(id), filters).then((assets) => assets.map(cleanUpAsset)); 40 | } 41 | 42 | export const addAssetToAlbum = async (albumId: string, assetIds: string[]) => { 43 | return API.put(ADD_ASSETS_ALBUMS_PATH(albumId), { ids: assetIds }); 44 | } 45 | 46 | export const createAlbum = async (albumData: IAlbumCreate) => { 47 | return API.post(CREATE_ALBUM_PATH, albumData); 48 | } 49 | 50 | export const shareAlbums = async (albums: { albumId: string, allowDownload: boolean, allowUpload: boolean, showMetadata: boolean }[]) => { 51 | return API.post(SHARE_ALBUMS_PATH, { albums }); 52 | } 53 | 54 | export const deleteAlbums = async (albumIds: string[]) => { 55 | return API.delete(DELETE_ALBUMS_PATH, { albumIds }); 56 | } -------------------------------------------------------------------------------- /src/handlers/api/analytics.handler.ts: -------------------------------------------------------------------------------- 1 | import { ASSET_STATISTICS, EXIF_DISTRIBUTION_PATH, HEATMAP_DATA, LIVE_PHOTO_STATISTICS } from "@/config/routes"; 2 | import API from "@/lib/api"; 3 | 4 | export type ISupportedEXIFColumns = 5 | "make" | "model" | "focal-length" | "city" | "state" | "country" | "iso" | "exposureTime" | 'lensModel' | "projectionType" | "storage"; 6 | 7 | export const getExifDistribution = async (column: ISupportedEXIFColumns) => { 8 | return API.get(EXIF_DISTRIBUTION_PATH(column)); 9 | }; 10 | 11 | export const getAssetStatistics = async () => { 12 | return API.get(ASSET_STATISTICS); 13 | } 14 | 15 | export const getLivePhotoStatistics = async () => { 16 | return API.get(LIVE_PHOTO_STATISTICS); 17 | } 18 | 19 | export const getHeatMapData = async () => { 20 | return API.get(HEATMAP_DATA); 21 | } -------------------------------------------------------------------------------- /src/handlers/api/asset.handler.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ASSET_GEO_HEATMAP_PATH, 3 | FIND_ASSETS, 4 | LIST_MISSING_LOCATION_ALBUMS_PATH, 5 | LIST_MISSING_LOCATION_ASSETS_PATH, 6 | LIST_MISSING_LOCATION_DATES_PATH, 7 | UPDATE_ASSETS_PATH, 8 | } from "@/config/routes"; 9 | import { cleanUpAsset } from "@/helpers/asset.helper"; 10 | import API from "@/lib/api"; 11 | import { IAsset } from "@/types/asset"; 12 | 13 | interface IMissingAssetAlbumsFilters { 14 | startDate?: string; 15 | endDate?: string; 16 | sortBy?: string; 17 | sortOrder?: string; 18 | } 19 | export interface IMissingLocationDatesResponse { 20 | label: string; 21 | asset_count: number; 22 | value: string; 23 | createdAt?: string; 24 | } 25 | 26 | 27 | export const listMissingLocationDates = async ( 28 | filters: IMissingAssetAlbumsFilters 29 | ): Promise => { 30 | return API.get(LIST_MISSING_LOCATION_DATES_PATH, filters); 31 | }; 32 | 33 | export const listMissingLocationAlbums = async ( 34 | filters: IMissingAssetAlbumsFilters 35 | ): Promise => { 36 | return API.get(LIST_MISSING_LOCATION_ALBUMS_PATH, filters); 37 | }; 38 | 39 | export const listMissingLocationAssets = async ( 40 | filters: IMissingAssetAlbumsFilters 41 | ): Promise => { 42 | return API.get(LIST_MISSING_LOCATION_ASSETS_PATH, filters).then((assets) => 43 | assets.map(cleanUpAsset) 44 | ); 45 | }; 46 | 47 | 48 | export interface IUpdateAssetsParams { 49 | ids: string[]; 50 | latitude?: number; 51 | longitude?: number; 52 | dateTimeOriginal?: string; 53 | } 54 | 55 | export const updateAssets = async (params: IUpdateAssetsParams) => { 56 | return API.put(UPDATE_ASSETS_PATH, params); 57 | } 58 | 59 | 60 | export const findAssets = async (query: string) => { 61 | return API.post(FIND_ASSETS, { query }); 62 | } 63 | 64 | export interface IHeatMapParams { 65 | albumIds?: string; 66 | peopleIds?: string; 67 | } 68 | export const getAssetGeoHeatmap = async (filters: IHeatMapParams) => { 69 | return API.get(ASSET_GEO_HEATMAP_PATH, filters); 70 | } 71 | 72 | export const deleteAssets = async (ids: string[]) => { 73 | return API.delete(UPDATE_ASSETS_PATH, { ids }); 74 | } -------------------------------------------------------------------------------- /src/handlers/api/common.handler.ts: -------------------------------------------------------------------------------- 1 | import { GET_FILTERS, REWIND_STATS, SEARCH_PLACES_PATH } from "@/config/routes"; 2 | import API from "@/lib/api"; 3 | import { IPlace } from "@/types/common"; 4 | 5 | export const searchPlaces = async (name: string): Promise => { 6 | return API.get(SEARCH_PLACES_PATH, { name }); 7 | } 8 | 9 | 10 | export const getFilters = async () => { 11 | return API.get(GET_FILTERS); 12 | } 13 | 14 | export const getRewindStats = async () => { 15 | return API.get(REWIND_STATS); 16 | } -------------------------------------------------------------------------------- /src/handlers/api/people.handler.ts: -------------------------------------------------------------------------------- 1 | import { LIST_PEOPLE_PATH, MERGE_PERSON_PATH, SEARCH_PEOPLE_PATH, SIMILAR_FACES_PATH, UPDATE_PERSON_PATH } from "@/config/routes" 2 | import { cleanUpPerson } from "@/helpers/person.helper"; 3 | import API from "@/lib/api" 4 | import { IPeopleListResponse, IPerson } from "@/types/person" 5 | 6 | type ISortField = "assetCount" | "updatedAt" | "createdAt"; 7 | 8 | export interface IPersonListFilters { 9 | page: number | string; 10 | perPage?: number; 11 | minimumAssetCount?: number; 12 | maximumAssetCount?: number; 13 | sort?: ISortField; 14 | sortOrder?: "asc" | "desc"; 15 | type?: string; 16 | query?: string; 17 | visibility?: "all" | "visible" | "hidden"; 18 | } 19 | export const listPeople = (filters: IPersonListFilters): Promise => { 20 | return API.get(LIST_PEOPLE_PATH, filters).then((response) => { 21 | return { 22 | ...response, 23 | people: response.people.map(cleanUpPerson), 24 | } 25 | }); 26 | } 27 | 28 | export const updatePerson = (id: string, data: Partial<{ 29 | name: string; 30 | birthDate: string | null; 31 | isHidden: boolean; 32 | }>) => { 33 | return API.put(UPDATE_PERSON_PATH(id), data) 34 | } 35 | 36 | export const searchPeople = (name: string) => { 37 | return API.get(SEARCH_PEOPLE_PATH, { name }).then((response) => response.map(cleanUpPerson)); 38 | } 39 | 40 | export const mergePerson = (id: string, targetIds: string[]) => { 41 | return API.post(MERGE_PERSON_PATH(id), { ids: targetIds }) 42 | } 43 | 44 | export const listSimilarFaces = (id: string, threshold: number) => { 45 | return API.get(SIMILAR_FACES_PATH(id), { threshold: threshold }) 46 | .then((response) => response.map((person: any) => cleanUpPerson(person, true))); 47 | } -------------------------------------------------------------------------------- /src/handlers/api/person.handler.ts: -------------------------------------------------------------------------------- 1 | import API from "@/lib/api"; 2 | import { GET_PERSON_INFO } from "@/config/routes"; 3 | 4 | export const getPersonInfo = async (personId: string) => { 5 | return API.get(GET_PERSON_INFO(personId)); 6 | } -------------------------------------------------------------------------------- /src/handlers/api/shareLink.handler.ts: -------------------------------------------------------------------------------- 1 | 2 | import { 3 | SHARE_LINK_ASSETS_PATH, 4 | SHARE_LINK_DOWNLOAD_PATH, 5 | SHARE_LINK_GENERATE_PATH, 6 | SHARE_LINK_PATH, 7 | SHARE_LINK_PEOPLE_PATH 8 | } from "@/config/routes"; 9 | import API from "@/lib/api"; 10 | import { ShareLinkFilters } from "@/types/shareLink"; 11 | 12 | export const getShareLinkInfo = async (token: string) => { 13 | return API.get(SHARE_LINK_PATH(token)); 14 | } 15 | 16 | export const getShareLinkAssets = async (token: string, filters: ShareLinkFilters) => { 17 | return API.get(SHARE_LINK_ASSETS_PATH(token), filters); 18 | } 19 | 20 | export const generateShareLink = async (filters: ShareLinkFilters) => { 21 | return API.post(SHARE_LINK_GENERATE_PATH, filters); 22 | } 23 | 24 | export const getShareLinkPeople = async (token: string) => { 25 | return API.get(SHARE_LINK_PEOPLE_PATH(token)); 26 | } 27 | 28 | export const downloadShareLink = async (token: string, assetIds: string[]) => { 29 | return API.post(SHARE_LINK_DOWNLOAD_PATH(token), { assetIds }); 30 | } -------------------------------------------------------------------------------- /src/handlers/api/user.handler.ts: -------------------------------------------------------------------------------- 1 | import { GET_ME_PATH, LOGIN_PATH, LOGOUT_PATH } from "@/config/routes"; 2 | import API from "@/lib/api"; 3 | 4 | export const getMe = async () => { 5 | return API.get(GET_ME_PATH); 6 | } 7 | 8 | export const loginUser = async (email: string, password: string) => { 9 | return API.post(LOGIN_PATH, { email, password }); 10 | } 11 | 12 | export const logoutUser = async () => { 13 | return API.post(LOGOUT_PATH); 14 | } -------------------------------------------------------------------------------- /src/helpers/asset.helper.ts: -------------------------------------------------------------------------------- 1 | import { ASSET_PREVIEW_PATH, ASSET_SHARE_THUMBNAIL_PATH, ASSET_THUMBNAIL_PATH, ASSET_VIDEO_PATH, PERSON_THUBNAIL_PATH } from "@/config/routes" 2 | import { IAsset } from "@/types/asset" 3 | 4 | 5 | export const cleanUpAsset = (asset: IAsset): IAsset => { 6 | return { 7 | ...asset, 8 | url: ASSET_THUMBNAIL_PATH(asset.id), 9 | previewUrl: ASSET_PREVIEW_PATH(asset.id), 10 | videoURL: ASSET_VIDEO_PATH(asset.id), 11 | } 12 | } 13 | 14 | 15 | export const cleanUpShareAsset = (asset: IAsset, token: string): IAsset => { 16 | return { 17 | ...asset, 18 | url: ASSET_SHARE_THUMBNAIL_PATH({ id: asset.id, size: "thumbnail", token, isPeople: false }), 19 | downloadUrl: ASSET_SHARE_THUMBNAIL_PATH({ id: asset.id, size: "original", token, isPeople: false }), 20 | previewUrl: ASSET_SHARE_THUMBNAIL_PATH({ id: asset.id, size: "preview", token, isPeople: false }), 21 | videoURL: ASSET_SHARE_THUMBNAIL_PATH({ id: asset.id, size: "video", token, isPeople: false }), 22 | } 23 | } 24 | 25 | export const cleanUpAssets = (assets: IAsset[]): IAsset[] => { 26 | return assets.map(cleanUpAsset); 27 | } 28 | 29 | function isRotated90CW(orientation: number) { 30 | return orientation === 5 || orientation === 6 || orientation === 90; 31 | } 32 | 33 | function isRotated270CW(orientation: number) { 34 | return orientation === 7 || orientation === 8 || orientation === -90; 35 | } 36 | 37 | export function isFlipped(orientation?: string | null) { 38 | const value = Number(orientation); 39 | return value && (isRotated270CW(value) || isRotated90CW(value)); 40 | } 41 | -------------------------------------------------------------------------------- /src/helpers/data.helper.ts: -------------------------------------------------------------------------------- 1 | export const stringToBoolean = (value: string | boolean): boolean => { 2 | if (typeof value === 'boolean') { 3 | return value; 4 | } 5 | return value === 'true'; 6 | } 7 | 8 | export const removeNullOrUndefinedProperties = (obj: any) => { 9 | return Object.fromEntries(Object.entries(obj).filter(([_, v]) => { 10 | if (Array.isArray(v)) { 11 | return v.length > 0; 12 | } 13 | 14 | if (typeof v === 'number') { 15 | return v !== null && v !== undefined && v !== 0; 16 | } 17 | 18 | return v !== null && v !== undefined && v !== '' && v !== 'null' && v !== 'undefined' && v !== 'null' && v !== 'undefined' 19 | })); 20 | } 21 | 22 | export const findMissingKeys = (obj: any, keys: string[]) => { 23 | 24 | return keys.filter((key) => !(key in obj) || obj[key] === '' || obj[key] === null || obj[key] === undefined); 25 | } -------------------------------------------------------------------------------- /src/helpers/date.helper.ts: -------------------------------------------------------------------------------- 1 | import { addSeconds, addHours, addMinutes, format, parse, addYears } from "date-fns" 2 | 3 | export const formatDate = (date: string, outputFormat?: string): string => { 4 | return format(date, outputFormat || "PPP") 5 | } 6 | 7 | export const parseDate = (date: string, inputFormat: string): Date => { 8 | return parse(date, inputFormat, new Date()); 9 | } 10 | 11 | export const addDays = (date: Date, days: number): Date => { 12 | const result = new Date(date); 13 | result.setDate(result.getDate() + days); 14 | return result; 15 | } 16 | 17 | 18 | export const offsetDate = (date: string, offset: { 19 | years: number, 20 | days: number, 21 | hours: number, 22 | minutes: number, 23 | seconds: number 24 | }): string => { 25 | const parsedDate = new Date(date); 26 | const result = addYears(parsedDate, offset.years || 0) 27 | const result2 = addDays(result, offset.days || 0) 28 | const result3 = addHours(result2, offset.hours || 0) 29 | const result4 = addMinutes(result3, offset.minutes || 0) 30 | const result5 = addSeconds(result4, offset.seconds || 0) 31 | return result5.toISOString() 32 | } -------------------------------------------------------------------------------- /src/helpers/person.helper.ts: -------------------------------------------------------------------------------- 1 | import { PERSON_THUBNAIL_PATH } from "@/config/routes" 2 | import { IPerson } from "@/types/person" 3 | import { parseDate } from "./date.helper" 4 | 5 | interface IAPIPerson extends Omit { 6 | updatedAt: string; 7 | birthDate: string | null; 8 | } 9 | 10 | export const cleanUpPerson = (person: IAPIPerson, skipMock?: boolean): IPerson => { 11 | return { 12 | ...person, 13 | thumbnailPath: PERSON_THUBNAIL_PATH(person.id), 14 | birthDate: person.birthDate ? new Date(person.birthDate) : null, 15 | updatedAt: new Date(person.updatedAt), 16 | } 17 | } -------------------------------------------------------------------------------- /src/helpers/string.helper.ts: -------------------------------------------------------------------------------- 1 | export const humanizeNumber = (number: number) => { 2 | return number.toLocaleString(); 3 | } 4 | 5 | export const pluralize = (number: number, word: string, pluralWord: string) => { 6 | return number === 1 ? word : pluralWord 7 | } 8 | 9 | export const humanizeBytes = (bytes: number) => { 10 | if (bytes < 1024) { 11 | return `${bytes} bytes` 12 | } 13 | if (bytes < 1024 * 1024) { 14 | return `${(bytes / 1024).toFixed(1)} KB` 15 | } 16 | if (bytes < 1024 * 1024 * 1024) { 17 | return `${(bytes / (1024 * 1024)).toFixed(1)} MB` 18 | } 19 | return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB` 20 | } 21 | 22 | export const humanizeDuration = (duration: string) => { 23 | if (!duration) { 24 | return null; 25 | } 26 | // Example input : 00:00:04.350 27 | const [hours, minutes, seconds] = duration.split(':').map(Number); 28 | const totalSeconds = Math.round(hours * 3600 + minutes * 60 + seconds); 29 | 30 | if (totalSeconds < 60) { 31 | return `${totalSeconds}s`; 32 | } 33 | if (totalSeconds < 3600) { 34 | // Show only minutes 35 | return `${Math.round(minutes)}m`; 36 | } 37 | 38 | 39 | return `${Math.round(hours)}h ${Math.round(minutes)}m`; 40 | } 41 | -------------------------------------------------------------------------------- /src/helpers/user.helper.ts: -------------------------------------------------------------------------------- 1 | import { ENV } from "@/config/environment" 2 | import { IUser } from "@/types/user" 3 | 4 | export const getUserHeaders = (user: { 5 | isUsingAPIKey?: boolean, 6 | isUsingShareKey?: boolean, 7 | accessToken?: string 8 | }, otherHeaders?: { 9 | 'Content-Type': string 10 | }) => { 11 | let headers: { 12 | 'Content-Type': string; 13 | 'x-api-key'?: string; 14 | 'Authorization'?: string 15 | } = { 16 | 'Content-Type': 'application/json', 17 | } 18 | if (user.isUsingShareKey) { 19 | headers['x-api-key'] = ENV.IMMICH_SHARE_LINK_KEY 20 | } else if (user.isUsingAPIKey) { 21 | headers['x-api-key'] = ENV.IMMICH_API_KEY 22 | } else { 23 | headers['Authorization'] = `Bearer ${user.accessToken}` 24 | } 25 | 26 | return {...headers, ...otherHeaders} 27 | } -------------------------------------------------------------------------------- /src/lib/cookie.ts: -------------------------------------------------------------------------------- 1 | import { ENV } from '@/config/environment' 2 | import type { CookieSerializeOptions } from 'cookie' 3 | import cookie from 'cookie' 4 | import type { IncomingMessage } from 'http' 5 | import type { NextApiRequest } from 'next' 6 | 7 | export const getCookie = ( 8 | req: NextApiRequest | IncomingMessage, 9 | name: string 10 | ) => { 11 | const cookieData = cookie.parse(req.headers.cookie || '') 12 | if (cookieData && cookieData[name]) { 13 | return cookieData[name] 14 | } 15 | return null 16 | } 17 | 18 | export const serializeCookie = ( 19 | name: string, 20 | value: string, 21 | options: CookieSerializeOptions = {} 22 | ) => { 23 | return cookie.serialize(name, String(value), { 24 | httpOnly: true, 25 | maxAge: 60 * 60 * 24 * 7, 26 | secure: ENV.SECURE_COOKIE, 27 | path: '/', 28 | ...options, 29 | }) 30 | } -------------------------------------------------------------------------------- /src/lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { type ClassValue, clsx } from "clsx" 2 | import { twMerge } from "tailwind-merge" 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)) 6 | } 7 | -------------------------------------------------------------------------------- /src/pages/404.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | export default function FourOFour() { 4 | return ( 5 |
6 |

404

7 |

Page not found

8 |
9 | ) 10 | } -------------------------------------------------------------------------------- /src/pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import RootLayout from "@/components/layouts/RootLayout"; 2 | import "@/styles/globals.scss"; 3 | import type { AppProps } from "next/app"; 4 | import { ThemeProvider } from "next-themes"; 5 | import { ENV } from "@/config/environment"; 6 | import ConfigContext, { ConfigContextType } from "@/contexts/ConfigContext"; 7 | import { useRef } from "react"; 8 | import { queryClient } from "@/config/queryClient"; 9 | import { QueryClientProvider } from "@tanstack/react-query"; 10 | interface AppPropsWithProps extends AppProps { 11 | props: ConfigContextType; 12 | } 13 | const App = ({ Component, pageProps, ...props }: AppPropsWithProps) => { 14 | const intialData = useRef(props.props); 15 | 16 | return ( 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | ); 27 | }; 28 | 29 | App.getInitialProps = async () => { 30 | 31 | return { 32 | props: { 33 | exImmichUrl: ENV.EXTERNAL_IMMICH_URL, 34 | immichURL: ENV.IMMICH_URL, 35 | version: ENV.VERSION, 36 | geminiEnabled: !!ENV.GEMINI_API_KEY?.length, 37 | googleMapsApiKey: ENV.GOOGLE_MAPS_API_KEY, 38 | }, 39 | }; 40 | }; 41 | 42 | export default App; 43 | -------------------------------------------------------------------------------- /src/pages/_document.tsx: -------------------------------------------------------------------------------- 1 | import { Html, Head, Main, NextScript } from "next/document"; 2 | 3 | export default function Document() { 4 | return ( 5 | 6 | 7 | 8 |
9 | 10 | 11 | 12 | ); 13 | } 14 | -------------------------------------------------------------------------------- /src/pages/api/albums/[id]/assets.ts: -------------------------------------------------------------------------------- 1 | import { NextApiRequest } from "next"; 2 | 3 | import { db } from "@/config/db"; 4 | import { getCurrentUser } from "@/handlers/serverUtils/user.utils"; 5 | import { NextApiResponse } from "next"; 6 | import { and, eq, isNotNull } from "drizzle-orm"; 7 | import { assets } from "@/schema/assets.schema"; 8 | import { albumsAssetsAssets } from "@/schema/albumAssetsAssets.schema"; 9 | import { assetFaces, exif, person } from "@/schema"; 10 | import { isFlipped } from "@/helpers/asset.helper"; 11 | import { ASSET_VIDEO_PATH } from "@/config/routes"; 12 | 13 | export default async function handler( 14 | req: NextApiRequest, 15 | res: NextApiResponse 16 | ) { 17 | const currentUser = await getCurrentUser(req); 18 | if (!currentUser) { 19 | return res.status(401).json({ error: "Unauthorized" }); 20 | } 21 | const { id, faceId, page = 1 } = req.query as { 22 | id?: string, 23 | faceId?: string, 24 | page?: number 25 | }; 26 | 27 | if (!id) { 28 | return res.status(400).json({ error: "Album id is required" }); 29 | } 30 | const dbAssets = await db.selectDistinctOn([assets.id], { 31 | id: assets.id, 32 | deviceId: assets.deviceId, 33 | type: assets.type, 34 | originalPath: assets.originalPath, 35 | isFavorite: assets.isFavorite, 36 | duration: assets.duration, 37 | encodedVideoPath: assets.encodedVideoPath, 38 | originalFileName: assets.originalFileName, 39 | sidecarPath: assets.sidecarPath, 40 | deletedAt: assets.deletedAt, 41 | localDateTime: assets.localDateTime, 42 | exifImageWidth: exif.exifImageWidth, 43 | exifImageHeight: exif.exifImageHeight, 44 | ownerId: assets.ownerId, 45 | dateTimeOriginal: exif.dateTimeOriginal, 46 | orientation: exif.orientation, 47 | }) 48 | .from(albumsAssetsAssets) 49 | .leftJoin(assets, eq(albumsAssetsAssets.assetsId, assets.id)) 50 | .leftJoin(exif, eq(assets.id, exif.assetId)) 51 | .leftJoin(assetFaces, eq(assets.id, assetFaces.assetId)) 52 | .leftJoin(person, eq(assetFaces.personId, person.id)) 53 | .where(and( 54 | eq(albumsAssetsAssets.albumsId, id), 55 | eq(assets.visibility, "timeline"), 56 | eq(assets.status, "active"), 57 | faceId ? eq(assetFaces.personId, faceId) : undefined, 58 | )) 59 | .limit(100) 60 | .offset(100 * (page - 1)); 61 | 62 | const cleanedAssets = dbAssets.map((asset) => { 63 | return { 64 | ...asset, 65 | exifImageHeight: isFlipped(asset?.orientation) 66 | ? asset?.exifImageWidth 67 | : asset?.exifImageHeight, 68 | exifImageWidth: isFlipped(asset?.orientation) 69 | ? asset?.exifImageHeight 70 | : asset?.exifImageWidth, 71 | orientation: asset?.orientation, 72 | downloadUrl: asset?.id ? ASSET_VIDEO_PATH(asset.id) : null, 73 | }; 74 | }); 75 | res.status(200).json(cleanedAssets); 76 | } -------------------------------------------------------------------------------- /src/pages/api/albums/[id]/info.ts: -------------------------------------------------------------------------------- 1 | import { NextApiRequest } from "next"; 2 | 3 | import { db } from "@/config/db"; 4 | import { getCurrentUser } from "@/handlers/serverUtils/user.utils"; 5 | import { NextApiResponse } from "next"; 6 | import { albums } from "@/schema/albums.schema"; 7 | import { count, eq, min, max, sql, and } from "drizzle-orm"; 8 | import { assets } from "@/schema/assets.schema"; 9 | import { albumsAssetsAssets } from "@/schema/albumAssetsAssets.schema"; 10 | import { assetFaces, exif, person } from "@/schema"; 11 | 12 | export default async function handler( 13 | req: NextApiRequest, 14 | res: NextApiResponse 15 | ) { 16 | const currentUser = await getCurrentUser(req); 17 | if (!currentUser) { 18 | return res.status(401).json({ error: "Unauthorized" }); 19 | } 20 | const { id } = req.query as { id: string }; 21 | 22 | const dbAlbums = await db.selectDistinctOn([albums.id], { 23 | id: albums.id, 24 | albumName: albums.albumName, 25 | createdAt: albums.createdAt, 26 | updatedAt: albums.updatedAt, 27 | albumThumbnailAssetId: albums.albumThumbnailAssetId, 28 | assetCount: count(assets.id), 29 | firstPhotoDate: min(exif.dateTimeOriginal), 30 | lastPhotoDate: max(exif.dateTimeOriginal), 31 | faceCount: count(sql`DISTINCT ${person.id}`), // Ensure unique personId 32 | }) 33 | .from(albums) 34 | .leftJoin(albumsAssetsAssets, eq(albums.id, albumsAssetsAssets.albumsId)) 35 | .leftJoin(assets, eq(albumsAssetsAssets.assetsId, assets.id)) 36 | .leftJoin(exif, and(eq(assets.id, exif.assetId), eq(assets.visibility, "timeline"))) 37 | .leftJoin(assetFaces, eq(assets.id, assetFaces.assetId)) 38 | .leftJoin(person, and(eq(assetFaces.personId, person.id), eq(person.isHidden, false))) 39 | .where(and(eq(albums.ownerId, currentUser.id), eq(albums.id, id))) 40 | .groupBy(albums.id) 41 | .limit(1); 42 | 43 | if (dbAlbums.length === 0) { 44 | return res.status(404).json({ error: "Album not found" }); 45 | } 46 | res.status(200).json(dbAlbums[0]); 47 | } -------------------------------------------------------------------------------- /src/pages/api/albums/[id]/people.ts: -------------------------------------------------------------------------------- 1 | import { NextApiRequest } from "next"; 2 | 3 | import { db } from "@/config/db"; 4 | import { getCurrentUser } from "@/handlers/serverUtils/user.utils"; 5 | import { NextApiResponse } from "next"; 6 | import { albums } from "@/schema/albums.schema"; 7 | import { count, desc, eq, and, isNotNull } from "drizzle-orm"; 8 | import { assets } from "@/schema/assets.schema"; 9 | import { albumsAssetsAssets } from "@/schema/albumAssetsAssets.schema"; 10 | import { assetFaces, person } from "@/schema"; 11 | 12 | export default async function handler( 13 | req: NextApiRequest, 14 | res: NextApiResponse 15 | ) { 16 | const currentUser = await getCurrentUser(req); 17 | if (!currentUser) { 18 | return res.status(401).json({ error: "Unauthorized" }); 19 | } 20 | const { id } = req.query as { id: string }; 21 | 22 | const dbAlbumPeople = await db.select({ 23 | id: person.id, 24 | name: person.name, 25 | thumbnailAssetId: person.faceAssetId, 26 | numberOfPhotos: count(assets.id), 27 | }) 28 | .from(albums) 29 | .leftJoin(albumsAssetsAssets, eq(albums.id, albumsAssetsAssets.albumsId)) 30 | .leftJoin(assets, eq(albumsAssetsAssets.assetsId, assets.id)) 31 | .leftJoin(assetFaces, eq(assets.id, assetFaces.assetId)) 32 | .leftJoin(person, and(eq(assetFaces.personId, person.id), eq(person.isHidden, false))) 33 | .where(and(eq(albums.ownerId, currentUser.id), eq(albums.id, id), isNotNull(person.id))) 34 | .orderBy(desc(person.name)) 35 | .groupBy(person.id); 36 | 37 | res.status(200).json(dbAlbumPeople); 38 | } -------------------------------------------------------------------------------- /src/pages/api/albums/[id]/public-info.ts: -------------------------------------------------------------------------------- 1 | import { NextApiRequest } from "next"; 2 | 3 | import { db } from "@/config/db"; 4 | import { NextApiResponse } from "next"; 5 | import { albums } from "@/schema/albums.schema"; 6 | import { count, eq, min, max, sql, and } from "drizzle-orm"; 7 | import { assets } from "@/schema/assets.schema"; 8 | import { albumsAssetsAssets } from "@/schema/albumAssetsAssets.schema"; 9 | import { assetFaces, exif, person } from "@/schema"; 10 | 11 | export default async function handler( 12 | req: NextApiRequest, 13 | res: NextApiResponse 14 | ) { 15 | 16 | const { id } = req.query as { id: string }; 17 | 18 | const dbAlbums = await db.selectDistinctOn([albums.id], { 19 | id: albums.id, 20 | albumName: albums.albumName, 21 | createdAt: albums.createdAt, 22 | updatedAt: albums.updatedAt, 23 | albumThumbnailAssetId: albums.albumThumbnailAssetId, 24 | assetCount: count(assets.id), 25 | firstPhotoDate: min(exif.dateTimeOriginal), 26 | lastPhotoDate: max(exif.dateTimeOriginal), 27 | faceCount: count(sql`DISTINCT ${person.id}`), // Ensure unique personId 28 | }) 29 | .from(albums) 30 | .leftJoin(albumsAssetsAssets, eq(albums.id, albumsAssetsAssets.albumsId)) 31 | .leftJoin(assets, eq(albumsAssetsAssets.assetsId, assets.id)) 32 | .leftJoin(exif, and(eq(assets.id, exif.assetId), eq(assets.visibility, "timeline"))) 33 | .leftJoin(assetFaces, eq(assets.id, assetFaces.assetId)) 34 | .leftJoin(person, and(eq(assetFaces.personId, person.id), eq(person.isHidden, false))) 35 | .where(and(eq(albums.id, id))) 36 | .groupBy(albums.id) 37 | .limit(1); 38 | 39 | if (dbAlbums.length === 0) { 40 | return res.status(404).json({ error: "Album not found" }); 41 | } 42 | res.status(200).json(dbAlbums[0]); 43 | } -------------------------------------------------------------------------------- /src/pages/api/albums/delete.ts: -------------------------------------------------------------------------------- 1 | import { ENV } from "@/config/environment"; 2 | import { getCurrentUser } from "@/handlers/serverUtils/user.utils"; 3 | import { NextApiResponse } from "next"; 4 | 5 | import { NextApiRequest } from "next"; 6 | 7 | interface IAlbumShare { 8 | albumId: string; 9 | allowDownload: boolean; 10 | allowUpload: boolean; 11 | showMetadata: boolean; 12 | } 13 | 14 | const deleteSingleAlbum = async (albumId: string, token: string) => { 15 | const url = ENV.IMMICH_URL + "/api/albums/" + albumId; 16 | return fetch(url, { 17 | method: 'DELETE', 18 | headers: { 19 | 'Authorization': `Bearer ${token}` 20 | } 21 | }).then(async (response) => { 22 | if (response.status >= 400) { 23 | return { 24 | error: "Failed to fetch assets", 25 | errorData: await response.json() 26 | }; 27 | } 28 | return { 29 | success: true 30 | }; 31 | }); 32 | } 33 | 34 | export default async function handler(req: NextApiRequest, res: NextApiResponse) { 35 | const currentUser = await getCurrentUser(req); 36 | 37 | if (!currentUser) { 38 | return res.status(401).json({ error: 'Unauthorized' }); 39 | } 40 | 41 | const { albumIds } = req.body as { albumIds: string[] }; 42 | if (!albumIds) { 43 | return res.status(400).json({ error: 'albumIds is required' }); 44 | } 45 | 46 | const deleteResults = await Promise.all(albumIds.map(async (albumId) => { 47 | return deleteSingleAlbum(albumId, currentUser.accessToken); 48 | })); 49 | return res.status(200).json(deleteResults); 50 | } -------------------------------------------------------------------------------- /src/pages/api/albums/potential-albums-assets.ts: -------------------------------------------------------------------------------- 1 | import { db } from "@/config/db"; 2 | import { getCurrentUser } from "@/handlers/serverUtils/user.utils"; 3 | import { isFlipped } from "@/helpers/asset.helper"; 4 | import { sql } from "drizzle-orm"; 5 | import type { NextApiRequest, NextApiResponse } from "next"; 6 | 7 | const SELECT_ORPHAN_PHOTOS = (date: string, ownerId: string) => 8 | sql.raw(` 9 | SELECT 10 | a."id", 11 | a."ownerId", 12 | a."deviceId", 13 | a."type", 14 | a."originalPath", 15 | a."isFavorite", 16 | a."duration", 17 | a."encodedVideoPath", 18 | a."originalFileName", 19 | a."sidecarPath", 20 | a."thumbhash", 21 | a."deletedAt", 22 | e."exifImageWidth", 23 | e."exifImageHeight", 24 | e."dateTimeOriginal", 25 | e."orientation" 26 | FROM 27 | assets a 28 | LEFT JOIN 29 | albums_assets_assets aaa 30 | ON a.id = aaa."assetsId" 31 | LEFT JOIN 32 | exif e 33 | ON a.id = e."assetId" 34 | WHERE 35 | aaa."albumsId" IS NULL 36 | AND a."ownerId" = '${ownerId}' 37 | AND e."dateTimeOriginal"::date = '${date}' 38 | AND a."visibility" = 'timeline' 39 | ORDER BY 40 | e."dateTimeOriginal" DESC 41 | `); 42 | 43 | export default async function handler( 44 | req: NextApiRequest, 45 | res: NextApiResponse 46 | ) { 47 | try { 48 | const currentUser = await getCurrentUser(req) 49 | const { startDate } = req.query as { startDate: string }; 50 | const { rows } = await db.execute(SELECT_ORPHAN_PHOTOS(startDate, currentUser.id)); 51 | 52 | const cleanedRows = rows.map((row: any) => { 53 | return { 54 | ...row, 55 | exifImageWidth: isFlipped(row.orientation || 0) ? row.exifImageHeight : row.exifImageWidth, 56 | exifImageHeight: isFlipped(row.orientation || 0) ? row.exifImageWidth : row.exifImageHeight, 57 | }; 58 | }); 59 | return res.status(200).json(cleanedRows); 60 | } catch (error: any) { 61 | res.status(500).json({ 62 | error: error?.message, 63 | }); 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/pages/api/albums/potential-albums-dates.ts: -------------------------------------------------------------------------------- 1 | // Next.js API route support: https://nextjs.org/docs/api-routes/introduction 2 | import { db } from "@/config/db"; 3 | import { IPotentialAlbumsDatesResponse } from "@/handlers/api/album.handler"; 4 | import { getCurrentUser } from "@/handlers/serverUtils/user.utils"; 5 | import { parseDate } from "@/helpers/date.helper"; 6 | import { albumsAssetsAssets } from "@/schema/albumAssetsAssets.schema"; 7 | import { assets } from "@/schema/assets.schema"; 8 | import { exif } from "@/schema/exif.schema"; 9 | import { and, count, desc, eq, isNotNull, isNull, sql } from "drizzle-orm"; 10 | import type { NextApiRequest, NextApiResponse } from "next"; 11 | 12 | 13 | export default async function handler( 14 | req: NextApiRequest, 15 | res: NextApiResponse 16 | ) { 17 | try { 18 | const { sortBy = "date", sortOrder = "desc", minAssets = 1 } = req.query as any; 19 | const currentUser = await getCurrentUser(req) 20 | const rows = await db.select({ 21 | date: sql`DATE(${exif.dateTimeOriginal})`, 22 | asset_count: desc(count(assets.id)), 23 | }).from(assets) 24 | .leftJoin(albumsAssetsAssets, eq(assets.id, albumsAssetsAssets.assetsId)) 25 | .leftJoin(exif, eq(assets.id, exif.assetId)) 26 | .where(and ( 27 | eq(assets.ownerId, currentUser.id), 28 | eq(assets.visibility, "timeline"), 29 | isNull(albumsAssetsAssets.albumsId), 30 | isNotNull(exif.dateTimeOriginal), 31 | 32 | )) 33 | .groupBy(sql`DATE(${exif.dateTimeOriginal})`) as IPotentialAlbumsDatesResponse[]; 34 | 35 | 36 | const filteredRows = rows.filter((row) => Number(row.asset_count) >= Number(minAssets)); 37 | if (sortBy === "date") { 38 | filteredRows.sort((a, b) => { 39 | const aDate = parseDate(a.date as string, "yyyy-MM-dd"); 40 | const bDate = parseDate(b.date as string, "yyyy-MM-dd"); 41 | return sortOrder === "asc" ? aDate.getTime() - bDate.getTime() : bDate.getTime() - aDate.getTime(); 42 | }); 43 | } else if (sortBy === "asset_count") { 44 | filteredRows.sort((a, b) => sortOrder === "asc" ? a.asset_count - b.asset_count : b.asset_count - a.asset_count); 45 | } 46 | return res.status(200).json(filteredRows); 47 | } catch (error: any) { 48 | res.status(500).json({ 49 | error: error?.message, 50 | }); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/pages/api/albums/share.ts: -------------------------------------------------------------------------------- 1 | import { ENV } from "@/config/environment"; 2 | import { getCurrentUser } from "@/handlers/serverUtils/user.utils"; 3 | import { NextApiResponse } from "next"; 4 | 5 | import { NextApiRequest } from "next"; 6 | 7 | interface IAlbumShare { 8 | albumId: string; 9 | allowDownload: boolean; 10 | allowUpload: boolean; 11 | showMetadata: boolean; 12 | } 13 | 14 | const generateShareLink = async (album: IAlbumShare, token: string) => { 15 | const url = ENV.IMMICH_URL + "/api/shared-links"; 16 | return fetch(url, { 17 | method: 'POST', 18 | body: JSON.stringify({ 19 | type: "ALBUM", 20 | albumId: album.albumId, 21 | allowDownload: album.allowDownload, 22 | allowUpload: album.allowUpload, 23 | showMetadata: album.showMetadata, 24 | }), 25 | headers: { 26 | 'Content-Type': 'application/json', 27 | 'Authorization': `Bearer ${token}` 28 | } 29 | }).then(async (response) => { 30 | if (response.status >= 400) { 31 | return { 32 | error: "Failed to fetch assets", 33 | errorData: await response.json() 34 | }; 35 | } 36 | const data = await response.json(); 37 | return { 38 | ...album, 39 | ...data, 40 | shareLink: ENV.EXTERNAL_IMMICH_URL + "/share/" + data.key 41 | }; 42 | }); 43 | } 44 | 45 | export default async function handler(req: NextApiRequest, res: NextApiResponse) { 46 | const currentUser = await getCurrentUser(req); 47 | 48 | if (!currentUser) { 49 | return res.status(401).json({ error: 'Unauthorized' }); 50 | } 51 | 52 | const { albums } = req.body as { albums: IAlbumShare[] }; 53 | if (!albums) { 54 | return res.status(400).json({ error: 'albums is required' }); 55 | } 56 | 57 | const shareLinks = await Promise.all(albums.map(async (album) => { 58 | return generateShareLink(album, currentUser.accessToken); 59 | })); 60 | return res.status(200).json(shareLinks); 61 | } -------------------------------------------------------------------------------- /src/pages/api/analytics/exif/[property].ts: -------------------------------------------------------------------------------- 1 | import { db } from "@/config/db"; 2 | import { getCurrentUser } from "@/handlers/serverUtils/user.utils"; 3 | import { assets, exif } from "@/schema"; 4 | import { and, count, desc, eq, isNotNull, ne } from "drizzle-orm"; 5 | import type { NextApiRequest, NextApiResponse } from "next"; 6 | 7 | const columnMap = { 8 | make: exif.make, 9 | model: exif.model, 10 | 'focal-length': exif.focalLength, 11 | city: exif.city, 12 | state: exif.state, 13 | country: exif.country, 14 | iso: exif.iso, 15 | exposureTime: exif.exposureTime, 16 | lensModel: exif.lensModel, 17 | projectionType: exif.projectionType, 18 | } 19 | 20 | export default async function handler( 21 | req: NextApiRequest, 22 | res: NextApiResponse 23 | ) { 24 | const { property } = req.query as { property: string }; 25 | 26 | const column = columnMap[property as keyof typeof columnMap]; 27 | 28 | if (!column) { 29 | return res.status(400).json({ 30 | name: "Error", 31 | }); 32 | } 33 | 34 | try { 35 | const currentUser = await getCurrentUser(req); 36 | const dataFromDB = await db.select({ 37 | value: count(), 38 | label: column, 39 | }) 40 | .from(exif) 41 | .leftJoin(assets, eq(assets.id, exif.assetId)) 42 | .where(and( 43 | isNotNull(column), 44 | eq(assets.ownerId, currentUser.id), 45 | )) 46 | .limit(20) 47 | .groupBy(column).orderBy(desc(count())); 48 | 49 | return res.status(200).json(dataFromDB); 50 | } catch (error: any) { 51 | res.status(500).json({ 52 | error: error?.message, 53 | }); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/pages/api/analytics/exif/storage.ts: -------------------------------------------------------------------------------- 1 | import { db } from "@/config/db"; 2 | import { getCurrentUser } from "@/handlers/serverUtils/user.utils"; 3 | import { humanizeBytes } from "@/helpers/string.helper"; 4 | import { assets, exif, users } from "@/schema"; 5 | import { desc, eq } from "drizzle-orm"; 6 | import { sum } from "drizzle-orm"; 7 | import { NextApiResponse } from "next"; 8 | 9 | import { NextApiRequest } from "next"; 10 | 11 | export default async function handler(req: NextApiRequest, res: NextApiResponse) { 12 | const currentUser = await getCurrentUser(req); 13 | if (!currentUser) { 14 | return res.status(401).json({ error: "Unauthorized" }); 15 | } 16 | const dbStorage = await db.select({ 17 | label: users.name, 18 | value: sum(exif.fileSizeInByte) 19 | }) 20 | .from(assets) 21 | .leftJoin(exif, eq(assets.id, exif.assetId)) 22 | .leftJoin(users, eq(assets.ownerId, users.id)) 23 | .orderBy(desc(sum(exif.fileSizeInByte))) 24 | .groupBy(assets.ownerId, users.name) 25 | 26 | const cleanedData = dbStorage.map((item) => ({ 27 | label: item.label, 28 | value: Math.round((parseInt(item.value ?? "0") / 1000000)), 29 | valueLabel: humanizeBytes(parseInt(item.value ?? "0")), 30 | })); 31 | res.status(200).json(cleanedData); 32 | } -------------------------------------------------------------------------------- /src/pages/api/analytics/statistics/heatmap.tsx: -------------------------------------------------------------------------------- 1 | // Next.js API route support: https://nextjs.org/docs/api-routes/introduction 2 | import { db } from "@/config/db"; 3 | import { getCurrentUser } from "@/handlers/serverUtils/user.utils"; 4 | import { assets, exif } from "@/schema"; 5 | import { and, count, desc, eq, gte, isNotNull, ne, sql, } from "drizzle-orm"; 6 | import type { NextApiRequest, NextApiResponse } from "next"; 7 | 8 | // Helper function to format date as YYYY-MM-DD 9 | function formatDate(date: Date) { 10 | return date.toISOString().split('T')[0]; 11 | } 12 | 13 | function fillMissingDates(data: any[]) { 14 | const today = new Date(); 15 | 16 | // Set the start date as one year ago, excluding the current month in the previous year 17 | const oneYearAgo = new Date(today.getFullYear() - 1, today.getMonth() + 1, 1); // First day of the next month last year 18 | 19 | const existingDates = new Set(data.map(item => item.date)); 20 | 21 | const filledData = []; 22 | 23 | // Iterate from today to one year ago, but skip the current month from last year 24 | for (let d = new Date(today); d >= oneYearAgo; d.setDate(d.getDate() - 1)) { 25 | const dateStr = formatDate(d); 26 | 27 | // Skip dates in the current month of the previous year 28 | if (d.getFullYear() === today.getFullYear() - 1 && d.getMonth() === today.getMonth()) { 29 | continue; 30 | } 31 | 32 | if (existingDates.has(dateStr)) { 33 | filledData.push(data.find(item => item.date === dateStr)); 34 | } else { 35 | filledData.push({ date: dateStr, count: 0 }); 36 | } 37 | } 38 | 39 | // Sort the filled data in descending order 40 | filledData.sort((a, b) => a.date.localeCompare(b.date)); 41 | return filledData; 42 | } 43 | export default async function handler( 44 | req: NextApiRequest, 45 | res: NextApiResponse 46 | ) { 47 | 48 | try { 49 | const currentUser = await getCurrentUser(req); 50 | const dataFromDB = await db.select({ 51 | date: sql`DATE(${assets.fileCreatedAt})`.as('date'), 52 | count: count(), 53 | }) 54 | .from(assets) 55 | .where( 56 | and( 57 | eq(assets.ownerId, currentUser.id), 58 | gte(assets.fileCreatedAt, sql`CURRENT_DATE - INTERVAL '1 YEAR'`)) 59 | ) 60 | .groupBy(sql`DATE(${assets.fileCreatedAt})`) 61 | .orderBy(sql`DATE(${assets.fileCreatedAt}) DESC`) 62 | const updatedData = fillMissingDates(dataFromDB); 63 | return res.status(200).json(updatedData); 64 | } catch (error: any) { 65 | res.status(500).json({ 66 | error: error?.message, 67 | }); 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/pages/api/analytics/statistics/livephoto.tsx: -------------------------------------------------------------------------------- 1 | // Next.js API route support: https://nextjs.org/docs/api-routes/introduction 2 | import { CHART_COLORS } from "@/config/constants/chart.constant"; 3 | import { db } from "@/config/db"; 4 | import { getCurrentUser } from "@/handlers/serverUtils/user.utils"; 5 | import { assets, exif } from "@/schema"; 6 | import { and, count, desc, eq, isNotNull, ne } from "drizzle-orm"; 7 | import type { NextApiRequest, NextApiResponse } from "next"; 8 | 9 | 10 | export default async function handler( 11 | req: NextApiRequest, 12 | res: NextApiResponse 13 | ) { 14 | 15 | try { 16 | const currentUser = await getCurrentUser(req); 17 | const dataFromDB = await db.select({ 18 | value: count(), 19 | }) 20 | .from(assets) 21 | .where(and( 22 | eq(assets.ownerId, currentUser.id), 23 | isNotNull(assets.livePhotoVideoId)) 24 | ); 25 | return res.status(200).json(dataFromDB); 26 | } catch (error: any) { 27 | res.status(500).json({ 28 | error: error?.message, 29 | }); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/pages/api/assets/geo-heatmap.ts: -------------------------------------------------------------------------------- 1 | import { NextApiResponse } from "next"; 2 | 3 | import { db } from "@/config/db"; 4 | import { getCurrentUser } from "@/handlers/serverUtils/user.utils"; 5 | import { NextApiRequest } from "next"; 6 | import { assetFaces, assets, exif, person } from "@/schema"; 7 | import { and, eq, inArray, isNotNull } from "drizzle-orm"; 8 | import { albumsAssetsAssets } from "@/schema/albumAssetsAssets.schema"; 9 | 10 | export default async function handler(req: NextApiRequest, res: NextApiResponse) { 11 | const currentUser = await getCurrentUser(req); 12 | const { albumIds, peopleIds } = req.query as { albumIds: string, peopleIds: string }; 13 | if (!currentUser) { 14 | return res.status(401).json({ message: 'Unauthorized' }); 15 | } 16 | 17 | 18 | const dbAssets = await db.select({ 19 | assetId: assets.id, 20 | latitude: exif.latitude, 21 | longitude: exif.longitude, 22 | }).from(assets) 23 | .innerJoin(exif, eq(assets.id, exif.assetId)) 24 | .innerJoin(albumsAssetsAssets, eq(assets.id, albumsAssetsAssets.assetsId)) 25 | .innerJoin(assetFaces, eq(albumsAssetsAssets.assetsId, assetFaces.assetId)) 26 | .innerJoin(person, eq(assetFaces.personId, person.id)) 27 | .where( 28 | and( 29 | eq(assets.ownerId, currentUser.id), 30 | isNotNull(exif.latitude), 31 | isNotNull(exif.longitude), 32 | albumIds?.length > 0 ? inArray(albumsAssetsAssets.albumsId, [albumIds]) : undefined, 33 | peopleIds?.length > 0 ? inArray(person.id, [peopleIds]) : undefined 34 | ) 35 | ); 36 | const heatmapData = dbAssets.map((asset) => [ 37 | asset.longitude, 38 | asset.latitude, 39 | ]); 40 | res.status(200).json(heatmapData); 41 | } 42 | -------------------------------------------------------------------------------- /src/pages/api/assets/missing-location-albums.ts: -------------------------------------------------------------------------------- 1 | import { db } from "@/config/db"; 2 | import { IMissingLocationDatesResponse } from "@/handlers/api/asset.handler"; 3 | import { getCurrentUser } from "@/handlers/serverUtils/user.utils"; 4 | import { assets, exif } from "@/schema"; 5 | import { albumsAssetsAssets } from "@/schema/albumAssetsAssets.schema"; 6 | import { albums } from "@/schema/albums.schema"; 7 | import { and, count, desc, eq, isNotNull, isNull, ne, sql } from "drizzle-orm"; 8 | import type { NextApiRequest, NextApiResponse } from "next"; 9 | 10 | export default async function handler( 11 | req: NextApiRequest, 12 | res: NextApiResponse 13 | ) { 14 | try { 15 | const { sortBy = "date", sortOrder = "desc" } = req.query; 16 | const currentUser = await getCurrentUser(req); 17 | if (!currentUser) { 18 | return res.status(401).json({ 19 | error: "Unauthorized", 20 | }); 21 | } 22 | const rows = await db 23 | .select({ 24 | asset_count: desc(count(assets.id)), 25 | label: albums.albumName, 26 | value: albums.id, 27 | }) 28 | .from(assets) 29 | .leftJoin(exif, eq(exif.assetId, assets.id)) 30 | .leftJoin(albumsAssetsAssets, eq(albumsAssetsAssets.assetsId, assets.id)) 31 | .leftJoin(albums, eq(albums.id, albumsAssetsAssets.albumsId)) 32 | .where( 33 | and( 34 | isNull(exif.latitude), 35 | isNotNull(assets.createdAt), 36 | isNotNull(exif.dateTimeOriginal), 37 | eq(assets.ownerId, currentUser.id), 38 | eq(assets.visibility, "timeline"), 39 | isNotNull(albums.id) 40 | )) 41 | .groupBy(albums.id) 42 | .orderBy(desc(count(assets.id))) as IMissingLocationDatesResponse[]; 43 | 44 | 45 | rows.sort((a, b) => { 46 | return sortOrder === "asc" ? a.asset_count - b.asset_count : b.asset_count - a.asset_count; 47 | }); 48 | return res.status(200).json(rows); 49 | } catch (error: any) { 50 | res.status(500).json({ 51 | error: error?.message, 52 | }); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/pages/api/assets/missing-location-dates.ts: -------------------------------------------------------------------------------- 1 | import { db } from "@/config/db"; 2 | import { IMissingLocationDatesResponse } from "@/handlers/api/asset.handler"; 3 | import { getCurrentUser } from "@/handlers/serverUtils/user.utils"; 4 | import { parseDate } from "@/helpers/date.helper"; 5 | import { assets, exif } from "@/schema"; 6 | import { and, count, desc, eq, isNotNull, isNull, ne, sql } from "drizzle-orm"; 7 | import type { NextApiRequest, NextApiResponse } from "next"; 8 | 9 | export default async function handler( 10 | req: NextApiRequest, 11 | res: NextApiResponse 12 | ) { 13 | try { 14 | const { sortBy = "date", sortOrder = "desc" } = req.query; 15 | const currentUser = await getCurrentUser(req); 16 | if (!currentUser) { 17 | return res.status(401).json({ 18 | error: "Unauthorized", 19 | }); 20 | } 21 | const rows = await db 22 | .select({ 23 | asset_count: desc(count(assets.id)), 24 | label: sql`DATE(${exif.dateTimeOriginal})`, 25 | value: sql`DATE(${exif.dateTimeOriginal})`, 26 | }) 27 | .from(assets) 28 | .leftJoin(exif, eq(exif.assetId, assets.id)) 29 | .where(and( 30 | isNull(exif.latitude), 31 | isNotNull(assets.createdAt), 32 | isNotNull(exif.dateTimeOriginal), 33 | eq(assets.ownerId, currentUser.id), 34 | eq(assets.visibility, "timeline"), 35 | eq(assets.status, "active"), 36 | isNull(assets.deletedAt), 37 | )) 38 | .groupBy(sql`DATE(${exif.dateTimeOriginal})`) 39 | .orderBy(desc(count(assets.id))) as IMissingLocationDatesResponse[]; 40 | 41 | if (sortBy === "date") { 42 | rows.sort((a, b) => { 43 | const aDate = parseDate(a.label, "yyyy-MM-dd"); 44 | const bDate = parseDate(b.label, "yyyy-MM-dd"); 45 | return sortOrder === "asc" ? aDate.getTime() - bDate.getTime() : bDate.getTime() - aDate.getTime(); 46 | }); 47 | } else if (sortBy === "asset_count") { 48 | rows.sort((a, b) => { 49 | return sortOrder === "asc" ? a.asset_count - b.asset_count : b.asset_count - a.asset_count; 50 | }); 51 | } 52 | return res.status(200).json(rows); 53 | } catch (error: any) { 54 | res.status(500).json({ 55 | error: error?.message, 56 | }); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/pages/api/filters/asset-filters.ts: -------------------------------------------------------------------------------- 1 | // Next.js API route support: https://nextjs.org/docs/api-routes/introduction 2 | import { db } from "@/config/db"; 3 | import { getCurrentUser } from "@/handlers/serverUtils/user.utils"; 4 | import { assets, exif } from "@/schema"; 5 | import { IExifColumns } from "@/schema/exif.schema"; 6 | import { IUser } from "@/types/user"; 7 | import { and, eq } from "drizzle-orm"; 8 | import type { NextApiRequest, NextApiResponse } from "next"; 9 | 10 | const FIELDS: IExifColumns[] = [ 11 | "make", 12 | "model", 13 | "lensModel", 14 | "city", 15 | "state", 16 | "country", 17 | "projectionType", 18 | "colorspace", 19 | "bitsPerSample", 20 | "rating", 21 | ]; 22 | 23 | const getFilters = async (currentUser: IUser) => { 24 | const filters: { 25 | [key in IExifColumns]?: (string | number | Date)[]; 26 | } = {}; 27 | for (const field of FIELDS) { 28 | const values = (await db.selectDistinct({ 29 | [field]: exif[field], 30 | }) 31 | .from(exif) 32 | .leftJoin(assets, eq(exif.assetId, assets.id)) 33 | .where( 34 | and( 35 | eq(assets.ownerId, currentUser.id), 36 | ) 37 | ) 38 | ).map((value) => value[field]).filter((value) => value !== null); 39 | 40 | filters[field] = values; 41 | } 42 | return filters; 43 | } 44 | 45 | export default async function handler( 46 | req: NextApiRequest, 47 | res: NextApiResponse 48 | ) { 49 | 50 | try { 51 | const currentUser = await getCurrentUser(req); 52 | if (!currentUser) { 53 | return res.status(401).json({ 54 | error: "Unauthorized", 55 | }); 56 | } 57 | const filters = await getFilters(currentUser); 58 | return res.status(200).json({ filters }); 59 | } catch (error: any) { 60 | res.status(500).json({ 61 | error: error?.message, 62 | }); 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/pages/api/find/search.ts: -------------------------------------------------------------------------------- 1 | import { db } from "@/config/db"; 2 | import { ENV } from "@/config/environment"; 3 | import { ASSET_VIDEO_PATH } from "@/config/routes"; 4 | import { getCurrentUser } from "@/handlers/serverUtils/user.utils"; 5 | import { cleanUpAsset, isFlipped } from "@/helpers/asset.helper"; 6 | import { parseFindQuery } from "@/helpers/gemini.helper"; 7 | import { person } from "@/schema"; 8 | import { Person } from "@/schema/person.schema"; 9 | import { inArray } from "drizzle-orm"; 10 | import { NextApiRequest, NextApiResponse } from "next"; 11 | 12 | export default async function search( 13 | req: NextApiRequest, 14 | res: NextApiResponse 15 | ) { 16 | try { 17 | const { query } = req.body; 18 | const currentUser = await getCurrentUser(req); 19 | const parsedQuery = await parseFindQuery(query as string); 20 | const { personIds } = parsedQuery; 21 | 22 | let dbPeople: Person[] = []; 23 | if (personIds) { 24 | dbPeople = await db 25 | .select() 26 | .from(person) 27 | .where(inArray(person.id, personIds)); 28 | } 29 | 30 | const url = ENV.IMMICH_URL + "/api/search/smart"; 31 | 32 | return fetch(url, { 33 | method: "POST", 34 | body: JSON.stringify({ ...parsedQuery, withExif: true }), 35 | headers: { 36 | "Content-Type": "application/json", 37 | Authorization: `Bearer ${currentUser?.accessToken}`, 38 | }, 39 | }) 40 | .then((response) => { 41 | if (response.status !== 200) { 42 | return res.json({ 43 | assets: [], 44 | filters: parsedQuery, 45 | error: "Failed to fetch assets", 46 | errorData: response.json(), 47 | }); 48 | } 49 | return response.json(); 50 | }) 51 | .then((data) => { 52 | const items = data.assets.items 53 | .map((item: any) => { 54 | return { 55 | ...item, 56 | exifImageHeight: isFlipped(item?.exifInfo?.orientation) 57 | ? item?.exifInfo?.exifImageWidth 58 | : item?.exifInfo?.exifImageHeight, 59 | exifImageWidth: isFlipped(item?.exifInfo?.orientation) 60 | ? item?.exifInfo?.exifImageHeight 61 | : item?.exifInfo?.exifImageWidth, 62 | orientation: item?.exifInfo?.orientation, 63 | downloadUrl: ASSET_VIDEO_PATH(item.id), 64 | }; 65 | }) 66 | .map(cleanUpAsset); 67 | res.status(200).json({ 68 | assets: items, 69 | filters: { 70 | ...parsedQuery, 71 | personIds: dbPeople.map((person) => person.name), 72 | }, 73 | }); 74 | }) 75 | .catch((err) => { 76 | res.status(500).json({ message: err.message }); 77 | }); 78 | } catch (err: any) { 79 | console.error(err); 80 | res.status(500).json({ message: err.message || "Failed to parse query" }); 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /src/pages/api/health.ts: -------------------------------------------------------------------------------- 1 | import { NextApiRequest, NextApiResponse } from 'next'; 2 | 3 | export default function handler(req: NextApiRequest, res: NextApiResponse) { 4 | if (req.method !== 'GET') { 5 | return res.status(405).json({ message: 'Method not allowed' }); 6 | } 7 | 8 | return res.status(200).json({ status: 'healthy' }); 9 | } -------------------------------------------------------------------------------- /src/pages/api/immich-proxy/[...path].ts: -------------------------------------------------------------------------------- 1 | import { ENV } from '@/config/environment'; 2 | import { getCurrentUser } from '@/handlers/serverUtils/user.utils'; 3 | import { getUserHeaders } from '@/helpers/user.helper'; 4 | import { NextApiRequest, NextApiResponse } from 'next' 5 | 6 | export default async function handler(req: NextApiRequest, res: NextApiResponse) { 7 | const proxyPath = req.url?.replace('/api/immich-proxy', ''); 8 | 9 | const targetUrl = `${ENV.IMMICH_URL}/api${proxyPath}`; 10 | 11 | const currentUser = await getCurrentUser(req); 12 | try { 13 | const response = await fetch(targetUrl, { 14 | method: req.method, 15 | headers: { 16 | ...getUserHeaders(currentUser), 17 | 'Accept-Encoding': 'gzip, deflate, br', 18 | }, 19 | body: req.method !== 'GET' && req.method !== 'HEAD' ? JSON.stringify(req.body) : null, 20 | }) 21 | 22 | // Forward the status code 23 | res.status(response.status) 24 | 25 | // Forward the headers 26 | const responseHeaders = response.headers 27 | responseHeaders.forEach((value, key) => { 28 | res.setHeader(key, value) 29 | }) 30 | 31 | // Stream the response body 32 | const reader = response.body?.getReader() 33 | if (reader) { 34 | while (true) { 35 | const { done, value } = await reader.read() 36 | if (done) break 37 | res.write(value) 38 | } 39 | } 40 | res.end() 41 | } catch (error: any) { 42 | console.error('Proxy error:', error) 43 | res.status(500).json({ error: error?.message }) 44 | } 45 | } -------------------------------------------------------------------------------- /src/pages/api/immich-proxy/asset/share-thumbnail/[id].ts: -------------------------------------------------------------------------------- 1 | // pages/api/proxy.js 2 | 3 | import { ENV } from '@/config/environment'; 4 | import { getUserHeaders } from '@/helpers/user.helper'; 5 | import { verify } from 'jsonwebtoken'; 6 | import { NextApiRequest, NextApiResponse } from 'next' 7 | 8 | export const config = { 9 | api: { 10 | bodyParser: false, 11 | }, 12 | } 13 | 14 | 15 | export default async function handler(req: NextApiRequest, res: NextApiResponse) { 16 | if (req.method !== 'GET') { 17 | return res.status(405).json({ message: 'Method Not Allowed' }) 18 | } 19 | 20 | const { id, size, token, p } = req.query; 21 | if (!token) { 22 | return res.status(401).json({ message: 'Unauthorized' }) 23 | } 24 | 25 | try { 26 | verify(token as string, ENV.JWT_SECRET); 27 | } catch (error) { 28 | return res.status(401).json({ message: 'Token is invalid' }) 29 | } 30 | 31 | const resource = p === "true" ? "people" : "assets"; 32 | const baseURL = `${ENV.IMMICH_URL}/api/${resource}/${id}`; 33 | const version = size === "original" ? "original" : "thumbnail"; 34 | let targetUrl = `${baseURL}/${version}?size=${size}`; 35 | 36 | try { 37 | // Forward the request to the target API 38 | const response = await fetch(targetUrl, { 39 | method: 'GET', 40 | headers: getUserHeaders({ isUsingShareKey: true }, { 41 | 'Content-Type': 'application/octet-stream', 42 | }), 43 | }) 44 | 45 | if (!response.ok) { 46 | const error = await response.json() 47 | throw new Error("Error fetching thumbnail " + error.message) 48 | } 49 | 50 | // Get the image data from the response 51 | const imageBuffer = await response.arrayBuffer() 52 | 53 | // Set the appropriate headers for the image response 54 | res.setHeader('Content-Type', response.headers.get('Content-Type') || 'image/png') 55 | res.setHeader('Content-Length', imageBuffer.byteLength) 56 | 57 | // Send the image data 58 | res.send(Buffer.from(imageBuffer)) 59 | } catch (error: any) { 60 | res.redirect("https://placehold.co/400") 61 | console.error('Error:', error) 62 | } 63 | } -------------------------------------------------------------------------------- /src/pages/api/immich-proxy/asset/thumbnail/[id].ts: -------------------------------------------------------------------------------- 1 | // pages/api/proxy.js 2 | 3 | import { ENV } from '@/config/environment'; 4 | import { getCurrentUser } from '@/handlers/serverUtils/user.utils'; 5 | import { getUserHeaders } from '@/helpers/user.helper'; 6 | import { NextApiRequest, NextApiResponse } from 'next' 7 | 8 | export const config = { 9 | api: { 10 | bodyParser: false, 11 | }, 12 | } 13 | 14 | export default async function handler(req: NextApiRequest, res: NextApiResponse) { 15 | if (req.method !== 'GET') { 16 | return res.status(405).json({ message: 'Method Not Allowed' }) 17 | } 18 | 19 | const { id, size } = req.query; 20 | const targetUrl = `${ENV.IMMICH_URL}/api/assets/${id}/thumbnail?size=${size || 'thumbnail'}`; 21 | 22 | const user = await getCurrentUser(req); 23 | if (!user) { 24 | return res.status(403).json({ message: 'Unauthorized' }) 25 | } 26 | try { 27 | // Forward the request to the target API 28 | const response = await fetch(targetUrl, { 29 | method: 'GET', 30 | headers: getUserHeaders(user, { 31 | 'Content-Type': 'application/octet-stream', 32 | }), 33 | }) 34 | 35 | if (!response.ok) { 36 | console.error('HTTP error:', response) 37 | throw new Error(`HTTP error! status: ${response.status}`) 38 | } 39 | 40 | // Get the image data from the response 41 | const imageBuffer = await response.arrayBuffer() 42 | 43 | // Set the appropriate headers for the image response 44 | res.setHeader('Content-Type', response.headers.get('Content-Type') || 'image/png') 45 | res.setHeader('Content-Length', imageBuffer.byteLength) 46 | 47 | // Send the image data 48 | res.send(Buffer.from(imageBuffer)) 49 | } catch (error) { 50 | res.status(500).json({ message: 'Internal Server Error' }) 51 | } 52 | } -------------------------------------------------------------------------------- /src/pages/api/immich-proxy/asset/video/[id].ts: -------------------------------------------------------------------------------- 1 | // pages/api/proxy.js 2 | 3 | import { ENV } from '@/config/environment'; 4 | import { getCurrentUser } from '@/handlers/serverUtils/user.utils'; 5 | import { getUserHeaders } from '@/helpers/user.helper'; 6 | import { NextApiRequest, NextApiResponse } from 'next' 7 | 8 | export const config = { 9 | api: { 10 | bodyParser: false, 11 | }, 12 | } 13 | 14 | export default async function handler(req: NextApiRequest, res: NextApiResponse) { 15 | if (req.method !== 'GET') { 16 | return res.status(405).json({ message: 'Method Not Allowed' }) 17 | } 18 | 19 | const { id } = req.query; 20 | const targetUrl = `${ENV.IMMICH_URL}/api/assets/${id}/original`; 21 | 22 | const user = await getCurrentUser(req); 23 | if (!user) { 24 | return res.status(403).json({ message: 'Unauthorized' }) 25 | } 26 | try { 27 | // Forward the request to the target API 28 | const response = await fetch(targetUrl, { 29 | method: 'GET', 30 | headers: getUserHeaders(user, { 31 | 'Content-Type': 'application/octet-stream', 32 | }), 33 | }) 34 | 35 | if (!response.ok) { 36 | console.error('HTTP error:', response) 37 | throw new Error(`HTTP error! status: ${response.status}`) 38 | } 39 | 40 | // Get the image data from the response 41 | const imageBuffer = await response.arrayBuffer() 42 | 43 | // Set the appropriate headers for the image response 44 | res.setHeader('Content-Type', response.headers.get('Content-Type') || 'image/png') 45 | res.setHeader('Content-Length', imageBuffer.byteLength) 46 | 47 | // Send the image data 48 | res.send(Buffer.from(imageBuffer)) 49 | } catch (error) { 50 | console.error('Error:', error) 51 | res.status(500).json({ message: 'Internal Server Error' }) 52 | } 53 | } -------------------------------------------------------------------------------- /src/pages/api/immich-proxy/thumbnail/[id].ts: -------------------------------------------------------------------------------- 1 | // pages/api/proxy.js 2 | 3 | import { ENV } from '@/config/environment'; 4 | import { getCurrentUser } from '@/handlers/serverUtils/user.utils'; 5 | import { getUserHeaders } from '@/helpers/user.helper'; 6 | import { NextApiRequest, NextApiResponse } from 'next' 7 | 8 | export const config = { 9 | api: { 10 | bodyParser: false, 11 | }, 12 | } 13 | 14 | export default async function handler(req: NextApiRequest, res: NextApiResponse) { 15 | if (req.method !== 'GET') { 16 | return res.status(405).json({ message: 'Method Not Allowed' }) 17 | } 18 | 19 | const { id } = req.query; 20 | const targetUrl = `${ENV.IMMICH_URL}/api/people/${id}/thumbnail`; 21 | 22 | const currentUser = await getCurrentUser(req); 23 | 24 | if (!currentUser) { 25 | return res.status(403).json({ message: 'Forbidden' }) 26 | } 27 | 28 | try { 29 | // Forward the request to the target API 30 | const response = await fetch(targetUrl, { 31 | method: 'GET', 32 | headers: getUserHeaders(currentUser, { 33 | 'Content-Type': 'application/octet-stream', 34 | }), 35 | }) 36 | 37 | if (!response.ok) { 38 | throw new Error(`HTTP error! status: ${response.status}`) 39 | } 40 | 41 | // Get the image data from the response 42 | const imageBuffer = await response.arrayBuffer() 43 | 44 | // Set the appropriate headers for the image response 45 | res.setHeader('Content-Type', response.headers.get('Content-Type') || 'image/png') 46 | res.setHeader('Content-Length', imageBuffer.byteLength) 47 | 48 | // Send the image data 49 | res.send(Buffer.from(imageBuffer)) 50 | } catch (error) { 51 | 52 | res.redirect("https://placehold.co/400") 53 | console.error('Error:', error) 54 | // res.status(500).json({ message: 'Internal Server Error' }) 55 | } 56 | } -------------------------------------------------------------------------------- /src/pages/api/people/[id]/info.ts: -------------------------------------------------------------------------------- 1 | import { NextApiResponse } from "next"; 2 | 3 | import { and, desc, eq, isNotNull, sql } from "drizzle-orm"; 4 | 5 | import { db } from "@/config/db"; 6 | import { person } from "@/schema/person.schema"; 7 | import { NextApiRequest } from "next"; 8 | import { albums } from "@/schema/albums.schema"; 9 | import { assetFaces, assets, exif } from "@/schema"; 10 | import { albumsAssetsAssets } from "@/schema/albumAssetsAssets.schema"; 11 | 12 | export default async function handler(req: NextApiRequest, res: NextApiResponse) { 13 | const { id } = req.query; 14 | const personRecords = await db 15 | .select() 16 | .from(person) 17 | .where(eq(person.id, id as string)) 18 | .limit(1); 19 | 20 | const personRecord = personRecords?.[0]; 21 | if (!personRecord) { 22 | return res.status(404).json({ 23 | error: "Person not found", 24 | }); 25 | } 26 | 27 | const dbPersonAlbums = await db 28 | .select({ 29 | id: albums.id, 30 | name: albums.albumName, 31 | thumbnail: albums.albumThumbnailAssetId, 32 | assetCount: sql`count(${assets.id})`, 33 | lastAssetDate: sql`max(${exif.dateTimeOriginal})`, 34 | }) 35 | .from(albums) 36 | .leftJoin(assetFaces, eq(assetFaces.personId, personRecord.id)) 37 | .leftJoin(assets, eq(assets.id, assetFaces.assetId)) 38 | .leftJoin(exif, eq(exif.assetId, assets.id)) 39 | .leftJoin(albumsAssetsAssets, eq(albumsAssetsAssets.assetsId, assets.id)) 40 | .where(eq(albumsAssetsAssets.albumsId, albums.id)) 41 | .groupBy(albums.id); 42 | 43 | 44 | const dbPersonCities = await db.select({ 45 | city: exif.city, 46 | country: exif.country, 47 | count: sql`count(${exif.assetId})`, 48 | }).from(exif) 49 | .leftJoin(assets, eq(assets.id, exif.assetId)) 50 | .leftJoin(assetFaces, eq(assetFaces.assetId, assets.id)) 51 | .where(and( 52 | eq(assetFaces.personId, personRecord.id), 53 | isNotNull(exif.city), 54 | isNotNull(exif.country), 55 | )) 56 | .groupBy(exif.city, exif.country) 57 | .orderBy(desc(exif.city)) 58 | 59 | 60 | return res.status(200).json({ 61 | ...personRecord, 62 | albums: dbPersonAlbums.sort((a, b) => b.assetCount - a.assetCount).map((album) => ({ 63 | ...album, 64 | lastAssetYear: album.lastAssetDate ? new Date(album.lastAssetDate).getFullYear() : null, 65 | })), 66 | cities: dbPersonCities, 67 | citiesCount: dbPersonCities.length, 68 | countriesCount: dbPersonCities.map((city) => city.country).filter((country, index, self) => self.indexOf(country) === index).length, 69 | }); 70 | } 71 | -------------------------------------------------------------------------------- /src/pages/api/share-link/[token]/download.ts: -------------------------------------------------------------------------------- 1 | import { ENV } from "@/config/environment"; 2 | import { JsonWebTokenError, verify } from "jsonwebtoken"; 3 | import { NextApiResponse } from "next"; 4 | 5 | import { NextApiRequest } from "next"; 6 | import { getUserHeaders } from "@/helpers/user.helper"; 7 | 8 | interface ShareLinkFilters { 9 | personIds: string[]; 10 | albumIds: string[]; 11 | startDate: string; 12 | endDate: string; 13 | } 14 | 15 | export default async function handler(req: NextApiRequest, res: NextApiResponse) { 16 | const { token } = req.query; 17 | const { assetIds } = req.body; 18 | try { 19 | if (!token) { 20 | return res.status(404).json({ message: "Token not found" }); 21 | } 22 | if (req.method !== "POST") { 23 | return res.status(405).json({ message: "Method not allowed" }); 24 | } 25 | 26 | const decoded = verify(token as string, ENV.JWT_SECRET); 27 | 28 | if (!decoded) { 29 | return res.status(401).json({ message: "Unauthorized" }); 30 | } 31 | 32 | const targetUrl = `${ENV.IMMICH_URL}/api/download/archive`; 33 | 34 | const response = await fetch(targetUrl, { 35 | method: 'POST', 36 | body: JSON.stringify({ assetIds }), 37 | headers: getUserHeaders({ isUsingShareKey: true }, { 38 | 'Content-Type': 'application/json', 39 | }), 40 | }) 41 | 42 | if (!response.ok) { 43 | throw new Error(`HTTP error! status: ${response.status}`) 44 | } 45 | 46 | // Get the image data from the response 47 | const imageBuffer = await response.arrayBuffer() 48 | 49 | // Set the appropriate headers for the image response 50 | res.setHeader('Content-Type', response.headers.get('Content-Type') || 'image/png') 51 | res.setHeader('Content-Length', imageBuffer.byteLength) 52 | 53 | // Send the image data 54 | res.send(Buffer.from(imageBuffer)) 55 | 56 | } catch (error) { 57 | if (error instanceof JsonWebTokenError) { 58 | return res.status(401).json({ message: "Please check your link and try again. Looks like it's expired." }); 59 | } 60 | return res.status(500).json({ message: (error as Error).message }); 61 | } 62 | } -------------------------------------------------------------------------------- /src/pages/api/share-link/[token]/index.ts: -------------------------------------------------------------------------------- 1 | import { NextApiResponse } from "next"; 2 | 3 | import { ENV } from "@/config/environment"; 4 | import { ShareLinkFilters } from "@/types/shareLink"; 5 | import { JsonWebTokenError, verify } from "jsonwebtoken"; 6 | import { NextApiRequest } from "next"; 7 | 8 | export default async function GET(req: NextApiRequest, res: NextApiResponse) { 9 | const { token } = req.query; 10 | try { 11 | 12 | if (!token) { 13 | return res.status(404).json({ message: "Token not found" }); 14 | } 15 | 16 | if (!ENV.IMMICH_SHARE_LINK_KEY) { 17 | return res.status(401).json({ message: "Please check your link and try again. If you're the admin, check if you've enabled all the configurations in the Immich Power Tools in your environment variables" }); 18 | } 19 | 20 | const decoded = verify(token as string, ENV.JWT_SECRET); 21 | 22 | 23 | if (!decoded) { 24 | return res.status(401).json({ message: "Link is invalid. Please check your link and try again" }); 25 | } 26 | 27 | 28 | return res.status(200).json(decoded); 29 | } catch (error) { 30 | if (error instanceof JsonWebTokenError) { 31 | return res.status(401).json({ message: "Please check your link and try again. Looks like it's expired." }); 32 | } 33 | return res.status(500).json({ message: (error as Error).message }); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/pages/api/share-link/generate.ts: -------------------------------------------------------------------------------- 1 | 2 | import { ENV } from "@/config/environment"; 3 | import { getCurrentUser } from "@/handlers/serverUtils/user.utils"; 4 | import { sign } from "jsonwebtoken"; 5 | import { NextApiRequest, NextApiResponse } from "next"; 6 | 7 | 8 | export default async function handler(req: NextApiRequest, res: NextApiResponse) { 9 | const currentUser = await getCurrentUser(req); 10 | const allFilters = req.body; 11 | const { expiresIn, ...filters } = allFilters; 12 | 13 | try { 14 | if (!currentUser) { 15 | return res.status(401).json({ message: "Unauthorized" }); 16 | } 17 | 18 | if (!ENV.JWT_SECRET) { 19 | return res.status(401).json({ message: "The JWT_SECRET is missing in the .env file. Please add it to the .env file for generating share links" }); 20 | } 21 | 22 | if (!ENV.IMMICH_SHARE_LINK_KEY) { 23 | return res.status(401).json({ message: "The IMMICH_SHARE_LINK_KEY is missing in the .env file. Please add it to the .env file for generating share links" }); 24 | } 25 | 26 | if (!ENV.POWER_TOOLS_ENDPOINT_URL) { 27 | return res.status(401).json({ message: "The POWER_TOOLS_ENDPOINT_URL is missing in the .env file. Please add it to the .env file for generating share links" }); 28 | } 29 | 30 | const token = sign(filters, ENV.JWT_SECRET, expiresIn !== "never" ? { 31 | expiresIn: expiresIn 32 | } : {}); 33 | 34 | return res.status(200).json({ link: `${ENV.POWER_TOOLS_ENDPOINT_URL}/s/${token}` }); 35 | } catch (error) { 36 | return res.status(500).json({ message: (error as Error).message }); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/pages/api/users/login.ts: -------------------------------------------------------------------------------- 1 | import { appConfig } from "@/config/app.config"; 2 | import { loginUser } from "@/handlers/serverUtils/user.utils"; 3 | import { serializeCookie } from "@/lib/cookie"; 4 | import { NextApiRequest, NextApiResponse } from "next"; 5 | 6 | export const config = { 7 | api: { 8 | bodyParser: true, 9 | }, 10 | }; 11 | 12 | export default async function handler( 13 | req: NextApiRequest, 14 | res: NextApiResponse 15 | ) { 16 | if (req.method !== "POST") { 17 | return res.status(405).json({ message: "Method Not Allowed" }); 18 | } 19 | 20 | const { email, password } = req.body; 21 | if (!email || !password) { 22 | return res.status(400).json({ message: "Email and password are required" }); 23 | } 24 | 25 | const user = await loginUser(email, password); 26 | if (user) { 27 | res.setHeader("Set-Cookie", serializeCookie(appConfig.sessionCookieName, user.accessToken)); 28 | return res.status(200).json(user); 29 | } 30 | else { 31 | return res.status(403).json({ message: "Invalid User Credentials" }); 32 | } 33 | 34 | } 35 | -------------------------------------------------------------------------------- /src/pages/api/users/logout.ts: -------------------------------------------------------------------------------- 1 | // pages/api/proxy.js 2 | 3 | import { appConfig } from "@/config/app.config"; 4 | import { getCurrentUser, logoutUser } from "@/handlers/serverUtils/user.utils"; 5 | import { serializeCookie } from "@/lib/cookie"; 6 | import { NextApiRequest, NextApiResponse } from "next"; 7 | 8 | export const config = { 9 | api: { 10 | bodyParser: true, 11 | }, 12 | }; 13 | 14 | export default async function handler( 15 | req: NextApiRequest, 16 | res: NextApiResponse 17 | ) { 18 | if (req.method !== "POST") { 19 | return res.status(405).json({ message: "Method Not Allowed" }); 20 | } 21 | 22 | 23 | try { 24 | const user = await getCurrentUser(req); 25 | const result = await logoutUser(user.accessToken as string); 26 | if (result.successful) { 27 | res.setHeader("Set-Cookie", serializeCookie(appConfig.sessionCookieName, "")); 28 | return res.status(200).json({ message: "Logged out successfully" }); 29 | } else { 30 | return res.status(403).json({ message: "Invalid User Credentials" }); 31 | } 32 | } catch (error) { 33 | console.error("Logout error:", error); 34 | return res.status(500).json({ message: "Internal Server Error" }); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/pages/api/users/me.ts: -------------------------------------------------------------------------------- 1 | // pages/api/proxy.js 2 | 3 | import { db } from "@/config/db"; 4 | import { ENV } from "@/config/environment"; 5 | import { getCurrentUser } from "@/handlers/serverUtils/user.utils"; 6 | import { NextApiRequest, NextApiResponse } from "next"; 7 | 8 | export const config = { 9 | api: { 10 | bodyParser: false, 11 | }, 12 | }; 13 | 14 | export default async function handler( 15 | req: NextApiRequest, 16 | res: NextApiResponse 17 | ) { 18 | if (req.method !== "GET") { 19 | return res.status(405).json({ message: "Method Not Allowed" }); 20 | } 21 | 22 | try { 23 | if (!db) { 24 | return res.status(500).json({ message: "Database connection failed" }); 25 | } 26 | 27 | const user = await getCurrentUser(req); 28 | 29 | if (user) return res.status(200).json(user); 30 | 31 | return res.status(403).json({ message: "Forbidden" }); 32 | 33 | } catch (error: any) { 34 | return res.status(500).json({ 35 | message: error.message ||"Internal Server Error", 36 | error: error.error || "Internal Server Error", 37 | }); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/pages/index.tsx: -------------------------------------------------------------------------------- 1 | import PeopleList from "@/components/people/PeopleList"; 2 | 3 | export default function Home() { 4 | return 5 | } 6 | -------------------------------------------------------------------------------- /src/schema/albumAssetsAssets.schema.ts: -------------------------------------------------------------------------------- 1 | import { pgTable, uuid, timestamp } from 'drizzle-orm/pg-core'; 2 | 3 | export const albumsAssetsAssets = pgTable('albums_assets_assets', { 4 | albumsId: uuid('albumsId').notNull(), 5 | assetsId: uuid('assetsId').notNull(), 6 | createdAt: timestamp('createdAt', { withTimezone: true }).defaultNow().notNull(), 7 | }); -------------------------------------------------------------------------------- /src/schema/albums.schema.ts: -------------------------------------------------------------------------------- 1 | import { pgTable, uuid, varchar, timestamp, boolean, text } from 'drizzle-orm/pg-core'; 2 | 3 | export const albums = pgTable('albums', { 4 | id: uuid('id').defaultRandom().primaryKey().notNull(), 5 | ownerId: uuid('ownerId').notNull(), 6 | albumName: varchar('albumName').notNull().default('Untitled Album'), 7 | createdAt: timestamp('createdAt', { withTimezone: true }).defaultNow().notNull(), 8 | albumThumbnailAssetId: uuid('albumThumbnailAssetId'), 9 | updatedAt: timestamp('updatedAt', { withTimezone: true }).defaultNow().notNull(), 10 | description: text('description').notNull().default(''), 11 | deletedAt: timestamp('deletedAt', { withTimezone: true }), 12 | isActivityEnabled: boolean('isActivityEnabled').notNull().default(true), 13 | order: varchar('order').notNull().default('desc'), 14 | }); 15 | -------------------------------------------------------------------------------- /src/schema/assetFaces.schema.ts: -------------------------------------------------------------------------------- 1 | import { pgTable, uuid, integer } from "drizzle-orm/pg-core"; 2 | 3 | export const assetFaces = pgTable("asset_faces", { 4 | id: uuid("id").defaultRandom().primaryKey(), 5 | assetId: uuid("assetId").notNull(), 6 | personId: uuid("personId"), 7 | imageWidth: integer("imageWidth").notNull().default(0), 8 | imageHeight: integer("imageHeight").notNull().default(0), 9 | boundingBoxX1: integer("boundingBoxX1").notNull().default(0), 10 | boundingBoxY1: integer("boundingBoxY1").notNull().default(0), 11 | boundingBoxX2: integer("boundingBoxX2").notNull().default(0), 12 | boundingBoxY2: integer("boundingBoxY2").notNull().default(0), 13 | }); 14 | -------------------------------------------------------------------------------- /src/schema/assets.schema.ts: -------------------------------------------------------------------------------- 1 | import { pgTable, uuid, varchar, timestamp, boolean } from 'drizzle-orm/pg-core'; 2 | 3 | export const assets = pgTable('assets', { 4 | id: uuid('id').defaultRandom().primaryKey().notNull(), 5 | deviceAssetId: varchar('deviceAssetId').notNull(), 6 | ownerId: uuid('ownerId').notNull(), 7 | deviceId: varchar('deviceId').notNull(), 8 | type: varchar('type').notNull(), 9 | originalPath: varchar('originalPath').notNull(), 10 | fileCreatedAt: timestamp('fileCreatedAt', { withTimezone: true }).notNull(), 11 | fileModifiedAt: timestamp('fileModifiedAt', { withTimezone: true }).notNull(), 12 | isFavorite: boolean('isFavorite').default(false).notNull(), 13 | duration: varchar('duration'), 14 | encodedVideoPath: varchar('encodedVideoPath').default(''), 15 | // checksum: bytea('checksum').notNull(), 16 | visibility: varchar('visibility').default('timeline').notNull(), 17 | livePhotoVideoId: uuid('livePhotoVideoId').references((): any => assets.id, { onDelete: 'set null', onUpdate: 'cascade' }), 18 | updatedAt: timestamp('updatedAt', { withTimezone: true }).defaultNow().notNull(), 19 | createdAt: timestamp('createdAt', { withTimezone: true }).defaultNow().notNull(), 20 | status: varchar('status').default('active').notNull(), 21 | originalFileName: varchar('originalFileName').notNull(), 22 | sidecarPath: varchar('sidecarPath'), 23 | // thumbhash: bytea('thumbhash'), 24 | isOffline: boolean('isOffline').default(false).notNull(), 25 | // libraryId: uuid('libraryId').references(() => libraries.id, { onDelete: 'cascade', onUpdate: 'cascade' }), 26 | isExternal: boolean('isExternal').default(false).notNull(), 27 | deletedAt: timestamp('deletedAt', { withTimezone: true }), 28 | localDateTime: timestamp('localDateTime', { withTimezone: true }).notNull(), 29 | stackId: uuid('stackId'), 30 | duplicateId: uuid('duplicateId'), 31 | }); 32 | 33 | // // Note: You'll need to define these referenced tables as well 34 | // export const users = pgTable('users', { 35 | // id: uuid('id').primaryKey(), 36 | // // ... other columns 37 | // }); 38 | 39 | // export const libraries = pgTable('libraries', { 40 | // id: uuid('id').primaryKey(), 41 | // // ... other columns 42 | // }); 43 | 44 | // export const assetStack = pgTable('asset_stack', { 45 | // id: uuid('id').primaryKey(), 46 | // // ... other columns 47 | // }); -------------------------------------------------------------------------------- /src/schema/exif.schema.ts: -------------------------------------------------------------------------------- 1 | import { pgTable, uuid, varchar, integer, bigint, timestamp, doublePrecision, text } from 'drizzle-orm/pg-core'; 2 | import { assets } from './assets.schema'; // Assuming you have an assets table defined 3 | 4 | export const exif = pgTable('exif', { 5 | assetId: uuid('assetId').primaryKey().notNull().references(() => assets.id, { onDelete: 'cascade' }), 6 | make: varchar('make'), 7 | model: varchar('model'), 8 | exifImageWidth: integer('exifImageWidth'), 9 | exifImageHeight: integer('exifImageHeight'), 10 | fileSizeInByte: bigint('fileSizeInByte', { mode: 'number' }), 11 | orientation: varchar('orientation'), 12 | dateTimeOriginal: timestamp('dateTimeOriginal', { withTimezone: true }), 13 | modifyDate: timestamp('modifyDate', { withTimezone: true }), 14 | lensModel: varchar('lensModel'), 15 | fNumber: doublePrecision('fNumber'), 16 | focalLength: doublePrecision('focalLength'), 17 | iso: integer('iso'), 18 | latitude: doublePrecision('latitude'), 19 | longitude: doublePrecision('longitude'), 20 | city: varchar('city'), 21 | state: varchar('state'), 22 | country: varchar('country'), 23 | description: text('description').notNull().default(''), 24 | fps: doublePrecision('fps'), 25 | exposureTime: varchar('exposureTime'), 26 | livePhotoCID: varchar('livePhotoCID'), 27 | timeZone: varchar('timeZone'), 28 | projectionType: varchar('projectionType'), 29 | profileDescription: varchar('profileDescription'), 30 | colorspace: varchar('colorspace'), 31 | bitsPerSample: integer('bitsPerSample'), 32 | autoStackId: varchar('autoStackId'), 33 | rating: integer('rating'), 34 | }); 35 | 36 | export type IExifColumns = keyof typeof exif.$inferSelect -------------------------------------------------------------------------------- /src/schema/faceSearch.schema.ts: -------------------------------------------------------------------------------- 1 | import { pgTable, uuid, vector } from "drizzle-orm/pg-core"; 2 | 3 | export const faceSearch = pgTable("face_search", { 4 | faceId: uuid("faceId").notNull(), 5 | embedding: vector("embedding", { 6 | dimensions: 512, 7 | }).notNull(), 8 | }); 9 | 10 | -------------------------------------------------------------------------------- /src/schema/index.ts: -------------------------------------------------------------------------------- 1 | export { assets } from './assets.schema'; 2 | export { exif } from './exif.schema'; 3 | export { person } from './person.schema'; 4 | export { assetFaces } from './assetFaces.schema'; 5 | export { users } from './users.schema'; 6 | export * from './relationships'; -------------------------------------------------------------------------------- /src/schema/person.schema.ts: -------------------------------------------------------------------------------- 1 | import { pgTable, uuid, varchar, timestamp, boolean, date, pgEnum } from "drizzle-orm/pg-core"; 2 | 3 | 4 | export const person = pgTable("person", { 5 | id: uuid("id").defaultRandom().primaryKey(), 6 | createdAt: timestamp("createdAt", { withTimezone: true }).defaultNow(), 7 | updatedAt: timestamp("updatedAt", { withTimezone: true }).defaultNow(), 8 | ownerId: uuid("ownerId").notNull(), 9 | name: varchar("name").notNull().default(''), 10 | thumbnailPath: varchar("thumbnailPath").notNull().default(''), 11 | isHidden: boolean("isHidden").notNull().default(false), 12 | birthDate: date("birthDate", { mode: "date" }), 13 | faceAssetId: uuid("faceAssetId"), 14 | }); 15 | 16 | export type Person = typeof person.$inferSelect; -------------------------------------------------------------------------------- /src/schema/relationships.ts: -------------------------------------------------------------------------------- 1 | import { relations } from "drizzle-orm"; 2 | import { assetFaces } from "./assetFaces.schema"; 3 | import { assets } from "./assets.schema"; 4 | import { person } from "./person.schema"; 5 | import { users } from "./users.schema"; 6 | import { faceSearch } from "./faceSearch.schema"; 7 | import { albumsAssetsAssets } from "./albumAssetsAssets.schema"; 8 | import { albums } from "./albums.schema"; 9 | // Assuming there are relations to be defined with `assets` and `person` 10 | export const assetFacesRelations = relations(assetFaces, ({ one }) => ({ 11 | asset: one(assets, { 12 | fields: [assetFaces.assetId], 13 | references: [assets.id], 14 | }), 15 | person: one(person, { 16 | fields: [assetFaces.personId], 17 | references: [person.id], 18 | }), 19 | })); 20 | 21 | 22 | // Assuming there are relations to be defined with `users` and `asset_faces` 23 | export const personRelations = relations(person, ({ one }) => ({ 24 | owner: one(users, { 25 | fields: [person.ownerId], 26 | references: [users.id], 27 | }), 28 | faceAsset: one(assetFaces, { 29 | fields: [person.faceAssetId], 30 | references: [assetFaces.id], 31 | }), 32 | })); 33 | 34 | 35 | // Assuming there are relations to be defined with `asset_faces` 36 | export const faceSearchRelations = relations(faceSearch, ({ one }) => ({ 37 | face: one(assetFaces, { 38 | fields: [faceSearch.faceId], 39 | references: [assetFaces.id], 40 | }), 41 | })); 42 | 43 | 44 | // Assuming there are relations to be defined with `albums` and `assets` 45 | export const albumsAssetsAssetsRelations = relations(albumsAssetsAssets, ({ one }) => ({ 46 | album: one(albums, { 47 | fields: [albumsAssetsAssets.albumsId], 48 | references: [albums.id], 49 | }), 50 | asset: one(assets, { 51 | fields: [albumsAssetsAssets.assetsId], 52 | references: [assets.id], 53 | }), 54 | })); 55 | 56 | -------------------------------------------------------------------------------- /src/schema/users.schema.ts: -------------------------------------------------------------------------------- 1 | import { pgTable, uuid, varchar, timestamp, boolean, bigint } from "drizzle-orm/pg-core"; 2 | 3 | export const users = pgTable("users", { 4 | id: uuid("id").defaultRandom().primaryKey(), 5 | email: varchar("email").notNull(), 6 | password: varchar("password").notNull().default(''), 7 | createdAt: timestamp("createdAt", { withTimezone: true }).defaultNow(), 8 | profileImagePath: varchar("profileImagePath").notNull().default(''), 9 | isAdmin: boolean("isAdmin").notNull().default(false), 10 | shouldChangePassword: boolean("shouldChangePassword").notNull().default(true), 11 | deletedAt: timestamp("deletedAt", { withTimezone: true }), 12 | oauthId: varchar("oauthId").notNull().default(''), 13 | updatedAt: timestamp("updatedAt", { withTimezone: true }).defaultNow(), 14 | storageLabel: varchar("storageLabel"), 15 | name: varchar("name").notNull().default(''), 16 | quotaSizeInBytes: bigint("quotaSizeInBytes", { 17 | mode: 'bigint' 18 | }), 19 | quotaUsageInBytes: bigint("quotaUsageInBytes", { 20 | mode: 'bigint' 21 | }).notNull(), 22 | status: varchar("status").notNull().default('active'), 23 | }); 24 | -------------------------------------------------------------------------------- /src/styles/globals.scss: -------------------------------------------------------------------------------- 1 | @import url("https://fonts.googleapis.com/css2?family=Inter"); 2 | 3 | @tailwind base; 4 | @tailwind components; 5 | @tailwind utilities; 6 | 7 | @layer base { 8 | :root { 9 | --background: 0 0% 100%; 10 | --foreground: 0 0% 3.9%; 11 | --card: 0 0% 100%; 12 | --card-foreground: 0 0% 3.9%; 13 | --popover: 0 0% 100%; 14 | --popover-foreground: 0 0% 3.9%; 15 | --primary: 0 0% 9%; 16 | --primary-foreground: 0 0% 98%; 17 | --secondary: 0 0% 96.1%; 18 | --secondary-foreground: 0 0% 9%; 19 | --muted: 0 0% 96.1%; 20 | --muted-foreground: 0 0% 45.1%; 21 | --accent: 0 0% 96.1%; 22 | --accent-foreground: 0 0% 9%; 23 | --destructive: 0 84.2% 60.2%; 24 | --destructive-foreground: 0 0% 98%; 25 | --border: 0 0% 89.8%; 26 | --input: 0 0% 89.8%; 27 | --ring: 0 0% 3.9%; 28 | --radius: 0.5rem; 29 | --chart-1: 12 76% 61%; 30 | --chart-2: 173 58% 39%; 31 | --chart-3: 197 37% 24%; 32 | --chart-4: 43 74% 66%; 33 | --chart-5: 27 87% 67%; 34 | } 35 | 36 | .dark { 37 | --background: 0 0% 3.9%; 38 | --foreground: 0 0% 98%; 39 | --card: 0 0% 3.9%; 40 | --card-foreground: 0 0% 98%; 41 | --popover: 0 0% 3.9%; 42 | --popover-foreground: 0 0% 98%; 43 | --primary: 0 0% 98%; 44 | --primary-foreground: 0 0% 9%; 45 | --secondary: 0 0% 14.9%; 46 | --secondary-foreground: 0 0% 98%; 47 | --muted: 0 0% 14.9%; 48 | --muted-foreground: 0 0% 63.9%; 49 | --accent: 0 0% 14.9%; 50 | --accent-foreground: 0 0% 98%; 51 | --destructive: 0 62.8% 30.6%; 52 | --destructive-foreground: 0 0% 98%; 53 | --border: 0 0% 14.9%; 54 | --input: 0 0% 14.9%; 55 | --ring: 0 0% 83.1%; 56 | --chart-1: 220 70% 50%; 57 | --chart-2: 160 60% 45%; 58 | --chart-3: 30 80% 55%; 59 | --chart-4: 280 65% 60%; 60 | --chart-5: 340 75% 55%; 61 | } 62 | } 63 | 64 | @layer base { 65 | * { 66 | @apply border-border; 67 | } 68 | body { 69 | @apply bg-background text-foreground; 70 | } 71 | } 72 | 73 | @layer base { 74 | :root { 75 | --chart-1: 12 76% 61%; 76 | --chart-2: 173 58% 39%; 77 | --chart-3: 197 37% 24%; 78 | --chart-4: 43 74% 66%; 79 | --chart-5: 27 87% 67%; 80 | } 81 | 82 | .dark { 83 | --chart-1: 220 70% 50%; 84 | --chart-2: 160 60% 45%; 85 | --chart-3: 30 80% 55%; 86 | --chart-4: 280 65% 60%; 87 | --chart-5: 340 75% 55%; 88 | } 89 | } 90 | 91 | 92 | #ReactGridGallery img { 93 | max-width: none !important; 94 | height: none !important; 95 | } 96 | 97 | .dark { 98 | .ReactGridGallery_tile { 99 | @apply bg-zinc-800 !important; 100 | } 101 | } -------------------------------------------------------------------------------- /src/types/album.d.ts: -------------------------------------------------------------------------------- 1 | export interface IAlbum { 2 | albumName: string; 3 | description: string; 4 | albumThumbnailAssetId: string; 5 | createdAt: Date; 6 | updatedAt: Date; 7 | id: string; 8 | ownerId: string; 9 | owner?: IAlbumOwner; 10 | albumUsers?: any[]; 11 | shared?: boolean; 12 | hasSharedLink?: boolean; 13 | startDate?: Date; 14 | endDate?: Date; 15 | assets?: any[]; 16 | assetCount: number; 17 | isActivityEnabled: boolean; 18 | order: string; 19 | lastModifiedAssetTimestamp: Date; 20 | firstPhotoDate: Date; 21 | lastPhotoDate: Date; 22 | faceCount: number; 23 | size: string; 24 | } 25 | 26 | export interface IAlbumOwner { 27 | id: string; 28 | email: string; 29 | name: string; 30 | profileImagePath: string; 31 | avatarColor: string; 32 | } 33 | 34 | export interface IAlbumCreate { 35 | albumName: string; 36 | assetIds?: string[]; 37 | albumUsers?: { 38 | role: string; 39 | userId: string; 40 | }[]; 41 | } -------------------------------------------------------------------------------- /src/types/asset.d.ts: -------------------------------------------------------------------------------- 1 | export interface IAsset { 2 | id: string; 3 | ownerId: string; 4 | deviceId: string; 5 | type: string; 6 | originalPath: string; 7 | isFavorite: boolean; 8 | duration: number | null; 9 | encodedVideoPath: string | null; 10 | originalFileName: string; 11 | thumbhash?: IAssetThumbhash; 12 | localDateTime: string | Date; 13 | exifImageWidth: number; 14 | exifImageHeight: number; 15 | url: string; 16 | previewUrl: string; 17 | videoURL?: string; 18 | dateTimeOriginal: string; 19 | orientation?: number | null | string; 20 | downloadUrl?: string; 21 | } 22 | 23 | export interface IAssetThumbhash { 24 | type: string; 25 | data: number[]; 26 | } 27 | -------------------------------------------------------------------------------- /src/types/common.d.ts: -------------------------------------------------------------------------------- 1 | export interface IListData { 2 | hasNextPage: boolean, 3 | total: number, 4 | hidden: number, 5 | } 6 | 7 | export interface IPlace { 8 | name: string; 9 | latitude: number; 10 | longitude: number; 11 | } -------------------------------------------------------------------------------- /src/types/person.d.ts: -------------------------------------------------------------------------------- 1 | export interface IPerson { 2 | id: string; 3 | name: string; 4 | birthDate: Date | null; 5 | thumbnailPath: string; 6 | isHidden: boolean; 7 | updatedAt: Date; 8 | assetCount: number; 9 | similarity?: number; 10 | } 11 | 12 | interface IPeopleListResponse extends IListData{ 13 | people: IPerson[] 14 | total: number 15 | } -------------------------------------------------------------------------------- /src/types/shareLink.d.ts: -------------------------------------------------------------------------------- 1 | export interface ShareLinkFilters { 2 | personIds?: string[]; 3 | albumIds?: string[]; 4 | startDate?: string; 5 | endDate?: string; 6 | p?: boolean; 7 | expiresIn?: string; 8 | } -------------------------------------------------------------------------------- /src/types/user.d.ts: -------------------------------------------------------------------------------- 1 | export interface IUser { 2 | id: string; 3 | email: string; 4 | name: string; 5 | profileImagePath: string; 6 | avatarColor: string; 7 | storageLabel: string; 8 | shouldChangePassword: boolean; 9 | isAdmin: boolean; 10 | createdAt: Date; 11 | deletedAt: null; 12 | updatedAt: Date; 13 | oauthId: string; 14 | quotaSizeInBytes: null; 15 | quotaUsageInBytes: number; 16 | status: string; 17 | license: null; 18 | isUsingAPIKey?: boolean; 19 | accessToken?: string; 20 | } 21 | -------------------------------------------------------------------------------- /tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from "tailwindcss" 2 | import { fontFamily } from "tailwindcss/defaultTheme" 3 | 4 | const config = { 5 | darkMode: ["class"], 6 | content: [ 7 | './pages/**/*.{ts,tsx}', 8 | './components/**/*.{ts,tsx}', 9 | './app/**/*.{ts,tsx}', 10 | './src/**/*.{ts,tsx}', 11 | ], 12 | prefix: "", 13 | theme: { 14 | fontFamily: { 15 | mono: [ ...fontFamily.mono ] 16 | }, 17 | container: { 18 | center: true, 19 | padding: '2rem', 20 | screens: { 21 | '2xl': '1400px' 22 | } 23 | }, 24 | extend: { 25 | colors: { 26 | border: 'hsl(var(--border))', 27 | input: 'hsl(var(--input))', 28 | ring: 'hsl(var(--ring))', 29 | background: 'hsl(var(--background))', 30 | foreground: 'hsl(var(--foreground))', 31 | primary: { 32 | DEFAULT: 'hsl(var(--primary))', 33 | foreground: 'hsl(var(--primary-foreground))' 34 | }, 35 | secondary: { 36 | DEFAULT: 'hsl(var(--secondary))', 37 | foreground: 'hsl(var(--secondary-foreground))' 38 | }, 39 | destructive: { 40 | DEFAULT: 'hsl(var(--destructive))', 41 | foreground: 'hsl(var(--destructive-foreground))' 42 | }, 43 | muted: { 44 | DEFAULT: 'hsl(var(--muted))', 45 | foreground: 'hsl(var(--muted-foreground))' 46 | }, 47 | accent: { 48 | DEFAULT: 'hsl(var(--accent))', 49 | foreground: 'hsl(var(--accent-foreground))' 50 | }, 51 | popover: { 52 | DEFAULT: 'hsl(var(--popover))', 53 | foreground: 'hsl(var(--popover-foreground))' 54 | }, 55 | card: { 56 | DEFAULT: 'hsl(var(--card))', 57 | foreground: 'hsl(var(--card-foreground))' 58 | } 59 | }, 60 | borderRadius: { 61 | lg: 'var(--radius)', 62 | md: 'calc(var(--radius) - 2px)', 63 | sm: 'calc(var(--radius) - 4px)' 64 | }, 65 | keyframes: { 66 | 'accordion-down': { 67 | from: { 68 | height: '0' 69 | }, 70 | to: { 71 | height: 'var(--radix-accordion-content-height)' 72 | } 73 | }, 74 | 'accordion-up': { 75 | from: { 76 | height: 'var(--radix-accordion-content-height)' 77 | }, 78 | to: { 79 | height: '0' 80 | } 81 | } 82 | }, 83 | animation: { 84 | 'accordion-down': 'accordion-down 0.2s ease-out', 85 | 'accordion-up': 'accordion-up 0.2s ease-out' 86 | } 87 | } 88 | }, 89 | plugins: [require("tailwindcss-animate")], 90 | } satisfies Config 91 | 92 | export default config -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": ["dom", "dom.iterable", "esnext"], 4 | "allowJs": true, 5 | "skipLibCheck": true, 6 | "strict": true, 7 | "noEmit": true, 8 | "esModuleInterop": true, 9 | "module": "esnext", 10 | "moduleResolution": "bundler", 11 | "resolveJsonModule": true, 12 | "isolatedModules": true, 13 | "jsx": "preserve", 14 | "incremental": true, 15 | "target": "ESNext", 16 | "paths": { 17 | "@/*": ["./src/*"] 18 | } 19 | }, 20 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"], 21 | "exclude": ["node_modules"] 22 | } 23 | --------------------------------------------------------------------------------