├── .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 | 
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 |
56 |
57 |
58 | {scenes.map((scene, index) => (
59 |
65 | {renderScene(scene)}
66 |
67 | ))}
68 |
69 |
70 |
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 |

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

30 |
Immich Power Tools
31 |
32 |
33 |
34 |
35 |
52 |
53 |
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
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 |
--------------------------------------------------------------------------------