mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-15 03:01:09 +02:00
✨ feat(photos): add Photos app with mana-media EXIF integration
- Add Photos NestJS backend (port 3019) with albums, favorites, tags - Add Photos SvelteKit web app (port 5189) with gallery, upload, filters - Extend mana-media with EXIF extraction service using exifr - Add cross-app photo listing endpoint to mana-media - Add photo stats endpoint to mana-media - Add photos to setup-databases.sh Backend features: - Albums CRUD with cover image and items management - Favorites toggle with status check - Tags CRUD with photo-tag associations - Photo proxy to mana-media with local data enrichment Web features: - Photo grid with infinite scroll - Photo detail modal with EXIF display - Album grid and detail views - Upload dropzone with progress tracking - Filter bar (app, date range, location, sort) - i18n support (de/en) - Svelte 5 runes mode Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
d3392f69a9
commit
90c2f8573e
80 changed files with 6891 additions and 503 deletions
212
apps/photos/CLAUDE.md
Normal file
212
apps/photos/CLAUDE.md
Normal file
|
|
@ -0,0 +1,212 @@
|
|||
# Photos Project Guide
|
||||
|
||||
## Overview
|
||||
|
||||
**Photos** is a unified photo gallery application for the ManaCore ecosystem. It aggregates photos from all apps (Picture, Chat, Contacts, NutriPhi, etc.) via the mana-media service, providing a central place to view, organize, and manage photos.
|
||||
|
||||
| App | Port | URL |
|
||||
|-----|------|-----|
|
||||
| Backend | 3019 | http://localhost:3019 |
|
||||
| Web App | 5189 | http://localhost:5189 |
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
apps/photos/
|
||||
├── apps/
|
||||
│ ├── backend/ # NestJS API server (@photos/backend)
|
||||
│ └── web/ # SvelteKit web application (@photos/web) [TODO]
|
||||
├── packages/
|
||||
│ └── shared/ # Shared types (@photos/shared)
|
||||
├── package.json
|
||||
└── CLAUDE.md
|
||||
```
|
||||
|
||||
## Commands
|
||||
|
||||
### Root Level (from monorepo root)
|
||||
|
||||
```bash
|
||||
# All apps
|
||||
pnpm photos:dev # Run all photos apps
|
||||
|
||||
# Individual apps
|
||||
pnpm dev:photos:backend # Start backend server (port 3019)
|
||||
pnpm dev:photos:web # Start web app (port 5189)
|
||||
|
||||
# Database
|
||||
pnpm photos:db:push # Push schema to database
|
||||
pnpm photos:db:studio # Open Drizzle Studio
|
||||
```
|
||||
|
||||
### Backend (apps/photos/apps/backend)
|
||||
|
||||
```bash
|
||||
pnpm dev # Start with hot reload
|
||||
pnpm build # Build for production
|
||||
pnpm start:prod # Start production server
|
||||
pnpm db:push # Push schema to database
|
||||
pnpm db:studio # Open Drizzle Studio
|
||||
```
|
||||
|
||||
## Technology Stack
|
||||
|
||||
| Layer | Technology |
|
||||
|-------|------------|
|
||||
| **Backend** | NestJS 10, Drizzle ORM, PostgreSQL |
|
||||
| **Web** | SvelteKit 2.x, Svelte 5 (runes mode), Tailwind CSS |
|
||||
| **Auth** | Mana Core Auth (JWT) |
|
||||
| **Media** | mana-media service (central media storage) |
|
||||
|
||||
## Core Features
|
||||
|
||||
1. **Gallery** - View all photos across apps in grid/list view
|
||||
2. **Albums** - Organize photos into custom albums
|
||||
3. **Favorites** - Mark photos as favorites
|
||||
4. **Tags** - Tag photos for organization
|
||||
5. **EXIF Data** - View camera, location, and date metadata
|
||||
6. **Upload** - Upload new photos directly
|
||||
7. **Smart Albums** - Auto-generated albums by date/location/camera
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌────────────────┐
|
||||
│ Photos Web │ SvelteKit (Port 5189)
|
||||
│ │ Gallery, Albums, Upload
|
||||
└───────┬────────┘
|
||||
│
|
||||
▼
|
||||
┌────────────────┐
|
||||
│ Photos Backend │ NestJS (Port 3019)
|
||||
│ │ Albums, Favorites, Tags
|
||||
└───────┬────────┘
|
||||
│
|
||||
▼
|
||||
┌────────────────┐
|
||||
│ mana-media │ (Port 3015)
|
||||
│ │ Central media storage
|
||||
└────────────────┘
|
||||
```
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### Photos (proxy to mana-media with enrichment)
|
||||
|
||||
| Endpoint | Method | Description |
|
||||
|----------|--------|-------------|
|
||||
| `/api/v1/photos` | GET | List photos with filters |
|
||||
| `/api/v1/photos/:mediaId` | GET | Get photo with metadata |
|
||||
| `/api/v1/photos/stats` | GET | Get photo statistics |
|
||||
|
||||
### Albums
|
||||
|
||||
| Endpoint | Method | Description |
|
||||
|----------|--------|-------------|
|
||||
| `/api/v1/albums` | GET | List user's albums |
|
||||
| `/api/v1/albums` | POST | Create album |
|
||||
| `/api/v1/albums/:id` | GET | Get album with items |
|
||||
| `/api/v1/albums/:id` | PATCH | Update album |
|
||||
| `/api/v1/albums/:id` | DELETE | Delete album |
|
||||
| `/api/v1/albums/:id/items` | POST | Add photos to album |
|
||||
| `/api/v1/albums/:id/items/:mediaId` | DELETE | Remove photo from album |
|
||||
| `/api/v1/albums/:id/cover` | PATCH | Set album cover |
|
||||
|
||||
### Favorites
|
||||
|
||||
| Endpoint | Method | Description |
|
||||
|----------|--------|-------------|
|
||||
| `/api/v1/favorites` | GET | List favorited photos |
|
||||
| `/api/v1/favorites/:mediaId` | POST | Add to favorites |
|
||||
| `/api/v1/favorites/:mediaId` | DELETE | Remove from favorites |
|
||||
| `/api/v1/favorites/:mediaId/toggle` | POST | Toggle favorite status |
|
||||
|
||||
### Tags
|
||||
|
||||
| Endpoint | Method | Description |
|
||||
|----------|--------|-------------|
|
||||
| `/api/v1/tags` | GET | List user's tags |
|
||||
| `/api/v1/tags` | POST | Create tag |
|
||||
| `/api/v1/tags/:id` | PATCH | Update tag |
|
||||
| `/api/v1/tags/:id` | DELETE | Delete tag |
|
||||
| `/api/v1/photos/:mediaId/tags` | GET | Get tags for photo |
|
||||
| `/api/v1/photos/:mediaId/tags/:tagId` | POST | Add tag to photo |
|
||||
| `/api/v1/photos/:mediaId/tags/:tagId` | DELETE | Remove tag from photo |
|
||||
|
||||
## Database Schema
|
||||
|
||||
### albums
|
||||
- `id` (UUID) - Primary key
|
||||
- `user_id` (TEXT) - Owner
|
||||
- `name` (VARCHAR) - Album name
|
||||
- `description` (TEXT) - Description
|
||||
- `cover_media_id` (TEXT) - Cover photo (mana-media ID)
|
||||
- `is_auto_generated` (BOOLEAN) - Smart album flag
|
||||
- `auto_generate_type` (TEXT) - date/location/camera
|
||||
- `auto_generate_value` (TEXT) - Filter value
|
||||
|
||||
### album_items
|
||||
- `id` (UUID) - Primary key
|
||||
- `album_id` (UUID) - FK to albums
|
||||
- `media_id` (TEXT) - mana-media ID
|
||||
- `sort_order` (INTEGER) - Sort order
|
||||
|
||||
### favorites
|
||||
- `id` (UUID) - Primary key
|
||||
- `user_id` (TEXT) - Owner
|
||||
- `media_id` (TEXT) - mana-media ID
|
||||
|
||||
### tags
|
||||
- `id` (UUID) - Primary key
|
||||
- `user_id` (TEXT) - Owner
|
||||
- `name` (VARCHAR) - Tag name
|
||||
- `color` (VARCHAR) - Hex color
|
||||
|
||||
### photo_tags
|
||||
- `media_id` (TEXT) - mana-media ID
|
||||
- `tag_id` (UUID) - FK to tags
|
||||
|
||||
## Environment Variables
|
||||
|
||||
### Backend (.env)
|
||||
|
||||
```env
|
||||
NODE_ENV=development
|
||||
PORT=3019
|
||||
DATABASE_URL=postgresql://manacore:devpassword@localhost:5432/photos
|
||||
MANA_CORE_AUTH_URL=http://localhost:3001
|
||||
MANA_MEDIA_URL=http://localhost:3015
|
||||
CORS_ORIGINS=http://localhost:5173,http://localhost:5189,http://localhost:8081
|
||||
```
|
||||
|
||||
### Web (.env)
|
||||
|
||||
```env
|
||||
PUBLIC_BACKEND_URL=http://localhost:3019
|
||||
PUBLIC_MANA_CORE_AUTH_URL=http://localhost:3001
|
||||
PUBLIC_MANA_MEDIA_URL=http://localhost:3015
|
||||
```
|
||||
|
||||
## Query Parameters for Photo Listing
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `apps` | string | Comma-separated app names (picture,chat,nutriphi) |
|
||||
| `mimeType` | string | MIME type filter (image/*, image/jpeg) |
|
||||
| `dateFrom` | ISO date | Start date filter |
|
||||
| `dateTo` | ISO date | End date filter |
|
||||
| `hasLocation` | boolean | Filter photos with GPS data |
|
||||
| `limit` | number | Results per page (default: 50) |
|
||||
| `offset` | number | Pagination offset |
|
||||
| `sortBy` | string | createdAt, dateTaken, size |
|
||||
| `sortOrder` | string | asc, desc |
|
||||
|
||||
## Integration with mana-media
|
||||
|
||||
The Photos backend acts as a proxy to mana-media, enriching responses with local data:
|
||||
|
||||
1. **Photos list** - Fetches from mana-media `/api/v1/media/list/all`, adds favorites and tags
|
||||
2. **Photo detail** - Fetches from mana-media `/api/v1/media/:id`, adds favorites and tags
|
||||
3. **Stats** - Fetches from mana-media `/api/v1/media/stats`
|
||||
|
||||
Local data (albums, favorites, tags) is stored in the Photos database, while media files and EXIF data are stored in mana-media.
|
||||
6
apps/photos/apps/backend/drizzle.config.ts
Normal file
6
apps/photos/apps/backend/drizzle.config.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import { createDrizzleConfig } from '@manacore/shared-drizzle-config';
|
||||
|
||||
export default createDrizzleConfig({
|
||||
dbName: 'photos',
|
||||
outDir: './drizzle',
|
||||
});
|
||||
8
apps/photos/apps/backend/nest-cli.json
Normal file
8
apps/photos/apps/backend/nest-cli.json
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true
|
||||
}
|
||||
}
|
||||
45
apps/photos/apps/backend/package.json
Normal file
45
apps/photos/apps/backend/package.json
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
{
|
||||
"name": "@photos/backend",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"description": "Photos Backend API",
|
||||
"scripts": {
|
||||
"dev": "nest start --watch",
|
||||
"build": "nest build",
|
||||
"start": "nest start",
|
||||
"start:prod": "node dist/main",
|
||||
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
||||
"type-check": "tsc --noEmit",
|
||||
"db:push": "drizzle-kit push",
|
||||
"db:studio": "drizzle-kit studio",
|
||||
"db:seed": "tsx src/db/seed.ts",
|
||||
"db:generate": "drizzle-kit generate"
|
||||
},
|
||||
"dependencies": {
|
||||
"@manacore/nestjs-integration": "workspace:*",
|
||||
"@manacore/shared-nestjs-auth": "workspace:*",
|
||||
"@manacore/shared-nestjs-health": "workspace:*",
|
||||
"@nestjs/common": "^10.4.9",
|
||||
"@nestjs/config": "^3.3.0",
|
||||
"@nestjs/core": "^10.4.9",
|
||||
"@nestjs/platform-express": "^10.4.9",
|
||||
"@photos/shared": "workspace:*",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.1",
|
||||
"dotenv": "^16.4.7",
|
||||
"drizzle-orm": "^0.38.3",
|
||||
"postgres": "^3.4.5",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@manacore/shared-drizzle-config": "workspace:*",
|
||||
"@nestjs/cli": "^10.4.9",
|
||||
"@nestjs/schematics": "^10.2.3",
|
||||
"@types/express": "^5.0.1",
|
||||
"@types/node": "^22.15.21",
|
||||
"drizzle-kit": "^0.30.2",
|
||||
"tsx": "^4.19.4",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
}
|
||||
91
apps/photos/apps/backend/src/album/album.controller.ts
Normal file
91
apps/photos/apps/backend/src/album/album.controller.ts
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Patch,
|
||||
Delete,
|
||||
Param,
|
||||
Body,
|
||||
UseGuards,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { JwtAuthGuard, CurrentUser, type CurrentUserData } from '@manacore/shared-nestjs-auth';
|
||||
import { AlbumService } from './album.service';
|
||||
import { CreateAlbumDto, UpdateAlbumDto, AddItemsDto } from './dto';
|
||||
|
||||
@Controller('albums')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class AlbumController {
|
||||
constructor(private albumService: AlbumService) {}
|
||||
|
||||
@Get()
|
||||
async findAll(@CurrentUser() user: CurrentUserData) {
|
||||
return this.albumService.findAll(user.userId);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
async findById(@Param('id') id: string, @CurrentUser() user: CurrentUserData) {
|
||||
const album = await this.albumService.findById(id, user.userId);
|
||||
if (!album) {
|
||||
throw new NotFoundException('Album not found');
|
||||
}
|
||||
return album;
|
||||
}
|
||||
|
||||
@Post()
|
||||
async create(@Body() dto: CreateAlbumDto, @CurrentUser() user: CurrentUserData) {
|
||||
return this.albumService.create(user.userId, dto);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
async update(
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdateAlbumDto,
|
||||
@CurrentUser() user: CurrentUserData
|
||||
) {
|
||||
const album = await this.albumService.update(id, user.userId, dto);
|
||||
if (!album) {
|
||||
throw new NotFoundException('Album not found');
|
||||
}
|
||||
return album;
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
async delete(@Param('id') id: string, @CurrentUser() user: CurrentUserData) {
|
||||
await this.albumService.delete(id, user.userId);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@Post(':id/items')
|
||||
async addItems(
|
||||
@Param('id') id: string,
|
||||
@Body() dto: AddItemsDto,
|
||||
@CurrentUser() user: CurrentUserData
|
||||
) {
|
||||
await this.albumService.addItems(id, user.userId, dto.mediaIds);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@Delete(':id/items/:mediaId')
|
||||
async removeItem(
|
||||
@Param('id') id: string,
|
||||
@Param('mediaId') mediaId: string,
|
||||
@CurrentUser() user: CurrentUserData
|
||||
) {
|
||||
await this.albumService.removeItem(id, user.userId, mediaId);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@Patch(':id/cover')
|
||||
async setCover(
|
||||
@Param('id') id: string,
|
||||
@Body() dto: { mediaId: string },
|
||||
@CurrentUser() user: CurrentUserData
|
||||
) {
|
||||
const album = await this.albumService.setCover(id, user.userId, dto.mediaId);
|
||||
if (!album) {
|
||||
throw new NotFoundException('Album not found');
|
||||
}
|
||||
return album;
|
||||
}
|
||||
}
|
||||
10
apps/photos/apps/backend/src/album/album.module.ts
Normal file
10
apps/photos/apps/backend/src/album/album.module.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { AlbumController } from './album.controller';
|
||||
import { AlbumService } from './album.service';
|
||||
|
||||
@Module({
|
||||
controllers: [AlbumController],
|
||||
providers: [AlbumService],
|
||||
exports: [AlbumService],
|
||||
})
|
||||
export class AlbumModule {}
|
||||
138
apps/photos/apps/backend/src/album/album.service.ts
Normal file
138
apps/photos/apps/backend/src/album/album.service.ts
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
import { Injectable, Inject, NotFoundException } from '@nestjs/common';
|
||||
import { eq, and, desc } from 'drizzle-orm';
|
||||
import { DATABASE_CONNECTION, Database } from '../db/database.module';
|
||||
import { albums, albumItems, type Album, type NewAlbum, type AlbumItem } from '../db/schema';
|
||||
|
||||
export interface AlbumWithItems extends Album {
|
||||
items: AlbumItem[];
|
||||
itemCount: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AlbumService {
|
||||
constructor(@Inject(DATABASE_CONNECTION) private db: Database) {}
|
||||
|
||||
async findAll(userId: string): Promise<Album[]> {
|
||||
return this.db
|
||||
.select()
|
||||
.from(albums)
|
||||
.where(eq(albums.userId, userId))
|
||||
.orderBy(albums.sortOrder, albums.createdAt);
|
||||
}
|
||||
|
||||
async findById(id: string, userId: string): Promise<AlbumWithItems | null> {
|
||||
const [album] = await this.db
|
||||
.select()
|
||||
.from(albums)
|
||||
.where(and(eq(albums.id, id), eq(albums.userId, userId)))
|
||||
.limit(1);
|
||||
|
||||
if (!album) return null;
|
||||
|
||||
const items = await this.db
|
||||
.select()
|
||||
.from(albumItems)
|
||||
.where(eq(albumItems.albumId, id))
|
||||
.orderBy(albumItems.sortOrder, albumItems.addedAt);
|
||||
|
||||
return {
|
||||
...album,
|
||||
items,
|
||||
itemCount: items.length,
|
||||
};
|
||||
}
|
||||
|
||||
async create(userId: string, data: Omit<NewAlbum, 'userId'>): Promise<Album> {
|
||||
const [album] = await this.db
|
||||
.insert(albums)
|
||||
.values({
|
||||
...data,
|
||||
userId,
|
||||
})
|
||||
.returning();
|
||||
return album;
|
||||
}
|
||||
|
||||
async update(id: string, userId: string, data: Partial<NewAlbum>): Promise<Album | null> {
|
||||
const [updated] = await this.db
|
||||
.update(albums)
|
||||
.set({
|
||||
...data,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(and(eq(albums.id, id), eq(albums.userId, userId)))
|
||||
.returning();
|
||||
return updated || null;
|
||||
}
|
||||
|
||||
async delete(id: string, userId: string): Promise<void> {
|
||||
await this.db.delete(albums).where(and(eq(albums.id, id), eq(albums.userId, userId)));
|
||||
}
|
||||
|
||||
async addItems(albumId: string, userId: string, mediaIds: string[]): Promise<void> {
|
||||
const [album] = await this.db
|
||||
.select()
|
||||
.from(albums)
|
||||
.where(and(eq(albums.id, albumId), eq(albums.userId, userId)))
|
||||
.limit(1);
|
||||
|
||||
if (!album) {
|
||||
throw new NotFoundException('Album not found');
|
||||
}
|
||||
|
||||
const existingItems = await this.db
|
||||
.select()
|
||||
.from(albumItems)
|
||||
.where(eq(albumItems.albumId, albumId));
|
||||
|
||||
const existingMediaIds = new Set(existingItems.map((i) => i.mediaId));
|
||||
const newMediaIds = mediaIds.filter((id) => !existingMediaIds.has(id));
|
||||
|
||||
if (newMediaIds.length > 0) {
|
||||
const maxOrder = existingItems.length;
|
||||
await this.db.insert(albumItems).values(
|
||||
newMediaIds.map((mediaId, index) => ({
|
||||
albumId,
|
||||
mediaId,
|
||||
sortOrder: maxOrder + index,
|
||||
}))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async removeItem(albumId: string, userId: string, mediaId: string): Promise<void> {
|
||||
const [album] = await this.db
|
||||
.select()
|
||||
.from(albums)
|
||||
.where(and(eq(albums.id, albumId), eq(albums.userId, userId)))
|
||||
.limit(1);
|
||||
|
||||
if (!album) {
|
||||
throw new NotFoundException('Album not found');
|
||||
}
|
||||
|
||||
await this.db
|
||||
.delete(albumItems)
|
||||
.where(and(eq(albumItems.albumId, albumId), eq(albumItems.mediaId, mediaId)));
|
||||
}
|
||||
|
||||
async setCover(albumId: string, userId: string, mediaId: string): Promise<Album | null> {
|
||||
return this.update(albumId, userId, { coverMediaId: mediaId });
|
||||
}
|
||||
|
||||
async getAlbumsForMedia(userId: string, mediaId: string): Promise<Album[]> {
|
||||
const items = await this.db
|
||||
.select({ albumId: albumItems.albumId })
|
||||
.from(albumItems)
|
||||
.innerJoin(albums, eq(albumItems.albumId, albums.id))
|
||||
.where(and(eq(albumItems.mediaId, mediaId), eq(albums.userId, userId)));
|
||||
|
||||
if (items.length === 0) return [];
|
||||
|
||||
const albumIds = items.map((i) => i.albumId);
|
||||
return this.db
|
||||
.select()
|
||||
.from(albums)
|
||||
.where(and(eq(albums.userId, userId)));
|
||||
}
|
||||
}
|
||||
40
apps/photos/apps/backend/src/album/dto/index.ts
Normal file
40
apps/photos/apps/backend/src/album/dto/index.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import { IsString, IsOptional, IsArray, MaxLength, IsBoolean } from 'class-validator';
|
||||
|
||||
export class CreateAlbumDto {
|
||||
@IsString()
|
||||
@MaxLength(255)
|
||||
name: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
description?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
coverMediaId?: string;
|
||||
}
|
||||
|
||||
export class UpdateAlbumDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(255)
|
||||
name?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
description?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
coverMediaId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isAutoGenerated?: boolean;
|
||||
}
|
||||
|
||||
export class AddItemsDto {
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
mediaIds: string[];
|
||||
}
|
||||
34
apps/photos/apps/backend/src/app.module.ts
Normal file
34
apps/photos/apps/backend/src/app.module.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { ManaCoreModule } from '@manacore/nestjs-integration';
|
||||
import { HealthModule } from '@manacore/shared-nestjs-health';
|
||||
import { DatabaseModule } from './db/database.module';
|
||||
import { AlbumModule } from './album/album.module';
|
||||
import { FavoriteModule } from './favorite/favorite.module';
|
||||
import { TagModule } from './tag/tag.module';
|
||||
import { PhotoModule } from './photo/photo.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
envFilePath: '.env',
|
||||
}),
|
||||
ManaCoreModule.forRootAsync({
|
||||
imports: [ConfigModule],
|
||||
useFactory: (configService: ConfigService) => ({
|
||||
appId: configService.get<string>('APP_ID', 'photos'),
|
||||
serviceKey: configService.get<string>('MANA_CORE_SERVICE_KEY', ''),
|
||||
debug: configService.get('NODE_ENV') === 'development',
|
||||
}),
|
||||
inject: [ConfigService],
|
||||
}),
|
||||
HealthModule.forRoot({ serviceName: 'photos-backend' }),
|
||||
DatabaseModule,
|
||||
AlbumModule,
|
||||
FavoriteModule,
|
||||
TagModule,
|
||||
PhotoModule,
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
27
apps/photos/apps/backend/src/db/database.module.ts
Normal file
27
apps/photos/apps/backend/src/db/database.module.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import { Module, Global } from '@nestjs/common';
|
||||
import { drizzle, PostgresJsDatabase } from 'drizzle-orm/postgres-js';
|
||||
import postgres from 'postgres';
|
||||
import * as schema from './schema';
|
||||
|
||||
export const DATABASE_CONNECTION = 'DATABASE_CONNECTION';
|
||||
|
||||
export type Database = PostgresJsDatabase<typeof schema>;
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [
|
||||
{
|
||||
provide: DATABASE_CONNECTION,
|
||||
useFactory: () => {
|
||||
const connectionString = process.env.DATABASE_URL;
|
||||
if (!connectionString) {
|
||||
throw new Error('DATABASE_URL environment variable is not set');
|
||||
}
|
||||
const client = postgres(connectionString);
|
||||
return drizzle(client, { schema });
|
||||
},
|
||||
},
|
||||
],
|
||||
exports: [DATABASE_CONNECTION],
|
||||
})
|
||||
export class DatabaseModule {}
|
||||
62
apps/photos/apps/backend/src/db/schema/albums.schema.ts
Normal file
62
apps/photos/apps/backend/src/db/schema/albums.schema.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import {
|
||||
pgTable,
|
||||
uuid,
|
||||
text,
|
||||
varchar,
|
||||
boolean,
|
||||
integer,
|
||||
timestamp,
|
||||
index,
|
||||
} from 'drizzle-orm/pg-core';
|
||||
import { relations } from 'drizzle-orm';
|
||||
|
||||
export const albums = pgTable(
|
||||
'albums',
|
||||
{
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
userId: text('user_id').notNull(),
|
||||
name: varchar('name', { length: 255 }).notNull(),
|
||||
description: text('description'),
|
||||
coverMediaId: text('cover_media_id'),
|
||||
isAutoGenerated: boolean('is_auto_generated').default(false).notNull(),
|
||||
autoGenerateType: text('auto_generate_type'),
|
||||
autoGenerateValue: text('auto_generate_value'),
|
||||
sortOrder: integer('sort_order').default(0).notNull(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
},
|
||||
(table) => [index('albums_user_id_idx').on(table.userId)]
|
||||
);
|
||||
|
||||
export const albumItems = pgTable(
|
||||
'album_items',
|
||||
{
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
albumId: uuid('album_id')
|
||||
.references(() => albums.id, { onDelete: 'cascade' })
|
||||
.notNull(),
|
||||
mediaId: text('media_id').notNull(),
|
||||
sortOrder: integer('sort_order').default(0).notNull(),
|
||||
addedAt: timestamp('added_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
},
|
||||
(table) => [
|
||||
index('album_items_album_id_idx').on(table.albumId),
|
||||
index('album_items_media_id_idx').on(table.mediaId),
|
||||
]
|
||||
);
|
||||
|
||||
export const albumsRelations = relations(albums, ({ many }) => ({
|
||||
items: many(albumItems),
|
||||
}));
|
||||
|
||||
export const albumItemsRelations = relations(albumItems, ({ one }) => ({
|
||||
album: one(albums, {
|
||||
fields: [albumItems.albumId],
|
||||
references: [albums.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export type Album = typeof albums.$inferSelect;
|
||||
export type NewAlbum = typeof albums.$inferInsert;
|
||||
export type AlbumItem = typeof albumItems.$inferSelect;
|
||||
export type NewAlbumItem = typeof albumItems.$inferInsert;
|
||||
19
apps/photos/apps/backend/src/db/schema/favorites.schema.ts
Normal file
19
apps/photos/apps/backend/src/db/schema/favorites.schema.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import { pgTable, uuid, text, timestamp, index, unique } from 'drizzle-orm/pg-core';
|
||||
|
||||
export const favorites = pgTable(
|
||||
'favorites',
|
||||
{
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
userId: text('user_id').notNull(),
|
||||
mediaId: text('media_id').notNull(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
},
|
||||
(table) => [
|
||||
index('favorites_user_id_idx').on(table.userId),
|
||||
index('favorites_media_id_idx').on(table.mediaId),
|
||||
unique('favorites_user_media_unique').on(table.userId, table.mediaId),
|
||||
]
|
||||
);
|
||||
|
||||
export type Favorite = typeof favorites.$inferSelect;
|
||||
export type NewFavorite = typeof favorites.$inferInsert;
|
||||
3
apps/photos/apps/backend/src/db/schema/index.ts
Normal file
3
apps/photos/apps/backend/src/db/schema/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export * from './albums.schema';
|
||||
export * from './favorites.schema';
|
||||
export * from './tags.schema';
|
||||
44
apps/photos/apps/backend/src/db/schema/tags.schema.ts
Normal file
44
apps/photos/apps/backend/src/db/schema/tags.schema.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import { pgTable, uuid, text, varchar, timestamp, index, primaryKey } from 'drizzle-orm/pg-core';
|
||||
import { relations } from 'drizzle-orm';
|
||||
|
||||
export const tags = pgTable(
|
||||
'tags',
|
||||
{
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
userId: text('user_id').notNull(),
|
||||
name: varchar('name', { length: 50 }).notNull(),
|
||||
color: varchar('color', { length: 20 }),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
},
|
||||
(table) => [index('tags_user_id_idx').on(table.userId)]
|
||||
);
|
||||
|
||||
export const photoTags = pgTable(
|
||||
'photo_tags',
|
||||
{
|
||||
mediaId: text('media_id').notNull(),
|
||||
tagId: uuid('tag_id')
|
||||
.references(() => tags.id, { onDelete: 'cascade' })
|
||||
.notNull(),
|
||||
addedAt: timestamp('added_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
},
|
||||
(table) => ({
|
||||
pk: primaryKey({ columns: [table.mediaId, table.tagId] }),
|
||||
})
|
||||
);
|
||||
|
||||
export const tagsRelations = relations(tags, ({ many }) => ({
|
||||
photoTags: many(photoTags),
|
||||
}));
|
||||
|
||||
export const photoTagsRelations = relations(photoTags, ({ one }) => ({
|
||||
tag: one(tags, {
|
||||
fields: [photoTags.tagId],
|
||||
references: [tags.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export type Tag = typeof tags.$inferSelect;
|
||||
export type NewTag = typeof tags.$inferInsert;
|
||||
export type PhotoTag = typeof photoTags.$inferSelect;
|
||||
export type NewPhotoTag = typeof photoTags.$inferInsert;
|
||||
45
apps/photos/apps/backend/src/favorite/favorite.controller.ts
Normal file
45
apps/photos/apps/backend/src/favorite/favorite.controller.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import { Controller, Get, Post, Delete, Param, Query, UseGuards } from '@nestjs/common';
|
||||
import { JwtAuthGuard, CurrentUser, type CurrentUserData } from '@manacore/shared-nestjs-auth';
|
||||
import { FavoriteService } from './favorite.service';
|
||||
|
||||
@Controller('favorites')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class FavoriteController {
|
||||
constructor(private favoriteService: FavoriteService) {}
|
||||
|
||||
@Get()
|
||||
async findAll(
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
@Query('limit') limit?: string,
|
||||
@Query('offset') offset?: string
|
||||
) {
|
||||
return this.favoriteService.findAll(
|
||||
user.userId,
|
||||
limit ? parseInt(limit) : 50,
|
||||
offset ? parseInt(offset) : 0
|
||||
);
|
||||
}
|
||||
|
||||
@Get(':mediaId/status')
|
||||
async getStatus(@Param('mediaId') mediaId: string, @CurrentUser() user: CurrentUserData) {
|
||||
const isFavorited = await this.favoriteService.isFavorited(user.userId, mediaId);
|
||||
return { isFavorited };
|
||||
}
|
||||
|
||||
@Post(':mediaId')
|
||||
async add(@Param('mediaId') mediaId: string, @CurrentUser() user: CurrentUserData) {
|
||||
await this.favoriteService.add(user.userId, mediaId);
|
||||
return { success: true, isFavorited: true };
|
||||
}
|
||||
|
||||
@Delete(':mediaId')
|
||||
async remove(@Param('mediaId') mediaId: string, @CurrentUser() user: CurrentUserData) {
|
||||
await this.favoriteService.remove(user.userId, mediaId);
|
||||
return { success: true, isFavorited: false };
|
||||
}
|
||||
|
||||
@Post(':mediaId/toggle')
|
||||
async toggle(@Param('mediaId') mediaId: string, @CurrentUser() user: CurrentUserData) {
|
||||
return this.favoriteService.toggle(user.userId, mediaId);
|
||||
}
|
||||
}
|
||||
10
apps/photos/apps/backend/src/favorite/favorite.module.ts
Normal file
10
apps/photos/apps/backend/src/favorite/favorite.module.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { FavoriteController } from './favorite.controller';
|
||||
import { FavoriteService } from './favorite.service';
|
||||
|
||||
@Module({
|
||||
controllers: [FavoriteController],
|
||||
providers: [FavoriteService],
|
||||
exports: [FavoriteService],
|
||||
})
|
||||
export class FavoriteModule {}
|
||||
71
apps/photos/apps/backend/src/favorite/favorite.service.ts
Normal file
71
apps/photos/apps/backend/src/favorite/favorite.service.ts
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
import { Injectable, Inject } from '@nestjs/common';
|
||||
import { eq, and, inArray, desc } from 'drizzle-orm';
|
||||
import { DATABASE_CONNECTION, Database } from '../db/database.module';
|
||||
import { favorites, type Favorite } from '../db/schema';
|
||||
|
||||
@Injectable()
|
||||
export class FavoriteService {
|
||||
constructor(@Inject(DATABASE_CONNECTION) private db: Database) {}
|
||||
|
||||
async findAll(userId: string, limit = 50, offset = 0): Promise<Favorite[]> {
|
||||
return this.db
|
||||
.select()
|
||||
.from(favorites)
|
||||
.where(eq(favorites.userId, userId))
|
||||
.orderBy(desc(favorites.createdAt))
|
||||
.limit(limit)
|
||||
.offset(offset);
|
||||
}
|
||||
|
||||
async isFavorited(userId: string, mediaId: string): Promise<boolean> {
|
||||
const [result] = await this.db
|
||||
.select()
|
||||
.from(favorites)
|
||||
.where(and(eq(favorites.userId, userId), eq(favorites.mediaId, mediaId)))
|
||||
.limit(1);
|
||||
return !!result;
|
||||
}
|
||||
|
||||
async getFavoritedIds(userId: string, mediaIds: string[]): Promise<Set<string>> {
|
||||
if (mediaIds.length === 0) return new Set();
|
||||
|
||||
const results = await this.db
|
||||
.select({ mediaId: favorites.mediaId })
|
||||
.from(favorites)
|
||||
.where(and(eq(favorites.userId, userId), inArray(favorites.mediaId, mediaIds)));
|
||||
|
||||
return new Set(results.map((r) => r.mediaId));
|
||||
}
|
||||
|
||||
async add(userId: string, mediaId: string): Promise<Favorite> {
|
||||
const existing = await this.isFavorited(userId, mediaId);
|
||||
if (existing) {
|
||||
const [result] = await this.db
|
||||
.select()
|
||||
.from(favorites)
|
||||
.where(and(eq(favorites.userId, userId), eq(favorites.mediaId, mediaId)))
|
||||
.limit(1);
|
||||
return result;
|
||||
}
|
||||
|
||||
const [favorite] = await this.db.insert(favorites).values({ userId, mediaId }).returning();
|
||||
return favorite;
|
||||
}
|
||||
|
||||
async remove(userId: string, mediaId: string): Promise<void> {
|
||||
await this.db
|
||||
.delete(favorites)
|
||||
.where(and(eq(favorites.userId, userId), eq(favorites.mediaId, mediaId)));
|
||||
}
|
||||
|
||||
async toggle(userId: string, mediaId: string): Promise<{ isFavorited: boolean }> {
|
||||
const isFavorited = await this.isFavorited(userId, mediaId);
|
||||
if (isFavorited) {
|
||||
await this.remove(userId, mediaId);
|
||||
return { isFavorited: false };
|
||||
} else {
|
||||
await this.add(userId, mediaId);
|
||||
return { isFavorited: true };
|
||||
}
|
||||
}
|
||||
}
|
||||
34
apps/photos/apps/backend/src/main.ts
Normal file
34
apps/photos/apps/backend/src/main.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import 'dotenv/config';
|
||||
import 'reflect-metadata';
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { ValidationPipe } from '@nestjs/common';
|
||||
import { AppModule } from './app.module';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
|
||||
app.enableCors({
|
||||
origin: process.env.CORS_ORIGINS?.split(',') || [
|
||||
'http://localhost:5173',
|
||||
'http://localhost:5189',
|
||||
'http://localhost:8081',
|
||||
],
|
||||
credentials: true,
|
||||
});
|
||||
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({
|
||||
transform: true,
|
||||
whitelist: true,
|
||||
forbidNonWhitelisted: true,
|
||||
})
|
||||
);
|
||||
|
||||
app.setGlobalPrefix('api/v1');
|
||||
|
||||
const port = process.env.PORT || 3019;
|
||||
await app.listen(port);
|
||||
console.log(`Photos Backend listening on port ${port}`);
|
||||
}
|
||||
|
||||
bootstrap();
|
||||
49
apps/photos/apps/backend/src/photo/photo.controller.ts
Normal file
49
apps/photos/apps/backend/src/photo/photo.controller.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import { Controller, Get, Query, Param, UseGuards, NotFoundException } from '@nestjs/common';
|
||||
import { JwtAuthGuard, CurrentUser, type CurrentUserData } from '@manacore/shared-nestjs-auth';
|
||||
import { PhotoService } from './photo.service';
|
||||
|
||||
@Controller('photos')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class PhotoController {
|
||||
constructor(private photoService: PhotoService) {}
|
||||
|
||||
@Get()
|
||||
async list(
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
@Query('apps') apps?: string,
|
||||
@Query('mimeType') mimeType?: string,
|
||||
@Query('dateFrom') dateFrom?: string,
|
||||
@Query('dateTo') dateTo?: string,
|
||||
@Query('hasLocation') hasLocation?: string,
|
||||
@Query('limit') limit?: string,
|
||||
@Query('offset') offset?: string,
|
||||
@Query('sortBy') sortBy?: 'createdAt' | 'dateTaken' | 'size',
|
||||
@Query('sortOrder') sortOrder?: 'asc' | 'desc'
|
||||
) {
|
||||
return this.photoService.listPhotos(user.userId, {
|
||||
apps: apps ? apps.split(',').map((a) => a.trim()) : undefined,
|
||||
mimeType: mimeType || 'image/*',
|
||||
dateFrom,
|
||||
dateTo,
|
||||
hasLocation: hasLocation === 'true',
|
||||
limit: limit ? parseInt(limit) : 50,
|
||||
offset: offset ? parseInt(offset) : 0,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
});
|
||||
}
|
||||
|
||||
@Get('stats')
|
||||
async stats(@CurrentUser() user: CurrentUserData) {
|
||||
return this.photoService.getStats(user.userId);
|
||||
}
|
||||
|
||||
@Get(':mediaId')
|
||||
async get(@Param('mediaId') mediaId: string, @CurrentUser() user: CurrentUserData) {
|
||||
const photo = await this.photoService.getPhoto(user.userId, mediaId);
|
||||
if (!photo) {
|
||||
throw new NotFoundException('Photo not found');
|
||||
}
|
||||
return photo;
|
||||
}
|
||||
}
|
||||
13
apps/photos/apps/backend/src/photo/photo.module.ts
Normal file
13
apps/photos/apps/backend/src/photo/photo.module.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { PhotoController } from './photo.controller';
|
||||
import { PhotoService } from './photo.service';
|
||||
import { FavoriteModule } from '../favorite/favorite.module';
|
||||
import { TagModule } from '../tag/tag.module';
|
||||
|
||||
@Module({
|
||||
imports: [FavoriteModule, TagModule],
|
||||
controllers: [PhotoController],
|
||||
providers: [PhotoService],
|
||||
exports: [PhotoService],
|
||||
})
|
||||
export class PhotoModule {}
|
||||
188
apps/photos/apps/backend/src/photo/photo.service.ts
Normal file
188
apps/photos/apps/backend/src/photo/photo.service.ts
Normal file
|
|
@ -0,0 +1,188 @@
|
|||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { FavoriteService } from '../favorite/favorite.service';
|
||||
import { TagService } from '../tag/tag.service';
|
||||
import type { Tag } from '../db/schema';
|
||||
|
||||
export interface MediaItem {
|
||||
id: string;
|
||||
status: string;
|
||||
originalName: string | null;
|
||||
mimeType: string;
|
||||
size: number;
|
||||
hash: string;
|
||||
urls: {
|
||||
original: string;
|
||||
thumbnail?: string;
|
||||
medium?: string;
|
||||
large?: string;
|
||||
};
|
||||
metadata?: {
|
||||
width?: number;
|
||||
height?: number;
|
||||
format?: string;
|
||||
};
|
||||
exif?: {
|
||||
cameraMake?: string;
|
||||
cameraModel?: string;
|
||||
dateTaken?: string;
|
||||
focalLength?: string;
|
||||
aperture?: string;
|
||||
iso?: number;
|
||||
exposureTime?: string;
|
||||
gpsLatitude?: string;
|
||||
gpsLongitude?: string;
|
||||
};
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface EnrichedPhoto extends MediaItem {
|
||||
isFavorited: boolean;
|
||||
tags: Tag[];
|
||||
}
|
||||
|
||||
export interface ListPhotosParams {
|
||||
apps?: string[];
|
||||
mimeType?: string;
|
||||
dateFrom?: string;
|
||||
dateTo?: string;
|
||||
hasLocation?: boolean;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
sortBy?: 'createdAt' | 'dateTaken' | 'size';
|
||||
sortOrder?: 'asc' | 'desc';
|
||||
}
|
||||
|
||||
export interface ListPhotosResult {
|
||||
items: EnrichedPhoto[];
|
||||
total: number;
|
||||
hasMore: boolean;
|
||||
}
|
||||
|
||||
export interface PhotoStats {
|
||||
totalCount: number;
|
||||
totalSize: number;
|
||||
byApp: Record<string, { count: number; size: number }>;
|
||||
byYear: Record<string, number>;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class PhotoService {
|
||||
private readonly logger = new Logger(PhotoService.name);
|
||||
private readonly manaMediaUrl: string;
|
||||
|
||||
constructor(
|
||||
private favoriteService: FavoriteService,
|
||||
private tagService: TagService
|
||||
) {
|
||||
this.manaMediaUrl = process.env.MANA_MEDIA_URL || 'http://localhost:3015';
|
||||
}
|
||||
|
||||
async listPhotos(userId: string, params: ListPhotosParams): Promise<ListPhotosResult> {
|
||||
const queryParams = new URLSearchParams();
|
||||
queryParams.set('userId', userId);
|
||||
|
||||
if (params.apps?.length) {
|
||||
queryParams.set('apps', params.apps.join(','));
|
||||
}
|
||||
if (params.mimeType) {
|
||||
queryParams.set('mimeType', params.mimeType);
|
||||
}
|
||||
if (params.dateFrom) {
|
||||
queryParams.set('dateFrom', params.dateFrom);
|
||||
}
|
||||
if (params.dateTo) {
|
||||
queryParams.set('dateTo', params.dateTo);
|
||||
}
|
||||
if (params.hasLocation) {
|
||||
queryParams.set('hasLocation', 'true');
|
||||
}
|
||||
if (params.limit) {
|
||||
queryParams.set('limit', String(params.limit));
|
||||
}
|
||||
if (params.offset) {
|
||||
queryParams.set('offset', String(params.offset));
|
||||
}
|
||||
if (params.sortBy) {
|
||||
queryParams.set('sortBy', params.sortBy);
|
||||
}
|
||||
if (params.sortOrder) {
|
||||
queryParams.set('sortOrder', params.sortOrder);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${this.manaMediaUrl}/api/v1/media/list/all?${queryParams.toString()}`
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
this.logger.error(`Failed to fetch photos from mana-media: ${response.status}`);
|
||||
return { items: [], total: 0, hasMore: false };
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const mediaItems: MediaItem[] = data.items || [];
|
||||
|
||||
// Enrich with local data
|
||||
const enriched = await this.enrichPhotos(userId, mediaItems);
|
||||
|
||||
return {
|
||||
items: enriched,
|
||||
total: data.total || 0,
|
||||
hasMore: data.hasMore || false,
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to fetch photos from mana-media', error);
|
||||
return { items: [], total: 0, hasMore: false };
|
||||
}
|
||||
}
|
||||
|
||||
async getPhoto(userId: string, mediaId: string): Promise<EnrichedPhoto | null> {
|
||||
try {
|
||||
const response = await fetch(`${this.manaMediaUrl}/api/v1/media/${mediaId}`);
|
||||
|
||||
if (!response.ok) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const mediaItem: MediaItem = await response.json();
|
||||
const [enriched] = await this.enrichPhotos(userId, [mediaItem]);
|
||||
return enriched;
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to fetch photo ${mediaId} from mana-media`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async getStats(userId: string): Promise<PhotoStats> {
|
||||
try {
|
||||
const response = await fetch(`${this.manaMediaUrl}/api/v1/media/stats?userId=${userId}`);
|
||||
|
||||
if (!response.ok) {
|
||||
return { totalCount: 0, totalSize: 0, byApp: {}, byYear: {} };
|
||||
}
|
||||
|
||||
return response.json();
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to fetch stats from mana-media', error);
|
||||
return { totalCount: 0, totalSize: 0, byApp: {}, byYear: {} };
|
||||
}
|
||||
}
|
||||
|
||||
private async enrichPhotos(userId: string, items: MediaItem[]): Promise<EnrichedPhoto[]> {
|
||||
if (items.length === 0) return [];
|
||||
|
||||
const mediaIds = items.map((i) => i.id);
|
||||
|
||||
// Fetch favorites and tags in parallel
|
||||
const [favoritedIds, tagsMap] = await Promise.all([
|
||||
this.favoriteService.getFavoritedIds(userId, mediaIds),
|
||||
this.tagService.getTagsForPhotos(mediaIds),
|
||||
]);
|
||||
|
||||
return items.map((item) => ({
|
||||
...item,
|
||||
isFavorited: favoritedIds.has(item.id),
|
||||
tags: tagsMap.get(item.id) || [],
|
||||
}));
|
||||
}
|
||||
}
|
||||
30
apps/photos/apps/backend/src/tag/dto/index.ts
Normal file
30
apps/photos/apps/backend/src/tag/dto/index.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import { IsString, IsOptional, IsArray, MaxLength } from 'class-validator';
|
||||
|
||||
export class CreateTagDto {
|
||||
@IsString()
|
||||
@MaxLength(50)
|
||||
name: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(20)
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export class UpdateTagDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(50)
|
||||
name?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(20)
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export class SetTagsDto {
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
tagIds: string[];
|
||||
}
|
||||
86
apps/photos/apps/backend/src/tag/tag.controller.ts
Normal file
86
apps/photos/apps/backend/src/tag/tag.controller.ts
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Patch,
|
||||
Delete,
|
||||
Param,
|
||||
Body,
|
||||
UseGuards,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { JwtAuthGuard, CurrentUser, type CurrentUserData } from '@manacore/shared-nestjs-auth';
|
||||
import { TagService } from './tag.service';
|
||||
import { CreateTagDto, UpdateTagDto, SetTagsDto } from './dto';
|
||||
|
||||
@Controller('tags')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class TagController {
|
||||
constructor(private tagService: TagService) {}
|
||||
|
||||
@Get()
|
||||
async findAll(@CurrentUser() user: CurrentUserData) {
|
||||
return this.tagService.findAll(user.userId);
|
||||
}
|
||||
|
||||
@Post()
|
||||
async create(@Body() dto: CreateTagDto, @CurrentUser() user: CurrentUserData) {
|
||||
return this.tagService.create(user.userId, dto);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
async update(
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdateTagDto,
|
||||
@CurrentUser() user: CurrentUserData
|
||||
) {
|
||||
const tag = await this.tagService.update(id, user.userId, dto);
|
||||
if (!tag) {
|
||||
throw new NotFoundException('Tag not found');
|
||||
}
|
||||
return tag;
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
async delete(@Param('id') id: string, @CurrentUser() user: CurrentUserData) {
|
||||
await this.tagService.delete(id, user.userId);
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
|
||||
@Controller('photos')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class PhotoTagController {
|
||||
constructor(private tagService: TagService) {}
|
||||
|
||||
@Get(':mediaId/tags')
|
||||
async getPhotoTags(@Param('mediaId') mediaId: string) {
|
||||
return this.tagService.getTagsForPhoto(mediaId);
|
||||
}
|
||||
|
||||
@Post(':mediaId/tags/:tagId')
|
||||
async addTag(
|
||||
@Param('mediaId') mediaId: string,
|
||||
@Param('tagId') tagId: string,
|
||||
@CurrentUser() user: CurrentUserData
|
||||
) {
|
||||
await this.tagService.addTagToPhoto(mediaId, tagId, user.userId);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@Delete(':mediaId/tags/:tagId')
|
||||
async removeTag(@Param('mediaId') mediaId: string, @Param('tagId') tagId: string) {
|
||||
await this.tagService.removeTagFromPhoto(mediaId, tagId);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@Patch(':mediaId/tags')
|
||||
async setTags(
|
||||
@Param('mediaId') mediaId: string,
|
||||
@Body() dto: SetTagsDto,
|
||||
@CurrentUser() user: CurrentUserData
|
||||
) {
|
||||
await this.tagService.setTagsForPhoto(mediaId, dto.tagIds, user.userId);
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
10
apps/photos/apps/backend/src/tag/tag.module.ts
Normal file
10
apps/photos/apps/backend/src/tag/tag.module.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { TagController, PhotoTagController } from './tag.controller';
|
||||
import { TagService } from './tag.service';
|
||||
|
||||
@Module({
|
||||
controllers: [TagController, PhotoTagController],
|
||||
providers: [TagService],
|
||||
exports: [TagService],
|
||||
})
|
||||
export class TagModule {}
|
||||
116
apps/photos/apps/backend/src/tag/tag.service.ts
Normal file
116
apps/photos/apps/backend/src/tag/tag.service.ts
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
import { Injectable, Inject, NotFoundException } from '@nestjs/common';
|
||||
import { eq, and, inArray } from 'drizzle-orm';
|
||||
import { DATABASE_CONNECTION, Database } from '../db/database.module';
|
||||
import { tags, photoTags, type Tag, type NewTag } from '../db/schema';
|
||||
|
||||
@Injectable()
|
||||
export class TagService {
|
||||
constructor(@Inject(DATABASE_CONNECTION) private db: Database) {}
|
||||
|
||||
async findAll(userId: string): Promise<Tag[]> {
|
||||
return this.db.select().from(tags).where(eq(tags.userId, userId)).orderBy(tags.name);
|
||||
}
|
||||
|
||||
async findById(id: string, userId: string): Promise<Tag | null> {
|
||||
const [tag] = await this.db
|
||||
.select()
|
||||
.from(tags)
|
||||
.where(and(eq(tags.id, id), eq(tags.userId, userId)))
|
||||
.limit(1);
|
||||
return tag || null;
|
||||
}
|
||||
|
||||
async create(userId: string, data: Omit<NewTag, 'userId'>): Promise<Tag> {
|
||||
const [tag] = await this.db
|
||||
.insert(tags)
|
||||
.values({ ...data, userId })
|
||||
.returning();
|
||||
return tag;
|
||||
}
|
||||
|
||||
async update(id: string, userId: string, data: Partial<NewTag>): Promise<Tag | null> {
|
||||
const [updated] = await this.db
|
||||
.update(tags)
|
||||
.set(data)
|
||||
.where(and(eq(tags.id, id), eq(tags.userId, userId)))
|
||||
.returning();
|
||||
return updated || null;
|
||||
}
|
||||
|
||||
async delete(id: string, userId: string): Promise<void> {
|
||||
await this.db.delete(tags).where(and(eq(tags.id, id), eq(tags.userId, userId)));
|
||||
}
|
||||
|
||||
async getTagsForPhoto(mediaId: string): Promise<Tag[]> {
|
||||
const tagIds = await this.db
|
||||
.select({ tagId: photoTags.tagId })
|
||||
.from(photoTags)
|
||||
.where(eq(photoTags.mediaId, mediaId));
|
||||
|
||||
if (tagIds.length === 0) return [];
|
||||
|
||||
return this.db
|
||||
.select()
|
||||
.from(tags)
|
||||
.where(
|
||||
inArray(
|
||||
tags.id,
|
||||
tagIds.map((t) => t.tagId)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
async getTagsForPhotos(mediaIds: string[]): Promise<Map<string, Tag[]>> {
|
||||
if (mediaIds.length === 0) return new Map();
|
||||
|
||||
const results = await this.db
|
||||
.select({ mediaId: photoTags.mediaId, tag: tags })
|
||||
.from(photoTags)
|
||||
.innerJoin(tags, eq(photoTags.tagId, tags.id))
|
||||
.where(inArray(photoTags.mediaId, mediaIds));
|
||||
|
||||
const map = new Map<string, Tag[]>();
|
||||
for (const { mediaId, tag } of results) {
|
||||
if (!map.has(mediaId)) {
|
||||
map.set(mediaId, []);
|
||||
}
|
||||
map.get(mediaId)!.push(tag);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
async addTagToPhoto(mediaId: string, tagId: string, userId: string): Promise<void> {
|
||||
const tag = await this.findById(tagId, userId);
|
||||
if (!tag) {
|
||||
throw new NotFoundException('Tag not found');
|
||||
}
|
||||
|
||||
await this.db.insert(photoTags).values({ mediaId, tagId }).onConflictDoNothing();
|
||||
}
|
||||
|
||||
async removeTagFromPhoto(mediaId: string, tagId: string): Promise<void> {
|
||||
await this.db
|
||||
.delete(photoTags)
|
||||
.where(and(eq(photoTags.mediaId, mediaId), eq(photoTags.tagId, tagId)));
|
||||
}
|
||||
|
||||
async setTagsForPhoto(mediaId: string, tagIds: string[], userId: string): Promise<void> {
|
||||
// Remove all existing tags
|
||||
await this.db.delete(photoTags).where(eq(photoTags.mediaId, mediaId));
|
||||
|
||||
// Add new tags
|
||||
if (tagIds.length > 0) {
|
||||
// Verify all tags belong to user
|
||||
const userTags = await this.db
|
||||
.select()
|
||||
.from(tags)
|
||||
.where(and(eq(tags.userId, userId), inArray(tags.id, tagIds)));
|
||||
|
||||
const validTagIds = userTags.map((t) => t.id);
|
||||
|
||||
if (validTagIds.length > 0) {
|
||||
await this.db.insert(photoTags).values(validTagIds.map((tagId) => ({ mediaId, tagId })));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
23
apps/photos/apps/backend/tsconfig.json
Normal file
23
apps/photos/apps/backend/tsconfig.json
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"declaration": true,
|
||||
"removeComments": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"experimentalDecorators": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"target": "ES2021",
|
||||
"sourceMap": true,
|
||||
"outDir": "./dist",
|
||||
"baseUrl": "./",
|
||||
"incremental": true,
|
||||
"skipLibCheck": true,
|
||||
"strictNullChecks": true,
|
||||
"noImplicitAny": true,
|
||||
"strictBindCallApply": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"esModuleInterop": true,
|
||||
"resolveJsonModule": true
|
||||
}
|
||||
}
|
||||
3
apps/photos/apps/web/.env.example
Normal file
3
apps/photos/apps/web/.env.example
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
PUBLIC_BACKEND_URL=http://localhost:3019
|
||||
PUBLIC_MANA_CORE_AUTH_URL=http://localhost:3001
|
||||
PUBLIC_MANA_MEDIA_URL=http://localhost:3015
|
||||
57
apps/photos/apps/web/package.json
Normal file
57
apps/photos/apps/web/package.json
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
{
|
||||
"name": "@photos/web",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||
"lint": "eslint .",
|
||||
"format": "prettier --write ."
|
||||
},
|
||||
"devDependencies": {
|
||||
"@manacore/shared-vite-config": "workspace:*",
|
||||
"@sveltejs/adapter-node": "^5.0.0",
|
||||
"@sveltejs/kit": "^2.47.1",
|
||||
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
||||
"@tailwindcss/vite": "^4.1.7",
|
||||
"@types/node": "^20.0.0",
|
||||
"prettier": "^3.1.1",
|
||||
"prettier-plugin-svelte": "^3.1.2",
|
||||
"svelte": "^5.41.0",
|
||||
"svelte-check": "^4.3.3",
|
||||
"tailwindcss": "^4.1.7",
|
||||
"tslib": "^2.4.1",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^6.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@manacore/shared-api-client": "workspace:*",
|
||||
"@manacore/shared-auth": "workspace:*",
|
||||
"@manacore/shared-auth-ui": "workspace:*",
|
||||
"@manacore/shared-branding": "workspace:*",
|
||||
"@manacore/shared-feedback-service": "workspace:*",
|
||||
"@manacore/shared-feedback-ui": "workspace:*",
|
||||
"@manacore/shared-help-content": "workspace:*",
|
||||
"@manacore/shared-help-types": "workspace:*",
|
||||
"@manacore/shared-help-ui": "workspace:*",
|
||||
"@manacore/shared-i18n": "workspace:*",
|
||||
"@manacore/shared-icons": "workspace:*",
|
||||
"@manacore/shared-profile-ui": "workspace:*",
|
||||
"@manacore/shared-splitscreen": "workspace:*",
|
||||
"@manacore/shared-stores": "workspace:*",
|
||||
"@manacore/shared-subscription-ui": "workspace:*",
|
||||
"@manacore/shared-tags": "workspace:*",
|
||||
"@manacore/shared-tailwind": "workspace:*",
|
||||
"@manacore/shared-theme": "workspace:*",
|
||||
"@manacore/shared-theme-ui": "workspace:*",
|
||||
"@manacore/shared-ui": "workspace:*",
|
||||
"@manacore/shared-utils": "workspace:*",
|
||||
"@photos/shared": "workspace:*",
|
||||
"date-fns": "^4.1.0",
|
||||
"svelte-i18n": "^4.0.1"
|
||||
},
|
||||
"type": "module"
|
||||
}
|
||||
202
apps/photos/apps/web/src/app.css
Normal file
202
apps/photos/apps/web/src/app.css
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
@import "tailwindcss";
|
||||
@import "@manacore/shared-tailwind/themes.css";
|
||||
|
||||
/* Scan shared packages for Tailwind classes */
|
||||
@source "../../../packages/shared/src";
|
||||
@source "../../../../../packages/shared-ui/src";
|
||||
@source "../../../../../packages/shared-theme-ui/src";
|
||||
@source "../../../../../packages/shared-theme-ui/src/components";
|
||||
@source "../../../../../packages/shared-theme-ui/src/pages";
|
||||
|
||||
/* Photos-specific CSS Variables */
|
||||
@layer base {
|
||||
:root {
|
||||
/* Spacing */
|
||||
--spacing-xs: 0.25rem;
|
||||
--spacing-sm: 0.5rem;
|
||||
--spacing-md: 1rem;
|
||||
--spacing-lg: 1.5rem;
|
||||
--spacing-xl: 2rem;
|
||||
--spacing-2xl: 3rem;
|
||||
|
||||
/* Border Radius */
|
||||
--radius-sm: 0.25rem;
|
||||
--radius-md: 0.5rem;
|
||||
--radius-lg: 0.75rem;
|
||||
--radius-xl: 1rem;
|
||||
--radius-full: 9999px;
|
||||
|
||||
/* Transitions */
|
||||
--transition-fast: 150ms ease;
|
||||
--transition-base: 200ms ease;
|
||||
--transition-slow: 300ms ease;
|
||||
|
||||
/* Gallery specific */
|
||||
--gallery-gap: 4px;
|
||||
--thumbnail-size: 200px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Utility Classes */
|
||||
@layer components {
|
||||
/* Photo card styles */
|
||||
.photo-card {
|
||||
position: relative;
|
||||
aspect-ratio: 1;
|
||||
overflow: hidden;
|
||||
border-radius: var(--radius-md);
|
||||
background-color: var(--color-muted);
|
||||
transition: transform var(--transition-base), box-shadow var(--transition-base);
|
||||
}
|
||||
|
||||
.photo-card:hover {
|
||||
transform: scale(1.02);
|
||||
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.2);
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.photo-card img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
/* Photo overlay */
|
||||
.photo-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(to top, rgba(0, 0, 0, 0.6) 0%, transparent 50%);
|
||||
opacity: 0;
|
||||
transition: opacity var(--transition-fast);
|
||||
}
|
||||
|
||||
.photo-card:hover .photo-overlay {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Album card styles */
|
||||
.album-card {
|
||||
position: relative;
|
||||
aspect-ratio: 4/3;
|
||||
overflow: hidden;
|
||||
border-radius: var(--radius-lg);
|
||||
background-color: var(--color-muted);
|
||||
transition: transform var(--transition-base), box-shadow var(--transition-base);
|
||||
}
|
||||
|
||||
.album-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
/* Upload dropzone */
|
||||
.dropzone {
|
||||
border: 2px dashed var(--color-border);
|
||||
border-radius: var(--radius-xl);
|
||||
padding: var(--spacing-2xl);
|
||||
text-align: center;
|
||||
background-color: var(--color-card);
|
||||
transition: all var(--transition-base);
|
||||
}
|
||||
|
||||
.dropzone.active {
|
||||
border-color: var(--color-primary);
|
||||
background-color: var(--color-accent);
|
||||
}
|
||||
|
||||
/* Tag styles */
|
||||
.tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: var(--radius-full);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Button styles */
|
||||
.btn {
|
||||
padding: var(--spacing-sm) var(--spacing-lg);
|
||||
border-radius: var(--radius-md);
|
||||
font-weight: 500;
|
||||
transition: all var(--transition-base);
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--color-primary);
|
||||
color: var(--color-primary-foreground);
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
filter: brightness(0.9);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--color-secondary);
|
||||
color: var(--color-secondary-foreground);
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
background: transparent;
|
||||
color: var(--color-foreground);
|
||||
}
|
||||
|
||||
.btn-ghost:hover {
|
||||
background: var(--color-accent);
|
||||
}
|
||||
|
||||
/* Icon button */
|
||||
.icon-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: var(--radius-full);
|
||||
background: transparent;
|
||||
color: var(--color-foreground);
|
||||
transition: all var(--transition-fast);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.icon-btn:hover {
|
||||
background: var(--color-accent);
|
||||
}
|
||||
|
||||
/* Lightbox styles */
|
||||
.lightbox-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.95);
|
||||
z-index: 50;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.lightbox-image {
|
||||
max-width: 90vw;
|
||||
max-height: 90vh;
|
||||
object-fit: contain;
|
||||
}
|
||||
}
|
||||
|
||||
/* Shadow variables */
|
||||
@layer base {
|
||||
:root {
|
||||
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
|
||||
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
||||
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
|
||||
--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.3);
|
||||
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.4);
|
||||
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.5);
|
||||
--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
}
|
||||
12
apps/photos/apps/web/src/app.html
Normal file
12
apps/photos/apps/web/src/app.html
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
71
apps/photos/apps/web/src/lib/api/client.ts
Normal file
71
apps/photos/apps/web/src/lib/api/client.ts
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
/**
|
||||
* API Client for Photos backend
|
||||
* Uses @manacore/shared-api-client for consistent error handling
|
||||
*/
|
||||
|
||||
import { createApiClient, type ApiResult } from '@manacore/shared-api-client';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { PUBLIC_BACKEND_URL } from '$env/static/public';
|
||||
|
||||
const API_URL = PUBLIC_BACKEND_URL || 'http://localhost:3019';
|
||||
|
||||
/**
|
||||
* Photos API client instance
|
||||
*/
|
||||
export const api = createApiClient({
|
||||
baseUrl: API_URL,
|
||||
apiPrefix: '/api/v1',
|
||||
getAuthToken: () => authStore.getValidToken(),
|
||||
timeout: 30000,
|
||||
debug: import.meta.env.DEV,
|
||||
});
|
||||
|
||||
/**
|
||||
* Legacy fetchWithAuth wrapper for backward compatibility
|
||||
*/
|
||||
export async function fetchWithAuth<T = unknown>(
|
||||
url: string,
|
||||
options: RequestInit = {}
|
||||
): Promise<T> {
|
||||
const method = options.method || 'GET';
|
||||
const body = options.body ? JSON.parse(options.body as string) : undefined;
|
||||
|
||||
let result: ApiResult<T>;
|
||||
switch (method) {
|
||||
case 'POST':
|
||||
result = await api.post<T>(url, body);
|
||||
break;
|
||||
case 'PUT':
|
||||
result = await api.put<T>(url, body);
|
||||
break;
|
||||
case 'PATCH':
|
||||
result = await api.patch<T>(url, body);
|
||||
break;
|
||||
case 'DELETE':
|
||||
result = await api.delete<T>(url);
|
||||
break;
|
||||
default:
|
||||
result = await api.get<T>(url);
|
||||
}
|
||||
|
||||
if (result.error) {
|
||||
throw new Error(result.error.message);
|
||||
}
|
||||
|
||||
return result.data as T;
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload file with auth
|
||||
*/
|
||||
export async function uploadWithAuth<T = unknown>(url: string, formData: FormData): Promise<T> {
|
||||
const result = await api.upload<T>(url, formData);
|
||||
|
||||
if (result.error) {
|
||||
throw new Error(result.error.message);
|
||||
}
|
||||
|
||||
return result.data as T;
|
||||
}
|
||||
|
||||
export type { ApiResult };
|
||||
108
apps/photos/apps/web/src/lib/components/albums/AlbumCard.svelte
Normal file
108
apps/photos/apps/web/src/lib/components/albums/AlbumCard.svelte
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
<script lang="ts">
|
||||
import type { Album } from '@photos/shared';
|
||||
|
||||
interface Props {
|
||||
album: Album;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
let { album, onClick }: Props = $props();
|
||||
</script>
|
||||
|
||||
<button class="album-card" onclick={onClick} type="button">
|
||||
{#if album.coverUrl}
|
||||
<img src={album.coverUrl} alt={album.name} class="album-cover" />
|
||||
{:else}
|
||||
<div class="album-placeholder">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="48"
|
||||
height="48"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path
|
||||
d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="album-overlay">
|
||||
<h3 class="album-name">{album.name}</h3>
|
||||
{#if album.description}
|
||||
<p class="album-description">{album.description}</p>
|
||||
{/if}
|
||||
<span class="album-count">{album.itemCount ?? 0} photos</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<style>
|
||||
.album-card {
|
||||
position: relative;
|
||||
aspect-ratio: 4/3;
|
||||
overflow: hidden;
|
||||
border-radius: var(--radius-lg);
|
||||
background-color: var(--color-muted);
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
padding: 0;
|
||||
transition:
|
||||
transform 200ms ease,
|
||||
box-shadow 200ms ease;
|
||||
}
|
||||
|
||||
.album-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.album-cover {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.album-placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--color-muted-foreground);
|
||||
background: linear-gradient(135deg, var(--color-muted) 0%, var(--color-accent) 100%);
|
||||
}
|
||||
|
||||
.album-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(to top, rgba(0, 0, 0, 0.8) 0%, transparent 60%);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-end;
|
||||
padding: 1rem;
|
||||
color: white;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.album-name {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.album-description {
|
||||
font-size: 0.875rem;
|
||||
opacity: 0.8;
|
||||
margin-bottom: 0.25rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.album-count {
|
||||
font-size: 0.75rem;
|
||||
opacity: 0.7;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
<script lang="ts">
|
||||
import type { Album } from '@photos/shared';
|
||||
import AlbumCard from './AlbumCard.svelte';
|
||||
|
||||
interface Props {
|
||||
albums: Album[];
|
||||
loading: boolean;
|
||||
onAlbumClick: (album: Album) => void;
|
||||
}
|
||||
|
||||
let { albums, loading, onAlbumClick }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="album-grid">
|
||||
{#each albums as album (album.id)}
|
||||
<AlbumCard {album} onClick={() => onAlbumClick(album)} />
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if loading}
|
||||
<div class="loading-indicator">
|
||||
<div class="spinner"></div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.album-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.loading-indicator {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 3px solid var(--color-border);
|
||||
border-top-color: var(--color-primary);
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,180 @@
|
|||
<script lang="ts">
|
||||
import { _ } from 'svelte-i18n';
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
onCreate: (data: { name: string; description?: string }) => void;
|
||||
}
|
||||
|
||||
let { onClose, onCreate }: Props = $props();
|
||||
|
||||
let name = $state('');
|
||||
let description = $state('');
|
||||
let loading = $state(false);
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
|
||||
function handleBackdropClick(e: Event) {
|
||||
if (e.target === e.currentTarget) {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit(e: Event) {
|
||||
e.preventDefault();
|
||||
if (!name.trim() || loading) return;
|
||||
|
||||
loading = true;
|
||||
await onCreate({
|
||||
name: name.trim(),
|
||||
description: description.trim() || undefined,
|
||||
});
|
||||
loading = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={handleKeydown} />
|
||||
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
|
||||
<div class="modal-backdrop" onclick={handleBackdropClick}>
|
||||
<div class="modal-content">
|
||||
<header class="modal-header">
|
||||
<h2 class="text-lg font-semibold">{$_('albums.create')}</h2>
|
||||
<button class="close-btn" onclick={onClose} type="button">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path d="M18 6 6 18" />
|
||||
<path d="m6 6 12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<form onsubmit={handleSubmit}>
|
||||
<div class="form-group">
|
||||
<label for="name" class="form-label">{$_('albums.name')}</label>
|
||||
<input
|
||||
id="name"
|
||||
type="text"
|
||||
class="form-input"
|
||||
bind:value={name}
|
||||
placeholder="My Album"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="description" class="form-label">{$_('albums.description')}</label>
|
||||
<textarea
|
||||
id="description"
|
||||
class="form-input"
|
||||
bind:value={description}
|
||||
placeholder="Optional description..."
|
||||
rows="3"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="btn btn-ghost" onclick={onClose} disabled={loading}>
|
||||
{$_('common.cancel')}
|
||||
</button>
|
||||
<button type="submit" class="btn btn-primary" disabled={!name.trim() || loading}>
|
||||
{#if loading}
|
||||
{$_('common.loading')}
|
||||
{:else}
|
||||
{$_('common.create')}
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 50;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: var(--color-card);
|
||||
border-radius: var(--radius-lg);
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
padding: 0.25rem;
|
||||
border-radius: 50%;
|
||||
background: transparent;
|
||||
color: var(--color-muted-foreground);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.close-btn:hover {
|
||||
background: var(--color-accent);
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
display: block;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.form-input {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-background);
|
||||
color: var(--color-foreground);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.form-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
textarea.form-input {
|
||||
resize: vertical;
|
||||
min-height: 80px;
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
</style>
|
||||
186
apps/photos/apps/web/src/lib/components/filters/FilterBar.svelte
Normal file
186
apps/photos/apps/web/src/lib/components/filters/FilterBar.svelte
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
<script lang="ts">
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { photoStore } from '$lib/stores/photos.svelte';
|
||||
|
||||
const apps = ['picture', 'chat', 'contacts', 'nutriphi'];
|
||||
|
||||
let selectedApps = $state<string[]>([]);
|
||||
let dateFrom = $state('');
|
||||
let dateTo = $state('');
|
||||
let hasLocation = $state<boolean | undefined>(undefined);
|
||||
let sortBy = $state<'dateTaken' | 'createdAt' | 'size'>('dateTaken');
|
||||
let sortOrder = $state<'asc' | 'desc'>('desc');
|
||||
|
||||
function toggleApp(app: string) {
|
||||
if (selectedApps.includes(app)) {
|
||||
selectedApps = selectedApps.filter((a) => a !== app);
|
||||
} else {
|
||||
selectedApps = [...selectedApps, app];
|
||||
}
|
||||
}
|
||||
|
||||
async function applyFilters() {
|
||||
await photoStore.setFilters({
|
||||
apps: selectedApps.length > 0 ? selectedApps : undefined,
|
||||
dateFrom: dateFrom || undefined,
|
||||
dateTo: dateTo || undefined,
|
||||
hasLocation,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
});
|
||||
}
|
||||
|
||||
async function clearFilters() {
|
||||
selectedApps = [];
|
||||
dateFrom = '';
|
||||
dateTo = '';
|
||||
hasLocation = undefined;
|
||||
sortBy = 'dateTaken';
|
||||
sortOrder = 'desc';
|
||||
await photoStore.setFilters({});
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="filter-bar">
|
||||
<div class="filter-section">
|
||||
<span class="filter-label" id="app-filter-label">{$_('filters.app')}</span>
|
||||
<div class="app-filters" role="group" aria-labelledby="app-filter-label">
|
||||
{#each apps as app}
|
||||
<button
|
||||
class="app-chip"
|
||||
class:selected={selectedApps.includes(app)}
|
||||
onclick={() => toggleApp(app)}
|
||||
aria-pressed={selectedApps.includes(app)}
|
||||
>
|
||||
{app}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="filter-section">
|
||||
<span class="filter-label">{$_('filters.dateRange')}</span>
|
||||
<div class="date-inputs">
|
||||
<input type="date" class="form-input" bind:value={dateFrom} aria-label={$_('filters.from')} />
|
||||
<span class="date-separator">-</span>
|
||||
<input type="date" class="form-input" bind:value={dateTo} aria-label={$_('filters.to')} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="filter-section">
|
||||
<label class="filter-label" for="location-filter">{$_('filters.hasLocation')}</label>
|
||||
<select id="location-filter" class="form-input" bind:value={hasLocation}>
|
||||
<option value={undefined}>All</option>
|
||||
<option value={true}>Yes</option>
|
||||
<option value={false}>No</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="filter-section">
|
||||
<label class="filter-label" for="sort-by-filter">{$_('filters.sortBy')}</label>
|
||||
<select id="sort-by-filter" class="form-input" bind:value={sortBy}>
|
||||
<option value="dateTaken">{$_('filters.date')}</option>
|
||||
<option value="createdAt">Created</option>
|
||||
<option value="size">{$_('filters.size')}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="filter-section">
|
||||
<label class="filter-label" for="sort-order-filter">{$_('filters.sortOrder')}</label>
|
||||
<select id="sort-order-filter" class="form-input" bind:value={sortOrder}>
|
||||
<option value="desc">{$_('filters.desc')}</option>
|
||||
<option value="asc">{$_('filters.asc')}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="filter-actions">
|
||||
<button class="btn btn-ghost" onclick={clearFilters}>
|
||||
{$_('filters.clear')}
|
||||
</button>
|
||||
<button class="btn btn-primary" onclick={applyFilters}> Apply </button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.filter-bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
background: var(--color-card);
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--color-border);
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.filter-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.filter-label {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-muted-foreground);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.app-filters {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.app-chip {
|
||||
padding: 0.25rem 0.75rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--color-background);
|
||||
color: var(--color-foreground);
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
transition: all 150ms;
|
||||
}
|
||||
|
||||
.app-chip:hover {
|
||||
background: var(--color-accent);
|
||||
}
|
||||
|
||||
.app-chip.selected {
|
||||
background: var(--color-primary);
|
||||
color: var(--color-primary-foreground);
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.date-inputs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.date-separator {
|
||||
color: var(--color-muted-foreground);
|
||||
}
|
||||
|
||||
.form-input {
|
||||
padding: 0.5rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-background);
|
||||
color: var(--color-foreground);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.form-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.filter-actions {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 0.5rem;
|
||||
margin-left: auto;
|
||||
}
|
||||
</style>
|
||||
177
apps/photos/apps/web/src/lib/components/gallery/PhotoCard.svelte
Normal file
177
apps/photos/apps/web/src/lib/components/gallery/PhotoCard.svelte
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
<script lang="ts">
|
||||
import type { Photo } from '@photos/shared';
|
||||
import { photoStore } from '$lib/stores/photos.svelte';
|
||||
|
||||
interface Props {
|
||||
photo: Photo;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
let { photo, onClick }: Props = $props();
|
||||
|
||||
let loaded = $state(false);
|
||||
let error = $state(false);
|
||||
|
||||
function handleFavoriteClick(e: Event) {
|
||||
e.stopPropagation();
|
||||
photoStore.toggleFavorite(photo.id);
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="photo-card"
|
||||
onclick={onClick}
|
||||
onkeydown={(e) => (e.key === 'Enter' || e.key === ' ') && onClick()}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
{#if !loaded && !error}
|
||||
<div class="placeholder"></div>
|
||||
{/if}
|
||||
|
||||
<img
|
||||
src={photo.thumbnailUrl || photo.url}
|
||||
alt=""
|
||||
class="photo-image"
|
||||
class:loaded
|
||||
onload={() => (loaded = true)}
|
||||
onerror={() => (error = true)}
|
||||
/>
|
||||
|
||||
{#if error}
|
||||
<div class="error-placeholder">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="32"
|
||||
height="32"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<rect width="18" height="18" x="3" y="3" rx="2" ry="2" />
|
||||
<circle cx="9" cy="9" r="2" />
|
||||
<path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21" />
|
||||
</svg>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="photo-overlay">
|
||||
<button
|
||||
type="button"
|
||||
class="favorite-btn"
|
||||
class:favorited={photo.isFavorited}
|
||||
onclick={handleFavoriteClick}
|
||||
title={photo.isFavorited ? 'Remove from favorites' : 'Add to favorites'}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill={photo.isFavorited ? 'currentColor' : 'none'}
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path
|
||||
d="M19 14c1.49-1.46 3-3.21 3-5.5A5.5 5.5 0 0 0 16.5 3c-1.76 0-3 .5-4.5 2-1.5-1.5-2.74-2-4.5-2A5.5 5.5 0 0 0 2 8.5c0 2.3 1.5 4.05 3 5.5l7 7Z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.photo-card {
|
||||
position: relative;
|
||||
aspect-ratio: 1;
|
||||
overflow: hidden;
|
||||
border-radius: var(--radius-md);
|
||||
background-color: var(--color-muted);
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
padding: 0;
|
||||
transition:
|
||||
transform 200ms ease,
|
||||
box-shadow 200ms ease;
|
||||
}
|
||||
|
||||
.photo-card:hover {
|
||||
transform: scale(1.02);
|
||||
box-shadow: 0 10px 20px rgba(0, 0, 0, 0.2);
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.placeholder {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(135deg, var(--color-muted) 0%, var(--color-accent) 100%);
|
||||
animation: pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
.photo-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
opacity: 0;
|
||||
transition: opacity 200ms;
|
||||
}
|
||||
|
||||
.photo-image.loaded {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.error-placeholder {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--color-muted-foreground);
|
||||
}
|
||||
|
||||
.photo-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(to top, rgba(0, 0, 0, 0.5) 0%, transparent 40%);
|
||||
opacity: 0;
|
||||
transition: opacity 150ms;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: flex-end;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.photo-card:hover .photo-overlay {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.favorite-btn {
|
||||
padding: 0.5rem;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
color: var(--color-muted-foreground);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all 150ms;
|
||||
}
|
||||
|
||||
.favorite-btn:hover {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.favorite-btn.favorited {
|
||||
color: #ef4444;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,421 @@
|
|||
<script lang="ts">
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { format } from 'date-fns';
|
||||
import type { Photo } from '@photos/shared';
|
||||
import { photoStore } from '$lib/stores/photos.svelte';
|
||||
import { tagStore } from '$lib/stores/tags.svelte';
|
||||
|
||||
interface Props {
|
||||
photo: Photo;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
let { photo, onClose }: Props = $props();
|
||||
|
||||
let showInfo = $state(true);
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
|
||||
function handleBackdropClick(e: Event) {
|
||||
if (e.target === e.currentTarget) {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
|
||||
function handleFavorite() {
|
||||
photoStore.toggleFavorite(photo.id);
|
||||
}
|
||||
|
||||
function formatDate(date: string | Date | null | undefined) {
|
||||
if (!date) return '-';
|
||||
return format(new Date(date), 'dd.MM.yyyy HH:mm');
|
||||
}
|
||||
|
||||
function formatSize(bytes: number | null | undefined) {
|
||||
if (!bytes) return '-';
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={handleKeydown} />
|
||||
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
|
||||
<div class="lightbox-backdrop" onclick={handleBackdropClick}>
|
||||
<div class="lightbox-container">
|
||||
<!-- Close button -->
|
||||
<button class="close-btn" onclick={onClose} title={$_('common.close')}>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path d="M18 6 6 18" />
|
||||
<path d="m6 6 12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Main image -->
|
||||
<div class="lightbox-main">
|
||||
<img src={photo.url} alt="" class="lightbox-image" />
|
||||
</div>
|
||||
|
||||
<!-- Info panel -->
|
||||
{#if showInfo}
|
||||
<div class="info-panel">
|
||||
<div class="info-header">
|
||||
<h3 class="font-medium">{$_('photo.details')}</h3>
|
||||
<button
|
||||
class="icon-btn"
|
||||
onclick={() => (showInfo = false)}
|
||||
title={$_('photo.hideInfo')}
|
||||
aria-label={$_('photo.hideInfo')}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path d="m15 18-6-6 6-6" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="info-actions">
|
||||
<button class="action-btn" class:favorited={photo.isFavorited} onclick={handleFavorite}>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill={photo.isFavorited ? 'currentColor' : 'none'}
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path
|
||||
d="M19 14c1.49-1.46 3-3.21 3-5.5A5.5 5.5 0 0 0 16.5 3c-1.76 0-3 .5-4.5 2-1.5-1.5-2.74-2-4.5-2A5.5 5.5 0 0 0 2 8.5c0 2.3 1.5 4.05 3 5.5l7 7Z"
|
||||
/>
|
||||
</svg>
|
||||
{photo.isFavorited ? $_('photo.unfavorite') : $_('photo.favorite')}
|
||||
</button>
|
||||
<a class="action-btn" href={photo.url} download target="_blank" rel="noopener noreferrer">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
||||
<polyline points="7 10 12 15 17 10" />
|
||||
<line x1="12" x2="12" y1="15" y2="3" />
|
||||
</svg>
|
||||
{$_('photo.download')}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Metadata -->
|
||||
<div class="info-section">
|
||||
<h4 class="info-label">{$_('exif.dimensions')}</h4>
|
||||
<p class="info-value">
|
||||
{photo.width && photo.height ? `${photo.width} x ${photo.height}` : '-'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="info-section">
|
||||
<h4 class="info-label">Size</h4>
|
||||
<p class="info-value">{formatSize(photo.size)}</p>
|
||||
</div>
|
||||
|
||||
<div class="info-section">
|
||||
<h4 class="info-label">{$_('exif.date')}</h4>
|
||||
<p class="info-value">{formatDate(photo.exif?.dateTaken || photo.createdAt)}</p>
|
||||
</div>
|
||||
|
||||
{#if photo.exif}
|
||||
{#if photo.exif.cameraMake || photo.exif.cameraModel}
|
||||
<div class="info-section">
|
||||
<h4 class="info-label">{$_('exif.camera')}</h4>
|
||||
<p class="info-value">
|
||||
{[photo.exif.cameraMake, photo.exif.cameraModel].filter(Boolean).join(' ')}
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if photo.exif.focalLength}
|
||||
<div class="info-section">
|
||||
<h4 class="info-label">{$_('exif.focalLength')}</h4>
|
||||
<p class="info-value">{photo.exif.focalLength}</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if photo.exif.aperture}
|
||||
<div class="info-section">
|
||||
<h4 class="info-label">{$_('exif.aperture')}</h4>
|
||||
<p class="info-value">f/{photo.exif.aperture}</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if photo.exif.iso}
|
||||
<div class="info-section">
|
||||
<h4 class="info-label">{$_('exif.iso')}</h4>
|
||||
<p class="info-value">ISO {photo.exif.iso}</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if photo.exif.exposureTime}
|
||||
<div class="info-section">
|
||||
<h4 class="info-label">{$_('exif.exposure')}</h4>
|
||||
<p class="info-value">{photo.exif.exposureTime}s</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if photo.exif.gpsLatitude && photo.exif.gpsLongitude}
|
||||
<div class="info-section">
|
||||
<h4 class="info-label">{$_('exif.location')}</h4>
|
||||
<a
|
||||
class="info-value location-link"
|
||||
href={`https://www.google.com/maps?q=${photo.exif.gpsLatitude},${photo.exif.gpsLongitude}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
View on map
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
|
||||
<polyline points="15 3 21 3 21 9" />
|
||||
<line x1="10" x2="21" y1="14" y2="3" />
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<!-- Tags -->
|
||||
{#if photo.tags && photo.tags.length > 0}
|
||||
<div class="info-section">
|
||||
<h4 class="info-label">{$_('photo.tags')}</h4>
|
||||
<div class="tags-list">
|
||||
{#each photo.tags as tag}
|
||||
<span class="tag" style="background-color: {tag.color}20; color: {tag.color}">
|
||||
{tag.name}
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<button class="show-info-btn" onclick={() => (showInfo = true)} title="Show info">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="M12 16v-4" />
|
||||
<path d="M12 8h.01" />
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.lightbox-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.95);
|
||||
z-index: 50;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.lightbox-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
position: absolute;
|
||||
top: 1rem;
|
||||
left: 1rem;
|
||||
padding: 0.5rem;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: white;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
z-index: 10;
|
||||
transition: background 150ms;
|
||||
}
|
||||
|
||||
.close-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.lightbox-main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.lightbox-image {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.info-panel {
|
||||
width: 320px;
|
||||
background: var(--color-card);
|
||||
color: var(--color-foreground);
|
||||
overflow-y: auto;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.info-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1.5rem;
|
||||
padding-bottom: 1rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.info-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-background);
|
||||
color: var(--color-foreground);
|
||||
cursor: pointer;
|
||||
transition: all 150ms;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.action-btn:hover {
|
||||
background: var(--color-accent);
|
||||
}
|
||||
|
||||
.action-btn.favorited {
|
||||
color: #ef4444;
|
||||
border-color: #ef4444;
|
||||
}
|
||||
|
||||
.info-section {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.info-label {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-muted-foreground);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.info-value {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.location-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
color: var(--color-primary);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.location-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.tags-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.tag {
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: var(--radius-full);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.show-info-btn {
|
||||
position: absolute;
|
||||
top: 1rem;
|
||||
right: 1rem;
|
||||
padding: 0.5rem;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: white;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.icon-btn {
|
||||
padding: 0.25rem;
|
||||
border-radius: 50%;
|
||||
background: transparent;
|
||||
color: var(--color-foreground);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.info-panel {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
width: 100%;
|
||||
max-height: 50%;
|
||||
border-radius: var(--radius-xl) var(--radius-xl) 0 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
105
apps/photos/apps/web/src/lib/components/gallery/PhotoGrid.svelte
Normal file
105
apps/photos/apps/web/src/lib/components/gallery/PhotoGrid.svelte
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import type { Photo } from '@photos/shared';
|
||||
import PhotoCard from './PhotoCard.svelte';
|
||||
|
||||
interface Props {
|
||||
photos: Photo[];
|
||||
loading: boolean;
|
||||
hasMore: boolean;
|
||||
onPhotoClick: (photo: Photo) => void;
|
||||
onLoadMore: () => void;
|
||||
}
|
||||
|
||||
let { photos, loading, hasMore, onPhotoClick, onLoadMore }: Props = $props();
|
||||
|
||||
let loadMoreRef = $state<HTMLDivElement | null>(null);
|
||||
let observer: IntersectionObserver;
|
||||
|
||||
onMount(() => {
|
||||
observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries[0].isIntersecting && hasMore && !loading) {
|
||||
onLoadMore();
|
||||
}
|
||||
},
|
||||
{ threshold: 0.1 }
|
||||
);
|
||||
|
||||
if (loadMoreRef) {
|
||||
observer.observe(loadMoreRef);
|
||||
}
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
};
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (loadMoreRef && observer) {
|
||||
observer.disconnect();
|
||||
observer.observe(loadMoreRef);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="photo-grid">
|
||||
{#each photos as photo (photo.id)}
|
||||
<PhotoCard {photo} onClick={() => onPhotoClick(photo)} />
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if loading}
|
||||
<div class="loading-indicator">
|
||||
<div class="spinner"></div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if hasMore}
|
||||
<div bind:this={loadMoreRef} class="load-more-trigger"></div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.photo-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: var(--gallery-gap, 4px);
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.photo-grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1200px) {
|
||||
.photo-grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
.loading-indicator {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 3px solid var(--color-border);
|
||||
border-top-color: var(--color-primary);
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.load-more-trigger {
|
||||
height: 1px;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,135 @@
|
|||
<script lang="ts">
|
||||
import { _ } from 'svelte-i18n';
|
||||
|
||||
interface Props {
|
||||
onFilesSelected: (files: File[]) => void;
|
||||
}
|
||||
|
||||
let { onFilesSelected }: Props = $props();
|
||||
|
||||
let dragActive = $state(false);
|
||||
let fileInput: HTMLInputElement;
|
||||
|
||||
function handleDragOver(e: DragEvent) {
|
||||
e.preventDefault();
|
||||
dragActive = true;
|
||||
}
|
||||
|
||||
function handleDragLeave(e: DragEvent) {
|
||||
e.preventDefault();
|
||||
dragActive = false;
|
||||
}
|
||||
|
||||
function handleDrop(e: DragEvent) {
|
||||
e.preventDefault();
|
||||
dragActive = false;
|
||||
|
||||
if (e.dataTransfer?.files) {
|
||||
const files = Array.from(e.dataTransfer.files);
|
||||
onFilesSelected(files);
|
||||
}
|
||||
}
|
||||
|
||||
function handleFileSelect(e: Event) {
|
||||
const input = e.target as HTMLInputElement;
|
||||
if (input.files) {
|
||||
const files = Array.from(input.files);
|
||||
onFilesSelected(files);
|
||||
input.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
function openFilePicker() {
|
||||
fileInput?.click();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="dropzone"
|
||||
class:active={dragActive}
|
||||
ondragover={handleDragOver}
|
||||
ondragleave={handleDragLeave}
|
||||
ondrop={handleDrop}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
onclick={openFilePicker}
|
||||
onkeydown={(e) => e.key === 'Enter' && openFilePicker()}
|
||||
>
|
||||
<input
|
||||
bind:this={fileInput}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
class="hidden"
|
||||
onchange={handleFileSelect}
|
||||
/>
|
||||
|
||||
<div class="dropzone-content">
|
||||
<div class="dropzone-icon">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="48"
|
||||
height="48"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
||||
<polyline points="17 8 12 3 7 8" />
|
||||
<line x1="12" x2="12" y1="3" y2="15" />
|
||||
</svg>
|
||||
</div>
|
||||
<p class="dropzone-text">{$_('upload.dropzone')}</p>
|
||||
<button type="button" class="btn btn-primary mt-4" onclick|stopPropagation={openFilePicker}>
|
||||
{$_('upload.selectFiles')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.dropzone {
|
||||
border: 2px dashed var(--color-border);
|
||||
border-radius: var(--radius-xl);
|
||||
padding: 3rem;
|
||||
text-align: center;
|
||||
background-color: var(--color-card);
|
||||
transition: all 200ms ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.dropzone:hover {
|
||||
border-color: var(--color-primary);
|
||||
background-color: var(--color-accent);
|
||||
}
|
||||
|
||||
.dropzone.active {
|
||||
border-color: var(--color-primary);
|
||||
background-color: var(--color-accent);
|
||||
border-style: solid;
|
||||
}
|
||||
|
||||
.dropzone-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.dropzone-icon {
|
||||
color: var(--color-muted-foreground);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.dropzone.active .dropzone-icon {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.dropzone-text {
|
||||
color: var(--color-muted-foreground);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
46
apps/photos/apps/web/src/lib/i18n/index.ts
Normal file
46
apps/photos/apps/web/src/lib/i18n/index.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import { browser } from '$app/environment';
|
||||
import { init, register, locale, waitLocale } from 'svelte-i18n';
|
||||
|
||||
// List of supported locales
|
||||
export const supportedLocales = ['de', 'en'] as const;
|
||||
export type SupportedLocale = (typeof supportedLocales)[number];
|
||||
|
||||
// Default locale
|
||||
const defaultLocale = 'de';
|
||||
|
||||
// Register all available locales
|
||||
register('de', () => import('./locales/de.json'));
|
||||
register('en', () => import('./locales/en.json'));
|
||||
|
||||
// Get initial locale from browser or localStorage
|
||||
function getInitialLocale(): SupportedLocale {
|
||||
if (browser) {
|
||||
const stored = localStorage.getItem('photos_locale');
|
||||
if (stored && supportedLocales.includes(stored as SupportedLocale)) {
|
||||
return stored as SupportedLocale;
|
||||
}
|
||||
|
||||
const browserLang = navigator.language.split('-')[0];
|
||||
if (supportedLocales.includes(browserLang as SupportedLocale)) {
|
||||
return browserLang as SupportedLocale;
|
||||
}
|
||||
}
|
||||
|
||||
return defaultLocale;
|
||||
}
|
||||
|
||||
// Initialize i18n
|
||||
init({
|
||||
fallbackLocale: defaultLocale,
|
||||
initialLocale: getInitialLocale(),
|
||||
});
|
||||
|
||||
// Set locale and persist
|
||||
export function setLocale(newLocale: SupportedLocale) {
|
||||
locale.set(newLocale);
|
||||
if (browser) {
|
||||
localStorage.setItem('photos_locale', newLocale);
|
||||
}
|
||||
}
|
||||
|
||||
export { waitLocale };
|
||||
124
apps/photos/apps/web/src/lib/i18n/locales/de.json
Normal file
124
apps/photos/apps/web/src/lib/i18n/locales/de.json
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
{
|
||||
"app": {
|
||||
"name": "Photos",
|
||||
"tagline": "Alle deine Fotos an einem Ort"
|
||||
},
|
||||
"nav": {
|
||||
"gallery": "Galerie",
|
||||
"albums": "Alben",
|
||||
"favorites": "Favoriten",
|
||||
"upload": "Hochladen",
|
||||
"settings": "Einstellungen"
|
||||
},
|
||||
"gallery": {
|
||||
"title": "Galerie",
|
||||
"empty": "Keine Fotos gefunden",
|
||||
"emptyHint": "Lade dein erstes Foto hoch oder verbinde Apps",
|
||||
"loadMore": "Mehr laden",
|
||||
"photo": "Foto",
|
||||
"photos": "Fotos"
|
||||
},
|
||||
"albums": {
|
||||
"title": "Alben",
|
||||
"empty": "Keine Alben vorhanden",
|
||||
"emptyHint": "Erstelle dein erstes Album",
|
||||
"create": "Album erstellen",
|
||||
"edit": "Album bearbeiten",
|
||||
"delete": "Album löschen",
|
||||
"deleteConfirm": "Möchtest du dieses Album wirklich löschen?",
|
||||
"name": "Name",
|
||||
"description": "Beschreibung",
|
||||
"items": "Fotos"
|
||||
},
|
||||
"favorites": {
|
||||
"title": "Favoriten",
|
||||
"empty": "Keine Favoriten",
|
||||
"emptyHint": "Markiere Fotos als Favorit mit dem Herz-Symbol"
|
||||
},
|
||||
"upload": {
|
||||
"title": "Hochladen",
|
||||
"dropzone": "Fotos hierher ziehen oder klicken zum Auswählen",
|
||||
"uploading": "Wird hochgeladen...",
|
||||
"success": "Erfolgreich hochgeladen",
|
||||
"error": "Fehler beim Hochladen",
|
||||
"selectFiles": "Dateien auswählen"
|
||||
},
|
||||
"photo": {
|
||||
"details": "Details",
|
||||
"exif": "EXIF-Daten",
|
||||
"tags": "Tags",
|
||||
"addToAlbum": "Zu Album hinzufügen",
|
||||
"removeFromAlbum": "Aus Album entfernen",
|
||||
"setCover": "Als Cover setzen",
|
||||
"download": "Herunterladen",
|
||||
"delete": "Löschen",
|
||||
"favorite": "Favorit",
|
||||
"unfavorite": "Favorit entfernen",
|
||||
"hideInfo": "Details ausblenden"
|
||||
},
|
||||
"exif": {
|
||||
"camera": "Kamera",
|
||||
"lens": "Objektiv",
|
||||
"focalLength": "Brennweite",
|
||||
"aperture": "Blende",
|
||||
"iso": "ISO",
|
||||
"exposure": "Belichtung",
|
||||
"date": "Aufnahmedatum",
|
||||
"location": "Standort",
|
||||
"dimensions": "Abmessungen"
|
||||
},
|
||||
"tags": {
|
||||
"title": "Tags",
|
||||
"create": "Tag erstellen",
|
||||
"edit": "Tag bearbeiten",
|
||||
"delete": "Tag löschen",
|
||||
"name": "Name",
|
||||
"color": "Farbe",
|
||||
"noTags": "Keine Tags"
|
||||
},
|
||||
"filters": {
|
||||
"title": "Filter",
|
||||
"app": "App",
|
||||
"allApps": "Alle Apps",
|
||||
"dateRange": "Zeitraum",
|
||||
"from": "Von",
|
||||
"to": "Bis",
|
||||
"hasLocation": "Mit Standort",
|
||||
"sortBy": "Sortieren nach",
|
||||
"date": "Datum",
|
||||
"size": "Größe",
|
||||
"sortOrder": "Reihenfolge",
|
||||
"asc": "Aufsteigend",
|
||||
"desc": "Absteigend",
|
||||
"clear": "Filter zurücksetzen"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Einstellungen",
|
||||
"theme": "Design",
|
||||
"language": "Sprache",
|
||||
"gridSize": "Rastergröße"
|
||||
},
|
||||
"auth": {
|
||||
"login": "Anmelden",
|
||||
"logout": "Abmelden",
|
||||
"email": "E-Mail",
|
||||
"password": "Passwort",
|
||||
"signIn": "Einloggen",
|
||||
"signUp": "Registrieren",
|
||||
"forgotPassword": "Passwort vergessen?"
|
||||
},
|
||||
"common": {
|
||||
"save": "Speichern",
|
||||
"cancel": "Abbrechen",
|
||||
"delete": "Löschen",
|
||||
"edit": "Bearbeiten",
|
||||
"create": "Erstellen",
|
||||
"close": "Schließen",
|
||||
"loading": "Lädt...",
|
||||
"error": "Fehler",
|
||||
"success": "Erfolg",
|
||||
"confirm": "Bestätigen",
|
||||
"yes": "Ja",
|
||||
"no": "Nein"
|
||||
}
|
||||
}
|
||||
124
apps/photos/apps/web/src/lib/i18n/locales/en.json
Normal file
124
apps/photos/apps/web/src/lib/i18n/locales/en.json
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
{
|
||||
"app": {
|
||||
"name": "Photos",
|
||||
"tagline": "All your photos in one place"
|
||||
},
|
||||
"nav": {
|
||||
"gallery": "Gallery",
|
||||
"albums": "Albums",
|
||||
"favorites": "Favorites",
|
||||
"upload": "Upload",
|
||||
"settings": "Settings"
|
||||
},
|
||||
"gallery": {
|
||||
"title": "Gallery",
|
||||
"empty": "No photos found",
|
||||
"emptyHint": "Upload your first photo or connect apps",
|
||||
"loadMore": "Load more",
|
||||
"photo": "photo",
|
||||
"photos": "photos"
|
||||
},
|
||||
"albums": {
|
||||
"title": "Albums",
|
||||
"empty": "No albums yet",
|
||||
"emptyHint": "Create your first album",
|
||||
"create": "Create Album",
|
||||
"edit": "Edit Album",
|
||||
"delete": "Delete Album",
|
||||
"deleteConfirm": "Are you sure you want to delete this album?",
|
||||
"name": "Name",
|
||||
"description": "Description",
|
||||
"items": "photos"
|
||||
},
|
||||
"favorites": {
|
||||
"title": "Favorites",
|
||||
"empty": "No favorites",
|
||||
"emptyHint": "Mark photos as favorite with the heart icon"
|
||||
},
|
||||
"upload": {
|
||||
"title": "Upload",
|
||||
"dropzone": "Drop photos here or click to select",
|
||||
"uploading": "Uploading...",
|
||||
"success": "Upload successful",
|
||||
"error": "Upload failed",
|
||||
"selectFiles": "Select files"
|
||||
},
|
||||
"photo": {
|
||||
"details": "Details",
|
||||
"exif": "EXIF Data",
|
||||
"tags": "Tags",
|
||||
"addToAlbum": "Add to Album",
|
||||
"removeFromAlbum": "Remove from Album",
|
||||
"setCover": "Set as Cover",
|
||||
"download": "Download",
|
||||
"delete": "Delete",
|
||||
"favorite": "Favorite",
|
||||
"unfavorite": "Remove Favorite",
|
||||
"hideInfo": "Hide details"
|
||||
},
|
||||
"exif": {
|
||||
"camera": "Camera",
|
||||
"lens": "Lens",
|
||||
"focalLength": "Focal Length",
|
||||
"aperture": "Aperture",
|
||||
"iso": "ISO",
|
||||
"exposure": "Exposure",
|
||||
"date": "Date Taken",
|
||||
"location": "Location",
|
||||
"dimensions": "Dimensions"
|
||||
},
|
||||
"tags": {
|
||||
"title": "Tags",
|
||||
"create": "Create Tag",
|
||||
"edit": "Edit Tag",
|
||||
"delete": "Delete Tag",
|
||||
"name": "Name",
|
||||
"color": "Color",
|
||||
"noTags": "No tags"
|
||||
},
|
||||
"filters": {
|
||||
"title": "Filters",
|
||||
"app": "App",
|
||||
"allApps": "All Apps",
|
||||
"dateRange": "Date Range",
|
||||
"from": "From",
|
||||
"to": "To",
|
||||
"hasLocation": "With Location",
|
||||
"sortBy": "Sort by",
|
||||
"date": "Date",
|
||||
"size": "Size",
|
||||
"sortOrder": "Order",
|
||||
"asc": "Ascending",
|
||||
"desc": "Descending",
|
||||
"clear": "Clear Filters"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Settings",
|
||||
"theme": "Theme",
|
||||
"language": "Language",
|
||||
"gridSize": "Grid Size"
|
||||
},
|
||||
"auth": {
|
||||
"login": "Login",
|
||||
"logout": "Logout",
|
||||
"email": "Email",
|
||||
"password": "Password",
|
||||
"signIn": "Sign In",
|
||||
"signUp": "Sign Up",
|
||||
"forgotPassword": "Forgot Password?"
|
||||
},
|
||||
"common": {
|
||||
"save": "Save",
|
||||
"cancel": "Cancel",
|
||||
"delete": "Delete",
|
||||
"edit": "Edit",
|
||||
"create": "Create",
|
||||
"close": "Close",
|
||||
"loading": "Loading...",
|
||||
"error": "Error",
|
||||
"success": "Success",
|
||||
"confirm": "Confirm",
|
||||
"yes": "Yes",
|
||||
"no": "No"
|
||||
}
|
||||
}
|
||||
232
apps/photos/apps/web/src/lib/stores/albums.svelte.ts
Normal file
232
apps/photos/apps/web/src/lib/stores/albums.svelte.ts
Normal file
|
|
@ -0,0 +1,232 @@
|
|||
/**
|
||||
* Albums Store - Manages album state using Svelte 5 runes
|
||||
*/
|
||||
|
||||
import { api } from '$lib/api/client';
|
||||
import type { Album, Photo } from '@photos/shared';
|
||||
|
||||
// State
|
||||
let albums = $state<Album[]>([]);
|
||||
let currentAlbum = $state<Album | null>(null);
|
||||
let albumPhotos = $state<Photo[]>([]);
|
||||
let loading = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
export const albumStore = {
|
||||
// Getters
|
||||
get albums() {
|
||||
return albums;
|
||||
},
|
||||
get currentAlbum() {
|
||||
return currentAlbum;
|
||||
},
|
||||
get albumPhotos() {
|
||||
return albumPhotos;
|
||||
},
|
||||
get loading() {
|
||||
return loading;
|
||||
},
|
||||
get error() {
|
||||
return error;
|
||||
},
|
||||
|
||||
/**
|
||||
* Load all albums
|
||||
*/
|
||||
async loadAlbums() {
|
||||
loading = true;
|
||||
error = null;
|
||||
|
||||
try {
|
||||
const result = await api.get<Album[]>('/albums');
|
||||
if (result.error) {
|
||||
error = result.error.message;
|
||||
return;
|
||||
}
|
||||
if (result.data) {
|
||||
albums = result.data;
|
||||
}
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to load albums';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Load single album with items
|
||||
*/
|
||||
async loadAlbum(id: string) {
|
||||
loading = true;
|
||||
error = null;
|
||||
|
||||
try {
|
||||
const result = await api.get<Album & { items: Photo[] }>(`/albums/${id}`);
|
||||
if (result.error) {
|
||||
error = result.error.message;
|
||||
return;
|
||||
}
|
||||
if (result.data) {
|
||||
currentAlbum = result.data;
|
||||
albumPhotos = result.data.items || [];
|
||||
}
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to load album';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Create new album
|
||||
*/
|
||||
async createAlbum(data: { name: string; description?: string }) {
|
||||
loading = true;
|
||||
error = null;
|
||||
|
||||
try {
|
||||
const result = await api.post<Album>('/albums', data);
|
||||
if (result.error) {
|
||||
error = result.error.message;
|
||||
return null;
|
||||
}
|
||||
if (result.data) {
|
||||
albums = [...albums, result.data];
|
||||
return result.data;
|
||||
}
|
||||
return null;
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to create album';
|
||||
return null;
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Update album
|
||||
*/
|
||||
async updateAlbum(id: string, data: { name?: string; description?: string }) {
|
||||
try {
|
||||
const result = await api.patch<Album>(`/albums/${id}`, data);
|
||||
if (result.error) {
|
||||
error = result.error.message;
|
||||
return null;
|
||||
}
|
||||
if (result.data) {
|
||||
albums = albums.map((a) => (a.id === id ? result.data! : a));
|
||||
if (currentAlbum?.id === id) {
|
||||
currentAlbum = result.data;
|
||||
}
|
||||
return result.data;
|
||||
}
|
||||
return null;
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to update album';
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete album
|
||||
*/
|
||||
async deleteAlbum(id: string) {
|
||||
try {
|
||||
const result = await api.delete(`/albums/${id}`);
|
||||
if (result.error) {
|
||||
error = result.error.message;
|
||||
return false;
|
||||
}
|
||||
albums = albums.filter((a) => a.id !== id);
|
||||
if (currentAlbum?.id === id) {
|
||||
currentAlbum = null;
|
||||
albumPhotos = [];
|
||||
}
|
||||
return true;
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to delete album';
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Add photos to album
|
||||
*/
|
||||
async addPhotosToAlbum(albumId: string, mediaIds: string[]) {
|
||||
try {
|
||||
const result = await api.post(`/albums/${albumId}/items`, { mediaIds });
|
||||
if (result.error) {
|
||||
error = result.error.message;
|
||||
return false;
|
||||
}
|
||||
// Reload album to get updated items
|
||||
if (currentAlbum?.id === albumId) {
|
||||
await this.loadAlbum(albumId);
|
||||
}
|
||||
return true;
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to add photos to album';
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Remove photo from album
|
||||
*/
|
||||
async removePhotoFromAlbum(albumId: string, mediaId: string) {
|
||||
try {
|
||||
const result = await api.delete(`/albums/${albumId}/items/${mediaId}`);
|
||||
if (result.error) {
|
||||
error = result.error.message;
|
||||
return false;
|
||||
}
|
||||
albumPhotos = albumPhotos.filter((p) => p.id !== mediaId);
|
||||
return true;
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to remove photo from album';
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Set album cover
|
||||
*/
|
||||
async setCover(albumId: string, mediaId: string) {
|
||||
try {
|
||||
const result = await api.patch<Album>(`/albums/${albumId}/cover`, { mediaId });
|
||||
if (result.error) {
|
||||
error = result.error.message;
|
||||
return false;
|
||||
}
|
||||
if (result.data) {
|
||||
albums = albums.map((a) => (a.id === albumId ? result.data! : a));
|
||||
if (currentAlbum?.id === albumId) {
|
||||
currentAlbum = result.data;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to set album cover';
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Clear current album
|
||||
*/
|
||||
clearCurrentAlbum() {
|
||||
currentAlbum = null;
|
||||
albumPhotos = [];
|
||||
},
|
||||
|
||||
/**
|
||||
* Reset store
|
||||
*/
|
||||
reset() {
|
||||
albums = [];
|
||||
currentAlbum = null;
|
||||
albumPhotos = [];
|
||||
loading = false;
|
||||
error = null;
|
||||
},
|
||||
};
|
||||
229
apps/photos/apps/web/src/lib/stores/auth.svelte.ts
Normal file
229
apps/photos/apps/web/src/lib/stores/auth.svelte.ts
Normal file
|
|
@ -0,0 +1,229 @@
|
|||
/**
|
||||
* Auth Store - Manages authentication state using Svelte 5 runes
|
||||
* Uses Mana Core Auth
|
||||
*/
|
||||
|
||||
import { browser } from '$app/environment';
|
||||
import { initializeWebAuth } from '@manacore/shared-auth';
|
||||
import type { UserData } from '@manacore/shared-auth';
|
||||
|
||||
// Get auth URL dynamically at runtime
|
||||
function getAuthUrl(): string {
|
||||
if (browser && typeof window !== 'undefined') {
|
||||
const injectedUrl = (window as unknown as { __PUBLIC_MANA_CORE_AUTH_URL__?: string })
|
||||
.__PUBLIC_MANA_CORE_AUTH_URL__;
|
||||
return injectedUrl || 'http://localhost:3001';
|
||||
}
|
||||
return process.env.PUBLIC_MANA_CORE_AUTH_URL || 'http://localhost:3001';
|
||||
}
|
||||
|
||||
// Get backend URL dynamically at runtime
|
||||
function getBackendUrl(): string {
|
||||
if (browser && typeof window !== 'undefined') {
|
||||
const injectedUrl = (window as unknown as { __PUBLIC_BACKEND_URL__?: string })
|
||||
.__PUBLIC_BACKEND_URL__;
|
||||
return injectedUrl || 'http://localhost:3019';
|
||||
}
|
||||
return process.env.PUBLIC_BACKEND_URL || 'http://localhost:3019';
|
||||
}
|
||||
|
||||
// Lazy initialization to avoid SSR issues with localStorage
|
||||
let _authService: ReturnType<typeof initializeWebAuth>['authService'] | null = null;
|
||||
let _tokenManager: ReturnType<typeof initializeWebAuth>['tokenManager'] | null = null;
|
||||
|
||||
function getAuthService() {
|
||||
if (!browser) return null;
|
||||
if (!_authService) {
|
||||
const auth = initializeWebAuth({
|
||||
baseUrl: getAuthUrl(),
|
||||
backendUrl: getBackendUrl(),
|
||||
});
|
||||
_authService = auth.authService;
|
||||
_tokenManager = auth.tokenManager;
|
||||
}
|
||||
return _authService;
|
||||
}
|
||||
|
||||
function getTokenManager() {
|
||||
if (!browser) return null;
|
||||
getAuthService();
|
||||
return _tokenManager;
|
||||
}
|
||||
|
||||
// State
|
||||
let user = $state<UserData | null>(null);
|
||||
let loading = $state(true);
|
||||
let initialized = $state(false);
|
||||
|
||||
export const authStore = {
|
||||
get user() {
|
||||
return user;
|
||||
},
|
||||
get loading() {
|
||||
return loading;
|
||||
},
|
||||
get isAuthenticated() {
|
||||
return !!user;
|
||||
},
|
||||
get initialized() {
|
||||
return initialized;
|
||||
},
|
||||
|
||||
async initialize() {
|
||||
if (initialized) return;
|
||||
|
||||
const authService = getAuthService();
|
||||
if (!authService) {
|
||||
initialized = true;
|
||||
loading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
loading = true;
|
||||
try {
|
||||
let authenticated = await authService.isAuthenticated();
|
||||
|
||||
if (!authenticated) {
|
||||
console.log('No local tokens, trying SSO...');
|
||||
const ssoResult = await authService.trySSO();
|
||||
if (ssoResult.success) {
|
||||
console.log('SSO successful');
|
||||
authenticated = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (authenticated) {
|
||||
const userData = await authService.getUserFromToken();
|
||||
user = userData;
|
||||
}
|
||||
initialized = true;
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize auth:', error);
|
||||
user = null;
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
async signIn(email: string, password: string) {
|
||||
const authService = getAuthService();
|
||||
if (!authService) {
|
||||
return { success: false, error: 'Auth not available on server' };
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await authService.signIn(email, password);
|
||||
|
||||
if (!result.success) {
|
||||
return { success: false, error: result.error || 'Login failed' };
|
||||
}
|
||||
|
||||
const userData = await authService.getUserFromToken();
|
||||
user = userData;
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
return { success: false, error: errorMessage };
|
||||
}
|
||||
},
|
||||
|
||||
async signUp(email: string, password: string) {
|
||||
const authService = getAuthService();
|
||||
if (!authService) {
|
||||
return { success: false, error: 'Auth not available on server', needsVerification: false };
|
||||
}
|
||||
|
||||
try {
|
||||
const sourceAppUrl = browser ? window.location.origin : undefined;
|
||||
const result = await authService.signUp(email, password, undefined, sourceAppUrl);
|
||||
|
||||
if (!result.success) {
|
||||
return { success: false, error: result.error || 'Signup failed', needsVerification: false };
|
||||
}
|
||||
|
||||
if (result.needsVerification) {
|
||||
return { success: true, needsVerification: true };
|
||||
}
|
||||
|
||||
const signInResult = await this.signIn(email, password);
|
||||
return { ...signInResult, needsVerification: false };
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
return { success: false, error: errorMessage, needsVerification: false };
|
||||
}
|
||||
},
|
||||
|
||||
async signOut() {
|
||||
const authService = getAuthService();
|
||||
if (!authService) {
|
||||
user = null;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await authService.signOut();
|
||||
user = null;
|
||||
} catch (error) {
|
||||
console.error('Sign out error:', error);
|
||||
user = null;
|
||||
}
|
||||
},
|
||||
|
||||
async resetPassword(email: string) {
|
||||
const authService = getAuthService();
|
||||
if (!authService) {
|
||||
return { success: false, error: 'Auth not available on server' };
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await authService.forgotPassword(email);
|
||||
|
||||
if (!result.success) {
|
||||
return { success: false, error: result.error || 'Password reset failed' };
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
return { success: false, error: errorMessage };
|
||||
}
|
||||
},
|
||||
|
||||
async resendVerificationEmail(email: string) {
|
||||
const authService = getAuthService();
|
||||
if (!authService) {
|
||||
return { success: false, error: 'Auth not available on server' };
|
||||
}
|
||||
|
||||
try {
|
||||
const sourceAppUrl = browser ? window.location.origin : undefined;
|
||||
const result = await authService.resendVerificationEmail(email, sourceAppUrl);
|
||||
|
||||
if (!result.success) {
|
||||
return { success: false, error: result.error || 'Failed to resend verification email' };
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
return { success: false, error: errorMessage };
|
||||
}
|
||||
},
|
||||
|
||||
async getAccessToken() {
|
||||
const authService = getAuthService();
|
||||
if (!authService) {
|
||||
return null;
|
||||
}
|
||||
return await authService.getAppToken();
|
||||
},
|
||||
|
||||
async getValidToken(): Promise<string | null> {
|
||||
const tokenManager = getTokenManager();
|
||||
if (!tokenManager) {
|
||||
return null;
|
||||
}
|
||||
return await tokenManager.getValidToken();
|
||||
},
|
||||
};
|
||||
169
apps/photos/apps/web/src/lib/stores/photos.svelte.ts
Normal file
169
apps/photos/apps/web/src/lib/stores/photos.svelte.ts
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
/**
|
||||
* Photos Store - Manages photo gallery state using Svelte 5 runes
|
||||
*/
|
||||
|
||||
import { api } from '$lib/api/client';
|
||||
import type { Photo, PhotoFilters, PhotoStats } from '@photos/shared';
|
||||
|
||||
// State
|
||||
let photos = $state<Photo[]>([]);
|
||||
let loading = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
let hasMore = $state(true);
|
||||
let filters = $state<PhotoFilters>({
|
||||
limit: 50,
|
||||
offset: 0,
|
||||
sortBy: 'dateTaken',
|
||||
sortOrder: 'desc',
|
||||
});
|
||||
let stats = $state<PhotoStats | null>(null);
|
||||
let selectedPhoto = $state<Photo | null>(null);
|
||||
|
||||
export const photoStore = {
|
||||
// Getters
|
||||
get photos() {
|
||||
return photos;
|
||||
},
|
||||
get loading() {
|
||||
return loading;
|
||||
},
|
||||
get error() {
|
||||
return error;
|
||||
},
|
||||
get hasMore() {
|
||||
return hasMore;
|
||||
},
|
||||
get filters() {
|
||||
return filters;
|
||||
},
|
||||
get stats() {
|
||||
return stats;
|
||||
},
|
||||
get selectedPhoto() {
|
||||
return selectedPhoto;
|
||||
},
|
||||
|
||||
/**
|
||||
* Load photos with current filters
|
||||
*/
|
||||
async loadPhotos(reset = false) {
|
||||
if (loading) return;
|
||||
|
||||
if (reset) {
|
||||
photos = [];
|
||||
filters = { ...filters, offset: 0 };
|
||||
hasMore = true;
|
||||
}
|
||||
|
||||
loading = true;
|
||||
error = null;
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (filters.apps?.length) params.set('apps', filters.apps.join(','));
|
||||
if (filters.mimeType) params.set('mimeType', filters.mimeType);
|
||||
if (filters.dateFrom) params.set('dateFrom', filters.dateFrom);
|
||||
if (filters.dateTo) params.set('dateTo', filters.dateTo);
|
||||
if (filters.hasLocation !== undefined) params.set('hasLocation', String(filters.hasLocation));
|
||||
params.set('limit', String(filters.limit || 50));
|
||||
params.set('offset', String(filters.offset || 0));
|
||||
params.set('sortBy', filters.sortBy || 'dateTaken');
|
||||
params.set('sortOrder', filters.sortOrder || 'desc');
|
||||
|
||||
const result = await api.get<{ items: Photo[]; total: number; hasMore: boolean }>(
|
||||
`/photos?${params.toString()}`
|
||||
);
|
||||
|
||||
if (result.error) {
|
||||
error = result.error.message;
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.data) {
|
||||
photos = reset ? result.data.items : [...photos, ...result.data.items];
|
||||
hasMore = result.data.hasMore;
|
||||
filters = { ...filters, offset: (filters.offset || 0) + result.data.items.length };
|
||||
}
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to load photos';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Load more photos (pagination)
|
||||
*/
|
||||
async loadMore() {
|
||||
if (!hasMore || loading) return;
|
||||
await this.loadPhotos(false);
|
||||
},
|
||||
|
||||
/**
|
||||
* Update filters and reload
|
||||
*/
|
||||
async setFilters(newFilters: Partial<PhotoFilters>) {
|
||||
filters = { ...filters, ...newFilters, offset: 0 };
|
||||
await this.loadPhotos(true);
|
||||
},
|
||||
|
||||
/**
|
||||
* Load photo statistics
|
||||
*/
|
||||
async loadStats() {
|
||||
try {
|
||||
const result = await api.get<PhotoStats>('/photos/stats');
|
||||
if (result.data) {
|
||||
stats = result.data;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load stats:', e);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Select a photo for detail view
|
||||
*/
|
||||
selectPhoto(photo: Photo | null) {
|
||||
selectedPhoto = photo;
|
||||
},
|
||||
|
||||
/**
|
||||
* Toggle favorite status
|
||||
*/
|
||||
async toggleFavorite(mediaId: string) {
|
||||
try {
|
||||
const result = await api.post<{ isFavorited: boolean }>(`/favorites/${mediaId}/toggle`);
|
||||
if (result.data) {
|
||||
// Update photo in list
|
||||
photos = photos.map((p) =>
|
||||
p.id === mediaId ? { ...p, isFavorited: result.data!.isFavorited } : p
|
||||
);
|
||||
// Update selected photo if it's the same
|
||||
if (selectedPhoto?.id === mediaId) {
|
||||
selectedPhoto = { ...selectedPhoto, isFavorited: result.data.isFavorited };
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to toggle favorite:', e);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Clear all state
|
||||
*/
|
||||
reset() {
|
||||
photos = [];
|
||||
loading = false;
|
||||
error = null;
|
||||
hasMore = true;
|
||||
filters = {
|
||||
limit: 50,
|
||||
offset: 0,
|
||||
sortBy: 'dateTaken',
|
||||
sortOrder: 'desc',
|
||||
};
|
||||
stats = null;
|
||||
selectedPhoto = null;
|
||||
},
|
||||
};
|
||||
171
apps/photos/apps/web/src/lib/stores/tags.svelte.ts
Normal file
171
apps/photos/apps/web/src/lib/stores/tags.svelte.ts
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
/**
|
||||
* Tags Store - Manages tag state using Svelte 5 runes
|
||||
*/
|
||||
|
||||
import { api } from '$lib/api/client';
|
||||
import type { Tag } from '@photos/shared';
|
||||
|
||||
// State
|
||||
let tags = $state<Tag[]>([]);
|
||||
let loading = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
export const tagStore = {
|
||||
// Getters
|
||||
get tags() {
|
||||
return tags;
|
||||
},
|
||||
get loading() {
|
||||
return loading;
|
||||
},
|
||||
get error() {
|
||||
return error;
|
||||
},
|
||||
|
||||
/**
|
||||
* Load all tags
|
||||
*/
|
||||
async loadTags() {
|
||||
loading = true;
|
||||
error = null;
|
||||
|
||||
try {
|
||||
const result = await api.get<Tag[]>('/tags');
|
||||
if (result.error) {
|
||||
error = result.error.message;
|
||||
return;
|
||||
}
|
||||
if (result.data) {
|
||||
tags = result.data;
|
||||
}
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to load tags';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Create new tag
|
||||
*/
|
||||
async createTag(data: { name: string; color?: string }) {
|
||||
try {
|
||||
const result = await api.post<Tag>('/tags', data);
|
||||
if (result.error) {
|
||||
error = result.error.message;
|
||||
return null;
|
||||
}
|
||||
if (result.data) {
|
||||
tags = [...tags, result.data];
|
||||
return result.data;
|
||||
}
|
||||
return null;
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to create tag';
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Update tag
|
||||
*/
|
||||
async updateTag(id: string, data: { name?: string; color?: string }) {
|
||||
try {
|
||||
const result = await api.patch<Tag>(`/tags/${id}`, data);
|
||||
if (result.error) {
|
||||
error = result.error.message;
|
||||
return null;
|
||||
}
|
||||
if (result.data) {
|
||||
tags = tags.map((t) => (t.id === id ? result.data! : t));
|
||||
return result.data;
|
||||
}
|
||||
return null;
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to update tag';
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete tag
|
||||
*/
|
||||
async deleteTag(id: string) {
|
||||
try {
|
||||
const result = await api.delete(`/tags/${id}`);
|
||||
if (result.error) {
|
||||
error = result.error.message;
|
||||
return false;
|
||||
}
|
||||
tags = tags.filter((t) => t.id !== id);
|
||||
return true;
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to delete tag';
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Get tags for a photo
|
||||
*/
|
||||
async getPhotoTags(mediaId: string): Promise<Tag[]> {
|
||||
try {
|
||||
const result = await api.get<Tag[]>(`/photos/${mediaId}/tags`);
|
||||
if (result.data) {
|
||||
return result.data;
|
||||
}
|
||||
return [];
|
||||
} catch (e) {
|
||||
console.error('Failed to get photo tags:', e);
|
||||
return [];
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Add tag to photo
|
||||
*/
|
||||
async addTagToPhoto(mediaId: string, tagId: string) {
|
||||
try {
|
||||
const result = await api.post(`/photos/${mediaId}/tags/${tagId}`);
|
||||
return !result.error;
|
||||
} catch (e) {
|
||||
console.error('Failed to add tag to photo:', e);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Remove tag from photo
|
||||
*/
|
||||
async removeTagFromPhoto(mediaId: string, tagId: string) {
|
||||
try {
|
||||
const result = await api.delete(`/photos/${mediaId}/tags/${tagId}`);
|
||||
return !result.error;
|
||||
} catch (e) {
|
||||
console.error('Failed to remove tag from photo:', e);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Set all tags for a photo
|
||||
*/
|
||||
async setPhotoTags(mediaId: string, tagIds: string[]) {
|
||||
try {
|
||||
const result = await api.patch(`/photos/${mediaId}/tags`, { tagIds });
|
||||
return !result.error;
|
||||
} catch (e) {
|
||||
console.error('Failed to set photo tags:', e);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Reset store
|
||||
*/
|
||||
reset() {
|
||||
tags = [];
|
||||
loading = false;
|
||||
error = null;
|
||||
},
|
||||
};
|
||||
7
apps/photos/apps/web/src/lib/stores/theme.ts
Normal file
7
apps/photos/apps/web/src/lib/stores/theme.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import { createThemeStore } from '@manacore/shared-theme';
|
||||
|
||||
// Create theme store with Photos' primary color
|
||||
export const theme = createThemeStore({
|
||||
appId: 'photos',
|
||||
defaultVariant: 'lume',
|
||||
});
|
||||
134
apps/photos/apps/web/src/routes/(app)/+layout.svelte
Normal file
134
apps/photos/apps/web/src/routes/(app)/+layout.svelte
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/stores';
|
||||
import { onMount } from 'svelte';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { PillNavigation } from '@manacore/shared-ui';
|
||||
import type { PillNavItem, PillDropdownItem } from '@manacore/shared-ui';
|
||||
import { theme } from '$lib/stores/theme';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { photoStore } from '$lib/stores/photos.svelte';
|
||||
import { albumStore } from '$lib/stores/albums.svelte';
|
||||
import { tagStore } from '$lib/stores/tags.svelte';
|
||||
import { THEME_DEFINITIONS, DEFAULT_THEME_VARIANTS } from '@manacore/shared-theme';
|
||||
import type { ThemeVariant } from '@manacore/shared-theme';
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
let isDark = $derived(theme.isDark);
|
||||
let userEmail = $derived(authStore.user?.email || 'Menu');
|
||||
|
||||
// Navigation items
|
||||
const navItems: PillNavItem[] = [
|
||||
{ href: '/', label: $_('nav.gallery'), icon: 'image' },
|
||||
{ href: '/albums', label: $_('nav.albums'), icon: 'folder' },
|
||||
{ href: '/favorites', label: $_('nav.favorites'), icon: 'heart' },
|
||||
{ href: '/upload', label: $_('nav.upload'), icon: 'upload' },
|
||||
{ href: '/settings', label: $_('nav.settings'), icon: 'settings' },
|
||||
];
|
||||
|
||||
// Theme dropdown items
|
||||
let themeVariantItems = $derived<PillDropdownItem[]>(
|
||||
DEFAULT_THEME_VARIANTS.map((variant) => ({
|
||||
id: variant,
|
||||
label: THEME_DEFINITIONS[variant].label,
|
||||
icon: THEME_DEFINITIONS[variant].icon,
|
||||
onClick: () => theme.setVariant(variant),
|
||||
active: theme.variant === variant,
|
||||
}))
|
||||
);
|
||||
|
||||
let currentThemeVariantLabel = $derived(THEME_DEFINITIONS[theme.variant].label);
|
||||
|
||||
function handleToggleTheme() {
|
||||
theme.toggleMode();
|
||||
}
|
||||
|
||||
function handleThemeModeChange(mode: 'light' | 'dark' | 'system') {
|
||||
theme.setMode(mode);
|
||||
}
|
||||
|
||||
async function handleLogout() {
|
||||
await authStore.signOut();
|
||||
photoStore.reset();
|
||||
albumStore.reset();
|
||||
tagStore.reset();
|
||||
goto('/login');
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
await authStore.initialize();
|
||||
if (!authStore.isAuthenticated) {
|
||||
goto('/login');
|
||||
return;
|
||||
}
|
||||
|
||||
// Load initial data
|
||||
await Promise.all([photoStore.loadStats(), albumStore.loadAlbums(), tagStore.loadTags()]);
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="layout-container">
|
||||
<PillNavigation
|
||||
items={navItems}
|
||||
currentPath={$page.url.pathname}
|
||||
appName="Photos"
|
||||
homeRoute="/"
|
||||
onToggleTheme={handleToggleTheme}
|
||||
{isDark}
|
||||
desktopPosition="bottom"
|
||||
showThemeToggle={true}
|
||||
showThemeVariants={true}
|
||||
{themeVariantItems}
|
||||
{currentThemeVariantLabel}
|
||||
themeMode={theme.mode}
|
||||
onThemeModeChange={handleThemeModeChange}
|
||||
showLogout={true}
|
||||
onLogout={handleLogout}
|
||||
loginHref="/login"
|
||||
primaryColor="#8b5cf6"
|
||||
{userEmail}
|
||||
settingsHref="/settings"
|
||||
/>
|
||||
|
||||
<main class="main-content bg-background">
|
||||
<div class="content-wrapper">
|
||||
{@render children()}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.layout-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
flex: 1;
|
||||
padding-bottom: calc(100px + env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.main-content {
|
||||
padding-bottom: calc(120px + env(safe-area-inset-bottom));
|
||||
}
|
||||
}
|
||||
|
||||
.content-wrapper {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.content-wrapper {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.content-wrapper {
|
||||
padding: 2rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
137
apps/photos/apps/web/src/routes/(app)/+page.svelte
Normal file
137
apps/photos/apps/web/src/routes/(app)/+page.svelte
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { photoStore } from '$lib/stores/photos.svelte';
|
||||
import PhotoGrid from '$lib/components/gallery/PhotoGrid.svelte';
|
||||
import PhotoDetailModal from '$lib/components/gallery/PhotoDetailModal.svelte';
|
||||
import FilterBar from '$lib/components/filters/FilterBar.svelte';
|
||||
|
||||
let showFilters = $state(false);
|
||||
|
||||
onMount(async () => {
|
||||
await photoStore.loadPhotos(true);
|
||||
});
|
||||
|
||||
function handlePhotoClick(photo: any) {
|
||||
photoStore.selectPhoto(photo);
|
||||
}
|
||||
|
||||
function handleCloseModal() {
|
||||
photoStore.selectPhoto(null);
|
||||
}
|
||||
|
||||
function handleLoadMore() {
|
||||
photoStore.loadMore();
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{$_('gallery.title')} | Photos</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="gallery-page">
|
||||
<header class="page-header">
|
||||
<h1 class="text-2xl font-bold">{$_('gallery.title')}</h1>
|
||||
<div class="flex items-center gap-2">
|
||||
{#if photoStore.stats}
|
||||
<span class="text-sm text-muted-foreground">
|
||||
{photoStore.stats.totalCount}
|
||||
{photoStore.stats.totalCount === 1 ? $_('gallery.photo') : $_('gallery.photos')}
|
||||
</span>
|
||||
{/if}
|
||||
<button
|
||||
class="icon-btn"
|
||||
onclick={() => (showFilters = !showFilters)}
|
||||
title={$_('filters.title')}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{#if showFilters}
|
||||
<FilterBar />
|
||||
{/if}
|
||||
|
||||
{#if photoStore.error}
|
||||
<div class="error-message">
|
||||
<p>{photoStore.error}</p>
|
||||
</div>
|
||||
{:else if photoStore.photos.length === 0 && !photoStore.loading}
|
||||
<div class="empty-state">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="64"
|
||||
height="64"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="text-muted-foreground"
|
||||
>
|
||||
<rect width="18" height="18" x="3" y="3" rx="2" ry="2" />
|
||||
<circle cx="9" cy="9" r="2" />
|
||||
<path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21" />
|
||||
</svg>
|
||||
<h2 class="text-lg font-medium mt-4">{$_('gallery.empty')}</h2>
|
||||
<p class="text-muted-foreground">{$_('gallery.emptyHint')}</p>
|
||||
</div>
|
||||
{:else}
|
||||
<PhotoGrid
|
||||
photos={photoStore.photos}
|
||||
loading={photoStore.loading}
|
||||
hasMore={photoStore.hasMore}
|
||||
onPhotoClick={handlePhotoClick}
|
||||
onLoadMore={handleLoadMore}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if photoStore.selectedPhoto}
|
||||
<PhotoDetailModal photo={photoStore.selectedPhoto} onClose={handleCloseModal} />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.gallery-page {
|
||||
max-width: 1600px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 400px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
padding: 1rem;
|
||||
background-color: var(--color-destructive);
|
||||
color: var(--color-destructive-foreground);
|
||||
border-radius: var(--radius-md);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
</style>
|
||||
119
apps/photos/apps/web/src/routes/(app)/albums/+page.svelte
Normal file
119
apps/photos/apps/web/src/routes/(app)/albums/+page.svelte
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
<script lang="ts">
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { goto } from '$app/navigation';
|
||||
import { albumStore } from '$lib/stores/albums.svelte';
|
||||
import AlbumGrid from '$lib/components/albums/AlbumGrid.svelte';
|
||||
import CreateAlbumModal from '$lib/components/albums/CreateAlbumModal.svelte';
|
||||
|
||||
let showCreateModal = $state(false);
|
||||
|
||||
function handleAlbumClick(album: any) {
|
||||
goto(`/albums/${album.id}`);
|
||||
}
|
||||
|
||||
async function handleCreateAlbum(data: { name: string; description?: string }) {
|
||||
const album = await albumStore.createAlbum(data);
|
||||
if (album) {
|
||||
showCreateModal = false;
|
||||
goto(`/albums/${album.id}`);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{$_('albums.title')} | Photos</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="albums-page">
|
||||
<header class="page-header">
|
||||
<h1 class="text-2xl font-bold">{$_('albums.title')}</h1>
|
||||
<button class="btn btn-primary" onclick={() => (showCreateModal = true)}>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="mr-1"
|
||||
>
|
||||
<path d="M5 12h14" />
|
||||
<path d="M12 5v14" />
|
||||
</svg>
|
||||
{$_('albums.create')}
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{#if albumStore.error}
|
||||
<div class="error-message">
|
||||
<p>{albumStore.error}</p>
|
||||
</div>
|
||||
{:else if albumStore.albums.length === 0 && !albumStore.loading}
|
||||
<div class="empty-state">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="64"
|
||||
height="64"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="text-muted-foreground"
|
||||
>
|
||||
<path
|
||||
d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"
|
||||
/>
|
||||
</svg>
|
||||
<h2 class="text-lg font-medium mt-4">{$_('albums.empty')}</h2>
|
||||
<p class="text-muted-foreground">{$_('albums.emptyHint')}</p>
|
||||
<button class="btn btn-primary mt-4" onclick={() => (showCreateModal = true)}>
|
||||
{$_('albums.create')}
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<AlbumGrid
|
||||
albums={albumStore.albums}
|
||||
loading={albumStore.loading}
|
||||
onAlbumClick={handleAlbumClick}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if showCreateModal}
|
||||
<CreateAlbumModal onClose={() => (showCreateModal = false)} onCreate={handleCreateAlbum} />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.albums-page {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 400px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
padding: 1rem;
|
||||
background-color: var(--color-destructive);
|
||||
color: var(--color-destructive-foreground);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
</style>
|
||||
151
apps/photos/apps/web/src/routes/(app)/albums/[id]/+page.svelte
Normal file
151
apps/photos/apps/web/src/routes/(app)/albums/[id]/+page.svelte
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
import { onMount } from 'svelte';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { albumStore } from '$lib/stores/albums.svelte';
|
||||
import { photoStore } from '$lib/stores/photos.svelte';
|
||||
import PhotoGrid from '$lib/components/gallery/PhotoGrid.svelte';
|
||||
import PhotoDetailModal from '$lib/components/gallery/PhotoDetailModal.svelte';
|
||||
|
||||
const albumId = $derived($page.params.id);
|
||||
|
||||
onMount(async () => {
|
||||
if (albumId) {
|
||||
await albumStore.loadAlbum(albumId);
|
||||
}
|
||||
});
|
||||
|
||||
function handlePhotoClick(photo: any) {
|
||||
photoStore.selectPhoto(photo);
|
||||
}
|
||||
|
||||
function handleCloseModal() {
|
||||
photoStore.selectPhoto(null);
|
||||
}
|
||||
|
||||
async function handleDeleteAlbum() {
|
||||
if (confirm($_('albums.deleteConfirm'))) {
|
||||
const success = await albumStore.deleteAlbum(albumId);
|
||||
if (success) {
|
||||
goto('/albums');
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{albumStore.currentAlbum?.name || $_('albums.title')} | Photos</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="album-detail-page">
|
||||
{#if albumStore.loading}
|
||||
<div class="loading-state">
|
||||
<div class="animate-pulse text-muted-foreground">{$_('common.loading')}</div>
|
||||
</div>
|
||||
{:else if albumStore.error}
|
||||
<div class="error-message">
|
||||
<p>{albumStore.error}</p>
|
||||
</div>
|
||||
{:else if albumStore.currentAlbum}
|
||||
<header class="page-header">
|
||||
<div class="flex items-center gap-3">
|
||||
<button class="icon-btn" onclick={() => goto('/albums')} title="Back">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="m15 18-6-6 6-6" />
|
||||
</svg>
|
||||
</button>
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold">{albumStore.currentAlbum.name}</h1>
|
||||
{#if albumStore.currentAlbum.description}
|
||||
<p class="text-sm text-muted-foreground">{albumStore.currentAlbum.description}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm text-muted-foreground">
|
||||
{albumStore.albumPhotos.length}
|
||||
{$_('albums.items')}
|
||||
</span>
|
||||
<button
|
||||
class="icon-btn text-destructive"
|
||||
onclick={handleDeleteAlbum}
|
||||
title={$_('albums.delete')}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M3 6h18" />
|
||||
<path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6" />
|
||||
<path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{#if albumStore.albumPhotos.length === 0}
|
||||
<div class="empty-state">
|
||||
<p class="text-muted-foreground">{$_('gallery.empty')}</p>
|
||||
</div>
|
||||
{:else}
|
||||
<PhotoGrid
|
||||
photos={albumStore.albumPhotos}
|
||||
loading={false}
|
||||
hasMore={false}
|
||||
onPhotoClick={handlePhotoClick}
|
||||
onLoadMore={() => {}}
|
||||
/>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if photoStore.selectedPhoto}
|
||||
<PhotoDetailModal photo={photoStore.selectedPhoto} onClose={handleCloseModal} />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.album-detail-page {
|
||||
max-width: 1600px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.loading-state,
|
||||
.empty-state {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 300px;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
padding: 1rem;
|
||||
background-color: var(--color-destructive);
|
||||
color: var(--color-destructive-foreground);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
</style>
|
||||
128
apps/photos/apps/web/src/routes/(app)/favorites/+page.svelte
Normal file
128
apps/photos/apps/web/src/routes/(app)/favorites/+page.svelte
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { api } from '$lib/api/client';
|
||||
import { photoStore } from '$lib/stores/photos.svelte';
|
||||
import PhotoGrid from '$lib/components/gallery/PhotoGrid.svelte';
|
||||
import PhotoDetailModal from '$lib/components/gallery/PhotoDetailModal.svelte';
|
||||
import type { Photo } from '@photos/shared';
|
||||
|
||||
let favorites = $state<Photo[]>([]);
|
||||
let loading = $state(true);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
onMount(async () => {
|
||||
await loadFavorites();
|
||||
});
|
||||
|
||||
async function loadFavorites() {
|
||||
loading = true;
|
||||
error = null;
|
||||
|
||||
try {
|
||||
const result = await api.get<{ items: Photo[] }>('/favorites');
|
||||
if (result.error) {
|
||||
error = result.error.message;
|
||||
return;
|
||||
}
|
||||
if (result.data) {
|
||||
favorites = result.data.items;
|
||||
}
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to load favorites';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handlePhotoClick(photo: Photo) {
|
||||
photoStore.selectPhoto(photo);
|
||||
}
|
||||
|
||||
function handleCloseModal() {
|
||||
photoStore.selectPhoto(null);
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{$_('favorites.title')} | Photos</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="favorites-page">
|
||||
<header class="page-header">
|
||||
<h1 class="text-2xl font-bold">{$_('favorites.title')}</h1>
|
||||
<span class="text-sm text-muted-foreground">
|
||||
{favorites.length}
|
||||
{favorites.length === 1 ? $_('gallery.photo') : $_('gallery.photos')}
|
||||
</span>
|
||||
</header>
|
||||
|
||||
{#if error}
|
||||
<div class="error-message">
|
||||
<p>{error}</p>
|
||||
</div>
|
||||
{:else if favorites.length === 0 && !loading}
|
||||
<div class="empty-state">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="64"
|
||||
height="64"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="text-muted-foreground"
|
||||
>
|
||||
<path
|
||||
d="M19 14c1.49-1.46 3-3.21 3-5.5A5.5 5.5 0 0 0 16.5 3c-1.76 0-3 .5-4.5 2-1.5-1.5-2.74-2-4.5-2A5.5 5.5 0 0 0 2 8.5c0 2.3 1.5 4.05 3 5.5l7 7Z"
|
||||
/>
|
||||
</svg>
|
||||
<h2 class="text-lg font-medium mt-4">{$_('favorites.empty')}</h2>
|
||||
<p class="text-muted-foreground">{$_('favorites.emptyHint')}</p>
|
||||
</div>
|
||||
{:else}
|
||||
<PhotoGrid
|
||||
photos={favorites}
|
||||
{loading}
|
||||
hasMore={false}
|
||||
onPhotoClick={handlePhotoClick}
|
||||
onLoadMore={() => {}}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if photoStore.selectedPhoto}
|
||||
<PhotoDetailModal photo={photoStore.selectedPhoto} onClose={handleCloseModal} />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.favorites-page {
|
||||
max-width: 1600px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 400px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
padding: 1rem;
|
||||
background-color: var(--color-destructive);
|
||||
color: var(--color-destructive-foreground);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
</style>
|
||||
239
apps/photos/apps/web/src/routes/(app)/settings/+page.svelte
Normal file
239
apps/photos/apps/web/src/routes/(app)/settings/+page.svelte
Normal file
|
|
@ -0,0 +1,239 @@
|
|||
<script lang="ts">
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { theme } from '$lib/stores/theme';
|
||||
import { setLocale, supportedLocales, type SupportedLocale } from '$lib/i18n';
|
||||
import { THEME_DEFINITIONS, DEFAULT_THEME_VARIANTS } from '@manacore/shared-theme';
|
||||
|
||||
let selectedLocale = $state<SupportedLocale>('de');
|
||||
|
||||
function handleLocaleChange(e: Event) {
|
||||
const locale = (e.target as HTMLSelectElement).value as SupportedLocale;
|
||||
selectedLocale = locale;
|
||||
setLocale(locale);
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{$_('settings.title')} | Photos</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="settings-page">
|
||||
<header class="page-header">
|
||||
<h1 class="text-2xl font-bold">{$_('settings.title')}</h1>
|
||||
</header>
|
||||
|
||||
<div class="settings-list">
|
||||
<!-- Theme -->
|
||||
<div class="setting-item">
|
||||
<div class="setting-label">
|
||||
<span class="font-medium">{$_('settings.theme')}</span>
|
||||
</div>
|
||||
<div class="setting-control">
|
||||
<div class="theme-buttons">
|
||||
<button
|
||||
class="theme-btn"
|
||||
class:active={theme.mode === 'light'}
|
||||
onclick={() => theme.setMode('light')}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<circle cx="12" cy="12" r="4" />
|
||||
<path d="M12 2v2" />
|
||||
<path d="M12 20v2" />
|
||||
<path d="m4.93 4.93 1.41 1.41" />
|
||||
<path d="m17.66 17.66 1.41 1.41" />
|
||||
<path d="M2 12h2" />
|
||||
<path d="M20 12h2" />
|
||||
<path d="m6.34 17.66-1.41 1.41" />
|
||||
<path d="m19.07 4.93-1.41 1.41" />
|
||||
</svg>
|
||||
Light
|
||||
</button>
|
||||
<button
|
||||
class="theme-btn"
|
||||
class:active={theme.mode === 'dark'}
|
||||
onclick={() => theme.setMode('dark')}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z" />
|
||||
</svg>
|
||||
Dark
|
||||
</button>
|
||||
<button
|
||||
class="theme-btn"
|
||||
class:active={theme.mode === 'system'}
|
||||
onclick={() => theme.setMode('system')}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<rect width="20" height="14" x="2" y="3" rx="2" />
|
||||
<line x1="8" x2="16" y1="21" y2="21" />
|
||||
<line x1="12" x2="12" y1="17" y2="21" />
|
||||
</svg>
|
||||
System
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Theme Variant -->
|
||||
<div class="setting-item">
|
||||
<div class="setting-label">
|
||||
<span class="font-medium">Theme Variant</span>
|
||||
</div>
|
||||
<div class="setting-control">
|
||||
<div class="variant-grid">
|
||||
{#each DEFAULT_THEME_VARIANTS as variant}
|
||||
<button
|
||||
class="variant-btn"
|
||||
class:active={theme.variant === variant}
|
||||
onclick={() => theme.setVariant(variant)}
|
||||
>
|
||||
<span class="variant-icon">{THEME_DEFINITIONS[variant].icon}</span>
|
||||
<span class="variant-label">{THEME_DEFINITIONS[variant].label}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Language -->
|
||||
<div class="setting-item">
|
||||
<div class="setting-label">
|
||||
<span class="font-medium">{$_('settings.language')}</span>
|
||||
</div>
|
||||
<div class="setting-control">
|
||||
<select class="select" value={selectedLocale} onchange={handleLocaleChange}>
|
||||
{#each supportedLocales as locale}
|
||||
<option value={locale}>
|
||||
{locale === 'de' ? 'Deutsch' : 'English'}
|
||||
</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.settings-page {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.settings-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.setting-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem;
|
||||
background: var(--color-card);
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.theme-buttons {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.theme-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 1rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-background);
|
||||
color: var(--color-foreground);
|
||||
cursor: pointer;
|
||||
transition: all 150ms;
|
||||
}
|
||||
|
||||
.theme-btn:hover {
|
||||
background: var(--color-accent);
|
||||
}
|
||||
|
||||
.theme-btn.active {
|
||||
border-color: var(--color-primary);
|
||||
background: var(--color-primary);
|
||||
color: var(--color-primary-foreground);
|
||||
}
|
||||
|
||||
.variant-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.variant-btn {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-background);
|
||||
cursor: pointer;
|
||||
transition: all 150ms;
|
||||
}
|
||||
|
||||
.variant-btn:hover {
|
||||
background: var(--color-accent);
|
||||
}
|
||||
|
||||
.variant-btn.active {
|
||||
border-color: var(--color-primary);
|
||||
background: var(--color-accent);
|
||||
}
|
||||
|
||||
.variant-icon {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.variant-label {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-muted-foreground);
|
||||
}
|
||||
|
||||
.select {
|
||||
padding: 0.5rem 1rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-background);
|
||||
color: var(--color-foreground);
|
||||
min-width: 150px;
|
||||
}
|
||||
</style>
|
||||
311
apps/photos/apps/web/src/routes/(app)/upload/+page.svelte
Normal file
311
apps/photos/apps/web/src/routes/(app)/upload/+page.svelte
Normal file
|
|
@ -0,0 +1,311 @@
|
|||
<script lang="ts">
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { goto } from '$app/navigation';
|
||||
import { uploadWithAuth } from '$lib/api/client';
|
||||
import UploadDropzone from '$lib/components/upload/UploadDropzone.svelte';
|
||||
|
||||
interface UploadFile {
|
||||
file: File;
|
||||
preview: string;
|
||||
progress: number;
|
||||
status: 'pending' | 'uploading' | 'success' | 'error';
|
||||
error?: string;
|
||||
}
|
||||
|
||||
let files = $state<UploadFile[]>([]);
|
||||
let uploading = $state(false);
|
||||
|
||||
function handleFilesSelected(selectedFiles: File[]) {
|
||||
const newFiles = selectedFiles
|
||||
.filter((file) => file.type.startsWith('image/'))
|
||||
.map((file) => ({
|
||||
file,
|
||||
preview: URL.createObjectURL(file),
|
||||
progress: 0,
|
||||
status: 'pending' as const,
|
||||
}));
|
||||
files = [...files, ...newFiles];
|
||||
}
|
||||
|
||||
function removeFile(index: number) {
|
||||
URL.revokeObjectURL(files[index].preview);
|
||||
files = files.filter((_, i) => i !== index);
|
||||
}
|
||||
|
||||
async function uploadAll() {
|
||||
if (files.length === 0 || uploading) return;
|
||||
|
||||
uploading = true;
|
||||
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
if (files[i].status !== 'pending') continue;
|
||||
|
||||
files[i].status = 'uploading';
|
||||
files[i].progress = 0;
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', files[i].file);
|
||||
formData.append('app', 'photos');
|
||||
|
||||
await uploadWithAuth('/photos/upload', formData);
|
||||
|
||||
files[i].status = 'success';
|
||||
files[i].progress = 100;
|
||||
} catch (e) {
|
||||
files[i].status = 'error';
|
||||
files[i].error = e instanceof Error ? e.message : 'Upload failed';
|
||||
}
|
||||
}
|
||||
|
||||
uploading = false;
|
||||
|
||||
// If all successful, redirect to gallery after a delay
|
||||
const allSuccess = files.every((f) => f.status === 'success');
|
||||
if (allSuccess) {
|
||||
setTimeout(() => goto('/'), 1500);
|
||||
}
|
||||
}
|
||||
|
||||
function clearCompleted() {
|
||||
files.filter((f) => f.status === 'success').forEach((f) => URL.revokeObjectURL(f.preview));
|
||||
files = files.filter((f) => f.status !== 'success');
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{$_('upload.title')} | Photos</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="upload-page">
|
||||
<header class="page-header">
|
||||
<h1 class="text-2xl font-bold">{$_('upload.title')}</h1>
|
||||
</header>
|
||||
|
||||
<UploadDropzone onFilesSelected={handleFilesSelected} />
|
||||
|
||||
{#if files.length > 0}
|
||||
<div class="upload-list">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<span class="text-sm text-muted-foreground">
|
||||
{files.length}
|
||||
{files.length === 1 ? 'file' : 'files'}
|
||||
</span>
|
||||
<div class="flex gap-2">
|
||||
{#if files.some((f) => f.status === 'success')}
|
||||
<button class="btn btn-ghost" onclick={clearCompleted}> Clear completed </button>
|
||||
{/if}
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
onclick={uploadAll}
|
||||
disabled={uploading || files.every((f) => f.status !== 'pending')}
|
||||
>
|
||||
{#if uploading}
|
||||
{$_('upload.uploading')}
|
||||
{:else}
|
||||
Upload All
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="file-grid">
|
||||
{#each files as file, index}
|
||||
<div
|
||||
class="file-item"
|
||||
class:success={file.status === 'success'}
|
||||
class:error={file.status === 'error'}
|
||||
>
|
||||
<img src={file.preview} alt="" class="file-preview" />
|
||||
<div class="file-overlay">
|
||||
{#if file.status === 'pending'}
|
||||
<button class="remove-btn" onclick={() => removeFile(index)}>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path d="M18 6 6 18" />
|
||||
<path d="m6 6 12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
{:else if file.status === 'uploading'}
|
||||
<div class="progress-ring">
|
||||
<svg viewBox="0 0 36 36">
|
||||
<path
|
||||
class="progress-bg"
|
||||
d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831"
|
||||
/>
|
||||
<path
|
||||
class="progress-bar"
|
||||
stroke-dasharray="{file.progress}, 100"
|
||||
d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
{:else if file.status === 'success'}
|
||||
<div class="status-icon success">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<polyline points="20 6 9 17 4 12" />
|
||||
</svg>
|
||||
</div>
|
||||
{:else if file.status === 'error'}
|
||||
<div class="status-icon error" title={file.error}>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path d="M18 6 6 18" />
|
||||
<path d="m6 6 12 12" />
|
||||
</svg>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="file-name">{file.file.name}</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.upload-page {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.upload-list {
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.file-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.file-item {
|
||||
position: relative;
|
||||
aspect-ratio: 1;
|
||||
border-radius: var(--radius-md);
|
||||
overflow: hidden;
|
||||
background: var(--color-muted);
|
||||
}
|
||||
|
||||
.file-item.success {
|
||||
outline: 2px solid var(--color-success, #22c55e);
|
||||
}
|
||||
|
||||
.file-item.error {
|
||||
outline: 2px solid var(--color-destructive);
|
||||
}
|
||||
|
||||
.file-preview {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.file-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
opacity: 0;
|
||||
transition: opacity 150ms;
|
||||
}
|
||||
|
||||
.file-item:hover .file-overlay {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.file-item.success .file-overlay,
|
||||
.file-item.error .file-overlay {
|
||||
opacity: 1;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.remove-btn {
|
||||
padding: 0.5rem;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
color: var(--color-foreground);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.progress-ring {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.progress-ring svg {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
|
||||
.progress-bg {
|
||||
fill: none;
|
||||
stroke: rgba(255, 255, 255, 0.3);
|
||||
stroke-width: 3;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
fill: none;
|
||||
stroke: white;
|
||||
stroke-width: 3;
|
||||
stroke-linecap: round;
|
||||
transition: stroke-dasharray 300ms;
|
||||
}
|
||||
|
||||
.status-icon {
|
||||
padding: 0.5rem;
|
||||
border-radius: 50%;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.status-icon.success {
|
||||
background: var(--color-success, #22c55e);
|
||||
}
|
||||
|
||||
.status-icon.error {
|
||||
background: var(--color-destructive);
|
||||
}
|
||||
|
||||
.file-name {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
padding: 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
background: linear-gradient(to top, rgba(0, 0, 0, 0.7), transparent);
|
||||
color: white;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
</style>
|
||||
2
apps/photos/apps/web/src/routes/(app)/upload/+page.ts
Normal file
2
apps/photos/apps/web/src/routes/(app)/upload/+page.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
// Disable SSR for upload page - it uses browser-only APIs like File and FormData
|
||||
export const ssr = false;
|
||||
5
apps/photos/apps/web/src/routes/(auth)/+layout.svelte
Normal file
5
apps/photos/apps/web/src/routes/(auth)/+layout.svelte
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
<script lang="ts">
|
||||
let { children } = $props();
|
||||
</script>
|
||||
|
||||
{@render children()}
|
||||
56
apps/photos/apps/web/src/routes/(auth)/login/+page.svelte
Normal file
56
apps/photos/apps/web/src/routes/(auth)/login/+page.svelte
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/stores';
|
||||
import { locale } from 'svelte-i18n';
|
||||
import { onMount } from 'svelte';
|
||||
import { LoginPage } from '@manacore/shared-auth-ui';
|
||||
import { getLoginTranslations } from '@manacore/shared-i18n';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import '$lib/i18n';
|
||||
|
||||
let redirectTo = $state('/');
|
||||
|
||||
onMount(() => {
|
||||
const storedReturnUrl = sessionStorage.getItem('auth-return-url');
|
||||
if (storedReturnUrl) {
|
||||
redirectTo = storedReturnUrl;
|
||||
sessionStorage.removeItem('auth-return-url');
|
||||
} else {
|
||||
redirectTo = $page.url.searchParams.get('redirectTo') || '/';
|
||||
}
|
||||
});
|
||||
|
||||
const translations = $derived(getLoginTranslations($locale || 'de'));
|
||||
const verified = $derived($page.url.searchParams.get('verified') === 'true');
|
||||
const initialEmail = $derived($page.url.searchParams.get('email') || '');
|
||||
|
||||
async function handleSignIn(email: string, password: string) {
|
||||
return authStore.signIn(email, password);
|
||||
}
|
||||
|
||||
async function handleResendVerification(email: string) {
|
||||
return authStore.resendVerificationEmail(email);
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{translations.title} | Photos</title>
|
||||
</svelte:head>
|
||||
|
||||
<LoginPage
|
||||
appName="Photos"
|
||||
primaryColor="#8b5cf6"
|
||||
onSignIn={handleSignIn}
|
||||
onResendVerification={handleResendVerification}
|
||||
{goto}
|
||||
enableGoogle={false}
|
||||
enableApple={false}
|
||||
successRedirect={redirectTo}
|
||||
registerPath="/register"
|
||||
forgotPasswordPath="/forgot-password"
|
||||
lightBackground="#faf5ff"
|
||||
darkBackground="#1e1b4b"
|
||||
{translations}
|
||||
{verified}
|
||||
{initialEmail}
|
||||
/>
|
||||
29
apps/photos/apps/web/src/routes/(auth)/register/+page.svelte
Normal file
29
apps/photos/apps/web/src/routes/(auth)/register/+page.svelte
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { locale } from 'svelte-i18n';
|
||||
import { RegisterPage } from '@manacore/shared-auth-ui';
|
||||
import { getRegisterTranslations } from '@manacore/shared-i18n';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import '$lib/i18n';
|
||||
|
||||
const translations = $derived(getRegisterTranslations($locale || 'de'));
|
||||
|
||||
async function handleSignUp(email: string, password: string) {
|
||||
return authStore.signUp(email, password);
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{translations.title} | Photos</title>
|
||||
</svelte:head>
|
||||
|
||||
<RegisterPage
|
||||
appName="Photos"
|
||||
primaryColor="#8b5cf6"
|
||||
onSignUp={handleSignUp}
|
||||
{goto}
|
||||
loginPath="/login"
|
||||
lightBackground="#faf5ff"
|
||||
darkBackground="#1e1b4b"
|
||||
{translations}
|
||||
/>
|
||||
37
apps/photos/apps/web/src/routes/+layout.svelte
Normal file
37
apps/photos/apps/web/src/routes/+layout.svelte
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
<script lang="ts">
|
||||
import '../app.css';
|
||||
import '$lib/i18n';
|
||||
import { onMount } from 'svelte';
|
||||
import { isLoading as i18nLoading } from 'svelte-i18n';
|
||||
import { theme } from '$lib/stores/theme';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { ToastContainer, setupGlobalErrorHandler } from '@manacore/shared-ui';
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
let loading = $state(true);
|
||||
let appReady = $derived(!loading && !$i18nLoading);
|
||||
|
||||
onMount(() => {
|
||||
const cleanupErrorHandler = setupGlobalErrorHandler();
|
||||
theme.initialize();
|
||||
|
||||
authStore.initialize().then(() => {
|
||||
loading = false;
|
||||
});
|
||||
|
||||
return cleanupErrorHandler;
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if !appReady}
|
||||
<div class="min-h-screen bg-background flex items-center justify-center">
|
||||
<div class="animate-pulse text-muted-foreground">Loading...</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="min-h-screen bg-background text-foreground">
|
||||
{@render children()}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<ToastContainer />
|
||||
0
apps/photos/apps/web/static/favicon.png
Normal file
0
apps/photos/apps/web/static/favicon.png
Normal file
14
apps/photos/apps/web/svelte.config.js
Normal file
14
apps/photos/apps/web/svelte.config.js
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import adapter from '@sveltejs/adapter-node';
|
||||
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
|
||||
|
||||
/** @type {import('@sveltejs/kit').Config} */
|
||||
const config = {
|
||||
preprocess: vitePreprocess(),
|
||||
kit: {
|
||||
adapter: adapter({
|
||||
out: 'build',
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
14
apps/photos/apps/web/tsconfig.json
Normal file
14
apps/photos/apps/web/tsconfig.json
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
{
|
||||
"extends": "./.svelte-kit/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"allowJs": true,
|
||||
"checkJs": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"skipLibCheck": true,
|
||||
"sourceMap": true,
|
||||
"strict": true,
|
||||
"moduleResolution": "bundler"
|
||||
}
|
||||
}
|
||||
18
apps/photos/apps/web/vite.config.ts
Normal file
18
apps/photos/apps/web/vite.config.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import { sveltekit } from '@sveltejs/kit/vite';
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
import { defineConfig } from 'vite';
|
||||
import { MANACORE_SHARED_PACKAGES } from '@manacore/shared-vite-config';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [tailwindcss(), sveltekit()],
|
||||
server: {
|
||||
port: 5189,
|
||||
strictPort: true,
|
||||
},
|
||||
ssr: {
|
||||
noExternal: [...MANACORE_SHARED_PACKAGES],
|
||||
},
|
||||
optimizeDeps: {
|
||||
exclude: [...MANACORE_SHARED_PACKAGES],
|
||||
},
|
||||
});
|
||||
18
apps/photos/package.json
Normal file
18
apps/photos/package.json
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
{
|
||||
"name": "photos",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"description": "Photos App - Unified Photo Gallery for ManaCore Ecosystem",
|
||||
"scripts": {
|
||||
"dev": "pnpm run --filter=@photos/* --parallel dev",
|
||||
"dev:backend": "pnpm --filter @photos/backend dev",
|
||||
"dev:web": "pnpm --filter @photos/web dev",
|
||||
"db:push": "pnpm --filter @photos/backend db:push",
|
||||
"db:studio": "pnpm --filter @photos/backend db:studio",
|
||||
"db:seed": "pnpm --filter @photos/backend db:seed"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.9.3"
|
||||
},
|
||||
"packageManager": "pnpm@9.15.0"
|
||||
}
|
||||
17
apps/photos/packages/shared/package.json
Normal file
17
apps/photos/packages/shared/package.json
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
{
|
||||
"name": "@photos/shared",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./types": "./src/types/index.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"type-check": "tsc --noEmit"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
}
|
||||
1
apps/photos/packages/shared/src/index.ts
Normal file
1
apps/photos/packages/shared/src/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from './types';
|
||||
109
apps/photos/packages/shared/src/types/index.ts
Normal file
109
apps/photos/packages/shared/src/types/index.ts
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
export interface Photo {
|
||||
id: string;
|
||||
status: string;
|
||||
originalName: string | null;
|
||||
mimeType: string;
|
||||
size: number;
|
||||
hash: string;
|
||||
url: string;
|
||||
thumbnailUrl?: string;
|
||||
urls: {
|
||||
original: string;
|
||||
thumbnail?: string;
|
||||
medium?: string;
|
||||
large?: string;
|
||||
};
|
||||
width?: number;
|
||||
height?: number;
|
||||
metadata?: {
|
||||
width?: number;
|
||||
height?: number;
|
||||
format?: string;
|
||||
};
|
||||
exif?: ExifData;
|
||||
isFavorited: boolean;
|
||||
tags: Tag[];
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface ExifData {
|
||||
cameraMake?: string;
|
||||
cameraModel?: string;
|
||||
dateTaken?: string;
|
||||
focalLength?: string;
|
||||
aperture?: string;
|
||||
iso?: number;
|
||||
exposureTime?: string;
|
||||
gpsLatitude?: string;
|
||||
gpsLongitude?: string;
|
||||
}
|
||||
|
||||
export interface Album {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
coverMediaId?: string;
|
||||
coverUrl?: string;
|
||||
isAutoGenerated: boolean;
|
||||
autoGenerateType?: string;
|
||||
autoGenerateValue?: string;
|
||||
itemCount: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface AlbumWithItems extends Album {
|
||||
items: AlbumItem[];
|
||||
}
|
||||
|
||||
export interface AlbumItem {
|
||||
id: string;
|
||||
albumId: string;
|
||||
mediaId: string;
|
||||
sortOrder: number;
|
||||
addedAt: string;
|
||||
}
|
||||
|
||||
export interface Tag {
|
||||
id: string;
|
||||
name: string;
|
||||
color?: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface PhotoStats {
|
||||
totalCount: number;
|
||||
totalSize: number;
|
||||
byApp: Record<string, { count: number; size: number }>;
|
||||
byYear: Record<string, number>;
|
||||
}
|
||||
|
||||
export interface ListPhotosParams {
|
||||
apps?: string[];
|
||||
mimeType?: string;
|
||||
dateFrom?: string;
|
||||
dateTo?: string;
|
||||
hasLocation?: boolean;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
sortBy?: 'createdAt' | 'dateTaken' | 'size';
|
||||
sortOrder?: 'asc' | 'desc';
|
||||
}
|
||||
|
||||
export interface ListPhotosResult {
|
||||
items: Photo[];
|
||||
total: number;
|
||||
hasMore: boolean;
|
||||
}
|
||||
|
||||
export interface PhotoFilters {
|
||||
apps?: string[];
|
||||
mimeType?: string;
|
||||
dateFrom?: string;
|
||||
dateTo?: string;
|
||||
hasLocation?: boolean;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
sortBy?: 'createdAt' | 'dateTaken' | 'size';
|
||||
sortOrder?: 'asc' | 'desc';
|
||||
}
|
||||
12
apps/photos/packages/shared/tsconfig.json
Normal file
12
apps/photos/packages/shared/tsconfig.json
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2021",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"declaration": true,
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
|
|
@ -105,6 +105,13 @@
|
|||
"todo:db:push": "pnpm --filter @todo/backend db:push",
|
||||
"todo:db:studio": "pnpm --filter @todo/backend db:studio",
|
||||
"todo:db:seed": "pnpm --filter @todo/backend db:seed",
|
||||
"photos:dev": "turbo run dev --filter=photos...",
|
||||
"dev:photos:web": "pnpm --filter @photos/web dev",
|
||||
"dev:photos:backend": "pnpm --filter @photos/backend dev",
|
||||
"dev:photos:app": "turbo run dev --filter=@photos/web --filter=@photos/backend",
|
||||
"dev:photos:full": "./scripts/setup-databases.sh photos && ./scripts/setup-databases.sh auth && concurrently -n auth,backend,web -c blue,green,cyan \"pnpm dev:auth\" \"pnpm dev:photos:backend\" \"pnpm dev:photos:web\"",
|
||||
"photos:db:push": "pnpm --filter @photos/backend db:push",
|
||||
"photos:db:studio": "pnpm --filter @photos/backend db:studio",
|
||||
"dev:tags-test": "./scripts/setup-databases.sh todo && ./scripts/setup-databases.sh calendar && ./scripts/setup-databases.sh contacts && ./scripts/setup-databases.sh auth && concurrently -n auth,todo-be,todo-web,cal-be,cal-web,con-be,con-web -c blue,green,cyan,yellow,magenta,red,white \"pnpm dev:auth\" \"pnpm dev:todo:backend\" \"pnpm dev:todo:web\" \"pnpm dev:calendar:backend\" \"pnpm dev:calendar:web\" \"pnpm dev:contacts:backend\" \"pnpm dev:contacts:web\"",
|
||||
"matrix:dev": "turbo run dev --filter=matrix...",
|
||||
"dev:matrix:web": "pnpm --filter @matrix/web dev",
|
||||
|
|
|
|||
1163
pnpm-lock.yaml
generated
1163
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
|
|
@ -74,6 +74,7 @@ ALL_DATABASES=(
|
|||
"figgos"
|
||||
"planta"
|
||||
"nutriphi"
|
||||
"photos"
|
||||
"projectdoc"
|
||||
"zitare_bot"
|
||||
"todo_bot"
|
||||
|
|
@ -133,6 +134,10 @@ setup_service() {
|
|||
create_db_if_not_exists "picture"
|
||||
push_schema "@picture/backend" "picture"
|
||||
;;
|
||||
photos)
|
||||
create_db_if_not_exists "photos"
|
||||
push_schema "@photos/backend" "photos"
|
||||
;;
|
||||
finance)
|
||||
create_db_if_not_exists "finance"
|
||||
push_schema "@finance/backend" "finance"
|
||||
|
|
@ -187,7 +192,7 @@ setup_service() {
|
|||
;;
|
||||
*)
|
||||
echo -e "${RED}Unknown service: $service${NC}"
|
||||
echo "Available services: auth, chat, zitare, contacts, calendar, clock, todo, manadeck, mail, moodlit, finance, voxel-lava, figgos, planta, nutriphi, presi, storage, projectdoc, zitare_bot, todo_bot, nutriphi_bot, questions, skilltree"
|
||||
echo "Available services: auth, chat, zitare, contacts, calendar, clock, todo, manadeck, mail, moodlit, picture, photos, finance, voxel-lava, figgos, planta, nutriphi, presi, storage, projectdoc, zitare_bot, todo_bot, nutriphi_bot, questions, skilltree"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
|
@ -211,7 +216,7 @@ echo -e "\n${GREEN}Step 2: Pushing schemas${NC}"
|
|||
echo "--------------------------------------"
|
||||
|
||||
# Push schemas for all known services
|
||||
for service in auth chat zitare contacts calendar clock todo manadeck picture mail moodlit finance voxel-lava figgos planta nutriphi presi storage questions skilltree; do
|
||||
for service in auth chat zitare contacts calendar clock todo manadeck picture photos mail moodlit finance voxel-lava figgos planta nutriphi presi storage questions skilltree; do
|
||||
setup_service "$service" 2>/dev/null || true
|
||||
done
|
||||
|
||||
|
|
|
|||
|
|
@ -27,7 +27,8 @@
|
|||
"reflect-metadata": "^0.2.0",
|
||||
"rxjs": "^7.8.0",
|
||||
"sharp": "^0.33.0",
|
||||
"uuid": "^11.0.0"
|
||||
"uuid": "^11.0.0",
|
||||
"exifr": "^7.1.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@manacore/shared-drizzle-config": "workspace:*",
|
||||
|
|
|
|||
|
|
@ -38,6 +38,17 @@ export const media = pgTable(
|
|||
height: integer('height'),
|
||||
format: text('format'),
|
||||
hasAlpha: boolean('has_alpha'),
|
||||
// EXIF metadata
|
||||
exifData: jsonb('exif_data'),
|
||||
dateTaken: timestamp('date_taken', { withTimezone: true }),
|
||||
cameraMake: text('camera_make'),
|
||||
cameraModel: text('camera_model'),
|
||||
focalLength: text('focal_length'),
|
||||
aperture: text('aperture'),
|
||||
iso: integer('iso'),
|
||||
exposureTime: text('exposure_time'),
|
||||
gpsLatitude: text('gps_latitude'),
|
||||
gpsLongitude: text('gps_longitude'),
|
||||
// Generated variants
|
||||
thumbnailKey: text('thumbnail_key'),
|
||||
mediumKey: text('medium_key'),
|
||||
|
|
@ -50,6 +61,8 @@ export const media = pgTable(
|
|||
index('media_content_hash_idx').on(table.contentHash),
|
||||
index('media_status_idx').on(table.status),
|
||||
index('media_created_at_idx').on(table.createdAt),
|
||||
index('media_date_taken_idx').on(table.dateTaken),
|
||||
index('media_camera_idx').on(table.cameraMake, table.cameraModel),
|
||||
]
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,8 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { ExifService } from './exif.service';
|
||||
|
||||
@Module({
|
||||
providers: [ExifService],
|
||||
exports: [ExifService],
|
||||
})
|
||||
export class ExifModule {}
|
||||
110
services/mana-media/apps/api/src/modules/exif/exif.service.ts
Normal file
110
services/mana-media/apps/api/src/modules/exif/exif.service.ts
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import exifr from 'exifr';
|
||||
|
||||
export interface ExifData {
|
||||
// Camera info
|
||||
cameraMake?: string;
|
||||
cameraModel?: string;
|
||||
// Lens info
|
||||
focalLength?: string;
|
||||
aperture?: string;
|
||||
// Exposure
|
||||
iso?: number;
|
||||
exposureTime?: string;
|
||||
// Date/time
|
||||
dateTaken?: Date;
|
||||
// GPS
|
||||
gpsLatitude?: string;
|
||||
gpsLongitude?: string;
|
||||
// Full raw EXIF data
|
||||
raw?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ExifService {
|
||||
private readonly logger = new Logger(ExifService.name);
|
||||
|
||||
/**
|
||||
* Extract EXIF data from an image buffer
|
||||
*/
|
||||
async extract(buffer: Buffer): Promise<ExifData | null> {
|
||||
try {
|
||||
const exif = await exifr.parse(buffer, {
|
||||
// Include GPS data
|
||||
gps: true,
|
||||
// Parse all EXIF data
|
||||
tiff: true,
|
||||
exif: true,
|
||||
});
|
||||
|
||||
if (!exif) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const result: ExifData = {
|
||||
raw: exif,
|
||||
};
|
||||
|
||||
// Camera info
|
||||
if (exif.Make) {
|
||||
result.cameraMake = String(exif.Make).trim();
|
||||
}
|
||||
if (exif.Model) {
|
||||
result.cameraModel = String(exif.Model).trim();
|
||||
}
|
||||
|
||||
// Lens/exposure settings
|
||||
if (exif.FocalLength) {
|
||||
result.focalLength = `${exif.FocalLength}mm`;
|
||||
}
|
||||
if (exif.FNumber) {
|
||||
result.aperture = String(exif.FNumber);
|
||||
}
|
||||
if (exif.ISO) {
|
||||
result.iso = Number(exif.ISO);
|
||||
}
|
||||
if (exif.ExposureTime) {
|
||||
// Format as fraction (e.g., "1/125")
|
||||
if (exif.ExposureTime < 1) {
|
||||
result.exposureTime = `1/${Math.round(1 / exif.ExposureTime)}`;
|
||||
} else {
|
||||
result.exposureTime = `${exif.ExposureTime}s`;
|
||||
}
|
||||
}
|
||||
|
||||
// Date taken
|
||||
if (exif.DateTimeOriginal) {
|
||||
result.dateTaken = new Date(exif.DateTimeOriginal);
|
||||
} else if (exif.CreateDate) {
|
||||
result.dateTaken = new Date(exif.CreateDate);
|
||||
}
|
||||
|
||||
// GPS coordinates
|
||||
if (exif.latitude !== undefined && exif.longitude !== undefined) {
|
||||
result.gpsLatitude = String(exif.latitude);
|
||||
result.gpsLongitude = String(exif.longitude);
|
||||
}
|
||||
|
||||
this.logger.debug(
|
||||
`Extracted EXIF: camera=${result.cameraMake} ${result.cameraModel}, date=${result.dateTaken}`
|
||||
);
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
this.logger.warn(`Failed to extract EXIF data: ${error}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the buffer likely contains EXIF data (quick check)
|
||||
*/
|
||||
hasExif(buffer: Buffer): boolean {
|
||||
// JPEG files with EXIF start with FFD8 and contain "Exif" marker
|
||||
if (buffer[0] === 0xff && buffer[1] === 0xd8) {
|
||||
const exifMarker = buffer.indexOf('Exif');
|
||||
return exifMarker !== -1 && exifMarker < 100;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
|
@ -4,6 +4,8 @@ import { ProcessService } from './process.service';
|
|||
import { ProcessWorker } from './process.worker';
|
||||
import { PROCESS_QUEUE } from './process.constants';
|
||||
import { UploadModule } from '../upload/upload.module';
|
||||
import { ExifModule } from '../exif/exif.module';
|
||||
import { StorageModule } from '../storage/storage.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
|
|
@ -11,6 +13,8 @@ import { UploadModule } from '../upload/upload.module';
|
|||
name: PROCESS_QUEUE,
|
||||
}),
|
||||
forwardRef(() => UploadModule),
|
||||
ExifModule,
|
||||
StorageModule,
|
||||
],
|
||||
providers: [ProcessService, ProcessWorker],
|
||||
exports: [ProcessService],
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import sharp from 'sharp';
|
||||
import { StorageService } from '../storage/storage.service';
|
||||
import { ExifService, type ExifData } from '../exif/exif.service';
|
||||
import { IMAGE_VARIANTS, SUPPORTED_IMAGE_TYPES } from './process.constants';
|
||||
|
||||
export interface ProcessResult {
|
||||
|
|
@ -13,11 +14,15 @@ export interface ProcessResult {
|
|||
format?: string;
|
||||
hasAlpha?: boolean;
|
||||
};
|
||||
exif?: ExifData;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ProcessService {
|
||||
constructor(private storage: StorageService) {}
|
||||
constructor(
|
||||
private storage: StorageService,
|
||||
private exifService: ExifService
|
||||
) {}
|
||||
|
||||
async processImage(
|
||||
mediaId: string,
|
||||
|
|
@ -35,6 +40,9 @@ export class ProcessService {
|
|||
const image = sharp(originalBuffer);
|
||||
const metadata = await image.metadata();
|
||||
|
||||
// Extract EXIF data
|
||||
const exifData = await this.exifService.extract(originalBuffer);
|
||||
|
||||
const result: ProcessResult = {
|
||||
metadata: {
|
||||
width: metadata.width,
|
||||
|
|
@ -42,6 +50,7 @@ export class ProcessService {
|
|||
format: metadata.format,
|
||||
hasAlpha: metadata.hasAlpha,
|
||||
},
|
||||
exif: exifData || undefined,
|
||||
};
|
||||
|
||||
// Generate variants
|
||||
|
|
|
|||
|
|
@ -57,10 +57,21 @@ export class ProcessWorker extends WorkerHost {
|
|||
height: result.metadata?.height,
|
||||
format: result.metadata?.format,
|
||||
hasAlpha: result.metadata?.hasAlpha,
|
||||
// EXIF data
|
||||
exifData: result.exif?.raw,
|
||||
dateTaken: result.exif?.dateTaken,
|
||||
cameraMake: result.exif?.cameraMake,
|
||||
cameraModel: result.exif?.cameraModel,
|
||||
focalLength: result.exif?.focalLength,
|
||||
aperture: result.exif?.aperture,
|
||||
iso: result.exif?.iso,
|
||||
exposureTime: result.exif?.exposureTime,
|
||||
gpsLatitude: result.exif?.gpsLatitude,
|
||||
gpsLongitude: result.exif?.gpsLongitude,
|
||||
});
|
||||
|
||||
this.logger.log(
|
||||
`Processed image ${mediaId}: thumbnail=${!!result.thumbnail}, medium=${!!result.medium}, large=${!!result.large}`
|
||||
`Processed image ${mediaId}: thumbnail=${!!result.thumbnail}, medium=${!!result.medium}, large=${!!result.large}, exif=${!!result.exif}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,9 +27,38 @@ interface UploadResponse {
|
|||
medium?: string;
|
||||
large?: string;
|
||||
};
|
||||
metadata?: {
|
||||
width?: number;
|
||||
height?: number;
|
||||
format?: string;
|
||||
};
|
||||
exif?: {
|
||||
cameraMake?: string;
|
||||
cameraModel?: string;
|
||||
dateTaken?: Date;
|
||||
focalLength?: string;
|
||||
aperture?: string;
|
||||
iso?: number;
|
||||
exposureTime?: string;
|
||||
gpsLatitude?: string;
|
||||
gpsLongitude?: string;
|
||||
};
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
interface ListAllResponse {
|
||||
items: UploadResponse[];
|
||||
total: number;
|
||||
hasMore: boolean;
|
||||
}
|
||||
|
||||
interface StatsResponse {
|
||||
totalCount: number;
|
||||
totalSize: number;
|
||||
byApp: Record<string, { count: number; size: number }>;
|
||||
byYear: Record<string, number>;
|
||||
}
|
||||
|
||||
interface ImportFromMatrixDto {
|
||||
mxcUrl: string;
|
||||
app: string;
|
||||
|
|
@ -135,6 +164,58 @@ export class UploadController {
|
|||
return records.map((r) => this.toResponse(r));
|
||||
}
|
||||
|
||||
/**
|
||||
* List media across all apps for a user with advanced filtering
|
||||
* Supports filtering by multiple apps, date range, MIME type, etc.
|
||||
*/
|
||||
@Get('list/all')
|
||||
async listAll(
|
||||
@Query('userId') userId: string,
|
||||
@Query('apps') apps?: string,
|
||||
@Query('mimeType') mimeType?: string,
|
||||
@Query('dateFrom') dateFrom?: string,
|
||||
@Query('dateTo') dateTo?: string,
|
||||
@Query('hasLocation') hasLocation?: string,
|
||||
@Query('limit') limit?: string,
|
||||
@Query('offset') offset?: string,
|
||||
@Query('sortBy') sortBy?: 'createdAt' | 'dateTaken' | 'size',
|
||||
@Query('sortOrder') sortOrder?: 'asc' | 'desc'
|
||||
): Promise<ListAllResponse> {
|
||||
if (!userId) {
|
||||
throw new BadRequestException('userId is required');
|
||||
}
|
||||
|
||||
const result = await this.uploadService.listAll({
|
||||
userId,
|
||||
apps: apps ? apps.split(',').map((a) => a.trim()) : undefined,
|
||||
mimeType,
|
||||
dateFrom: dateFrom ? new Date(dateFrom) : undefined,
|
||||
dateTo: dateTo ? new Date(dateTo) : undefined,
|
||||
hasLocation: hasLocation === 'true',
|
||||
limit: limit ? parseInt(limit) : 50,
|
||||
offset: offset ? parseInt(offset) : 0,
|
||||
sortBy: sortBy || 'createdAt',
|
||||
sortOrder: sortOrder || 'desc',
|
||||
});
|
||||
|
||||
return {
|
||||
items: result.items.map((r) => this.toResponse(r)),
|
||||
total: result.total,
|
||||
hasMore: result.hasMore,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get media statistics for a user
|
||||
*/
|
||||
@Get('stats')
|
||||
async stats(@Query('userId') userId: string): Promise<StatsResponse> {
|
||||
if (!userId) {
|
||||
throw new BadRequestException('userId is required');
|
||||
}
|
||||
return this.uploadService.getStats(userId);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
async delete(@Param('id') id: string): Promise<{ success: boolean }> {
|
||||
const deleted = await this.uploadService.delete(id);
|
||||
|
|
@ -160,6 +241,8 @@ export class UploadController {
|
|||
medium: record.keys.medium ? `${baseUrl}/media/${record.id}/file/medium` : undefined,
|
||||
large: record.keys.large ? `${baseUrl}/media/${record.id}/file/large` : undefined,
|
||||
},
|
||||
metadata: record.metadata,
|
||||
exif: record.exif,
|
||||
createdAt: record.createdAt,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { InjectQueue } from '@nestjs/bullmq';
|
|||
import { Queue } from 'bullmq';
|
||||
import * as mime from 'mime-types';
|
||||
import * as crypto from 'crypto';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { eq, and, or, gte, lte, like, isNotNull, sql, desc, asc, inArray } from 'drizzle-orm';
|
||||
import { StorageService } from '../storage/storage.service';
|
||||
import { MatrixService } from '../matrix/matrix.service';
|
||||
import { PROCESS_QUEUE } from '../process/process.constants';
|
||||
|
|
@ -38,10 +38,47 @@ export interface MediaRecord {
|
|||
format?: string;
|
||||
hasAlpha?: boolean;
|
||||
};
|
||||
exif?: {
|
||||
cameraMake?: string;
|
||||
cameraModel?: string;
|
||||
dateTaken?: Date;
|
||||
focalLength?: string;
|
||||
aperture?: string;
|
||||
iso?: number;
|
||||
exposureTime?: string;
|
||||
gpsLatitude?: string;
|
||||
gpsLongitude?: string;
|
||||
};
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface ListAllOptions {
|
||||
userId: string;
|
||||
apps?: string[];
|
||||
mimeType?: string;
|
||||
dateFrom?: Date;
|
||||
dateTo?: Date;
|
||||
hasLocation?: boolean;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
sortBy?: 'createdAt' | 'dateTaken' | 'size';
|
||||
sortOrder?: 'asc' | 'desc';
|
||||
}
|
||||
|
||||
export interface ListAllResult {
|
||||
items: MediaRecord[];
|
||||
total: number;
|
||||
hasMore: boolean;
|
||||
}
|
||||
|
||||
export interface StatsResult {
|
||||
totalCount: number;
|
||||
totalSize: number;
|
||||
byApp: Record<string, { count: number; size: number }>;
|
||||
byYear: Record<string, number>;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class UploadService {
|
||||
constructor(
|
||||
|
|
@ -208,6 +245,16 @@ export class UploadService {
|
|||
| 'height'
|
||||
| 'format'
|
||||
| 'hasAlpha'
|
||||
| 'exifData'
|
||||
| 'dateTaken'
|
||||
| 'cameraMake'
|
||||
| 'cameraModel'
|
||||
| 'focalLength'
|
||||
| 'aperture'
|
||||
| 'iso'
|
||||
| 'exposureTime'
|
||||
| 'gpsLatitude'
|
||||
| 'gpsLongitude'
|
||||
>
|
||||
>
|
||||
): Promise<MediaRecord | null> {
|
||||
|
|
@ -277,6 +324,136 @@ export class UploadService {
|
|||
return results.map((r) => this.toMediaRecord(r));
|
||||
}
|
||||
|
||||
/**
|
||||
* List media across all apps for a user with advanced filtering
|
||||
*/
|
||||
async listAll(options: ListAllOptions): Promise<ListAllResult> {
|
||||
const conditions = [eq(mediaReferences.userId, options.userId)];
|
||||
|
||||
// Filter by multiple apps
|
||||
if (options.apps && options.apps.length > 0) {
|
||||
conditions.push(inArray(mediaReferences.app, options.apps));
|
||||
}
|
||||
|
||||
// Filter by MIME type (supports wildcards like "image/*")
|
||||
if (options.mimeType) {
|
||||
if (options.mimeType.endsWith('/*')) {
|
||||
const prefix = options.mimeType.slice(0, -1);
|
||||
conditions.push(like(media.mimeType, `${prefix}%`));
|
||||
} else {
|
||||
conditions.push(eq(media.mimeType, options.mimeType));
|
||||
}
|
||||
}
|
||||
|
||||
// Filter by date range
|
||||
if (options.dateFrom) {
|
||||
conditions.push(gte(media.createdAt, options.dateFrom));
|
||||
}
|
||||
if (options.dateTo) {
|
||||
conditions.push(lte(media.createdAt, options.dateTo));
|
||||
}
|
||||
|
||||
// Filter by location
|
||||
if (options.hasLocation) {
|
||||
conditions.push(isNotNull(media.gpsLatitude));
|
||||
conditions.push(isNotNull(media.gpsLongitude));
|
||||
}
|
||||
|
||||
// Only show ready media
|
||||
conditions.push(eq(media.status, 'ready'));
|
||||
|
||||
// Build order by
|
||||
const orderColumn =
|
||||
options.sortBy === 'dateTaken'
|
||||
? media.dateTaken
|
||||
: options.sortBy === 'size'
|
||||
? media.size
|
||||
: media.createdAt;
|
||||
const orderFn = options.sortOrder === 'asc' ? asc : desc;
|
||||
|
||||
// Get total count
|
||||
const countResult = await this.db
|
||||
.select({ count: sql<number>`count(distinct ${media.id})` })
|
||||
.from(media)
|
||||
.innerJoin(mediaReferences, eq(media.id, mediaReferences.mediaId))
|
||||
.where(and(...conditions));
|
||||
const total = Number(countResult[0]?.count || 0);
|
||||
|
||||
// Get paginated results
|
||||
const limit = options.limit || 50;
|
||||
const offset = options.offset || 0;
|
||||
|
||||
const results = await this.db
|
||||
.selectDistinct({ media: media })
|
||||
.from(media)
|
||||
.innerJoin(mediaReferences, eq(media.id, mediaReferences.mediaId))
|
||||
.where(and(...conditions))
|
||||
.orderBy(orderFn(orderColumn))
|
||||
.limit(limit)
|
||||
.offset(offset);
|
||||
|
||||
return {
|
||||
items: results.map((r) => this.toMediaRecord(r.media)),
|
||||
total,
|
||||
hasMore: offset + results.length < total,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get media statistics for a user
|
||||
*/
|
||||
async getStats(userId: string): Promise<StatsResult> {
|
||||
// Total count and size
|
||||
const totalResult = await this.db
|
||||
.select({
|
||||
count: sql<number>`count(distinct ${media.id})`,
|
||||
size: sql<number>`sum(${media.size})`,
|
||||
})
|
||||
.from(media)
|
||||
.innerJoin(mediaReferences, eq(media.id, mediaReferences.mediaId))
|
||||
.where(eq(mediaReferences.userId, userId));
|
||||
|
||||
// By app
|
||||
const byAppResult = await this.db
|
||||
.select({
|
||||
app: mediaReferences.app,
|
||||
count: sql<number>`count(distinct ${media.id})`,
|
||||
size: sql<number>`sum(${media.size})`,
|
||||
})
|
||||
.from(media)
|
||||
.innerJoin(mediaReferences, eq(media.id, mediaReferences.mediaId))
|
||||
.where(eq(mediaReferences.userId, userId))
|
||||
.groupBy(mediaReferences.app);
|
||||
|
||||
// By year
|
||||
const byYearResult = await this.db
|
||||
.select({
|
||||
year: sql<string>`extract(year from ${media.createdAt})::text`,
|
||||
count: sql<number>`count(distinct ${media.id})`,
|
||||
})
|
||||
.from(media)
|
||||
.innerJoin(mediaReferences, eq(media.id, mediaReferences.mediaId))
|
||||
.where(eq(mediaReferences.userId, userId))
|
||||
.groupBy(sql`extract(year from ${media.createdAt})`);
|
||||
|
||||
const byApp: Record<string, { count: number; size: number }> = {};
|
||||
for (const row of byAppResult) {
|
||||
byApp[row.app] = { count: Number(row.count), size: Number(row.size) };
|
||||
}
|
||||
|
||||
const byYear: Record<string, number> = {};
|
||||
for (const row of byYearResult) {
|
||||
byYear[row.year] = Number(row.count);
|
||||
}
|
||||
|
||||
return {
|
||||
totalCount: Number(totalResult[0]?.count || 0),
|
||||
totalSize: Number(totalResult[0]?.size || 0),
|
||||
byApp,
|
||||
byYear,
|
||||
};
|
||||
}
|
||||
|
||||
private async findByHash(hash: string): Promise<Media | null> {
|
||||
const [result] = await this.db.select().from(media).where(eq(media.contentHash, hash)).limit(1);
|
||||
return result || null;
|
||||
|
|
@ -322,6 +499,20 @@ export class UploadService {
|
|||
hasAlpha: m.hasAlpha || undefined,
|
||||
}
|
||||
: undefined,
|
||||
exif:
|
||||
m.cameraMake || m.dateTaken || m.gpsLatitude
|
||||
? {
|
||||
cameraMake: m.cameraMake || undefined,
|
||||
cameraModel: m.cameraModel || undefined,
|
||||
dateTaken: m.dateTaken || undefined,
|
||||
focalLength: m.focalLength || undefined,
|
||||
aperture: m.aperture || undefined,
|
||||
iso: m.iso || undefined,
|
||||
exposureTime: m.exposureTime || undefined,
|
||||
gpsLatitude: m.gpsLatitude || undefined,
|
||||
gpsLongitude: m.gpsLongitude || undefined,
|
||||
}
|
||||
: undefined,
|
||||
createdAt: m.createdAt,
|
||||
updatedAt: m.updatedAt,
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue