mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 22:21:10 +02:00
✨ feat(storage): add Storage app scaffolding and root config
- Add storage app directory structure - Add storage dev scripts to root package.json - Add storage backend and web env generation in generate-env.mjs - Update COMMANDS.md with calendar app reference 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
1a88dec0a5
commit
19500e8467
88 changed files with 8293 additions and 5 deletions
|
|
@ -9,6 +9,7 @@ pnpm docker:down
|
|||
|
||||
pnpm dev:chat:app
|
||||
pnpm dev:contacts:app
|
||||
pnpm dev:calendar:app
|
||||
pnpm dev:picture:app
|
||||
pnpm dev:manacore:app
|
||||
pnpm dev:zitare:app
|
||||
|
|
|
|||
256
apps/storage/CLAUDE.md
Normal file
256
apps/storage/CLAUDE.md
Normal file
|
|
@ -0,0 +1,256 @@
|
|||
# Storage Project Guide
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
apps/storage/
|
||||
├── apps/
|
||||
│ ├── backend/ # NestJS API server (@storage/backend) - Port 3016
|
||||
│ ├── landing/ # Astro marketing landing page (@storage/landing)
|
||||
│ └── web/ # SvelteKit web application (@storage/web) - Port 5185
|
||||
├── packages/
|
||||
│ └── shared/ # Shared types, utils, configs (@storage/shared)
|
||||
└── package.json
|
||||
```
|
||||
|
||||
## Commands
|
||||
|
||||
### Root Level (from monorepo root)
|
||||
|
||||
```bash
|
||||
pnpm storage:dev # Run all storage apps
|
||||
pnpm dev:storage:web # Start web app
|
||||
pnpm dev:storage:landing # Start landing page
|
||||
pnpm dev:storage:backend # Start backend server
|
||||
pnpm dev:storage:app # Start web + backend together
|
||||
pnpm storage:db:push # Push schema to database
|
||||
pnpm storage:db:studio # Open Drizzle Studio
|
||||
pnpm storage:db:seed # Seed database
|
||||
```
|
||||
|
||||
### Backend (apps/storage/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
|
||||
```
|
||||
|
||||
### Web App (apps/storage/apps/web)
|
||||
|
||||
```bash
|
||||
pnpm dev # Start dev server
|
||||
pnpm build # Build for production
|
||||
pnpm preview # Preview production build
|
||||
```
|
||||
|
||||
### Landing Page (apps/storage/apps/landing)
|
||||
|
||||
```bash
|
||||
pnpm dev # Start dev server
|
||||
pnpm build # Build for production
|
||||
```
|
||||
|
||||
## Technology Stack
|
||||
|
||||
- **Web**: SvelteKit 2.x, Svelte 5 (runes mode), Tailwind CSS
|
||||
- **Landing**: Astro 5.x, Tailwind CSS
|
||||
- **Backend**: NestJS 11, Drizzle ORM, PostgreSQL
|
||||
- **Storage**: S3-compatible (MinIO local, Hetzner production)
|
||||
- **Types**: TypeScript 5.x
|
||||
|
||||
## Architecture
|
||||
|
||||
### Backend API Endpoints
|
||||
|
||||
#### Files
|
||||
|
||||
| Endpoint | Method | Description |
|
||||
| --------------------------------- | ------ | -------------------------- |
|
||||
| `/api/v1/health` | GET | Health check |
|
||||
| `/api/v1/files` | GET | List files (with folderId) |
|
||||
| `/api/v1/files/:id` | GET | Get file details |
|
||||
| `/api/v1/files/upload` | POST | Upload file (multipart) |
|
||||
| `/api/v1/files/:id/download` | GET | Download file |
|
||||
| `/api/v1/files/:id` | PATCH | Update file (rename) |
|
||||
| `/api/v1/files/:id/move` | PATCH | Move file to folder |
|
||||
| `/api/v1/files/:id` | DELETE | Soft delete file |
|
||||
| `/api/v1/files/:id/favorite` | POST | Toggle favorite |
|
||||
| `/api/v1/files/:id/versions` | GET | List file versions |
|
||||
| `/api/v1/files/:id/versions` | POST | Upload new version |
|
||||
| `/api/v1/files/:id/tags` | POST | Update file tags |
|
||||
|
||||
#### Folders
|
||||
|
||||
| Endpoint | Method | Description |
|
||||
| --------------------------------- | ------ | -------------------------- |
|
||||
| `/api/v1/folders` | GET | List root folders |
|
||||
| `/api/v1/folders/:id` | GET | Get folder with contents |
|
||||
| `/api/v1/folders/:id/tree` | GET | Get folder tree (sidebar) |
|
||||
| `/api/v1/folders` | POST | Create folder |
|
||||
| `/api/v1/folders/:id` | PATCH | Update folder |
|
||||
| `/api/v1/folders/:id/move` | PATCH | Move folder |
|
||||
| `/api/v1/folders/:id` | DELETE | Soft delete folder |
|
||||
| `/api/v1/folders/:id/favorite` | POST | Toggle favorite |
|
||||
|
||||
#### Shares
|
||||
|
||||
| Endpoint | Method | Description |
|
||||
| --------------------------------- | ------ | -------------------------- |
|
||||
| `/api/v1/shares` | GET | List user's shares |
|
||||
| `/api/v1/shares` | POST | Create share link |
|
||||
| `/api/v1/shares/:id` | PATCH | Update share settings |
|
||||
| `/api/v1/shares/:id` | DELETE | Revoke share |
|
||||
| `/api/v1/public/shares/:token` | GET | Access shared item (public)|
|
||||
| `/api/v1/public/shares/:token/download` | GET | Download shared file |
|
||||
|
||||
#### 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 |
|
||||
|
||||
#### Trash
|
||||
|
||||
| Endpoint | Method | Description |
|
||||
| --------------------------------- | ------ | -------------------------- |
|
||||
| `/api/v1/trash` | GET | List trash items |
|
||||
| `/api/v1/trash/:id/restore` | POST | Restore item |
|
||||
| `/api/v1/trash/:id` | DELETE | Permanently delete |
|
||||
| `/api/v1/trash` | DELETE | Empty trash |
|
||||
|
||||
#### Search & Favorites
|
||||
|
||||
| Endpoint | Method | Description |
|
||||
| --------------------------------- | ------ | -------------------------- |
|
||||
| `/api/v1/search?q=...` | GET | Search files & folders |
|
||||
| `/api/v1/favorites` | GET | List favorites |
|
||||
|
||||
### Database Schema
|
||||
|
||||
**files** - File metadata
|
||||
|
||||
- `id` (UUID) - Primary key
|
||||
- `user_id` (VARCHAR) - User reference
|
||||
- `name` (VARCHAR) - Display name
|
||||
- `original_name` (VARCHAR) - Original filename
|
||||
- `mime_type` (VARCHAR) - MIME type
|
||||
- `size` (BIGINT) - File size in bytes
|
||||
- `storage_path` (VARCHAR) - Full S3 path
|
||||
- `storage_key` (VARCHAR) - S3 object key (unique)
|
||||
- `parent_folder_id` (UUID) - Parent folder reference
|
||||
- `current_version` (INTEGER) - Current version number
|
||||
- `is_favorite` (BOOLEAN) - Favorite flag
|
||||
- `is_deleted` (BOOLEAN) - Soft delete flag
|
||||
- `deleted_at` (TIMESTAMP) - Deletion timestamp
|
||||
- `created_at`, `updated_at` (TIMESTAMP)
|
||||
|
||||
**folders** - Folder hierarchy
|
||||
|
||||
- `id` (UUID) - Primary key
|
||||
- `user_id` (VARCHAR) - User reference
|
||||
- `name` (VARCHAR) - Folder name
|
||||
- `description` (TEXT) - Optional description
|
||||
- `parent_folder_id` (UUID) - Self-reference for hierarchy
|
||||
- `path` (TEXT) - Materialized path (e.g., /root/subfolder)
|
||||
- `depth` (INTEGER) - Depth in hierarchy
|
||||
- `is_favorite` (BOOLEAN) - Favorite flag
|
||||
- `is_deleted` (BOOLEAN) - Soft delete flag
|
||||
- `deleted_at` (TIMESTAMP) - Deletion timestamp
|
||||
- `created_at`, `updated_at` (TIMESTAMP)
|
||||
|
||||
**file_versions** - Version history
|
||||
|
||||
- `id` (UUID) - Primary key
|
||||
- `file_id` (UUID) - File reference
|
||||
- `version_number` (INTEGER) - Version number
|
||||
- `storage_path` (VARCHAR) - S3 path for this version
|
||||
- `storage_key` (VARCHAR) - S3 key for this version
|
||||
- `size` (BIGINT) - Version size
|
||||
- `comment` (TEXT) - Version comment
|
||||
- `created_by` (VARCHAR) - User who created version
|
||||
- `created_at` (TIMESTAMP)
|
||||
|
||||
**shares** - Sharing links
|
||||
|
||||
- `id` (UUID) - Primary key
|
||||
- `user_id` (VARCHAR) - Owner reference
|
||||
- `file_id` (UUID) - Shared file (nullable)
|
||||
- `folder_id` (UUID) - Shared folder (nullable)
|
||||
- `share_type` (VARCHAR) - 'file' or 'folder'
|
||||
- `share_token` (VARCHAR) - Unique public token
|
||||
- `access_level` (VARCHAR) - 'view', 'edit', 'download'
|
||||
- `password` (VARCHAR) - Optional password hash
|
||||
- `max_downloads` (INTEGER) - Download limit
|
||||
- `download_count` (INTEGER) - Current downloads
|
||||
- `expires_at` (TIMESTAMP) - Expiration date
|
||||
- `created_at` (TIMESTAMP)
|
||||
|
||||
**tags** - User tags
|
||||
|
||||
- `id` (UUID) - Primary key
|
||||
- `user_id` (VARCHAR) - User reference
|
||||
- `name` (VARCHAR) - Tag name
|
||||
- `color` (VARCHAR) - Tag color
|
||||
- `created_at` (TIMESTAMP)
|
||||
|
||||
**file_tags** - Many-to-many relation
|
||||
|
||||
- `file_id` (UUID) - File reference
|
||||
- `tag_id` (UUID) - Tag reference
|
||||
|
||||
### Environment Variables
|
||||
|
||||
#### Backend (.env)
|
||||
|
||||
```
|
||||
NODE_ENV=development
|
||||
PORT=3016
|
||||
DATABASE_URL=postgresql://manacore:devpassword@localhost:5432/storage
|
||||
MANA_CORE_AUTH_URL=http://localhost:3001
|
||||
CORS_ORIGINS=http://localhost:5173,http://localhost:5185,http://localhost:8081
|
||||
S3_ENDPOINT=http://localhost:9000
|
||||
S3_REGION=us-east-1
|
||||
S3_ACCESS_KEY=minioadmin
|
||||
S3_SECRET_KEY=minioadmin
|
||||
STORAGE_S3_PUBLIC_URL=http://localhost:9000/storage-storage
|
||||
MAX_FILE_SIZE=104857600
|
||||
MAX_FILES_PER_UPLOAD=10
|
||||
```
|
||||
|
||||
#### Web (.env)
|
||||
|
||||
```
|
||||
PUBLIC_BACKEND_URL=http://localhost:3016
|
||||
PUBLIC_MANA_CORE_AUTH_URL=http://localhost:3001
|
||||
```
|
||||
|
||||
## Shared Packages
|
||||
|
||||
### @storage/shared
|
||||
|
||||
- Types: `File`, `Folder`, `FileVersion`, `Share`, `Tag`
|
||||
- Utils: File type detection, size formatting, path utilities
|
||||
|
||||
## Code Style Guidelines
|
||||
|
||||
- **TypeScript**: Strict typing with interfaces
|
||||
- **Web**: Svelte 5 runes mode (`$state`, `$derived`, `$effect`)
|
||||
- **Styling**: Tailwind CSS
|
||||
- **Formatting**: Prettier with project config
|
||||
|
||||
## Important Notes
|
||||
|
||||
1. **Authentication**: Uses Mana Core Auth (JWT in Authorization header)
|
||||
2. **Database**: PostgreSQL with Drizzle ORM
|
||||
3. **Port**: Backend runs on port 3016, Web on port 5185 by default
|
||||
4. **Storage**: Uses MinIO/S3 for file storage via @manacore/shared-storage
|
||||
5. **Bucket**: `storage-storage` bucket for all files
|
||||
6. **Soft Delete**: Files/folders are soft-deleted first (trash), then permanently deleted
|
||||
7. **Versioning**: Files support version history, each version stored separately in S3
|
||||
8. **Sharing**: Public links with optional password, download limits, and expiration
|
||||
12
apps/storage/apps/backend/drizzle.config.ts
Normal file
12
apps/storage/apps/backend/drizzle.config.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import { defineConfig } from 'drizzle-kit';
|
||||
|
||||
export default defineConfig({
|
||||
dialect: 'postgresql',
|
||||
schema: './src/db/schema/index.ts',
|
||||
out: './src/db/migrations',
|
||||
dbCredentials: {
|
||||
url: process.env.STORAGE_DATABASE_URL || process.env.DATABASE_URL || 'postgresql://manacore:devpassword@localhost:5432/storage',
|
||||
},
|
||||
verbose: true,
|
||||
strict: true,
|
||||
});
|
||||
10
apps/storage/apps/backend/nest-cli.json
Normal file
10
apps/storage/apps/backend/nest-cli.json
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": false,
|
||||
"assets": [],
|
||||
"watchAssets": false
|
||||
}
|
||||
}
|
||||
55
apps/storage/apps/backend/package.json
Normal file
55
apps/storage/apps/backend/package.json
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
{
|
||||
"name": "@storage/backend",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "nest build",
|
||||
"start": "nest start",
|
||||
"dev": "nest start --watch",
|
||||
"start:dev": "nest start --watch",
|
||||
"start:debug": "nest start --debug --watch",
|
||||
"start:prod": "node dist/main",
|
||||
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
||||
"type-check": "tsc --noEmit",
|
||||
"migration:generate": "drizzle-kit generate",
|
||||
"migration:run": "tsx src/db/migrate.ts",
|
||||
"db:push": "drizzle-kit push",
|
||||
"db:studio": "drizzle-kit studio",
|
||||
"db:seed": "tsx src/db/seed.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@manacore/shared-nestjs-auth": "workspace:*",
|
||||
"@manacore/shared-storage": "workspace:*",
|
||||
"@nestjs/common": "^10.4.15",
|
||||
"@nestjs/config": "^3.3.0",
|
||||
"@nestjs/core": "^10.4.15",
|
||||
"@nestjs/platform-express": "^10.4.15",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.1",
|
||||
"dotenv": "^16.4.7",
|
||||
"drizzle-kit": "^0.30.2",
|
||||
"drizzle-orm": "^0.38.3",
|
||||
"postgres": "^3.4.5",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^10.4.9",
|
||||
"@nestjs/schematics": "^10.2.3",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/multer": "^1.4.12",
|
||||
"@types/node": "^22.10.2",
|
||||
"@typescript-eslint/eslint-plugin": "^8.18.1",
|
||||
"@typescript-eslint/parser": "^8.18.1",
|
||||
"eslint": "^9.17.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-plugin-prettier": "^5.2.1",
|
||||
"prettier": "^3.4.2",
|
||||
"source-map-support": "^0.5.21",
|
||||
"ts-loader": "^9.5.1",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "^5.7.2"
|
||||
}
|
||||
}
|
||||
29
apps/storage/apps/backend/src/app.module.ts
Normal file
29
apps/storage/apps/backend/src/app.module.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { DatabaseModule } from './db/database.module';
|
||||
import { HealthModule } from './health/health.module';
|
||||
import { FileModule } from './file/file.module';
|
||||
import { FolderModule } from './folder/folder.module';
|
||||
import { ShareModule } from './share/share.module';
|
||||
import { TagModule } from './tag/tag.module';
|
||||
import { TrashModule } from './trash/trash.module';
|
||||
import { SearchModule } from './search/search.module';
|
||||
import { StorageModule } from './storage/storage.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
}),
|
||||
DatabaseModule,
|
||||
HealthModule,
|
||||
StorageModule,
|
||||
FileModule,
|
||||
FolderModule,
|
||||
ShareModule,
|
||||
TagModule,
|
||||
TrashModule,
|
||||
SearchModule,
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
38
apps/storage/apps/backend/src/db/connection.ts
Normal file
38
apps/storage/apps/backend/src/db/connection.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import { drizzle } from 'drizzle-orm/postgres-js';
|
||||
import * as schema from './schema';
|
||||
|
||||
// Use require for postgres to avoid ESM/CommonJS interop issues
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const postgres = require('postgres');
|
||||
|
||||
let connection: ReturnType<typeof postgres> | null = null;
|
||||
let db: ReturnType<typeof drizzle> | null = null;
|
||||
|
||||
export function getConnection(databaseUrl: string) {
|
||||
if (!connection) {
|
||||
connection = postgres(databaseUrl, {
|
||||
max: 10,
|
||||
idle_timeout: 20,
|
||||
connect_timeout: 10,
|
||||
});
|
||||
}
|
||||
return connection;
|
||||
}
|
||||
|
||||
export function getDb(databaseUrl: string) {
|
||||
if (!db) {
|
||||
const conn = getConnection(databaseUrl);
|
||||
db = drizzle(conn, { schema });
|
||||
}
|
||||
return db;
|
||||
}
|
||||
|
||||
export async function closeConnection() {
|
||||
if (connection) {
|
||||
await connection.end();
|
||||
connection = null;
|
||||
db = null;
|
||||
}
|
||||
}
|
||||
|
||||
export type Database = ReturnType<typeof getDb>;
|
||||
28
apps/storage/apps/backend/src/db/database.module.ts
Normal file
28
apps/storage/apps/backend/src/db/database.module.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import { Module, Global, OnModuleDestroy } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { getDb, closeConnection, type Database } from './connection';
|
||||
|
||||
export const DATABASE_CONNECTION = 'DATABASE_CONNECTION';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [
|
||||
{
|
||||
provide: DATABASE_CONNECTION,
|
||||
useFactory: (configService: ConfigService): Database => {
|
||||
const databaseUrl = configService.get<string>('DATABASE_URL');
|
||||
if (!databaseUrl) {
|
||||
throw new Error('DATABASE_URL environment variable is not set');
|
||||
}
|
||||
return getDb(databaseUrl);
|
||||
},
|
||||
inject: [ConfigService],
|
||||
},
|
||||
],
|
||||
exports: [DATABASE_CONNECTION],
|
||||
})
|
||||
export class DatabaseModule implements OnModuleDestroy {
|
||||
async onModuleDestroy() {
|
||||
await closeConnection();
|
||||
}
|
||||
}
|
||||
33
apps/storage/apps/backend/src/db/schema/file-tags.schema.ts
Normal file
33
apps/storage/apps/backend/src/db/schema/file-tags.schema.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import { pgTable, uuid, primaryKey } from 'drizzle-orm/pg-core';
|
||||
import { relations } from 'drizzle-orm';
|
||||
import { files } from './files.schema';
|
||||
import { tags } from './tags.schema';
|
||||
|
||||
export const fileTags = pgTable(
|
||||
'file_tags',
|
||||
{
|
||||
fileId: uuid('file_id')
|
||||
.references(() => files.id, { onDelete: 'cascade' })
|
||||
.notNull(),
|
||||
tagId: uuid('tag_id')
|
||||
.references(() => tags.id, { onDelete: 'cascade' })
|
||||
.notNull(),
|
||||
},
|
||||
(table) => ({
|
||||
pk: primaryKey({ columns: [table.fileId, table.tagId] }),
|
||||
})
|
||||
);
|
||||
|
||||
export const fileTagsRelations = relations(fileTags, ({ one }) => ({
|
||||
file: one(files, {
|
||||
fields: [fileTags.fileId],
|
||||
references: [files.id],
|
||||
}),
|
||||
tag: one(tags, {
|
||||
fields: [fileTags.tagId],
|
||||
references: [tags.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export type FileTag = typeof fileTags.$inferSelect;
|
||||
export type NewFileTag = typeof fileTags.$inferInsert;
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
import { pgTable, uuid, varchar, timestamp, bigint, integer, text } from 'drizzle-orm/pg-core';
|
||||
import { relations } from 'drizzle-orm';
|
||||
import { files } from './files.schema';
|
||||
|
||||
export const fileVersions = pgTable('file_versions', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
fileId: uuid('file_id')
|
||||
.references(() => files.id, { onDelete: 'cascade' })
|
||||
.notNull(),
|
||||
|
||||
// Version info
|
||||
versionNumber: integer('version_number').notNull(),
|
||||
|
||||
// Storage info for this version
|
||||
storagePath: varchar('storage_path', { length: 1000 }).notNull(),
|
||||
storageKey: varchar('storage_key', { length: 500 }).notNull(),
|
||||
size: bigint('size', { mode: 'number' }).notNull(),
|
||||
checksum: varchar('checksum', { length: 64 }),
|
||||
|
||||
// Metadata
|
||||
comment: text('comment'), // Optional version comment
|
||||
createdBy: varchar('created_by', { length: 255 }).notNull(),
|
||||
|
||||
// Timestamps
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
});
|
||||
|
||||
export const fileVersionsRelations = relations(fileVersions, ({ one }) => ({
|
||||
file: one(files, {
|
||||
fields: [fileVersions.fileId],
|
||||
references: [files.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export type FileVersion = typeof fileVersions.$inferSelect;
|
||||
export type NewFileVersion = typeof fileVersions.$inferInsert;
|
||||
47
apps/storage/apps/backend/src/db/schema/files.schema.ts
Normal file
47
apps/storage/apps/backend/src/db/schema/files.schema.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import { pgTable, uuid, varchar, text, timestamp, bigint, boolean, integer } from 'drizzle-orm/pg-core';
|
||||
import { relations } from 'drizzle-orm';
|
||||
import { folders } from './folders.schema';
|
||||
|
||||
export const files = pgTable('files', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
userId: varchar('user_id', { length: 255 }).notNull(),
|
||||
|
||||
// File metadata
|
||||
name: varchar('name', { length: 500 }).notNull(),
|
||||
originalName: varchar('original_name', { length: 500 }).notNull(),
|
||||
mimeType: varchar('mime_type', { length: 255 }).notNull(),
|
||||
size: bigint('size', { mode: 'number' }).notNull(),
|
||||
|
||||
// Storage location
|
||||
storagePath: varchar('storage_path', { length: 1000 }).notNull(),
|
||||
storageKey: varchar('storage_key', { length: 500 }).notNull().unique(),
|
||||
|
||||
// Hierarchy
|
||||
parentFolderId: uuid('parent_folder_id').references(() => folders.id, { onDelete: 'set null' }),
|
||||
|
||||
// File properties
|
||||
checksum: varchar('checksum', { length: 64 }), // SHA-256
|
||||
thumbnailPath: varchar('thumbnail_path', { length: 500 }),
|
||||
|
||||
// Versioning
|
||||
currentVersion: integer('current_version').default(1).notNull(),
|
||||
|
||||
// Status flags
|
||||
isFavorite: boolean('is_favorite').default(false).notNull(),
|
||||
isDeleted: boolean('is_deleted').default(false).notNull(),
|
||||
deletedAt: timestamp('deleted_at', { withTimezone: true }),
|
||||
|
||||
// Timestamps
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
});
|
||||
|
||||
export const filesRelations = relations(files, ({ one }) => ({
|
||||
parentFolder: one(folders, {
|
||||
fields: [files.parentFolderId],
|
||||
references: [folders.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export type File = typeof files.$inferSelect;
|
||||
export type NewFile = typeof files.$inferInsert;
|
||||
41
apps/storage/apps/backend/src/db/schema/folders.schema.ts
Normal file
41
apps/storage/apps/backend/src/db/schema/folders.schema.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import { pgTable, uuid, varchar, timestamp, boolean, text, integer } from 'drizzle-orm/pg-core';
|
||||
import { relations } from 'drizzle-orm';
|
||||
|
||||
export const folders = pgTable('folders', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
userId: varchar('user_id', { length: 255 }).notNull(),
|
||||
|
||||
// Folder metadata
|
||||
name: varchar('name', { length: 255 }).notNull(),
|
||||
color: varchar('color', { length: 20 }),
|
||||
description: text('description'),
|
||||
|
||||
// Hierarchy (self-referencing)
|
||||
parentFolderId: uuid('parent_folder_id'),
|
||||
|
||||
// Path for efficient queries (e.g., "/root-uuid/parent-uuid/current-uuid")
|
||||
path: text('path').notNull(),
|
||||
depth: integer('depth').default(0).notNull(),
|
||||
|
||||
// Status flags
|
||||
isFavorite: boolean('is_favorite').default(false).notNull(),
|
||||
isDeleted: boolean('is_deleted').default(false).notNull(),
|
||||
deletedAt: timestamp('deleted_at', { withTimezone: true }),
|
||||
|
||||
// Timestamps
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
});
|
||||
|
||||
// Self-referencing relation
|
||||
export const foldersRelations = relations(folders, ({ one, many }) => ({
|
||||
parentFolder: one(folders, {
|
||||
fields: [folders.parentFolderId],
|
||||
references: [folders.id],
|
||||
relationName: 'folder_parent',
|
||||
}),
|
||||
childFolders: many(folders, { relationName: 'folder_parent' }),
|
||||
}));
|
||||
|
||||
export type Folder = typeof folders.$inferSelect;
|
||||
export type NewFolder = typeof folders.$inferInsert;
|
||||
17
apps/storage/apps/backend/src/db/schema/index.ts
Normal file
17
apps/storage/apps/backend/src/db/schema/index.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
// Folders (must be first due to self-reference)
|
||||
export * from './folders.schema';
|
||||
|
||||
// Files (references folders)
|
||||
export * from './files.schema';
|
||||
|
||||
// File versions (references files)
|
||||
export * from './file-versions.schema';
|
||||
|
||||
// Tags
|
||||
export * from './tags.schema';
|
||||
|
||||
// File-Tags junction (references files and tags)
|
||||
export * from './file-tags.schema';
|
||||
|
||||
// Shares (references files and folders)
|
||||
export * from './shares.schema';
|
||||
50
apps/storage/apps/backend/src/db/schema/shares.schema.ts
Normal file
50
apps/storage/apps/backend/src/db/schema/shares.schema.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import { pgTable, uuid, varchar, timestamp, boolean, integer, pgEnum } from 'drizzle-orm/pg-core';
|
||||
import { relations } from 'drizzle-orm';
|
||||
import { files } from './files.schema';
|
||||
import { folders } from './folders.schema';
|
||||
|
||||
export const shareTypeEnum = pgEnum('share_type', ['file', 'folder']);
|
||||
export const shareAccessEnum = pgEnum('share_access', ['view', 'edit', 'download']);
|
||||
|
||||
export const shares = pgTable('shares', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
userId: varchar('user_id', { length: 255 }).notNull(), // Owner
|
||||
|
||||
// Share target (one of these will be set)
|
||||
fileId: uuid('file_id').references(() => files.id, { onDelete: 'cascade' }),
|
||||
folderId: uuid('folder_id').references(() => folders.id, { onDelete: 'cascade' }),
|
||||
shareType: shareTypeEnum('share_type').notNull(),
|
||||
|
||||
// Share link
|
||||
shareToken: varchar('share_token', { length: 64 }).notNull().unique(),
|
||||
accessLevel: shareAccessEnum('access_level').default('view').notNull(),
|
||||
|
||||
// Security
|
||||
password: varchar('password', { length: 255 }), // Hashed password
|
||||
maxDownloads: integer('max_downloads'),
|
||||
downloadCount: integer('download_count').default(0).notNull(),
|
||||
|
||||
// Expiration
|
||||
expiresAt: timestamp('expires_at', { withTimezone: true }),
|
||||
|
||||
// Status
|
||||
isActive: boolean('is_active').default(true).notNull(),
|
||||
|
||||
// Timestamps
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
lastAccessedAt: timestamp('last_accessed_at', { withTimezone: true }),
|
||||
});
|
||||
|
||||
export const sharesRelations = relations(shares, ({ one }) => ({
|
||||
file: one(files, {
|
||||
fields: [shares.fileId],
|
||||
references: [files.id],
|
||||
}),
|
||||
folder: one(folders, {
|
||||
fields: [shares.folderId],
|
||||
references: [folders.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export type Share = typeof shares.$inferSelect;
|
||||
export type NewShare = typeof shares.$inferInsert;
|
||||
20
apps/storage/apps/backend/src/db/schema/tags.schema.ts
Normal file
20
apps/storage/apps/backend/src/db/schema/tags.schema.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import { pgTable, uuid, varchar, timestamp } from 'drizzle-orm/pg-core';
|
||||
import { relations } from 'drizzle-orm';
|
||||
import { fileTags } from './file-tags.schema';
|
||||
|
||||
export const tags = pgTable('tags', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
userId: varchar('user_id', { length: 255 }).notNull(),
|
||||
|
||||
name: varchar('name', { length: 50 }).notNull(),
|
||||
color: varchar('color', { length: 20 }),
|
||||
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
});
|
||||
|
||||
export const tagsRelations = relations(tags, ({ many }) => ({
|
||||
fileTags: many(fileTags),
|
||||
}));
|
||||
|
||||
export type Tag = typeof tags.$inferSelect;
|
||||
export type NewTag = typeof tags.$inferInsert;
|
||||
20
apps/storage/apps/backend/src/file/dto/create-file.dto.ts
Normal file
20
apps/storage/apps/backend/src/file/dto/create-file.dto.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import { IsString, IsOptional, IsUUID, MaxLength } from 'class-validator';
|
||||
|
||||
export class CreateFileDto {
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
parentFolderId?: string;
|
||||
}
|
||||
|
||||
export class UpdateFileDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(500)
|
||||
name?: string;
|
||||
}
|
||||
|
||||
export class MoveFileDto {
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
parentFolderId?: string | null;
|
||||
}
|
||||
123
apps/storage/apps/backend/src/file/file.controller.ts
Normal file
123
apps/storage/apps/backend/src/file/file.controller.ts
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Patch,
|
||||
Delete,
|
||||
Body,
|
||||
Param,
|
||||
Query,
|
||||
UseGuards,
|
||||
UseInterceptors,
|
||||
UploadedFile,
|
||||
UploadedFiles,
|
||||
Res,
|
||||
BadRequestException,
|
||||
} from '@nestjs/common';
|
||||
import { FileInterceptor, FilesInterceptor } from '@nestjs/platform-express';
|
||||
import { Response } from 'express';
|
||||
import { JwtAuthGuard, CurrentUser, type CurrentUserData } from '@manacore/shared-nestjs-auth';
|
||||
import { FileService } from './file.service';
|
||||
import { CreateFileDto, UpdateFileDto, MoveFileDto } from './dto/create-file.dto';
|
||||
|
||||
const MAX_FILE_SIZE = 100 * 1024 * 1024; // 100MB
|
||||
const MAX_FILES = 10;
|
||||
|
||||
@Controller('api/v1/files')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class FileController {
|
||||
constructor(private readonly fileService: FileService) {}
|
||||
|
||||
@Get()
|
||||
async findAll(@CurrentUser() user: CurrentUserData, @Query('parentFolderId') parentFolderId?: string) {
|
||||
return this.fileService.findAll(user.userId, parentFolderId);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
async findOne(@CurrentUser() user: CurrentUserData, @Param('id') id: string) {
|
||||
return this.fileService.findOne(user.userId, id);
|
||||
}
|
||||
|
||||
@Post('upload')
|
||||
@UseInterceptors(
|
||||
FileInterceptor('file', {
|
||||
limits: { fileSize: MAX_FILE_SIZE },
|
||||
})
|
||||
)
|
||||
async upload(
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
@UploadedFile() file: Express.Multer.File,
|
||||
@Body() dto: CreateFileDto
|
||||
) {
|
||||
if (!file) {
|
||||
throw new BadRequestException('No file provided');
|
||||
}
|
||||
return this.fileService.upload(user.userId, file, dto);
|
||||
}
|
||||
|
||||
@Post('upload-multiple')
|
||||
@UseInterceptors(
|
||||
FilesInterceptor('files', MAX_FILES, {
|
||||
limits: { fileSize: MAX_FILE_SIZE },
|
||||
})
|
||||
)
|
||||
async uploadMultiple(
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
@UploadedFiles() uploadedFiles: Express.Multer.File[],
|
||||
@Body() dto: CreateFileDto
|
||||
) {
|
||||
if (!uploadedFiles || uploadedFiles.length === 0) {
|
||||
throw new BadRequestException('No files provided');
|
||||
}
|
||||
|
||||
const results = await Promise.all(
|
||||
uploadedFiles.map((file) => this.fileService.upload(user.userId, file, dto))
|
||||
);
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
@Get(':id/download')
|
||||
async download(
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
@Param('id') id: string,
|
||||
@Query('url') urlOnly: string,
|
||||
@Res() res: Response
|
||||
) {
|
||||
if (urlOnly === 'true') {
|
||||
const url = await this.fileService.getDownloadUrl(user.userId, id);
|
||||
return res.json({ url });
|
||||
}
|
||||
|
||||
const { buffer, file } = await this.fileService.download(user.userId, id);
|
||||
|
||||
res.set({
|
||||
'Content-Type': file.mimeType,
|
||||
'Content-Disposition': `attachment; filename="${encodeURIComponent(file.name)}"`,
|
||||
'Content-Length': buffer.length,
|
||||
});
|
||||
|
||||
res.send(buffer);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
async update(@CurrentUser() user: CurrentUserData, @Param('id') id: string, @Body() dto: UpdateFileDto) {
|
||||
return this.fileService.update(user.userId, id, dto);
|
||||
}
|
||||
|
||||
@Patch(':id/move')
|
||||
async move(@CurrentUser() user: CurrentUserData, @Param('id') id: string, @Body() dto: MoveFileDto) {
|
||||
return this.fileService.move(user.userId, id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
async delete(@CurrentUser() user: CurrentUserData, @Param('id') id: string) {
|
||||
await this.fileService.delete(user.userId, id);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@Post(':id/favorite')
|
||||
async toggleFavorite(@CurrentUser() user: CurrentUserData, @Param('id') id: string) {
|
||||
return this.fileService.toggleFavorite(user.userId, id);
|
||||
}
|
||||
}
|
||||
18
apps/storage/apps/backend/src/file/file.module.ts
Normal file
18
apps/storage/apps/backend/src/file/file.module.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { MulterModule } from '@nestjs/platform-express';
|
||||
import { FileController } from './file.controller';
|
||||
import { FileService } from './file.service';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
MulterModule.register({
|
||||
limits: {
|
||||
fileSize: 100 * 1024 * 1024, // 100MB
|
||||
},
|
||||
}),
|
||||
],
|
||||
controllers: [FileController],
|
||||
providers: [FileService],
|
||||
exports: [FileService],
|
||||
})
|
||||
export class FileModule {}
|
||||
163
apps/storage/apps/backend/src/file/file.service.ts
Normal file
163
apps/storage/apps/backend/src/file/file.service.ts
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
import { Injectable, Inject, NotFoundException, BadRequestException } from '@nestjs/common';
|
||||
import { eq, and, isNull } from 'drizzle-orm';
|
||||
import { DATABASE_CONNECTION } from '../db/database.module';
|
||||
import { type Database } from '../db/connection';
|
||||
import { files, fileVersions, type File, type NewFile, type NewFileVersion } from '../db/schema';
|
||||
import { StorageService } from '../storage/storage.service';
|
||||
import { CreateFileDto, UpdateFileDto, MoveFileDto } from './dto/create-file.dto';
|
||||
|
||||
@Injectable()
|
||||
export class FileService {
|
||||
constructor(
|
||||
@Inject(DATABASE_CONNECTION) private db: Database,
|
||||
private storageService: StorageService
|
||||
) {}
|
||||
|
||||
async findAll(userId: string, parentFolderId?: string): Promise<File[]> {
|
||||
if (parentFolderId) {
|
||||
return this.db
|
||||
.select()
|
||||
.from(files)
|
||||
.where(
|
||||
and(eq(files.userId, userId), eq(files.parentFolderId, parentFolderId), eq(files.isDeleted, false))
|
||||
);
|
||||
}
|
||||
|
||||
// Root files (no parent folder)
|
||||
return this.db
|
||||
.select()
|
||||
.from(files)
|
||||
.where(and(eq(files.userId, userId), isNull(files.parentFolderId), eq(files.isDeleted, false)));
|
||||
}
|
||||
|
||||
async findOne(userId: string, id: string): Promise<File> {
|
||||
const result = await this.db
|
||||
.select()
|
||||
.from(files)
|
||||
.where(and(eq(files.id, id), eq(files.userId, userId), eq(files.isDeleted, false)));
|
||||
|
||||
if (result.length === 0) {
|
||||
throw new NotFoundException('File not found');
|
||||
}
|
||||
|
||||
return result[0];
|
||||
}
|
||||
|
||||
async upload(
|
||||
userId: string,
|
||||
file: Express.Multer.File,
|
||||
dto: CreateFileDto
|
||||
): Promise<File> {
|
||||
if (!file) {
|
||||
throw new BadRequestException('No file provided');
|
||||
}
|
||||
|
||||
// Upload to S3
|
||||
const uploadResult = await this.storageService.uploadFile(
|
||||
userId,
|
||||
file.buffer,
|
||||
file.originalname,
|
||||
file.mimetype
|
||||
);
|
||||
|
||||
// Create file record
|
||||
const newFile: NewFile = {
|
||||
userId,
|
||||
name: file.originalname,
|
||||
originalName: file.originalname,
|
||||
mimeType: file.mimetype,
|
||||
size: file.size,
|
||||
storagePath: uploadResult.storagePath,
|
||||
storageKey: uploadResult.storageKey,
|
||||
parentFolderId: dto.parentFolderId || null,
|
||||
currentVersion: 1,
|
||||
};
|
||||
|
||||
const result = await this.db.insert(files).values(newFile).returning();
|
||||
const createdFile = result[0];
|
||||
|
||||
// Create initial version record
|
||||
const version: NewFileVersion = {
|
||||
fileId: createdFile.id,
|
||||
versionNumber: 1,
|
||||
storagePath: uploadResult.storagePath,
|
||||
storageKey: uploadResult.storageKey,
|
||||
size: file.size,
|
||||
createdBy: userId,
|
||||
};
|
||||
|
||||
await this.db.insert(fileVersions).values(version);
|
||||
|
||||
return createdFile;
|
||||
}
|
||||
|
||||
async update(userId: string, id: string, dto: UpdateFileDto): Promise<File> {
|
||||
await this.findOne(userId, id);
|
||||
|
||||
const result = await this.db
|
||||
.update(files)
|
||||
.set({
|
||||
...dto,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(and(eq(files.id, id), eq(files.userId, userId)))
|
||||
.returning();
|
||||
|
||||
return result[0];
|
||||
}
|
||||
|
||||
async move(userId: string, id: string, dto: MoveFileDto): Promise<File> {
|
||||
await this.findOne(userId, id);
|
||||
|
||||
const result = await this.db
|
||||
.update(files)
|
||||
.set({
|
||||
parentFolderId: dto.parentFolderId || null,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(and(eq(files.id, id), eq(files.userId, userId)))
|
||||
.returning();
|
||||
|
||||
return result[0];
|
||||
}
|
||||
|
||||
async delete(userId: string, id: string): Promise<void> {
|
||||
await this.findOne(userId, id);
|
||||
|
||||
// Soft delete
|
||||
await this.db
|
||||
.update(files)
|
||||
.set({
|
||||
isDeleted: true,
|
||||
deletedAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(and(eq(files.id, id), eq(files.userId, userId)));
|
||||
}
|
||||
|
||||
async toggleFavorite(userId: string, id: string): Promise<File> {
|
||||
const file = await this.findOne(userId, id);
|
||||
|
||||
const result = await this.db
|
||||
.update(files)
|
||||
.set({
|
||||
isFavorite: !file.isFavorite,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(and(eq(files.id, id), eq(files.userId, userId)))
|
||||
.returning();
|
||||
|
||||
return result[0];
|
||||
}
|
||||
|
||||
async download(userId: string, id: string): Promise<{ buffer: Buffer; file: File }> {
|
||||
const file = await this.findOne(userId, id);
|
||||
const buffer = await this.storageService.downloadFile(file.storageKey);
|
||||
return { buffer, file };
|
||||
}
|
||||
|
||||
async getDownloadUrl(userId: string, id: string): Promise<string> {
|
||||
const file = await this.findOne(userId, id);
|
||||
return this.storageService.getDownloadUrl(file.storageKey);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
import { IsString, IsOptional, IsUUID, MaxLength } from 'class-validator';
|
||||
|
||||
export class CreateFolderDto {
|
||||
@IsString()
|
||||
@MaxLength(255)
|
||||
name: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
parentFolderId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(20)
|
||||
color?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
description?: string;
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
import { IsString, IsOptional, IsUUID, MaxLength } from 'class-validator';
|
||||
|
||||
export class UpdateFolderDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(255)
|
||||
name?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(20)
|
||||
color?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export class MoveFolderDto {
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
parentFolderId?: string | null;
|
||||
}
|
||||
47
apps/storage/apps/backend/src/folder/folder.controller.ts
Normal file
47
apps/storage/apps/backend/src/folder/folder.controller.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import { Controller, Get, Post, Patch, Delete, Body, Param, Query, UseGuards } from '@nestjs/common';
|
||||
import { JwtAuthGuard, CurrentUser, type CurrentUserData } from '@manacore/shared-nestjs-auth';
|
||||
import { FolderService } from './folder.service';
|
||||
import { CreateFolderDto } from './dto/create-folder.dto';
|
||||
import { UpdateFolderDto, MoveFolderDto } from './dto/update-folder.dto';
|
||||
|
||||
@Controller('api/v1/folders')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class FolderController {
|
||||
constructor(private readonly folderService: FolderService) {}
|
||||
|
||||
@Get()
|
||||
async findAll(@CurrentUser() user: CurrentUserData, @Query('parentFolderId') parentFolderId?: string) {
|
||||
return this.folderService.findAll(user.userId, parentFolderId);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
async findOne(@CurrentUser() user: CurrentUserData, @Param('id') id: string) {
|
||||
return this.folderService.findOne(user.userId, id);
|
||||
}
|
||||
|
||||
@Post()
|
||||
async create(@CurrentUser() user: CurrentUserData, @Body() dto: CreateFolderDto) {
|
||||
return this.folderService.create(user.userId, dto);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
async update(@CurrentUser() user: CurrentUserData, @Param('id') id: string, @Body() dto: UpdateFolderDto) {
|
||||
return this.folderService.update(user.userId, id, dto);
|
||||
}
|
||||
|
||||
@Patch(':id/move')
|
||||
async move(@CurrentUser() user: CurrentUserData, @Param('id') id: string, @Body() dto: MoveFolderDto) {
|
||||
return this.folderService.move(user.userId, id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
async delete(@CurrentUser() user: CurrentUserData, @Param('id') id: string) {
|
||||
await this.folderService.delete(user.userId, id);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@Post(':id/favorite')
|
||||
async toggleFavorite(@CurrentUser() user: CurrentUserData, @Param('id') id: string) {
|
||||
return this.folderService.toggleFavorite(user.userId, id);
|
||||
}
|
||||
}
|
||||
10
apps/storage/apps/backend/src/folder/folder.module.ts
Normal file
10
apps/storage/apps/backend/src/folder/folder.module.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { FolderController } from './folder.controller';
|
||||
import { FolderService } from './folder.service';
|
||||
|
||||
@Module({
|
||||
controllers: [FolderController],
|
||||
providers: [FolderService],
|
||||
exports: [FolderService],
|
||||
})
|
||||
export class FolderModule {}
|
||||
140
apps/storage/apps/backend/src/folder/folder.service.ts
Normal file
140
apps/storage/apps/backend/src/folder/folder.service.ts
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
import { Injectable, Inject, NotFoundException } from '@nestjs/common';
|
||||
import { eq, and, isNull } from 'drizzle-orm';
|
||||
import { DATABASE_CONNECTION } from '../db/database.module';
|
||||
import { type Database } from '../db/connection';
|
||||
import { folders, type Folder, type NewFolder } from '../db/schema';
|
||||
import { CreateFolderDto } from './dto/create-folder.dto';
|
||||
import { UpdateFolderDto, MoveFolderDto } from './dto/update-folder.dto';
|
||||
|
||||
@Injectable()
|
||||
export class FolderService {
|
||||
constructor(@Inject(DATABASE_CONNECTION) private db: Database) {}
|
||||
|
||||
async findAll(userId: string, parentFolderId?: string): Promise<Folder[]> {
|
||||
if (parentFolderId) {
|
||||
return this.db
|
||||
.select()
|
||||
.from(folders)
|
||||
.where(
|
||||
and(
|
||||
eq(folders.userId, userId),
|
||||
eq(folders.parentFolderId, parentFolderId),
|
||||
eq(folders.isDeleted, false)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Root folders (no parent)
|
||||
return this.db
|
||||
.select()
|
||||
.from(folders)
|
||||
.where(and(eq(folders.userId, userId), isNull(folders.parentFolderId), eq(folders.isDeleted, false)));
|
||||
}
|
||||
|
||||
async findOne(userId: string, id: string): Promise<Folder> {
|
||||
const result = await this.db
|
||||
.select()
|
||||
.from(folders)
|
||||
.where(and(eq(folders.id, id), eq(folders.userId, userId), eq(folders.isDeleted, false)));
|
||||
|
||||
if (result.length === 0) {
|
||||
throw new NotFoundException('Folder not found');
|
||||
}
|
||||
|
||||
return result[0];
|
||||
}
|
||||
|
||||
async create(userId: string, dto: CreateFolderDto): Promise<Folder> {
|
||||
let path = `/${dto.name}`;
|
||||
let depth = 0;
|
||||
|
||||
if (dto.parentFolderId) {
|
||||
const parent = await this.findOne(userId, dto.parentFolderId);
|
||||
path = `${parent.path}/${dto.name}`;
|
||||
depth = parent.depth + 1;
|
||||
}
|
||||
|
||||
const newFolder: NewFolder = {
|
||||
userId,
|
||||
name: dto.name,
|
||||
parentFolderId: dto.parentFolderId || null,
|
||||
color: dto.color,
|
||||
description: dto.description,
|
||||
path,
|
||||
depth,
|
||||
};
|
||||
|
||||
const result = await this.db.insert(folders).values(newFolder).returning();
|
||||
return result[0];
|
||||
}
|
||||
|
||||
async update(userId: string, id: string, dto: UpdateFolderDto): Promise<Folder> {
|
||||
const folder = await this.findOne(userId, id);
|
||||
|
||||
const result = await this.db
|
||||
.update(folders)
|
||||
.set({
|
||||
...dto,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(and(eq(folders.id, id), eq(folders.userId, userId)))
|
||||
.returning();
|
||||
|
||||
return result[0];
|
||||
}
|
||||
|
||||
async move(userId: string, id: string, dto: MoveFolderDto): Promise<Folder> {
|
||||
const folder = await this.findOne(userId, id);
|
||||
|
||||
let newPath = `/${folder.name}`;
|
||||
let newDepth = 0;
|
||||
|
||||
if (dto.parentFolderId) {
|
||||
const parent = await this.findOne(userId, dto.parentFolderId);
|
||||
newPath = `${parent.path}/${folder.name}`;
|
||||
newDepth = parent.depth + 1;
|
||||
}
|
||||
|
||||
const result = await this.db
|
||||
.update(folders)
|
||||
.set({
|
||||
parentFolderId: dto.parentFolderId || null,
|
||||
path: newPath,
|
||||
depth: newDepth,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(and(eq(folders.id, id), eq(folders.userId, userId)))
|
||||
.returning();
|
||||
|
||||
return result[0];
|
||||
}
|
||||
|
||||
async delete(userId: string, id: string): Promise<void> {
|
||||
await this.findOne(userId, id);
|
||||
|
||||
// Soft delete
|
||||
await this.db
|
||||
.update(folders)
|
||||
.set({
|
||||
isDeleted: true,
|
||||
deletedAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(and(eq(folders.id, id), eq(folders.userId, userId)));
|
||||
}
|
||||
|
||||
async toggleFavorite(userId: string, id: string): Promise<Folder> {
|
||||
const folder = await this.findOne(userId, id);
|
||||
|
||||
const result = await this.db
|
||||
.update(folders)
|
||||
.set({
|
||||
isFavorite: !folder.isFavorite,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(and(eq(folders.id, id), eq(folders.userId, userId)))
|
||||
.returning();
|
||||
|
||||
return result[0];
|
||||
}
|
||||
}
|
||||
13
apps/storage/apps/backend/src/health/health.controller.ts
Normal file
13
apps/storage/apps/backend/src/health/health.controller.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import { Controller, Get } from '@nestjs/common';
|
||||
|
||||
@Controller('api/v1/health')
|
||||
export class HealthController {
|
||||
@Get()
|
||||
check() {
|
||||
return {
|
||||
status: 'ok',
|
||||
service: 'storage-backend',
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
7
apps/storage/apps/backend/src/health/health.module.ts
Normal file
7
apps/storage/apps/backend/src/health/health.module.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { HealthController } from './health.controller';
|
||||
|
||||
@Module({
|
||||
controllers: [HealthController],
|
||||
})
|
||||
export class HealthModule {}
|
||||
32
apps/storage/apps/backend/src/main.ts
Normal file
32
apps/storage/apps/backend/src/main.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import { NestFactory } from '@nestjs/core';
|
||||
import { ValidationPipe } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { AppModule } from './app.module';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
|
||||
const configService = app.get(ConfigService);
|
||||
const port = configService.get<number>('PORT') || 3016;
|
||||
const corsOrigins = configService.get<string>('CORS_ORIGINS') || '';
|
||||
|
||||
// Enable CORS
|
||||
app.enableCors({
|
||||
origin: corsOrigins.split(',').filter(Boolean),
|
||||
credentials: true,
|
||||
});
|
||||
|
||||
// Enable validation
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({
|
||||
whitelist: true,
|
||||
transform: true,
|
||||
forbidNonWhitelisted: true,
|
||||
})
|
||||
);
|
||||
|
||||
await app.listen(port);
|
||||
console.log(`Storage backend running on http://localhost:${port}`);
|
||||
}
|
||||
|
||||
bootstrap();
|
||||
22
apps/storage/apps/backend/src/search/search.controller.ts
Normal file
22
apps/storage/apps/backend/src/search/search.controller.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
|
||||
import { JwtAuthGuard, CurrentUser, type CurrentUserData } from '@manacore/shared-nestjs-auth';
|
||||
import { SearchService } from './search.service';
|
||||
|
||||
@Controller('api/v1')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class SearchController {
|
||||
constructor(private readonly searchService: SearchService) {}
|
||||
|
||||
@Get('search')
|
||||
async search(@CurrentUser() user: CurrentUserData, @Query('q') query: string) {
|
||||
if (!query || query.trim().length === 0) {
|
||||
return { files: [], folders: [] };
|
||||
}
|
||||
return this.searchService.search(user.userId, query.trim());
|
||||
}
|
||||
|
||||
@Get('favorites')
|
||||
async getFavorites(@CurrentUser() user: CurrentUserData) {
|
||||
return this.searchService.getFavorites(user.userId);
|
||||
}
|
||||
}
|
||||
10
apps/storage/apps/backend/src/search/search.module.ts
Normal file
10
apps/storage/apps/backend/src/search/search.module.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { SearchController } from './search.controller';
|
||||
import { SearchService } from './search.service';
|
||||
|
||||
@Module({
|
||||
controllers: [SearchController],
|
||||
providers: [SearchService],
|
||||
exports: [SearchService],
|
||||
})
|
||||
export class SearchModule {}
|
||||
54
apps/storage/apps/backend/src/search/search.service.ts
Normal file
54
apps/storage/apps/backend/src/search/search.service.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import { Injectable, Inject } from '@nestjs/common';
|
||||
import { eq, and, ilike, or } from 'drizzle-orm';
|
||||
import { DATABASE_CONNECTION } from '../db/database.module';
|
||||
import { type Database } from '../db/connection';
|
||||
import { files, folders, type File, type Folder } from '../db/schema';
|
||||
|
||||
@Injectable()
|
||||
export class SearchService {
|
||||
constructor(@Inject(DATABASE_CONNECTION) private db: Database) {}
|
||||
|
||||
async search(userId: string, query: string): Promise<{ files: File[]; folders: Folder[] }> {
|
||||
const searchPattern = `%${query}%`;
|
||||
|
||||
const matchingFiles = await this.db
|
||||
.select()
|
||||
.from(files)
|
||||
.where(
|
||||
and(
|
||||
eq(files.userId, userId),
|
||||
eq(files.isDeleted, false),
|
||||
or(ilike(files.name, searchPattern), ilike(files.originalName, searchPattern))
|
||||
)
|
||||
)
|
||||
.limit(50);
|
||||
|
||||
const matchingFolders = await this.db
|
||||
.select()
|
||||
.from(folders)
|
||||
.where(
|
||||
and(
|
||||
eq(folders.userId, userId),
|
||||
eq(folders.isDeleted, false),
|
||||
or(ilike(folders.name, searchPattern), ilike(folders.description, searchPattern))
|
||||
)
|
||||
)
|
||||
.limit(50);
|
||||
|
||||
return { files: matchingFiles, folders: matchingFolders };
|
||||
}
|
||||
|
||||
async getFavorites(userId: string): Promise<{ files: File[]; folders: Folder[] }> {
|
||||
const favoriteFiles = await this.db
|
||||
.select()
|
||||
.from(files)
|
||||
.where(and(eq(files.userId, userId), eq(files.isDeleted, false), eq(files.isFavorite, true)));
|
||||
|
||||
const favoriteFolders = await this.db
|
||||
.select()
|
||||
.from(folders)
|
||||
.where(and(eq(folders.userId, userId), eq(folders.isDeleted, false), eq(folders.isFavorite, true)));
|
||||
|
||||
return { files: favoriteFiles, folders: favoriteFolders };
|
||||
}
|
||||
}
|
||||
54
apps/storage/apps/backend/src/share/share.controller.ts
Normal file
54
apps/storage/apps/backend/src/share/share.controller.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import { Controller, Get, Post, Delete, Body, Param, UseGuards } from '@nestjs/common';
|
||||
import { JwtAuthGuard, CurrentUser, type CurrentUserData } from '@manacore/shared-nestjs-auth';
|
||||
import { ShareService } from './share.service';
|
||||
|
||||
@Controller('api/v1/shares')
|
||||
export class ShareController {
|
||||
constructor(private readonly shareService: ShareService) {}
|
||||
|
||||
@Get()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
async findAll(@CurrentUser() user: CurrentUserData) {
|
||||
return this.shareService.findAll(user.userId);
|
||||
}
|
||||
|
||||
@Get(':token')
|
||||
async findByToken(@Param('token') token: string) {
|
||||
return this.shareService.findByToken(token);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
async create(
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
@Body()
|
||||
dto: {
|
||||
fileId?: string;
|
||||
folderId?: string;
|
||||
accessLevel?: 'view' | 'edit' | 'download';
|
||||
password?: string;
|
||||
maxDownloads?: number;
|
||||
expiresInDays?: number;
|
||||
}
|
||||
) {
|
||||
const expiresAt = dto.expiresInDays
|
||||
? new Date(Date.now() + dto.expiresInDays * 24 * 60 * 60 * 1000)
|
||||
: undefined;
|
||||
|
||||
return this.shareService.create(user.userId, {
|
||||
fileId: dto.fileId,
|
||||
folderId: dto.folderId,
|
||||
accessLevel: dto.accessLevel,
|
||||
password: dto.password,
|
||||
maxDownloads: dto.maxDownloads,
|
||||
expiresAt,
|
||||
});
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
async delete(@CurrentUser() user: CurrentUserData, @Param('id') id: string) {
|
||||
await this.shareService.delete(user.userId, id);
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
10
apps/storage/apps/backend/src/share/share.module.ts
Normal file
10
apps/storage/apps/backend/src/share/share.module.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { ShareController } from './share.controller';
|
||||
import { ShareService } from './share.service';
|
||||
|
||||
@Module({
|
||||
controllers: [ShareController],
|
||||
providers: [ShareService],
|
||||
exports: [ShareService],
|
||||
})
|
||||
export class ShareModule {}
|
||||
93
apps/storage/apps/backend/src/share/share.service.ts
Normal file
93
apps/storage/apps/backend/src/share/share.service.ts
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
import { Injectable, Inject, NotFoundException } from '@nestjs/common';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import { randomBytes } from 'crypto';
|
||||
import { DATABASE_CONNECTION } from '../db/database.module';
|
||||
import { type Database } from '../db/connection';
|
||||
import { shares, type Share, type NewShare } from '../db/schema';
|
||||
|
||||
@Injectable()
|
||||
export class ShareService {
|
||||
constructor(@Inject(DATABASE_CONNECTION) private db: Database) {}
|
||||
|
||||
async findAll(userId: string): Promise<Share[]> {
|
||||
return this.db
|
||||
.select()
|
||||
.from(shares)
|
||||
.where(and(eq(shares.userId, userId), eq(shares.isActive, true)));
|
||||
}
|
||||
|
||||
async findByToken(token: string): Promise<Share> {
|
||||
const result = await this.db
|
||||
.select()
|
||||
.from(shares)
|
||||
.where(and(eq(shares.shareToken, token), eq(shares.isActive, true)));
|
||||
|
||||
if (result.length === 0) {
|
||||
throw new NotFoundException('Share not found');
|
||||
}
|
||||
|
||||
const share = result[0];
|
||||
|
||||
// Check expiration
|
||||
if (share.expiresAt && new Date() > share.expiresAt) {
|
||||
throw new NotFoundException('Share link has expired');
|
||||
}
|
||||
|
||||
// Check download limit
|
||||
if (share.maxDownloads && share.downloadCount >= share.maxDownloads) {
|
||||
throw new NotFoundException('Share link download limit reached');
|
||||
}
|
||||
|
||||
return share;
|
||||
}
|
||||
|
||||
async create(
|
||||
userId: string,
|
||||
data: {
|
||||
fileId?: string;
|
||||
folderId?: string;
|
||||
accessLevel?: 'view' | 'edit' | 'download';
|
||||
password?: string;
|
||||
maxDownloads?: number;
|
||||
expiresAt?: Date;
|
||||
}
|
||||
): Promise<Share> {
|
||||
const shareToken = randomBytes(32).toString('hex');
|
||||
const shareType = data.fileId ? 'file' : 'folder';
|
||||
|
||||
const newShare: NewShare = {
|
||||
userId,
|
||||
fileId: data.fileId,
|
||||
folderId: data.folderId,
|
||||
shareType,
|
||||
shareToken,
|
||||
accessLevel: data.accessLevel || 'view',
|
||||
password: data.password, // Should be hashed in production
|
||||
maxDownloads: data.maxDownloads,
|
||||
expiresAt: data.expiresAt,
|
||||
};
|
||||
|
||||
const result = await this.db.insert(shares).values(newShare).returning();
|
||||
return result[0];
|
||||
}
|
||||
|
||||
async delete(userId: string, id: string): Promise<void> {
|
||||
await this.db
|
||||
.update(shares)
|
||||
.set({ isActive: false })
|
||||
.where(and(eq(shares.id, id), eq(shares.userId, userId)));
|
||||
}
|
||||
|
||||
async incrementDownloadCount(id: string): Promise<void> {
|
||||
const share = await this.db.select().from(shares).where(eq(shares.id, id));
|
||||
if (share.length > 0) {
|
||||
await this.db
|
||||
.update(shares)
|
||||
.set({
|
||||
downloadCount: share[0].downloadCount + 1,
|
||||
lastAccessedAt: new Date(),
|
||||
})
|
||||
.where(eq(shares.id, id));
|
||||
}
|
||||
}
|
||||
}
|
||||
9
apps/storage/apps/backend/src/storage/storage.module.ts
Normal file
9
apps/storage/apps/backend/src/storage/storage.module.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import { Module, Global } from '@nestjs/common';
|
||||
import { StorageService } from './storage.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [StorageService],
|
||||
exports: [StorageService],
|
||||
})
|
||||
export class StorageModule {}
|
||||
70
apps/storage/apps/backend/src/storage/storage.service.ts
Normal file
70
apps/storage/apps/backend/src/storage/storage.service.ts
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import {
|
||||
createStorageStorage,
|
||||
StorageClient,
|
||||
generateUserFileKey,
|
||||
getContentType,
|
||||
validateFileSize,
|
||||
} from '@manacore/shared-storage';
|
||||
|
||||
@Injectable()
|
||||
export class StorageService {
|
||||
private storage: StorageClient;
|
||||
private maxFileSize: number;
|
||||
|
||||
constructor(private configService: ConfigService) {
|
||||
const publicUrl = this.configService.get<string>('STORAGE_S3_PUBLIC_URL');
|
||||
this.storage = createStorageStorage(publicUrl);
|
||||
this.maxFileSize = this.configService.get<number>('STORAGE_MAX_FILE_SIZE') || 100 * 1024 * 1024; // 100MB default
|
||||
}
|
||||
|
||||
async uploadFile(
|
||||
userId: string,
|
||||
buffer: Buffer,
|
||||
originalName: string,
|
||||
mimeType: string,
|
||||
subfolder?: string
|
||||
) {
|
||||
if (!validateFileSize(buffer.length, this.maxFileSize / (1024 * 1024))) {
|
||||
throw new Error(`File size exceeds maximum allowed size of ${this.maxFileSize / (1024 * 1024)}MB`);
|
||||
}
|
||||
|
||||
const storageKey = generateUserFileKey(userId, originalName, subfolder);
|
||||
|
||||
const result = await this.storage.upload(storageKey, buffer, {
|
||||
contentType: mimeType || getContentType(originalName),
|
||||
});
|
||||
|
||||
return {
|
||||
storageKey,
|
||||
storagePath: result.key,
|
||||
publicUrl: result.url,
|
||||
etag: result.etag,
|
||||
};
|
||||
}
|
||||
|
||||
async downloadFile(storageKey: string): Promise<Buffer> {
|
||||
return this.storage.download(storageKey);
|
||||
}
|
||||
|
||||
async deleteFile(storageKey: string): Promise<void> {
|
||||
await this.storage.delete(storageKey);
|
||||
}
|
||||
|
||||
async fileExists(storageKey: string): Promise<boolean> {
|
||||
return this.storage.exists(storageKey);
|
||||
}
|
||||
|
||||
async getDownloadUrl(storageKey: string, expiresIn = 3600): Promise<string> {
|
||||
return this.storage.getDownloadUrl(storageKey, { expiresIn });
|
||||
}
|
||||
|
||||
async getUploadUrl(storageKey: string, expiresIn = 3600): Promise<string> {
|
||||
return this.storage.getUploadUrl(storageKey, { expiresIn });
|
||||
}
|
||||
|
||||
getPublicUrl(storageKey: string): string | undefined {
|
||||
return this.storage.getPublicUrl(storageKey);
|
||||
}
|
||||
}
|
||||
34
apps/storage/apps/backend/src/tag/tag.controller.ts
Normal file
34
apps/storage/apps/backend/src/tag/tag.controller.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import { Controller, Get, Post, Patch, Delete, Body, Param, UseGuards } from '@nestjs/common';
|
||||
import { JwtAuthGuard, CurrentUser, type CurrentUserData } from '@manacore/shared-nestjs-auth';
|
||||
import { TagService } from './tag.service';
|
||||
|
||||
@Controller('api/v1/tags')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class TagController {
|
||||
constructor(private readonly tagService: TagService) {}
|
||||
|
||||
@Get()
|
||||
async findAll(@CurrentUser() user: CurrentUserData) {
|
||||
return this.tagService.findAll(user.userId);
|
||||
}
|
||||
|
||||
@Post()
|
||||
async create(@CurrentUser() user: CurrentUserData, @Body() dto: { name: string; color?: string }) {
|
||||
return this.tagService.create(user.userId, dto.name, dto.color);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
async update(
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
@Param('id') id: string,
|
||||
@Body() dto: { name?: string; color?: string }
|
||||
) {
|
||||
return this.tagService.update(user.userId, id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
async delete(@CurrentUser() user: CurrentUserData, @Param('id') id: string) {
|
||||
await this.tagService.delete(user.userId, id);
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
10
apps/storage/apps/backend/src/tag/tag.module.ts
Normal file
10
apps/storage/apps/backend/src/tag/tag.module.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { TagController } from './tag.controller';
|
||||
import { TagService } from './tag.service';
|
||||
|
||||
@Module({
|
||||
controllers: [TagController],
|
||||
providers: [TagService],
|
||||
exports: [TagService],
|
||||
})
|
||||
export class TagModule {}
|
||||
61
apps/storage/apps/backend/src/tag/tag.service.ts
Normal file
61
apps/storage/apps/backend/src/tag/tag.service.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
import { Injectable, Inject, NotFoundException } from '@nestjs/common';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import { DATABASE_CONNECTION } from '../db/database.module';
|
||||
import { type Database } from '../db/connection';
|
||||
import { tags, fileTags, 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));
|
||||
}
|
||||
|
||||
async create(userId: string, name: string, color?: string): Promise<Tag> {
|
||||
const newTag: NewTag = {
|
||||
userId,
|
||||
name,
|
||||
color,
|
||||
};
|
||||
|
||||
const result = await this.db.insert(tags).values(newTag).returning();
|
||||
return result[0];
|
||||
}
|
||||
|
||||
async update(userId: string, id: string, data: { name?: string; color?: string }): Promise<Tag> {
|
||||
const result = await this.db
|
||||
.update(tags)
|
||||
.set(data)
|
||||
.where(and(eq(tags.id, id), eq(tags.userId, userId)))
|
||||
.returning();
|
||||
|
||||
if (result.length === 0) {
|
||||
throw new NotFoundException('Tag not found');
|
||||
}
|
||||
|
||||
return result[0];
|
||||
}
|
||||
|
||||
async delete(userId: string, id: string): Promise<void> {
|
||||
await this.db.delete(tags).where(and(eq(tags.id, id), eq(tags.userId, userId)));
|
||||
}
|
||||
|
||||
async addTagToFile(fileId: string, tagId: string): Promise<void> {
|
||||
await this.db.insert(fileTags).values({ fileId, tagId }).onConflictDoNothing();
|
||||
}
|
||||
|
||||
async removeTagFromFile(fileId: string, tagId: string): Promise<void> {
|
||||
await this.db.delete(fileTags).where(and(eq(fileTags.fileId, fileId), eq(fileTags.tagId, tagId)));
|
||||
}
|
||||
|
||||
async getFileTags(fileId: string): Promise<Tag[]> {
|
||||
const result = await this.db
|
||||
.select({ tag: tags })
|
||||
.from(fileTags)
|
||||
.innerJoin(tags, eq(fileTags.tagId, tags.id))
|
||||
.where(eq(fileTags.fileId, fileId));
|
||||
|
||||
return result.map((r) => r.tag);
|
||||
}
|
||||
}
|
||||
46
apps/storage/apps/backend/src/trash/trash.controller.ts
Normal file
46
apps/storage/apps/backend/src/trash/trash.controller.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import { Controller, Get, Post, Delete, Param, Query, UseGuards } from '@nestjs/common';
|
||||
import { JwtAuthGuard, CurrentUser, type CurrentUserData } from '@manacore/shared-nestjs-auth';
|
||||
import { TrashService } from './trash.service';
|
||||
|
||||
@Controller('api/v1/trash')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class TrashController {
|
||||
constructor(private readonly trashService: TrashService) {}
|
||||
|
||||
@Get()
|
||||
async findAll(@CurrentUser() user: CurrentUserData) {
|
||||
return this.trashService.findAll(user.userId);
|
||||
}
|
||||
|
||||
@Post(':id/restore')
|
||||
async restore(
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
@Param('id') id: string,
|
||||
@Query('type') type: 'file' | 'folder'
|
||||
) {
|
||||
if (type === 'folder') {
|
||||
return this.trashService.restoreFolder(user.userId, id);
|
||||
}
|
||||
return this.trashService.restoreFile(user.userId, id);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
async permanentlyDelete(
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
@Param('id') id: string,
|
||||
@Query('type') type: 'file' | 'folder'
|
||||
) {
|
||||
if (type === 'folder') {
|
||||
await this.trashService.permanentlyDeleteFolder(user.userId, id);
|
||||
} else {
|
||||
await this.trashService.permanentlyDeleteFile(user.userId, id);
|
||||
}
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@Delete()
|
||||
async emptyTrash(@CurrentUser() user: CurrentUserData) {
|
||||
await this.trashService.emptyTrash(user.userId);
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
10
apps/storage/apps/backend/src/trash/trash.module.ts
Normal file
10
apps/storage/apps/backend/src/trash/trash.module.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { TrashController } from './trash.controller';
|
||||
import { TrashService } from './trash.service';
|
||||
|
||||
@Module({
|
||||
controllers: [TrashController],
|
||||
providers: [TrashService],
|
||||
exports: [TrashService],
|
||||
})
|
||||
export class TrashModule {}
|
||||
104
apps/storage/apps/backend/src/trash/trash.service.ts
Normal file
104
apps/storage/apps/backend/src/trash/trash.service.ts
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
import { Injectable, Inject, NotFoundException } from '@nestjs/common';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import { DATABASE_CONNECTION } from '../db/database.module';
|
||||
import { type Database } from '../db/connection';
|
||||
import { files, folders, type File, type Folder } from '../db/schema';
|
||||
import { StorageService } from '../storage/storage.service';
|
||||
|
||||
@Injectable()
|
||||
export class TrashService {
|
||||
constructor(
|
||||
@Inject(DATABASE_CONNECTION) private db: Database,
|
||||
private storageService: StorageService
|
||||
) {}
|
||||
|
||||
async findAll(userId: string): Promise<{ files: File[]; folders: Folder[] }> {
|
||||
const trashedFiles = await this.db
|
||||
.select()
|
||||
.from(files)
|
||||
.where(and(eq(files.userId, userId), eq(files.isDeleted, true)));
|
||||
|
||||
const trashedFolders = await this.db
|
||||
.select()
|
||||
.from(folders)
|
||||
.where(and(eq(folders.userId, userId), eq(folders.isDeleted, true)));
|
||||
|
||||
return { files: trashedFiles, folders: trashedFolders };
|
||||
}
|
||||
|
||||
async restoreFile(userId: string, id: string): Promise<File> {
|
||||
const result = await this.db
|
||||
.update(files)
|
||||
.set({
|
||||
isDeleted: false,
|
||||
deletedAt: null,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(and(eq(files.id, id), eq(files.userId, userId)))
|
||||
.returning();
|
||||
|
||||
if (result.length === 0) {
|
||||
throw new NotFoundException('File not found in trash');
|
||||
}
|
||||
|
||||
return result[0];
|
||||
}
|
||||
|
||||
async restoreFolder(userId: string, id: string): Promise<Folder> {
|
||||
const result = await this.db
|
||||
.update(folders)
|
||||
.set({
|
||||
isDeleted: false,
|
||||
deletedAt: null,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(and(eq(folders.id, id), eq(folders.userId, userId)))
|
||||
.returning();
|
||||
|
||||
if (result.length === 0) {
|
||||
throw new NotFoundException('Folder not found in trash');
|
||||
}
|
||||
|
||||
return result[0];
|
||||
}
|
||||
|
||||
async permanentlyDeleteFile(userId: string, id: string): Promise<void> {
|
||||
const file = await this.db
|
||||
.select()
|
||||
.from(files)
|
||||
.where(and(eq(files.id, id), eq(files.userId, userId), eq(files.isDeleted, true)));
|
||||
|
||||
if (file.length === 0) {
|
||||
throw new NotFoundException('File not found in trash');
|
||||
}
|
||||
|
||||
// Delete from S3
|
||||
await this.storageService.deleteFile(file[0].storageKey);
|
||||
|
||||
// Delete from database
|
||||
await this.db.delete(files).where(eq(files.id, id));
|
||||
}
|
||||
|
||||
async permanentlyDeleteFolder(userId: string, id: string): Promise<void> {
|
||||
await this.db
|
||||
.delete(folders)
|
||||
.where(and(eq(folders.id, id), eq(folders.userId, userId), eq(folders.isDeleted, true)));
|
||||
}
|
||||
|
||||
async emptyTrash(userId: string): Promise<void> {
|
||||
// Get all trashed files to delete from S3
|
||||
const trashedFiles = await this.db
|
||||
.select()
|
||||
.from(files)
|
||||
.where(and(eq(files.userId, userId), eq(files.isDeleted, true)));
|
||||
|
||||
// Delete from S3
|
||||
for (const file of trashedFiles) {
|
||||
await this.storageService.deleteFile(file.storageKey);
|
||||
}
|
||||
|
||||
// Delete from database
|
||||
await this.db.delete(files).where(and(eq(files.userId, userId), eq(files.isDeleted, true)));
|
||||
await this.db.delete(folders).where(and(eq(folders.userId, userId), eq(folders.isDeleted, true)));
|
||||
}
|
||||
}
|
||||
25
apps/storage/apps/backend/tsconfig.json
Normal file
25
apps/storage/apps/backend/tsconfig.json
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"moduleResolution": "node",
|
||||
"declaration": true,
|
||||
"removeComments": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"experimentalDecorators": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"target": "ES2021",
|
||||
"sourceMap": true,
|
||||
"outDir": "./dist",
|
||||
"baseUrl": "./",
|
||||
"rootDir": "./src",
|
||||
"incremental": true,
|
||||
"skipLibCheck": true,
|
||||
"strictNullChecks": true,
|
||||
"noImplicitAny": true,
|
||||
"strictBindCallApply": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
47
apps/storage/apps/web/package.json
Normal file
47
apps/storage/apps/web/package.json
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
{
|
||||
"name": "@storage/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": {
|
||||
"@sveltejs/adapter-auto": "^3.0.0",
|
||||
"@sveltejs/kit": "^2.0.0",
|
||||
"@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.0.0",
|
||||
"svelte-check": "^4.0.0",
|
||||
"tailwindcss": "^4.1.7",
|
||||
"tslib": "^2.4.1",
|
||||
"typescript": "^5.0.0",
|
||||
"vite": "^6.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@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-i18n": "workspace:*",
|
||||
"@manacore/shared-icons": "workspace:*",
|
||||
"@manacore/shared-profile-ui": "workspace:*",
|
||||
"@manacore/shared-subscription-ui": "workspace:*",
|
||||
"@manacore/shared-tailwind": "workspace:*",
|
||||
"@manacore/shared-theme": "workspace:*",
|
||||
"@manacore/shared-theme-ui": "workspace:*",
|
||||
"@manacore/shared-ui": "workspace:*",
|
||||
"svelte-i18n": "^4.0.1",
|
||||
"lucide-svelte": "^0.469.0"
|
||||
},
|
||||
"type": "module"
|
||||
}
|
||||
9
apps/storage/apps/web/src/app.css
Normal file
9
apps/storage/apps/web/src/app.css
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
@import "tailwindcss";
|
||||
@import "@manacore/shared-tailwind/themes.css";
|
||||
|
||||
/* Scan shared packages for Tailwind classes */
|
||||
@source "../../../../packages/shared-ui/src";
|
||||
@source "../../../../packages/shared-auth-ui/src";
|
||||
@source "../../../../packages/shared-branding/src";
|
||||
@source "../../../../packages/shared-theme-ui/src";
|
||||
@source "../../../../packages/shared-feedback-ui/src";
|
||||
13
apps/storage/apps/web/src/app.html
Normal file
13
apps/storage/apps/web/src/app.html
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Storage - Cloud Drive</title>
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
281
apps/storage/apps/web/src/lib/api/client.ts
Normal file
281
apps/storage/apps/web/src/lib/api/client.ts
Normal file
|
|
@ -0,0 +1,281 @@
|
|||
/**
|
||||
* API Client for Storage Backend
|
||||
*/
|
||||
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
|
||||
const API_BASE_URL = 'http://localhost:3016/api/v1';
|
||||
|
||||
export interface ApiResponse<T> {
|
||||
data?: T;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
async function getHeaders(): Promise<HeadersInit> {
|
||||
const token = await authStore.getAccessToken();
|
||||
const headers: HeadersInit = {
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
async function request<T>(
|
||||
endpoint: string,
|
||||
options: RequestInit = {}
|
||||
): Promise<ApiResponse<T>> {
|
||||
try {
|
||||
const headers = await getHeaders();
|
||||
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
|
||||
...options,
|
||||
headers: {
|
||||
...headers,
|
||||
...(options.headers || {}),
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
return { error: errorData.message || `HTTP ${response.status}` };
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return { data };
|
||||
} catch (error) {
|
||||
return { error: error instanceof Error ? error.message : 'Unknown error' };
|
||||
}
|
||||
}
|
||||
|
||||
// File Types
|
||||
export interface StorageFile {
|
||||
id: string;
|
||||
userId: string;
|
||||
name: string;
|
||||
originalName: string;
|
||||
mimeType: string;
|
||||
size: number;
|
||||
storagePath: string;
|
||||
storageKey: string;
|
||||
parentFolderId: string | null;
|
||||
currentVersion: number;
|
||||
isFavorite: boolean;
|
||||
isDeleted: boolean;
|
||||
deletedAt: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface StorageFolder {
|
||||
id: string;
|
||||
userId: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
color: string | null;
|
||||
parentFolderId: string | null;
|
||||
path: string;
|
||||
depth: number;
|
||||
isFavorite: boolean;
|
||||
isDeleted: boolean;
|
||||
deletedAt: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface Share {
|
||||
id: string;
|
||||
userId: string;
|
||||
fileId: string | null;
|
||||
folderId: string | null;
|
||||
shareType: 'file' | 'folder';
|
||||
shareToken: string;
|
||||
accessLevel: 'view' | 'edit' | 'download';
|
||||
password: string | null;
|
||||
maxDownloads: number | null;
|
||||
downloadCount: number;
|
||||
expiresAt: string | null;
|
||||
isActive: boolean;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface Tag {
|
||||
id: string;
|
||||
userId: string;
|
||||
name: string;
|
||||
color: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
// Files API
|
||||
export const filesApi = {
|
||||
list: (folderId?: string) =>
|
||||
request<StorageFile[]>(`/files${folderId ? `?folderId=${folderId}` : ''}`),
|
||||
|
||||
get: (id: string) => request<StorageFile>(`/files/${id}`),
|
||||
|
||||
upload: async (file: File, folderId?: string): Promise<ApiResponse<StorageFile>> => {
|
||||
const token = await authStore.getAccessToken();
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
if (folderId) {
|
||||
formData.append('parentFolderId', folderId);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/files/upload`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
return { error: errorData.message || `HTTP ${response.status}` };
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return { data };
|
||||
} catch (error) {
|
||||
return { error: error instanceof Error ? error.message : 'Unknown error' };
|
||||
}
|
||||
},
|
||||
|
||||
download: async (id: string): Promise<Blob | null> => {
|
||||
const token = await authStore.getAccessToken();
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/files/${id}/download`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
if (!response.ok) return null;
|
||||
return await response.blob();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
rename: (id: string, name: string) =>
|
||||
request<StorageFile>(`/files/${id}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ name }),
|
||||
}),
|
||||
|
||||
move: (id: string, parentFolderId: string | null) =>
|
||||
request<StorageFile>(`/files/${id}/move`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ parentFolderId }),
|
||||
}),
|
||||
|
||||
delete: (id: string) =>
|
||||
request<{ success: boolean }>(`/files/${id}`, { method: 'DELETE' }),
|
||||
|
||||
toggleFavorite: (id: string) =>
|
||||
request<StorageFile>(`/files/${id}/favorite`, { method: 'POST' }),
|
||||
};
|
||||
|
||||
// Folders API
|
||||
export const foldersApi = {
|
||||
list: (parentId?: string) =>
|
||||
request<StorageFolder[]>(`/folders${parentId ? `?parentId=${parentId}` : ''}`),
|
||||
|
||||
get: (id: string) =>
|
||||
request<{ folder: StorageFolder; files: StorageFile[]; subfolders: StorageFolder[] }>(
|
||||
`/folders/${id}`
|
||||
),
|
||||
|
||||
create: (name: string, parentFolderId?: string, color?: string) =>
|
||||
request<StorageFolder>('/folders', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ name, parentFolderId, color }),
|
||||
}),
|
||||
|
||||
rename: (id: string, name: string) =>
|
||||
request<StorageFolder>(`/folders/${id}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ name }),
|
||||
}),
|
||||
|
||||
move: (id: string, parentFolderId: string | null) =>
|
||||
request<StorageFolder>(`/folders/${id}/move`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ parentFolderId }),
|
||||
}),
|
||||
|
||||
delete: (id: string) =>
|
||||
request<{ success: boolean }>(`/folders/${id}`, { method: 'DELETE' }),
|
||||
|
||||
toggleFavorite: (id: string) =>
|
||||
request<StorageFolder>(`/folders/${id}/favorite`, { method: 'POST' }),
|
||||
};
|
||||
|
||||
// Shares API
|
||||
export const sharesApi = {
|
||||
list: () => request<Share[]>('/shares'),
|
||||
|
||||
get: (token: string) =>
|
||||
request<{ share: Share; file?: StorageFile; folder?: StorageFolder }>(`/shares/${token}`),
|
||||
|
||||
create: (data: {
|
||||
fileId?: string;
|
||||
folderId?: string;
|
||||
accessLevel?: 'view' | 'edit' | 'download';
|
||||
password?: string;
|
||||
maxDownloads?: number;
|
||||
expiresAt?: string;
|
||||
}) =>
|
||||
request<Share>('/shares', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
|
||||
delete: (id: string) =>
|
||||
request<{ success: boolean }>(`/shares/${id}`, { method: 'DELETE' }),
|
||||
};
|
||||
|
||||
// Tags API
|
||||
export const tagsApi = {
|
||||
list: () => request<Tag[]>('/tags'),
|
||||
|
||||
create: (name: string, color?: string) =>
|
||||
request<Tag>('/tags', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ name, color }),
|
||||
}),
|
||||
|
||||
update: (id: string, data: { name?: string; color?: string }) =>
|
||||
request<Tag>(`/tags/${id}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
|
||||
delete: (id: string) =>
|
||||
request<{ success: boolean }>(`/tags/${id}`, { method: 'DELETE' }),
|
||||
};
|
||||
|
||||
// Trash API
|
||||
export const trashApi = {
|
||||
list: () => request<{ files: StorageFile[]; folders: StorageFolder[] }>('/trash'),
|
||||
|
||||
restore: (id: string, type: 'file' | 'folder') =>
|
||||
request<StorageFile | StorageFolder>(`/trash/${id}/restore?type=${type}`, {
|
||||
method: 'POST',
|
||||
}),
|
||||
|
||||
permanentDelete: (id: string, type: 'file' | 'folder') =>
|
||||
request<{ success: boolean }>(`/trash/${id}?type=${type}`, { method: 'DELETE' }),
|
||||
|
||||
empty: () => request<{ success: boolean }>('/trash', { method: 'DELETE' }),
|
||||
};
|
||||
|
||||
// Search API
|
||||
export const searchApi = {
|
||||
search: (query: string) =>
|
||||
request<{ files: StorageFile[]; folders: StorageFolder[] }>(`/search?q=${encodeURIComponent(query)}`),
|
||||
|
||||
favorites: () =>
|
||||
request<{ files: StorageFile[]; folders: StorageFolder[] }>('/favorites'),
|
||||
};
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
<script lang="ts">
|
||||
import { locale } from 'svelte-i18n';
|
||||
import { PillDropdown } from '@manacore/shared-ui';
|
||||
import { getLanguageDropdownItems, getCurrentLanguageLabel } from '@manacore/shared-i18n';
|
||||
import { setLocale, supportedLocales } from '$lib/i18n';
|
||||
|
||||
let currentLocale = $derived($locale || 'de');
|
||||
|
||||
function handleLocaleChange(newLocale: string) {
|
||||
setLocale(newLocale as any);
|
||||
}
|
||||
|
||||
let languageItems = $derived(
|
||||
getLanguageDropdownItems(supportedLocales, currentLocale, handleLocaleChange)
|
||||
);
|
||||
let currentLabel = $derived(getCurrentLanguageLabel(currentLocale));
|
||||
</script>
|
||||
|
||||
<PillDropdown items={languageItems} label={currentLabel} direction="down" />
|
||||
187
apps/storage/apps/web/src/lib/components/ToastContainer.svelte
Normal file
187
apps/storage/apps/web/src/lib/components/ToastContainer.svelte
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
<script lang="ts">
|
||||
import { toast, type Toast } from '$lib/stores/toast';
|
||||
import { fly } from 'svelte/transition';
|
||||
|
||||
let toasts = $state<Toast[]>([]);
|
||||
|
||||
toast.subscribe((value) => {
|
||||
toasts = value;
|
||||
});
|
||||
|
||||
function handleClose(id: string) {
|
||||
toast.remove(id);
|
||||
}
|
||||
|
||||
function getIcon(type: Toast['type']) {
|
||||
switch (type) {
|
||||
case 'success':
|
||||
return `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"></path>
|
||||
<polyline points="22 4 12 14.01 9 11.01"></polyline>
|
||||
</svg>`;
|
||||
case 'error':
|
||||
return `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="10"></circle>
|
||||
<line x1="15" y1="9" x2="9" y2="15"></line>
|
||||
<line x1="9" y1="9" x2="15" y2="15"></line>
|
||||
</svg>`;
|
||||
case 'warning':
|
||||
return `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"></path>
|
||||
<line x1="12" y1="9" x2="12" y2="13"></line>
|
||||
<line x1="12" y1="17" x2="12.01" y2="17"></line>
|
||||
</svg>`;
|
||||
case 'info':
|
||||
default:
|
||||
return `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="10"></circle>
|
||||
<line x1="12" y1="16" x2="12" y2="12"></line>
|
||||
<line x1="12" y1="8" x2="12.01" y2="8"></line>
|
||||
</svg>`;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="toast-container">
|
||||
{#each toasts as toastItem (toastItem.id)}
|
||||
<div
|
||||
class="toast toast-{toastItem.type}"
|
||||
transition:fly={{ y: 20, duration: 300 }}
|
||||
role="alert"
|
||||
>
|
||||
<div class="toast-icon">
|
||||
{@html getIcon(toastItem.type)}
|
||||
</div>
|
||||
<p class="toast-message">{toastItem.message}</p>
|
||||
<button
|
||||
class="toast-close"
|
||||
onclick={() => handleClose(toastItem.id)}
|
||||
aria-label="Close notification"
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||||
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.toast-container {
|
||||
position: fixed;
|
||||
bottom: 2rem;
|
||||
right: 2rem;
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-sm);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.toast {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-md);
|
||||
padding: var(--spacing-md) var(--spacing-lg);
|
||||
background: rgb(var(--color-surface-elevated));
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-xl);
|
||||
border: 1px solid rgb(var(--color-border));
|
||||
min-width: 300px;
|
||||
max-width: 400px;
|
||||
pointer-events: auto;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.toast-icon {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.toast-success {
|
||||
border-left: 4px solid rgb(var(--color-success));
|
||||
}
|
||||
|
||||
.toast-success .toast-icon {
|
||||
color: rgb(var(--color-success));
|
||||
}
|
||||
|
||||
.toast-error {
|
||||
border-left: 4px solid rgb(var(--color-error));
|
||||
}
|
||||
|
||||
.toast-error .toast-icon {
|
||||
color: rgb(var(--color-error));
|
||||
}
|
||||
|
||||
.toast-warning {
|
||||
border-left: 4px solid rgb(var(--color-warning));
|
||||
}
|
||||
|
||||
.toast-warning .toast-icon {
|
||||
color: rgb(var(--color-warning));
|
||||
}
|
||||
|
||||
.toast-info {
|
||||
border-left: 4px solid rgb(var(--color-info));
|
||||
}
|
||||
|
||||
.toast-info .toast-icon {
|
||||
color: rgb(var(--color-info));
|
||||
}
|
||||
|
||||
.toast-message {
|
||||
flex: 1;
|
||||
margin: 0;
|
||||
font-size: 0.9375rem;
|
||||
color: rgb(var(--color-text-primary));
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.toast-close {
|
||||
flex-shrink: 0;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: var(--spacing-xs);
|
||||
cursor: pointer;
|
||||
color: rgb(var(--color-text-secondary));
|
||||
transition: all var(--transition-fast);
|
||||
border-radius: var(--radius-sm);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.toast-close:hover {
|
||||
background: rgba(var(--color-border), 0.5);
|
||||
color: rgb(var(--color-text-primary));
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.toast-container {
|
||||
bottom: 6rem;
|
||||
right: 1rem;
|
||||
left: 1rem;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.toast {
|
||||
min-width: auto;
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.toast-message {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,90 @@
|
|||
<script lang="ts">
|
||||
import { ChevronRight, Home } from 'lucide-svelte';
|
||||
|
||||
interface BreadcrumbItem {
|
||||
id: string | null;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
items: BreadcrumbItem[];
|
||||
onNavigate: (id: string | null) => void;
|
||||
}
|
||||
|
||||
let { items, onNavigate }: Props = $props();
|
||||
</script>
|
||||
|
||||
<nav class="breadcrumb" aria-label="Breadcrumb">
|
||||
<ol class="breadcrumb-list">
|
||||
<li class="breadcrumb-item">
|
||||
<button onclick={() => onNavigate(null)} class="breadcrumb-link" aria-label="Home">
|
||||
<Home size={16} />
|
||||
<span>Meine Dateien</span>
|
||||
</button>
|
||||
</li>
|
||||
{#each items as item, index (item.id)}
|
||||
<li class="breadcrumb-item">
|
||||
<ChevronRight size={16} class="separator" />
|
||||
{#if index === items.length - 1}
|
||||
<span class="breadcrumb-current">{item.name}</span>
|
||||
{:else}
|
||||
<button onclick={() => onNavigate(item.id)} class="breadcrumb-link">
|
||||
{item.name}
|
||||
</button>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
<style>
|
||||
.breadcrumb {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.breadcrumb-list {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.25rem;
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.breadcrumb-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.breadcrumb-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 0.875rem;
|
||||
color: rgb(var(--color-text-secondary));
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.breadcrumb-link:hover {
|
||||
background: rgb(var(--color-surface));
|
||||
color: rgb(var(--color-text-primary));
|
||||
}
|
||||
|
||||
.breadcrumb-current {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: rgb(var(--color-text-primary));
|
||||
padding: 0.25rem 0.5rem;
|
||||
}
|
||||
|
||||
.breadcrumb-item :global(.separator) {
|
||||
color: rgb(var(--color-text-tertiary));
|
||||
}
|
||||
</style>
|
||||
202
apps/storage/apps/web/src/lib/components/files/FileCard.svelte
Normal file
202
apps/storage/apps/web/src/lib/components/files/FileCard.svelte
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
<script lang="ts">
|
||||
import type { StorageFile } from '$lib/api/client';
|
||||
import {
|
||||
File,
|
||||
FileImage,
|
||||
FileText,
|
||||
FileVideo,
|
||||
FileAudio,
|
||||
FileArchive,
|
||||
Heart,
|
||||
MoreVertical,
|
||||
} from 'lucide-svelte';
|
||||
|
||||
interface Props {
|
||||
file: StorageFile;
|
||||
onClick?: () => void;
|
||||
onAction?: (action: string) => void;
|
||||
}
|
||||
|
||||
let { file, onClick, onAction }: Props = $props();
|
||||
|
||||
let showMenu = $state(false);
|
||||
|
||||
function getFileIcon(mimeType: string) {
|
||||
if (mimeType.startsWith('image/')) return FileImage;
|
||||
if (mimeType.startsWith('video/')) return FileVideo;
|
||||
if (mimeType.startsWith('audio/')) return FileAudio;
|
||||
if (mimeType.startsWith('text/')) return FileText;
|
||||
if (mimeType.includes('zip') || mimeType.includes('archive')) return FileArchive;
|
||||
return File;
|
||||
}
|
||||
|
||||
function formatFileSize(bytes: number): string {
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
function handleMenuClick(e: MouseEvent) {
|
||||
e.stopPropagation();
|
||||
showMenu = !showMenu;
|
||||
}
|
||||
|
||||
function handleAction(action: string) {
|
||||
showMenu = false;
|
||||
onAction?.(action);
|
||||
}
|
||||
|
||||
const Icon = getFileIcon(file.mimeType);
|
||||
</script>
|
||||
|
||||
<button class="file-card" onclick={onClick} type="button">
|
||||
<div class="file-icon">
|
||||
<Icon size={40} strokeWidth={1.5} />
|
||||
{#if file.isFavorite}
|
||||
<div class="favorite-badge">
|
||||
<Heart size={12} fill="currentColor" />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="file-info">
|
||||
<span class="file-name" title={file.name}>{file.name}</span>
|
||||
<span class="file-size">{formatFileSize(file.size)}</span>
|
||||
</div>
|
||||
<button class="menu-button" onclick={handleMenuClick} type="button">
|
||||
<MoreVertical size={16} />
|
||||
</button>
|
||||
|
||||
{#if showMenu}
|
||||
<div class="menu-dropdown">
|
||||
<button onclick={() => handleAction('download')}>Herunterladen</button>
|
||||
<button onclick={() => handleAction('rename')}>Umbenennen</button>
|
||||
<button onclick={() => handleAction('share')}>Teilen</button>
|
||||
<button onclick={() => handleAction('favorite')}>
|
||||
{file.isFavorite ? 'Favorit entfernen' : 'Als Favorit'}
|
||||
</button>
|
||||
<button onclick={() => handleAction('move')}>Verschieben</button>
|
||||
<hr />
|
||||
<button class="danger" onclick={() => handleAction('delete')}>Löschen</button>
|
||||
</div>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<style>
|
||||
.file-card {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 1rem;
|
||||
background: rgb(var(--color-surface-elevated));
|
||||
border: 1px solid rgb(var(--color-border));
|
||||
border-radius: var(--radius-lg);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.file-card:hover {
|
||||
border-color: rgb(var(--color-primary));
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.file-icon {
|
||||
position: relative;
|
||||
color: rgb(var(--color-primary));
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.favorite-badge {
|
||||
position: absolute;
|
||||
top: -4px;
|
||||
right: -4px;
|
||||
color: rgb(var(--color-warning));
|
||||
}
|
||||
|
||||
.file-info {
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.file-name {
|
||||
display: block;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: rgb(var(--color-text-primary));
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.file-size {
|
||||
display: block;
|
||||
font-size: 0.75rem;
|
||||
color: rgb(var(--color-text-secondary));
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.menu-button {
|
||||
position: absolute;
|
||||
top: 0.5rem;
|
||||
right: 0.5rem;
|
||||
padding: 0.25rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
color: rgb(var(--color-text-secondary));
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition: opacity var(--transition-fast);
|
||||
}
|
||||
|
||||
.file-card:hover .menu-button {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.menu-button:hover {
|
||||
background: rgb(var(--color-surface));
|
||||
color: rgb(var(--color-text-primary));
|
||||
}
|
||||
|
||||
.menu-dropdown {
|
||||
position: absolute;
|
||||
top: 2rem;
|
||||
right: 0.5rem;
|
||||
min-width: 150px;
|
||||
background: rgb(var(--color-surface-elevated));
|
||||
border: 1px solid rgb(var(--color-border));
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-lg);
|
||||
z-index: 100;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.menu-dropdown button {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.75rem;
|
||||
text-align: left;
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 0.875rem;
|
||||
color: rgb(var(--color-text-primary));
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.menu-dropdown button:hover {
|
||||
background: rgb(var(--color-surface));
|
||||
}
|
||||
|
||||
.menu-dropdown button.danger {
|
||||
color: rgb(var(--color-error));
|
||||
}
|
||||
|
||||
.menu-dropdown hr {
|
||||
margin: 0.25rem 0;
|
||||
border: none;
|
||||
border-top: 1px solid rgb(var(--color-border));
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
<script lang="ts">
|
||||
import type { StorageFile, StorageFolder } from '$lib/api/client';
|
||||
import FileCard from './FileCard.svelte';
|
||||
import FolderCard from './FolderCard.svelte';
|
||||
|
||||
interface Props {
|
||||
files: StorageFile[];
|
||||
folders: StorageFolder[];
|
||||
onFileClick?: (file: StorageFile) => void;
|
||||
onFolderClick?: (folder: StorageFolder) => void;
|
||||
onFileAction?: (action: string, file: StorageFile) => void;
|
||||
onFolderAction?: (action: string, folder: StorageFolder) => void;
|
||||
}
|
||||
|
||||
let { files, folders, onFileClick, onFolderClick, onFileAction, onFolderAction }: Props =
|
||||
$props();
|
||||
</script>
|
||||
|
||||
<div class="file-grid">
|
||||
{#each folders as folder (folder.id)}
|
||||
<FolderCard
|
||||
{folder}
|
||||
onClick={() => onFolderClick?.(folder)}
|
||||
onAction={(action) => onFolderAction?.(action, folder)}
|
||||
/>
|
||||
{/each}
|
||||
{#each files as file (file.id)}
|
||||
<FileCard
|
||||
{file}
|
||||
onClick={() => onFileClick?.(file)}
|
||||
onAction={(action) => onFileAction?.(action, file)}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.file-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.file-grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||
gap: 0.75rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,97 @@
|
|||
<script lang="ts">
|
||||
import type { StorageFile, StorageFolder } from '$lib/api/client';
|
||||
import FileRow from './FileRow.svelte';
|
||||
import FolderRow from './FolderRow.svelte';
|
||||
|
||||
interface Props {
|
||||
files: StorageFile[];
|
||||
folders: StorageFolder[];
|
||||
onFileClick?: (file: StorageFile) => void;
|
||||
onFolderClick?: (folder: StorageFolder) => void;
|
||||
onFileAction?: (action: string, file: StorageFile) => void;
|
||||
onFolderAction?: (action: string, folder: StorageFolder) => void;
|
||||
}
|
||||
|
||||
let { files, folders, onFileClick, onFolderClick, onFileAction, onFolderAction }: Props =
|
||||
$props();
|
||||
|
||||
function formatFileSize(bytes: number): string {
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string): string {
|
||||
return new Date(dateStr).toLocaleDateString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="file-list">
|
||||
<div class="list-header">
|
||||
<span class="col-name">Name</span>
|
||||
<span class="col-size">Größe</span>
|
||||
<span class="col-date">Geändert</span>
|
||||
<span class="col-actions"></span>
|
||||
</div>
|
||||
<div class="list-body">
|
||||
{#each folders as folder (folder.id)}
|
||||
<FolderRow
|
||||
{folder}
|
||||
{formatDate}
|
||||
onClick={() => onFolderClick?.(folder)}
|
||||
onAction={(action) => onFolderAction?.(action, folder)}
|
||||
/>
|
||||
{/each}
|
||||
{#each files as file (file.id)}
|
||||
<FileRow
|
||||
{file}
|
||||
{formatFileSize}
|
||||
{formatDate}
|
||||
onClick={() => onFileClick?.(file)}
|
||||
onAction={(action) => onFileAction?.(action, file)}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.file-list {
|
||||
border: 1px solid rgb(var(--color-border));
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.list-header {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 100px 120px 50px;
|
||||
gap: 1rem;
|
||||
padding: 0.75rem 1rem;
|
||||
background: rgb(var(--color-surface));
|
||||
border-bottom: 1px solid rgb(var(--color-border));
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
color: rgb(var(--color-text-secondary));
|
||||
}
|
||||
|
||||
.list-body {
|
||||
background: rgb(var(--color-surface-elevated));
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.list-header {
|
||||
grid-template-columns: 1fr 50px;
|
||||
}
|
||||
|
||||
.col-size,
|
||||
.col-date {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
200
apps/storage/apps/web/src/lib/components/files/FileRow.svelte
Normal file
200
apps/storage/apps/web/src/lib/components/files/FileRow.svelte
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
<script lang="ts">
|
||||
import type { StorageFile } from '$lib/api/client';
|
||||
import {
|
||||
File,
|
||||
FileImage,
|
||||
FileText,
|
||||
FileVideo,
|
||||
FileAudio,
|
||||
FileArchive,
|
||||
Heart,
|
||||
MoreVertical,
|
||||
} from 'lucide-svelte';
|
||||
|
||||
interface Props {
|
||||
file: StorageFile;
|
||||
formatFileSize: (bytes: number) => string;
|
||||
formatDate: (dateStr: string) => string;
|
||||
onClick?: () => void;
|
||||
onAction?: (action: string) => void;
|
||||
}
|
||||
|
||||
let { file, formatFileSize, formatDate, onClick, onAction }: Props = $props();
|
||||
|
||||
let showMenu = $state(false);
|
||||
|
||||
function getFileIcon(mimeType: string) {
|
||||
if (mimeType.startsWith('image/')) return FileImage;
|
||||
if (mimeType.startsWith('video/')) return FileVideo;
|
||||
if (mimeType.startsWith('audio/')) return FileAudio;
|
||||
if (mimeType.startsWith('text/')) return FileText;
|
||||
if (mimeType.includes('zip') || mimeType.includes('archive')) return FileArchive;
|
||||
return File;
|
||||
}
|
||||
|
||||
function handleMenuClick(e: MouseEvent) {
|
||||
e.stopPropagation();
|
||||
showMenu = !showMenu;
|
||||
}
|
||||
|
||||
function handleAction(action: string) {
|
||||
showMenu = false;
|
||||
onAction?.(action);
|
||||
}
|
||||
|
||||
const Icon = getFileIcon(file.mimeType);
|
||||
</script>
|
||||
|
||||
<button class="file-row" onclick={onClick} type="button">
|
||||
<span class="col-name">
|
||||
<span class="icon">
|
||||
<Icon size={20} strokeWidth={1.5} />
|
||||
</span>
|
||||
<span class="name" title={file.name}>{file.name}</span>
|
||||
{#if file.isFavorite}
|
||||
<Heart size={14} fill="currentColor" class="favorite-icon" />
|
||||
{/if}
|
||||
</span>
|
||||
<span class="col-size">{formatFileSize(file.size)}</span>
|
||||
<span class="col-date">{formatDate(file.updatedAt)}</span>
|
||||
<span class="col-actions">
|
||||
<button class="menu-button" onclick={handleMenuClick} type="button">
|
||||
<MoreVertical size={16} />
|
||||
</button>
|
||||
{#if showMenu}
|
||||
<div class="menu-dropdown">
|
||||
<button onclick={() => handleAction('download')}>Herunterladen</button>
|
||||
<button onclick={() => handleAction('rename')}>Umbenennen</button>
|
||||
<button onclick={() => handleAction('share')}>Teilen</button>
|
||||
<button onclick={() => handleAction('favorite')}>
|
||||
{file.isFavorite ? 'Favorit entfernen' : 'Als Favorit'}
|
||||
</button>
|
||||
<button onclick={() => handleAction('move')}>Verschieben</button>
|
||||
<hr />
|
||||
<button class="danger" onclick={() => handleAction('delete')}>Löschen</button>
|
||||
</div>
|
||||
{/if}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<style>
|
||||
.file-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 100px 120px 50px;
|
||||
gap: 1rem;
|
||||
padding: 0.75rem 1rem;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid rgb(var(--color-border));
|
||||
background: transparent;
|
||||
border-left: none;
|
||||
border-right: none;
|
||||
border-top: none;
|
||||
cursor: pointer;
|
||||
transition: background var(--transition-fast);
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.file-row:hover {
|
||||
background: rgb(var(--color-surface));
|
||||
}
|
||||
|
||||
.col-name {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.icon {
|
||||
flex-shrink: 0;
|
||||
color: rgb(var(--color-primary));
|
||||
}
|
||||
|
||||
.name {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font-size: 0.875rem;
|
||||
color: rgb(var(--color-text-primary));
|
||||
}
|
||||
|
||||
.col-name :global(.favorite-icon) {
|
||||
flex-shrink: 0;
|
||||
color: rgb(var(--color-warning));
|
||||
}
|
||||
|
||||
.col-size,
|
||||
.col-date {
|
||||
font-size: 0.875rem;
|
||||
color: rgb(var(--color-text-secondary));
|
||||
}
|
||||
|
||||
.col-actions {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.menu-button {
|
||||
padding: 0.25rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
color: rgb(var(--color-text-secondary));
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.menu-button:hover {
|
||||
background: rgb(var(--color-surface));
|
||||
color: rgb(var(--color-text-primary));
|
||||
}
|
||||
|
||||
.menu-dropdown {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
right: 0;
|
||||
min-width: 150px;
|
||||
background: rgb(var(--color-surface-elevated));
|
||||
border: 1px solid rgb(var(--color-border));
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-lg);
|
||||
z-index: 100;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.menu-dropdown button {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.75rem;
|
||||
text-align: left;
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 0.875rem;
|
||||
color: rgb(var(--color-text-primary));
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.menu-dropdown button:hover {
|
||||
background: rgb(var(--color-surface));
|
||||
}
|
||||
|
||||
.menu-dropdown button.danger {
|
||||
color: rgb(var(--color-error));
|
||||
}
|
||||
|
||||
.menu-dropdown hr {
|
||||
margin: 0.25rem 0;
|
||||
border: none;
|
||||
border-top: 1px solid rgb(var(--color-border));
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.file-row {
|
||||
grid-template-columns: 1fr 50px;
|
||||
}
|
||||
|
||||
.col-size,
|
||||
.col-date {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
179
apps/storage/apps/web/src/lib/components/files/FolderCard.svelte
Normal file
179
apps/storage/apps/web/src/lib/components/files/FolderCard.svelte
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
<script lang="ts">
|
||||
import type { StorageFolder } from '$lib/api/client';
|
||||
import { Folder, Heart, MoreVertical } from 'lucide-svelte';
|
||||
|
||||
interface Props {
|
||||
folder: StorageFolder;
|
||||
onClick?: () => void;
|
||||
onAction?: (action: string) => void;
|
||||
}
|
||||
|
||||
let { folder, onClick, onAction }: Props = $props();
|
||||
|
||||
let showMenu = $state(false);
|
||||
|
||||
function handleMenuClick(e: MouseEvent) {
|
||||
e.stopPropagation();
|
||||
showMenu = !showMenu;
|
||||
}
|
||||
|
||||
function handleAction(action: string) {
|
||||
showMenu = false;
|
||||
onAction?.(action);
|
||||
}
|
||||
|
||||
// Color mapping for folder colors
|
||||
const colorMap: Record<string, string> = {
|
||||
blue: '#3b82f6',
|
||||
green: '#22c55e',
|
||||
yellow: '#eab308',
|
||||
red: '#ef4444',
|
||||
purple: '#a855f7',
|
||||
pink: '#ec4899',
|
||||
orange: '#f97316',
|
||||
teal: '#14b8a6',
|
||||
};
|
||||
|
||||
let folderColor = $derived(folder.color ? colorMap[folder.color] || folder.color : undefined);
|
||||
</script>
|
||||
|
||||
<button class="folder-card" onclick={onClick} type="button">
|
||||
<div class="folder-icon" style:color={folderColor}>
|
||||
<Folder size={40} strokeWidth={1.5} fill="currentColor" />
|
||||
{#if folder.isFavorite}
|
||||
<div class="favorite-badge">
|
||||
<Heart size={12} fill="currentColor" />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="folder-info">
|
||||
<span class="folder-name" title={folder.name}>{folder.name}</span>
|
||||
</div>
|
||||
<button class="menu-button" onclick={handleMenuClick} type="button">
|
||||
<MoreVertical size={16} />
|
||||
</button>
|
||||
|
||||
{#if showMenu}
|
||||
<div class="menu-dropdown">
|
||||
<button onclick={() => handleAction('rename')}>Umbenennen</button>
|
||||
<button onclick={() => handleAction('share')}>Teilen</button>
|
||||
<button onclick={() => handleAction('favorite')}>
|
||||
{folder.isFavorite ? 'Favorit entfernen' : 'Als Favorit'}
|
||||
</button>
|
||||
<button onclick={() => handleAction('move')}>Verschieben</button>
|
||||
<hr />
|
||||
<button class="danger" onclick={() => handleAction('delete')}>Löschen</button>
|
||||
</div>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<style>
|
||||
.folder-card {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 1rem;
|
||||
background: rgb(var(--color-surface-elevated));
|
||||
border: 1px solid rgb(var(--color-border));
|
||||
border-radius: var(--radius-lg);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.folder-card:hover {
|
||||
border-color: rgb(var(--color-primary));
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.folder-icon {
|
||||
position: relative;
|
||||
color: rgb(var(--color-primary));
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.favorite-badge {
|
||||
position: absolute;
|
||||
top: -4px;
|
||||
right: -4px;
|
||||
color: rgb(var(--color-warning));
|
||||
}
|
||||
|
||||
.folder-info {
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.folder-name {
|
||||
display: block;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: rgb(var(--color-text-primary));
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.menu-button {
|
||||
position: absolute;
|
||||
top: 0.5rem;
|
||||
right: 0.5rem;
|
||||
padding: 0.25rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
color: rgb(var(--color-text-secondary));
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition: opacity var(--transition-fast);
|
||||
}
|
||||
|
||||
.folder-card:hover .menu-button {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.menu-button:hover {
|
||||
background: rgb(var(--color-surface));
|
||||
color: rgb(var(--color-text-primary));
|
||||
}
|
||||
|
||||
.menu-dropdown {
|
||||
position: absolute;
|
||||
top: 2rem;
|
||||
right: 0.5rem;
|
||||
min-width: 150px;
|
||||
background: rgb(var(--color-surface-elevated));
|
||||
border: 1px solid rgb(var(--color-border));
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-lg);
|
||||
z-index: 100;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.menu-dropdown button {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.75rem;
|
||||
text-align: left;
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 0.875rem;
|
||||
color: rgb(var(--color-text-primary));
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.menu-dropdown button:hover {
|
||||
background: rgb(var(--color-surface));
|
||||
}
|
||||
|
||||
.menu-dropdown button.danger {
|
||||
color: rgb(var(--color-error));
|
||||
}
|
||||
|
||||
.menu-dropdown hr {
|
||||
margin: 0.25rem 0;
|
||||
border: none;
|
||||
border-top: 1px solid rgb(var(--color-border));
|
||||
}
|
||||
</style>
|
||||
193
apps/storage/apps/web/src/lib/components/files/FolderRow.svelte
Normal file
193
apps/storage/apps/web/src/lib/components/files/FolderRow.svelte
Normal file
|
|
@ -0,0 +1,193 @@
|
|||
<script lang="ts">
|
||||
import type { StorageFolder } from '$lib/api/client';
|
||||
import { Folder, Heart, MoreVertical } from 'lucide-svelte';
|
||||
|
||||
interface Props {
|
||||
folder: StorageFolder;
|
||||
formatDate: (dateStr: string) => string;
|
||||
onClick?: () => void;
|
||||
onAction?: (action: string) => void;
|
||||
}
|
||||
|
||||
let { folder, formatDate, onClick, onAction }: Props = $props();
|
||||
|
||||
let showMenu = $state(false);
|
||||
|
||||
// Color mapping for folder colors
|
||||
const colorMap: Record<string, string> = {
|
||||
blue: '#3b82f6',
|
||||
green: '#22c55e',
|
||||
yellow: '#eab308',
|
||||
red: '#ef4444',
|
||||
purple: '#a855f7',
|
||||
pink: '#ec4899',
|
||||
orange: '#f97316',
|
||||
teal: '#14b8a6',
|
||||
};
|
||||
|
||||
let folderColor = $derived(folder.color ? colorMap[folder.color] || folder.color : undefined);
|
||||
|
||||
function handleMenuClick(e: MouseEvent) {
|
||||
e.stopPropagation();
|
||||
showMenu = !showMenu;
|
||||
}
|
||||
|
||||
function handleAction(action: string) {
|
||||
showMenu = false;
|
||||
onAction?.(action);
|
||||
}
|
||||
</script>
|
||||
|
||||
<button class="folder-row" onclick={onClick} type="button">
|
||||
<span class="col-name">
|
||||
<span class="icon" style:color={folderColor}>
|
||||
<Folder size={20} strokeWidth={1.5} fill="currentColor" />
|
||||
</span>
|
||||
<span class="name" title={folder.name}>{folder.name}</span>
|
||||
{#if folder.isFavorite}
|
||||
<Heart size={14} fill="currentColor" class="favorite-icon" />
|
||||
{/if}
|
||||
</span>
|
||||
<span class="col-size">—</span>
|
||||
<span class="col-date">{formatDate(folder.updatedAt)}</span>
|
||||
<span class="col-actions">
|
||||
<button class="menu-button" onclick={handleMenuClick} type="button">
|
||||
<MoreVertical size={16} />
|
||||
</button>
|
||||
{#if showMenu}
|
||||
<div class="menu-dropdown">
|
||||
<button onclick={() => handleAction('rename')}>Umbenennen</button>
|
||||
<button onclick={() => handleAction('share')}>Teilen</button>
|
||||
<button onclick={() => handleAction('favorite')}>
|
||||
{folder.isFavorite ? 'Favorit entfernen' : 'Als Favorit'}
|
||||
</button>
|
||||
<button onclick={() => handleAction('move')}>Verschieben</button>
|
||||
<hr />
|
||||
<button class="danger" onclick={() => handleAction('delete')}>Löschen</button>
|
||||
</div>
|
||||
{/if}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<style>
|
||||
.folder-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 100px 120px 50px;
|
||||
gap: 1rem;
|
||||
padding: 0.75rem 1rem;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid rgb(var(--color-border));
|
||||
background: transparent;
|
||||
border-left: none;
|
||||
border-right: none;
|
||||
border-top: none;
|
||||
cursor: pointer;
|
||||
transition: background var(--transition-fast);
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.folder-row:hover {
|
||||
background: rgb(var(--color-surface));
|
||||
}
|
||||
|
||||
.col-name {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.icon {
|
||||
flex-shrink: 0;
|
||||
color: rgb(var(--color-primary));
|
||||
}
|
||||
|
||||
.name {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font-size: 0.875rem;
|
||||
color: rgb(var(--color-text-primary));
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.col-name :global(.favorite-icon) {
|
||||
flex-shrink: 0;
|
||||
color: rgb(var(--color-warning));
|
||||
}
|
||||
|
||||
.col-size,
|
||||
.col-date {
|
||||
font-size: 0.875rem;
|
||||
color: rgb(var(--color-text-secondary));
|
||||
}
|
||||
|
||||
.col-actions {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.menu-button {
|
||||
padding: 0.25rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
color: rgb(var(--color-text-secondary));
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.menu-button:hover {
|
||||
background: rgb(var(--color-surface));
|
||||
color: rgb(var(--color-text-primary));
|
||||
}
|
||||
|
||||
.menu-dropdown {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
right: 0;
|
||||
min-width: 150px;
|
||||
background: rgb(var(--color-surface-elevated));
|
||||
border: 1px solid rgb(var(--color-border));
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-lg);
|
||||
z-index: 100;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.menu-dropdown button {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.75rem;
|
||||
text-align: left;
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 0.875rem;
|
||||
color: rgb(var(--color-text-primary));
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.menu-dropdown button:hover {
|
||||
background: rgb(var(--color-surface));
|
||||
}
|
||||
|
||||
.menu-dropdown button.danger {
|
||||
color: rgb(var(--color-error));
|
||||
}
|
||||
|
||||
.menu-dropdown hr {
|
||||
margin: 0.25rem 0;
|
||||
border: none;
|
||||
border-top: 1px solid rgb(var(--color-border));
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.folder-row {
|
||||
grid-template-columns: 1fr 50px;
|
||||
}
|
||||
|
||||
.col-size,
|
||||
.col-date {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,278 @@
|
|||
<script lang="ts">
|
||||
import { X } from 'lucide-svelte';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onCreate: (name: string, color?: string) => void;
|
||||
}
|
||||
|
||||
let { open, onClose, onCreate }: Props = $props();
|
||||
|
||||
let folderName = $state('');
|
||||
let selectedColor = $state<string | undefined>(undefined);
|
||||
let loading = $state(false);
|
||||
|
||||
const colors = [
|
||||
{ id: 'blue', value: '#3b82f6', label: 'Blau' },
|
||||
{ id: 'green', value: '#22c55e', label: 'Grün' },
|
||||
{ id: 'yellow', value: '#eab308', label: 'Gelb' },
|
||||
{ id: 'red', value: '#ef4444', label: 'Rot' },
|
||||
{ id: 'purple', value: '#a855f7', label: 'Lila' },
|
||||
{ id: 'pink', value: '#ec4899', label: 'Pink' },
|
||||
{ id: 'orange', value: '#f97316', label: 'Orange' },
|
||||
{ id: 'teal', value: '#14b8a6', label: 'Türkis' },
|
||||
];
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!folderName.trim()) return;
|
||||
|
||||
loading = true;
|
||||
try {
|
||||
await onCreate(folderName.trim(), selectedColor);
|
||||
folderName = '';
|
||||
selectedColor = undefined;
|
||||
onClose();
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if open}
|
||||
<div
|
||||
class="modal-overlay"
|
||||
onclick={onClose}
|
||||
onkeydown={handleKeydown}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="modal-title"
|
||||
tabindex="-1"
|
||||
>
|
||||
<div class="modal-content" onclick={(e) => e.stopPropagation()}>
|
||||
<div class="modal-header">
|
||||
<h2 id="modal-title">Neuer Ordner</h2>
|
||||
<button class="close-button" onclick={onClose} aria-label="Schließen">
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onsubmit={(e) => { e.preventDefault(); handleSubmit(); }}>
|
||||
<div class="form-group">
|
||||
<label for="folder-name">Ordnername</label>
|
||||
<input
|
||||
type="text"
|
||||
id="folder-name"
|
||||
bind:value={folderName}
|
||||
placeholder="Neuer Ordner"
|
||||
autofocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Ordnerfarbe (optional)</label>
|
||||
<div class="color-picker">
|
||||
<button
|
||||
type="button"
|
||||
class="color-option default"
|
||||
class:selected={!selectedColor}
|
||||
onclick={() => (selectedColor = undefined)}
|
||||
aria-label="Standard"
|
||||
>
|
||||
<span class="checkmark">✓</span>
|
||||
</button>
|
||||
{#each colors as color (color.id)}
|
||||
<button
|
||||
type="button"
|
||||
class="color-option"
|
||||
class:selected={selectedColor === color.id}
|
||||
style="background-color: {color.value}"
|
||||
onclick={() => (selectedColor = color.id)}
|
||||
aria-label={color.label}
|
||||
>
|
||||
{#if selectedColor === color.id}
|
||||
<span class="checkmark white">✓</span>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="btn-secondary" onclick={onClose}>Abbrechen</button>
|
||||
<button type="submit" class="btn-primary" disabled={!folderName.trim() || loading}>
|
||||
{loading ? 'Erstellen...' : 'Erstellen'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: rgb(var(--color-surface-elevated));
|
||||
border-radius: var(--radius-xl);
|
||||
box-shadow: var(--shadow-xl);
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
margin: 1rem;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 1rem 1.5rem;
|
||||
border-bottom: 1px solid rgb(var(--color-border));
|
||||
}
|
||||
|
||||
.modal-header h2 {
|
||||
margin: 0;
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
color: rgb(var(--color-text-primary));
|
||||
}
|
||||
|
||||
.close-button {
|
||||
padding: 0.25rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
color: rgb(var(--color-text-secondary));
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.close-button:hover {
|
||||
background: rgb(var(--color-surface));
|
||||
color: rgb(var(--color-text-primary));
|
||||
}
|
||||
|
||||
form {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: rgb(var(--color-text-primary));
|
||||
}
|
||||
|
||||
.form-group input {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
background: rgb(var(--color-surface));
|
||||
border: 1px solid rgb(var(--color-border));
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 0.875rem;
|
||||
color: rgb(var(--color-text-primary));
|
||||
}
|
||||
|
||||
.form-group input:focus {
|
||||
outline: none;
|
||||
border-color: rgb(var(--color-primary));
|
||||
box-shadow: 0 0 0 3px rgba(var(--color-primary), 0.1);
|
||||
}
|
||||
|
||||
.color-picker {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.color-option {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid transparent;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.color-option.default {
|
||||
background: rgb(var(--color-surface));
|
||||
border-color: rgb(var(--color-border));
|
||||
}
|
||||
|
||||
.color-option.selected {
|
||||
border-color: rgb(var(--color-text-primary));
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.checkmark {
|
||||
font-size: 0.75rem;
|
||||
color: rgb(var(--color-text-primary));
|
||||
}
|
||||
|
||||
.checkmark.white {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.75rem;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.btn-secondary,
|
||||
.btn-primary {
|
||||
padding: 0.625rem 1rem;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: transparent;
|
||||
border: 1px solid rgb(var(--color-border));
|
||||
color: rgb(var(--color-text-primary));
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: rgb(var(--color-surface));
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: rgb(var(--color-primary));
|
||||
border: none;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.btn-primary:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
157
apps/storage/apps/web/src/lib/components/files/UploadZone.svelte
Normal file
157
apps/storage/apps/web/src/lib/components/files/UploadZone.svelte
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
<script lang="ts">
|
||||
import { Upload, X } from 'lucide-svelte';
|
||||
|
||||
interface Props {
|
||||
onUpload: (files: FileList) => void;
|
||||
uploading?: boolean;
|
||||
progress?: number;
|
||||
}
|
||||
|
||||
let { onUpload, uploading = false, progress = 0 }: Props = $props();
|
||||
|
||||
let isDragging = $state(false);
|
||||
let fileInput: HTMLInputElement;
|
||||
|
||||
function handleDragOver(e: DragEvent) {
|
||||
e.preventDefault();
|
||||
isDragging = true;
|
||||
}
|
||||
|
||||
function handleDragLeave(e: DragEvent) {
|
||||
e.preventDefault();
|
||||
isDragging = false;
|
||||
}
|
||||
|
||||
function handleDrop(e: DragEvent) {
|
||||
e.preventDefault();
|
||||
isDragging = false;
|
||||
|
||||
if (e.dataTransfer?.files && e.dataTransfer.files.length > 0) {
|
||||
onUpload(e.dataTransfer.files);
|
||||
}
|
||||
}
|
||||
|
||||
function handleFileSelect(e: Event) {
|
||||
const target = e.target as HTMLInputElement;
|
||||
if (target.files && target.files.length > 0) {
|
||||
onUpload(target.files);
|
||||
target.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
function openFileDialog() {
|
||||
fileInput?.click();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="upload-zone"
|
||||
class:dragging={isDragging}
|
||||
class:uploading
|
||||
ondragover={handleDragOver}
|
||||
ondragleave={handleDragLeave}
|
||||
ondrop={handleDrop}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
onclick={openFileDialog}
|
||||
onkeydown={(e) => e.key === 'Enter' && openFileDialog()}
|
||||
>
|
||||
<input
|
||||
type="file"
|
||||
multiple
|
||||
bind:this={fileInput}
|
||||
onchange={handleFileSelect}
|
||||
class="file-input"
|
||||
aria-label="Dateien auswählen"
|
||||
/>
|
||||
|
||||
{#if uploading}
|
||||
<div class="upload-progress">
|
||||
<div class="progress-bar">
|
||||
<div class="progress-fill" style="width: {progress}%"></div>
|
||||
</div>
|
||||
<span class="progress-text">Hochladen... {progress}%</span>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="upload-content">
|
||||
<Upload size={32} />
|
||||
<span class="upload-text">
|
||||
Dateien hierher ziehen oder <strong>klicken</strong> zum Auswählen
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.upload-zone {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2rem;
|
||||
border: 2px dashed rgb(var(--color-border));
|
||||
border-radius: var(--radius-lg);
|
||||
background: rgb(var(--color-surface));
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.upload-zone:hover,
|
||||
.upload-zone.dragging {
|
||||
border-color: rgb(var(--color-primary));
|
||||
background: rgba(var(--color-primary), 0.05);
|
||||
}
|
||||
|
||||
.upload-zone.uploading {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.file-input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.upload-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
color: rgb(var(--color-text-secondary));
|
||||
}
|
||||
|
||||
.upload-text {
|
||||
font-size: 0.875rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.upload-text strong {
|
||||
color: rgb(var(--color-primary));
|
||||
}
|
||||
|
||||
.upload-progress {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
width: 100%;
|
||||
max-width: 300px;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
width: 100%;
|
||||
height: 8px;
|
||||
background: rgb(var(--color-surface-elevated));
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: rgb(var(--color-primary));
|
||||
border-radius: 4px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.progress-text {
|
||||
font-size: 0.875rem;
|
||||
color: rgb(var(--color-text-secondary));
|
||||
}
|
||||
</style>
|
||||
49
apps/storage/apps/web/src/lib/i18n/index.ts
Normal file
49
apps/storage/apps/web/src/lib/i18n/index.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import { browser } from '$app/environment';
|
||||
import { init, register, locale, waitLocale } from 'svelte-i18n';
|
||||
|
||||
// List of supported locales
|
||||
export const supportedLocales = ['de', 'en', 'it', 'fr', 'es'] 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) {
|
||||
// Check localStorage first
|
||||
const stored = localStorage.getItem('storage_locale');
|
||||
if (stored && supportedLocales.includes(stored as SupportedLocale)) {
|
||||
return stored as SupportedLocale;
|
||||
}
|
||||
|
||||
// Fall back to browser language
|
||||
const browserLang = navigator.language.split('-')[0];
|
||||
if (supportedLocales.includes(browserLang as SupportedLocale)) {
|
||||
return browserLang as SupportedLocale;
|
||||
}
|
||||
}
|
||||
|
||||
return defaultLocale;
|
||||
}
|
||||
|
||||
// Initialize i18n at module scope (required for SSR)
|
||||
init({
|
||||
fallbackLocale: defaultLocale,
|
||||
initialLocale: getInitialLocale(),
|
||||
});
|
||||
|
||||
// Set locale and persist to localStorage
|
||||
export function setLocale(newLocale: SupportedLocale) {
|
||||
locale.set(newLocale);
|
||||
if (browser) {
|
||||
localStorage.setItem('storage_locale', newLocale);
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for locale to be loaded (useful for SSR)
|
||||
export { waitLocale };
|
||||
77
apps/storage/apps/web/src/lib/i18n/locales/de.json
Normal file
77
apps/storage/apps/web/src/lib/i18n/locales/de.json
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
{
|
||||
"app": {
|
||||
"name": "Storage",
|
||||
"description": "Sichere Cloud-Speicherung für deine Dateien"
|
||||
},
|
||||
"nav": {
|
||||
"files": "Dateien",
|
||||
"shared": "Geteilt",
|
||||
"favorites": "Favoriten",
|
||||
"trash": "Papierkorb",
|
||||
"search": "Suche",
|
||||
"settings": "Einstellungen",
|
||||
"profile": "Profil",
|
||||
"feedback": "Feedback"
|
||||
},
|
||||
"files": {
|
||||
"title": "Meine Dateien",
|
||||
"upload": "Hochladen",
|
||||
"newFolder": "Neuer Ordner",
|
||||
"empty": "Keine Dateien vorhanden",
|
||||
"dropHere": "Dateien hier ablegen",
|
||||
"viewGrid": "Rasteransicht",
|
||||
"viewList": "Listenansicht"
|
||||
},
|
||||
"folder": {
|
||||
"create": "Ordner erstellen",
|
||||
"name": "Ordnername",
|
||||
"color": "Ordnerfarbe"
|
||||
},
|
||||
"actions": {
|
||||
"download": "Herunterladen",
|
||||
"rename": "Umbenennen",
|
||||
"move": "Verschieben",
|
||||
"share": "Teilen",
|
||||
"favorite": "Als Favorit markieren",
|
||||
"unfavorite": "Favorit entfernen",
|
||||
"delete": "Löschen",
|
||||
"restore": "Wiederherstellen",
|
||||
"permanentDelete": "Endgültig löschen"
|
||||
},
|
||||
"trash": {
|
||||
"title": "Papierkorb",
|
||||
"empty": "Papierkorb ist leer",
|
||||
"emptyTrash": "Papierkorb leeren",
|
||||
"restoreAll": "Alle wiederherstellen"
|
||||
},
|
||||
"share": {
|
||||
"title": "Teilen",
|
||||
"createLink": "Link erstellen",
|
||||
"copyLink": "Link kopieren",
|
||||
"linkCopied": "Link kopiert!",
|
||||
"accessLevel": "Zugriffsebene",
|
||||
"view": "Ansehen",
|
||||
"download": "Herunterladen",
|
||||
"edit": "Bearbeiten",
|
||||
"password": "Passwortschutz",
|
||||
"expiration": "Ablaufdatum",
|
||||
"maxDownloads": "Max. Downloads"
|
||||
},
|
||||
"search": {
|
||||
"title": "Suche",
|
||||
"placeholder": "Dateien und Ordner durchsuchen...",
|
||||
"noResults": "Keine Ergebnisse gefunden"
|
||||
},
|
||||
"favorites": {
|
||||
"title": "Favoriten",
|
||||
"empty": "Keine Favoriten vorhanden"
|
||||
},
|
||||
"common": {
|
||||
"loading": "Laden...",
|
||||
"save": "Speichern",
|
||||
"cancel": "Abbrechen",
|
||||
"confirm": "Bestätigen",
|
||||
"error": "Fehler",
|
||||
"success": "Erfolgreich"
|
||||
}
|
||||
}
|
||||
77
apps/storage/apps/web/src/lib/i18n/locales/en.json
Normal file
77
apps/storage/apps/web/src/lib/i18n/locales/en.json
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
{
|
||||
"app": {
|
||||
"name": "Storage",
|
||||
"description": "Secure cloud storage for your files"
|
||||
},
|
||||
"nav": {
|
||||
"files": "Files",
|
||||
"shared": "Shared",
|
||||
"favorites": "Favorites",
|
||||
"trash": "Trash",
|
||||
"search": "Search",
|
||||
"settings": "Settings",
|
||||
"profile": "Profile",
|
||||
"feedback": "Feedback"
|
||||
},
|
||||
"files": {
|
||||
"title": "My Files",
|
||||
"upload": "Upload",
|
||||
"newFolder": "New Folder",
|
||||
"empty": "No files yet",
|
||||
"dropHere": "Drop files here",
|
||||
"viewGrid": "Grid view",
|
||||
"viewList": "List view"
|
||||
},
|
||||
"folder": {
|
||||
"create": "Create folder",
|
||||
"name": "Folder name",
|
||||
"color": "Folder color"
|
||||
},
|
||||
"actions": {
|
||||
"download": "Download",
|
||||
"rename": "Rename",
|
||||
"move": "Move",
|
||||
"share": "Share",
|
||||
"favorite": "Add to favorites",
|
||||
"unfavorite": "Remove from favorites",
|
||||
"delete": "Delete",
|
||||
"restore": "Restore",
|
||||
"permanentDelete": "Delete permanently"
|
||||
},
|
||||
"trash": {
|
||||
"title": "Trash",
|
||||
"empty": "Trash is empty",
|
||||
"emptyTrash": "Empty trash",
|
||||
"restoreAll": "Restore all"
|
||||
},
|
||||
"share": {
|
||||
"title": "Share",
|
||||
"createLink": "Create link",
|
||||
"copyLink": "Copy link",
|
||||
"linkCopied": "Link copied!",
|
||||
"accessLevel": "Access level",
|
||||
"view": "View",
|
||||
"download": "Download",
|
||||
"edit": "Edit",
|
||||
"password": "Password protection",
|
||||
"expiration": "Expiration date",
|
||||
"maxDownloads": "Max downloads"
|
||||
},
|
||||
"search": {
|
||||
"title": "Search",
|
||||
"placeholder": "Search files and folders...",
|
||||
"noResults": "No results found"
|
||||
},
|
||||
"favorites": {
|
||||
"title": "Favorites",
|
||||
"empty": "No favorites yet"
|
||||
},
|
||||
"common": {
|
||||
"loading": "Loading...",
|
||||
"save": "Save",
|
||||
"cancel": "Cancel",
|
||||
"confirm": "Confirm",
|
||||
"error": "Error",
|
||||
"success": "Success"
|
||||
}
|
||||
}
|
||||
159
apps/storage/apps/web/src/lib/stores/auth.svelte.ts
Normal file
159
apps/storage/apps/web/src/lib/stores/auth.svelte.ts
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
/**
|
||||
* Auth Store - Manages authentication state using Svelte 5 runes
|
||||
* Uses Mana Core Auth
|
||||
*/
|
||||
|
||||
import { browser } from '$app/environment';
|
||||
import { initializeWebAuth, type UserData } from '@manacore/shared-auth';
|
||||
|
||||
const MANA_AUTH_URL = 'http://localhost:3001';
|
||||
|
||||
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: MANA_AUTH_URL });
|
||||
_authService = auth.authService;
|
||||
_tokenManager = auth.tokenManager;
|
||||
}
|
||||
return _authService;
|
||||
}
|
||||
|
||||
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 {
|
||||
const authenticated = await authService.isAuthenticated();
|
||||
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 result = await authService.signUp(email, password);
|
||||
|
||||
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 getAccessToken() {
|
||||
const authService = getAuthService();
|
||||
if (!authService) {
|
||||
return null;
|
||||
}
|
||||
return await authService.getAppToken();
|
||||
},
|
||||
};
|
||||
172
apps/storage/apps/web/src/lib/stores/files.svelte.ts
Normal file
172
apps/storage/apps/web/src/lib/stores/files.svelte.ts
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
/**
|
||||
* Files Store - Manages files and folders state
|
||||
*/
|
||||
|
||||
import { filesApi, foldersApi, type StorageFile, type StorageFolder } from '$lib/api/client';
|
||||
|
||||
let files = $state<StorageFile[]>([]);
|
||||
let folders = $state<StorageFolder[]>([]);
|
||||
let currentFolder = $state<StorageFolder | null>(null);
|
||||
let loading = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
let viewMode = $state<'grid' | 'list'>('grid');
|
||||
|
||||
export const filesStore = {
|
||||
get files() {
|
||||
return files;
|
||||
},
|
||||
get folders() {
|
||||
return folders;
|
||||
},
|
||||
get currentFolder() {
|
||||
return currentFolder;
|
||||
},
|
||||
get loading() {
|
||||
return loading;
|
||||
},
|
||||
get error() {
|
||||
return error;
|
||||
},
|
||||
get viewMode() {
|
||||
return viewMode;
|
||||
},
|
||||
|
||||
setViewMode(mode: 'grid' | 'list') {
|
||||
viewMode = mode;
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
localStorage.setItem('storage-view-mode', mode);
|
||||
}
|
||||
},
|
||||
|
||||
initViewMode() {
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
const saved = localStorage.getItem('storage-view-mode');
|
||||
if (saved === 'grid' || saved === 'list') {
|
||||
viewMode = saved;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
async loadFolder(folderId?: string) {
|
||||
loading = true;
|
||||
error = null;
|
||||
|
||||
try {
|
||||
if (folderId) {
|
||||
const result = await foldersApi.get(folderId);
|
||||
if (result.error) {
|
||||
error = result.error;
|
||||
return;
|
||||
}
|
||||
if (result.data) {
|
||||
currentFolder = result.data.folder;
|
||||
files = result.data.files;
|
||||
folders = result.data.subfolders;
|
||||
}
|
||||
} else {
|
||||
// Load root
|
||||
currentFolder = null;
|
||||
const [filesResult, foldersResult] = await Promise.all([
|
||||
filesApi.list(),
|
||||
foldersApi.list(),
|
||||
]);
|
||||
|
||||
if (filesResult.error) {
|
||||
error = filesResult.error;
|
||||
return;
|
||||
}
|
||||
if (foldersResult.error) {
|
||||
error = foldersResult.error;
|
||||
return;
|
||||
}
|
||||
|
||||
files = filesResult.data || [];
|
||||
folders = foldersResult.data || [];
|
||||
}
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Unknown error';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
async uploadFile(file: File) {
|
||||
const result = await filesApi.upload(file, currentFolder?.id);
|
||||
if (result.data) {
|
||||
files = [...files, result.data];
|
||||
}
|
||||
return result;
|
||||
},
|
||||
|
||||
async createFolder(name: string, color?: string) {
|
||||
const result = await foldersApi.create(name, currentFolder?.id, color);
|
||||
if (result.data) {
|
||||
folders = [...folders, result.data];
|
||||
}
|
||||
return result;
|
||||
},
|
||||
|
||||
async deleteFile(id: string) {
|
||||
const result = await filesApi.delete(id);
|
||||
if (!result.error) {
|
||||
files = files.filter((f) => f.id !== id);
|
||||
}
|
||||
return result;
|
||||
},
|
||||
|
||||
async deleteFolder(id: string) {
|
||||
const result = await foldersApi.delete(id);
|
||||
if (!result.error) {
|
||||
folders = folders.filter((f) => f.id !== id);
|
||||
}
|
||||
return result;
|
||||
},
|
||||
|
||||
async toggleFileFavorite(id: string) {
|
||||
const result = await filesApi.toggleFavorite(id);
|
||||
if (result.data) {
|
||||
files = files.map((f) => (f.id === id ? result.data! : f));
|
||||
}
|
||||
return result;
|
||||
},
|
||||
|
||||
async toggleFolderFavorite(id: string) {
|
||||
const result = await foldersApi.toggleFavorite(id);
|
||||
if (result.data) {
|
||||
folders = folders.map((f) => (f.id === id ? result.data! : f));
|
||||
}
|
||||
return result;
|
||||
},
|
||||
|
||||
async renameFile(id: string, name: string) {
|
||||
const result = await filesApi.rename(id, name);
|
||||
if (result.data) {
|
||||
files = files.map((f) => (f.id === id ? result.data! : f));
|
||||
}
|
||||
return result;
|
||||
},
|
||||
|
||||
async renameFolder(id: string, name: string) {
|
||||
const result = await foldersApi.rename(id, name);
|
||||
if (result.data) {
|
||||
folders = folders.map((f) => (f.id === id ? result.data! : f));
|
||||
}
|
||||
return result;
|
||||
},
|
||||
|
||||
async downloadFile(id: string, filename: string) {
|
||||
const blob = await filesApi.download(id);
|
||||
if (blob) {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
};
|
||||
8
apps/storage/apps/web/src/lib/stores/navigation.ts
Normal file
8
apps/storage/apps/web/src/lib/stores/navigation.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
/**
|
||||
* Navigation Store - Manages sidebar and navigation state
|
||||
*/
|
||||
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
export const isSidebarMode = writable(false);
|
||||
export const isNavCollapsed = writable(false);
|
||||
95
apps/storage/apps/web/src/lib/stores/theme.svelte.ts
Normal file
95
apps/storage/apps/web/src/lib/stores/theme.svelte.ts
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
/**
|
||||
* Theme Store - Manages theme state
|
||||
*/
|
||||
|
||||
import { browser } from '$app/environment';
|
||||
import {
|
||||
THEME_DEFINITIONS,
|
||||
type ThemeMode,
|
||||
type ThemeVariant,
|
||||
DEFAULT_THEME_VARIANT,
|
||||
FREE_THEME_VARIANTS,
|
||||
} from '@manacore/shared-theme';
|
||||
|
||||
const STORAGE_KEY_MODE = 'storage-theme-mode';
|
||||
const STORAGE_KEY_VARIANT = 'storage-theme-variant';
|
||||
|
||||
function createThemeStore() {
|
||||
let mode = $state<ThemeMode>('system');
|
||||
let variant = $state<ThemeVariant>(DEFAULT_THEME_VARIANT);
|
||||
let systemPrefersDark = $state(false);
|
||||
|
||||
function getSystemPreference(): boolean {
|
||||
if (!browser) return false;
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
}
|
||||
|
||||
function applyTheme() {
|
||||
if (!browser) return;
|
||||
|
||||
const isDarkMode = mode === 'dark' || (mode === 'system' && systemPrefersDark);
|
||||
const themeClass = isDarkMode
|
||||
? THEME_DEFINITIONS[variant].darkClass
|
||||
: THEME_DEFINITIONS[variant].lightClass;
|
||||
|
||||
document.documentElement.className = themeClass;
|
||||
}
|
||||
|
||||
return {
|
||||
get mode() {
|
||||
return mode;
|
||||
},
|
||||
get variant() {
|
||||
return variant;
|
||||
},
|
||||
get isDark() {
|
||||
return mode === 'dark' || (mode === 'system' && systemPrefersDark);
|
||||
},
|
||||
get variants() {
|
||||
return FREE_THEME_VARIANTS;
|
||||
},
|
||||
|
||||
initialize() {
|
||||
if (!browser) return;
|
||||
|
||||
const savedMode = localStorage.getItem(STORAGE_KEY_MODE) as ThemeMode | null;
|
||||
const savedVariant = localStorage.getItem(STORAGE_KEY_VARIANT) as ThemeVariant | null;
|
||||
|
||||
if (savedMode) mode = savedMode;
|
||||
if (savedVariant && savedVariant in THEME_DEFINITIONS) variant = savedVariant;
|
||||
|
||||
systemPrefersDark = getSystemPreference();
|
||||
|
||||
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
mediaQuery.addEventListener('change', (e) => {
|
||||
systemPrefersDark = e.matches;
|
||||
applyTheme();
|
||||
});
|
||||
|
||||
applyTheme();
|
||||
},
|
||||
|
||||
setMode(newMode: ThemeMode) {
|
||||
mode = newMode;
|
||||
if (browser) {
|
||||
localStorage.setItem(STORAGE_KEY_MODE, newMode);
|
||||
}
|
||||
applyTheme();
|
||||
},
|
||||
|
||||
setVariant(newVariant: ThemeVariant) {
|
||||
variant = newVariant;
|
||||
if (browser) {
|
||||
localStorage.setItem(STORAGE_KEY_VARIANT, newVariant);
|
||||
}
|
||||
applyTheme();
|
||||
},
|
||||
|
||||
toggleMode() {
|
||||
const newMode = mode === 'dark' ? 'light' : 'dark';
|
||||
this.setMode(newMode);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const theme = createThemeStore();
|
||||
63
apps/storage/apps/web/src/lib/stores/toast.ts
Normal file
63
apps/storage/apps/web/src/lib/stores/toast.ts
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
/**
|
||||
* Toast Store - Manages toast notifications
|
||||
*/
|
||||
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
export interface Toast {
|
||||
id: string;
|
||||
type: 'success' | 'error' | 'warning' | 'info';
|
||||
message: string;
|
||||
duration?: number;
|
||||
}
|
||||
|
||||
function createToastStore() {
|
||||
const { subscribe, update } = writable<Toast[]>([]);
|
||||
|
||||
function add(toast: Omit<Toast, 'id'>) {
|
||||
const id = crypto.randomUUID();
|
||||
const duration = toast.duration ?? 5000;
|
||||
|
||||
update((toasts) => [...toasts, { ...toast, id }]);
|
||||
|
||||
if (duration > 0) {
|
||||
setTimeout(() => {
|
||||
remove(id);
|
||||
}, duration);
|
||||
}
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
function remove(id: string) {
|
||||
update((toasts) => toasts.filter((t) => t.id !== id));
|
||||
}
|
||||
|
||||
function success(message: string, duration?: number) {
|
||||
return add({ type: 'success', message, duration });
|
||||
}
|
||||
|
||||
function error(message: string, duration?: number) {
|
||||
return add({ type: 'error', message, duration });
|
||||
}
|
||||
|
||||
function warning(message: string, duration?: number) {
|
||||
return add({ type: 'warning', message, duration });
|
||||
}
|
||||
|
||||
function info(message: string, duration?: number) {
|
||||
return add({ type: 'info', message, duration });
|
||||
}
|
||||
|
||||
return {
|
||||
subscribe,
|
||||
add,
|
||||
remove,
|
||||
success,
|
||||
error,
|
||||
warning,
|
||||
info,
|
||||
};
|
||||
}
|
||||
|
||||
export const toast = createToastStore();
|
||||
271
apps/storage/apps/web/src/routes/+layout.svelte
Normal file
271
apps/storage/apps/web/src/routes/+layout.svelte
Normal file
|
|
@ -0,0 +1,271 @@
|
|||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/stores';
|
||||
import { onMount } from 'svelte';
|
||||
import { locale } from 'svelte-i18n';
|
||||
import { PillNavigation } from '@manacore/shared-ui';
|
||||
import type { PillNavItem, PillDropdownItem } from '@manacore/shared-ui';
|
||||
import { theme } from '$lib/stores/theme.svelte';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { THEME_DEFINITIONS } from '@manacore/shared-theme';
|
||||
import {
|
||||
isSidebarMode as sidebarModeStore,
|
||||
isNavCollapsed as collapsedStore,
|
||||
} from '$lib/stores/navigation';
|
||||
import { getLanguageDropdownItems, getCurrentLanguageLabel } from '@manacore/shared-i18n';
|
||||
import { getPillAppItems } from '@manacore/shared-branding';
|
||||
import { setLocale, supportedLocales } from '$lib/i18n';
|
||||
import ToastContainer from '$lib/components/ToastContainer.svelte';
|
||||
import '../app.css';
|
||||
|
||||
// App switcher items
|
||||
const appItems = getPillAppItems('storage');
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
let loading = $state(true);
|
||||
let isSidebarMode = $state(false);
|
||||
let isCollapsed = $state(false);
|
||||
|
||||
// Use theme store's isDark directly
|
||||
let isDark = $derived(theme.isDark);
|
||||
|
||||
// Theme variant dropdown items
|
||||
let themeVariantItems = $derived<PillDropdownItem[]>([
|
||||
...theme.variants.map((variant) => ({
|
||||
id: variant,
|
||||
label: THEME_DEFINITIONS[variant].label,
|
||||
icon: THEME_DEFINITIONS[variant].icon,
|
||||
onClick: () => theme.setVariant(variant),
|
||||
active: theme.variant === variant,
|
||||
})),
|
||||
{
|
||||
id: 'all-themes',
|
||||
label: 'Alle Themes',
|
||||
icon: 'palette',
|
||||
onClick: () => goto('/themes'),
|
||||
active: false,
|
||||
},
|
||||
]);
|
||||
|
||||
// Current theme variant label
|
||||
let currentThemeVariantLabel = $derived(THEME_DEFINITIONS[theme.variant].label);
|
||||
|
||||
// Language selector items
|
||||
let currentLocale = $derived($locale || 'de');
|
||||
function handleLocaleChange(newLocale: string) {
|
||||
setLocale(newLocale as any);
|
||||
}
|
||||
let languageItems = $derived(
|
||||
getLanguageDropdownItems(supportedLocales, currentLocale, handleLocaleChange)
|
||||
);
|
||||
let currentLanguageLabel = $derived(getCurrentLanguageLabel(currentLocale));
|
||||
|
||||
// User email for user dropdown
|
||||
let userEmail = $derived(authStore.user?.email || 'Menü');
|
||||
|
||||
// Navigation items for Storage
|
||||
const navItems: PillNavItem[] = [
|
||||
{ href: '/files', label: 'Dateien', icon: 'folder' },
|
||||
{ href: '/shared', label: 'Geteilt', icon: 'share' },
|
||||
{ href: '/favorites', label: 'Favoriten', icon: 'heart' },
|
||||
{ href: '/trash', label: 'Papierkorb', icon: 'trash' },
|
||||
{ href: '/search', label: 'Suche', icon: 'search' },
|
||||
{ href: '/feedback', label: 'Feedback', icon: 'chat' },
|
||||
];
|
||||
|
||||
// Navigation shortcuts
|
||||
const navRoutes = navItems.map((item) => item.href);
|
||||
|
||||
// Check if current path is auth page
|
||||
let isAuthPage = $derived(
|
||||
$page.url.pathname === '/login' ||
|
||||
$page.url.pathname === '/register' ||
|
||||
$page.url.pathname === '/forgot-password'
|
||||
);
|
||||
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
const target = event.target as HTMLElement;
|
||||
|
||||
// Cmd/Ctrl+K to open search
|
||||
if ((event.ctrlKey || event.metaKey) && event.key === 'k') {
|
||||
event.preventDefault();
|
||||
goto('/search');
|
||||
return;
|
||||
}
|
||||
|
||||
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ((event.ctrlKey || event.metaKey) && !event.shiftKey && !event.altKey) {
|
||||
const num = parseInt(event.key);
|
||||
if (num >= 1 && num <= navRoutes.length) {
|
||||
event.preventDefault();
|
||||
const route = navRoutes[num - 1];
|
||||
if (route) {
|
||||
goto(route);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleModeChange(isSidebar: boolean) {
|
||||
isSidebarMode = isSidebar;
|
||||
sidebarModeStore.set(isSidebar);
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
localStorage.setItem('storage-nav-sidebar', String(isSidebar));
|
||||
}
|
||||
}
|
||||
|
||||
function handleCollapsedChange(collapsed: boolean) {
|
||||
isCollapsed = collapsed;
|
||||
collapsedStore.set(collapsed);
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
localStorage.setItem('storage-nav-collapsed', String(collapsed));
|
||||
}
|
||||
}
|
||||
|
||||
function handleToggleTheme() {
|
||||
theme.toggleMode();
|
||||
}
|
||||
|
||||
function handleThemeModeChange(mode: 'light' | 'dark' | 'system') {
|
||||
theme.setMode(mode);
|
||||
}
|
||||
|
||||
async function handleLogout() {
|
||||
await authStore.signOut();
|
||||
goto('/login');
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
// Initialize theme
|
||||
theme.initialize();
|
||||
|
||||
// Initialize auth
|
||||
await authStore.initialize();
|
||||
|
||||
// Initialize sidebar mode from localStorage
|
||||
const savedSidebar = localStorage.getItem('storage-nav-sidebar');
|
||||
if (savedSidebar === 'true') {
|
||||
isSidebarMode = true;
|
||||
sidebarModeStore.set(true);
|
||||
}
|
||||
|
||||
// Initialize collapsed state from localStorage
|
||||
const savedCollapsed = localStorage.getItem('storage-nav-collapsed');
|
||||
if (savedCollapsed === 'true') {
|
||||
isCollapsed = true;
|
||||
collapsedStore.set(true);
|
||||
}
|
||||
|
||||
loading = false;
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={handleKeydown} />
|
||||
|
||||
<ToastContainer />
|
||||
|
||||
{#if loading}
|
||||
<div class="flex min-h-screen items-center justify-center bg-background">
|
||||
<div class="text-center">
|
||||
<div
|
||||
class="mb-4 inline-block h-12 w-12 animate-spin rounded-full border-4 border-solid border-primary border-r-transparent"
|
||||
></div>
|
||||
<p class="text-muted-foreground">Laden...</p>
|
||||
</div>
|
||||
</div>
|
||||
{:else if isAuthPage}
|
||||
<!-- Auth pages without navigation -->
|
||||
{@render children()}
|
||||
{:else}
|
||||
<!-- Navigation Layout -->
|
||||
<div class="layout-container">
|
||||
<PillNavigation
|
||||
items={navItems}
|
||||
currentPath={$page.url.pathname}
|
||||
appName="Storage"
|
||||
homeRoute="/files"
|
||||
onToggleTheme={handleToggleTheme}
|
||||
{isDark}
|
||||
{isSidebarMode}
|
||||
onModeChange={handleModeChange}
|
||||
{isCollapsed}
|
||||
onCollapsedChange={handleCollapsedChange}
|
||||
showThemeToggle={true}
|
||||
showThemeVariants={true}
|
||||
{themeVariantItems}
|
||||
{currentThemeVariantLabel}
|
||||
themeMode={theme.mode}
|
||||
onThemeModeChange={handleThemeModeChange}
|
||||
showLanguageSwitcher={true}
|
||||
{languageItems}
|
||||
{currentLanguageLabel}
|
||||
showLogout={authStore.isAuthenticated}
|
||||
onLogout={handleLogout}
|
||||
loginHref="/login"
|
||||
primaryColor="#3b82f6"
|
||||
showAppSwitcher={true}
|
||||
{appItems}
|
||||
{userEmail}
|
||||
settingsHref="/settings"
|
||||
manaHref="/mana"
|
||||
profileHref="/profile"
|
||||
allAppsHref="/apps"
|
||||
/>
|
||||
|
||||
<main
|
||||
class="main-content bg-background"
|
||||
class:sidebar-mode={isSidebarMode && !isCollapsed}
|
||||
class:floating-mode={!isSidebarMode && !isCollapsed}
|
||||
>
|
||||
<div class="content-wrapper">
|
||||
{@render children()}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.layout-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
flex: 1;
|
||||
transition: all 300ms ease;
|
||||
}
|
||||
|
||||
.main-content.floating-mode {
|
||||
padding-top: 100px;
|
||||
}
|
||||
|
||||
.main-content.sidebar-mode {
|
||||
padding-left: 180px;
|
||||
}
|
||||
|
||||
.content-wrapper {
|
||||
max-width: 80rem;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
padding: 2rem 1rem;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.content-wrapper {
|
||||
padding-left: 1.5rem;
|
||||
padding-right: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.content-wrapper {
|
||||
padding-left: 2rem;
|
||||
padding-right: 2rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
13
apps/storage/apps/web/src/routes/+page.svelte
Normal file
13
apps/storage/apps/web/src/routes/+page.svelte
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
// Redirect to /files on root
|
||||
onMount(() => {
|
||||
goto('/files');
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="flex min-h-screen items-center justify-center">
|
||||
<p class="text-muted-foreground">Weiterleitung...</p>
|
||||
</div>
|
||||
236
apps/storage/apps/web/src/routes/favorites/+page.svelte
Normal file
236
apps/storage/apps/web/src/routes/favorites/+page.svelte
Normal file
|
|
@ -0,0 +1,236 @@
|
|||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { onMount } from 'svelte';
|
||||
import { Heart, Grid, List } from 'lucide-svelte';
|
||||
import { searchApi, type StorageFile, type StorageFolder } from '$lib/api/client';
|
||||
import { filesStore } from '$lib/stores/files.svelte';
|
||||
import { toast } from '$lib/stores/toast';
|
||||
import FileGrid from '$lib/components/files/FileGrid.svelte';
|
||||
import FileList from '$lib/components/files/FileList.svelte';
|
||||
|
||||
let files = $state<StorageFile[]>([]);
|
||||
let folders = $state<StorageFolder[]>([]);
|
||||
let loading = $state(true);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
onMount(async () => {
|
||||
filesStore.initViewMode();
|
||||
await loadFavorites();
|
||||
});
|
||||
|
||||
async function loadFavorites() {
|
||||
loading = true;
|
||||
error = null;
|
||||
|
||||
const result = await searchApi.favorites();
|
||||
if (result.error) {
|
||||
error = result.error;
|
||||
} else if (result.data) {
|
||||
files = result.data.files;
|
||||
folders = result.data.folders;
|
||||
}
|
||||
|
||||
loading = false;
|
||||
}
|
||||
|
||||
function handleFolderClick(folder: StorageFolder) {
|
||||
goto(`/files/${folder.id}`);
|
||||
}
|
||||
|
||||
function handleFileClick(file: StorageFile) {
|
||||
console.log('File clicked:', file);
|
||||
}
|
||||
|
||||
async function handleFileAction(action: string, file: StorageFile) {
|
||||
if (action === 'favorite') {
|
||||
const result = await filesStore.toggleFileFavorite(file.id);
|
||||
if (!result.error) {
|
||||
files = files.filter((f) => f.id !== file.id);
|
||||
toast.success('Favorit entfernt');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleFolderAction(action: string, folder: StorageFolder) {
|
||||
if (action === 'favorite') {
|
||||
const result = await filesStore.toggleFolderFavorite(folder.id);
|
||||
if (!result.error) {
|
||||
folders = folders.filter((f) => f.id !== folder.id);
|
||||
toast.success('Favorit entfernt');
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Favoriten - Storage</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="favorites-page">
|
||||
<div class="page-header">
|
||||
<h1>
|
||||
<Heart size={24} />
|
||||
Favoriten
|
||||
</h1>
|
||||
|
||||
<div class="view-toggle">
|
||||
<button
|
||||
class="view-btn"
|
||||
class:active={filesStore.viewMode === 'grid'}
|
||||
onclick={() => filesStore.setViewMode('grid')}
|
||||
aria-label="Rasteransicht"
|
||||
>
|
||||
<Grid size={18} />
|
||||
</button>
|
||||
<button
|
||||
class="view-btn"
|
||||
class:active={filesStore.viewMode === 'list'}
|
||||
onclick={() => filesStore.setViewMode('list')}
|
||||
aria-label="Listenansicht"
|
||||
>
|
||||
<List size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if loading}
|
||||
<div class="loading-state">
|
||||
<div class="spinner"></div>
|
||||
<p>Laden...</p>
|
||||
</div>
|
||||
{:else if error}
|
||||
<div class="error-state">
|
||||
<p>Fehler: {error}</p>
|
||||
<button onclick={loadFavorites}>Erneut versuchen</button>
|
||||
</div>
|
||||
{:else if files.length === 0 && folders.length === 0}
|
||||
<div class="empty-state">
|
||||
<Heart size={48} />
|
||||
<h2>Keine Favoriten</h2>
|
||||
<p>Markiere Dateien und Ordner als Favoriten, um sie hier schnell zu finden.</p>
|
||||
</div>
|
||||
{:else if filesStore.viewMode === 'grid'}
|
||||
<FileGrid
|
||||
{files}
|
||||
{folders}
|
||||
onFileClick={handleFileClick}
|
||||
onFolderClick={handleFolderClick}
|
||||
onFileAction={handleFileAction}
|
||||
onFolderAction={handleFolderAction}
|
||||
/>
|
||||
{:else}
|
||||
<FileList
|
||||
{files}
|
||||
{folders}
|
||||
onFileClick={handleFileClick}
|
||||
onFolderClick={handleFolderClick}
|
||||
onFileAction={handleFileAction}
|
||||
onFolderAction={handleFolderAction}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.favorites-page {
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.page-header h1 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
margin: 0;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
color: rgb(var(--color-text-primary));
|
||||
}
|
||||
|
||||
.view-toggle {
|
||||
display: flex;
|
||||
background: rgb(var(--color-surface));
|
||||
border-radius: var(--radius-md);
|
||||
padding: 0.25rem;
|
||||
}
|
||||
|
||||
.view-btn {
|
||||
padding: 0.5rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
color: rgb(var(--color-text-secondary));
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.view-btn:hover {
|
||||
color: rgb(var(--color-text-primary));
|
||||
}
|
||||
|
||||
.view-btn.active {
|
||||
background: rgb(var(--color-surface-elevated));
|
||||
color: rgb(var(--color-primary));
|
||||
}
|
||||
|
||||
.loading-state,
|
||||
.error-state,
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 4rem 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.loading-state .spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 3px solid rgb(var(--color-border));
|
||||
border-top-color: rgb(var(--color-primary));
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.loading-state p,
|
||||
.error-state p {
|
||||
margin-top: 1rem;
|
||||
color: rgb(var(--color-text-secondary));
|
||||
}
|
||||
|
||||
.error-state button {
|
||||
margin-top: 1rem;
|
||||
padding: 0.5rem 1rem;
|
||||
background: rgb(var(--color-primary));
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
color: rgb(var(--color-text-secondary));
|
||||
}
|
||||
|
||||
.empty-state h2 {
|
||||
margin: 1rem 0 0.5rem;
|
||||
font-size: 1.25rem;
|
||||
color: rgb(var(--color-text-primary));
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
218
apps/storage/apps/web/src/routes/feedback/+page.svelte
Normal file
218
apps/storage/apps/web/src/routes/feedback/+page.svelte
Normal file
|
|
@ -0,0 +1,218 @@
|
|||
<script lang="ts">
|
||||
import { MessageSquare, Send } from 'lucide-svelte';
|
||||
import { toast } from '$lib/stores/toast';
|
||||
|
||||
let type = $state<'bug' | 'feature' | 'other'>('feature');
|
||||
let message = $state('');
|
||||
let sending = $state(false);
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!message.trim()) return;
|
||||
|
||||
sending = true;
|
||||
|
||||
// Simulate sending feedback
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
|
||||
toast.success('Feedback gesendet! Vielen Dank.');
|
||||
message = '';
|
||||
type = 'feature';
|
||||
sending = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Feedback - Storage</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="feedback-page">
|
||||
<div class="page-header">
|
||||
<h1>
|
||||
<MessageSquare size={24} />
|
||||
Feedback
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div class="feedback-card">
|
||||
<p class="intro">
|
||||
Wir freuen uns über dein Feedback! Teile uns mit, was wir verbessern können oder welche
|
||||
Funktionen du dir wünschst.
|
||||
</p>
|
||||
|
||||
<form onsubmit={(e) => { e.preventDefault(); handleSubmit(); }}>
|
||||
<div class="form-group">
|
||||
<label>Art des Feedbacks</label>
|
||||
<div class="type-selector">
|
||||
<button
|
||||
type="button"
|
||||
class="type-btn"
|
||||
class:active={type === 'bug'}
|
||||
onclick={() => (type = 'bug')}
|
||||
>
|
||||
Bug melden
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="type-btn"
|
||||
class:active={type === 'feature'}
|
||||
onclick={() => (type = 'feature')}
|
||||
>
|
||||
Feature-Wunsch
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="type-btn"
|
||||
class:active={type === 'other'}
|
||||
onclick={() => (type = 'other')}
|
||||
>
|
||||
Sonstiges
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="message">Deine Nachricht</label>
|
||||
<textarea
|
||||
id="message"
|
||||
bind:value={message}
|
||||
placeholder="Beschreibe dein Feedback hier..."
|
||||
rows="6"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="submit-btn" disabled={!message.trim() || sending}>
|
||||
<Send size={18} />
|
||||
{sending ? 'Wird gesendet...' : 'Feedback senden'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.feedback-page {
|
||||
min-height: 100%;
|
||||
max-width: 600px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.page-header h1 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
margin: 0;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
color: rgb(var(--color-text-primary));
|
||||
}
|
||||
|
||||
.feedback-card {
|
||||
padding: 2rem;
|
||||
background: rgb(var(--color-surface-elevated));
|
||||
border: 1px solid rgb(var(--color-border));
|
||||
border-radius: var(--radius-xl);
|
||||
}
|
||||
|
||||
.intro {
|
||||
margin: 0 0 1.5rem;
|
||||
font-size: 0.875rem;
|
||||
color: rgb(var(--color-text-secondary));
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: rgb(var(--color-text-primary));
|
||||
}
|
||||
|
||||
.type-selector {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.type-btn {
|
||||
flex: 1;
|
||||
padding: 0.625rem;
|
||||
background: rgb(var(--color-surface));
|
||||
border: 1px solid rgb(var(--color-border));
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 0.875rem;
|
||||
color: rgb(var(--color-text-secondary));
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.type-btn:hover {
|
||||
border-color: rgb(var(--color-primary));
|
||||
color: rgb(var(--color-text-primary));
|
||||
}
|
||||
|
||||
.type-btn.active {
|
||||
background: rgba(var(--color-primary), 0.1);
|
||||
border-color: rgb(var(--color-primary));
|
||||
color: rgb(var(--color-primary));
|
||||
}
|
||||
|
||||
textarea {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
background: rgb(var(--color-surface));
|
||||
border: 1px solid rgb(var(--color-border));
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 0.875rem;
|
||||
color: rgb(var(--color-text-primary));
|
||||
resize: vertical;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
textarea:focus {
|
||||
outline: none;
|
||||
border-color: rgb(var(--color-primary));
|
||||
box-shadow: 0 0 0 3px rgba(var(--color-primary), 0.1);
|
||||
}
|
||||
|
||||
textarea::placeholder {
|
||||
color: rgb(var(--color-text-tertiary));
|
||||
}
|
||||
|
||||
.submit-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
background: rgb(var(--color-primary));
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
transition: opacity var(--transition-fast);
|
||||
}
|
||||
|
||||
.submit-btn:hover:not(:disabled) {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.submit-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.type-selector {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
417
apps/storage/apps/web/src/routes/files/+page.svelte
Normal file
417
apps/storage/apps/web/src/routes/files/+page.svelte
Normal file
|
|
@ -0,0 +1,417 @@
|
|||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { onMount } from 'svelte';
|
||||
import { Grid, List, Plus, FolderPlus, Upload } from 'lucide-svelte';
|
||||
import { filesStore } from '$lib/stores/files.svelte';
|
||||
import { toast } from '$lib/stores/toast';
|
||||
import type { StorageFile, StorageFolder } from '$lib/api/client';
|
||||
import FileGrid from '$lib/components/files/FileGrid.svelte';
|
||||
import FileList from '$lib/components/files/FileList.svelte';
|
||||
import Breadcrumb from '$lib/components/files/Breadcrumb.svelte';
|
||||
import UploadZone from '$lib/components/files/UploadZone.svelte';
|
||||
import NewFolderModal from '$lib/components/files/NewFolderModal.svelte';
|
||||
|
||||
let showUploadZone = $state(false);
|
||||
let showNewFolderModal = $state(false);
|
||||
let uploading = $state(false);
|
||||
let uploadProgress = $state(0);
|
||||
|
||||
// Breadcrumb items from current folder path
|
||||
let breadcrumbItems = $derived(
|
||||
filesStore.currentFolder
|
||||
? [{ id: filesStore.currentFolder.id, name: filesStore.currentFolder.name }]
|
||||
: []
|
||||
);
|
||||
|
||||
onMount(() => {
|
||||
filesStore.initViewMode();
|
||||
filesStore.loadFolder();
|
||||
});
|
||||
|
||||
function handleFolderClick(folder: StorageFolder) {
|
||||
goto(`/files/${folder.id}`);
|
||||
}
|
||||
|
||||
function handleFileClick(file: StorageFile) {
|
||||
// TODO: Open file preview
|
||||
console.log('File clicked:', file);
|
||||
}
|
||||
|
||||
async function handleFileAction(action: string, file: StorageFile) {
|
||||
switch (action) {
|
||||
case 'download':
|
||||
await filesStore.downloadFile(file.id, file.name);
|
||||
toast.success('Download gestartet');
|
||||
break;
|
||||
case 'rename':
|
||||
const newName = prompt('Neuer Name:', file.name);
|
||||
if (newName && newName !== file.name) {
|
||||
const result = await filesStore.renameFile(file.id, newName);
|
||||
if (result.error) {
|
||||
toast.error(result.error);
|
||||
} else {
|
||||
toast.success('Datei umbenannt');
|
||||
}
|
||||
}
|
||||
break;
|
||||
case 'favorite':
|
||||
const favResult = await filesStore.toggleFileFavorite(file.id);
|
||||
if (!favResult.error) {
|
||||
toast.success(file.isFavorite ? 'Favorit entfernt' : 'Als Favorit markiert');
|
||||
}
|
||||
break;
|
||||
case 'delete':
|
||||
if (confirm('Datei in den Papierkorb verschieben?')) {
|
||||
const delResult = await filesStore.deleteFile(file.id);
|
||||
if (delResult.error) {
|
||||
toast.error(delResult.error);
|
||||
} else {
|
||||
toast.success('In den Papierkorb verschoben');
|
||||
}
|
||||
}
|
||||
break;
|
||||
case 'share':
|
||||
// TODO: Open share modal
|
||||
toast.info('Teilen-Funktion kommt bald');
|
||||
break;
|
||||
case 'move':
|
||||
// TODO: Open move modal
|
||||
toast.info('Verschieben-Funktion kommt bald');
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleFolderAction(action: string, folder: StorageFolder) {
|
||||
switch (action) {
|
||||
case 'rename':
|
||||
const newName = prompt('Neuer Name:', folder.name);
|
||||
if (newName && newName !== folder.name) {
|
||||
const result = await filesStore.renameFolder(folder.id, newName);
|
||||
if (result.error) {
|
||||
toast.error(result.error);
|
||||
} else {
|
||||
toast.success('Ordner umbenannt');
|
||||
}
|
||||
}
|
||||
break;
|
||||
case 'favorite':
|
||||
const favResult = await filesStore.toggleFolderFavorite(folder.id);
|
||||
if (!favResult.error) {
|
||||
toast.success(folder.isFavorite ? 'Favorit entfernt' : 'Als Favorit markiert');
|
||||
}
|
||||
break;
|
||||
case 'delete':
|
||||
if (confirm('Ordner und Inhalt in den Papierkorb verschieben?')) {
|
||||
const delResult = await filesStore.deleteFolder(folder.id);
|
||||
if (delResult.error) {
|
||||
toast.error(delResult.error);
|
||||
} else {
|
||||
toast.success('In den Papierkorb verschoben');
|
||||
}
|
||||
}
|
||||
break;
|
||||
case 'share':
|
||||
toast.info('Teilen-Funktion kommt bald');
|
||||
break;
|
||||
case 'move':
|
||||
toast.info('Verschieben-Funktion kommt bald');
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUpload(files: FileList) {
|
||||
uploading = true;
|
||||
uploadProgress = 0;
|
||||
|
||||
const totalFiles = files.length;
|
||||
let completed = 0;
|
||||
|
||||
for (const file of files) {
|
||||
const result = await filesStore.uploadFile(file);
|
||||
if (result.error) {
|
||||
toast.error(`Fehler beim Hochladen von ${file.name}: ${result.error}`);
|
||||
}
|
||||
completed++;
|
||||
uploadProgress = Math.round((completed / totalFiles) * 100);
|
||||
}
|
||||
|
||||
uploading = false;
|
||||
uploadProgress = 0;
|
||||
showUploadZone = false;
|
||||
toast.success(`${totalFiles} Datei(en) hochgeladen`);
|
||||
}
|
||||
|
||||
async function handleCreateFolder(name: string, color?: string) {
|
||||
const result = await filesStore.createFolder(name, color);
|
||||
if (result.error) {
|
||||
toast.error(result.error);
|
||||
} else {
|
||||
toast.success('Ordner erstellt');
|
||||
}
|
||||
}
|
||||
|
||||
function handleBreadcrumbNavigate(id: string | null) {
|
||||
if (id) {
|
||||
goto(`/files/${id}`);
|
||||
} else {
|
||||
goto('/files');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Meine Dateien - Storage</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="files-page">
|
||||
<div class="page-header">
|
||||
<div class="header-left">
|
||||
<h1>Meine Dateien</h1>
|
||||
<Breadcrumb items={breadcrumbItems} onNavigate={handleBreadcrumbNavigate} />
|
||||
</div>
|
||||
|
||||
<div class="header-actions">
|
||||
<div class="view-toggle">
|
||||
<button
|
||||
class="view-btn"
|
||||
class:active={filesStore.viewMode === 'grid'}
|
||||
onclick={() => filesStore.setViewMode('grid')}
|
||||
aria-label="Rasteransicht"
|
||||
>
|
||||
<Grid size={18} />
|
||||
</button>
|
||||
<button
|
||||
class="view-btn"
|
||||
class:active={filesStore.viewMode === 'list'}
|
||||
onclick={() => filesStore.setViewMode('list')}
|
||||
aria-label="Listenansicht"
|
||||
>
|
||||
<List size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button class="action-btn" onclick={() => (showNewFolderModal = true)}>
|
||||
<FolderPlus size={18} />
|
||||
<span>Neuer Ordner</span>
|
||||
</button>
|
||||
|
||||
<button class="action-btn primary" onclick={() => (showUploadZone = !showUploadZone)}>
|
||||
<Upload size={18} />
|
||||
<span>Hochladen</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if showUploadZone}
|
||||
<UploadZone onUpload={handleUpload} {uploading} progress={uploadProgress} />
|
||||
{/if}
|
||||
|
||||
{#if filesStore.loading}
|
||||
<div class="loading-state">
|
||||
<div class="spinner"></div>
|
||||
<p>Laden...</p>
|
||||
</div>
|
||||
{:else if filesStore.error}
|
||||
<div class="error-state">
|
||||
<p>Fehler: {filesStore.error}</p>
|
||||
<button onclick={() => filesStore.loadFolder()}>Erneut versuchen</button>
|
||||
</div>
|
||||
{:else if filesStore.files.length === 0 && filesStore.folders.length === 0}
|
||||
<div class="empty-state">
|
||||
<Upload size={48} />
|
||||
<h2>Noch keine Dateien</h2>
|
||||
<p>Lade deine ersten Dateien hoch oder erstelle einen Ordner.</p>
|
||||
<div class="empty-actions">
|
||||
<button class="action-btn" onclick={() => (showNewFolderModal = true)}>
|
||||
<FolderPlus size={18} />
|
||||
<span>Neuer Ordner</span>
|
||||
</button>
|
||||
<button class="action-btn primary" onclick={() => (showUploadZone = true)}>
|
||||
<Upload size={18} />
|
||||
<span>Hochladen</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{:else if filesStore.viewMode === 'grid'}
|
||||
<FileGrid
|
||||
files={filesStore.files}
|
||||
folders={filesStore.folders}
|
||||
onFileClick={handleFileClick}
|
||||
onFolderClick={handleFolderClick}
|
||||
onFileAction={handleFileAction}
|
||||
onFolderAction={handleFolderAction}
|
||||
/>
|
||||
{:else}
|
||||
<FileList
|
||||
files={filesStore.files}
|
||||
folders={filesStore.folders}
|
||||
onFileClick={handleFileClick}
|
||||
onFolderClick={handleFolderClick}
|
||||
onFileAction={handleFileAction}
|
||||
onFolderAction={handleFolderAction}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<NewFolderModal
|
||||
open={showNewFolderModal}
|
||||
onClose={() => (showNewFolderModal = false)}
|
||||
onCreate={handleCreateFolder}
|
||||
/>
|
||||
|
||||
<style>
|
||||
.files-page {
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.header-left h1 {
|
||||
margin: 0 0 0.5rem 0;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
color: rgb(var(--color-text-primary));
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.view-toggle {
|
||||
display: flex;
|
||||
background: rgb(var(--color-surface));
|
||||
border-radius: var(--radius-md);
|
||||
padding: 0.25rem;
|
||||
}
|
||||
|
||||
.view-btn {
|
||||
padding: 0.5rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
color: rgb(var(--color-text-secondary));
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.view-btn:hover {
|
||||
color: rgb(var(--color-text-primary));
|
||||
}
|
||||
|
||||
.view-btn.active {
|
||||
background: rgb(var(--color-surface-elevated));
|
||||
color: rgb(var(--color-primary));
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 1rem;
|
||||
background: rgb(var(--color-surface-elevated));
|
||||
border: 1px solid rgb(var(--color-border));
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 0.875rem;
|
||||
color: rgb(var(--color-text-primary));
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.action-btn:hover {
|
||||
background: rgb(var(--color-surface));
|
||||
}
|
||||
|
||||
.action-btn.primary {
|
||||
background: rgb(var(--color-primary));
|
||||
border-color: rgb(var(--color-primary));
|
||||
color: white;
|
||||
}
|
||||
|
||||
.action-btn.primary:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.loading-state,
|
||||
.error-state,
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 4rem 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.loading-state .spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 3px solid rgb(var(--color-border));
|
||||
border-top-color: rgb(var(--color-primary));
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.loading-state p,
|
||||
.error-state p {
|
||||
margin-top: 1rem;
|
||||
color: rgb(var(--color-text-secondary));
|
||||
}
|
||||
|
||||
.error-state button {
|
||||
margin-top: 1rem;
|
||||
padding: 0.5rem 1rem;
|
||||
background: rgb(var(--color-primary));
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
color: rgb(var(--color-text-secondary));
|
||||
}
|
||||
|
||||
.empty-state h2 {
|
||||
margin: 1rem 0 0.5rem;
|
||||
font-size: 1.25rem;
|
||||
color: rgb(var(--color-text-primary));
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
margin: 0 0 1.5rem;
|
||||
}
|
||||
|
||||
.empty-actions {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.page-header {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.action-btn span {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
457
apps/storage/apps/web/src/routes/files/[folderId]/+page.svelte
Normal file
457
apps/storage/apps/web/src/routes/files/[folderId]/+page.svelte
Normal file
|
|
@ -0,0 +1,457 @@
|
|||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/stores';
|
||||
import { onMount } from 'svelte';
|
||||
import { Grid, List, FolderPlus, Upload, ArrowLeft } from 'lucide-svelte';
|
||||
import { filesStore } from '$lib/stores/files.svelte';
|
||||
import { toast } from '$lib/stores/toast';
|
||||
import type { StorageFile, StorageFolder } from '$lib/api/client';
|
||||
import FileGrid from '$lib/components/files/FileGrid.svelte';
|
||||
import FileList from '$lib/components/files/FileList.svelte';
|
||||
import Breadcrumb from '$lib/components/files/Breadcrumb.svelte';
|
||||
import UploadZone from '$lib/components/files/UploadZone.svelte';
|
||||
import NewFolderModal from '$lib/components/files/NewFolderModal.svelte';
|
||||
|
||||
let showUploadZone = $state(false);
|
||||
let showNewFolderModal = $state(false);
|
||||
let uploading = $state(false);
|
||||
let uploadProgress = $state(0);
|
||||
|
||||
let folderId = $derived($page.params.folderId);
|
||||
|
||||
// Breadcrumb items from current folder path
|
||||
let breadcrumbItems = $derived(
|
||||
filesStore.currentFolder
|
||||
? [{ id: filesStore.currentFolder.id, name: filesStore.currentFolder.name }]
|
||||
: []
|
||||
);
|
||||
|
||||
$effect(() => {
|
||||
if (folderId) {
|
||||
filesStore.loadFolder(folderId);
|
||||
}
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
filesStore.initViewMode();
|
||||
});
|
||||
|
||||
function handleFolderClick(folder: StorageFolder) {
|
||||
goto(`/files/${folder.id}`);
|
||||
}
|
||||
|
||||
function handleFileClick(file: StorageFile) {
|
||||
console.log('File clicked:', file);
|
||||
}
|
||||
|
||||
async function handleFileAction(action: string, file: StorageFile) {
|
||||
switch (action) {
|
||||
case 'download':
|
||||
await filesStore.downloadFile(file.id, file.name);
|
||||
toast.success('Download gestartet');
|
||||
break;
|
||||
case 'rename':
|
||||
const newName = prompt('Neuer Name:', file.name);
|
||||
if (newName && newName !== file.name) {
|
||||
const result = await filesStore.renameFile(file.id, newName);
|
||||
if (result.error) {
|
||||
toast.error(result.error);
|
||||
} else {
|
||||
toast.success('Datei umbenannt');
|
||||
}
|
||||
}
|
||||
break;
|
||||
case 'favorite':
|
||||
const favResult = await filesStore.toggleFileFavorite(file.id);
|
||||
if (!favResult.error) {
|
||||
toast.success(file.isFavorite ? 'Favorit entfernt' : 'Als Favorit markiert');
|
||||
}
|
||||
break;
|
||||
case 'delete':
|
||||
if (confirm('Datei in den Papierkorb verschieben?')) {
|
||||
const delResult = await filesStore.deleteFile(file.id);
|
||||
if (delResult.error) {
|
||||
toast.error(delResult.error);
|
||||
} else {
|
||||
toast.success('In den Papierkorb verschoben');
|
||||
}
|
||||
}
|
||||
break;
|
||||
case 'share':
|
||||
toast.info('Teilen-Funktion kommt bald');
|
||||
break;
|
||||
case 'move':
|
||||
toast.info('Verschieben-Funktion kommt bald');
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleFolderAction(action: string, folder: StorageFolder) {
|
||||
switch (action) {
|
||||
case 'rename':
|
||||
const newName = prompt('Neuer Name:', folder.name);
|
||||
if (newName && newName !== folder.name) {
|
||||
const result = await filesStore.renameFolder(folder.id, newName);
|
||||
if (result.error) {
|
||||
toast.error(result.error);
|
||||
} else {
|
||||
toast.success('Ordner umbenannt');
|
||||
}
|
||||
}
|
||||
break;
|
||||
case 'favorite':
|
||||
const favResult = await filesStore.toggleFolderFavorite(folder.id);
|
||||
if (!favResult.error) {
|
||||
toast.success(folder.isFavorite ? 'Favorit entfernt' : 'Als Favorit markiert');
|
||||
}
|
||||
break;
|
||||
case 'delete':
|
||||
if (confirm('Ordner und Inhalt in den Papierkorb verschieben?')) {
|
||||
const delResult = await filesStore.deleteFolder(folder.id);
|
||||
if (delResult.error) {
|
||||
toast.error(delResult.error);
|
||||
} else {
|
||||
toast.success('In den Papierkorb verschoben');
|
||||
}
|
||||
}
|
||||
break;
|
||||
case 'share':
|
||||
toast.info('Teilen-Funktion kommt bald');
|
||||
break;
|
||||
case 'move':
|
||||
toast.info('Verschieben-Funktion kommt bald');
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUpload(files: FileList) {
|
||||
uploading = true;
|
||||
uploadProgress = 0;
|
||||
|
||||
const totalFiles = files.length;
|
||||
let completed = 0;
|
||||
|
||||
for (const file of files) {
|
||||
const result = await filesStore.uploadFile(file);
|
||||
if (result.error) {
|
||||
toast.error(`Fehler beim Hochladen von ${file.name}: ${result.error}`);
|
||||
}
|
||||
completed++;
|
||||
uploadProgress = Math.round((completed / totalFiles) * 100);
|
||||
}
|
||||
|
||||
uploading = false;
|
||||
uploadProgress = 0;
|
||||
showUploadZone = false;
|
||||
toast.success(`${totalFiles} Datei(en) hochgeladen`);
|
||||
}
|
||||
|
||||
async function handleCreateFolder(name: string, color?: string) {
|
||||
const result = await filesStore.createFolder(name, color);
|
||||
if (result.error) {
|
||||
toast.error(result.error);
|
||||
} else {
|
||||
toast.success('Ordner erstellt');
|
||||
}
|
||||
}
|
||||
|
||||
function handleBreadcrumbNavigate(id: string | null) {
|
||||
if (id) {
|
||||
goto(`/files/${id}`);
|
||||
} else {
|
||||
goto('/files');
|
||||
}
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
const parentId = filesStore.currentFolder?.parentFolderId;
|
||||
if (parentId) {
|
||||
goto(`/files/${parentId}`);
|
||||
} else {
|
||||
goto('/files');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{filesStore.currentFolder?.name || 'Ordner'} - Storage</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="files-page">
|
||||
<div class="page-header">
|
||||
<div class="header-left">
|
||||
<button class="back-btn" onclick={goBack} aria-label="Zurück">
|
||||
<ArrowLeft size={20} />
|
||||
</button>
|
||||
<div>
|
||||
<h1>{filesStore.currentFolder?.name || 'Ordner'}</h1>
|
||||
<Breadcrumb items={breadcrumbItems} onNavigate={handleBreadcrumbNavigate} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="header-actions">
|
||||
<div class="view-toggle">
|
||||
<button
|
||||
class="view-btn"
|
||||
class:active={filesStore.viewMode === 'grid'}
|
||||
onclick={() => filesStore.setViewMode('grid')}
|
||||
aria-label="Rasteransicht"
|
||||
>
|
||||
<Grid size={18} />
|
||||
</button>
|
||||
<button
|
||||
class="view-btn"
|
||||
class:active={filesStore.viewMode === 'list'}
|
||||
onclick={() => filesStore.setViewMode('list')}
|
||||
aria-label="Listenansicht"
|
||||
>
|
||||
<List size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button class="action-btn" onclick={() => (showNewFolderModal = true)}>
|
||||
<FolderPlus size={18} />
|
||||
<span>Neuer Ordner</span>
|
||||
</button>
|
||||
|
||||
<button class="action-btn primary" onclick={() => (showUploadZone = !showUploadZone)}>
|
||||
<Upload size={18} />
|
||||
<span>Hochladen</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if showUploadZone}
|
||||
<UploadZone onUpload={handleUpload} {uploading} progress={uploadProgress} />
|
||||
{/if}
|
||||
|
||||
{#if filesStore.loading}
|
||||
<div class="loading-state">
|
||||
<div class="spinner"></div>
|
||||
<p>Laden...</p>
|
||||
</div>
|
||||
{:else if filesStore.error}
|
||||
<div class="error-state">
|
||||
<p>Fehler: {filesStore.error}</p>
|
||||
<button onclick={() => filesStore.loadFolder(folderId)}>Erneut versuchen</button>
|
||||
</div>
|
||||
{:else if filesStore.files.length === 0 && filesStore.folders.length === 0}
|
||||
<div class="empty-state">
|
||||
<Upload size={48} />
|
||||
<h2>Leerer Ordner</h2>
|
||||
<p>Dieser Ordner ist leer. Lade Dateien hoch oder erstelle Unterordner.</p>
|
||||
<div class="empty-actions">
|
||||
<button class="action-btn" onclick={() => (showNewFolderModal = true)}>
|
||||
<FolderPlus size={18} />
|
||||
<span>Neuer Ordner</span>
|
||||
</button>
|
||||
<button class="action-btn primary" onclick={() => (showUploadZone = true)}>
|
||||
<Upload size={18} />
|
||||
<span>Hochladen</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{:else if filesStore.viewMode === 'grid'}
|
||||
<FileGrid
|
||||
files={filesStore.files}
|
||||
folders={filesStore.folders}
|
||||
onFileClick={handleFileClick}
|
||||
onFolderClick={handleFolderClick}
|
||||
onFileAction={handleFileAction}
|
||||
onFolderAction={handleFolderAction}
|
||||
/>
|
||||
{:else}
|
||||
<FileList
|
||||
files={filesStore.files}
|
||||
folders={filesStore.folders}
|
||||
onFileClick={handleFileClick}
|
||||
onFolderClick={handleFolderClick}
|
||||
onFileAction={handleFileAction}
|
||||
onFolderAction={handleFolderAction}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<NewFolderModal
|
||||
open={showNewFolderModal}
|
||||
onClose={() => (showNewFolderModal = false)}
|
||||
onCreate={handleCreateFolder}
|
||||
/>
|
||||
|
||||
<style>
|
||||
.files-page {
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
padding: 0.5rem;
|
||||
background: rgb(var(--color-surface-elevated));
|
||||
border: 1px solid rgb(var(--color-border));
|
||||
border-radius: var(--radius-md);
|
||||
color: rgb(var(--color-text-secondary));
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.back-btn:hover {
|
||||
color: rgb(var(--color-text-primary));
|
||||
background: rgb(var(--color-surface));
|
||||
}
|
||||
|
||||
.header-left h1 {
|
||||
margin: 0 0 0.5rem 0;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
color: rgb(var(--color-text-primary));
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.view-toggle {
|
||||
display: flex;
|
||||
background: rgb(var(--color-surface));
|
||||
border-radius: var(--radius-md);
|
||||
padding: 0.25rem;
|
||||
}
|
||||
|
||||
.view-btn {
|
||||
padding: 0.5rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
color: rgb(var(--color-text-secondary));
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.view-btn:hover {
|
||||
color: rgb(var(--color-text-primary));
|
||||
}
|
||||
|
||||
.view-btn.active {
|
||||
background: rgb(var(--color-surface-elevated));
|
||||
color: rgb(var(--color-primary));
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 1rem;
|
||||
background: rgb(var(--color-surface-elevated));
|
||||
border: 1px solid rgb(var(--color-border));
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 0.875rem;
|
||||
color: rgb(var(--color-text-primary));
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.action-btn:hover {
|
||||
background: rgb(var(--color-surface));
|
||||
}
|
||||
|
||||
.action-btn.primary {
|
||||
background: rgb(var(--color-primary));
|
||||
border-color: rgb(var(--color-primary));
|
||||
color: white;
|
||||
}
|
||||
|
||||
.action-btn.primary:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.loading-state,
|
||||
.error-state,
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 4rem 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.loading-state .spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 3px solid rgb(var(--color-border));
|
||||
border-top-color: rgb(var(--color-primary));
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.loading-state p,
|
||||
.error-state p {
|
||||
margin-top: 1rem;
|
||||
color: rgb(var(--color-text-secondary));
|
||||
}
|
||||
|
||||
.error-state button {
|
||||
margin-top: 1rem;
|
||||
padding: 0.5rem 1rem;
|
||||
background: rgb(var(--color-primary));
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
color: rgb(var(--color-text-secondary));
|
||||
}
|
||||
|
||||
.empty-state h2 {
|
||||
margin: 1rem 0 0.5rem;
|
||||
font-size: 1.25rem;
|
||||
color: rgb(var(--color-text-primary));
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
margin: 0 0 1.5rem;
|
||||
}
|
||||
|
||||
.empty-actions {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.page-header {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.action-btn span {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { locale } from 'svelte-i18n';
|
||||
import { ForgotPasswordPage } from '@manacore/shared-auth-ui';
|
||||
import { StorageLogo } from '@manacore/shared-branding';
|
||||
import { getForgotPasswordTranslations } from '@manacore/shared-i18n';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import LanguageSelector from '$lib/components/LanguageSelector.svelte';
|
||||
import '$lib/i18n';
|
||||
|
||||
const translations = $derived(getForgotPasswordTranslations($locale || 'de'));
|
||||
|
||||
async function handleResetPassword(email: string) {
|
||||
return authStore.resetPassword(email);
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{translations.title} - Storage</title>
|
||||
</svelte:head>
|
||||
|
||||
<ForgotPasswordPage
|
||||
appName="Storage"
|
||||
logo={StorageLogo}
|
||||
primaryColor="#3b82f6"
|
||||
onResetPassword={handleResetPassword}
|
||||
{goto}
|
||||
loginPath="/login"
|
||||
lightBackground="#eff6ff"
|
||||
darkBackground="#0f172a"
|
||||
{translations}
|
||||
>
|
||||
{#snippet headerControls()}
|
||||
<LanguageSelector />
|
||||
{/snippet}
|
||||
</ForgotPasswordPage>
|
||||
38
apps/storage/apps/web/src/routes/login/+page.svelte
Normal file
38
apps/storage/apps/web/src/routes/login/+page.svelte
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { locale } from 'svelte-i18n';
|
||||
import { LoginPage } from '@manacore/shared-auth-ui';
|
||||
import { StorageLogo } from '@manacore/shared-branding';
|
||||
import { getLoginTranslations } from '@manacore/shared-i18n';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import LanguageSelector from '$lib/components/LanguageSelector.svelte';
|
||||
import '$lib/i18n';
|
||||
|
||||
const translations = $derived(getLoginTranslations($locale || 'de'));
|
||||
|
||||
async function handleSignIn(email: string, password: string) {
|
||||
return authStore.signIn(email, password);
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{translations.title} - Storage</title>
|
||||
</svelte:head>
|
||||
|
||||
<LoginPage
|
||||
appName="Storage"
|
||||
logo={StorageLogo}
|
||||
primaryColor="#3b82f6"
|
||||
onSignIn={handleSignIn}
|
||||
{goto}
|
||||
successRedirect="/files"
|
||||
registerPath="/register"
|
||||
forgotPasswordPath="/forgot-password"
|
||||
lightBackground="#eff6ff"
|
||||
darkBackground="#0f172a"
|
||||
{translations}
|
||||
>
|
||||
{#snippet headerControls()}
|
||||
<LanguageSelector />
|
||||
{/snippet}
|
||||
</LoginPage>
|
||||
132
apps/storage/apps/web/src/routes/profile/+page.svelte
Normal file
132
apps/storage/apps/web/src/routes/profile/+page.svelte
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
<script lang="ts">
|
||||
import { User } from 'lucide-svelte';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Profil - Storage</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="profile-page">
|
||||
<div class="page-header">
|
||||
<h1>
|
||||
<User size={24} />
|
||||
Profil
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div class="profile-card">
|
||||
<div class="avatar">
|
||||
<User size={48} />
|
||||
</div>
|
||||
<div class="profile-info">
|
||||
<h2>{authStore.user?.email || 'Nicht angemeldet'}</h2>
|
||||
<p>Mitglied seit 2024</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="profile-section">
|
||||
<h3>Kontoinformationen</h3>
|
||||
<div class="info-row">
|
||||
<span class="label">E-Mail</span>
|
||||
<span class="value">{authStore.user?.email || '—'}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="label">Benutzer-ID</span>
|
||||
<span class="value">{authStore.user?.id || '—'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.profile-page {
|
||||
min-height: 100%;
|
||||
max-width: 600px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.page-header h1 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
margin: 0;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
color: rgb(var(--color-text-primary));
|
||||
}
|
||||
|
||||
.profile-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1.5rem;
|
||||
padding: 2rem;
|
||||
background: rgb(var(--color-surface-elevated));
|
||||
border: 1px solid rgb(var(--color-border));
|
||||
border-radius: var(--radius-xl);
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
background: rgb(var(--color-surface));
|
||||
border-radius: 50%;
|
||||
color: rgb(var(--color-text-secondary));
|
||||
}
|
||||
|
||||
.profile-info h2 {
|
||||
margin: 0;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: rgb(var(--color-text-primary));
|
||||
}
|
||||
|
||||
.profile-info p {
|
||||
margin: 0.25rem 0 0;
|
||||
font-size: 0.875rem;
|
||||
color: rgb(var(--color-text-secondary));
|
||||
}
|
||||
|
||||
.profile-section {
|
||||
padding: 1.5rem;
|
||||
background: rgb(var(--color-surface-elevated));
|
||||
border: 1px solid rgb(var(--color-border));
|
||||
border-radius: var(--radius-xl);
|
||||
}
|
||||
|
||||
.profile-section h3 {
|
||||
margin: 0 0 1rem;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: rgb(var(--color-text-primary));
|
||||
}
|
||||
|
||||
.info-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 0.75rem 0;
|
||||
border-bottom: 1px solid rgb(var(--color-border));
|
||||
}
|
||||
|
||||
.info-row:last-child {
|
||||
border-bottom: none;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: 0.875rem;
|
||||
color: rgb(var(--color-text-secondary));
|
||||
}
|
||||
|
||||
.value {
|
||||
font-size: 0.875rem;
|
||||
color: rgb(var(--color-text-primary));
|
||||
font-family: monospace;
|
||||
}
|
||||
</style>
|
||||
37
apps/storage/apps/web/src/routes/register/+page.svelte
Normal file
37
apps/storage/apps/web/src/routes/register/+page.svelte
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { locale } from 'svelte-i18n';
|
||||
import { RegisterPage } from '@manacore/shared-auth-ui';
|
||||
import { StorageLogo } from '@manacore/shared-branding';
|
||||
import { getRegisterTranslations } from '@manacore/shared-i18n';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import LanguageSelector from '$lib/components/LanguageSelector.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} - Storage</title>
|
||||
</svelte:head>
|
||||
|
||||
<RegisterPage
|
||||
appName="Storage"
|
||||
logo={StorageLogo}
|
||||
primaryColor="#3b82f6"
|
||||
onSignUp={handleSignUp}
|
||||
{goto}
|
||||
successRedirect="/files"
|
||||
loginPath="/login"
|
||||
lightBackground="#eff6ff"
|
||||
darkBackground="#0f172a"
|
||||
{translations}
|
||||
>
|
||||
{#snippet headerControls()}
|
||||
<LanguageSelector />
|
||||
{/snippet}
|
||||
</RegisterPage>
|
||||
284
apps/storage/apps/web/src/routes/search/+page.svelte
Normal file
284
apps/storage/apps/web/src/routes/search/+page.svelte
Normal file
|
|
@ -0,0 +1,284 @@
|
|||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/stores';
|
||||
import { onMount } from 'svelte';
|
||||
import { Search, Grid, List } from 'lucide-svelte';
|
||||
import { searchApi, type StorageFile, type StorageFolder } from '$lib/api/client';
|
||||
import { filesStore } from '$lib/stores/files.svelte';
|
||||
import FileGrid from '$lib/components/files/FileGrid.svelte';
|
||||
import FileList from '$lib/components/files/FileList.svelte';
|
||||
|
||||
let query = $state('');
|
||||
let files = $state<StorageFile[]>([]);
|
||||
let folders = $state<StorageFolder[]>([]);
|
||||
let loading = $state(false);
|
||||
let searched = $state(false);
|
||||
|
||||
// Get initial query from URL
|
||||
let initialQuery = $derived($page.url.searchParams.get('q') || '');
|
||||
|
||||
onMount(() => {
|
||||
filesStore.initViewMode();
|
||||
if (initialQuery) {
|
||||
query = initialQuery;
|
||||
handleSearch();
|
||||
}
|
||||
});
|
||||
|
||||
async function handleSearch() {
|
||||
if (!query.trim()) return;
|
||||
|
||||
loading = true;
|
||||
searched = true;
|
||||
|
||||
const result = await searchApi.search(query.trim());
|
||||
if (result.data) {
|
||||
files = result.data.files;
|
||||
folders = result.data.folders;
|
||||
}
|
||||
|
||||
loading = false;
|
||||
|
||||
// Update URL
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set('q', query.trim());
|
||||
window.history.replaceState({}, '', url.toString());
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Enter') {
|
||||
handleSearch();
|
||||
}
|
||||
}
|
||||
|
||||
function handleFolderClick(folder: StorageFolder) {
|
||||
goto(`/files/${folder.id}`);
|
||||
}
|
||||
|
||||
function handleFileClick(file: StorageFile) {
|
||||
console.log('File clicked:', file);
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Suche - Storage</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="search-page">
|
||||
<div class="page-header">
|
||||
<h1>
|
||||
<Search size={24} />
|
||||
Suche
|
||||
</h1>
|
||||
|
||||
<div class="view-toggle">
|
||||
<button
|
||||
class="view-btn"
|
||||
class:active={filesStore.viewMode === 'grid'}
|
||||
onclick={() => filesStore.setViewMode('grid')}
|
||||
aria-label="Rasteransicht"
|
||||
>
|
||||
<Grid size={18} />
|
||||
</button>
|
||||
<button
|
||||
class="view-btn"
|
||||
class:active={filesStore.viewMode === 'list'}
|
||||
onclick={() => filesStore.setViewMode('list')}
|
||||
aria-label="Listenansicht"
|
||||
>
|
||||
<List size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="search-bar">
|
||||
<Search size={20} />
|
||||
<input
|
||||
type="text"
|
||||
bind:value={query}
|
||||
onkeydown={handleKeydown}
|
||||
placeholder="Dateien und Ordner durchsuchen..."
|
||||
autofocus
|
||||
/>
|
||||
<button onclick={handleSearch} disabled={!query.trim() || loading}>
|
||||
{loading ? 'Suche...' : 'Suchen'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if loading}
|
||||
<div class="loading-state">
|
||||
<div class="spinner"></div>
|
||||
<p>Suche läuft...</p>
|
||||
</div>
|
||||
{:else if searched && files.length === 0 && folders.length === 0}
|
||||
<div class="empty-state">
|
||||
<Search size={48} />
|
||||
<h2>Keine Ergebnisse</h2>
|
||||
<p>Keine Dateien oder Ordner für "{query}" gefunden.</p>
|
||||
</div>
|
||||
{:else if searched}
|
||||
<div class="results-header">
|
||||
<span>{files.length + folders.length} Ergebnis(se) für "{query}"</span>
|
||||
</div>
|
||||
|
||||
{#if filesStore.viewMode === 'grid'}
|
||||
<FileGrid {files} {folders} onFileClick={handleFileClick} onFolderClick={handleFolderClick} />
|
||||
{:else}
|
||||
<FileList {files} {folders} onFileClick={handleFileClick} onFolderClick={handleFolderClick} />
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="empty-state">
|
||||
<Search size={48} />
|
||||
<h2>Dateien durchsuchen</h2>
|
||||
<p>Gib einen Suchbegriff ein, um Dateien und Ordner zu finden.</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.search-page {
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.page-header h1 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
margin: 0;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
color: rgb(var(--color-text-primary));
|
||||
}
|
||||
|
||||
.view-toggle {
|
||||
display: flex;
|
||||
background: rgb(var(--color-surface));
|
||||
border-radius: var(--radius-md);
|
||||
padding: 0.25rem;
|
||||
}
|
||||
|
||||
.view-btn {
|
||||
padding: 0.5rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
color: rgb(var(--color-text-secondary));
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.view-btn:hover {
|
||||
color: rgb(var(--color-text-primary));
|
||||
}
|
||||
|
||||
.view-btn.active {
|
||||
background: rgb(var(--color-surface-elevated));
|
||||
color: rgb(var(--color-primary));
|
||||
}
|
||||
|
||||
.search-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 1rem;
|
||||
background: rgb(var(--color-surface-elevated));
|
||||
border: 1px solid rgb(var(--color-border));
|
||||
border-radius: var(--radius-lg);
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.search-bar :global(svg) {
|
||||
color: rgb(var(--color-text-secondary));
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.search-bar input {
|
||||
flex: 1;
|
||||
background: transparent;
|
||||
border: none;
|
||||
font-size: 1rem;
|
||||
color: rgb(var(--color-text-primary));
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.search-bar input::placeholder {
|
||||
color: rgb(var(--color-text-tertiary));
|
||||
}
|
||||
|
||||
.search-bar button {
|
||||
padding: 0.5rem 1rem;
|
||||
background: rgb(var(--color-primary));
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 0.875rem;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
transition: opacity var(--transition-fast);
|
||||
}
|
||||
|
||||
.search-bar button:hover:not(:disabled) {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.search-bar button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.results-header {
|
||||
margin-bottom: 1rem;
|
||||
font-size: 0.875rem;
|
||||
color: rgb(var(--color-text-secondary));
|
||||
}
|
||||
|
||||
.loading-state,
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 4rem 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.loading-state .spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 3px solid rgb(var(--color-border));
|
||||
border-top-color: rgb(var(--color-primary));
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.loading-state p {
|
||||
margin-top: 1rem;
|
||||
color: rgb(var(--color-text-secondary));
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
color: rgb(var(--color-text-secondary));
|
||||
}
|
||||
|
||||
.empty-state h2 {
|
||||
margin: 1rem 0 0.5rem;
|
||||
font-size: 1.25rem;
|
||||
color: rgb(var(--color-text-primary));
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
181
apps/storage/apps/web/src/routes/settings/+page.svelte
Normal file
181
apps/storage/apps/web/src/routes/settings/+page.svelte
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
<script lang="ts">
|
||||
import { Settings } from 'lucide-svelte';
|
||||
import { theme } from '$lib/stores/theme.svelte';
|
||||
import { THEME_DEFINITIONS } from '@manacore/shared-theme';
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Einstellungen - Storage</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="settings-page">
|
||||
<div class="page-header">
|
||||
<h1>
|
||||
<Settings size={24} />
|
||||
Einstellungen
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div class="settings-section">
|
||||
<h2>Darstellung</h2>
|
||||
|
||||
<div class="setting-item">
|
||||
<div class="setting-info">
|
||||
<span class="setting-label">Farbmodus</span>
|
||||
<span class="setting-description">Wähle zwischen Hell, Dunkel oder System</span>
|
||||
</div>
|
||||
<div class="setting-control">
|
||||
<select value={theme.mode} onchange={(e) => theme.setMode((e.target as HTMLSelectElement).value as any)}>
|
||||
<option value="light">Hell</option>
|
||||
<option value="dark">Dunkel</option>
|
||||
<option value="system">System</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-item">
|
||||
<div class="setting-info">
|
||||
<span class="setting-label">Theme</span>
|
||||
<span class="setting-description">Wähle eine Farbpalette</span>
|
||||
</div>
|
||||
<div class="setting-control">
|
||||
<select value={theme.variant} onchange={(e) => theme.setVariant((e.target as HTMLSelectElement).value as any)}>
|
||||
{#each theme.variants as variant}
|
||||
<option value={variant}>{THEME_DEFINITIONS[variant].label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-section">
|
||||
<h2>Speicher</h2>
|
||||
|
||||
<div class="setting-item">
|
||||
<div class="setting-info">
|
||||
<span class="setting-label">Speicherplatz</span>
|
||||
<span class="setting-description">Dein genutzter Speicherplatz</span>
|
||||
</div>
|
||||
<div class="storage-bar">
|
||||
<div class="storage-used" style="width: 25%"></div>
|
||||
</div>
|
||||
<span class="storage-text">2.5 GB von 10 GB verwendet</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-section">
|
||||
<h2>Über</h2>
|
||||
|
||||
<div class="setting-item">
|
||||
<div class="setting-info">
|
||||
<span class="setting-label">Version</span>
|
||||
<span class="setting-description">Storage App v1.0.0</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.settings-page {
|
||||
min-height: 100%;
|
||||
max-width: 600px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.page-header h1 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
margin: 0;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
color: rgb(var(--color-text-primary));
|
||||
}
|
||||
|
||||
.settings-section {
|
||||
margin-bottom: 2rem;
|
||||
padding: 1.5rem;
|
||||
background: rgb(var(--color-surface-elevated));
|
||||
border: 1px solid rgb(var(--color-border));
|
||||
border-radius: var(--radius-xl);
|
||||
}
|
||||
|
||||
.settings-section h2 {
|
||||
margin: 0 0 1.5rem 0;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: rgb(var(--color-text-primary));
|
||||
}
|
||||
|
||||
.setting-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
padding: 1rem 0;
|
||||
border-bottom: 1px solid rgb(var(--color-border));
|
||||
}
|
||||
|
||||
.setting-item:last-child {
|
||||
border-bottom: none;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.setting-item:first-of-type {
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.setting-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.setting-label {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: rgb(var(--color-text-primary));
|
||||
}
|
||||
|
||||
.setting-description {
|
||||
font-size: 0.75rem;
|
||||
color: rgb(var(--color-text-secondary));
|
||||
}
|
||||
|
||||
.setting-control select {
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: rgb(var(--color-surface));
|
||||
border: 1px solid rgb(var(--color-border));
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 0.875rem;
|
||||
color: rgb(var(--color-text-primary));
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.setting-control select:focus {
|
||||
outline: none;
|
||||
border-color: rgb(var(--color-primary));
|
||||
}
|
||||
|
||||
.storage-bar {
|
||||
width: 100%;
|
||||
height: 8px;
|
||||
background: rgb(var(--color-surface));
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.storage-used {
|
||||
height: 100%;
|
||||
background: rgb(var(--color-primary));
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.storage-text {
|
||||
font-size: 0.75rem;
|
||||
color: rgb(var(--color-text-secondary));
|
||||
}
|
||||
</style>
|
||||
336
apps/storage/apps/web/src/routes/shared/+page.svelte
Normal file
336
apps/storage/apps/web/src/routes/shared/+page.svelte
Normal file
|
|
@ -0,0 +1,336 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { Share2, Link, Copy, Trash2 } from 'lucide-svelte';
|
||||
import { sharesApi, type Share } from '$lib/api/client';
|
||||
import { toast } from '$lib/stores/toast';
|
||||
|
||||
let shares = $state<Share[]>([]);
|
||||
let loading = $state(true);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
onMount(async () => {
|
||||
await loadShares();
|
||||
});
|
||||
|
||||
async function loadShares() {
|
||||
loading = true;
|
||||
error = null;
|
||||
|
||||
const result = await sharesApi.list();
|
||||
if (result.error) {
|
||||
error = result.error;
|
||||
} else if (result.data) {
|
||||
shares = result.data;
|
||||
}
|
||||
|
||||
loading = false;
|
||||
}
|
||||
|
||||
async function copyShareLink(token: string) {
|
||||
const url = `${window.location.origin}/s/${token}`;
|
||||
await navigator.clipboard.writeText(url);
|
||||
toast.success('Link kopiert!');
|
||||
}
|
||||
|
||||
async function deleteShare(id: string) {
|
||||
if (!confirm('Share-Link wirklich löschen?')) return;
|
||||
|
||||
const result = await sharesApi.delete(id);
|
||||
if (result.error) {
|
||||
toast.error(result.error);
|
||||
} else {
|
||||
shares = shares.filter((s) => s.id !== id);
|
||||
toast.success('Share-Link gelöscht');
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string | null): string {
|
||||
if (!dateStr) return 'Kein Ablaufdatum';
|
||||
return new Date(dateStr).toLocaleDateString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
});
|
||||
}
|
||||
|
||||
function getAccessLevelLabel(level: string): string {
|
||||
switch (level) {
|
||||
case 'view':
|
||||
return 'Ansehen';
|
||||
case 'download':
|
||||
return 'Herunterladen';
|
||||
case 'edit':
|
||||
return 'Bearbeiten';
|
||||
default:
|
||||
return level;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Geteilt - Storage</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="shared-page">
|
||||
<div class="page-header">
|
||||
<h1>
|
||||
<Share2 size={24} />
|
||||
Geteilte Links
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{#if loading}
|
||||
<div class="loading-state">
|
||||
<div class="spinner"></div>
|
||||
<p>Laden...</p>
|
||||
</div>
|
||||
{:else if error}
|
||||
<div class="error-state">
|
||||
<p>Fehler: {error}</p>
|
||||
<button onclick={loadShares}>Erneut versuchen</button>
|
||||
</div>
|
||||
{:else if shares.length === 0}
|
||||
<div class="empty-state">
|
||||
<Share2 size={48} />
|
||||
<h2>Keine geteilten Links</h2>
|
||||
<p>Teile Dateien oder Ordner, um Links hier zu verwalten.</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="shares-list">
|
||||
{#each shares as share (share.id)}
|
||||
<div class="share-item">
|
||||
<div class="share-info">
|
||||
<div class="share-icon">
|
||||
<Link size={20} />
|
||||
</div>
|
||||
<div class="share-details">
|
||||
<span class="share-type">
|
||||
{share.shareType === 'file' ? 'Datei' : 'Ordner'}
|
||||
</span>
|
||||
<div class="share-meta">
|
||||
<span class="badge">{getAccessLevelLabel(share.accessLevel)}</span>
|
||||
{#if share.password}
|
||||
<span class="badge secure">Passwortgeschützt</span>
|
||||
{/if}
|
||||
{#if share.maxDownloads}
|
||||
<span class="badge">{share.downloadCount}/{share.maxDownloads} Downloads</span>
|
||||
{/if}
|
||||
</div>
|
||||
<span class="share-expires">
|
||||
{share.expiresAt ? `Läuft ab: ${formatDate(share.expiresAt)}` : 'Kein Ablaufdatum'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="share-actions">
|
||||
<button class="copy-btn" onclick={() => copyShareLink(share.shareToken)}>
|
||||
<Copy size={16} />
|
||||
Link kopieren
|
||||
</button>
|
||||
<button class="delete-btn" onclick={() => deleteShare(share.id)}>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.shared-page {
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.page-header h1 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
margin: 0;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
color: rgb(var(--color-text-primary));
|
||||
}
|
||||
|
||||
.loading-state,
|
||||
.error-state,
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 4rem 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.loading-state .spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 3px solid rgb(var(--color-border));
|
||||
border-top-color: rgb(var(--color-primary));
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.loading-state p,
|
||||
.error-state p {
|
||||
margin-top: 1rem;
|
||||
color: rgb(var(--color-text-secondary));
|
||||
}
|
||||
|
||||
.error-state button {
|
||||
margin-top: 1rem;
|
||||
padding: 0.5rem 1rem;
|
||||
background: rgb(var(--color-primary));
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
color: rgb(var(--color-text-secondary));
|
||||
}
|
||||
|
||||
.empty-state h2 {
|
||||
margin: 1rem 0 0.5rem;
|
||||
font-size: 1.25rem;
|
||||
color: rgb(var(--color-text-primary));
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.shares-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.share-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1rem;
|
||||
background: rgb(var(--color-surface-elevated));
|
||||
border: 1px solid rgb(var(--color-border));
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
|
||||
.share-info {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.share-icon {
|
||||
padding: 0.5rem;
|
||||
background: rgb(var(--color-surface));
|
||||
border-radius: var(--radius-md);
|
||||
color: rgb(var(--color-primary));
|
||||
}
|
||||
|
||||
.share-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.share-type {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: rgb(var(--color-text-primary));
|
||||
}
|
||||
|
||||
.share-meta {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.badge {
|
||||
padding: 0.125rem 0.5rem;
|
||||
background: rgb(var(--color-surface));
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 0.75rem;
|
||||
color: rgb(var(--color-text-secondary));
|
||||
}
|
||||
|
||||
.badge.secure {
|
||||
background: rgba(var(--color-success), 0.1);
|
||||
color: rgb(var(--color-success));
|
||||
}
|
||||
|
||||
.share-expires {
|
||||
font-size: 0.75rem;
|
||||
color: rgb(var(--color-text-tertiary));
|
||||
}
|
||||
|
||||
.share-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.copy-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: rgb(var(--color-primary));
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 0.75rem;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
transition: opacity var(--transition-fast);
|
||||
}
|
||||
|
||||
.copy-btn:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.delete-btn {
|
||||
padding: 0.5rem;
|
||||
background: transparent;
|
||||
border: 1px solid rgb(var(--color-border));
|
||||
border-radius: var(--radius-md);
|
||||
color: rgb(var(--color-error));
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.delete-btn:hover {
|
||||
background: rgba(var(--color-error), 0.1);
|
||||
border-color: rgb(var(--color-error));
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.share-item {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.share-actions {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.copy-btn {
|
||||
flex: 1;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
131
apps/storage/apps/web/src/routes/themes/+page.svelte
Normal file
131
apps/storage/apps/web/src/routes/themes/+page.svelte
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
<script lang="ts">
|
||||
import { Palette, Check } from 'lucide-svelte';
|
||||
import { theme } from '$lib/stores/theme.svelte';
|
||||
import { THEME_DEFINITIONS, ALL_THEME_VARIANTS, type ThemeVariant } from '@manacore/shared-theme';
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Themes - Storage</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="themes-page">
|
||||
<div class="page-header">
|
||||
<h1>
|
||||
<Palette size={24} />
|
||||
Themes
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div class="themes-grid">
|
||||
{#each ALL_THEME_VARIANTS as variant}
|
||||
{@const def = THEME_DEFINITIONS[variant]}
|
||||
<button
|
||||
class="theme-card"
|
||||
class:active={theme.variant === variant}
|
||||
onclick={() => theme.setVariant(variant)}
|
||||
>
|
||||
<div class="theme-preview" style="background: linear-gradient(135deg, {def.colors.primary}, {def.colors.accent})">
|
||||
{#if theme.variant === variant}
|
||||
<div class="check-badge">
|
||||
<Check size={16} />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="theme-info">
|
||||
<span class="theme-name">{def.label}</span>
|
||||
<span class="theme-icon">{def.icon}</span>
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.themes-page {
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.page-header h1 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
margin: 0;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
color: rgb(var(--color-text-primary));
|
||||
}
|
||||
|
||||
.themes-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.theme-card {
|
||||
position: relative;
|
||||
padding: 0;
|
||||
background: rgb(var(--color-surface-elevated));
|
||||
border: 2px solid rgb(var(--color-border));
|
||||
border-radius: var(--radius-xl);
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.theme-card:hover {
|
||||
border-color: rgb(var(--color-primary));
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.theme-card.active {
|
||||
border-color: rgb(var(--color-primary));
|
||||
}
|
||||
|
||||
.theme-preview {
|
||||
position: relative;
|
||||
height: 100px;
|
||||
}
|
||||
|
||||
.check-badge {
|
||||
position: absolute;
|
||||
top: 0.5rem;
|
||||
right: 0.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
background: white;
|
||||
border-radius: 50%;
|
||||
color: rgb(var(--color-primary));
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.theme-info {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.theme-name {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: rgb(var(--color-text-primary));
|
||||
}
|
||||
|
||||
.theme-icon {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.themes-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
361
apps/storage/apps/web/src/routes/trash/+page.svelte
Normal file
361
apps/storage/apps/web/src/routes/trash/+page.svelte
Normal file
|
|
@ -0,0 +1,361 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { Trash2, RotateCcw, AlertTriangle } from 'lucide-svelte';
|
||||
import { trashApi, type StorageFile, type StorageFolder } from '$lib/api/client';
|
||||
import { toast } from '$lib/stores/toast';
|
||||
|
||||
let files = $state<StorageFile[]>([]);
|
||||
let folders = $state<StorageFolder[]>([]);
|
||||
let loading = $state(true);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
onMount(async () => {
|
||||
await loadTrash();
|
||||
});
|
||||
|
||||
async function loadTrash() {
|
||||
loading = true;
|
||||
error = null;
|
||||
|
||||
const result = await trashApi.list();
|
||||
if (result.error) {
|
||||
error = result.error;
|
||||
} else if (result.data) {
|
||||
files = result.data.files;
|
||||
folders = result.data.folders;
|
||||
}
|
||||
|
||||
loading = false;
|
||||
}
|
||||
|
||||
async function handleRestore(id: string, type: 'file' | 'folder') {
|
||||
const result = await trashApi.restore(id, type);
|
||||
if (result.error) {
|
||||
toast.error(result.error);
|
||||
} else {
|
||||
if (type === 'file') {
|
||||
files = files.filter((f) => f.id !== id);
|
||||
} else {
|
||||
folders = folders.filter((f) => f.id !== id);
|
||||
}
|
||||
toast.success('Wiederhergestellt');
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePermanentDelete(id: string, type: 'file' | 'folder') {
|
||||
if (!confirm('Endgültig löschen? Dies kann nicht rückgängig gemacht werden.')) return;
|
||||
|
||||
const result = await trashApi.permanentDelete(id, type);
|
||||
if (result.error) {
|
||||
toast.error(result.error);
|
||||
} else {
|
||||
if (type === 'file') {
|
||||
files = files.filter((f) => f.id !== id);
|
||||
} else {
|
||||
folders = folders.filter((f) => f.id !== id);
|
||||
}
|
||||
toast.success('Endgültig gelöscht');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleEmptyTrash() {
|
||||
if (!confirm('Papierkorb leeren? Alle Elemente werden endgültig gelöscht.')) return;
|
||||
|
||||
const result = await trashApi.empty();
|
||||
if (result.error) {
|
||||
toast.error(result.error);
|
||||
} else {
|
||||
files = [];
|
||||
folders = [];
|
||||
toast.success('Papierkorb geleert');
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string | null): string {
|
||||
if (!dateStr) return '—';
|
||||
return new Date(dateStr).toLocaleDateString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Papierkorb - Storage</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="trash-page">
|
||||
<div class="page-header">
|
||||
<h1>
|
||||
<Trash2 size={24} />
|
||||
Papierkorb
|
||||
</h1>
|
||||
|
||||
{#if files.length > 0 || folders.length > 0}
|
||||
<button class="empty-btn" onclick={handleEmptyTrash}>
|
||||
<AlertTriangle size={16} />
|
||||
Papierkorb leeren
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if loading}
|
||||
<div class="loading-state">
|
||||
<div class="spinner"></div>
|
||||
<p>Laden...</p>
|
||||
</div>
|
||||
{:else if error}
|
||||
<div class="error-state">
|
||||
<p>Fehler: {error}</p>
|
||||
<button onclick={loadTrash}>Erneut versuchen</button>
|
||||
</div>
|
||||
{:else if files.length === 0 && folders.length === 0}
|
||||
<div class="empty-state">
|
||||
<Trash2 size={48} />
|
||||
<h2>Papierkorb ist leer</h2>
|
||||
<p>Gelöschte Dateien und Ordner erscheinen hier.</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="trash-list">
|
||||
{#each folders as folder (folder.id)}
|
||||
<div class="trash-item">
|
||||
<div class="item-info">
|
||||
<span class="item-icon folder">📁</span>
|
||||
<div class="item-details">
|
||||
<span class="item-name">{folder.name}</span>
|
||||
<span class="item-meta">Gelöscht am {formatDate(folder.deletedAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="item-actions">
|
||||
<button class="restore-btn" onclick={() => handleRestore(folder.id, 'folder')}>
|
||||
<RotateCcw size={16} />
|
||||
Wiederherstellen
|
||||
</button>
|
||||
<button
|
||||
class="delete-btn"
|
||||
onclick={() => handlePermanentDelete(folder.id, 'folder')}
|
||||
>
|
||||
Endgültig löschen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
{#each files as file (file.id)}
|
||||
<div class="trash-item">
|
||||
<div class="item-info">
|
||||
<span class="item-icon file">📄</span>
|
||||
<div class="item-details">
|
||||
<span class="item-name">{file.name}</span>
|
||||
<span class="item-meta">Gelöscht am {formatDate(file.deletedAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="item-actions">
|
||||
<button class="restore-btn" onclick={() => handleRestore(file.id, 'file')}>
|
||||
<RotateCcw size={16} />
|
||||
Wiederherstellen
|
||||
</button>
|
||||
<button class="delete-btn" onclick={() => handlePermanentDelete(file.id, 'file')}>
|
||||
Endgültig löschen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.trash-page {
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.page-header h1 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
margin: 0;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
color: rgb(var(--color-text-primary));
|
||||
}
|
||||
|
||||
.empty-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 1rem;
|
||||
background: rgb(var(--color-error));
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 0.875rem;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
transition: opacity var(--transition-fast);
|
||||
}
|
||||
|
||||
.empty-btn:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.loading-state,
|
||||
.error-state,
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 4rem 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.loading-state .spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 3px solid rgb(var(--color-border));
|
||||
border-top-color: rgb(var(--color-primary));
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.loading-state p,
|
||||
.error-state p {
|
||||
margin-top: 1rem;
|
||||
color: rgb(var(--color-text-secondary));
|
||||
}
|
||||
|
||||
.error-state button {
|
||||
margin-top: 1rem;
|
||||
padding: 0.5rem 1rem;
|
||||
background: rgb(var(--color-primary));
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
color: rgb(var(--color-text-secondary));
|
||||
}
|
||||
|
||||
.empty-state h2 {
|
||||
margin: 1rem 0 0.5rem;
|
||||
font-size: 1.25rem;
|
||||
color: rgb(var(--color-text-primary));
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.trash-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.trash-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1rem;
|
||||
background: rgb(var(--color-surface-elevated));
|
||||
border: 1px solid rgb(var(--color-border));
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
|
||||
.item-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.item-icon {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.item-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.item-name {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: rgb(var(--color-text-primary));
|
||||
}
|
||||
|
||||
.item-meta {
|
||||
font-size: 0.75rem;
|
||||
color: rgb(var(--color-text-secondary));
|
||||
}
|
||||
|
||||
.item-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.restore-btn,
|
||||
.delete-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.375rem 0.75rem;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.restore-btn {
|
||||
background: rgb(var(--color-surface));
|
||||
border: 1px solid rgb(var(--color-border));
|
||||
color: rgb(var(--color-text-primary));
|
||||
}
|
||||
|
||||
.restore-btn:hover {
|
||||
border-color: rgb(var(--color-primary));
|
||||
color: rgb(var(--color-primary));
|
||||
}
|
||||
|
||||
.delete-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: rgb(var(--color-error));
|
||||
}
|
||||
|
||||
.delete-btn:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.trash-item {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.item-actions {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.restore-btn {
|
||||
flex: 1;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
12
apps/storage/apps/web/svelte.config.js
Normal file
12
apps/storage/apps/web/svelte.config.js
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import adapter from '@sveltejs/adapter-auto';
|
||||
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
|
||||
|
||||
/** @type {import('@sveltejs/kit').Config} */
|
||||
const config = {
|
||||
preprocess: vitePreprocess(),
|
||||
kit: {
|
||||
adapter: adapter(),
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
14
apps/storage/apps/web/tsconfig.json
Normal file
14
apps/storage/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"
|
||||
}
|
||||
}
|
||||
44
apps/storage/apps/web/vite.config.ts
Normal file
44
apps/storage/apps/web/vite.config.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import { sveltekit } from '@sveltejs/kit/vite';
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [tailwindcss(), sveltekit()],
|
||||
server: {
|
||||
port: 5185,
|
||||
strictPort: true,
|
||||
},
|
||||
ssr: {
|
||||
noExternal: [
|
||||
'@manacore/shared-icons',
|
||||
'@manacore/shared-ui',
|
||||
'@manacore/shared-tailwind',
|
||||
'@manacore/shared-theme',
|
||||
'@manacore/shared-theme-ui',
|
||||
'@manacore/shared-feedback-ui',
|
||||
'@manacore/shared-feedback-service',
|
||||
'@manacore/shared-feedback-types',
|
||||
'@manacore/shared-auth',
|
||||
'@manacore/shared-auth-ui',
|
||||
'@manacore/shared-branding',
|
||||
'@manacore/shared-subscription-ui',
|
||||
'lucide-svelte',
|
||||
],
|
||||
},
|
||||
optimizeDeps: {
|
||||
exclude: [
|
||||
'@manacore/shared-icons',
|
||||
'@manacore/shared-ui',
|
||||
'@manacore/shared-tailwind',
|
||||
'@manacore/shared-theme',
|
||||
'@manacore/shared-theme-ui',
|
||||
'@manacore/shared-feedback-ui',
|
||||
'@manacore/shared-feedback-service',
|
||||
'@manacore/shared-feedback-types',
|
||||
'@manacore/shared-auth',
|
||||
'@manacore/shared-auth-ui',
|
||||
'@manacore/shared-branding',
|
||||
'@manacore/shared-subscription-ui',
|
||||
],
|
||||
},
|
||||
});
|
||||
9
apps/storage/package.json
Normal file
9
apps/storage/package.json
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"name": "storage",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"description": "Cloud storage application (like Google Drive/Dropbox)",
|
||||
"scripts": {
|
||||
"dev": "turbo run dev"
|
||||
}
|
||||
}
|
||||
17
apps/storage/packages/shared/package.json
Normal file
17
apps/storage/packages/shared/package.json
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
{
|
||||
"name": "@storage/shared",
|
||||
"version": "0.1.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.7.3"
|
||||
}
|
||||
}
|
||||
|
|
@ -73,6 +73,14 @@
|
|||
"calendar:db:push": "pnpm --filter @calendar/backend db:push",
|
||||
"calendar:db:studio": "pnpm --filter @calendar/backend db:studio",
|
||||
"calendar:db:seed": "pnpm --filter @calendar/backend db:seed",
|
||||
"storage:dev": "turbo run dev --filter=storage...",
|
||||
"dev:storage:web": "pnpm --filter @storage/web dev",
|
||||
"dev:storage:landing": "pnpm --filter @storage/landing dev",
|
||||
"dev:storage:backend": "pnpm --filter @storage/backend dev",
|
||||
"dev:storage:app": "turbo run dev --filter=@storage/web --filter=@storage/backend",
|
||||
"storage:db:push": "pnpm --filter @storage/backend db:push",
|
||||
"storage:db:studio": "pnpm --filter @storage/backend db:studio",
|
||||
"storage:db:seed": "pnpm --filter @storage/backend db:seed",
|
||||
"voxel-lava:dev": "turbo run dev --filter=@voxel-lava/web --filter=@voxel-lava/backend",
|
||||
"dev:voxel-lava:web": "pnpm --filter @voxel-lava/web dev",
|
||||
"dev:voxel-lava:backend": "pnpm --filter @voxel-lava/backend start:dev",
|
||||
|
|
|
|||
414
pnpm-lock.yaml
generated
414
pnpm-lock.yaml
generated
|
|
@ -149,14 +149,14 @@ importers:
|
|||
version: link:../../../../packages/shared-landing-ui
|
||||
astro:
|
||||
specifier: ^5.16.0
|
||||
version: 5.16.0(@netlify/blobs@10.4.1)(@types/node@20.19.25)(ioredis@5.8.2)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.53.3)(terser@5.44.1)(tsx@4.20.6)(typescript@5.9.3)(yaml@2.8.1)
|
||||
version: 5.16.0(@netlify/blobs@10.4.1)(@types/node@20.19.25)(ioredis@5.8.2)(jiti@1.21.7)(lightningcss@1.30.2)(rollup@4.53.3)(terser@5.44.1)(tsx@4.20.6)(typescript@5.9.3)(yaml@2.8.1)
|
||||
typescript:
|
||||
specifier: ^5.9.2
|
||||
version: 5.9.3
|
||||
devDependencies:
|
||||
'@astrojs/tailwind':
|
||||
specifier: ^6.0.2
|
||||
version: 6.0.2(astro@5.16.0(@netlify/blobs@10.4.1)(@types/node@20.19.25)(ioredis@5.8.2)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.53.3)(terser@5.44.1)(tsx@4.20.6)(typescript@5.9.3)(yaml@2.8.1))(tailwindcss@3.4.18(tsx@4.20.6)(yaml@2.8.1))(ts-node@10.9.2(@types/node@20.19.25)(typescript@5.9.3))
|
||||
version: 6.0.2(astro@5.16.0(@netlify/blobs@10.4.1)(@types/node@20.19.25)(ioredis@5.8.2)(jiti@1.21.7)(lightningcss@1.30.2)(rollup@4.53.3)(terser@5.44.1)(tsx@4.20.6)(typescript@5.9.3)(yaml@2.8.1))(tailwindcss@3.4.18(tsx@4.20.6)(yaml@2.8.1))(ts-node@10.9.2(@types/node@20.19.25)(typescript@5.9.3))
|
||||
'@tailwindcss/typography':
|
||||
specifier: ^0.5.18
|
||||
version: 0.5.19(tailwindcss@3.4.18(tsx@4.20.6)(yaml@2.8.1))
|
||||
|
|
@ -165,13 +165,13 @@ importers:
|
|||
version: 20.19.25
|
||||
eslint:
|
||||
specifier: ^9.0.0
|
||||
version: 9.39.1(jiti@2.6.1)
|
||||
version: 9.39.1(jiti@1.21.7)
|
||||
eslint-config-prettier:
|
||||
specifier: ^9.1.0
|
||||
version: 9.1.2(eslint@9.39.1(jiti@2.6.1))
|
||||
version: 9.1.2(eslint@9.39.1(jiti@1.21.7))
|
||||
eslint-plugin-astro:
|
||||
specifier: ^1.0.0
|
||||
version: 1.5.0(eslint@9.39.1(jiti@2.6.1))
|
||||
version: 1.5.0(eslint@9.39.1(jiti@1.21.7))
|
||||
prettier:
|
||||
specifier: ^3.6.2
|
||||
version: 3.6.2
|
||||
|
|
@ -2338,6 +2338,199 @@ importers:
|
|||
specifier: ^5.7.2
|
||||
version: 5.9.3
|
||||
|
||||
apps/storage: {}
|
||||
|
||||
apps/storage/apps/backend:
|
||||
dependencies:
|
||||
'@manacore/shared-nestjs-auth':
|
||||
specifier: workspace:*
|
||||
version: link:../../../../packages/shared-nestjs-auth
|
||||
'@manacore/shared-storage':
|
||||
specifier: workspace:*
|
||||
version: link:../../../../packages/shared-storage
|
||||
'@nestjs/common':
|
||||
specifier: ^10.4.15
|
||||
version: 10.4.20(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
'@nestjs/config':
|
||||
specifier: ^3.3.0
|
||||
version: 3.3.0(@nestjs/common@10.4.20(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(rxjs@7.8.2)
|
||||
'@nestjs/core':
|
||||
specifier: ^10.4.15
|
||||
version: 10.4.20(@nestjs/common@10.4.20(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@10.4.20)(@nestjs/websockets@10.4.20)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
'@nestjs/platform-express':
|
||||
specifier: ^10.4.15
|
||||
version: 10.4.20(@nestjs/common@10.4.20(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.20)
|
||||
class-transformer:
|
||||
specifier: ^0.5.1
|
||||
version: 0.5.1
|
||||
class-validator:
|
||||
specifier: ^0.14.1
|
||||
version: 0.14.3
|
||||
dotenv:
|
||||
specifier: ^16.4.7
|
||||
version: 16.6.1
|
||||
drizzle-kit:
|
||||
specifier: ^0.30.2
|
||||
version: 0.30.6
|
||||
drizzle-orm:
|
||||
specifier: ^0.38.3
|
||||
version: 0.38.4(@opentelemetry/api@1.9.0)(@types/react@19.2.7)(expo-sqlite@15.2.14(expo@54.0.25)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(kysely@0.28.8)(postgres@3.4.7)(react@19.1.0)
|
||||
postgres:
|
||||
specifier: ^3.4.5
|
||||
version: 3.4.7
|
||||
reflect-metadata:
|
||||
specifier: ^0.2.2
|
||||
version: 0.2.2
|
||||
rxjs:
|
||||
specifier: ^7.8.1
|
||||
version: 7.8.2
|
||||
devDependencies:
|
||||
'@nestjs/cli':
|
||||
specifier: ^10.4.9
|
||||
version: 10.4.9(esbuild@0.27.0)
|
||||
'@nestjs/schematics':
|
||||
specifier: ^10.2.3
|
||||
version: 10.2.3(chokidar@3.6.0)(typescript@5.9.3)
|
||||
'@types/express':
|
||||
specifier: ^5.0.0
|
||||
version: 5.0.5
|
||||
'@types/multer':
|
||||
specifier: ^1.4.12
|
||||
version: 1.4.13
|
||||
'@types/node':
|
||||
specifier: ^22.10.2
|
||||
version: 22.19.1
|
||||
'@typescript-eslint/eslint-plugin':
|
||||
specifier: ^8.18.1
|
||||
version: 8.48.0(@typescript-eslint/parser@8.48.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3)
|
||||
'@typescript-eslint/parser':
|
||||
specifier: ^8.18.1
|
||||
version: 8.48.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3)
|
||||
eslint:
|
||||
specifier: ^9.17.0
|
||||
version: 9.39.1(jiti@2.6.1)
|
||||
eslint-config-prettier:
|
||||
specifier: ^9.1.0
|
||||
version: 9.1.2(eslint@9.39.1(jiti@2.6.1))
|
||||
eslint-plugin-prettier:
|
||||
specifier: ^5.2.1
|
||||
version: 5.5.4(@types/eslint@9.6.1)(eslint-config-prettier@9.1.2(eslint@9.39.1(jiti@2.6.1)))(eslint@9.39.1(jiti@2.6.1))(prettier@3.6.2)
|
||||
prettier:
|
||||
specifier: ^3.4.2
|
||||
version: 3.6.2
|
||||
source-map-support:
|
||||
specifier: ^0.5.21
|
||||
version: 0.5.21
|
||||
ts-loader:
|
||||
specifier: ^9.5.1
|
||||
version: 9.5.4(typescript@5.9.3)(webpack@5.100.2(esbuild@0.27.0))
|
||||
ts-node:
|
||||
specifier: ^10.9.2
|
||||
version: 10.9.2(@types/node@22.19.1)(typescript@5.9.3)
|
||||
tsconfig-paths:
|
||||
specifier: ^4.2.0
|
||||
version: 4.2.0
|
||||
tsx:
|
||||
specifier: ^4.19.2
|
||||
version: 4.20.6
|
||||
typescript:
|
||||
specifier: ^5.7.2
|
||||
version: 5.9.3
|
||||
|
||||
apps/storage/apps/web:
|
||||
dependencies:
|
||||
'@manacore/shared-auth':
|
||||
specifier: workspace:*
|
||||
version: link:../../../../packages/shared-auth
|
||||
'@manacore/shared-auth-ui':
|
||||
specifier: workspace:*
|
||||
version: link:../../../../packages/shared-auth-ui
|
||||
'@manacore/shared-branding':
|
||||
specifier: workspace:*
|
||||
version: link:../../../../packages/shared-branding
|
||||
'@manacore/shared-feedback-service':
|
||||
specifier: workspace:*
|
||||
version: link:../../../../packages/shared-feedback-service
|
||||
'@manacore/shared-feedback-ui':
|
||||
specifier: workspace:*
|
||||
version: link:../../../../packages/shared-feedback-ui
|
||||
'@manacore/shared-i18n':
|
||||
specifier: workspace:*
|
||||
version: link:../../../../packages/shared-i18n
|
||||
'@manacore/shared-icons':
|
||||
specifier: workspace:*
|
||||
version: link:../../../../packages/shared-icons
|
||||
'@manacore/shared-profile-ui':
|
||||
specifier: workspace:*
|
||||
version: link:../../../../packages/shared-profile-ui
|
||||
'@manacore/shared-subscription-ui':
|
||||
specifier: workspace:*
|
||||
version: link:../../../../packages/shared-subscription-ui
|
||||
'@manacore/shared-tailwind':
|
||||
specifier: workspace:*
|
||||
version: link:../../../../packages/shared-tailwind
|
||||
'@manacore/shared-theme':
|
||||
specifier: workspace:*
|
||||
version: link:../../../../packages/shared-theme
|
||||
'@manacore/shared-theme-ui':
|
||||
specifier: workspace:*
|
||||
version: link:../../../../packages/shared-theme-ui
|
||||
'@manacore/shared-ui':
|
||||
specifier: workspace:*
|
||||
version: link:../../../../packages/shared-ui
|
||||
lucide-svelte:
|
||||
specifier: ^0.469.0
|
||||
version: 0.469.0(svelte@5.44.0)
|
||||
svelte-i18n:
|
||||
specifier: ^4.0.1
|
||||
version: 4.0.1(svelte@5.44.0)
|
||||
devDependencies:
|
||||
'@sveltejs/adapter-auto':
|
||||
specifier: ^3.0.0
|
||||
version: 3.3.1(@sveltejs/kit@2.49.0(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.44.0)(vite@6.4.1(@types/node@20.19.25)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1)))(svelte@5.44.0)(vite@6.4.1(@types/node@20.19.25)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1)))
|
||||
'@sveltejs/kit':
|
||||
specifier: ^2.0.0
|
||||
version: 2.49.0(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.44.0)(vite@6.4.1(@types/node@20.19.25)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1)))(svelte@5.44.0)(vite@6.4.1(@types/node@20.19.25)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1))
|
||||
'@sveltejs/vite-plugin-svelte':
|
||||
specifier: ^5.0.0
|
||||
version: 5.1.1(svelte@5.44.0)(vite@6.4.1(@types/node@20.19.25)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1))
|
||||
'@tailwindcss/vite':
|
||||
specifier: ^4.1.7
|
||||
version: 4.1.17(vite@6.4.1(@types/node@20.19.25)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1))
|
||||
'@types/node':
|
||||
specifier: ^20.0.0
|
||||
version: 20.19.25
|
||||
prettier:
|
||||
specifier: ^3.1.1
|
||||
version: 3.6.2
|
||||
prettier-plugin-svelte:
|
||||
specifier: ^3.1.2
|
||||
version: 3.4.0(prettier@3.6.2)(svelte@5.44.0)
|
||||
svelte:
|
||||
specifier: ^5.0.0
|
||||
version: 5.44.0
|
||||
svelte-check:
|
||||
specifier: ^4.0.0
|
||||
version: 4.3.4(picomatch@4.0.3)(svelte@5.44.0)(typescript@5.9.3)
|
||||
tailwindcss:
|
||||
specifier: ^4.1.7
|
||||
version: 4.1.17
|
||||
tslib:
|
||||
specifier: ^2.4.1
|
||||
version: 2.8.1
|
||||
typescript:
|
||||
specifier: ^5.0.0
|
||||
version: 5.9.3
|
||||
vite:
|
||||
specifier: ^6.0.0
|
||||
version: 6.4.1(@types/node@20.19.25)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1)
|
||||
|
||||
apps/storage/packages/shared:
|
||||
devDependencies:
|
||||
typescript:
|
||||
specifier: ^5.7.3
|
||||
version: 5.9.3
|
||||
|
||||
apps/zitare:
|
||||
devDependencies:
|
||||
typescript:
|
||||
|
|
@ -14045,6 +14238,11 @@ packages:
|
|||
peerDependencies:
|
||||
svelte: ^3 || ^4 || ^5.0.0-next.42
|
||||
|
||||
lucide-svelte@0.469.0:
|
||||
resolution: {integrity: sha512-PMIJ8jrFqVUsXJz4d1yfAQplaGhNOahwwkzbunha8DhpiD73xqX24n8dE1dPpUk3vcrdWVsHc1y/liHHotOnGQ==}
|
||||
peerDependencies:
|
||||
svelte: ^3 || ^4 || ^5.0.0-next.42
|
||||
|
||||
luxon@3.5.0:
|
||||
resolution: {integrity: sha512-rh+Zjr6DNfUYR3bPwJEnuwDdqMbxZW7LOQfUN4B54+Cl+0o5zaU9RJ6bcidfDtC1cWCZXQ+nvX8bf6bAji37QQ==}
|
||||
engines: {node: '>=12'}
|
||||
|
|
@ -18593,6 +18791,16 @@ snapshots:
|
|||
transitivePeerDependencies:
|
||||
- ts-node
|
||||
|
||||
'@astrojs/tailwind@6.0.2(astro@5.16.0(@netlify/blobs@10.4.1)(@types/node@20.19.25)(ioredis@5.8.2)(jiti@1.21.7)(lightningcss@1.30.2)(rollup@4.53.3)(terser@5.44.1)(tsx@4.20.6)(typescript@5.9.3)(yaml@2.8.1))(tailwindcss@3.4.18(tsx@4.20.6)(yaml@2.8.1))(ts-node@10.9.2(@types/node@20.19.25)(typescript@5.9.3))':
|
||||
dependencies:
|
||||
astro: 5.16.0(@netlify/blobs@10.4.1)(@types/node@20.19.25)(ioredis@5.8.2)(jiti@1.21.7)(lightningcss@1.30.2)(rollup@4.53.3)(terser@5.44.1)(tsx@4.20.6)(typescript@5.9.3)(yaml@2.8.1)
|
||||
autoprefixer: 10.4.22(postcss@8.5.6)
|
||||
postcss: 8.5.6
|
||||
postcss-load-config: 4.0.2(postcss@8.5.6)(ts-node@10.9.2(@types/node@20.19.25)(typescript@5.9.3))
|
||||
tailwindcss: 3.4.18(tsx@4.20.6)(yaml@2.8.1)
|
||||
transitivePeerDependencies:
|
||||
- ts-node
|
||||
|
||||
'@astrojs/tailwind@6.0.2(astro@5.16.0(@netlify/blobs@10.4.1)(@types/node@20.19.25)(ioredis@5.8.2)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.53.3)(terser@5.44.1)(tsx@4.20.6)(typescript@5.9.3)(yaml@2.8.1))(tailwindcss@3.4.18(tsx@4.20.6)(yaml@2.8.1))(ts-node@10.9.2(@types/node@20.19.25)(typescript@5.9.3))':
|
||||
dependencies:
|
||||
astro: 5.16.0(@netlify/blobs@10.4.1)(@types/node@20.19.25)(ioredis@5.8.2)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.53.3)(terser@5.44.1)(tsx@4.20.6)(typescript@5.9.3)(yaml@2.8.1)
|
||||
|
|
@ -20728,6 +20936,11 @@ snapshots:
|
|||
eslint: 8.57.1
|
||||
eslint-visitor-keys: 3.4.3
|
||||
|
||||
'@eslint-community/eslint-utils@4.9.0(eslint@9.39.1(jiti@1.21.7))':
|
||||
dependencies:
|
||||
eslint: 9.39.1(jiti@1.21.7)
|
||||
eslint-visitor-keys: 3.4.3
|
||||
|
||||
'@eslint-community/eslint-utils@4.9.0(eslint@9.39.1(jiti@2.6.1))':
|
||||
dependencies:
|
||||
eslint: 9.39.1(jiti@2.6.1)
|
||||
|
|
@ -27446,6 +27659,108 @@ snapshots:
|
|||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
astro@5.16.0(@netlify/blobs@10.4.1)(@types/node@20.19.25)(ioredis@5.8.2)(jiti@1.21.7)(lightningcss@1.30.2)(rollup@4.53.3)(terser@5.44.1)(tsx@4.20.6)(typescript@5.9.3)(yaml@2.8.1):
|
||||
dependencies:
|
||||
'@astrojs/compiler': 2.13.0
|
||||
'@astrojs/internal-helpers': 0.7.5
|
||||
'@astrojs/markdown-remark': 6.3.9
|
||||
'@astrojs/telemetry': 3.3.0
|
||||
'@capsizecss/unpack': 3.0.1
|
||||
'@oslojs/encoding': 1.1.0
|
||||
'@rollup/pluginutils': 5.3.0(rollup@4.53.3)
|
||||
acorn: 8.15.0
|
||||
aria-query: 5.3.2
|
||||
axobject-query: 4.1.0
|
||||
boxen: 8.0.1
|
||||
ci-info: 4.3.1
|
||||
clsx: 2.1.1
|
||||
common-ancestor-path: 1.0.1
|
||||
cookie: 1.1.0
|
||||
cssesc: 3.0.0
|
||||
debug: 4.4.3
|
||||
deterministic-object-hash: 2.0.2
|
||||
devalue: 5.5.0
|
||||
diff: 5.2.0
|
||||
dlv: 1.1.3
|
||||
dset: 3.1.4
|
||||
es-module-lexer: 1.7.0
|
||||
esbuild: 0.25.12
|
||||
estree-walker: 3.0.3
|
||||
flattie: 1.1.1
|
||||
fontace: 0.3.1
|
||||
github-slugger: 2.0.0
|
||||
html-escaper: 3.0.3
|
||||
http-cache-semantics: 4.2.0
|
||||
import-meta-resolve: 4.2.0
|
||||
js-yaml: 4.1.1
|
||||
magic-string: 0.30.21
|
||||
magicast: 0.5.1
|
||||
mrmime: 2.0.1
|
||||
neotraverse: 0.6.18
|
||||
p-limit: 6.2.0
|
||||
p-queue: 8.1.1
|
||||
package-manager-detector: 1.5.0
|
||||
piccolore: 0.1.3
|
||||
picomatch: 4.0.3
|
||||
prompts: 2.4.2
|
||||
rehype: 13.0.2
|
||||
semver: 7.7.3
|
||||
shiki: 3.15.0
|
||||
smol-toml: 1.5.2
|
||||
svgo: 4.0.0
|
||||
tinyexec: 1.0.2
|
||||
tinyglobby: 0.2.15
|
||||
tsconfck: 3.1.6(typescript@5.9.3)
|
||||
ultrahtml: 1.6.0
|
||||
unifont: 0.6.0
|
||||
unist-util-visit: 5.0.0
|
||||
unstorage: 1.17.3(@netlify/blobs@10.4.1)(ioredis@5.8.2)
|
||||
vfile: 6.0.3
|
||||
vite: 6.4.1(@types/node@20.19.25)(jiti@1.21.7)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1)
|
||||
vitefu: 1.1.1(vite@6.4.1(@types/node@20.19.25)(jiti@1.21.7)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1))
|
||||
xxhash-wasm: 1.1.0
|
||||
yargs-parser: 21.1.1
|
||||
yocto-spinner: 0.2.3
|
||||
zod: 3.25.76
|
||||
zod-to-json-schema: 3.25.0(zod@3.25.76)
|
||||
zod-to-ts: 1.2.0(typescript@5.9.3)(zod@3.25.76)
|
||||
optionalDependencies:
|
||||
sharp: 0.34.5
|
||||
transitivePeerDependencies:
|
||||
- '@azure/app-configuration'
|
||||
- '@azure/cosmos'
|
||||
- '@azure/data-tables'
|
||||
- '@azure/identity'
|
||||
- '@azure/keyvault-secrets'
|
||||
- '@azure/storage-blob'
|
||||
- '@capacitor/preferences'
|
||||
- '@deno/kv'
|
||||
- '@netlify/blobs'
|
||||
- '@planetscale/database'
|
||||
- '@types/node'
|
||||
- '@upstash/redis'
|
||||
- '@vercel/blob'
|
||||
- '@vercel/functions'
|
||||
- '@vercel/kv'
|
||||
- aws4fetch
|
||||
- db0
|
||||
- idb-keyval
|
||||
- ioredis
|
||||
- jiti
|
||||
- less
|
||||
- lightningcss
|
||||
- rollup
|
||||
- sass
|
||||
- sass-embedded
|
||||
- stylus
|
||||
- sugarss
|
||||
- supports-color
|
||||
- terser
|
||||
- tsx
|
||||
- typescript
|
||||
- uploadthing
|
||||
- yaml
|
||||
|
||||
astro@5.16.0(@netlify/blobs@10.4.1)(@types/node@20.19.25)(ioredis@5.8.2)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.53.3)(terser@5.44.1)(tsx@4.20.6)(typescript@5.9.3)(yaml@2.8.1):
|
||||
dependencies:
|
||||
'@astrojs/compiler': 2.13.0
|
||||
|
|
@ -29556,6 +29871,11 @@ snapshots:
|
|||
optionalDependencies:
|
||||
source-map: 0.6.1
|
||||
|
||||
eslint-compat-utils@0.6.5(eslint@9.39.1(jiti@1.21.7)):
|
||||
dependencies:
|
||||
eslint: 9.39.1(jiti@1.21.7)
|
||||
semver: 7.7.3
|
||||
|
||||
eslint-compat-utils@0.6.5(eslint@9.39.1(jiti@2.6.1)):
|
||||
dependencies:
|
||||
eslint: 9.39.1(jiti@2.6.1)
|
||||
|
|
@ -29603,6 +29923,10 @@ snapshots:
|
|||
dependencies:
|
||||
eslint: 8.57.1
|
||||
|
||||
eslint-config-prettier@9.1.2(eslint@9.39.1(jiti@1.21.7)):
|
||||
dependencies:
|
||||
eslint: 9.39.1(jiti@1.21.7)
|
||||
|
||||
eslint-config-prettier@9.1.2(eslint@9.39.1(jiti@2.6.1)):
|
||||
dependencies:
|
||||
eslint: 9.39.1(jiti@2.6.1)
|
||||
|
|
@ -29727,6 +30051,20 @@ snapshots:
|
|||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
eslint-plugin-astro@1.5.0(eslint@9.39.1(jiti@1.21.7)):
|
||||
dependencies:
|
||||
'@eslint-community/eslint-utils': 4.9.0(eslint@9.39.1(jiti@1.21.7))
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
'@typescript-eslint/types': 8.48.0
|
||||
astro-eslint-parser: 1.2.2
|
||||
eslint: 9.39.1(jiti@1.21.7)
|
||||
eslint-compat-utils: 0.6.5(eslint@9.39.1(jiti@1.21.7))
|
||||
globals: 16.5.0
|
||||
postcss: 8.5.6
|
||||
postcss-selector-parser: 7.1.0
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
eslint-plugin-astro@1.5.0(eslint@9.39.1(jiti@2.6.1)):
|
||||
dependencies:
|
||||
'@eslint-community/eslint-utils': 4.9.0(eslint@9.39.1(jiti@2.6.1))
|
||||
|
|
@ -30077,6 +30415,47 @@ snapshots:
|
|||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
eslint@9.39.1(jiti@1.21.7):
|
||||
dependencies:
|
||||
'@eslint-community/eslint-utils': 4.9.0(eslint@9.39.1(jiti@1.21.7))
|
||||
'@eslint-community/regexpp': 4.12.2
|
||||
'@eslint/config-array': 0.21.1
|
||||
'@eslint/config-helpers': 0.4.2
|
||||
'@eslint/core': 0.17.0
|
||||
'@eslint/eslintrc': 3.3.1
|
||||
'@eslint/js': 9.39.1
|
||||
'@eslint/plugin-kit': 0.4.1
|
||||
'@humanfs/node': 0.16.7
|
||||
'@humanwhocodes/module-importer': 1.0.1
|
||||
'@humanwhocodes/retry': 0.4.3
|
||||
'@types/estree': 1.0.8
|
||||
ajv: 6.12.6
|
||||
chalk: 4.1.2
|
||||
cross-spawn: 7.0.6
|
||||
debug: 4.4.3
|
||||
escape-string-regexp: 4.0.0
|
||||
eslint-scope: 8.4.0
|
||||
eslint-visitor-keys: 4.2.1
|
||||
espree: 10.4.0
|
||||
esquery: 1.6.0
|
||||
esutils: 2.0.3
|
||||
fast-deep-equal: 3.1.3
|
||||
file-entry-cache: 8.0.0
|
||||
find-up: 5.0.0
|
||||
glob-parent: 6.0.2
|
||||
ignore: 5.3.2
|
||||
imurmurhash: 0.1.4
|
||||
is-glob: 4.0.3
|
||||
json-stable-stringify-without-jsonify: 1.0.1
|
||||
lodash.merge: 4.6.2
|
||||
minimatch: 3.1.2
|
||||
natural-compare: 1.4.0
|
||||
optionator: 0.9.4
|
||||
optionalDependencies:
|
||||
jiti: 1.21.7
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
eslint@9.39.1(jiti@2.6.1):
|
||||
dependencies:
|
||||
'@eslint-community/eslint-utils': 4.9.0(eslint@9.39.1(jiti@2.6.1))
|
||||
|
|
@ -34493,6 +34872,10 @@ snapshots:
|
|||
dependencies:
|
||||
svelte: 5.44.0
|
||||
|
||||
lucide-svelte@0.469.0(svelte@5.44.0):
|
||||
dependencies:
|
||||
svelte: 5.44.0
|
||||
|
||||
luxon@3.5.0: {}
|
||||
|
||||
lz-string@1.5.0:
|
||||
|
|
@ -39695,6 +40078,23 @@ snapshots:
|
|||
lightningcss: 1.30.2
|
||||
terser: 5.44.1
|
||||
|
||||
vite@6.4.1(@types/node@20.19.25)(jiti@1.21.7)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1):
|
||||
dependencies:
|
||||
esbuild: 0.25.12
|
||||
fdir: 6.5.0(picomatch@4.0.3)
|
||||
picomatch: 4.0.3
|
||||
postcss: 8.5.6
|
||||
rollup: 4.53.3
|
||||
tinyglobby: 0.2.15
|
||||
optionalDependencies:
|
||||
'@types/node': 20.19.25
|
||||
fsevents: 2.3.3
|
||||
jiti: 1.21.7
|
||||
lightningcss: 1.30.2
|
||||
terser: 5.44.1
|
||||
tsx: 4.20.6
|
||||
yaml: 2.8.1
|
||||
|
||||
vite@6.4.1(@types/node@20.19.25)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1):
|
||||
dependencies:
|
||||
esbuild: 0.25.12
|
||||
|
|
@ -39798,6 +40198,10 @@ snapshots:
|
|||
tsx: 4.20.6
|
||||
yaml: 2.8.1
|
||||
|
||||
vitefu@1.1.1(vite@6.4.1(@types/node@20.19.25)(jiti@1.21.7)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1)):
|
||||
optionalDependencies:
|
||||
vite: 6.4.1(@types/node@20.19.25)(jiti@1.21.7)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1)
|
||||
|
||||
vitefu@1.1.1(vite@6.4.1(@types/node@20.19.25)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1)):
|
||||
optionalDependencies:
|
||||
vite: 6.4.1(@types/node@20.19.25)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1)
|
||||
|
|
|
|||
|
|
@ -456,6 +456,36 @@ const APP_CONFIGS = [
|
|||
PUBLIC_MANA_CORE_AUTH_URL: (env) => env.MANA_CORE_AUTH_URL,
|
||||
},
|
||||
},
|
||||
|
||||
// Storage Backend (NestJS)
|
||||
{
|
||||
path: 'apps/storage/apps/backend/.env',
|
||||
vars: {
|
||||
NODE_ENV: () => 'development',
|
||||
PORT: (env) => env.STORAGE_BACKEND_PORT || '3016',
|
||||
DATABASE_URL: (env) => env.STORAGE_DATABASE_URL,
|
||||
MANA_CORE_AUTH_URL: (env) => env.MANA_CORE_AUTH_URL,
|
||||
DEV_BYPASS_AUTH: () => 'true',
|
||||
DEV_USER_ID: (env) => env.DEV_USER_ID || '00000000-0000-0000-0000-000000000000',
|
||||
S3_ENDPOINT: (env) => env.S3_ENDPOINT,
|
||||
S3_REGION: (env) => env.S3_REGION,
|
||||
S3_ACCESS_KEY: (env) => env.S3_ACCESS_KEY,
|
||||
S3_SECRET_KEY: (env) => env.S3_SECRET_KEY,
|
||||
STORAGE_S3_PUBLIC_URL: (env) => env.STORAGE_S3_PUBLIC_URL,
|
||||
STORAGE_MAX_FILE_SIZE: (env) => env.STORAGE_MAX_FILE_SIZE || '104857600',
|
||||
STORAGE_MAX_FILES_PER_UPLOAD: (env) => env.STORAGE_MAX_FILES_PER_UPLOAD || '10',
|
||||
CORS_ORIGINS: (env) => env.CORS_ORIGINS,
|
||||
},
|
||||
},
|
||||
|
||||
// Storage Web (SvelteKit)
|
||||
{
|
||||
path: 'apps/storage/apps/web/.env',
|
||||
vars: {
|
||||
PUBLIC_BACKEND_URL: (env) => `http://localhost:${env.STORAGE_BACKEND_PORT || '3016'}`,
|
||||
PUBLIC_MANA_CORE_AUTH_URL: (env) => env.MANA_CORE_AUTH_URL,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
function main() {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue