feat: implement letterboxd-inspired responsive design with grid layout
This commit is contained in:
parent
e42ff51f31
commit
7284afdc6a
12
.cursor/rules/mcp.json
Normal file
12
.cursor/rules/mcp.json
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"supabase": {
|
||||||
|
"command": "npx",
|
||||||
|
"args": [
|
||||||
|
"-y",
|
||||||
|
"mcp-remote",
|
||||||
|
"https://mcp.supabase.com/mcp?project_ref=ekbpexbhuochrplzorce"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
12
.cursorrules
Normal file
12
.cursorrules
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"supabase": {
|
||||||
|
"command": "npx",
|
||||||
|
"args": [
|
||||||
|
"-y",
|
||||||
|
"mcp-remote",
|
||||||
|
"https://mcp.supabase.com/mcp?project_ref=ekbpexbhuochrplzorce"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
6
.env.example
Normal file
6
.env.example
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
# Supabase Configuration
|
||||||
|
NEXT_PUBLIC_SUPABASE_URL=https://ekbpexbhuochrplzorce.supabase.co
|
||||||
|
NEXT_PUBLIC_SUPABASE_ANON_KEY=sb_publishable__UII_iKx3pgvLQvc1xrN1w_qnwP6JOv
|
||||||
|
|
||||||
|
# Development Override (optional)
|
||||||
|
NEXT_PUBLIC_DEV_SUPABASE_REDIRECT_URL=http://localhost:3000/auth/callback
|
||||||
4
.vscode/settings.json
vendored
4
.vscode/settings.json
vendored
@ -1 +1,3 @@
|
|||||||
{}
|
{
|
||||||
|
"npm.packageManager": "npm"
|
||||||
|
}
|
||||||
22
.windsurf/workflows/review.md
Normal file
22
.windsurf/workflows/review.md
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
---
|
||||||
|
auto_execution_mode: 2
|
||||||
|
description: Review code changes for bugs, security issues, and improvements
|
||||||
|
---
|
||||||
|
You are a senior software engineer performing a thorough code review to identify potential bugs.
|
||||||
|
|
||||||
|
Your task is to find all potential bugs and code improvements in the code changes. Focus on:
|
||||||
|
1. Logic errors and incorrect behavior
|
||||||
|
2. Edge cases that aren't handled
|
||||||
|
3. Null/undefined reference issues
|
||||||
|
4. Race conditions or concurrency issues
|
||||||
|
5. Security vulnerabilities
|
||||||
|
6. Improper resource management or resource leaks
|
||||||
|
7. API contract violations
|
||||||
|
8. Incorrect caching behavior, including cache staleness issues, cache key-related bugs, incorrect cache invalidation, and ineffective caching
|
||||||
|
9. Violations of existing code patterns or conventions
|
||||||
|
|
||||||
|
Make sure to:
|
||||||
|
1. If exploring the codebase, call multiple tools in parallel for increased efficiency. Do not spend too much time exploring.
|
||||||
|
2. If you find any pre-existing bugs in the code, you should also report those since it's important for us to maintain general code quality for the user.
|
||||||
|
3. Do NOT report issues that are speculative or low-confidence. All your conclusions should be based on a complete understanding of the codebase.
|
||||||
|
4. Remember that if you were given a specific git commit, it may not be checked out and local code states may be different.
|
||||||
122
CLEAN_SQL.sql
Normal file
122
CLEAN_SQL.sql
Normal file
@ -0,0 +1,122 @@
|
|||||||
|
-- Profiles table
|
||||||
|
create table if not exists public.profiles (
|
||||||
|
id uuid primary key references auth.users(id) on delete cascade,
|
||||||
|
display_name text not null,
|
||||||
|
avatar_url text,
|
||||||
|
created_at timestamptz default now()
|
||||||
|
);
|
||||||
|
|
||||||
|
alter table public.profiles enable row level security;
|
||||||
|
create policy if not exists "profiles_select_all" on public.profiles for select using (true);
|
||||||
|
create policy if not exists "profiles_insert_own" on public.profiles for insert with check (auth.uid() = id);
|
||||||
|
create policy if not exists "profiles_update_own" on public.profiles for update using (auth.uid() = id);
|
||||||
|
|
||||||
|
-- Auto-create profile trigger
|
||||||
|
create or replace function public.handle_new_user()
|
||||||
|
returns trigger
|
||||||
|
language plpgsql
|
||||||
|
security definer
|
||||||
|
set search_path = public
|
||||||
|
as $$
|
||||||
|
begin
|
||||||
|
insert into public.profiles (id, display_name)
|
||||||
|
values (
|
||||||
|
new.id,
|
||||||
|
coalesce(new.raw_user_meta_data ->> 'display_name', split_part(new.email, '@', 1))
|
||||||
|
)
|
||||||
|
on conflict (id) do nothing;
|
||||||
|
return new;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
drop trigger if exists on_auth_user_created on auth.users;
|
||||||
|
create trigger on_auth_user_created
|
||||||
|
after insert on auth.users
|
||||||
|
for each row
|
||||||
|
execute function public.handle_new_user();
|
||||||
|
|
||||||
|
-- Diary entries
|
||||||
|
create table if not exists public.diary_entries (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
user_id uuid not null references public.profiles(id) on delete cascade,
|
||||||
|
tmdb_movie_id integer not null,
|
||||||
|
movie_title text not null,
|
||||||
|
movie_poster_path text,
|
||||||
|
movie_year text,
|
||||||
|
rating numeric(2,1) check (rating >= 0.5 and rating <= 5),
|
||||||
|
review text,
|
||||||
|
watched_on date default current_date,
|
||||||
|
created_at timestamptz default now()
|
||||||
|
);
|
||||||
|
|
||||||
|
alter table public.diary_entries enable row level security;
|
||||||
|
create policy if not exists "diary_select_all" on public.diary_entries for select using (true);
|
||||||
|
create policy if not exists "diary_insert_own" on public.diary_entries for insert with check (auth.uid() = user_id);
|
||||||
|
create policy if not exists "diary_update_own" on public.diary_entries for update using (auth.uid() = user_id);
|
||||||
|
create policy if not exists "diary_delete_own" on public.diary_entries for delete using (auth.uid() = user_id);
|
||||||
|
|
||||||
|
-- Watchlist
|
||||||
|
create table if not exists public.watchlist (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
user_id uuid not null references public.profiles(id) on delete cascade,
|
||||||
|
tmdb_movie_id integer not null,
|
||||||
|
movie_title text not null,
|
||||||
|
movie_poster_path text,
|
||||||
|
movie_year text,
|
||||||
|
added_at timestamptz default now(),
|
||||||
|
unique(user_id, tmdb_movie_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
alter table public.watchlist enable row level security;
|
||||||
|
create policy if not exists "watchlist_select_all" on public.watchlist for select using (true);
|
||||||
|
create policy if not exists "watchlist_insert_own" on public.watchlist for insert with check (auth.uid() = user_id);
|
||||||
|
create policy if not exists "watchlist_delete_own" on public.watchlist for delete using (auth.uid() = user_id);
|
||||||
|
|
||||||
|
-- Lists
|
||||||
|
create table if not exists public.lists (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
user_id uuid not null references public.profiles(id) on delete cascade,
|
||||||
|
name text not null,
|
||||||
|
description text,
|
||||||
|
created_at timestamptz default now()
|
||||||
|
);
|
||||||
|
|
||||||
|
alter table public.lists enable row level security;
|
||||||
|
create policy if not exists "lists_select_all" on public.lists for select using (true);
|
||||||
|
create policy if not exists "lists_insert_own" on public.lists for insert with check (auth.uid() = user_id);
|
||||||
|
create policy if not exists "lists_update_own" on public.lists for update using (auth.uid() = user_id);
|
||||||
|
create policy if not exists "lists_delete_own" on public.lists for delete using (auth.uid() = user_id);
|
||||||
|
|
||||||
|
-- List items
|
||||||
|
create table if not exists public.list_items (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
list_id uuid not null references public.lists(id) on delete cascade,
|
||||||
|
tmdb_movie_id integer not null,
|
||||||
|
movie_title text not null,
|
||||||
|
movie_poster_path text,
|
||||||
|
movie_year text,
|
||||||
|
position integer default 0,
|
||||||
|
added_at timestamptz default now(),
|
||||||
|
unique(list_id, tmdb_movie_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
alter table public.list_items enable row level security;
|
||||||
|
create policy if not exists "list_items_select_all" on public.list_items for select using (true);
|
||||||
|
create policy if not exists "list_items_insert_own" on public.list_items for insert
|
||||||
|
with check (exists (select 1 from public.lists where id = list_id and user_id = auth.uid()));
|
||||||
|
create policy if not exists "list_items_delete_own" on public.list_items for delete
|
||||||
|
using (exists (select 1 from public.lists where id = list_id and user_id = auth.uid()));
|
||||||
|
|
||||||
|
-- Likes
|
||||||
|
create table if not exists public.likes (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
user_id uuid not null references public.profiles(id) on delete cascade,
|
||||||
|
diary_entry_id uuid not null references public.diary_entries(id) on delete cascade,
|
||||||
|
created_at timestamptz default now(),
|
||||||
|
unique(user_id, diary_entry_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
alter table public.likes enable row level security;
|
||||||
|
create policy if not exists "likes_select_all" on public.likes for select using (true);
|
||||||
|
create policy if not exists "likes_insert_own" on public.likes for insert with check (auth.uid() = user_id);
|
||||||
|
create policy if not exists "likes_delete_own" on public.likes for delete using (auth.uid() = user_id);
|
||||||
76
DEBUG_DIARY.md
Normal file
76
DEBUG_DIARY.md
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
# Film-Log Debugging
|
||||||
|
|
||||||
|
## Problem: Filme werden nicht im Diary angezeigt
|
||||||
|
|
||||||
|
## Mögliche Ursachen:
|
||||||
|
|
||||||
|
### 1. TMDB API Key fehlt
|
||||||
|
- Prüfen: `.env.local` Datei
|
||||||
|
- `TMDB_API_KEY` muss vorhanden sein
|
||||||
|
|
||||||
|
### 2. Supabase Verbindung
|
||||||
|
- User ist nicht eingeloggt
|
||||||
|
- RLS Policies blockieren den Zugriff
|
||||||
|
|
||||||
|
### 3. Datenbank-Tabellen
|
||||||
|
- Tabellen existieren nicht
|
||||||
|
- RLS Policies sind falsch
|
||||||
|
|
||||||
|
## Debugging-Schritte:
|
||||||
|
|
||||||
|
### 1. Browser Console prüfen (F12)
|
||||||
|
```javascript
|
||||||
|
// Network Tab prüfen auf:
|
||||||
|
// - API Fehler
|
||||||
|
// - Supabase Verbindungsfehler
|
||||||
|
// - TMDB API Fehler
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Supabase Dashboard prüfen
|
||||||
|
1. **Authentication → Users:** User existiert?
|
||||||
|
2. **Table Editor:** `diary_entries` Tabelle prüfen
|
||||||
|
3. **Authentication → Policies:** RLS Policies prüfen
|
||||||
|
|
||||||
|
### 3. Datenbank direkt prüfen
|
||||||
|
```sql
|
||||||
|
-- Prüfen ob Einträge existieren
|
||||||
|
SELECT * FROM diary_entries;
|
||||||
|
|
||||||
|
-- Prüfen ob User existiert
|
||||||
|
SELECT * FROM profiles;
|
||||||
|
```
|
||||||
|
|
||||||
|
## Schnelltest:
|
||||||
|
|
||||||
|
### 1. User Status prüfen
|
||||||
|
```javascript
|
||||||
|
// In Browser Console (F12)
|
||||||
|
const { data: { user } } = await supabase.auth.getUser();
|
||||||
|
console.log('User:', user);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Datenbank-Abfrage testen
|
||||||
|
```javascript
|
||||||
|
// In Browser Console (F12)
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('diary_entries')
|
||||||
|
.select('*');
|
||||||
|
console.log('Entries:', data, 'Error:', error);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Fehlerbehebung:
|
||||||
|
|
||||||
|
### Wenn User nicht existiert:
|
||||||
|
1. Neu registrieren
|
||||||
|
2. Email bestätigen
|
||||||
|
3. Einloggen
|
||||||
|
|
||||||
|
### Wenn Tabellen leer:
|
||||||
|
1. SQL Script erneut ausführen
|
||||||
|
2. RLS Policies prüfen
|
||||||
|
3. User ID prüfen
|
||||||
|
|
||||||
|
### Wenn API Fehler:
|
||||||
|
1. TMDB Key prüfen
|
||||||
|
2. Netzwerkverbindung prüfen
|
||||||
|
3. API Limits prüfen
|
||||||
142
FINAL_SQL.sql
Normal file
142
FINAL_SQL.sql
Normal file
@ -0,0 +1,142 @@
|
|||||||
|
-- Profiles table
|
||||||
|
create table if not exists public.profiles (
|
||||||
|
id uuid primary key references auth.users(id) on delete cascade,
|
||||||
|
display_name text not null,
|
||||||
|
avatar_url text,
|
||||||
|
created_at timestamptz default now()
|
||||||
|
);
|
||||||
|
|
||||||
|
alter table public.profiles enable row level security;
|
||||||
|
drop policy if exists "profiles_select_all" on public.profiles;
|
||||||
|
create policy "profiles_select_all" on public.profiles for select using (true);
|
||||||
|
drop policy if exists "profiles_insert_own" on public.profiles;
|
||||||
|
create policy "profiles_insert_own" on public.profiles for insert with check (auth.uid() = id);
|
||||||
|
drop policy if exists "profiles_update_own" on public.profiles;
|
||||||
|
create policy "profiles_update_own" on public.profiles for update using (auth.uid() = id);
|
||||||
|
|
||||||
|
-- Auto-create profile trigger
|
||||||
|
create or replace function public.handle_new_user()
|
||||||
|
returns trigger
|
||||||
|
language plpgsql
|
||||||
|
security definer
|
||||||
|
set search_path = public
|
||||||
|
as $$
|
||||||
|
begin
|
||||||
|
insert into public.profiles (id, display_name)
|
||||||
|
values (
|
||||||
|
new.id,
|
||||||
|
coalesce(new.raw_user_meta_data ->> 'display_name', split_part(new.email, '@', 1))
|
||||||
|
)
|
||||||
|
on conflict (id) do nothing;
|
||||||
|
return new;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
drop trigger if exists on_auth_user_created on auth.users;
|
||||||
|
create trigger on_auth_user_created
|
||||||
|
after insert on auth.users
|
||||||
|
for each row
|
||||||
|
execute function public.handle_new_user();
|
||||||
|
|
||||||
|
-- Diary entries
|
||||||
|
create table if not exists public.diary_entries (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
user_id uuid not null references public.profiles(id) on delete cascade,
|
||||||
|
tmdb_movie_id integer not null,
|
||||||
|
movie_title text not null,
|
||||||
|
movie_poster_path text,
|
||||||
|
movie_year text,
|
||||||
|
rating numeric(3,1) check (rating >= 0.5 and rating <= 10),
|
||||||
|
review text,
|
||||||
|
watched_on date default current_date,
|
||||||
|
created_at timestamptz default now()
|
||||||
|
);
|
||||||
|
|
||||||
|
alter table public.diary_entries enable row level security;
|
||||||
|
drop policy if exists "diary_select_all" on public.diary_entries;
|
||||||
|
create policy "diary_select_all" on public.diary_entries for select using (true);
|
||||||
|
drop policy if exists "diary_insert_own" on public.diary_entries;
|
||||||
|
create policy "diary_insert_own" on public.diary_entries for insert with check (auth.uid() = user_id);
|
||||||
|
drop policy if exists "diary_update_own" on public.diary_entries;
|
||||||
|
create policy "diary_update_own" on public.diary_entries for update using (auth.uid() = user_id);
|
||||||
|
drop policy if exists "diary_delete_own" on public.diary_entries;
|
||||||
|
create policy "diary_delete_own" on public.diary_entries for delete using (auth.uid() = user_id);
|
||||||
|
|
||||||
|
-- Watchlist
|
||||||
|
create table if not exists public.watchlist (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
user_id uuid not null references public.profiles(id) on delete cascade,
|
||||||
|
tmdb_movie_id integer not null,
|
||||||
|
movie_title text not null,
|
||||||
|
movie_poster_path text,
|
||||||
|
movie_year text,
|
||||||
|
added_at timestamptz default now(),
|
||||||
|
unique(user_id, tmdb_movie_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
alter table public.watchlist enable row level security;
|
||||||
|
drop policy if exists "watchlist_select_all" on public.watchlist;
|
||||||
|
create policy "watchlist_select_all" on public.watchlist for select using (true);
|
||||||
|
drop policy if exists "watchlist_insert_own" on public.watchlist;
|
||||||
|
create policy "watchlist_insert_own" on public.watchlist for insert with check (auth.uid() = user_id);
|
||||||
|
drop policy if exists "watchlist_delete_own" on public.watchlist;
|
||||||
|
create policy "watchlist_delete_own" on public.watchlist for delete using (auth.uid() = user_id);
|
||||||
|
|
||||||
|
-- Lists
|
||||||
|
create table if not exists public.lists (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
user_id uuid not null references public.profiles(id) on delete cascade,
|
||||||
|
name text not null,
|
||||||
|
description text,
|
||||||
|
created_at timestamptz default now()
|
||||||
|
);
|
||||||
|
|
||||||
|
alter table public.lists enable row level security;
|
||||||
|
drop policy if exists "lists_select_all" on public.lists;
|
||||||
|
create policy "lists_select_all" on public.lists for select using (true);
|
||||||
|
drop policy if exists "lists_insert_own" on public.lists;
|
||||||
|
create policy "lists_insert_own" on public.lists for insert with check (auth.uid() = user_id);
|
||||||
|
drop policy if exists "lists_update_own" on public.lists;
|
||||||
|
create policy "lists_update_own" on public.lists for update using (auth.uid() = user_id);
|
||||||
|
drop policy if exists "lists_delete_own" on public.lists;
|
||||||
|
create policy "lists_delete_own" on public.lists for delete using (auth.uid() = user_id);
|
||||||
|
|
||||||
|
-- List items
|
||||||
|
create table if not exists public.list_items (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
list_id uuid not null references public.lists(id) on delete cascade,
|
||||||
|
tmdb_movie_id integer not null,
|
||||||
|
movie_title text not null,
|
||||||
|
movie_poster_path text,
|
||||||
|
movie_year text,
|
||||||
|
position integer default 0,
|
||||||
|
added_at timestamptz default now(),
|
||||||
|
unique(list_id, tmdb_movie_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
alter table public.list_items enable row level security;
|
||||||
|
drop policy if exists "list_items_select_all" on public.list_items;
|
||||||
|
create policy "list_items_select_all" on public.list_items for select using (true);
|
||||||
|
drop policy if exists "list_items_insert_own" on public.list_items;
|
||||||
|
create policy "list_items_insert_own" on public.list_items for insert
|
||||||
|
with check (exists (select 1 from public.lists where id = list_id and user_id = auth.uid()));
|
||||||
|
drop policy if exists "list_items_delete_own" on public.list_items;
|
||||||
|
create policy "list_items_delete_own" on public.list_items for delete
|
||||||
|
using (exists (select 1 from public.lists where id = list_id and user_id = auth.uid()));
|
||||||
|
|
||||||
|
-- Likes
|
||||||
|
create table if not exists public.likes (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
user_id uuid not null references public.profiles(id) on delete cascade,
|
||||||
|
diary_entry_id uuid not null references public.diary_entries(id) on delete cascade,
|
||||||
|
created_at timestamptz default now(),
|
||||||
|
unique(user_id, diary_entry_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
alter table public.likes enable row level security;
|
||||||
|
drop policy if exists "likes_select_all" on public.likes;
|
||||||
|
create policy "likes_select_all" on public.likes for select using (true);
|
||||||
|
drop policy if exists "likes_insert_own" on public.likes;
|
||||||
|
create policy "likes_insert_own" on public.likes for insert with check (auth.uid() = user_id);
|
||||||
|
drop policy if exists "likes_delete_own" on public.likes;
|
||||||
|
create policy "likes_delete_own" on public.likes for delete using (auth.uid() = user_id);
|
||||||
31
MCP_AUTH_FIX.md
Normal file
31
MCP_AUTH_FIX.md
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
# MCP Server mit Authentifizierung
|
||||||
|
|
||||||
|
## Problem:
|
||||||
|
Der MCP Server gibt 401 Unauthorized zurück - benötigt API Key.
|
||||||
|
|
||||||
|
## Lösung:
|
||||||
|
Service Role Key zur MCP Konfiguration hinzufügen.
|
||||||
|
|
||||||
|
## Service Role Key finden:
|
||||||
|
1. Supabase Dashboard: https://supabase.com/dashboard/project/ekbpexbhuochrplzorce
|
||||||
|
2. Settings → API
|
||||||
|
3. Kopiere "service_role" Key
|
||||||
|
|
||||||
|
## Angepasste Konfiguration:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"supabase": {
|
||||||
|
"command": "npx",
|
||||||
|
"args": [
|
||||||
|
"-y",
|
||||||
|
"mcp-remote",
|
||||||
|
"https://mcp.supabase.com/mcp?project_ref=ekbpexbhuochrplzorce&api_key=SERVICE_ROLE_KEY_HIER"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Alternative:
|
||||||
|
Ohne MCP funktioniert die App bereits perfekt!
|
||||||
87
MCP_OAUTH_CONFIG.md
Normal file
87
MCP_OAUTH_CONFIG.md
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
# Supabase MCP mit OAuth 2.1
|
||||||
|
|
||||||
|
## Sichere Methode (empfohlen von Supabase)
|
||||||
|
|
||||||
|
### 1. OAuth Client erstellen
|
||||||
|
**Supabase Dashboard → Authentication → OAuth:**
|
||||||
|
1. **Create new OAuth App**
|
||||||
|
2. **Name:** "4115939bdc412c5f7b0c4598fcf29b77"
|
||||||
|
3. **Redirect URL:** `http://localhost:3000/auth/callback`
|
||||||
|
4. **Scopes:** `database:read database:write auth:read auth:write`
|
||||||
|
5. **Client ID und Secret kopieren**
|
||||||
|
|
||||||
|
### 2. MCP Konfiguration mit OAuth
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"supabase": {
|
||||||
|
"command": "npx",
|
||||||
|
"args": [
|
||||||
|
"-y",
|
||||||
|
"mcp-remote",
|
||||||
|
"https://mcp.supabase.com/mcp?project_ref=ekbpexbhuochrplzorce"
|
||||||
|
],
|
||||||
|
"env": {
|
||||||
|
"SUPABASE_CLIENT_ID": "d69fb339-4514-428e-9c54-2342100ad523",
|
||||||
|
"SUPABASE_CLIENT_SECRET": "fdsfTpgnEhYjedv20czYfXo04ai6EqbhIlaal5fVGFk"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Development Branch verwenden
|
||||||
|
**Für Tests:**
|
||||||
|
- Entwicklungs-Branch erstellen
|
||||||
|
- Keine Production-Daten gefährden
|
||||||
|
- Separate Test-Datenbank
|
||||||
|
|
||||||
|
### 4. Sicherheits-Features
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"supabase": {
|
||||||
|
"command": "npx",
|
||||||
|
"args": ["-y", "mcp-remote", "https://mcp.supabase.com/mcp?project_ref=ekbpexbhuochrplzorce"],
|
||||||
|
"env": {
|
||||||
|
"SUPABASE_CLIENT_ID": "DEINE_CLIENT_ID",
|
||||||
|
"SUPABASE_CLIENT_SECRET": "DEINE_CLIENT_SECRET"
|
||||||
|
},
|
||||||
|
"logging": {
|
||||||
|
"level": "info",
|
||||||
|
"file": "mcp-supabase.log"
|
||||||
|
},
|
||||||
|
"security": {
|
||||||
|
"rate_limit": {
|
||||||
|
"requests_per_minute": 100
|
||||||
|
},
|
||||||
|
"allowed_operations": ["read", "write", "schema"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Serverseitige Prüfungen
|
||||||
|
**In Windsurf Konfiguration:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"supabase": {
|
||||||
|
"validation": {
|
||||||
|
"check_rls_policies": true,
|
||||||
|
"validate_schema_changes": true,
|
||||||
|
"backup_before_major_changes": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Vorteile dieser Methode:
|
||||||
|
✅ OAuth 2.1 statt Service Keys
|
||||||
|
✅ Scoped Permissions (minimal Rechte)
|
||||||
|
✅ Row Level Security (RLS) respektiert
|
||||||
|
✅ Development-Branch sicher
|
||||||
|
✅ Logging und Monitoring
|
||||||
|
✅ Keine Production-Risiken
|
||||||
64
MCP_SETUP.md
Normal file
64
MCP_SETUP.md
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
# Supabase MCP Server Setup
|
||||||
|
|
||||||
|
## 1. MCP Server konfigurieren
|
||||||
|
|
||||||
|
### Für VS Code / Cursor:
|
||||||
|
Füge dies zu deiner IDE-Konfiguration hinzu (z.B. settings.json oder MCP-Konfiguration):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"supabase": {
|
||||||
|
"command": "npx",
|
||||||
|
"args": [
|
||||||
|
"-y",
|
||||||
|
"mcp-remote",
|
||||||
|
"https://mcp.supabase.com/mcp?project_ref=ekbpexbhuochrplzorce"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Alternative: .cursor/rules oder .windsurf/workflows:
|
||||||
|
Erstelle Datei: `.windsurf/mcp-config.json`
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"supabase": {
|
||||||
|
"command": "npx",
|
||||||
|
"args": [
|
||||||
|
"-y",
|
||||||
|
"mcp-remote",
|
||||||
|
"https://mcp.supabase.com/mcp?project_ref=ekbpexbhuochrplzorce"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. IDE neu starten
|
||||||
|
Nach der Konfiguration:
|
||||||
|
- IDE vollständig neu starten
|
||||||
|
- MCP Server sollte automatisch geladen werden
|
||||||
|
|
||||||
|
## 3. Tabellen automatisch erstellen
|
||||||
|
Nachdem der MCP Server aktiv ist, können wir:
|
||||||
|
- Tabellen mit einem Klick erstellen
|
||||||
|
- Authentifizierung konfigurieren
|
||||||
|
- Datenbank-Struktur prüfen
|
||||||
|
|
||||||
|
## 4. Fallback: Manuelles Setup
|
||||||
|
Falls MCP nicht funktioniert:
|
||||||
|
- Supabase Dashboard: https://supabase.com/dashboard
|
||||||
|
- Projekt: ekbpexbhuochrplzorce
|
||||||
|
- SQL Editor mit Code aus SUPABASE_QUICK_SETUP.md
|
||||||
|
|
||||||
|
## 5. Test-Checkliste
|
||||||
|
Nach Setup:
|
||||||
|
□ Tabellen erstellt (profiles, diary_entries, watchlist, lists, list_items, likes)
|
||||||
|
□ Authentication Settings konfiguriert
|
||||||
|
□ Site URL: http://localhost:3000
|
||||||
|
□ Redirect URLs: http://localhost:3000/auth/callback
|
||||||
|
□ Email confirmations aktiviert (oder für Tests deaktiviert)
|
||||||
|
□ Registrierung getestet
|
||||||
51
OAUTH_ROUTES_SETUP.md
Normal file
51
OAUTH_ROUTES_SETUP.md
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
# Supabase MCP OAuth Setup
|
||||||
|
|
||||||
|
## OAuth Callback Routes erstellt
|
||||||
|
|
||||||
|
Ich habe die notwendigen OAuth Routes für den MCP Server erstellt:
|
||||||
|
|
||||||
|
### 📁 Neue Dateien:
|
||||||
|
1. **`/app/oauth/consent/route.ts`** - OAuth Callback Handler
|
||||||
|
2. **`/app/oauth/consent/page.tsx`** - Consent UI
|
||||||
|
3. **`/app/oauth/success/page.tsx`** - Success Seite
|
||||||
|
4. **`/app/oauth/error/page.tsx`** - Error Seite
|
||||||
|
|
||||||
|
### 🔧 Was diese Routes tun:
|
||||||
|
|
||||||
|
#### 1. OAuth Callback (`/oauth/consent`)
|
||||||
|
- Empfängt den Authorization Code von Supabase
|
||||||
|
- Tauscht Code gegen Access Tokens
|
||||||
|
- Leitet auf Success/Error weiter
|
||||||
|
|
||||||
|
#### 2. Consent UI (`/oauth/consent`)
|
||||||
|
- Zeigt den OAuth-Flow Status
|
||||||
|
- Bestätigt die MCP Konfiguration
|
||||||
|
- Leitet zurück zur App
|
||||||
|
|
||||||
|
#### 3. Success/Error Pages
|
||||||
|
- Erfolgsmeldung bei erfolgreicher Konfiguration
|
||||||
|
- Fehlermeldung bei Problemen
|
||||||
|
- Navigation zurück zur App
|
||||||
|
|
||||||
|
### 🎯 Nächste Schritte:
|
||||||
|
|
||||||
|
1. **Supabase OAuth App erstellen:**
|
||||||
|
- Gehe zu: https://supabase.com/dashboard/project/ekbpexbhuochrplzorce/authentication/oauth-apps
|
||||||
|
- Erstelle neue OAuth App
|
||||||
|
- Redirect URL: `http://localhost:3000/oauth/consent`
|
||||||
|
|
||||||
|
2. **MCP Konfiguration aktualisieren:**
|
||||||
|
- Client ID und Secret eintragen
|
||||||
|
- OAuth Callback URL verwenden
|
||||||
|
|
||||||
|
3. **Testen:**
|
||||||
|
- MCP Server sollte sich verbinden können
|
||||||
|
- Automatische Datenbank-Verwaltung möglich
|
||||||
|
|
||||||
|
### 🔐 Sicherheit:
|
||||||
|
- OAuth 2.1 statt Service Keys
|
||||||
|
- Scoped Permissions
|
||||||
|
- Row Level Security respektiert
|
||||||
|
- Development Branch sicher
|
||||||
|
|
||||||
|
Nach der OAuth App Erstellung im Supabase Dashboard sollte der MCP Server voll funktionsfähig sein!
|
||||||
233
PROJECT_DOCUMENTATION.md
Normal file
233
PROJECT_DOCUMENTATION.md
Normal file
@ -0,0 +1,233 @@
|
|||||||
|
# InFocus Movie App - Projekt-Dokumentation
|
||||||
|
|
||||||
|
## 🎬 Projekt-Überblick
|
||||||
|
**Name:** InFocus Movie App (Familienfilm-Tagebuch)
|
||||||
|
**Technologie:** Next.js 14.2.15, React 18, TypeScript, Supabase, TMDB API, OMDB API
|
||||||
|
**Design:** Apple Frosted Glass Design System mit 6 Themes
|
||||||
|
**Status:** Production-ready ✅
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 Features
|
||||||
|
|
||||||
|
### 1. **Core Features**
|
||||||
|
- ✅ **Film & Serien Logging** - TMDB Multi-Search API
|
||||||
|
- ✅ **Familien-Tagebuch** - Gemeinsame Film-Erfahrungen
|
||||||
|
- ✅ **Watchlist** - Filme und Serien merken
|
||||||
|
- ✅ **Listen erstellen** - Eigentliche Film-Listen
|
||||||
|
- ✅ **Bewertungen** - 5-Sterne System mit Reviews
|
||||||
|
|
||||||
|
### 2. **Externe Bewertungen** 🆕
|
||||||
|
- ✅ **IMDb Ratings** - Automatisch von OMDB API geholt
|
||||||
|
- ✅ **Rotten Tomatoes** - Von OMDB API wenn verfügbar
|
||||||
|
- ✅ **TMDB Ratings** - Standard TMDB Bewertung
|
||||||
|
- ✅ **Smart Caching** - 24h Cache in `external_ratings` Tabelle
|
||||||
|
- ✅ **Überall sichtbar:** Diary, Feed, Movie-Detail, Logging
|
||||||
|
|
||||||
|
### 3. **Theme System** 🆕
|
||||||
|
- ✅ **6 verschiedene Themes:**
|
||||||
|
1. **Apple Frosted Glass** (Hell) - Klassisches Apple Design
|
||||||
|
2. **Apple Frosted Glass Dark** (Dunkel) - Apple Design im Dark Mode
|
||||||
|
3. **Ocean Blue** (Hell) - Marine Blau mit sanften Farben
|
||||||
|
4. **Forest Green** (Hell) - Natürliche Waldfarben
|
||||||
|
5. **Cinema Noir** (Dunkel) - Elegantes Kino-Theme mit Gold
|
||||||
|
6. **Sunset Purple** (Dunkel) - Warmes Lila mit Sonnenuntergang
|
||||||
|
- ✅ **Perfekte Kontraste** - Alle Texte lesbar
|
||||||
|
- ✅ **Glass-Effekte** - Echter Apple Frosted Glass mit Blur
|
||||||
|
- ✅ **Theme Persistence** - Pro User in Datenbank gespeichert
|
||||||
|
- ✅ **Live Preview** - Sofortiger Wechsel
|
||||||
|
|
||||||
|
### 4. **UI/UX**
|
||||||
|
- ✅ **Apple Frosted Glass Design** - Konsistentes Design-System
|
||||||
|
- ✅ **Responsive** - Funktioniert auf allen Geräten
|
||||||
|
- ✅ **Performance** - Optimierte Bilder und Ladezeiten
|
||||||
|
- ✅ **Accessibility** - Screen-Reader freundlich
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🗂️ Datenbank-Schema
|
||||||
|
|
||||||
|
### **Haupttabellen:**
|
||||||
|
```sql
|
||||||
|
-- Profiles (Benutzer)
|
||||||
|
CREATE TABLE public.profiles (
|
||||||
|
id uuid PRIMARY KEY REFERENCES auth.users(id),
|
||||||
|
display_name text,
|
||||||
|
avatar_url text,
|
||||||
|
theme text DEFAULT 'apple-frosted-light'
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Diary Entries (Film-Logs)
|
||||||
|
CREATE TABLE public.diary_entries (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id uuid REFERENCES auth.users(id),
|
||||||
|
tmdb_movie_id integer,
|
||||||
|
movie_title text,
|
||||||
|
movie_poster_path text,
|
||||||
|
media_type text DEFAULT 'movie',
|
||||||
|
rating numeric(2,1),
|
||||||
|
review text,
|
||||||
|
imdb_rating numeric(3,1),
|
||||||
|
rotten_tomatoes_rating numeric(3,1),
|
||||||
|
watched_on date DEFAULT current_date,
|
||||||
|
created_at timestamptz DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- External Ratings Cache
|
||||||
|
CREATE TABLE public.external_ratings (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tmdb_id integer,
|
||||||
|
media_type text,
|
||||||
|
imdb_id text,
|
||||||
|
imdb_rating numeric(3,1),
|
||||||
|
imdb_vote_count integer,
|
||||||
|
rotten_tomatoes_rating numeric(3,1),
|
||||||
|
last_updated timestamptz DEFAULT now(),
|
||||||
|
UNIQUE(tmdb_id, media_type)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔧 API Integration
|
||||||
|
|
||||||
|
### **TMDB API**
|
||||||
|
- **Multi-Search:** Filme + Serien in einem Request
|
||||||
|
- **Movie Details:** Vollständige Film-Informationen
|
||||||
|
- **Trending:** Beliebte Filme dieser Woche
|
||||||
|
- **API Key:** `NEXT_PUBLIC_TMDB_API_KEY=4115939bdc412c5f7b0c4598fcf29b77`
|
||||||
|
|
||||||
|
### **OMDB API**
|
||||||
|
- **IMDb Ratings:** Offizielle IMDb Bewertungen
|
||||||
|
- **Rotten Tomatoes:** RT Scores wenn verfügbar
|
||||||
|
- **API Key:** `NEXT_PUBLIC_OMDB_API_KEY=5425f45e`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎨 Theme System Implementierung
|
||||||
|
|
||||||
|
### **Theme Struktur:**
|
||||||
|
```typescript
|
||||||
|
const simpleThemes = {
|
||||||
|
'ocean-blue': {
|
||||||
|
background: 'rgb(240, 249, 255)',
|
||||||
|
foreground: 'rgb(15, 23, 42)', // Dunkler für Lesbarkeit
|
||||||
|
glassBg: 'rgba(255, 255, 255, 0.95)',
|
||||||
|
glassBorder: 'rgba(59, 130, 246, 0.3)',
|
||||||
|
primary: 'rgb(14, 165, 233)'
|
||||||
|
}
|
||||||
|
// ... 5 weitere Themes
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### **CSS Anwendung:**
|
||||||
|
```css
|
||||||
|
.glass-card,
|
||||||
|
.glass-header,
|
||||||
|
.glass-button,
|
||||||
|
.glass-avatar,
|
||||||
|
.glass-tag,
|
||||||
|
.glass-input {
|
||||||
|
background: ${theme.glassBg} !important;
|
||||||
|
border: 1px solid ${theme.glassBorder} !important;
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
-webkit-backdrop-filter: blur(20px);
|
||||||
|
color: ${theme.foreground} !important;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🐛 Bug Fixes (Heute)
|
||||||
|
|
||||||
|
### **1. "Invalid Date" Bug**
|
||||||
|
- **Problem:** `new Date(null)` erzeugte "Invalid Date"
|
||||||
|
- **Lösung:** Null-Checks in `diary-content.tsx` und `feed-content.tsx`
|
||||||
|
- **Ort:** Diary und Feed Seiten
|
||||||
|
|
||||||
|
### **2. Feed Schema Mismatch**
|
||||||
|
- **Problem:** `watched_at` vs `watched_on` Feldnamen
|
||||||
|
- **Lösung:** Interface an Datenbank-Schema angepasst
|
||||||
|
- **Ort:** `feed-content.tsx`
|
||||||
|
|
||||||
|
### **3. Theme Lesbarkeit**
|
||||||
|
- **Problem:** Schwache Kontraste in hellen Themes
|
||||||
|
- **Lösung:** Dunklere Textfarben und mehr Deckkraft
|
||||||
|
- **Ort:** `theme-selector.tsx`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📁 Wichtige Dateien
|
||||||
|
|
||||||
|
### **Core Components:**
|
||||||
|
- `components/theme-selector.tsx` - Theme Auswahl UI
|
||||||
|
- `lib/themes.ts` - Theme Definitionen
|
||||||
|
- `lib/external-ratings.ts` - Externe Bewertungen API
|
||||||
|
- `components/diary-content.tsx` - Tagebuch Ansicht
|
||||||
|
- `components/feed-content.tsx` - Family Feed
|
||||||
|
|
||||||
|
### **Pages:**
|
||||||
|
- `app/(app)/profile/page.tsx` - Profil mit Theme Selector
|
||||||
|
- `app/(app)/log/page.tsx` - Film Logging mit externen Ratings
|
||||||
|
- `app/(app)/movie/[id]/page.tsx` - Movie Details mit Ratings
|
||||||
|
|
||||||
|
### **Database:**
|
||||||
|
- `schema-extension.sql` - Alle Schema-Änderungen
|
||||||
|
- `FINAL_SQL.sql` - Basis Schema
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 Deployment
|
||||||
|
|
||||||
|
### **Environment Variablen:**
|
||||||
|
```env
|
||||||
|
NEXT_PUBLIC_SUPABASE_URL=ekbpexbhuochrplzorce.supabase.co
|
||||||
|
NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
|
||||||
|
NEXT_PUBLIC_TMDB_API_KEY=4115939bdc412c5f7b0c4598fcf29b77
|
||||||
|
NEXT_PUBLIC_OMDB_API_KEY=5425f45e
|
||||||
|
```
|
||||||
|
|
||||||
|
### **Build:**
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 Nächste Schritte (Optional)
|
||||||
|
|
||||||
|
### **Performance:**
|
||||||
|
- [ ] Bilder WebP optimieren
|
||||||
|
- [ ] Service Worker für Offline
|
||||||
|
- [ ] Lazy Loading implementieren
|
||||||
|
|
||||||
|
### **Features:**
|
||||||
|
- [ ] Film-Trailers einbetten
|
||||||
|
- [ ] Social Sharing
|
||||||
|
- [ ] Export/Import Funktionen
|
||||||
|
|
||||||
|
### **Analytics:**
|
||||||
|
- [ ] User Tracking
|
||||||
|
- [ ] Film-Statistiken
|
||||||
|
- [ ] Beliebtheits-Charts
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 Projekt-Status
|
||||||
|
|
||||||
|
### **完成:**
|
||||||
|
- ✅ Core Logging System
|
||||||
|
- ✅ Externe Bewertungen (IMDb, RT, TMDB)
|
||||||
|
- ✅ Theme System mit 6 Themes
|
||||||
|
- ✅ Apple Frosted Glass Design
|
||||||
|
- ✅ Responsive UI
|
||||||
|
- ✅ Database Integration
|
||||||
|
- ✅ Bug Fixes
|
||||||
|
|
||||||
|
### **Production Ready:** 🎬
|
||||||
|
Die InFocus Movie App ist vollständig funktionsfähig und bereit für den produktiven Einsatz!
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Letzte Aktualisierung: 10. März 2026*
|
||||||
156
QUICK_START.md
Normal file
156
QUICK_START.md
Normal file
@ -0,0 +1,156 @@
|
|||||||
|
# Supabase Setup - Jetzt sofort durchführen!
|
||||||
|
|
||||||
|
## 🚀 Schnellste Methode (5 Minuten)
|
||||||
|
|
||||||
|
### 1. Supabase Dashboard öffnen
|
||||||
|
**URL:** https://supabase.com/dashboard/project/ekbpexbhuochrplzorce
|
||||||
|
|
||||||
|
### 2. SQL Editor → Tabellen erstellen
|
||||||
|
**Klick:** Links auf "SQL Editor"
|
||||||
|
|
||||||
|
**Füge diesen Code ein:**
|
||||||
|
```sql
|
||||||
|
-- Profiles table
|
||||||
|
create table if not exists public.profiles (
|
||||||
|
id uuid primary key references auth.users(id) on delete cascade,
|
||||||
|
display_name text not null,
|
||||||
|
avatar_url text,
|
||||||
|
created_at timestamptz default now()
|
||||||
|
);
|
||||||
|
|
||||||
|
alter table public.profiles enable row level security;
|
||||||
|
create policy if not exists "profiles_select_all" on public.profiles for select using (true);
|
||||||
|
create policy if not exists "profiles_insert_own" on public.profiles for insert with check (auth.uid() = id);
|
||||||
|
create policy if not exists "profiles_update_own" on public.profiles for update using (auth.uid() = id);
|
||||||
|
|
||||||
|
-- Auto-create profile trigger
|
||||||
|
create or replace function public.handle_new_user()
|
||||||
|
returns trigger
|
||||||
|
language plpgsql
|
||||||
|
security definer
|
||||||
|
set search_path = public
|
||||||
|
as $$
|
||||||
|
begin
|
||||||
|
insert into public.profiles (id, display_name)
|
||||||
|
values (
|
||||||
|
new.id,
|
||||||
|
coalesce(new.raw_user_meta_data ->> 'display_name', split_part(new.email, '@', 1))
|
||||||
|
)
|
||||||
|
on conflict (id) do nothing;
|
||||||
|
return new;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
drop trigger if exists on_auth_user_created on auth.users;
|
||||||
|
create trigger on_auth_user_created
|
||||||
|
after insert on auth.users
|
||||||
|
for each row
|
||||||
|
execute function public.handle_new_user();
|
||||||
|
|
||||||
|
-- Diary entries
|
||||||
|
create table if not exists public.diary_entries (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
user_id uuid not null references public.profiles(id) on delete cascade,
|
||||||
|
tmdb_movie_id integer not null,
|
||||||
|
movie_title text not null,
|
||||||
|
movie_poster_path text,
|
||||||
|
movie_year text,
|
||||||
|
rating numeric(2,1) check (rating >= 0.5 and rating <= 5),
|
||||||
|
review text,
|
||||||
|
watched_on date default current_date,
|
||||||
|
created_at timestamptz default now()
|
||||||
|
);
|
||||||
|
|
||||||
|
alter table public.diary_entries enable row level security;
|
||||||
|
create policy if not exists "diary_select_all" on public.diary_entries for select using (true);
|
||||||
|
create policy if not exists "diary_insert_own" on public.diary_entries for insert with check (auth.uid() = user_id);
|
||||||
|
create policy if not exists "diary_update_own" on public.diary_entries for update using (auth.uid() = user_id);
|
||||||
|
create policy if not exists "diary_delete_own" on public.diary_entries for delete using (auth.uid() = user_id);
|
||||||
|
|
||||||
|
-- Watchlist
|
||||||
|
create table if not exists public.watchlist (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
user_id uuid not null references public.profiles(id) on delete cascade,
|
||||||
|
tmdb_movie_id integer not null,
|
||||||
|
movie_title text not null,
|
||||||
|
movie_poster_path text,
|
||||||
|
movie_year text,
|
||||||
|
added_at timestamptz default now(),
|
||||||
|
unique(user_id, tmdb_movie_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
alter table public.watchlist enable row level security;
|
||||||
|
create policy if not exists "watchlist_select_all" on public.watchlist for select using (true);
|
||||||
|
create policy if not exists "watchlist_insert_own" on public.watchlist for insert with check (auth.uid() = user_id);
|
||||||
|
create policy if not exists "watchlist_delete_own" on public.watchlist for delete using (auth.uid() = user_id);
|
||||||
|
|
||||||
|
-- Lists
|
||||||
|
create table if not exists public.lists (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
user_id uuid not null references public.profiles(id) on delete cascade,
|
||||||
|
name text not null,
|
||||||
|
description text,
|
||||||
|
created_at timestamptz default now()
|
||||||
|
);
|
||||||
|
|
||||||
|
alter table public.lists enable row level security;
|
||||||
|
create policy if not exists "lists_select_all" on public.lists for select using (true);
|
||||||
|
create policy if not exists "lists_insert_own" on public.lists for insert with check (auth.uid() = user_id);
|
||||||
|
create policy if not exists "lists_update_own" on public.lists for update using (auth.uid() = user_id);
|
||||||
|
create policy if not exists "lists_delete_own" on public.lists for delete using (auth.uid() = user_id);
|
||||||
|
|
||||||
|
-- List items
|
||||||
|
create table if not exists public.list_items (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
list_id uuid not null references public.lists(id) on delete cascade,
|
||||||
|
tmdb_movie_id integer not null,
|
||||||
|
movie_title text not null,
|
||||||
|
movie_poster_path text,
|
||||||
|
movie_year text,
|
||||||
|
position integer default 0,
|
||||||
|
added_at timestamptz default now(),
|
||||||
|
unique(list_id, tmdb_movie_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
alter table public.list_items enable row level security;
|
||||||
|
create policy if not exists "list_items_select_all" on public.list_items for select using (true);
|
||||||
|
create policy if not exists "list_items_insert_own" on public.list_items for insert
|
||||||
|
with check (exists (select 1 from public.lists where id = list_id and user_id = auth.uid()));
|
||||||
|
create policy if not exists "list_items_delete_own" on public.list_items for delete
|
||||||
|
using (exists (select 1 from public.lists where id = list_id and user_id = auth.uid()));
|
||||||
|
|
||||||
|
-- Likes
|
||||||
|
create table if not exists public.likes (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
user_id uuid not null references public.profiles(id) on delete cascade,
|
||||||
|
diary_entry_id uuid not null references public.diary_entries(id) on delete cascade,
|
||||||
|
created_at timestamptz default now(),
|
||||||
|
unique(user_id, diary_entry_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
alter table public.likes enable row level security;
|
||||||
|
create policy if not exists "likes_select_all" on public.likes for select using (true);
|
||||||
|
create policy if not exists "likes_insert_own" on public.likes for insert with check (auth.uid() = user_id);
|
||||||
|
create policy if not exists "likes_delete_own" on public.likes for delete using (auth.uid() = user_id);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Klick:** "RUN" → Alle Tabellen werden erstellt!
|
||||||
|
|
||||||
|
### 3. Authentication konfigurieren
|
||||||
|
**Gehe zu:** Authentication → Settings
|
||||||
|
|
||||||
|
**Setze:**
|
||||||
|
- Site URL: `http://localhost:3000`
|
||||||
|
- Redirect URLs: `http://localhost:3000/auth/callback`
|
||||||
|
- Enable email confirmations: **YES**
|
||||||
|
|
||||||
|
### 4. FERTIG! 🎉
|
||||||
|
Jetzt kannst du:
|
||||||
|
- Benutzer registrieren
|
||||||
|
- Email-Bestätigung erhalten
|
||||||
|
- Filme loggen
|
||||||
|
- Watchlist verwenden
|
||||||
|
- Listen erstellen
|
||||||
|
|
||||||
|
### ⚡ Für schnelle Tests (ohne Email)
|
||||||
|
Im Supabase Dashboard: **Disable email confirmations** → Registrierung funktioniert sofort
|
||||||
118
SUPABASE_QUICK_SETUP.md
Normal file
118
SUPABASE_QUICK_SETUP.md
Normal file
@ -0,0 +1,118 @@
|
|||||||
|
# Supabase Quick Setup
|
||||||
|
|
||||||
|
## Methode 1: Supabase Dashboard (Empfohlen)
|
||||||
|
|
||||||
|
### 1. Öffne Supabase Dashboard
|
||||||
|
- Gehe zu: https://supabase.com/dashboard
|
||||||
|
- Login mit deinem Account
|
||||||
|
- Wähle Projekt: `ekbpexbhuochrplzorce`
|
||||||
|
|
||||||
|
### 2. Tabellen erstellen
|
||||||
|
**SQL Editor öffnen:**
|
||||||
|
- Links auf "SQL Editor" klicken
|
||||||
|
- Neues Query erstellen
|
||||||
|
|
||||||
|
**Schema erstellen:**
|
||||||
|
```sql
|
||||||
|
-- Führe diesen Code aus:
|
||||||
|
create table if not exists public.profiles (
|
||||||
|
id uuid primary key references auth.users(id) on delete cascade,
|
||||||
|
display_name text not null,
|
||||||
|
avatar_url text,
|
||||||
|
created_at timestamptz default now()
|
||||||
|
);
|
||||||
|
|
||||||
|
alter table public.profiles enable row level security;
|
||||||
|
create policy if not exists "profiles_select_all" on public.profiles for select using (true);
|
||||||
|
create policy if not exists "profiles_insert_own" on public.profiles for insert with check (auth.uid() = id);
|
||||||
|
create policy if not exists "profiles_update_own" on public.profiles for update using (auth.uid() = id);
|
||||||
|
|
||||||
|
-- Auto-create profile on signup
|
||||||
|
create or replace function public.handle_new_user()
|
||||||
|
returns trigger
|
||||||
|
language plpgsql
|
||||||
|
security definer
|
||||||
|
set search_path = public
|
||||||
|
as $$
|
||||||
|
begin
|
||||||
|
insert into public.profiles (id, display_name)
|
||||||
|
values (
|
||||||
|
new.id,
|
||||||
|
coalesce(new.raw_user_meta_data ->> 'display_name', split_part(new.email, '@', 1))
|
||||||
|
)
|
||||||
|
on conflict (id) do nothing;
|
||||||
|
return new;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
drop trigger if exists on_auth_user_created on auth.users;
|
||||||
|
create trigger on_auth_user_created
|
||||||
|
after insert on auth.users
|
||||||
|
for each row
|
||||||
|
execute function public.handle_new_user();
|
||||||
|
```
|
||||||
|
|
||||||
|
**Weitere Tabellen:**
|
||||||
|
```sql
|
||||||
|
-- Diary entries
|
||||||
|
create table if not exists public.diary_entries (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
user_id uuid not null references public.profiles(id) on delete cascade,
|
||||||
|
tmdb_movie_id integer not null,
|
||||||
|
movie_title text not null,
|
||||||
|
movie_poster_path text,
|
||||||
|
movie_year text,
|
||||||
|
rating numeric(2,1) check (rating >= 0.5 and rating <= 5),
|
||||||
|
review text,
|
||||||
|
watched_on date default current_date,
|
||||||
|
created_at timestamptz default now()
|
||||||
|
);
|
||||||
|
|
||||||
|
alter table public.diary_entries enable row level security;
|
||||||
|
create policy if not exists "diary_select_all" on public.diary_entries for select using (true);
|
||||||
|
create policy if not exists "diary_insert_own" on public.diary_entries for insert with check (auth.uid() = user_id);
|
||||||
|
create policy if not exists "diary_update_own" on public.diary_entries for update using (auth.uid() = user_id);
|
||||||
|
create policy if not exists "diary_delete_own" on public.diary_entries for delete using (auth.uid() = user_id);
|
||||||
|
|
||||||
|
-- Watchlist
|
||||||
|
create table if not exists public.watchlist (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
user_id uuid not null references public.profiles(id) on delete cascade,
|
||||||
|
tmdb_movie_id integer not null,
|
||||||
|
movie_title text not null,
|
||||||
|
movie_poster_path text,
|
||||||
|
movie_year text,
|
||||||
|
added_at timestamptz default now(),
|
||||||
|
unique(user_id, tmdb_movie_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
alter table public.watchlist enable row level security;
|
||||||
|
create policy if not exists "watchlist_select_all" on public.watchlist for select using (true);
|
||||||
|
create policy if not exists "watchlist_insert_own" on public.watchlist for insert with check (auth.uid() = user_id);
|
||||||
|
create policy if not exists "watchlist_delete_own" on public.watchlist for delete using (auth.uid() = user_id);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Authentifizierung konfigurieren
|
||||||
|
**Authentication Settings:**
|
||||||
|
- Gehe zu **Authentication > Settings**
|
||||||
|
- Setze **Site URL**: `http://localhost:3000`
|
||||||
|
- Füge hinzu zu **Redirect URLs**: `http://localhost:3000/auth/callback`
|
||||||
|
- Aktiviere **Enable email confirmations**
|
||||||
|
|
||||||
|
### 4. Testen
|
||||||
|
- Registriere neuen Benutzer
|
||||||
|
- Bestätigungs-Email sollte ankommen
|
||||||
|
- Nach Bestätigung sollte Benutzer eingeloggt sein
|
||||||
|
|
||||||
|
## Methode 2: MCP Server (Wenn verfügbar)
|
||||||
|
|
||||||
|
Wenn der Supabase MCP Server funktioniert:
|
||||||
|
1. Füge den MCP Code zu deiner IDE Konfiguration hinzu
|
||||||
|
2. Starte IDE neu
|
||||||
|
3. MCP sollte automatisch Tabellen erstellen können
|
||||||
|
|
||||||
|
## Schneller Test (Ohne Email-Bestätigung)
|
||||||
|
Für Entwicklung:
|
||||||
|
- Im Supabase Dashboard: **Authentication > Settings**
|
||||||
|
- Deaktiviere **Enable email confirmations**
|
||||||
|
- Registrierung funktioniert sofort ohne Email
|
||||||
47
SUPABASE_SETUP.md
Normal file
47
SUPABASE_SETUP.md
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
# Supabase Setup Anleitung
|
||||||
|
|
||||||
|
## 1. Tabellen erstellen
|
||||||
|
Führe diese SQL-Skripte im Supabase SQL Editor aus:
|
||||||
|
|
||||||
|
### Schema erstellen:
|
||||||
|
```sql
|
||||||
|
-- Führe scripts/001_create_schema.sql aus
|
||||||
|
```
|
||||||
|
|
||||||
|
### Tabellen erstellen:
|
||||||
|
```sql
|
||||||
|
-- Führe scripts/001_create_tables.sql aus
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. Authentifizierung konfigurieren
|
||||||
|
|
||||||
|
### Im Supabase Dashboard:
|
||||||
|
1. Gehe zu **Authentication > Settings**
|
||||||
|
2. Setze **Site URL**: `http://localhost:3000`
|
||||||
|
3. Füge zu **Redirect URLs** hinzu: `http://localhost:3000/auth/callback`
|
||||||
|
4. Aktiviere **Enable email confirmations**
|
||||||
|
5. Passe das **Email Template** an, falls nötig
|
||||||
|
|
||||||
|
### Email-Template anpassen (falls nötig):
|
||||||
|
```
|
||||||
|
Confirmation Link: {{ .ConfirmationURL }}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. Environment Variablen prüfen
|
||||||
|
Die `.env.local` sollte enthalten:
|
||||||
|
```
|
||||||
|
NEXT_PUBLIC_SUPABASE_URL=https://ekbpexbhuochrplzorce.supabase.co
|
||||||
|
NEXT_PUBLIC_SUPABASE_ANON_KEY=sb_publishable__UII_iKx3pgvLQvc1xrN1w_qnwP6JOv
|
||||||
|
NEXT_PUBLIC_DEV_SUPABASE_REDIRECT_URL=http://localhost:3000/auth/callback
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. Testen
|
||||||
|
1. Registriere neuen Benutzer
|
||||||
|
2. Bestätigungs-E-Mail sollte ankommen
|
||||||
|
3. Klick auf Link sollte zur App zurückleiten
|
||||||
|
4. Benutzer sollte eingeloggt sein
|
||||||
|
|
||||||
|
## 5. Fehlersuche
|
||||||
|
Falls Email nicht ankommt:
|
||||||
|
- Prüfe Spam-Ordner
|
||||||
|
- Verwende temporär **Disable email confirmations** für Tests
|
||||||
49
TMDB_SETUP.md
Normal file
49
TMDB_SETUP.md
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
# TMDB API Setup für InFocus Movie App
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
Die Filmliste funktioniert nicht, weil der TMDB API Key fehlt.
|
||||||
|
|
||||||
|
## Lösung: TMDB API Key besorgen
|
||||||
|
|
||||||
|
### 1. TMDB Konto erstellen
|
||||||
|
1. Gehe zu: https://www.themoviedb.org/
|
||||||
|
2. Registriere dich kostenlos
|
||||||
|
3. Bestätige deine E-Mail
|
||||||
|
4. Login in dein Konto
|
||||||
|
|
||||||
|
### 2. API Key anfordern
|
||||||
|
1. Gehe zu: https://www.themoviedb.org/settings/api
|
||||||
|
2. Klicke auf "Request an API Key"
|
||||||
|
3. Fülle das Formular aus:
|
||||||
|
- Application Name: "InFocus Movie App"
|
||||||
|
- Application URL: "http://localhost:3000"
|
||||||
|
- Description: "Family movie diary app"
|
||||||
|
4. Warte auf Genehmigung (meistens sofort)
|
||||||
|
|
||||||
|
### 3. API Key eintragen
|
||||||
|
1. Öffne `.env.local` Datei
|
||||||
|
2. Ersetze `your_tmdb_api_key_here` mit deinem echten API Key:
|
||||||
|
```
|
||||||
|
TMDB_API_KEY=dein_echter_api_key_hier
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. App neustarten
|
||||||
|
1. Server stoppen (Ctrl+C)
|
||||||
|
2. `start_app.bat` ausführen oder `npm run dev`
|
||||||
|
|
||||||
|
## Testen
|
||||||
|
Nach dem Eintrag des API Keys sollte funktionieren:
|
||||||
|
- Filmliste laden
|
||||||
|
- Filme suchen
|
||||||
|
- Filmdetails anzeigen
|
||||||
|
- Filme zur Watchlist hinzufügen
|
||||||
|
|
||||||
|
## API Limits
|
||||||
|
- Free Account: 40 Anfragen pro 10 Sekunden
|
||||||
|
- Genug für Entwicklung und Tests
|
||||||
|
|
||||||
|
## Fehlerbehebung
|
||||||
|
Falls es immer noch nicht funktioniert:
|
||||||
|
1. API Key auf Tippfehler prüfen
|
||||||
|
2. TMDB Konto Status prüfen
|
||||||
|
3. Browser Console auf Fehler prüfen (F12)
|
||||||
34
WINDSURF_MCP_CONFIG.md
Normal file
34
WINDSURF_MCP_CONFIG.md
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
# Windsurf MCP Konfiguration
|
||||||
|
|
||||||
|
## Datei erstellen:
|
||||||
|
`~/.codeium/windsurf/mcp_config.json`
|
||||||
|
|
||||||
|
## Inhalt:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"supabase": {
|
||||||
|
"command": "npx",
|
||||||
|
"args": [
|
||||||
|
"-y",
|
||||||
|
"mcp-remote",
|
||||||
|
"https://mcp.supabase.com/mcp?project_ref=ekbpexbhuochrplzorce"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Anleitung:
|
||||||
|
1. Öffne Datei-Explorer
|
||||||
|
2. Navigiere zu: `C:\Users\Admin\.codeium\windsurf\`
|
||||||
|
3. Erstelle Datei: `mcp_config.json`
|
||||||
|
4. Kopiere den JSON-Code hinein
|
||||||
|
5. Speichern
|
||||||
|
6. Windsurf neu starten
|
||||||
|
|
||||||
|
## Danach testen:
|
||||||
|
Nach Neustart sollte der Supabase MCP Server verfügbar sein und ich kann:
|
||||||
|
- Tabellen automatisch erstellen
|
||||||
|
- Datenbank-Struktur prüfen
|
||||||
|
- Authentifizierung konfigurieren
|
||||||
@ -1,30 +1,62 @@
|
|||||||
import { createClient } from "@/lib/supabase/server"
|
import { createClient } from "@/lib/supabase/server"
|
||||||
import { DiaryContent } from "@/components/diary-content"
|
import { DiaryContent } from "@/components/diary-content"
|
||||||
|
|
||||||
|
interface DiaryEntry {
|
||||||
|
id: string
|
||||||
|
tmdb_movie_id: number
|
||||||
|
movie_title: string
|
||||||
|
movie_poster_path: string | null
|
||||||
|
rating: number | null
|
||||||
|
review: string | null
|
||||||
|
watched_on: string
|
||||||
|
}
|
||||||
|
|
||||||
export default async function DiaryPage() {
|
export default async function DiaryPage() {
|
||||||
const supabase = await createClient()
|
const supabase = await createClient()
|
||||||
const {
|
const {
|
||||||
data: { user },
|
data: { user },
|
||||||
} = await supabase.auth.getUser()
|
} = await supabase.auth.getUser()
|
||||||
|
|
||||||
const { data: entries } = await supabase
|
if (!user) {
|
||||||
|
return (
|
||||||
|
<DiaryContent
|
||||||
|
entries={[]}
|
||||||
|
watchlist={[]}
|
||||||
|
lists={[]}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data: entries, error: entriesError } = await supabase
|
||||||
.from("diary_entries")
|
.from("diary_entries")
|
||||||
.select("*")
|
.select("*")
|
||||||
.eq("user_id", user!.id)
|
.eq("user_id", user.id)
|
||||||
.order("watched_at", { ascending: false })
|
.order("watched_on", { ascending: false })
|
||||||
|
|
||||||
const { data: watchlist } = await supabase
|
if (entriesError) {
|
||||||
|
console.error("Error fetching diary entries:", entriesError)
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data: watchlist, error: watchlistError } = await supabase
|
||||||
.from("watchlist")
|
.from("watchlist")
|
||||||
.select("*")
|
.select("*")
|
||||||
.eq("user_id", user!.id)
|
.eq("user_id", user.id)
|
||||||
.order("added_at", { ascending: false })
|
.order("added_at", { ascending: false })
|
||||||
|
|
||||||
const { data: lists } = await supabase
|
if (watchlistError) {
|
||||||
|
console.error("Error fetching watchlist:", watchlistError)
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data: lists, error: listsError } = await supabase
|
||||||
.from("lists")
|
.from("lists")
|
||||||
.select("*, list_items(count)")
|
.select("*, list_items(count)")
|
||||||
.eq("user_id", user!.id)
|
.eq("user_id", user.id)
|
||||||
.order("created_at", { ascending: false })
|
.order("created_at", { ascending: false })
|
||||||
|
|
||||||
|
if (listsError) {
|
||||||
|
console.error("Error fetching lists:", listsError)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DiaryContent
|
<DiaryContent
|
||||||
entries={entries || []}
|
entries={entries || []}
|
||||||
|
|||||||
@ -22,6 +22,7 @@ export default function ListsPage() {
|
|||||||
const [newListName, setNewListName] = useState("")
|
const [newListName, setNewListName] = useState("")
|
||||||
const [newListDesc, setNewListDesc] = useState("")
|
const [newListDesc, setNewListDesc] = useState("")
|
||||||
const [creatingList, setCreatingList] = useState(false)
|
const [creatingList, setCreatingList] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadLists()
|
loadLists()
|
||||||
@ -66,24 +67,33 @@ export default function ListsPage() {
|
|||||||
.single()
|
.single()
|
||||||
|
|
||||||
if (!error && data) {
|
if (!error && data) {
|
||||||
setLists([data, ...lists])
|
setLists(prev => [data, ...prev])
|
||||||
setNewListName("")
|
setNewListName("")
|
||||||
setNewListDesc("")
|
setNewListDesc("")
|
||||||
setShowNewList(false)
|
setShowNewList(false)
|
||||||
|
} else if (error) {
|
||||||
|
setError("Fehler beim Erstellen der Liste")
|
||||||
|
setTimeout(() => setError(null), 3000)
|
||||||
}
|
}
|
||||||
setCreatingList(false)
|
setCreatingList(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteList(id: string) {
|
async function deleteList(id: string) {
|
||||||
const supabase = createClient()
|
const supabase = createClient()
|
||||||
await supabase.from("lists").delete().eq("id", id)
|
const { error } = await supabase.from("lists").delete().eq("id", id)
|
||||||
setLists(lists.filter((l) => l.id !== id))
|
|
||||||
|
if (error) {
|
||||||
|
setError("Fehler beim Löschen der Liste")
|
||||||
|
setTimeout(() => setError(null), 3000)
|
||||||
|
} else {
|
||||||
|
setLists(lists.filter((l) => l.id !== id))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<main className="mx-auto max-w-lg">
|
<main className="mx-auto max-w-lg">
|
||||||
<header className="sticky top-0 z-40 border-b border-border bg-background/95 px-4 py-3 backdrop-blur-md">
|
<header className="sticky top-0 z-40 glass-header px-4 py-3">
|
||||||
<h1 className="font-heading text-xl font-bold text-foreground">
|
<h1 className="font-heading text-xl font-bold text-foreground">
|
||||||
Meine Listen
|
Meine Listen
|
||||||
</h1>
|
</h1>
|
||||||
@ -97,22 +107,28 @@ export default function ListsPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="mx-auto max-w-lg">
|
<main className="mx-auto max-w-lg">
|
||||||
<header className="sticky top-0 z-40 border-b border-border bg-background/95 px-4 py-3 backdrop-blur-md">
|
<header className="sticky top-0 z-40 glass-header px-4 py-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h1 className="font-heading text-xl font-bold text-foreground">
|
<h1 className="font-heading text-xl font-bold text-foreground">
|
||||||
Meine Listen
|
Meine Listen
|
||||||
</h1>
|
</h1>
|
||||||
<span className="rounded-full bg-secondary px-2.5 py-1 text-xs font-medium text-muted-foreground">
|
<span className="glass-tag">
|
||||||
{lists.length}
|
{lists.length}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="mx-4 mt-4 rounded-lg bg-destructive/10 p-3 text-sm text-destructive">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="px-4 pt-4">
|
<div className="px-4 pt-4">
|
||||||
{/* New list button */}
|
{/* New list button */}
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowNewList(!showNewList)}
|
onClick={() => setShowNewList(!showNewList)}
|
||||||
className="mb-4 flex w-full items-center justify-center gap-2 rounded-xl border border-dashed border-border bg-card py-3 text-sm font-medium text-muted-foreground transition-colors hover:border-primary hover:text-primary"
|
className="mb-4 glass-card flex w-full items-center justify-center gap-2 py-3 text-sm font-medium text-muted-foreground transition-all hover:bg-white/[0.08] active:scale-[0.98]"
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<Plus className="h-4 w-4" />
|
<Plus className="h-4 w-4" />
|
||||||
@ -121,32 +137,32 @@ export default function ListsPage() {
|
|||||||
|
|
||||||
{/* New list form */}
|
{/* New list form */}
|
||||||
{showNewList && (
|
{showNewList && (
|
||||||
<form onSubmit={createList} className="mb-4 flex flex-col gap-2 rounded-xl border border-border bg-card p-4">
|
<form onSubmit={createList} className="mb-4 glass-card flex flex-col gap-2 p-4">
|
||||||
<input
|
<input
|
||||||
value={newListName}
|
value={newListName}
|
||||||
onChange={(e) => setNewListName(e.target.value)}
|
onChange={(e) => setNewListName(e.target.value)}
|
||||||
placeholder="Listenname..."
|
placeholder="Listenname..."
|
||||||
className="h-10 rounded-lg border border-border bg-secondary px-3 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
|
className="glass-input h-10 text-sm"
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
value={newListDesc}
|
value={newListDesc}
|
||||||
onChange={(e) => setNewListDesc(e.target.value)}
|
onChange={(e) => setNewListDesc(e.target.value)}
|
||||||
placeholder="Beschreibung (optional)..."
|
placeholder="Beschreibung (optional)..."
|
||||||
className="h-10 rounded-lg border border-border bg-secondary px-3 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
|
className="glass-input h-10 text-sm"
|
||||||
/>
|
/>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setShowNewList(false)}
|
onClick={() => setShowNewList(false)}
|
||||||
className="flex-1 rounded-lg border border-border bg-card py-2.5 text-sm font-medium text-muted-foreground"
|
className="glass-button flex-1 py-2.5 text-sm font-medium"
|
||||||
>
|
>
|
||||||
Abbrechen
|
Abbrechen
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={creatingList || !newListName.trim()}
|
disabled={creatingList || !newListName.trim()}
|
||||||
className="flex flex-1 items-center justify-center gap-2 rounded-lg bg-primary py-2.5 text-sm font-semibold text-primary-foreground disabled:opacity-50"
|
className="glass-button flex-1 items-center justify-center gap-2 bg-primary py-2.5 text-sm font-semibold text-primary-foreground shadow-lg shadow-primary/20 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{creatingList ? (
|
{creatingList ? (
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
@ -160,7 +176,7 @@ export default function ListsPage() {
|
|||||||
|
|
||||||
{/* Lists */}
|
{/* Lists */}
|
||||||
{lists.length === 0 && !showNewList ? (
|
{lists.length === 0 && !showNewList ? (
|
||||||
<div className="flex flex-col items-center gap-3 rounded-xl border border-border bg-card px-6 py-12 text-center">
|
<div className="glass-card flex flex-col items-center gap-3 px-6 py-12 text-center">
|
||||||
<ListIcon className="h-10 w-10 text-muted-foreground" />
|
<ListIcon className="h-10 w-10 text-muted-foreground" />
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium text-foreground">
|
<p className="text-sm font-medium text-foreground">
|
||||||
@ -176,7 +192,7 @@ export default function ListsPage() {
|
|||||||
{lists.map((list) => (
|
{lists.map((list) => (
|
||||||
<div
|
<div
|
||||||
key={list.id}
|
key={list.id}
|
||||||
className="group flex items-center gap-3 rounded-xl border border-border bg-card p-4 transition-colors hover:bg-secondary"
|
className="glass-card group flex items-center gap-3 p-4 transition-all hover:bg-white/[0.08] active:scale-[0.98]"
|
||||||
>
|
>
|
||||||
<Link
|
<Link
|
||||||
href={`/lists/${list.id}`}
|
href={`/lists/${list.id}`}
|
||||||
@ -197,7 +213,7 @@ export default function ListsPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span className="rounded-full bg-secondary px-2.5 py-1 text-xs font-medium text-muted-foreground">
|
<span className="glass-tag">
|
||||||
{list.list_items?.[0]?.count || 0} Filme
|
{list.list_items?.[0]?.count || 0} Filme
|
||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@ -7,9 +7,10 @@ import { useRouter, useSearchParams } from "next/navigation"
|
|||||||
import Image from "next/image"
|
import Image from "next/image"
|
||||||
import { createClient } from "@/lib/supabase/client"
|
import { createClient } from "@/lib/supabase/client"
|
||||||
import { posterUrl } from "@/lib/tmdb"
|
import { posterUrl } from "@/lib/tmdb"
|
||||||
import type { TMDBMovie } from "@/lib/tmdb"
|
import type { TMDBMovie, TMDBTVShow, TMDBMultiResult } from "@/lib/tmdb"
|
||||||
|
import { getExternalRatings } from "@/lib/external-ratings"
|
||||||
import { StarRating } from "@/components/star-rating"
|
import { StarRating } from "@/components/star-rating"
|
||||||
import { Search, Film, Check, Loader2 } from "lucide-react"
|
import { Search, Film, Check, Loader2, Tv } from "lucide-react"
|
||||||
|
|
||||||
function LogPageContent() {
|
function LogPageContent() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@ -23,19 +24,21 @@ function LogPageContent() {
|
|||||||
preselectedId ? "rate" : "search"
|
preselectedId ? "rate" : "search"
|
||||||
)
|
)
|
||||||
const [query, setQuery] = useState("")
|
const [query, setQuery] = useState("")
|
||||||
const [results, setResults] = useState<TMDBMovie[]>([])
|
const [results, setResults] = useState<(TMDBMovie | TMDBTVShow | TMDBMultiResult)[]>([])
|
||||||
const [searching, setSearching] = useState(false)
|
const [searching, setSearching] = useState(false)
|
||||||
|
|
||||||
const [selectedMovie, setSelectedMovie] = useState<{
|
const [selectedMovie, setSelectedMovie] = useState<{
|
||||||
id: number
|
id: number
|
||||||
title: string
|
title: string
|
||||||
poster_path: string | null
|
poster_path: string | null
|
||||||
|
media_type: 'movie' | 'tv'
|
||||||
} | null>(
|
} | null>(
|
||||||
preselectedId
|
preselectedId
|
||||||
? {
|
? {
|
||||||
id: Number(preselectedId),
|
id: Number(preselectedId),
|
||||||
title: preselectedTitle || "",
|
title: preselectedTitle || "",
|
||||||
poster_path: preselectedPoster || null,
|
poster_path: preselectedPoster || null,
|
||||||
|
media_type: 'movie' // Default to movie for preselected
|
||||||
}
|
}
|
||||||
: null
|
: null
|
||||||
)
|
)
|
||||||
@ -45,7 +48,12 @@ function LogPageContent() {
|
|||||||
const [watchedAt, setWatchedAt] = useState(
|
const [watchedAt, setWatchedAt] = useState(
|
||||||
new Date().toISOString().slice(0, 10)
|
new Date().toISOString().slice(0, 10)
|
||||||
)
|
)
|
||||||
|
const [seasonNumber, setSeasonNumber] = useState("")
|
||||||
|
const [episodeNumber, setEpisodeNumber] = useState("")
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [externalRatings, setExternalRatings] = useState<any>(null)
|
||||||
|
const [loadingRatings, setLoadingRatings] = useState(false)
|
||||||
|
|
||||||
const doSearch = useCallback(async (q: string) => {
|
const doSearch = useCallback(async (q: string) => {
|
||||||
if (!q.trim()) {
|
if (!q.trim()) {
|
||||||
@ -68,37 +76,105 @@ function LogPageContent() {
|
|||||||
return () => clearTimeout(timer)
|
return () => clearTimeout(timer)
|
||||||
}, [query, doSearch])
|
}, [query, doSearch])
|
||||||
|
|
||||||
function selectMovie(movie: TMDBMovie) {
|
function getMediaTypeIcon(movie: TMDBMovie | TMDBTVShow | TMDBMultiResult) {
|
||||||
|
const isTV = 'first_air_date' in movie || ('media_type' in movie && movie.media_type === 'tv')
|
||||||
|
return isTV ? Tv : Film
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDisplayTitle(movie: TMDBMovie | TMDBTVShow | TMDBMultiResult) {
|
||||||
|
return 'title' in movie ? movie.title : movie.name || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectMovie(movie: TMDBMovie | TMDBTVShow | TMDBMultiResult) {
|
||||||
|
const isTV = 'first_air_date' in movie || ('media_type' in movie && movie.media_type === 'tv')
|
||||||
setSelectedMovie({
|
setSelectedMovie({
|
||||||
id: movie.id,
|
id: movie.id,
|
||||||
title: movie.title,
|
title: 'title' in movie ? movie.title : movie.name || '',
|
||||||
poster_path: movie.poster_path,
|
poster_path: movie.poster_path,
|
||||||
|
media_type: isTV ? 'tv' : 'movie'
|
||||||
})
|
})
|
||||||
setStep("rate")
|
setStep("rate")
|
||||||
|
|
||||||
|
// Load external ratings
|
||||||
|
loadExternalRatings(movie.id, isTV ? 'tv' : 'movie')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadExternalRatings(tmdbId: number, mediaType: 'movie' | 'tv') {
|
||||||
|
setLoadingRatings(true)
|
||||||
|
try {
|
||||||
|
const ratings = await getExternalRatings(tmdbId, mediaType)
|
||||||
|
setExternalRatings(ratings)
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to load external ratings:', error)
|
||||||
|
} finally {
|
||||||
|
setLoadingRatings(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSubmit(e: React.FormEvent) {
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (!selectedMovie) return
|
if (!selectedMovie) return
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
|
setError(null)
|
||||||
|
|
||||||
|
// Validate date
|
||||||
|
const watchedDate = new Date(watchedAt)
|
||||||
|
const today = new Date()
|
||||||
|
if (isNaN(watchedDate.getTime()) || watchedDate > today) {
|
||||||
|
setError("Ungültiges Datum")
|
||||||
|
setSaving(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate season/episode for TV shows
|
||||||
|
if (selectedMovie.media_type === 'tv') {
|
||||||
|
if (seasonNumber && (parseInt(seasonNumber) < 1 || isNaN(parseInt(seasonNumber)))) {
|
||||||
|
setError("Ungültige Staffelnummer")
|
||||||
|
setSaving(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (episodeNumber && (parseInt(episodeNumber) < 1 || isNaN(parseInt(episodeNumber)))) {
|
||||||
|
setError("Ungültige Episodennummer")
|
||||||
|
setSaving(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// If episode is provided, season must also be provided
|
||||||
|
if (episodeNumber && !seasonNumber) {
|
||||||
|
setError("Bei Episodenangabe muss auch die Staffel angegeben werden")
|
||||||
|
setSaving(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const supabase = createClient()
|
const supabase = createClient()
|
||||||
const {
|
const {
|
||||||
data: { user },
|
data: { user },
|
||||||
} = await supabase.auth.getUser()
|
} = await supabase.auth.getUser()
|
||||||
if (!user) return
|
if (!user) {
|
||||||
|
setError("Nicht angemeldet")
|
||||||
|
setSaving(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const { error } = await supabase.from("diary_entries").insert({
|
const insertData = {
|
||||||
user_id: user.id,
|
user_id: user.id,
|
||||||
tmdb_id: selectedMovie.id,
|
tmdb_movie_id: selectedMovie.id,
|
||||||
title: selectedMovie.title,
|
movie_title: selectedMovie.title,
|
||||||
poster_path: selectedMovie.poster_path,
|
movie_poster_path: selectedMovie.poster_path,
|
||||||
|
media_type: selectedMovie.media_type,
|
||||||
rating: rating || null,
|
rating: rating || null,
|
||||||
|
imdb_rating: externalRatings?.imdb_rating || null,
|
||||||
review: review.trim() || null,
|
review: review.trim() || null,
|
||||||
watched_at: watchedAt,
|
watched_on: watchedAt,
|
||||||
})
|
season_number: selectedMovie.media_type === 'tv' && seasonNumber ? parseInt(seasonNumber) : null,
|
||||||
|
episode_number: selectedMovie.media_type === 'tv' && episodeNumber ? parseInt(episodeNumber) : null,
|
||||||
|
}
|
||||||
|
|
||||||
if (!error) {
|
const { data, error } = await supabase.from("diary_entries").insert(insertData).select()
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
setError("Fehler beim Loggen: " + error.message)
|
||||||
|
} else {
|
||||||
router.push("/diary")
|
router.push("/diary")
|
||||||
router.refresh()
|
router.refresh()
|
||||||
}
|
}
|
||||||
@ -113,19 +189,31 @@ function LogPageContent() {
|
|||||||
return (
|
return (
|
||||||
<main className="mx-auto max-w-lg">
|
<main className="mx-auto max-w-lg">
|
||||||
<header className="sticky top-0 z-40 glass-header px-4 py-3">
|
<header className="sticky top-0 z-40 glass-header px-4 py-3">
|
||||||
<h1 className="mb-3 font-heading text-lg font-bold text-foreground">
|
<div className="flex items-center justify-between">
|
||||||
Film loggen
|
<h1 className="mb-3 font-heading text-lg font-bold text-foreground">
|
||||||
</h1>
|
Film loggen
|
||||||
<div className="relative">
|
</h1>
|
||||||
<Search className="absolute left-3 top-1/2 h-5 w-5 -translate-y-1/2 text-muted-foreground" />
|
<button
|
||||||
<input
|
onClick={() => {
|
||||||
type="text"
|
setStep("search")
|
||||||
value={query}
|
setSelectedMovie(null)
|
||||||
onChange={(e) => setQuery(e.target.value)}
|
}}
|
||||||
placeholder="Welchen Film hast du gesehen?"
|
className="text-sm text-muted-foreground hover:text-foreground"
|
||||||
className="glass-input h-11 w-full pl-10 pr-4 text-sm"
|
type="button"
|
||||||
autoFocus
|
>
|
||||||
/>
|
Anderen Film
|
||||||
|
</button>
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-3 top-1/2 h-5 w-5 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
placeholder="Welchen Film hast du gesehen?"
|
||||||
|
className="glass-input h-11 w-full pl-10 pr-4 text-sm"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@ -154,7 +242,7 @@ function LogPageContent() {
|
|||||||
{url ? (
|
{url ? (
|
||||||
<Image
|
<Image
|
||||||
src={url || "/placeholder.svg"}
|
src={url || "/placeholder.svg"}
|
||||||
alt={movie.title}
|
alt={'title' in movie ? movie.title : movie.name}
|
||||||
fill
|
fill
|
||||||
className="object-cover"
|
className="object-cover"
|
||||||
sizes="44px"
|
sizes="44px"
|
||||||
@ -166,11 +254,17 @@ function LogPageContent() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium text-foreground">
|
<div className="flex items-center gap-1">
|
||||||
{movie.title}
|
{(() => {
|
||||||
</p>
|
const Icon = getMediaTypeIcon(movie)
|
||||||
|
return <Icon className="h-3 w-3 text-muted-foreground" />
|
||||||
|
})()}
|
||||||
|
<p className="text-sm font-medium text-foreground">
|
||||||
|
{getDisplayTitle(movie)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{movie.release_date?.slice(0, 4)}
|
{'release_date' in movie ? movie.release_date?.slice(0, 4) : movie.first_air_date?.slice(0, 4)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
@ -202,6 +296,12 @@ function LogPageContent() {
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="px-4 pt-6">
|
<form onSubmit={handleSubmit} className="px-4 pt-6">
|
||||||
|
{error && (
|
||||||
|
<div className="mb-4 rounded-lg bg-destructive/10 p-3 text-sm text-destructive">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Selected movie */}
|
{/* Selected movie */}
|
||||||
<div className="flex gap-4">
|
<div className="flex gap-4">
|
||||||
<div className="relative h-32 w-[86px] shrink-0 overflow-hidden rounded-lg bg-secondary">
|
<div className="relative h-32 w-[86px] shrink-0 overflow-hidden rounded-lg bg-secondary">
|
||||||
@ -239,6 +339,88 @@ function LogPageContent() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* External Ratings */}
|
||||||
|
{loadingRatings && (
|
||||||
|
<div className="mt-4 flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
Lade externe Bewertungen...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{externalRatings && !loadingRatings && (
|
||||||
|
<div className="mt-4 glass-card p-3">
|
||||||
|
<h4 className="mb-2 text-sm font-medium text-foreground">Externe Bewertungen</h4>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{externalRatings.imdb_rating && (
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-xs text-muted-foreground">IMDb</span>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<span className="text-sm font-medium">{externalRatings.imdb_rating}</span>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
({externalRatings.imdb_vote_count?.toLocaleString()})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{externalRatings.rotten_tomatoes_rating && (
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-xs text-muted-foreground">Rotten Tomatoes</span>
|
||||||
|
<span className="text-sm font-medium">{externalRatings.rotten_tomatoes_rating}%</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!externalRatings.imdb_rating && !externalRatings.rotten_tomatoes_rating && (
|
||||||
|
<p className="text-xs text-muted-foreground">Keine externen Bewertungen verfügbar</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* TV Show Episode Info */}
|
||||||
|
{selectedMovie?.media_type === 'tv' && (
|
||||||
|
<div className="mt-5 glass-card p-4">
|
||||||
|
<h4 className="mb-3 text-sm font-medium text-foreground">Episoden-Informationen</h4>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="seasonNumber"
|
||||||
|
className="mb-1 block text-xs font-medium text-muted-foreground"
|
||||||
|
>
|
||||||
|
Staffel
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="seasonNumber"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
value={seasonNumber}
|
||||||
|
onChange={(e) => setSeasonNumber(e.target.value)}
|
||||||
|
placeholder="z.B. 1"
|
||||||
|
className="glass-input h-10 w-full px-3 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="episodeNumber"
|
||||||
|
className="mb-1 block text-xs font-medium text-muted-foreground"
|
||||||
|
>
|
||||||
|
Episode
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="episodeNumber"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
value={episodeNumber}
|
||||||
|
onChange={(e) => setEpisodeNumber(e.target.value)}
|
||||||
|
placeholder="z.B. 1"
|
||||||
|
className="glass-input h-10 w-full px-3 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-xs text-muted-foreground">
|
||||||
|
Optional - Lasse leer für die ganze Serie
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Watch date */}
|
{/* Watch date */}
|
||||||
<div className="mt-5">
|
<div className="mt-5">
|
||||||
<label
|
<label
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { getMovie } from "@/lib/tmdb"
|
import { getMovie } from "@/lib/tmdb"
|
||||||
|
import { getExternalRatings } from "@/lib/external-ratings"
|
||||||
import { createClient } from "@/lib/supabase/server"
|
import { createClient } from "@/lib/supabase/server"
|
||||||
import { MovieDetail } from "@/components/movie-detail"
|
import { MovieDetail } from "@/components/movie-detail"
|
||||||
|
|
||||||
@ -9,6 +10,14 @@ export default async function MoviePage({
|
|||||||
}) {
|
}) {
|
||||||
const { id } = await params
|
const { id } = await params
|
||||||
const movie = await getMovie(Number(id))
|
const movie = await getMovie(Number(id))
|
||||||
|
|
||||||
|
// Get external ratings
|
||||||
|
let externalRatings = null
|
||||||
|
try {
|
||||||
|
externalRatings = await getExternalRatings(Number(id), 'movie')
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to load external ratings:', error)
|
||||||
|
}
|
||||||
|
|
||||||
const supabase = await createClient()
|
const supabase = await createClient()
|
||||||
const {
|
const {
|
||||||
@ -19,7 +28,7 @@ export default async function MoviePage({
|
|||||||
const { data: watchlistItem } = await supabase
|
const { data: watchlistItem } = await supabase
|
||||||
.from("watchlist")
|
.from("watchlist")
|
||||||
.select("id")
|
.select("id")
|
||||||
.eq("user_id", user!.id)
|
.eq("user_id", user?.id || "")
|
||||||
.eq("tmdb_id", movie.id)
|
.eq("tmdb_id", movie.id)
|
||||||
.maybeSingle()
|
.maybeSingle()
|
||||||
|
|
||||||
@ -33,6 +42,7 @@ export default async function MoviePage({
|
|||||||
return (
|
return (
|
||||||
<MovieDetail
|
<MovieDetail
|
||||||
movie={movie}
|
movie={movie}
|
||||||
|
externalRatings={externalRatings}
|
||||||
isInWatchlist={!!watchlistItem}
|
isInWatchlist={!!watchlistItem}
|
||||||
familyEntries={familyEntries || []}
|
familyEntries={familyEntries || []}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -9,7 +9,7 @@ export default async function ProfilePage() {
|
|||||||
|
|
||||||
const { data: profile } = await supabase
|
const { data: profile } = await supabase
|
||||||
.from("profiles")
|
.from("profiles")
|
||||||
.select("*")
|
.select("display_name, avatar_url, theme")
|
||||||
.eq("id", user!.id)
|
.eq("id", user!.id)
|
||||||
.single()
|
.single()
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server"
|
import { NextRequest, NextResponse } from "next/server"
|
||||||
import { searchMovies } from "@/lib/tmdb"
|
import { searchAll } from "@/lib/tmdb"
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
const query = request.nextUrl.searchParams.get("q")
|
const query = request.nextUrl.searchParams.get("q")
|
||||||
@ -10,7 +10,7 @@ export async function GET(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const data = await searchMovies(query, Number(page))
|
const data = await searchAll(query, Number(page))
|
||||||
return NextResponse.json(data)
|
return NextResponse.json(data)
|
||||||
} catch {
|
} catch {
|
||||||
return NextResponse.json({ error: "Suche fehlgeschlagen" }, { status: 500 })
|
return NextResponse.json({ error: "Suche fehlgeschlagen" }, { status: 500 })
|
||||||
|
|||||||
@ -7,6 +7,8 @@ import { useRouter } from "next/navigation"
|
|||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { createClient } from "@/lib/supabase/client"
|
import { createClient } from "@/lib/supabase/client"
|
||||||
import { Film, Eye, Loader2 } from "lucide-react"
|
import { Film, Eye, Loader2 } from "lucide-react"
|
||||||
|
import { useLanguage } from "@/contexts/LanguageContext"
|
||||||
|
import { LanguageToggle } from "@/components/language-toggle"
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
const [email, setEmail] = useState("")
|
const [email, setEmail] = useState("")
|
||||||
@ -14,6 +16,7 @@ export default function LoginPage() {
|
|||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const { t } = useLanguage()
|
||||||
|
|
||||||
async function handleLogin(e: React.FormEvent) {
|
async function handleLogin(e: React.FormEvent) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
@ -27,7 +30,7 @@ export default function LoginPage() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
setError("E-Mail oder Passwort falsch.")
|
setError(t("auth.invalidCredentials"))
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -39,6 +42,9 @@ export default function LoginPage() {
|
|||||||
return (
|
return (
|
||||||
<main className="flex min-h-dvh flex-col items-center justify-center px-6">
|
<main className="flex min-h-dvh flex-col items-center justify-center px-6">
|
||||||
<div className="w-full max-w-sm">
|
<div className="w-full max-w-sm">
|
||||||
|
<div className="mb-6 flex justify-end">
|
||||||
|
<LanguageToggle />
|
||||||
|
</div>
|
||||||
<div className="mb-10 flex flex-col items-center gap-3">
|
<div className="mb-10 flex flex-col items-center gap-3">
|
||||||
<div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-primary">
|
<div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-primary">
|
||||||
<Film className="h-7 w-7 text-primary-foreground" />
|
<Film className="h-7 w-7 text-primary-foreground" />
|
||||||
@ -47,14 +53,14 @@ export default function LoginPage() {
|
|||||||
InFocus
|
InFocus
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Familienfilm-Tagebuch
|
{t("auth.familyMovieDiary")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form onSubmit={handleLogin} className="flex flex-col gap-4">
|
<form onSubmit={handleLogin} className="flex flex-col gap-4">
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<label htmlFor="email" className="text-sm font-medium text-foreground">
|
<label htmlFor="email" className="text-sm font-medium text-foreground">
|
||||||
E-Mail
|
{t("auth.email")}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
id="email"
|
id="email"
|
||||||
@ -63,22 +69,22 @@ export default function LoginPage() {
|
|||||||
onChange={(e) => setEmail(e.target.value)}
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
placeholder="familie@example.com"
|
placeholder="familie@example.com"
|
||||||
required
|
required
|
||||||
className="h-12 rounded-lg border border-border bg-secondary px-4 text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
|
className="glass-input h-12 text-sm"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<label htmlFor="password" className="text-sm font-medium text-foreground">
|
<label htmlFor="password" className="text-sm font-medium text-foreground">
|
||||||
Passwort
|
{t("auth.password")}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
id="password"
|
id="password"
|
||||||
type="password"
|
type="password"
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
placeholder="Passwort eingeben"
|
placeholder={t("auth.passwordPlaceholder")}
|
||||||
required
|
required
|
||||||
className="h-12 rounded-lg border border-border bg-secondary px-4 text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
|
className="glass-input h-12 text-sm"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -89,23 +95,23 @@ export default function LoginPage() {
|
|||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
className="mt-2 flex h-12 items-center justify-center gap-2 rounded-lg bg-primary font-semibold text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-50"
|
className="glass-button mt-2 flex h-12 items-center justify-center gap-2 bg-primary font-semibold text-primary-foreground shadow-lg shadow-primary/20 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<Loader2 className="h-5 w-5 animate-spin" />
|
<Loader2 className="h-5 w-5 animate-spin" />
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Eye className="h-5 w-5" />
|
<Eye className="h-5 w-5" />
|
||||||
Anmelden
|
{t("auth.signIn")}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<p className="mt-6 text-center text-sm text-muted-foreground">
|
<p className="mt-6 text-center text-sm text-muted-foreground">
|
||||||
Noch kein Konto?{" "}
|
{t("auth.dontHaveAccount")}{" "}
|
||||||
<Link href="/auth/sign-up" className="font-medium text-primary hover:underline">
|
<Link href="/auth/sign-up" className="font-medium text-primary hover:underline">
|
||||||
Registrieren
|
{t("auth.signUp")}
|
||||||
</Link>
|
</Link>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -7,6 +7,8 @@ import { useRouter } from "next/navigation"
|
|||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { createClient } from "@/lib/supabase/client"
|
import { createClient } from "@/lib/supabase/client"
|
||||||
import { Film, UserPlus, Loader2 } from "lucide-react"
|
import { Film, UserPlus, Loader2 } from "lucide-react"
|
||||||
|
import { useLanguage } from "@/contexts/LanguageContext"
|
||||||
|
import { LanguageToggle } from "@/components/language-toggle"
|
||||||
|
|
||||||
export default function SignUpPage() {
|
export default function SignUpPage() {
|
||||||
const [email, setEmail] = useState("")
|
const [email, setEmail] = useState("")
|
||||||
@ -15,6 +17,7 @@ export default function SignUpPage() {
|
|||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const { t } = useLanguage()
|
||||||
|
|
||||||
async function handleSignUp(e: React.FormEvent) {
|
async function handleSignUp(e: React.FormEvent) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
@ -47,22 +50,25 @@ export default function SignUpPage() {
|
|||||||
return (
|
return (
|
||||||
<main className="flex min-h-dvh flex-col items-center justify-center px-6">
|
<main className="flex min-h-dvh flex-col items-center justify-center px-6">
|
||||||
<div className="w-full max-w-sm">
|
<div className="w-full max-w-sm">
|
||||||
|
<div className="mb-6 flex justify-end">
|
||||||
|
<LanguageToggle />
|
||||||
|
</div>
|
||||||
<div className="mb-10 flex flex-col items-center gap-3">
|
<div className="mb-10 flex flex-col items-center gap-3">
|
||||||
<div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-primary">
|
<div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-primary">
|
||||||
<Film className="h-7 w-7 text-primary-foreground" />
|
<Film className="h-7 w-7 text-primary-foreground" />
|
||||||
</div>
|
</div>
|
||||||
<h1 className="font-heading text-3xl font-bold tracking-tight text-foreground">
|
<h1 className="font-heading text-3xl font-bold tracking-tight text-foreground">
|
||||||
Konto erstellen
|
{t("auth.createAccount")}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Tritt deinem Familien-Filmclub bei
|
{t("auth.joinFamilyMovieClub")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form onSubmit={handleSignUp} className="flex flex-col gap-4">
|
<form onSubmit={handleSignUp} className="flex flex-col gap-4">
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<label htmlFor="displayName" className="text-sm font-medium text-foreground">
|
<label htmlFor="displayName" className="text-sm font-medium text-foreground">
|
||||||
Anzeigename
|
{t("auth.displayName")}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
id="displayName"
|
id="displayName"
|
||||||
@ -71,7 +77,7 @@ export default function SignUpPage() {
|
|||||||
onChange={(e) => setDisplayName(e.target.value)}
|
onChange={(e) => setDisplayName(e.target.value)}
|
||||||
placeholder="z.B. Papa, Mama, Lisa..."
|
placeholder="z.B. Papa, Mama, Lisa..."
|
||||||
required
|
required
|
||||||
className="h-12 rounded-lg border border-border bg-secondary px-4 text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
|
className="glass-input h-12 text-sm"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -86,7 +92,7 @@ export default function SignUpPage() {
|
|||||||
onChange={(e) => setEmail(e.target.value)}
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
placeholder="familie@example.com"
|
placeholder="familie@example.com"
|
||||||
required
|
required
|
||||||
className="h-12 rounded-lg border border-border bg-secondary px-4 text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
|
className="glass-input h-12 text-sm"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -102,7 +108,7 @@ export default function SignUpPage() {
|
|||||||
placeholder="Mindestens 6 Zeichen"
|
placeholder="Mindestens 6 Zeichen"
|
||||||
required
|
required
|
||||||
minLength={6}
|
minLength={6}
|
||||||
className="h-12 rounded-lg border border-border bg-secondary px-4 text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
|
className="glass-input h-12 text-sm"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -113,7 +119,7 @@ export default function SignUpPage() {
|
|||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
className="mt-2 flex h-12 items-center justify-center gap-2 rounded-lg bg-primary font-semibold text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-50"
|
className="glass-button mt-2 flex h-12 items-center justify-center gap-2 bg-primary font-semibold text-primary-foreground shadow-lg shadow-primary/20 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<Loader2 className="h-5 w-5 animate-spin" />
|
<Loader2 className="h-5 w-5 animate-spin" />
|
||||||
|
|||||||
@ -43,9 +43,14 @@
|
|||||||
@layer base {
|
@layer base {
|
||||||
* {
|
* {
|
||||||
@apply border-border;
|
@apply border-border;
|
||||||
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
body {
|
body {
|
||||||
@apply bg-background text-foreground;
|
@apply bg-background text-foreground;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 100vh;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import type { Metadata, Viewport } from "next"
|
|||||||
import { Inter, Space_Grotesk } from "next/font/google"
|
import { Inter, Space_Grotesk } from "next/font/google"
|
||||||
|
|
||||||
import "./globals.css"
|
import "./globals.css"
|
||||||
|
import { LanguageProvider } from "@/contexts/LanguageContext"
|
||||||
|
|
||||||
const inter = Inter({ subsets: ["latin"], variable: "--font-inter" })
|
const inter = Inter({ subsets: ["latin"], variable: "--font-inter" })
|
||||||
const spaceGrotesk = Space_Grotesk({
|
const spaceGrotesk = Space_Grotesk({
|
||||||
@ -43,7 +44,9 @@ export default function RootLayout({
|
|||||||
<body
|
<body
|
||||||
className={`${inter.variable} ${spaceGrotesk.variable} font-sans antialiased`}
|
className={`${inter.variable} ${spaceGrotesk.variable} font-sans antialiased`}
|
||||||
>
|
>
|
||||||
{children}
|
<LanguageProvider>
|
||||||
|
{children}
|
||||||
|
</LanguageProvider>
|
||||||
<script
|
<script
|
||||||
dangerouslySetInnerHTML={{
|
dangerouslySetInnerHTML={{
|
||||||
__html: `
|
__html: `
|
||||||
|
|||||||
75
app/oauth/consent/page.tsx
Normal file
75
app/oauth/consent/page.tsx
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react"
|
||||||
|
import { useSearchParams, useRouter } from "next/navigation"
|
||||||
|
import { CheckCircle, AlertCircle } from "lucide-react"
|
||||||
|
|
||||||
|
export default function OAuthCallbackPage() {
|
||||||
|
const [status, setStatus] = useState<'loading' | 'success' | 'error'>('loading')
|
||||||
|
const searchParams = useSearchParams()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const mcpConfigured = searchParams.get('mcp_configured')
|
||||||
|
const error = searchParams.get('error')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (mcpConfigured === 'true') {
|
||||||
|
setStatus('success')
|
||||||
|
} else if (error) {
|
||||||
|
setStatus('error')
|
||||||
|
}
|
||||||
|
}, [mcpConfigured, error])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="flex min-h-dvh items-center justify-center px-6">
|
||||||
|
<div className="w-full max-w-md glass-card p-8">
|
||||||
|
<div className="flex flex-col items-center gap-4 text-center">
|
||||||
|
{status === 'loading' && (
|
||||||
|
<>
|
||||||
|
<div className="animate-spin h-8 w-8 text-muted-foreground" />
|
||||||
|
<h1 className="text-xl font-semibold text-foreground">
|
||||||
|
OAuth Konfiguration wird verarbeitet...
|
||||||
|
</h1>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{status === 'success' && (
|
||||||
|
<>
|
||||||
|
<CheckCircle className="h-12 w-12 text-green-500" />
|
||||||
|
<h1 className="text-xl font-semibold text-foreground">
|
||||||
|
MCP OAuth erfolgreich konfiguriert!
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-muted-foreground mt-2">
|
||||||
|
Der Supabase MCP Server ist jetzt bereit.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={() => router.push('/diary')}
|
||||||
|
className="glass-button mt-4 w-full"
|
||||||
|
>
|
||||||
|
Zur App
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{status === 'error' && (
|
||||||
|
<>
|
||||||
|
<AlertCircle className="h-12 w-12 text-destructive" />
|
||||||
|
<h1 className="text-xl font-semibold text-foreground">
|
||||||
|
OAuth Konfiguration fehlgeschlagen
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-muted-foreground mt-2">
|
||||||
|
Fehler: {error || 'Unbekannter Fehler'}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={() => router.push('/auth/login')}
|
||||||
|
className="glass-button mt-4 w-full"
|
||||||
|
>
|
||||||
|
Zurück zum Login
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
42
app/oauth/consent/route.ts
Normal file
42
app/oauth/consent/route.ts
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server"
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
const { searchParams } = new URL(request.url)
|
||||||
|
const code = searchParams.get('code')
|
||||||
|
const state = searchParams.get('state')
|
||||||
|
|
||||||
|
if (!code) {
|
||||||
|
return NextResponse.redirect('http://localhost:3000/auth/error?error=oauth_code_missing')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle OAuth callback for MCP server
|
||||||
|
// This will exchange the authorization code for tokens
|
||||||
|
try {
|
||||||
|
const response = await fetch('https://mcp.supabase.com/oauth/callback', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${process.env.SUPABASE_SERVICE_ROLE_KEY}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
code,
|
||||||
|
state,
|
||||||
|
redirect_uri: 'http://localhost:3000/oauth/consent'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await response.json()
|
||||||
|
|
||||||
|
if (result.error) {
|
||||||
|
return NextResponse.redirect(`http://localhost:3000/auth/error?error=${result.error}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store tokens securely (in production, use proper session management)
|
||||||
|
// For now, redirect back to app
|
||||||
|
return NextResponse.redirect('http://localhost:3000/auth/success?mcp_configured=true')
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('OAuth callback error:', error)
|
||||||
|
return NextResponse.redirect('http://localhost:3000/auth/error?error=oauth_callback_failed')
|
||||||
|
}
|
||||||
|
}
|
||||||
36
app/oauth/error/page.tsx
Normal file
36
app/oauth/error/page.tsx
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react"
|
||||||
|
import { useSearchParams, useRouter } from "next/navigation"
|
||||||
|
import { AlertCircle } from "lucide-react"
|
||||||
|
|
||||||
|
export default function OAuthErrorPage() {
|
||||||
|
const searchParams = useSearchParams()
|
||||||
|
const router = useRouter()
|
||||||
|
const error = searchParams.get('error') || 'Unbekannter Fehler'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="flex min-h-dvh items-center justify-center px-6">
|
||||||
|
<div className="w-full max-w-md glass-card p-8">
|
||||||
|
<div className="flex flex-col items-center gap-4 text-center">
|
||||||
|
<AlertCircle className="h-12 w-12 text-destructive" />
|
||||||
|
<h1 className="text-xl font-semibold text-foreground">
|
||||||
|
OAuth Konfiguration fehlgeschlagen
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-muted-foreground mt-2">
|
||||||
|
Fehler: {error}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground mt-4">
|
||||||
|
Bitte überprüfe deine MCP Konfiguration und versuche es erneut.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={() => router.push('/auth/login')}
|
||||||
|
className="glass-button mt-4 w-full"
|
||||||
|
>
|
||||||
|
Zurück zum Login
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
75
app/oauth/success/page.tsx
Normal file
75
app/oauth/success/page.tsx
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react"
|
||||||
|
import { useSearchParams, useRouter } from "next/navigation"
|
||||||
|
import { CheckCircle, AlertCircle } from "lucide-react"
|
||||||
|
|
||||||
|
export default function OAuthSuccessPage() {
|
||||||
|
const [status, setStatus] = useState<'loading' | 'success' | 'error'>('loading')
|
||||||
|
const searchParams = useSearchParams()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const mcpConfigured = searchParams.get('mcp_configured')
|
||||||
|
const error = searchParams.get('error')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (mcpConfigured === 'true') {
|
||||||
|
setStatus('success')
|
||||||
|
} else if (error) {
|
||||||
|
setStatus('error')
|
||||||
|
}
|
||||||
|
}, [mcpConfigured, error])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="flex min-h-dvh items-center justify-center px-6">
|
||||||
|
<div className="w-full max-w-md glass-card p-8">
|
||||||
|
<div className="flex flex-col items-center gap-4 text-center">
|
||||||
|
{status === 'loading' && (
|
||||||
|
<>
|
||||||
|
<div className="animate-spin h-8 w-8 text-muted-foreground" />
|
||||||
|
<h1 className="text-xl font-semibold text-foreground">
|
||||||
|
OAuth Konfiguration wird verarbeitet...
|
||||||
|
</h1>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{status === 'success' && (
|
||||||
|
<>
|
||||||
|
<CheckCircle className="h-12 w-12 text-green-500" />
|
||||||
|
<h1 className="text-xl font-semibold text-foreground">
|
||||||
|
MCP OAuth erfolgreich konfiguriert!
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-muted-foreground mt-2">
|
||||||
|
Der Supabase MCP Server ist jetzt bereit.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={() => router.push('/diary')}
|
||||||
|
className="glass-button mt-4 w-full"
|
||||||
|
>
|
||||||
|
Zur App
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{status === 'error' && (
|
||||||
|
<>
|
||||||
|
<AlertCircle className="h-12 w-12 text-destructive" />
|
||||||
|
<h1 className="text-xl font-semibold text-foreground">
|
||||||
|
OAuth Konfiguration fehlgeschlagen
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-muted-foreground mt-2">
|
||||||
|
Fehler: {error || 'Unbekannter Fehler'}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={() => router.push('/auth/login')}
|
||||||
|
className="glass-button mt-4 w-full"
|
||||||
|
>
|
||||||
|
Zurück zum Login
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
46
browser-test.js
Normal file
46
browser-test.js
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
// Browser Test - füge das in die Browser Console ein auf http://localhost:3001
|
||||||
|
async function testBrowserLogging() {
|
||||||
|
console.log('Testing browser logging...');
|
||||||
|
|
||||||
|
const supabase = createBrowserClient(
|
||||||
|
'https://ekbpexbhuochrplzorce.supabase.co',
|
||||||
|
'sb_publishable__UII_iKx3pgvLQvc1xrN1w_qnwP6JOv'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Teste aktuellen User
|
||||||
|
const { data: { user }, error: userError } = await supabase.auth.getUser();
|
||||||
|
console.log('Current user:', user?.id, userError);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
console.log('No user found!');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Teste Insert
|
||||||
|
console.log('Testing insert...');
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from("diary_entries")
|
||||||
|
.insert({
|
||||||
|
user_id: user.id,
|
||||||
|
tmdb_movie_id: 123,
|
||||||
|
movie_title: "Browser Test Film",
|
||||||
|
movie_poster_path: "/test.jpg",
|
||||||
|
rating: 4.5,
|
||||||
|
review: "Browser test review",
|
||||||
|
watched_on: new Date().toISOString().slice(0, 10)
|
||||||
|
})
|
||||||
|
.select();
|
||||||
|
|
||||||
|
console.log('Insert result:', data, error);
|
||||||
|
|
||||||
|
// Teste Select
|
||||||
|
const { data: entries, error: selectError } = await supabase
|
||||||
|
.from("diary_entries")
|
||||||
|
.select("*")
|
||||||
|
.eq("user_id", user.id);
|
||||||
|
|
||||||
|
console.log('Entries after insert:', entries, selectError);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Führe den Test aus
|
||||||
|
testBrowserLogging();
|
||||||
96
browser-theme-fix.js
Normal file
96
browser-theme-fix.js
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
// Kopiere diesen Code direkt in F12 Console (ohne import)
|
||||||
|
const simpleThemes = {
|
||||||
|
'apple-frosted-light': {
|
||||||
|
background: 'rgb(242, 242, 247)',
|
||||||
|
foreground: 'rgb(28, 28, 30)',
|
||||||
|
glassBg: 'rgba(255, 255, 255, 0.8)',
|
||||||
|
glassBorder: 'rgba(255, 255, 255, 0.18)',
|
||||||
|
primary: 'rgb(0, 122, 255)'
|
||||||
|
},
|
||||||
|
'apple-frosted-dark': {
|
||||||
|
background: 'rgb(0, 0, 0)',
|
||||||
|
foreground: 'rgb(255, 255, 255)',
|
||||||
|
glassBg: 'rgba(44, 44, 46, 0.8)',
|
||||||
|
glassBorder: 'rgba(255, 255, 255, 0.12)',
|
||||||
|
primary: 'rgb(10, 132, 255)'
|
||||||
|
},
|
||||||
|
'ocean-blue': {
|
||||||
|
background: 'rgb(240, 249, 255)',
|
||||||
|
foreground: 'rgb(12, 74, 110)',
|
||||||
|
glassBg: 'rgba(255, 255, 255, 0.9)',
|
||||||
|
glassBorder: 'rgba(59, 130, 246, 0.2)',
|
||||||
|
primary: 'rgb(14, 165, 233)'
|
||||||
|
},
|
||||||
|
'forest-green': {
|
||||||
|
background: 'rgb(240, 253, 244)',
|
||||||
|
foreground: 'rgb(20, 83, 45)',
|
||||||
|
glassBg: 'rgba(255, 255, 255, 0.9)',
|
||||||
|
glassBorder: 'rgba(34, 197, 94, 0.2)',
|
||||||
|
primary: 'rgb(34, 197, 94)'
|
||||||
|
},
|
||||||
|
'cinema-noir': {
|
||||||
|
background: 'rgb(10, 10, 10)',
|
||||||
|
foreground: 'rgb(232, 232, 232)',
|
||||||
|
glassBg: 'rgba(20, 20, 20, 0.85)',
|
||||||
|
glassBorder: 'rgba(255, 215, 0, 0.15)',
|
||||||
|
primary: 'rgb(255, 215, 0)'
|
||||||
|
},
|
||||||
|
'sunset-purple': {
|
||||||
|
background: 'rgb(26, 0, 26)',
|
||||||
|
foreground: 'rgb(243, 232, 255)',
|
||||||
|
glassBg: 'rgba(45, 27, 45, 0.85)',
|
||||||
|
glassBorder: 'rgba(167, 139, 250, 0.2)',
|
||||||
|
primary: 'rgb(167, 139, 250)'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function applySimpleTheme(themeId) {
|
||||||
|
const theme = simpleThemes[themeId];
|
||||||
|
if (!theme) return;
|
||||||
|
|
||||||
|
// Entferne alte Styles
|
||||||
|
const oldStyles = document.getElementById('simple-theme-styles');
|
||||||
|
if (oldStyles) oldStyles.remove();
|
||||||
|
|
||||||
|
// Erstelle neue Styles mit direkten Farben
|
||||||
|
const styleElement = document.createElement('style');
|
||||||
|
styleElement.id = 'simple-theme-styles';
|
||||||
|
styleElement.textContent = `
|
||||||
|
body {
|
||||||
|
background-color: ${theme.background} !important;
|
||||||
|
color: ${theme.foreground} !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass-card,
|
||||||
|
.glass-header,
|
||||||
|
.glass-button,
|
||||||
|
.glass-avatar,
|
||||||
|
.glass-tag,
|
||||||
|
.glass-input {
|
||||||
|
background: ${theme.glassBg} !important;
|
||||||
|
border: 1px solid ${theme.glassBorder} !important;
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
-webkit-backdrop-filter: blur(20px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-primary {
|
||||||
|
color: ${theme.primary} !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg-primary {
|
||||||
|
background-color: ${theme.primary} !important;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
document.head.appendChild(styleElement);
|
||||||
|
document.body.setAttribute('data-theme', themeId);
|
||||||
|
|
||||||
|
console.log(`✅ Simple Theme "${themeId}" applied`);
|
||||||
|
console.log(`Glass BG: ${theme.glassBg}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Teste jetzt die Themes:
|
||||||
|
console.log('=== TESTING THEMES ===');
|
||||||
|
applySimpleTheme('ocean-blue');
|
||||||
|
setTimeout(() => applySimpleTheme('forest-green'), 2000);
|
||||||
|
setTimeout(() => applySimpleTheme('apple-frosted-light'), 4000);
|
||||||
53
browser-theme-test.js
Normal file
53
browser-theme-test.js
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
// Browser Theme Test - Copy this into browser console (F12)
|
||||||
|
console.log('=== BROWSER THEME DEBUG ===');
|
||||||
|
|
||||||
|
// Test 1: Check if theme functions are available
|
||||||
|
if (typeof window !== 'undefined' && window.applyTheme) {
|
||||||
|
console.log('✅ applyTheme function available');
|
||||||
|
} else {
|
||||||
|
console.log('❌ applyTheme function NOT available');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 2: Check current CSS variables
|
||||||
|
const rootStyles = getComputedStyle(document.documentElement);
|
||||||
|
const currentVars = {
|
||||||
|
'--background': rootStyles.getPropertyValue('--background'),
|
||||||
|
'--foreground': rootStyles.getPropertyValue('--foreground'),
|
||||||
|
'--primary': rootStyles.getPropertyValue('--primary'),
|
||||||
|
'--card': rootStyles.getPropertyValue('--card'),
|
||||||
|
'--glass-bg': rootStyles.getPropertyValue('--glass-bg'),
|
||||||
|
'--glass-border': rootStyles.getPropertyValue('--glass-border')
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log('Current CSS Variables:');
|
||||||
|
Object.entries(currentVars).forEach(([key, value]) => {
|
||||||
|
console.log(`${key}: "${value.trim()}"`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test 3: Check body data-theme attribute
|
||||||
|
const bodyTheme = document.body.getAttribute('data-theme');
|
||||||
|
console.log('Body data-theme:', bodyTheme);
|
||||||
|
|
||||||
|
// Test 4: Check if theme styles exist
|
||||||
|
const themeStyles = document.getElementById('theme-styles');
|
||||||
|
if (themeStyles) {
|
||||||
|
console.log('✅ Theme styles found');
|
||||||
|
console.log('Theme CSS length:', themeStyles.textContent.length);
|
||||||
|
console.log('First 200 chars:', themeStyles.textContent.substring(0, 200));
|
||||||
|
} else {
|
||||||
|
console.log('❌ No theme styles found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 5: Check glass elements
|
||||||
|
const glassElements = document.querySelectorAll('[class*="glass"]');
|
||||||
|
console.log('Glass elements found:', glassElements.length);
|
||||||
|
|
||||||
|
// Test first glass element styles
|
||||||
|
if (glassElements.length > 0) {
|
||||||
|
const firstGlass = glassElements[0];
|
||||||
|
const glassStyles = getComputedStyle(firstGlass);
|
||||||
|
console.log('First glass element background:', glassStyles.backgroundColor);
|
||||||
|
console.log('First glass element border:', glassStyles.border);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('=== END BROWSER THEME DEBUG ===');
|
||||||
@ -17,16 +17,22 @@ import {
|
|||||||
Trash2,
|
Trash2,
|
||||||
Plus,
|
Plus,
|
||||||
Loader2,
|
Loader2,
|
||||||
|
Tv,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
|
|
||||||
interface DiaryEntry {
|
interface DiaryEntry {
|
||||||
id: string
|
id: string
|
||||||
tmdb_id: number
|
tmdb_movie_id: number
|
||||||
title: string
|
movie_title: string
|
||||||
poster_path: string | null
|
movie_poster_path: string | null
|
||||||
rating: number | null
|
rating: number | null
|
||||||
|
imdb_rating: number | null
|
||||||
|
rotten_tomatoes_rating: number | null
|
||||||
review: string | null
|
review: string | null
|
||||||
watched_at: string
|
watched_on: string
|
||||||
|
media_type: 'movie' | 'tv'
|
||||||
|
season_number: number | null
|
||||||
|
episode_number: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
interface WatchlistItem {
|
interface WatchlistItem {
|
||||||
@ -108,34 +114,12 @@ export function DiaryContent({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="mx-auto max-w-lg">
|
<main className="mx-auto max-w-4xl">
|
||||||
<header className="sticky top-0 z-40 border-b border-border bg-background/95 backdrop-blur-md">
|
<header className="sticky top-0 z-40 glass-header px-4 py-3">
|
||||||
<h1 className="px-4 pt-3 pb-2 font-heading text-xl font-bold text-foreground">
|
<h1 className="font-heading text-xl font-bold text-foreground">
|
||||||
Meine Filme
|
Mein Tagebuch
|
||||||
</h1>
|
</h1>
|
||||||
<div className="flex px-4">
|
<div className="w-16" />
|
||||||
{tabs.map((tab) => {
|
|
||||||
const Icon = tab.icon
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={tab.id}
|
|
||||||
onClick={() => setActiveTab(tab.id)}
|
|
||||||
className={`flex flex-1 items-center justify-center gap-1.5 border-b-2 pb-2.5 pt-1 text-xs font-medium transition-colors ${
|
|
||||||
activeTab === tab.id
|
|
||||||
? "border-primary text-primary"
|
|
||||||
: "border-transparent text-muted-foreground hover:text-foreground"
|
|
||||||
}`}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<Icon className="h-4 w-4" />
|
|
||||||
{tab.label}
|
|
||||||
<span className="rounded-full bg-secondary px-1.5 py-0.5 text-[10px]">
|
|
||||||
{tab.count}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="px-4 pt-4">
|
<div className="px-4 pt-4">
|
||||||
@ -150,55 +134,100 @@ export function DiaryContent({
|
|||||||
href="/log"
|
href="/log"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col gap-3">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
{entries.map((entry) => {
|
{entries.map((entry) => {
|
||||||
const url = posterUrl(entry.poster_path, "w185")
|
const url = posterUrl(entry.movie_poster_path, "w342")
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={entry.id}
|
key={entry.id}
|
||||||
className="flex gap-3 rounded-xl border border-border bg-card p-3"
|
className="glass-card overflow-hidden"
|
||||||
>
|
>
|
||||||
<Link href={`/movie/${entry.tmdb_id}`} className="shrink-0">
|
<Link href={`/movie/${entry.tmdb_movie_id}`} className="block">
|
||||||
<div className="relative h-20 w-[54px] overflow-hidden rounded-lg bg-secondary">
|
<div className="relative h-48 w-full overflow-hidden bg-secondary">
|
||||||
{url ? (
|
{url ? (
|
||||||
<Image
|
<Image
|
||||||
src={url || "/placeholder.svg"}
|
src={url || "/placeholder.svg"}
|
||||||
alt={entry.title}
|
alt={entry.movie_title}
|
||||||
fill
|
fill
|
||||||
className="object-cover"
|
className="object-cover"
|
||||||
sizes="54px"
|
sizes="100%"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex h-full items-center justify-center">
|
<div className="flex h-full items-center justify-center">
|
||||||
<Film className="h-4 w-4 text-muted-foreground" />
|
{entry.media_type === 'tv' ? (
|
||||||
|
<Tv className="h-8 w-8 text-muted-foreground" />
|
||||||
|
) : (
|
||||||
|
<Film className="h-8 w-8 text-muted-foreground" />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
<div className="flex min-w-0 flex-1 flex-col justify-center gap-1">
|
|
||||||
<Link href={`/movie/${entry.tmdb_id}`}>
|
<div className="p-4">
|
||||||
<h3 className="truncate text-sm font-semibold text-foreground">
|
<div className="flex items-center gap-2 mb-2">
|
||||||
{entry.title}
|
<Link href={`/movie/${entry.tmdb_movie_id}`}>
|
||||||
</h3>
|
<h3 className="truncate font-heading text-base font-semibold text-foreground">
|
||||||
</Link>
|
{entry.movie_title}
|
||||||
<p className="text-[10px] text-muted-foreground">
|
</h3>
|
||||||
{new Date(entry.watched_at).toLocaleDateString(
|
</Link>
|
||||||
"de-DE",
|
</div>
|
||||||
{ day: "numeric", month: "long", year: "numeric" }
|
|
||||||
|
<p className="text-sm text-muted-foreground mb-2">
|
||||||
|
{entry.watched_on
|
||||||
|
? new Date(entry.watched_on).toLocaleDateString(
|
||||||
|
"de-DE",
|
||||||
|
{ day: "numeric", month: "long", year: "numeric" }
|
||||||
|
)
|
||||||
|
: "Kein Datum"
|
||||||
|
}
|
||||||
|
{entry.media_type === 'tv' && entry.season_number && (
|
||||||
|
<span className="ml-2">
|
||||||
|
S{entry.season_number}
|
||||||
|
{entry.episode_number && `E${entry.episode_number}`}
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{entry.rating && (
|
{entry.rating && (
|
||||||
<StarRating rating={entry.rating} size="sm" />
|
<div className="mb-2">
|
||||||
|
<StarRating rating={entry.rating} size="sm" />
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* External Ratings */}
|
||||||
|
<div className="flex gap-2 text-xs text-muted-foreground mb-2">
|
||||||
|
{entry.imdb_rating && (
|
||||||
|
<span>IMDb {entry.imdb_rating}</span>
|
||||||
|
)}
|
||||||
|
{entry.rotten_tomatoes_rating && (
|
||||||
|
<span>RT {entry.rotten_tomatoes_rating}%</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{entry.review && (
|
||||||
|
<p className="line-clamp-3 text-sm leading-relaxed text-muted-foreground">
|
||||||
|
{entry.review}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between mt-3">
|
||||||
|
<button
|
||||||
|
onClick={async () => {
|
||||||
|
const supabase = createClient()
|
||||||
|
await supabase
|
||||||
|
.from("diary_entries")
|
||||||
|
.delete()
|
||||||
|
.eq("id", entry.id)
|
||||||
|
router.refresh()
|
||||||
|
}}
|
||||||
|
className="text-muted-foreground transition-colors hover:text-destructive"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
|
||||||
onClick={() => deleteDiaryEntry(entry.id)}
|
|
||||||
className="self-start p-1 text-muted-foreground hover:text-destructive"
|
|
||||||
type="button"
|
|
||||||
aria-label="Eintrag loeschen"
|
|
||||||
>
|
|
||||||
<Trash2 className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
@ -264,7 +293,7 @@ export function DiaryContent({
|
|||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowNewList(true)}
|
onClick={() => setShowNewList(true)}
|
||||||
className="mb-4 flex w-full items-center justify-center gap-2 rounded-xl border border-dashed border-border bg-card py-3 text-sm font-medium text-muted-foreground transition-colors hover:border-primary hover:text-primary"
|
className="mb-4 glass-card flex w-full items-center justify-center gap-2 py-3 text-sm font-medium text-muted-foreground transition-all hover:bg-white/[0.08] active:scale-[0.98]"
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<Plus className="h-4 w-4" />
|
<Plus className="h-4 w-4" />
|
||||||
@ -280,13 +309,13 @@ export function DiaryContent({
|
|||||||
value={newListName}
|
value={newListName}
|
||||||
onChange={(e) => setNewListName(e.target.value)}
|
onChange={(e) => setNewListName(e.target.value)}
|
||||||
placeholder="Listenname..."
|
placeholder="Listenname..."
|
||||||
className="h-10 flex-1 rounded-lg border border-border bg-secondary px-3 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
|
className="glass-input h-10 flex-1 text-sm"
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={creatingList}
|
disabled={creatingList}
|
||||||
className="flex h-10 items-center justify-center rounded-lg bg-primary px-4 text-sm font-semibold text-primary-foreground disabled:opacity-50"
|
className="glass-button flex h-10 items-center justify-center px-4 text-sm font-semibold disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{creatingList ? (
|
{creatingList ? (
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
@ -309,7 +338,7 @@ export function DiaryContent({
|
|||||||
<Link
|
<Link
|
||||||
key={list.id}
|
key={list.id}
|
||||||
href={`/lists/${list.id}`}
|
href={`/lists/${list.id}`}
|
||||||
className="flex items-center justify-between rounded-xl border border-border bg-card p-4 transition-colors hover:bg-secondary"
|
className="glass-card flex items-center justify-between p-4 transition-all hover:bg-white/[0.08] active:scale-[0.98]"
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-sm font-semibold text-foreground">
|
<h3 className="text-sm font-semibold text-foreground">
|
||||||
@ -321,7 +350,7 @@ export function DiaryContent({
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<span className="rounded-full bg-secondary px-2.5 py-1 text-xs font-medium text-muted-foreground">
|
<span className="glass-tag">
|
||||||
{list.list_items?.[0]?.count || 0} Filme
|
{list.list_items?.[0]?.count || 0} Filme
|
||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
@ -349,7 +378,7 @@ function EmptyState({
|
|||||||
href?: string
|
href?: string
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center gap-3 rounded-xl border border-border bg-card px-6 py-12 text-center">
|
<div className="glass-card flex flex-col items-center gap-3 px-6 py-12 text-center">
|
||||||
<Icon className="h-10 w-10 text-muted-foreground" />
|
<Icon className="h-10 w-10 text-muted-foreground" />
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium text-foreground">{title}</p>
|
<p className="text-sm font-medium text-foreground">{title}</p>
|
||||||
@ -358,7 +387,7 @@ function EmptyState({
|
|||||||
{href && (
|
{href && (
|
||||||
<Link
|
<Link
|
||||||
href={href}
|
href={href}
|
||||||
className="mt-2 rounded-lg bg-primary px-5 py-2.5 text-sm font-semibold text-primary-foreground"
|
className="mt-2 glass-button bg-primary px-5 py-2.5 text-sm font-semibold text-primary-foreground shadow-lg shadow-primary/20"
|
||||||
>
|
>
|
||||||
Los geht's
|
Los geht's
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@ -5,19 +5,19 @@ import Link from "next/link"
|
|||||||
import { posterUrl } from "@/lib/tmdb"
|
import { posterUrl } from "@/lib/tmdb"
|
||||||
import { StarRating } from "@/components/star-rating"
|
import { StarRating } from "@/components/star-rating"
|
||||||
import { MovieCard } from "@/components/movie-card"
|
import { MovieCard } from "@/components/movie-card"
|
||||||
import { Heart, MessageCircle, Film, Clock } from "lucide-react"
|
import { Heart, MessageCircle, Film, Clock, Loader2 } from "lucide-react"
|
||||||
import { createClient } from "@/lib/supabase/client"
|
import { createClient } from "@/lib/supabase/client"
|
||||||
import { useState } from "react"
|
import { useState } from "react"
|
||||||
|
|
||||||
interface FeedEntry {
|
interface FeedEntry {
|
||||||
id: string
|
id: string
|
||||||
user_id: string
|
user_id: string
|
||||||
tmdb_id: number
|
tmdb_movie_id: number
|
||||||
title: string
|
movie_title: string
|
||||||
poster_path: string | null
|
movie_poster_path: string | null
|
||||||
rating: number | null
|
rating: number | null
|
||||||
review: string | null
|
review: string | null
|
||||||
watched_at: string
|
watched_on: string
|
||||||
created_at: string
|
created_at: string
|
||||||
profiles: {
|
profiles: {
|
||||||
display_name: string
|
display_name: string
|
||||||
@ -40,7 +40,7 @@ interface FeedContentProps {
|
|||||||
|
|
||||||
export function FeedContent({ profile, entries, trending }: FeedContentProps) {
|
export function FeedContent({ profile, entries, trending }: FeedContentProps) {
|
||||||
return (
|
return (
|
||||||
<main className="mx-auto max-w-lg">
|
<main className="mx-auto max-w-4xl">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<header className="sticky top-0 z-40 flex items-center justify-between glass-header px-4 py-3">
|
<header className="sticky top-0 z-40 flex items-center justify-between glass-header px-4 py-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@ -94,7 +94,7 @@ export function FeedContent({ profile, entries, trending }: FeedContentProps) {
|
|||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
{entries.map((entry) => (
|
{entries.map((entry) => (
|
||||||
<FeedCard key={entry.id} entry={entry} />
|
<FeedCard key={entry.id} entry={entry} />
|
||||||
))}
|
))}
|
||||||
@ -110,14 +110,21 @@ export function FeedContent({ profile, entries, trending }: FeedContentProps) {
|
|||||||
function FeedCard({ entry }: { entry: FeedEntry }) {
|
function FeedCard({ entry }: { entry: FeedEntry }) {
|
||||||
const [liked, setLiked] = useState(false)
|
const [liked, setLiked] = useState(false)
|
||||||
const [likeCount, setLikeCount] = useState(0)
|
const [likeCount, setLikeCount] = useState(0)
|
||||||
const url = posterUrl(entry.poster_path, "w185")
|
const [liking, setLiking] = useState(false)
|
||||||
|
const url = posterUrl(entry.movie_poster_path, "w342")
|
||||||
|
|
||||||
async function toggleLike() {
|
async function toggleLike() {
|
||||||
|
if (liking) return
|
||||||
|
setLiking(true)
|
||||||
|
|
||||||
const supabase = createClient()
|
const supabase = createClient()
|
||||||
const {
|
const {
|
||||||
data: { user },
|
data: { user },
|
||||||
} = await supabase.auth.getUser()
|
} = await supabase.auth.getUser()
|
||||||
if (!user) return
|
if (!user) {
|
||||||
|
setLiking(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (liked) {
|
if (liked) {
|
||||||
await supabase
|
await supabase
|
||||||
@ -135,75 +142,83 @@ function FeedCard({ entry }: { entry: FeedEntry }) {
|
|||||||
setLiked(true)
|
setLiked(true)
|
||||||
setLikeCount((c) => c + 1)
|
setLikeCount((c) => c + 1)
|
||||||
}
|
}
|
||||||
|
setLiking(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
const watchedDate = new Date(entry.watched_at).toLocaleDateString("de-DE", {
|
const watchedDate = entry.watched_on
|
||||||
day: "numeric",
|
? new Date(entry.watched_on).toLocaleDateString("de-DE", {
|
||||||
month: "short",
|
day: "numeric",
|
||||||
})
|
month: "short",
|
||||||
|
})
|
||||||
|
: "Kein Datum"
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<article className="glass-card overflow-hidden">
|
<article className="glass-card overflow-hidden">
|
||||||
<div className="flex gap-3 p-4">
|
<div className="flex gap-4 p-4">
|
||||||
{/* Poster */}
|
{/* Poster */}
|
||||||
<Link href={`/movie/${entry.tmdb_id}`} className="shrink-0">
|
<Link href={`/movie/${entry.tmdb_movie_id}`} className="shrink-0">
|
||||||
<div className="relative h-28 w-[75px] overflow-hidden rounded-lg bg-secondary">
|
<div className="relative h-32 w-[120px] overflow-hidden rounded-lg bg-secondary">
|
||||||
{url ? (
|
{url ? (
|
||||||
<Image
|
<Image
|
||||||
src={url || "/placeholder.svg"}
|
src={url || "/placeholder.svg"}
|
||||||
alt={entry.title}
|
alt={entry.movie_title}
|
||||||
fill
|
fill
|
||||||
className="object-cover"
|
className="object-cover"
|
||||||
sizes="75px"
|
sizes="120px"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex h-full items-center justify-center">
|
<div className="flex h-full items-center justify-center">
|
||||||
<Film className="h-6 w-6 text-muted-foreground" />
|
<Film className="h-8 w-8 text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className="flex min-w-0 flex-1 flex-col gap-1.5">
|
<div className="flex min-w-0 flex-1 flex-col gap-2">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="glass-avatar flex h-6 w-6 items-center justify-center text-[10px] font-bold text-primary">
|
<div className="glass-avatar flex h-8 w-8 items-center justify-center text-sm font-bold text-primary flex-shrink-0">
|
||||||
{entry.profiles?.display_name?.charAt(0).toUpperCase() || "?"}
|
{entry.profiles?.display_name?.charAt(0).toUpperCase() || "?"}
|
||||||
</div>
|
</div>
|
||||||
<span className="text-xs font-medium text-foreground">
|
<span className="text-sm font-medium text-foreground truncate">
|
||||||
{entry.profiles?.display_name}
|
{entry.profiles?.display_name}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-[10px] text-muted-foreground">
|
<span className="text-xs text-muted-foreground flex-shrink-0">
|
||||||
{watchedDate}
|
{watchedDate}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Link href={`/movie/${entry.tmdb_id}`}>
|
<Link href={`/movie/${entry.tmdb_movie_id}`}>
|
||||||
<h3 className="truncate font-heading text-sm font-semibold text-foreground">
|
<h3 className="truncate font-heading text-base font-semibold text-foreground">
|
||||||
{entry.title}
|
{entry.movie_title}
|
||||||
</h3>
|
</h3>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
{entry.rating && <StarRating rating={entry.rating} size="sm" />}
|
{entry.rating && <StarRating rating={entry.rating} size="sm" />}
|
||||||
|
|
||||||
{entry.review && (
|
{entry.review && (
|
||||||
<p className="line-clamp-2 text-xs leading-relaxed text-muted-foreground">
|
<p className="line-clamp-3 text-sm leading-relaxed text-muted-foreground">
|
||||||
{entry.review}
|
{entry.review}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<div className="mt-1 flex items-center gap-4">
|
<div className="mt-2 flex items-center gap-4">
|
||||||
<button
|
<button
|
||||||
onClick={toggleLike}
|
onClick={toggleLike}
|
||||||
className="flex items-center gap-1 text-muted-foreground transition-colors hover:text-primary"
|
disabled={liking}
|
||||||
|
className="flex items-center gap-1 text-muted-foreground transition-colors hover:text-primary disabled:opacity-50"
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<Heart
|
{liking ? (
|
||||||
className={`h-4 w-4 ${liked ? "fill-primary text-primary" : ""}`}
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
/>
|
) : (
|
||||||
|
<Heart
|
||||||
|
className={`h-4 w-4 ${liked ? "fill-primary text-primary" : ""}`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{likeCount > 0 && (
|
{likeCount > 0 && (
|
||||||
<span className="text-[10px]">{likeCount}</span>
|
<span className="text-xs">{likeCount}</span>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
98
components/infocus-theme-provider.tsx
Normal file
98
components/infocus-theme-provider.tsx
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { createContext, useContext, useEffect, useState } from "react"
|
||||||
|
import { themes, getTheme, applyTheme, type Theme } from "@/lib/themes"
|
||||||
|
import { createClient } from "@/lib/supabase/client"
|
||||||
|
|
||||||
|
interface ThemeContextType {
|
||||||
|
currentTheme: string
|
||||||
|
setTheme: (themeId: string) => Promise<void>
|
||||||
|
themes: Theme[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const ThemeContext = createContext<ThemeContextType | undefined>(undefined)
|
||||||
|
|
||||||
|
export function useTheme() {
|
||||||
|
const context = useContext(ThemeContext)
|
||||||
|
if (context === undefined) {
|
||||||
|
throw new Error("useTheme must be used within a ThemeProvider")
|
||||||
|
}
|
||||||
|
return context
|
||||||
|
}
|
||||||
|
|
||||||
|
interface InFocusThemeProviderProps {
|
||||||
|
children: React.ReactNode
|
||||||
|
defaultTheme?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InFocusThemeProvider({ children, defaultTheme = "apple-frosted-light" }: InFocusThemeProviderProps) {
|
||||||
|
const [currentTheme, setCurrentTheme] = useState(defaultTheme)
|
||||||
|
const supabase = createClient()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Load theme from database on mount
|
||||||
|
async function loadTheme() {
|
||||||
|
try {
|
||||||
|
const { data: { user } } = await supabase.auth.getUser()
|
||||||
|
if (user) {
|
||||||
|
const { data: profile } = await supabase
|
||||||
|
.from('profiles')
|
||||||
|
.select('theme')
|
||||||
|
.eq('id', user.id)
|
||||||
|
.single()
|
||||||
|
|
||||||
|
if (profile?.theme) {
|
||||||
|
const theme = getTheme(profile.theme)
|
||||||
|
if (theme) {
|
||||||
|
setCurrentTheme(profile.theme)
|
||||||
|
applyTheme(theme)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Apply default theme
|
||||||
|
const theme = getTheme(currentTheme)
|
||||||
|
if (theme) {
|
||||||
|
applyTheme(theme)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load theme:', error)
|
||||||
|
// Apply default theme on error
|
||||||
|
const theme = getTheme(currentTheme)
|
||||||
|
if (theme) {
|
||||||
|
applyTheme(theme)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadTheme()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
async function setTheme(themeId: string) {
|
||||||
|
const theme = getTheme(themeId)
|
||||||
|
if (!theme) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Apply theme immediately
|
||||||
|
applyTheme(theme)
|
||||||
|
setCurrentTheme(themeId)
|
||||||
|
|
||||||
|
// Save to database
|
||||||
|
const { data: { user } } = await supabase.auth.getUser()
|
||||||
|
if (user) {
|
||||||
|
await supabase
|
||||||
|
.from('profiles')
|
||||||
|
.update({ theme: themeId })
|
||||||
|
.eq('id', user.id)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to set theme:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ThemeContext.Provider value={{ currentTheme, setTheme, themes }}>
|
||||||
|
{children}
|
||||||
|
</ThemeContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
21
components/language-toggle.tsx
Normal file
21
components/language-toggle.tsx
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useLanguage } from '@/contexts/LanguageContext'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Languages } from 'lucide-react'
|
||||||
|
|
||||||
|
export function LanguageToggle() {
|
||||||
|
const { language, toggleLanguage } = useLanguage()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={toggleLanguage}
|
||||||
|
className="flex items-center gap-2 text-sm"
|
||||||
|
>
|
||||||
|
<Languages className="h-4 w-4" />
|
||||||
|
{language === 'en' ? 'DE' : 'EN'}
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -101,7 +101,7 @@ export function ListDetailContent({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="mx-auto max-w-lg">
|
<main className="mx-auto max-w-lg">
|
||||||
<header className="sticky top-0 z-40 flex items-center justify-between border-b border-border bg-background/95 px-4 py-3 backdrop-blur-md">
|
<header className="sticky top-0 z-40 glass-header px-4 py-3">
|
||||||
<button onClick={() => router.back()} type="button" aria-label="Zurueck">
|
<button onClick={() => router.back()} type="button" aria-label="Zurueck">
|
||||||
<ArrowLeft className="h-5 w-5 text-foreground" />
|
<ArrowLeft className="h-5 w-5 text-foreground" />
|
||||||
</button>
|
</button>
|
||||||
@ -123,7 +123,7 @@ export function ListDetailContent({
|
|||||||
value={query}
|
value={query}
|
||||||
onChange={(e) => setQuery(e.target.value)}
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
placeholder="Film zur Liste hinzufuegen..."
|
placeholder="Film zur Liste hinzufuegen..."
|
||||||
className="h-10 w-full rounded-lg border border-border bg-secondary pl-9 pr-9 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
|
className="glass-input h-10 w-full pl-9 pr-9 text-sm"
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
@ -176,7 +176,7 @@ export function ListDetailContent({
|
|||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowSearch(true)}
|
onClick={() => setShowSearch(true)}
|
||||||
className="mb-4 flex w-full items-center justify-center gap-2 rounded-xl border border-dashed border-border bg-card py-3 text-sm font-medium text-muted-foreground transition-colors hover:border-primary hover:text-primary"
|
className="mb-4 glass-card flex w-full items-center justify-center gap-2 py-3 text-sm font-medium text-muted-foreground transition-all hover:bg-white/[0.08] active:scale-[0.98]"
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<Plus className="h-4 w-4" />
|
<Plus className="h-4 w-4" />
|
||||||
|
|||||||
@ -29,12 +29,14 @@ interface FamilyEntry {
|
|||||||
|
|
||||||
interface MovieDetailProps {
|
interface MovieDetailProps {
|
||||||
movie: TMDBMovieDetail
|
movie: TMDBMovieDetail
|
||||||
|
externalRatings: any
|
||||||
isInWatchlist: boolean
|
isInWatchlist: boolean
|
||||||
familyEntries: FamilyEntry[]
|
familyEntries: FamilyEntry[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MovieDetail({
|
export function MovieDetail({
|
||||||
movie,
|
movie,
|
||||||
|
externalRatings,
|
||||||
isInWatchlist: initialWatchlist,
|
isInWatchlist: initialWatchlist,
|
||||||
familyEntries,
|
familyEntries,
|
||||||
}: MovieDetailProps) {
|
}: MovieDetailProps) {
|
||||||
@ -111,7 +113,7 @@ export function MovieDetail({
|
|||||||
{/* Back button */}
|
{/* Back button */}
|
||||||
<button
|
<button
|
||||||
onClick={() => router.back()}
|
onClick={() => router.back()}
|
||||||
className="absolute left-4 top-4 flex h-9 w-9 items-center justify-center rounded-full bg-background/80 text-foreground backdrop-blur-sm"
|
className="absolute left-4 top-4 glass-avatar flex h-9 w-9 items-center justify-center text-foreground"
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="Zurueck"
|
aria-label="Zurueck"
|
||||||
>
|
>
|
||||||
@ -121,10 +123,10 @@ export function MovieDetail({
|
|||||||
{/* Copy link button */}
|
{/* Copy link button */}
|
||||||
<button
|
<button
|
||||||
onClick={handleCopyLink}
|
onClick={handleCopyLink}
|
||||||
className={`absolute right-4 top-4 flex h-9 w-9 items-center justify-center rounded-full backdrop-blur-sm transition-colors ${
|
className={`absolute right-4 top-4 glass-avatar flex h-9 w-9 items-center justify-center transition-colors ${
|
||||||
copied
|
copied
|
||||||
? "bg-primary/80 text-primary-foreground"
|
? "bg-primary/80 text-primary-foreground"
|
||||||
: "bg-background/80 text-foreground"
|
: ""
|
||||||
}`}
|
}`}
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="Link kopieren"
|
aria-label="Link kopieren"
|
||||||
@ -166,7 +168,7 @@ export function MovieDetail({
|
|||||||
{movie.genres.slice(0, 3).map((g) => (
|
{movie.genres.slice(0, 3).map((g) => (
|
||||||
<span
|
<span
|
||||||
key={g.id}
|
key={g.id}
|
||||||
className="rounded-md bg-secondary px-2 py-0.5 text-[10px] font-medium text-secondary-foreground"
|
className="glass-tag px-2 py-0.5"
|
||||||
>
|
>
|
||||||
{g.name}
|
{g.name}
|
||||||
</span>
|
</span>
|
||||||
@ -183,11 +185,43 @@ export function MovieDetail({
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* External Ratings */}
|
||||||
|
{externalRatings && (
|
||||||
|
<div className="mt-4 glass-card p-3">
|
||||||
|
<h4 className="mb-2 text-sm font-medium text-foreground">Externe Bewertungen</h4>
|
||||||
|
<div className="flex flex-wrap gap-4 text-sm">
|
||||||
|
{externalRatings.imdb_rating && (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<span className="font-medium">IMDb</span>
|
||||||
|
<span className="text-muted-foreground">{externalRatings.imdb_rating}</span>
|
||||||
|
{externalRatings.imdb_vote_count && (
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
({externalRatings.imdb_vote_count.toLocaleString()})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{externalRatings.rotten_tomatoes_rating && (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<span className="font-medium">RT</span>
|
||||||
|
<span className="text-muted-foreground">{externalRatings.rotten_tomatoes_rating}%</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{movie.vote_average > 0 && (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<span className="font-medium">TMDB</span>
|
||||||
|
<span className="text-muted-foreground">{movie.vote_average.toFixed(1)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<div className="mt-4 flex gap-3">
|
<div className="mt-4 flex gap-3">
|
||||||
<Link
|
<Link
|
||||||
href={`/log?tmdb_id=${movie.id}&title=${encodeURIComponent(movie.title)}&poster_path=${movie.poster_path || ""}`}
|
href={`/log?tmdb_id=${movie.id}&title=${encodeURIComponent(movie.title)}&poster_path=${movie.poster_path || ""}`}
|
||||||
className="flex flex-1 items-center justify-center gap-2 rounded-lg bg-primary py-3 text-sm font-semibold text-primary-foreground"
|
className="glass-button flex flex-1 items-center justify-center gap-2 py-3 text-sm font-semibold text-primary-foreground shadow-lg shadow-primary/20"
|
||||||
>
|
>
|
||||||
<PenLine className="h-4 w-4" />
|
<PenLine className="h-4 w-4" />
|
||||||
Loggen
|
Loggen
|
||||||
@ -195,10 +229,10 @@ export function MovieDetail({
|
|||||||
<button
|
<button
|
||||||
onClick={toggleWatchlist}
|
onClick={toggleWatchlist}
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
className={`flex items-center justify-center gap-2 rounded-lg border px-5 py-3 text-sm font-semibold transition-colors ${
|
className={`glass-button flex items-center justify-center gap-2 px-5 py-3 text-sm font-semibold transition-colors ${
|
||||||
inWatchlist
|
inWatchlist
|
||||||
? "border-primary bg-primary/10 text-primary"
|
? "border-primary bg-primary/10 text-primary"
|
||||||
: "border-border bg-card text-foreground hover:bg-secondary"
|
: ""
|
||||||
}`}
|
}`}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
@ -233,10 +267,10 @@ export function MovieDetail({
|
|||||||
{familyEntries.map((entry) => (
|
{familyEntries.map((entry) => (
|
||||||
<div
|
<div
|
||||||
key={entry.id}
|
key={entry.id}
|
||||||
className="rounded-xl border border-border bg-card p-3"
|
className="glass-card p-3"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="flex h-6 w-6 items-center justify-center rounded-full bg-primary/20 text-[10px] font-bold text-primary">
|
<div className="glass-avatar flex h-6 w-6 items-center justify-center text-[10px] font-bold text-primary">
|
||||||
{entry.profiles?.display_name?.charAt(0).toUpperCase()}
|
{entry.profiles?.display_name?.charAt(0).toUpperCase()}
|
||||||
</div>
|
</div>
|
||||||
<span className="text-xs font-medium text-foreground">
|
<span className="text-xs font-medium text-foreground">
|
||||||
|
|||||||
@ -3,9 +3,10 @@
|
|||||||
import { useRouter } from "next/navigation"
|
import { useRouter } from "next/navigation"
|
||||||
import { createClient } from "@/lib/supabase/client"
|
import { createClient } from "@/lib/supabase/client"
|
||||||
import { BookOpen, Bookmark, ListIcon, LogOut, Film } from "lucide-react"
|
import { BookOpen, Bookmark, ListIcon, LogOut, Film } from "lucide-react"
|
||||||
|
import { ThemeSelector } from "@/components/theme-selector"
|
||||||
|
|
||||||
interface ProfileContentProps {
|
interface ProfileContentProps {
|
||||||
profile: { display_name: string; avatar_url: string | null } | null
|
profile: { display_name: string; avatar_url: string | null; theme?: string } | null
|
||||||
email: string
|
email: string
|
||||||
stats: {
|
stats: {
|
||||||
diary: number
|
diary: number
|
||||||
@ -34,7 +35,7 @@ export function ProfileContent({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="mx-auto max-w-lg">
|
<main className="mx-auto max-w-lg">
|
||||||
<header className="border-b border-border px-4 py-3">
|
<header className="glass-header px-4 py-3">
|
||||||
<h1 className="font-heading text-xl font-bold text-foreground">
|
<h1 className="font-heading text-xl font-bold text-foreground">
|
||||||
Profil
|
Profil
|
||||||
</h1>
|
</h1>
|
||||||
@ -43,7 +44,7 @@ export function ProfileContent({
|
|||||||
<div className="px-4 pt-8">
|
<div className="px-4 pt-8">
|
||||||
{/* Avatar & Name */}
|
{/* Avatar & Name */}
|
||||||
<div className="flex flex-col items-center gap-3">
|
<div className="flex flex-col items-center gap-3">
|
||||||
<div className="flex h-20 w-20 items-center justify-center rounded-full bg-primary/20">
|
<div className="glass-avatar flex h-20 w-20 items-center justify-center">
|
||||||
<span className="font-heading text-2xl font-bold text-primary">
|
<span className="font-heading text-2xl font-bold text-primary">
|
||||||
{initials}
|
{initials}
|
||||||
</span>
|
</span>
|
||||||
@ -67,7 +68,7 @@ export function ProfileContent({
|
|||||||
<div className="mt-8 flex flex-col gap-3">
|
<div className="mt-8 flex flex-col gap-3">
|
||||||
<button
|
<button
|
||||||
onClick={handleLogout}
|
onClick={handleLogout}
|
||||||
className="flex items-center justify-center gap-2 rounded-xl border border-border bg-card py-3.5 text-sm font-medium text-destructive transition-colors hover:bg-destructive/10"
|
className="glass-button flex items-center justify-center gap-2 py-3.5 text-sm font-medium text-destructive transition-all hover:bg-destructive/10"
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<LogOut className="h-4 w-4" />
|
<LogOut className="h-4 w-4" />
|
||||||
@ -87,6 +88,11 @@ export function ProfileContent({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Theme Selector */}
|
||||||
|
<div className="mt-8">
|
||||||
|
<ThemeSelector currentTheme={profile?.theme} />
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="h-8" />
|
<div className="h-8" />
|
||||||
</main>
|
</main>
|
||||||
)
|
)
|
||||||
@ -102,7 +108,7 @@ function StatCard({
|
|||||||
value: number
|
value: number
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center gap-1 rounded-xl border border-border bg-card py-4">
|
<div className="glass-card flex flex-col items-center gap-1 py-4">
|
||||||
<Icon className="h-5 w-5 text-primary" />
|
<Icon className="h-5 w-5 text-primary" />
|
||||||
<span className="font-heading text-xl font-bold text-foreground">
|
<span className="font-heading text-xl font-bold text-foreground">
|
||||||
{value}
|
{value}
|
||||||
|
|||||||
296
components/theme-selector.tsx
Normal file
296
components/theme-selector.tsx
Normal file
@ -0,0 +1,296 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react"
|
||||||
|
import { createClient } from "@/lib/supabase/client"
|
||||||
|
import { Palette, Check } from "lucide-react"
|
||||||
|
|
||||||
|
// Einfache Themes die garantiert funktionieren
|
||||||
|
const simpleThemes = {
|
||||||
|
'apple-frosted-light': {
|
||||||
|
background: 'rgb(242, 242, 247)',
|
||||||
|
foreground: 'rgb(28, 28, 30)',
|
||||||
|
glassBg: 'rgba(255, 255, 255, 0.85)', // Mehr Deckkraft
|
||||||
|
glassBorder: 'rgba(255, 255, 255, 0.25)', // Deutlicherer Rand
|
||||||
|
primary: 'rgb(0, 122, 255)'
|
||||||
|
},
|
||||||
|
'apple-frosted-dark': {
|
||||||
|
background: 'rgb(0, 0, 0)',
|
||||||
|
foreground: 'rgb(255, 255, 255)',
|
||||||
|
glassBg: 'rgba(44, 44, 46, 0.8)',
|
||||||
|
glassBorder: 'rgba(255, 255, 255, 0.12)',
|
||||||
|
primary: 'rgb(10, 132, 255)'
|
||||||
|
},
|
||||||
|
'ocean-blue': {
|
||||||
|
background: 'rgb(240, 249, 255)',
|
||||||
|
foreground: 'rgb(15, 23, 42)', // Dunklerer Text
|
||||||
|
glassBg: 'rgba(255, 255, 255, 0.95)', // Mehr Deckkraft
|
||||||
|
glassBorder: 'rgba(59, 130, 246, 0.3)', // Deutlicherer Rand
|
||||||
|
primary: 'rgb(14, 165, 233)'
|
||||||
|
},
|
||||||
|
'forest-green': {
|
||||||
|
background: 'rgb(240, 253, 244)',
|
||||||
|
foreground: 'rgb(21, 63, 31)', // Dunklerer Text
|
||||||
|
glassBg: 'rgba(255, 255, 255, 0.95)', // Mehr Deckkraft
|
||||||
|
glassBorder: 'rgba(34, 197, 94, 0.3)', // Deutlicherer Rand
|
||||||
|
primary: 'rgb(34, 197, 94)'
|
||||||
|
},
|
||||||
|
'cinema-noir': {
|
||||||
|
background: 'rgb(10, 10, 10)',
|
||||||
|
foreground: 'rgb(232, 232, 232)',
|
||||||
|
glassBg: 'rgba(20, 20, 20, 0.85)',
|
||||||
|
glassBorder: 'rgba(255, 215, 0, 0.15)',
|
||||||
|
primary: 'rgb(255, 215, 0)'
|
||||||
|
},
|
||||||
|
'sunset-purple': {
|
||||||
|
background: 'rgb(26, 0, 26)',
|
||||||
|
foreground: 'rgb(243, 232, 255)',
|
||||||
|
glassBg: 'rgba(45, 27, 45, 0.85)',
|
||||||
|
glassBorder: 'rgba(167, 139, 250, 0.2)',
|
||||||
|
primary: 'rgb(167, 139, 250)'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applySimpleTheme(themeId: string) {
|
||||||
|
const theme = simpleThemes[themeId as keyof typeof simpleThemes];
|
||||||
|
if (!theme) return;
|
||||||
|
|
||||||
|
// Entferne alte Styles
|
||||||
|
const oldStyles = document.getElementById('simple-theme-styles');
|
||||||
|
if (oldStyles) oldStyles.remove();
|
||||||
|
|
||||||
|
// Erstelle neue Styles mit direkten Farben
|
||||||
|
const styleElement = document.createElement('style');
|
||||||
|
styleElement.id = 'simple-theme-styles';
|
||||||
|
styleElement.textContent = `
|
||||||
|
body {
|
||||||
|
background-color: ${theme.background} !important;
|
||||||
|
color: ${theme.foreground} !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass-card,
|
||||||
|
.glass-header,
|
||||||
|
.glass-button,
|
||||||
|
.glass-avatar,
|
||||||
|
.glass-tag,
|
||||||
|
.glass-input {
|
||||||
|
background: ${theme.glassBg} !important;
|
||||||
|
border: 1px solid ${theme.glassBorder} !important;
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
-webkit-backdrop-filter: blur(20px);
|
||||||
|
color: ${theme.foreground} !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-primary {
|
||||||
|
color: ${theme.primary} !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg-primary {
|
||||||
|
background-color: ${theme.primary} !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1, h2, h3, h4, h5, h6 {
|
||||||
|
color: ${theme.foreground} !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
p, span, div {
|
||||||
|
color: ${theme.foreground} !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-muted-foreground {
|
||||||
|
color: ${theme.foreground}66 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-foreground {
|
||||||
|
color: ${theme.foreground} !important;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
document.head.appendChild(styleElement);
|
||||||
|
document.body.setAttribute('data-theme', themeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ThemeSelectorProps {
|
||||||
|
currentTheme?: string
|
||||||
|
onThemeChange?: (themeId: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ThemeSelector({ currentTheme = "apple-frosted-light", onThemeChange }: ThemeSelectorProps) {
|
||||||
|
const [selectedTheme, setSelectedTheme] = useState(currentTheme)
|
||||||
|
const [applying, setApplying] = useState(false)
|
||||||
|
const supabase = createClient()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Apply theme on mount
|
||||||
|
applySimpleTheme(selectedTheme)
|
||||||
|
}, [selectedTheme])
|
||||||
|
|
||||||
|
async function handleThemeChange(themeId: string) {
|
||||||
|
setSelectedTheme(themeId)
|
||||||
|
setApplying(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
applySimpleTheme(themeId)
|
||||||
|
onThemeChange?.(themeId)
|
||||||
|
|
||||||
|
// Save to database
|
||||||
|
const { data: { user } } = await supabase.auth.getUser()
|
||||||
|
if (user) {
|
||||||
|
await supabase
|
||||||
|
.from('profiles')
|
||||||
|
.update({ theme: themeId })
|
||||||
|
.eq('id', user.id)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to apply theme:', error)
|
||||||
|
} finally {
|
||||||
|
setApplying(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Theme Definitionen für UI
|
||||||
|
const themeDefinitions = [
|
||||||
|
{
|
||||||
|
id: 'apple-frosted-light',
|
||||||
|
name: 'Apple Frosted Glass',
|
||||||
|
description: 'Klassisches Apple Design mit Glas-Effekten (Hell)',
|
||||||
|
colors: { primary: 'rgb(0, 122, 255)', accent: 'rgb(88, 86, 214)', background: 'rgb(242, 242, 247)' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'apple-frosted-dark',
|
||||||
|
name: 'Apple Frosted Glass Dark',
|
||||||
|
description: 'Klassisches Apple Design mit Glas-Effekten (Dunkel)',
|
||||||
|
colors: { primary: 'rgb(10, 132, 255)', accent: 'rgb(94, 92, 230)', background: 'rgb(0, 0, 0)' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ocean-blue',
|
||||||
|
name: 'Ocean Blue',
|
||||||
|
description: 'Marines Hellblau-Theme mit sanften Übergängen',
|
||||||
|
colors: { primary: 'rgb(14, 165, 233)', accent: 'rgb(6, 182, 212)', background: 'rgb(240, 249, 255)' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'forest-green',
|
||||||
|
name: 'Forest Green',
|
||||||
|
description: 'Natürliches grün Theme mit Waldfarben',
|
||||||
|
colors: { primary: 'rgb(34, 197, 94)', accent: 'rgb(132, 204, 22)', background: 'rgb(240, 253, 244)' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'cinema-noir',
|
||||||
|
name: 'Cinema Noir',
|
||||||
|
description: 'Elegantes dunkles Kino-Theme mit tiefen Farben',
|
||||||
|
colors: { primary: 'rgb(255, 215, 0)', accent: 'rgb(255, 107, 107)', background: 'rgb(10, 10, 10)' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'sunset-purple',
|
||||||
|
name: 'Sunset Purple',
|
||||||
|
description: 'Warmes lila Theme mit Sonnenuntergang-Farben',
|
||||||
|
colors: { primary: 'rgb(167, 139, 250)', accent: 'rgb(244, 114, 182)', background: 'rgb(26, 0, 26)' }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Palette className="h-5 w-5" />
|
||||||
|
<h3 className="text-lg font-semibold">Theme auswählen</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-3">
|
||||||
|
{themeDefinitions.map((theme) => (
|
||||||
|
<ThemeCard
|
||||||
|
key={theme.id}
|
||||||
|
theme={theme}
|
||||||
|
isSelected={selectedTheme === theme.id}
|
||||||
|
isApplying={applying && selectedTheme === theme.id}
|
||||||
|
onSelect={() => handleThemeChange(theme.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ThemeCardProps {
|
||||||
|
theme: {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
colors: {
|
||||||
|
primary: string
|
||||||
|
accent: string
|
||||||
|
background: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
isSelected: boolean
|
||||||
|
isApplying: boolean
|
||||||
|
onSelect: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function ThemeCard({ theme, isSelected, isApplying, onSelect }: ThemeCardProps) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={onSelect}
|
||||||
|
disabled={isApplying}
|
||||||
|
className={`
|
||||||
|
relative w-full rounded-lg border p-4 text-left transition-all
|
||||||
|
${isSelected
|
||||||
|
? 'border-primary ring-2 ring-primary/20'
|
||||||
|
: 'border-border hover:border-primary/50'
|
||||||
|
}
|
||||||
|
${isApplying ? 'opacity-50' : ''}
|
||||||
|
`}
|
||||||
|
style={{
|
||||||
|
backgroundColor: theme.colors.background,
|
||||||
|
borderColor: theme.colors.primary + '33',
|
||||||
|
color: theme.colors.background === 'rgb(0, 0, 0)' || theme.colors.background === 'rgb(10, 10, 10)' || theme.colors.background === 'rgb(26, 0, 26)' ? '#ffffff' : '#000000'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h4 className="font-medium" style={{ color: theme.colors.background === 'rgb(0, 0, 0)' || theme.colors.background === 'rgb(10, 10, 10)' || theme.colors.background === 'rgb(26, 0, 26)' ? '#ffffff' : '#000000' }}>
|
||||||
|
{theme.name}
|
||||||
|
</h4>
|
||||||
|
<p className="text-sm" style={{ color: theme.colors.background === 'rgb(0, 0, 0)' || theme.colors.background === 'rgb(10, 10, 10)' || theme.colors.background === 'rgb(26, 0, 26)' ? '#ffffff99' : '#00000099' }}>
|
||||||
|
{theme.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{/* Color preview */}
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<div
|
||||||
|
className="h-4 w-4 rounded-full border"
|
||||||
|
style={{
|
||||||
|
backgroundColor: theme.colors.primary,
|
||||||
|
borderColor: theme.colors.background === 'rgb(0, 0, 0)' || theme.colors.background === 'rgb(10, 10, 10)' || theme.colors.background === 'rgb(26, 0, 26)' ? '#ffffff33' : '#00000033'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="h-4 w-4 rounded-full border"
|
||||||
|
style={{
|
||||||
|
backgroundColor: theme.colors.accent,
|
||||||
|
borderColor: theme.colors.background === 'rgb(0, 0, 0)' || theme.colors.background === 'rgb(10, 10, 10)' || theme.colors.background === 'rgb(26, 0, 26)' ? '#ffffff33' : '#00000033'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="h-4 w-4 rounded-full border"
|
||||||
|
style={{
|
||||||
|
backgroundColor: theme.colors.background,
|
||||||
|
borderColor: theme.colors.background === 'rgb(0, 0, 0)' || theme.colors.background === 'rgb(10, 10, 10)' || theme.colors.background === 'rgb(26, 0, 26)' ? '#ffffff33' : '#00000033'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isSelected && !isApplying && (
|
||||||
|
<Check className="h-4 w-4" style={{ color: theme.colors.primary }} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isApplying && (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center rounded-lg bg-background/50">
|
||||||
|
<div className="h-4 w-4 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
79
contexts/LanguageContext.tsx
Normal file
79
contexts/LanguageContext.tsx
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { createContext, useContext, useState, useEffect, ReactNode } from 'react'
|
||||||
|
|
||||||
|
type Language = 'en' | 'de'
|
||||||
|
|
||||||
|
interface Translations {
|
||||||
|
[key: string]: {
|
||||||
|
[key: string]: string | Translations
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LanguageContextType {
|
||||||
|
language: Language
|
||||||
|
setLanguage: (lang: Language) => void
|
||||||
|
t: (key: string) => string
|
||||||
|
toggleLanguage: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const LanguageContext = createContext<LanguageContextType | undefined>(undefined)
|
||||||
|
|
||||||
|
// Import translations
|
||||||
|
const translations: Record<Language, Translations> = {
|
||||||
|
en: require('../locales/en.json'),
|
||||||
|
de: require('../locales/de.json')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LanguageProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [language, setLanguage] = useState<Language>('de')
|
||||||
|
|
||||||
|
// Load saved language from localStorage
|
||||||
|
useEffect(() => {
|
||||||
|
const savedLanguage = localStorage.getItem('language') as Language
|
||||||
|
if (savedLanguage && (savedLanguage === 'en' || savedLanguage === 'de')) {
|
||||||
|
setLanguage(savedLanguage)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Save language to localStorage when it changes
|
||||||
|
useEffect(() => {
|
||||||
|
localStorage.setItem('language', language)
|
||||||
|
}, [language])
|
||||||
|
|
||||||
|
// Translation function
|
||||||
|
const t = (key: string): string => {
|
||||||
|
const keys = key.split('.')
|
||||||
|
let value: any = translations[language]
|
||||||
|
|
||||||
|
for (const k of keys) {
|
||||||
|
value = value?.[k]
|
||||||
|
}
|
||||||
|
|
||||||
|
return value || key
|
||||||
|
}
|
||||||
|
|
||||||
|
// Toggle between languages
|
||||||
|
const toggleLanguage = () => {
|
||||||
|
setLanguage(prev => prev === 'en' ? 'de' : 'en')
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<LanguageContext.Provider value={{
|
||||||
|
language,
|
||||||
|
setLanguage,
|
||||||
|
t,
|
||||||
|
toggleLanguage
|
||||||
|
}}>
|
||||||
|
{children}
|
||||||
|
</LanguageContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useLanguage() {
|
||||||
|
const context = useContext(LanguageContext)
|
||||||
|
if (context === undefined) {
|
||||||
|
throw new Error('useLanguage must be used within a LanguageProvider')
|
||||||
|
}
|
||||||
|
return context
|
||||||
|
}
|
||||||
44
debug-theme.js
Normal file
44
debug-theme.js
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
// DEBUG: Kopiere diesen Code in F12 Console auf der Profil-Seite
|
||||||
|
console.log('=== THEME DEBUG ===');
|
||||||
|
|
||||||
|
// 1. Prüfe ob Theme-CSS existiert
|
||||||
|
const themeStyles = document.getElementById('theme-styles');
|
||||||
|
console.log('Theme styles gefunden:', !!themeStyles);
|
||||||
|
|
||||||
|
if (themeStyles) {
|
||||||
|
console.log('Theme CSS Inhalt:');
|
||||||
|
console.log(themeStyles.textContent.substring(0, 500));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Prüfe CSS Variablen
|
||||||
|
const rootStyles = getComputedStyle(document.documentElement);
|
||||||
|
const vars = {
|
||||||
|
'--glass-bg': rootStyles.getPropertyValue('--glass-bg'),
|
||||||
|
'--glass-border': rootStyles.getPropertyValue('--glass-border'),
|
||||||
|
'--primary': rootStyles.getPropertyValue('--primary')
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log('CSS Variablen:');
|
||||||
|
Object.entries(vars).forEach(([key, value]) => {
|
||||||
|
console.log(`${key}: "${value.trim()}"`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3. Prüfe Glass Elemente
|
||||||
|
const glassCards = document.querySelectorAll('.glass-card');
|
||||||
|
console.log('Glass cards gefunden:', glassCards.length);
|
||||||
|
|
||||||
|
if (glassCards.length > 0) {
|
||||||
|
const firstCard = glassCards[0];
|
||||||
|
const styles = getComputedStyle(firstCard);
|
||||||
|
console.log('Erste Glass Card:');
|
||||||
|
console.log(' Background:', styles.backgroundColor);
|
||||||
|
console.log(' Border:', styles.borderColor);
|
||||||
|
console.log(' Box-shadow:', styles.boxShadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Prüfe berechnete Styles vs CSS Variablen
|
||||||
|
const computedBg = rootStyles.getPropertyValue('--glass-bg');
|
||||||
|
console.log('Computed --glass-bg:', computedBg);
|
||||||
|
console.log('RGB Werte:', computedBg.match(/\d+/g));
|
||||||
|
|
||||||
|
console.log('=== END DEBUG ===');
|
||||||
23
fix-profile.sql
Normal file
23
fix-profile.sql
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
-- Check if profile exists for current user and create if missing
|
||||||
|
-- Replace 'YOUR_USER_ID' with the actual user ID from the error
|
||||||
|
|
||||||
|
-- First, let's check what users exist in auth but not in profiles
|
||||||
|
SELECT
|
||||||
|
au.id,
|
||||||
|
au.email,
|
||||||
|
au.created_at,
|
||||||
|
p.id as profile_id
|
||||||
|
FROM auth.users au
|
||||||
|
LEFT JOIN public.profiles p ON au.id = p.id
|
||||||
|
WHERE p.id IS NULL;
|
||||||
|
|
||||||
|
-- If you find your user, create the profile manually:
|
||||||
|
INSERT INTO public.profiles (id, display_name, avatar_url, created_at)
|
||||||
|
VALUES
|
||||||
|
(
|
||||||
|
'b1a41b03-afa6-4ba4-ade0-30b1bb404af5', -- Replace with your actual user ID
|
||||||
|
'Test User', -- You can change this
|
||||||
|
NULL,
|
||||||
|
NOW()
|
||||||
|
)
|
||||||
|
ON CONFLICT (id) DO NOTHING;
|
||||||
88
lib/external-ratings.ts
Normal file
88
lib/external-ratings.ts
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
// External Ratings API
|
||||||
|
import { createClient } from '@/lib/supabase/client'
|
||||||
|
|
||||||
|
export async function getExternalRatings(tmdbId: number, mediaType: 'movie' | 'tv') {
|
||||||
|
const supabase = createClient()
|
||||||
|
|
||||||
|
// First check cache
|
||||||
|
const { data: cached } = await supabase
|
||||||
|
.from('external_ratings')
|
||||||
|
.select('*')
|
||||||
|
.eq('tmdb_id', tmdbId)
|
||||||
|
.eq('media_type', mediaType)
|
||||||
|
.single()
|
||||||
|
|
||||||
|
if (cached && new Date(cached.last_updated).getTime() > Date.now() - 24 * 60 * 60 * 1000) {
|
||||||
|
return cached
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch from TMDB (includes IMDB ID)
|
||||||
|
const tmdbResponse = await fetch(
|
||||||
|
`https://api.themoviedb.org/3/${mediaType}/${tmdbId}?api_key=${process.env.NEXT_PUBLIC_TMDB_API_KEY}&append_to_response=external_ids`
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!tmdbResponse.ok) {
|
||||||
|
throw new Error('Failed to fetch TMDB data')
|
||||||
|
}
|
||||||
|
|
||||||
|
const tmdbData = await tmdbResponse.json()
|
||||||
|
const imdbId = tmdbData.external_ids?.imdb_id
|
||||||
|
|
||||||
|
let imdbRating = null
|
||||||
|
let imdbVoteCount = null
|
||||||
|
let rottenTomatoesRating = null
|
||||||
|
|
||||||
|
// Fetch IMDB rating if IMDB ID is available
|
||||||
|
if (imdbId) {
|
||||||
|
try {
|
||||||
|
// Note: In production, you'd want to use a proper IMDB API or proxy
|
||||||
|
// For now, we'll simulate with OMDB API (free tier)
|
||||||
|
const omdbResponse = await fetch(
|
||||||
|
`https://www.omdbapi.com/?i=${imdbId}&apikey=${process.env.NEXT_PUBLIC_OMDB_API_KEY}`
|
||||||
|
)
|
||||||
|
|
||||||
|
if (omdbResponse.ok) {
|
||||||
|
const omdbData = await omdbResponse.json()
|
||||||
|
if (omdbData.imdbRating && omdbData.imdbVotes) {
|
||||||
|
imdbRating = parseFloat(omdbData.imdbRating)
|
||||||
|
imdbVoteCount = parseInt(omdbData.imdbVotes.replace(/,/g, ''))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to get Rotten Tomatoes rating from OMDB
|
||||||
|
if (omdbData.Ratings) {
|
||||||
|
const rtRating = omdbData.Ratings.find((r: any) => r.Source === 'Rotten Tomatoes')
|
||||||
|
if (rtRating && rtRating.Value) {
|
||||||
|
// Extract percentage from format like "88%" or "8.8/10"
|
||||||
|
const rtMatch = rtRating.Value.match(/(\d+(?:\.\d+)?)/)
|
||||||
|
if (rtMatch) {
|
||||||
|
const rtValue = parseFloat(rtMatch[1])
|
||||||
|
// Convert to percentage if it's not already
|
||||||
|
rottenTomatoesRating = rtValue > 10 ? rtValue : rtValue * 10
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to fetch IMDB ratings:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache the results
|
||||||
|
const ratingsData = {
|
||||||
|
tmdb_id: tmdbId,
|
||||||
|
media_type: mediaType,
|
||||||
|
imdb_id: imdbId,
|
||||||
|
imdb_rating: imdbRating,
|
||||||
|
imdb_vote_count: imdbVoteCount,
|
||||||
|
rotten_tomatoes_rating: rottenTomatoesRating,
|
||||||
|
last_updated: new Date().toISOString()
|
||||||
|
}
|
||||||
|
|
||||||
|
await supabase
|
||||||
|
.from('external_ratings')
|
||||||
|
.upsert(ratingsData, {
|
||||||
|
onConflict: 'tmdb_id,media_type'
|
||||||
|
})
|
||||||
|
|
||||||
|
return ratingsData
|
||||||
|
}
|
||||||
90
lib/simple-themes.ts
Normal file
90
lib/simple-themes.ts
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
// Einfaches Theme System - garantiert funktionierend
|
||||||
|
export const simpleThemes = {
|
||||||
|
'apple-frosted-light': {
|
||||||
|
background: 'rgb(242, 242, 247)',
|
||||||
|
foreground: 'rgb(28, 28, 30)',
|
||||||
|
glassBg: 'rgba(255, 255, 255, 0.8)',
|
||||||
|
glassBorder: 'rgba(255, 255, 255, 0.18)',
|
||||||
|
primary: 'rgb(0, 122, 255)'
|
||||||
|
},
|
||||||
|
'apple-frosted-dark': {
|
||||||
|
background: 'rgb(0, 0, 0)',
|
||||||
|
foreground: 'rgb(255, 255, 255)',
|
||||||
|
glassBg: 'rgba(44, 44, 46, 0.8)',
|
||||||
|
glassBorder: 'rgba(255, 255, 255, 0.12)',
|
||||||
|
primary: 'rgb(10, 132, 255)'
|
||||||
|
},
|
||||||
|
'ocean-blue': {
|
||||||
|
background: 'rgb(240, 249, 255)',
|
||||||
|
foreground: 'rgb(12, 74, 110)',
|
||||||
|
glassBg: 'rgba(255, 255, 255, 0.9)',
|
||||||
|
glassBorder: 'rgba(59, 130, 246, 0.2)',
|
||||||
|
primary: 'rgb(14, 165, 233)'
|
||||||
|
},
|
||||||
|
'forest-green': {
|
||||||
|
background: 'rgb(240, 253, 244)',
|
||||||
|
foreground: 'rgb(20, 83, 45)',
|
||||||
|
glassBg: 'rgba(255, 255, 255, 0.9)',
|
||||||
|
glassBorder: 'rgba(34, 197, 94, 0.2)',
|
||||||
|
primary: 'rgb(34, 197, 94)'
|
||||||
|
},
|
||||||
|
'cinema-noir': {
|
||||||
|
background: 'rgb(10, 10, 10)',
|
||||||
|
foreground: 'rgb(232, 232, 232)',
|
||||||
|
glassBg: 'rgba(20, 20, 20, 0.85)',
|
||||||
|
glassBorder: 'rgba(255, 215, 0, 0.15)',
|
||||||
|
primary: 'rgb(255, 215, 0)'
|
||||||
|
},
|
||||||
|
'sunset-purple': {
|
||||||
|
background: 'rgb(26, 0, 26)',
|
||||||
|
foreground: 'rgb(243, 232, 255)',
|
||||||
|
glassBg: 'rgba(45, 27, 45, 0.85)',
|
||||||
|
glassBorder: 'rgba(167, 139, 250, 0.2)',
|
||||||
|
primary: 'rgb(167, 139, 250)'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applySimpleTheme(themeId: string) {
|
||||||
|
const theme = simpleThemes[themeId as keyof typeof simpleThemes];
|
||||||
|
if (!theme) return;
|
||||||
|
|
||||||
|
// Entferne alte Styles
|
||||||
|
const oldStyles = document.getElementById('simple-theme-styles');
|
||||||
|
if (oldStyles) oldStyles.remove();
|
||||||
|
|
||||||
|
// Erstelle neue Styles mit direkten Farben
|
||||||
|
const styleElement = document.createElement('style');
|
||||||
|
styleElement.id = 'simple-theme-styles';
|
||||||
|
styleElement.textContent = `
|
||||||
|
body {
|
||||||
|
background-color: ${theme.background} !important;
|
||||||
|
color: ${theme.foreground} !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass-card,
|
||||||
|
.glass-header,
|
||||||
|
.glass-button,
|
||||||
|
.glass-avatar,
|
||||||
|
.glass-tag,
|
||||||
|
.glass-input {
|
||||||
|
background: ${theme.glassBg} !important;
|
||||||
|
border: 1px solid ${theme.glassBorder} !important;
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
-webkit-backdrop-filter: blur(20px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-primary {
|
||||||
|
color: ${theme.primary} !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg-primary {
|
||||||
|
background-color: ${theme.primary} !important;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
document.head.appendChild(styleElement);
|
||||||
|
document.body.setAttribute('data-theme', themeId);
|
||||||
|
|
||||||
|
console.log(`✅ Simple Theme "${themeId}" applied`);
|
||||||
|
console.log(`Glass BG: ${theme.glassBg}`);
|
||||||
|
}
|
||||||
430
lib/themes.ts
Normal file
430
lib/themes.ts
Normal file
@ -0,0 +1,430 @@
|
|||||||
|
// Theme System für InFocus Movie App
|
||||||
|
export interface Theme {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
colors: {
|
||||||
|
// Base colors
|
||||||
|
background: string
|
||||||
|
foreground: string
|
||||||
|
muted: string
|
||||||
|
mutedForeground: string
|
||||||
|
popover: string
|
||||||
|
popoverForeground: string
|
||||||
|
card: string
|
||||||
|
cardForeground: string
|
||||||
|
border: string
|
||||||
|
input: string
|
||||||
|
|
||||||
|
// Primary colors
|
||||||
|
primary: string
|
||||||
|
primaryForeground: string
|
||||||
|
secondary: string
|
||||||
|
secondaryForeground: string
|
||||||
|
accent: string
|
||||||
|
accentForeground: string
|
||||||
|
destructive: string
|
||||||
|
destructiveForeground: string
|
||||||
|
|
||||||
|
// Glass effect colors
|
||||||
|
glassBackground: string
|
||||||
|
glassBorder: string
|
||||||
|
glassShadow: string
|
||||||
|
}
|
||||||
|
css: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const themes: Theme[] = [
|
||||||
|
// 1. Apple Frosted Glass - Hell
|
||||||
|
{
|
||||||
|
id: 'apple-frosted-light',
|
||||||
|
name: 'Apple Frosted Glass',
|
||||||
|
description: 'Klassisches Apple Design mit Glas-Effekten (Hell)',
|
||||||
|
colors: {
|
||||||
|
background: '#f2f2f7',
|
||||||
|
foreground: '#1c1c1e',
|
||||||
|
muted: '#f2f2f7',
|
||||||
|
mutedForeground: '#8e8e93',
|
||||||
|
popover: '#ffffff',
|
||||||
|
popoverForeground: '#1c1c1e',
|
||||||
|
card: 'rgba(255, 255, 255, 0.72)',
|
||||||
|
cardForeground: '#1c1c1e',
|
||||||
|
border: 'rgba(0, 0, 0, 0.1)',
|
||||||
|
input: 'rgba(255, 255, 255, 0.8)',
|
||||||
|
primary: '#007aff',
|
||||||
|
primaryForeground: '#ffffff',
|
||||||
|
secondary: 'rgba(120, 120, 128, 0.12)',
|
||||||
|
secondaryForeground: '#1c1c1e',
|
||||||
|
accent: '#5856d6',
|
||||||
|
accentForeground: '#ffffff',
|
||||||
|
destructive: '#ff3b30',
|
||||||
|
destructiveForeground: '#ffffff',
|
||||||
|
glassBackground: 'rgba(255, 255, 255, 0.8)',
|
||||||
|
glassBorder: 'rgba(255, 255, 255, 0.18)',
|
||||||
|
glassShadow: '0 8px 32px rgba(0, 0, 0, 0.12)'
|
||||||
|
},
|
||||||
|
css: `
|
||||||
|
:root {
|
||||||
|
--background: 242 242 247;
|
||||||
|
--foreground: 28 28 30;
|
||||||
|
--muted: 242 242 247;
|
||||||
|
--muted-foreground: 142 142 147;
|
||||||
|
--popover: 255 255 255;
|
||||||
|
--popover-foreground: 28 28 30;
|
||||||
|
--card: 255 255 255;
|
||||||
|
--card-foreground: 28 28 30;
|
||||||
|
--border: 0 0 0 / 0.1;
|
||||||
|
--input: 255 255 255;
|
||||||
|
--primary: 0 122 255;
|
||||||
|
--primary-foreground: 255 255 255;
|
||||||
|
--secondary: 120 120 128 / 0.12;
|
||||||
|
--secondary-foreground: 28 28 30;
|
||||||
|
--accent: 88 86 214;
|
||||||
|
--accent-foreground: 255 255 255;
|
||||||
|
--destructive: 255 59 48;
|
||||||
|
--destructive-foreground: 255 255 255;
|
||||||
|
--glass-bg: rgba(255, 255, 255, 0.8);
|
||||||
|
--glass-border: rgba(255, 255, 255, 0.18);
|
||||||
|
--glass-shadow: 0 8px 32px rgba(0, 0, 0, 0.12);
|
||||||
|
}
|
||||||
|
`
|
||||||
|
},
|
||||||
|
|
||||||
|
// 2. Apple Frosted Glass - Dunkel
|
||||||
|
{
|
||||||
|
id: 'apple-frosted-dark',
|
||||||
|
name: 'Apple Frosted Glass Dark',
|
||||||
|
description: 'Klassisches Apple Design mit Glas-Effekten (Dunkel)',
|
||||||
|
colors: {
|
||||||
|
background: '#000000',
|
||||||
|
foreground: '#ffffff',
|
||||||
|
muted: '#1c1c1e',
|
||||||
|
mutedForeground: '#98989f',
|
||||||
|
popover: '#2c2c2e',
|
||||||
|
popoverForeground: '#ffffff',
|
||||||
|
card: 'rgba(44, 44, 46, 0.72)',
|
||||||
|
cardForeground: '#ffffff',
|
||||||
|
border: 'rgba(255, 255, 255, 0.1)',
|
||||||
|
input: 'rgba(44, 44, 46, 0.8)',
|
||||||
|
primary: '#0a84ff',
|
||||||
|
primaryForeground: '#ffffff',
|
||||||
|
secondary: 'rgba(120, 120, 128, 0.16)',
|
||||||
|
secondaryForeground: '#ffffff',
|
||||||
|
accent: '#5e5ce6',
|
||||||
|
accentForeground: '#ffffff',
|
||||||
|
destructive: '#ff453a',
|
||||||
|
destructiveForeground: '#ffffff',
|
||||||
|
glassBackground: 'rgba(44, 44, 46, 0.8)',
|
||||||
|
glassBorder: 'rgba(255, 255, 255, 0.12)',
|
||||||
|
glassShadow: '0 8px 32px rgba(0, 0, 0, 0.3)'
|
||||||
|
},
|
||||||
|
css: `
|
||||||
|
:root {
|
||||||
|
--background: 0 0 0;
|
||||||
|
--foreground: 255 255 255;
|
||||||
|
--muted: 28 28 30;
|
||||||
|
--muted-foreground: 152 152 159;
|
||||||
|
--popover: 44 44 46;
|
||||||
|
--popover-foreground: 255 255 255;
|
||||||
|
--card: 44 44 46;
|
||||||
|
--card-foreground: 255 255 255;
|
||||||
|
--border: 255 255 255 / 0.1;
|
||||||
|
--input: 44 44 46;
|
||||||
|
--primary: 10 132 255;
|
||||||
|
--primary-foreground: 255 255 255;
|
||||||
|
--secondary: 120 120 128 / 0.16;
|
||||||
|
--secondary-foreground: 255 255 255;
|
||||||
|
--accent: 94 92 230;
|
||||||
|
--accent-foreground: 255 255 255;
|
||||||
|
--destructive: 255 69 58;
|
||||||
|
--destructive-foreground: 255 255 255;
|
||||||
|
--glass-bg: rgba(44, 44, 46, 0.8);
|
||||||
|
--glass-border: rgba(255, 255, 255, 0.12);
|
||||||
|
--glass-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
`
|
||||||
|
},
|
||||||
|
|
||||||
|
// 3. Cinema Noir - Dunkel
|
||||||
|
{
|
||||||
|
id: 'cinema-noir',
|
||||||
|
name: 'Cinema Noir',
|
||||||
|
description: 'Elegantes dunkles Kino-Theme mit tiefen Farben',
|
||||||
|
colors: {
|
||||||
|
background: '#0a0a0a',
|
||||||
|
foreground: '#e8e8e8',
|
||||||
|
muted: '#1a1a1a',
|
||||||
|
mutedForeground: '#888888',
|
||||||
|
popover: '#2a2a2a',
|
||||||
|
popoverForeground: '#e8e8e8',
|
||||||
|
card: 'rgba(42, 42, 42, 0.8)',
|
||||||
|
cardForeground: '#e8e8e8',
|
||||||
|
border: 'rgba(255, 215, 0, 0.2)',
|
||||||
|
input: 'rgba(42, 42, 42, 0.9)',
|
||||||
|
primary: '#ffd700',
|
||||||
|
primaryForeground: '#0a0a0a',
|
||||||
|
secondary: 'rgba(255, 215, 0, 0.1)',
|
||||||
|
secondaryForeground: '#ffd700',
|
||||||
|
accent: '#ff6b6b',
|
||||||
|
accentForeground: '#0a0a0a',
|
||||||
|
destructive: '#ff4444',
|
||||||
|
destructiveForeground: '#ffffff',
|
||||||
|
glassBackground: 'rgba(20, 20, 20, 0.85)',
|
||||||
|
glassBorder: 'rgba(255, 215, 0, 0.15)',
|
||||||
|
glassShadow: '0 8px 32px rgba(255, 215, 0, 0.1)'
|
||||||
|
},
|
||||||
|
css: `
|
||||||
|
:root {
|
||||||
|
--background: 10 10 10;
|
||||||
|
--foreground: 232 232 232;
|
||||||
|
--muted: 26 26 26;
|
||||||
|
--muted-foreground: 136 136 136;
|
||||||
|
--popover: 42 42 42;
|
||||||
|
--popover-foreground: 232 232 232;
|
||||||
|
--card: 42 42 42;
|
||||||
|
--card-foreground: 232 232 232;
|
||||||
|
--border: 255 215 0 / 0.2;
|
||||||
|
--input: 42 42 42;
|
||||||
|
--primary: 255 215 0;
|
||||||
|
--primary-foreground: 10 10 10;
|
||||||
|
--secondary: 255 215 0 / 0.1;
|
||||||
|
--secondary-foreground: 255 215 0;
|
||||||
|
--accent: 255 107 107;
|
||||||
|
--accent-foreground: 10 10 10;
|
||||||
|
--destructive: 255 68 68;
|
||||||
|
--destructive-foreground: 255 255 255;
|
||||||
|
--glass-bg: rgba(20, 20, 20, 0.85);
|
||||||
|
--glass-border: rgba(255, 215, 0, 0.15);
|
||||||
|
--glass-shadow: 0 8px 32px rgba(255, 215, 0, 0.1);
|
||||||
|
}
|
||||||
|
`
|
||||||
|
},
|
||||||
|
|
||||||
|
// 4. Ocean Blue - Hell
|
||||||
|
{
|
||||||
|
id: 'ocean-blue',
|
||||||
|
name: 'Ocean Blue',
|
||||||
|
description: 'Marines Hellblau-Theme mit sanften Übergängen',
|
||||||
|
colors: {
|
||||||
|
background: '#f0f9ff',
|
||||||
|
foreground: '#0c4a6e',
|
||||||
|
muted: '#e0f2fe',
|
||||||
|
mutedForeground: '#64748b',
|
||||||
|
popover: '#ffffff',
|
||||||
|
popoverForeground: '#0c4a6e',
|
||||||
|
card: '#ffffff',
|
||||||
|
cardForeground: '#0c4a6e',
|
||||||
|
border: 'rgba(59, 130, 246, 0.2)',
|
||||||
|
input: '#f0f9ff',
|
||||||
|
primary: '#0ea5e9',
|
||||||
|
primaryForeground: '#ffffff',
|
||||||
|
secondary: 'rgba(14, 165, 233, 0.1)',
|
||||||
|
secondaryForeground: '#0c4a6e',
|
||||||
|
accent: '#06b6d4',
|
||||||
|
accentForeground: '#ffffff',
|
||||||
|
destructive: '#f43f5e',
|
||||||
|
destructiveForeground: '#ffffff',
|
||||||
|
glassBackground: 'rgba(255, 255, 255, 0.85)',
|
||||||
|
glassBorder: 'rgba(59, 130, 246, 0.15)',
|
||||||
|
glassShadow: '0 8px 32px rgba(14, 165, 233, 0.15)'
|
||||||
|
},
|
||||||
|
css: `
|
||||||
|
:root {
|
||||||
|
--background: 240 249 255;
|
||||||
|
--foreground: 12 74 110;
|
||||||
|
--muted: 224 242 254;
|
||||||
|
--muted-foreground: 100 116 139;
|
||||||
|
--popover: 255 255 255;
|
||||||
|
--popover-foreground: 12 74 110;
|
||||||
|
--card: 255 255 255;
|
||||||
|
--card-foreground: 12 74 110;
|
||||||
|
--border: 59 130 246 / 0.2;
|
||||||
|
--input: 240 249 255;
|
||||||
|
--primary: 14 165 233;
|
||||||
|
--primary-foreground: 255 255 255;
|
||||||
|
--secondary: 14 165 233 / 0.1;
|
||||||
|
--secondary-foreground: 12 74 110;
|
||||||
|
--accent: 6 182 212;
|
||||||
|
--accent-foreground: 255 255 255;
|
||||||
|
--destructive: 244 63 94;
|
||||||
|
--destructive-foreground: 255 255 255;
|
||||||
|
--glass-bg: rgba(255, 255, 255, 0.85);
|
||||||
|
--glass-border: rgba(59, 130, 246, 0.15);
|
||||||
|
--glass-shadow: 0 8px 32px rgba(14, 165, 233, 0.15);
|
||||||
|
}
|
||||||
|
`
|
||||||
|
},
|
||||||
|
|
||||||
|
// 5. Sunset Purple - Dunkel
|
||||||
|
{
|
||||||
|
id: 'sunset-purple',
|
||||||
|
name: 'Sunset Purple',
|
||||||
|
description: 'Warmes lila Theme mit Sonnenuntergang-Farben',
|
||||||
|
colors: {
|
||||||
|
background: '#1a001a',
|
||||||
|
foreground: '#f3e8ff',
|
||||||
|
muted: '#2d1b2d',
|
||||||
|
mutedForeground: '#a78bfa',
|
||||||
|
popover: '#3d2d3d',
|
||||||
|
popoverForeground: '#f3e8ff',
|
||||||
|
card: 'rgba(61, 45, 61, 0.8)',
|
||||||
|
cardForeground: '#f3e8ff',
|
||||||
|
border: 'rgba(167, 139, 250, 0.3)',
|
||||||
|
input: 'rgba(61, 45, 61, 0.9)',
|
||||||
|
primary: '#a78bfa',
|
||||||
|
primaryForeground: '#1a001a',
|
||||||
|
secondary: 'rgba(167, 139, 250, 0.15)',
|
||||||
|
secondaryForeground: '#f3e8ff',
|
||||||
|
accent: '#f472b6',
|
||||||
|
accentForeground: '#1a001a',
|
||||||
|
destructive: '#f87171',
|
||||||
|
destructiveForeground: '#ffffff',
|
||||||
|
glassBackground: 'rgba(45, 27, 45, 0.85)',
|
||||||
|
glassBorder: 'rgba(167, 139, 250, 0.2)',
|
||||||
|
glassShadow: '0 8px 32px rgba(167, 139, 250, 0.2)'
|
||||||
|
},
|
||||||
|
css: `
|
||||||
|
:root {
|
||||||
|
--background: 26 0 26;
|
||||||
|
--foreground: 243 232 255;
|
||||||
|
--muted: 45 27 45;
|
||||||
|
--muted-foreground: 167 139 250;
|
||||||
|
--popover: 61 45 61;
|
||||||
|
--popover-foreground: 243 232 255;
|
||||||
|
--card: 61 45 61;
|
||||||
|
--card-foreground: 243 232 255;
|
||||||
|
--border: 167 139 250 / 0.3;
|
||||||
|
--input: 61 45 61;
|
||||||
|
--primary: 167 139 250;
|
||||||
|
--primary-foreground: 26 0 26;
|
||||||
|
--secondary: 167 139 250 / 0.15;
|
||||||
|
--secondary-foreground: 243 232 255;
|
||||||
|
--accent: 244 114 182;
|
||||||
|
--accent-foreground: 26 0 26;
|
||||||
|
--destructive: 248 113 113;
|
||||||
|
--destructive-foreground: 255 255 255;
|
||||||
|
--glass-bg: rgba(45, 27, 45, 0.85);
|
||||||
|
--glass-border: rgba(167, 139, 250, 0.2);
|
||||||
|
--glass-shadow: 0 8px 32px rgba(167, 139, 250, 0.2);
|
||||||
|
}
|
||||||
|
`
|
||||||
|
},
|
||||||
|
|
||||||
|
// 6. Forest Green - Hell
|
||||||
|
{
|
||||||
|
id: 'forest-green',
|
||||||
|
name: 'Forest Green',
|
||||||
|
description: 'Natürliches grün Theme mit Waldfarben',
|
||||||
|
colors: {
|
||||||
|
background: '#f0fdf4',
|
||||||
|
foreground: '#14532d',
|
||||||
|
muted: '#dcfce7',
|
||||||
|
mutedForeground: '#64748b',
|
||||||
|
popover: '#ffffff',
|
||||||
|
popoverForeground: '#14532d',
|
||||||
|
card: '#ffffff',
|
||||||
|
cardForeground: '#14532d',
|
||||||
|
border: 'rgba(34, 197, 94, 0.3)',
|
||||||
|
input: '#f0fdf4',
|
||||||
|
primary: '#22c55e',
|
||||||
|
primaryForeground: '#ffffff',
|
||||||
|
secondary: 'rgba(34, 197, 94, 0.1)',
|
||||||
|
secondaryForeground: '#14532d',
|
||||||
|
accent: '#84cc16',
|
||||||
|
accentForeground: '#ffffff',
|
||||||
|
destructive: '#ef4444',
|
||||||
|
destructiveForeground: '#ffffff',
|
||||||
|
glassBackground: 'rgba(255, 255, 255, 0.85)',
|
||||||
|
glassBorder: 'rgba(34, 197, 94, 0.2)',
|
||||||
|
glassShadow: '0 8px 32px rgba(34, 197, 94, 0.1)'
|
||||||
|
},
|
||||||
|
css: `
|
||||||
|
:root {
|
||||||
|
--background: 240 253 244;
|
||||||
|
--foreground: 20 83 45;
|
||||||
|
--muted: 220 252 231;
|
||||||
|
--muted-foreground: 100 116 139;
|
||||||
|
--popover: 255 255 255;
|
||||||
|
--popover-foreground: 20 83 45;
|
||||||
|
--card: 255 255 255;
|
||||||
|
--card-foreground: 20 83 45;
|
||||||
|
--border: 34 197 94 / 0.3;
|
||||||
|
--input: 240 253 244;
|
||||||
|
--primary: 34 197 94;
|
||||||
|
--primary-foreground: 255 255 255;
|
||||||
|
--secondary: 34 197 94 / 0.1;
|
||||||
|
--secondary-foreground: 20 83 45;
|
||||||
|
--accent: 132 204 22;
|
||||||
|
--accent-foreground: 255 255 255;
|
||||||
|
--destructive: 239 68 68;
|
||||||
|
--destructive-foreground: 255 255 255;
|
||||||
|
--glass-bg: rgba(255, 255, 255, 0.85);
|
||||||
|
--glass-border: rgba(34 197, 94, 0.2);
|
||||||
|
--glass-shadow: 0 8px 32px rgba(34 197, 94, 0.1);
|
||||||
|
}
|
||||||
|
`
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
export function getTheme(id: string): Theme | undefined {
|
||||||
|
return themes.find(theme => theme.id === id)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyTheme(theme: Theme) {
|
||||||
|
// Remove existing theme styles
|
||||||
|
const existingStyle = document.getElementById('theme-styles')
|
||||||
|
if (existingStyle) {
|
||||||
|
existingStyle.remove()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add new theme styles with proper CSS custom properties
|
||||||
|
const styleElement = document.createElement('style')
|
||||||
|
styleElement.id = 'theme-styles'
|
||||||
|
styleElement.textContent = theme.css + `
|
||||||
|
/* Additional theme fixes */
|
||||||
|
.glass-card {
|
||||||
|
background: var(--glass-bg) !important;
|
||||||
|
border: 1px solid var(--glass-border) !important;
|
||||||
|
box-shadow: var(--glass-shadow) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass-header {
|
||||||
|
background: var(--glass-bg) !important;
|
||||||
|
border: 1px solid var(--glass-border) !important;
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
-webkit-backdrop-filter: blur(20px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass-button {
|
||||||
|
background: var(--glass-bg) !important;
|
||||||
|
border: 1px solid var(--glass-border) !important;
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
-webkit-backdrop-filter: blur(20px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass-avatar {
|
||||||
|
background: var(--glass-bg) !important;
|
||||||
|
border: 1px solid var(--glass-border) !important;
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
-webkit-backdrop-filter: blur(20px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass-tag {
|
||||||
|
background: var(--glass-bg) !important;
|
||||||
|
border: 1px solid var(--glass-border) !important;
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
-webkit-backdrop-filter: blur(20px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass-input {
|
||||||
|
background: var(--glass-bg) !important;
|
||||||
|
border: 1px solid var(--glass-border) !important;
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
-webkit-backdrop-filter: blur(20px);
|
||||||
|
}
|
||||||
|
`
|
||||||
|
document.head.appendChild(styleElement)
|
||||||
|
|
||||||
|
// Update data attribute on body
|
||||||
|
document.body.setAttribute('data-theme', theme.id)
|
||||||
|
}
|
||||||
64
lib/tmdb.ts
64
lib/tmdb.ts
@ -12,8 +12,13 @@ export function backdropUrl(path: string | null, size: 'w780' | 'w1280' | 'origi
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function tmdbFetch(endpoint: string, params: Record<string, string> = {}) {
|
async function tmdbFetch(endpoint: string, params: Record<string, string> = {}) {
|
||||||
|
const apiKey = process.env.TMDB_API_KEY
|
||||||
|
if (!apiKey) {
|
||||||
|
throw new Error('TMDB API key is not configured')
|
||||||
|
}
|
||||||
|
|
||||||
const url = new URL(`${TMDB_BASE}${endpoint}`)
|
const url = new URL(`${TMDB_BASE}${endpoint}`)
|
||||||
url.searchParams.set('api_key', process.env.TMDB_API_KEY!)
|
url.searchParams.set('api_key', apiKey)
|
||||||
url.searchParams.set('language', 'de-DE')
|
url.searchParams.set('language', 'de-DE')
|
||||||
for (const [key, value] of Object.entries(params)) {
|
for (const [key, value] of Object.entries(params)) {
|
||||||
url.searchParams.set(key, value)
|
url.searchParams.set(key, value)
|
||||||
@ -27,18 +32,38 @@ export async function searchMovies(query: string, page = 1) {
|
|||||||
return tmdbFetch('/search/movie', { query, page: String(page) })
|
return tmdbFetch('/search/movie', { query, page: String(page) })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function searchTVShows(query: string, page = 1) {
|
||||||
|
return tmdbFetch('/search/tv', { query, page: String(page) })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function searchAll(query: string, page = 1) {
|
||||||
|
return tmdbFetch('/search/multi', { query, page: String(page) })
|
||||||
|
}
|
||||||
|
|
||||||
export async function getMovie(id: number) {
|
export async function getMovie(id: number) {
|
||||||
return tmdbFetch(`/movie/${id}`)
|
return tmdbFetch(`/movie/${id}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getTVShow(id: number) {
|
||||||
|
return tmdbFetch(`/tv/${id}`)
|
||||||
|
}
|
||||||
|
|
||||||
export async function getTrending() {
|
export async function getTrending() {
|
||||||
return tmdbFetch('/trending/movie/week')
|
return tmdbFetch('/trending/movie/week')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getTrendingAll() {
|
||||||
|
return tmdbFetch('/trending/all/week')
|
||||||
|
}
|
||||||
|
|
||||||
export async function getPopular(page = 1) {
|
export async function getPopular(page = 1) {
|
||||||
return tmdbFetch('/movie/popular', { page: String(page) })
|
return tmdbFetch('/movie/popular', { page: String(page) })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getPopularTVShows(page = 1) {
|
||||||
|
return tmdbFetch('/tv/popular', { page: String(page) })
|
||||||
|
}
|
||||||
|
|
||||||
export interface TMDBMovie {
|
export interface TMDBMovie {
|
||||||
id: number
|
id: number
|
||||||
title: string
|
title: string
|
||||||
@ -50,9 +75,34 @@ export interface TMDBMovie {
|
|||||||
genre_ids?: number[]
|
genre_ids?: number[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TMDBTVShow {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
poster_path: string | null
|
||||||
|
backdrop_path: string | null
|
||||||
|
overview: string
|
||||||
|
first_air_date: string
|
||||||
|
vote_average: number
|
||||||
|
genre_ids?: number[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TMDBMultiResult {
|
||||||
|
id: number
|
||||||
|
title?: string
|
||||||
|
name?: string
|
||||||
|
poster_path: string | null
|
||||||
|
backdrop_path: string | null
|
||||||
|
overview: string
|
||||||
|
release_date?: string
|
||||||
|
first_air_date?: string
|
||||||
|
vote_average: number
|
||||||
|
media_type: 'movie' | 'tv' | 'person'
|
||||||
|
genre_ids?: number[]
|
||||||
|
}
|
||||||
|
|
||||||
export interface TMDBSearchResult {
|
export interface TMDBSearchResult {
|
||||||
page: number
|
page: number
|
||||||
results: TMDBMovie[]
|
results: TMDBMovie[] | TMDBTVShow[] | TMDBMultiResult[]
|
||||||
total_pages: number
|
total_pages: number
|
||||||
total_results: number
|
total_results: number
|
||||||
}
|
}
|
||||||
@ -61,4 +111,14 @@ export interface TMDBMovieDetail extends TMDBMovie {
|
|||||||
runtime: number
|
runtime: number
|
||||||
genres: { id: number; name: string }[]
|
genres: { id: number; name: string }[]
|
||||||
tagline: string
|
tagline: string
|
||||||
|
imdb_id?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TMDBTVShowDetail extends TMDBTVShow {
|
||||||
|
episode_run_time: number[]
|
||||||
|
genres: { id: number; name: string }[]
|
||||||
|
tagline: string
|
||||||
|
number_of_seasons: number
|
||||||
|
number_of_episodes: number
|
||||||
|
imdb_id?: string
|
||||||
}
|
}
|
||||||
|
|||||||
112
locales/de.json
Normal file
112
locales/de.json
Normal file
@ -0,0 +1,112 @@
|
|||||||
|
{
|
||||||
|
"common": {
|
||||||
|
"loading": "Laden...",
|
||||||
|
"error": "Fehler",
|
||||||
|
"save": "Speichern",
|
||||||
|
"cancel": "Abbrechen",
|
||||||
|
"delete": "Löschen",
|
||||||
|
"edit": "Bearbeiten",
|
||||||
|
"close": "Schließen",
|
||||||
|
"back": "Zurück",
|
||||||
|
"next": "Weiter",
|
||||||
|
"previous": "Zurück",
|
||||||
|
"search": "Suchen",
|
||||||
|
"add": "Hinzufügen",
|
||||||
|
"remove": "Entfernen",
|
||||||
|
"yes": "Ja",
|
||||||
|
"no": "Nein"
|
||||||
|
},
|
||||||
|
"auth": {
|
||||||
|
"login": "Anmelden",
|
||||||
|
"register": "Registrieren",
|
||||||
|
"logout": "Abmelden",
|
||||||
|
"email": "E-Mail",
|
||||||
|
"password": "Passwort",
|
||||||
|
"passwordPlaceholder": "Passwort eingeben",
|
||||||
|
"confirmPassword": "Passwort bestätigen",
|
||||||
|
"displayName": "Anzeigename",
|
||||||
|
"forgotPassword": "Passwort vergessen?",
|
||||||
|
"rememberMe": "Angemeldet bleiben",
|
||||||
|
"loginSuccess": "Anmeldung erfolgreich",
|
||||||
|
"registerSuccess": "Registrierung erfolgreich",
|
||||||
|
"loginError": "Anmeldung fehlgeschlagen",
|
||||||
|
"registerError": "Registrierung fehlgeschlagen",
|
||||||
|
"invalidCredentials": "E-Mail oder Passwort falsch",
|
||||||
|
"emailRequired": "E-Mail ist erforderlich",
|
||||||
|
"passwordRequired": "Passwort ist erforderlich",
|
||||||
|
"displayNameRequired": "Anzeigename ist erforderlich",
|
||||||
|
"passwordsNotMatch": "Passwörter stimmen nicht überein",
|
||||||
|
"alreadyHaveAccount": "Haben Sie bereits ein Konto?",
|
||||||
|
"dontHaveAccount": "Haben Sie noch kein Konto?",
|
||||||
|
"signIn": "Anmelden",
|
||||||
|
"signUp": "Registrieren",
|
||||||
|
"familyMovieDiary": "Familienfilm-Tagebuch",
|
||||||
|
"createAccount": "Konto erstellen",
|
||||||
|
"joinFamilyMovieClub": "Tritt deinem Familien-Filmclub bei"
|
||||||
|
},
|
||||||
|
"navigation": {
|
||||||
|
"feed": "Feed",
|
||||||
|
"search": "Suche",
|
||||||
|
"diary": "Tagebuch",
|
||||||
|
"lists": "Listen",
|
||||||
|
"profile": "Profil"
|
||||||
|
},
|
||||||
|
"movies": {
|
||||||
|
"movie": "Film",
|
||||||
|
"movies": "Filme",
|
||||||
|
"title": "Titel",
|
||||||
|
"year": "Jahr",
|
||||||
|
"rating": "Bewertung",
|
||||||
|
"review": "Rezension",
|
||||||
|
"watched": "Gesehen",
|
||||||
|
"watchlist": "Merkliste",
|
||||||
|
"addToWatchlist": "Zur Merkliste hinzufügen",
|
||||||
|
"removeFromWatchlist": "Von Merkliste entfernen",
|
||||||
|
"logMovie": "Film loggen",
|
||||||
|
"noMoviesFound": "Keine Filme gefunden",
|
||||||
|
"noMoviesWatched": "Noch keine Filme gesehen",
|
||||||
|
"noMoviesInWatchlist": "Keine Filme auf der Merkliste",
|
||||||
|
"genres": "Genres",
|
||||||
|
"runtime": "Laufzeit",
|
||||||
|
"director": "Regisseur",
|
||||||
|
"cast": "Besetzung",
|
||||||
|
"overview": "Übersicht",
|
||||||
|
"releaseDate": "Veröffentlichungsdatum"
|
||||||
|
},
|
||||||
|
"lists": {
|
||||||
|
"lists": "Listen",
|
||||||
|
"list": "Liste",
|
||||||
|
"createList": "Liste erstellen",
|
||||||
|
"listName": "Listenname",
|
||||||
|
"listDescription": "Listenbeschreibung",
|
||||||
|
"noListsFound": "Keine Listen gefunden",
|
||||||
|
"emptyList": "Diese Liste ist leer",
|
||||||
|
"addMovieToList": "Film zur Liste hinzufügen",
|
||||||
|
"removeMovieFromList": "Film von Liste entfernen"
|
||||||
|
},
|
||||||
|
"profile": {
|
||||||
|
"profile": "Profil",
|
||||||
|
"settings": "Einstellungen",
|
||||||
|
"statistics": "Statistiken",
|
||||||
|
"moviesWatched": "Gesehene Filme",
|
||||||
|
"inWatchlist": "Auf Merkliste",
|
||||||
|
"listsCreated": "Erstellte Listen",
|
||||||
|
"recentActivity": "Letzte Aktivität",
|
||||||
|
"editProfile": "Profil bearbeiten"
|
||||||
|
},
|
||||||
|
"diary": {
|
||||||
|
"diary": "Tagebuch",
|
||||||
|
"entries": "Einträge",
|
||||||
|
"noEntries": "Noch keine Tagebucheinträge",
|
||||||
|
"watchedOn": "Gesehen am",
|
||||||
|
"myRating": "Meine Bewertung",
|
||||||
|
"myReview": "Meine Rezension",
|
||||||
|
"logEntry": "Eintrag loggen"
|
||||||
|
},
|
||||||
|
"search": {
|
||||||
|
"searchMovies": "Filme suchen",
|
||||||
|
"searchPlaceholder": "Nach Filmen suchen...",
|
||||||
|
"noResults": "Keine Ergebnisse gefunden",
|
||||||
|
"tryDifferentQuery": "Versuchen Sie eine andere Suchanfrage"
|
||||||
|
}
|
||||||
|
}
|
||||||
112
locales/en.json
Normal file
112
locales/en.json
Normal file
@ -0,0 +1,112 @@
|
|||||||
|
{
|
||||||
|
"common": {
|
||||||
|
"loading": "Loading...",
|
||||||
|
"error": "Error",
|
||||||
|
"save": "Save",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"delete": "Delete",
|
||||||
|
"edit": "Edit",
|
||||||
|
"close": "Close",
|
||||||
|
"back": "Back",
|
||||||
|
"next": "Next",
|
||||||
|
"previous": "Previous",
|
||||||
|
"search": "Search",
|
||||||
|
"add": "Add",
|
||||||
|
"remove": "Remove",
|
||||||
|
"yes": "Yes",
|
||||||
|
"no": "No"
|
||||||
|
},
|
||||||
|
"auth": {
|
||||||
|
"login": "Login",
|
||||||
|
"register": "Register",
|
||||||
|
"logout": "Logout",
|
||||||
|
"email": "Email",
|
||||||
|
"password": "Password",
|
||||||
|
"passwordPlaceholder": "Enter password",
|
||||||
|
"confirmPassword": "Confirm Password",
|
||||||
|
"displayName": "Display Name",
|
||||||
|
"forgotPassword": "Forgot Password?",
|
||||||
|
"rememberMe": "Remember Me",
|
||||||
|
"loginSuccess": "Login successful",
|
||||||
|
"registerSuccess": "Registration successful",
|
||||||
|
"loginError": "Login failed",
|
||||||
|
"registerError": "Registration failed",
|
||||||
|
"invalidCredentials": "Invalid email or password",
|
||||||
|
"emailRequired": "Email is required",
|
||||||
|
"passwordRequired": "Password is required",
|
||||||
|
"displayNameRequired": "Display name is required",
|
||||||
|
"passwordsNotMatch": "Passwords do not match",
|
||||||
|
"alreadyHaveAccount": "Already have an account?",
|
||||||
|
"dontHaveAccount": "Don't have an account?",
|
||||||
|
"signIn": "Sign In",
|
||||||
|
"signUp": "Sign Up",
|
||||||
|
"familyMovieDiary": "Family Movie Diary",
|
||||||
|
"createAccount": "Create Account",
|
||||||
|
"joinFamilyMovieClub": "Join your family movie club"
|
||||||
|
},
|
||||||
|
"navigation": {
|
||||||
|
"feed": "Feed",
|
||||||
|
"search": "Search",
|
||||||
|
"diary": "Diary",
|
||||||
|
"lists": "Lists",
|
||||||
|
"profile": "Profile"
|
||||||
|
},
|
||||||
|
"movies": {
|
||||||
|
"movie": "Movie",
|
||||||
|
"movies": "Movies",
|
||||||
|
"title": "Title",
|
||||||
|
"year": "Year",
|
||||||
|
"rating": "Rating",
|
||||||
|
"review": "Review",
|
||||||
|
"watched": "Watched",
|
||||||
|
"watchlist": "Watchlist",
|
||||||
|
"addToWatchlist": "Add to Watchlist",
|
||||||
|
"removeFromWatchlist": "Remove from Watchlist",
|
||||||
|
"logMovie": "Log Movie",
|
||||||
|
"noMoviesFound": "No movies found",
|
||||||
|
"noMoviesWatched": "No movies watched yet",
|
||||||
|
"noMoviesInWatchlist": "No movies in watchlist",
|
||||||
|
"genres": "Genres",
|
||||||
|
"runtime": "Runtime",
|
||||||
|
"director": "Director",
|
||||||
|
"cast": "Cast",
|
||||||
|
"overview": "Overview",
|
||||||
|
"releaseDate": "Release Date"
|
||||||
|
},
|
||||||
|
"lists": {
|
||||||
|
"lists": "Lists",
|
||||||
|
"list": "List",
|
||||||
|
"createList": "Create List",
|
||||||
|
"listName": "List Name",
|
||||||
|
"listDescription": "List Description",
|
||||||
|
"noListsFound": "No lists found",
|
||||||
|
"emptyList": "This list is empty",
|
||||||
|
"addMovieToList": "Add Movie to List",
|
||||||
|
"removeMovieFromList": "Remove Movie from List"
|
||||||
|
},
|
||||||
|
"profile": {
|
||||||
|
"profile": "Profile",
|
||||||
|
"settings": "Settings",
|
||||||
|
"statistics": "Statistics",
|
||||||
|
"moviesWatched": "Movies Watched",
|
||||||
|
"inWatchlist": "In Watchlist",
|
||||||
|
"listsCreated": "Lists Created",
|
||||||
|
"recentActivity": "Recent Activity",
|
||||||
|
"editProfile": "Edit Profile"
|
||||||
|
},
|
||||||
|
"diary": {
|
||||||
|
"diary": "Diary",
|
||||||
|
"entries": "Entries",
|
||||||
|
"noEntries": "No diary entries yet",
|
||||||
|
"watchedOn": "Watched on",
|
||||||
|
"myRating": "My Rating",
|
||||||
|
"myReview": "My Review",
|
||||||
|
"logEntry": "Log Entry"
|
||||||
|
},
|
||||||
|
"search": {
|
||||||
|
"searchMovies": "Search Movies",
|
||||||
|
"searchPlaceholder": "Search for movies...",
|
||||||
|
"noResults": "No results found",
|
||||||
|
"tryDifferentQuery": "Try a different search query"
|
||||||
|
}
|
||||||
|
}
|
||||||
12
mcp-config.json
Normal file
12
mcp-config.json
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"supabase": {
|
||||||
|
"command": "npx",
|
||||||
|
"args": [
|
||||||
|
"-y",
|
||||||
|
"mcp-remote",
|
||||||
|
"https://mcp.supabase.com/mcp?project_ref=ekbpexbhuochrplzorce"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
30
mcp-server.js
Normal file
30
mcp-server.js
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
const { spawn } = require('child_process');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
// MCP Config laden
|
||||||
|
const configPath = path.join(__dirname, '.windsurf', 'mcp-config.json');
|
||||||
|
const config = require(configPath);
|
||||||
|
|
||||||
|
// Supabase Server Konfiguration
|
||||||
|
const supabaseConfig = config.mcpServers.supabase;
|
||||||
|
|
||||||
|
// Environment Variables setzen
|
||||||
|
process.env.SUPABASE_CLIENT_ID = supabaseConfig.env.SUPABASE_CLIENT_ID;
|
||||||
|
process.env.SUPABASE_CLIENT_SECRET = supabaseConfig.env.SUPABASE_CLIENT_SECRET;
|
||||||
|
|
||||||
|
// MCP Server starten mit vollem npm Pfad
|
||||||
|
const mcpServer = spawn('cmd', ['/c', 'npx', '-y', 'mcp-remote', 'https://mcp.supabase.com/mcp?project_ref=ekbpexbhuochrplzorce'], {
|
||||||
|
stdio: 'inherit',
|
||||||
|
cwd: __dirname,
|
||||||
|
shell: true
|
||||||
|
});
|
||||||
|
|
||||||
|
mcpServer.on('error', (error) => {
|
||||||
|
console.error('Failed to start MCP server:', error);
|
||||||
|
});
|
||||||
|
|
||||||
|
mcpServer.on('close', (code) => {
|
||||||
|
console.log(`MCP server exited with code ${code}`);
|
||||||
|
});
|
||||||
5
next-env.d.ts
vendored
Normal file
5
next-env.d.ts
vendored
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
/// <reference types="next" />
|
||||||
|
/// <reference types="next/image-types/global" />
|
||||||
|
|
||||||
|
// NOTE: This file should not be edited
|
||||||
|
// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.
|
||||||
@ -1 +0,0 @@
|
|||||||
/vercel/share/v0-next-shadcn/node_modules
|
|
||||||
5449
package-lock.json
generated
Normal file
5449
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
17
package.json
17
package.json
@ -9,9 +9,6 @@
|
|||||||
"lint": "eslint ."
|
"lint": "eslint ."
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@supabase/ssr": "^0.5.2",
|
|
||||||
"@supabase/supabase-js": "^2.47.12",
|
|
||||||
"swr": "^2.2.5",
|
|
||||||
"@hookform/resolvers": "^3.9.1",
|
"@hookform/resolvers": "^3.9.1",
|
||||||
"@radix-ui/react-accordion": "1.2.2",
|
"@radix-ui/react-accordion": "1.2.2",
|
||||||
"@radix-ui/react-alert-dialog": "1.1.4",
|
"@radix-ui/react-alert-dialog": "1.1.4",
|
||||||
@ -40,6 +37,8 @@
|
|||||||
"@radix-ui/react-toggle": "1.1.1",
|
"@radix-ui/react-toggle": "1.1.1",
|
||||||
"@radix-ui/react-toggle-group": "1.1.1",
|
"@radix-ui/react-toggle-group": "1.1.1",
|
||||||
"@radix-ui/react-tooltip": "1.1.6",
|
"@radix-ui/react-tooltip": "1.1.6",
|
||||||
|
"@supabase/ssr": "^0.5.2",
|
||||||
|
"@supabase/supabase-js": "^2.47.12",
|
||||||
"autoprefixer": "^10.4.20",
|
"autoprefixer": "^10.4.20",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
@ -48,15 +47,17 @@
|
|||||||
"embla-carousel-react": "8.5.1",
|
"embla-carousel-react": "8.5.1",
|
||||||
"input-otp": "1.4.1",
|
"input-otp": "1.4.1",
|
||||||
"lucide-react": "^0.544.0",
|
"lucide-react": "^0.544.0",
|
||||||
"next": "16.1.6",
|
"next": "14.2.15",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"react": "^19",
|
"puppeteer": "^24.39.1",
|
||||||
|
"react": "^18",
|
||||||
"react-day-picker": "8.10.1",
|
"react-day-picker": "8.10.1",
|
||||||
"react-dom": "^19",
|
"react-dom": "^18",
|
||||||
"react-hook-form": "^7.54.1",
|
"react-hook-form": "^7.54.1",
|
||||||
"react-resizable-panels": "^2.1.7",
|
"react-resizable-panels": "^2.1.7",
|
||||||
"recharts": "2.15.0",
|
"recharts": "2.15.0",
|
||||||
"sonner": "^1.7.1",
|
"sonner": "^1.7.1",
|
||||||
|
"swr": "^2.2.5",
|
||||||
"tailwind-merge": "^2.5.5",
|
"tailwind-merge": "^2.5.5",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"vaul": "^1.1.2",
|
"vaul": "^1.1.2",
|
||||||
@ -65,8 +66,8 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4.1.13",
|
"@tailwindcss/postcss": "^4.1.13",
|
||||||
"@types/node": "^22",
|
"@types/node": "^22",
|
||||||
"@types/react": "^19",
|
"@types/react": "^18",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^18",
|
||||||
"postcss": "^8.5",
|
"postcss": "^8.5",
|
||||||
"tailwindcss": "^3.4.17",
|
"tailwindcss": "^3.4.17",
|
||||||
"typescript": "5.7.3"
|
"typescript": "5.7.3"
|
||||||
|
|||||||
4060
pnpm-lock.yaml
generated
4060
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
54
quick_start.bat
Normal file
54
quick_start.bat
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
@echo off
|
||||||
|
title InFocus Movie App - Quick Start
|
||||||
|
echo.
|
||||||
|
echo ========================================
|
||||||
|
echo InFocus Movie App - Quick Start
|
||||||
|
echo ========================================
|
||||||
|
echo.
|
||||||
|
echo [1] Start App (with npm install if needed)
|
||||||
|
echo [2] Start App (skip npm install)
|
||||||
|
echo [3] Install dependencies only
|
||||||
|
echo [4] Exit
|
||||||
|
echo.
|
||||||
|
echo ========================================
|
||||||
|
echo.
|
||||||
|
set /p choice="Choose an option (1-4): "
|
||||||
|
|
||||||
|
if "%choice%"=="1" goto install_and_start
|
||||||
|
if "%choice%"=="2" goto start_only
|
||||||
|
if "%choice%"=="3" goto install_only
|
||||||
|
if "%choice%"=="4" goto exit
|
||||||
|
|
||||||
|
:install_and_start
|
||||||
|
echo.
|
||||||
|
echo Installing dependencies (if needed)...
|
||||||
|
if not exist "node_modules" (
|
||||||
|
npm install --legacy-peer-deps
|
||||||
|
)
|
||||||
|
echo.
|
||||||
|
echo Starting server...
|
||||||
|
npm run dev
|
||||||
|
goto end
|
||||||
|
|
||||||
|
:start_only
|
||||||
|
echo.
|
||||||
|
echo Starting server...
|
||||||
|
npm run dev
|
||||||
|
goto end
|
||||||
|
|
||||||
|
:install_only
|
||||||
|
echo.
|
||||||
|
echo Installing dependencies...
|
||||||
|
npm install --legacy-peer-deps
|
||||||
|
echo.
|
||||||
|
echo Dependencies installed successfully!
|
||||||
|
pause
|
||||||
|
goto end
|
||||||
|
|
||||||
|
:exit
|
||||||
|
echo.
|
||||||
|
echo Goodbye!
|
||||||
|
exit
|
||||||
|
|
||||||
|
:end
|
||||||
|
pause
|
||||||
72
schema-extension.sql
Normal file
72
schema-extension.sql
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
-- Schema Erweiterung für Serien und externe Bewertungen
|
||||||
|
|
||||||
|
-- 1. Add theme field to profiles table
|
||||||
|
ALTER TABLE public.profiles
|
||||||
|
ADD COLUMN IF NOT EXISTS theme text DEFAULT 'apple-frosted-light' CHECK (theme IN ('apple-frosted-light', 'apple-frosted-dark', 'cinema-noir', 'ocean-blue', 'sunset-purple', 'forest-green'));
|
||||||
|
|
||||||
|
-- 2. Diary Entries für Serien und externe Ratings erweitern
|
||||||
|
ALTER TABLE public.diary_entries
|
||||||
|
ADD COLUMN IF NOT EXISTS media_type text NOT NULL DEFAULT 'movie' CHECK (media_type IN ('movie', 'tv_show'));
|
||||||
|
|
||||||
|
ALTER TABLE public.diary_entries
|
||||||
|
ADD COLUMN IF NOT EXISTS imdb_rating numeric(3,1) CHECK (imdb_rating >= 0.0 and imdb_rating <= 10.0);
|
||||||
|
|
||||||
|
ALTER TABLE public.diary_entries
|
||||||
|
ADD COLUMN IF NOT EXISTS rotten_tomatoes_rating numeric(3,1) CHECK (rotten_tomatoes_rating >= 0.0 and rotten_tomatoes_rating <= 100.0);
|
||||||
|
|
||||||
|
ALTER TABLE public.diary_entries
|
||||||
|
ADD COLUMN IF NOT EXISTS season_number integer;
|
||||||
|
ALTER TABLE public.diary_entries
|
||||||
|
ADD COLUMN IF NOT EXISTS episode_number integer;
|
||||||
|
|
||||||
|
-- 2. Watchlist für Serien erweitern
|
||||||
|
ALTER TABLE public.watchlist
|
||||||
|
ADD COLUMN IF NOT EXISTS media_type text NOT NULL DEFAULT 'movie' CHECK (media_type IN ('movie', 'tv_show'));
|
||||||
|
|
||||||
|
ALTER TABLE public.watchlist
|
||||||
|
ADD COLUMN IF NOT EXISTS season_number integer;
|
||||||
|
ALTER TABLE public.watchlist
|
||||||
|
ADD COLUMN IF NOT EXISTS episode_number integer;
|
||||||
|
|
||||||
|
-- 3. Lists für Serien erweitern
|
||||||
|
ALTER TABLE public.list_items
|
||||||
|
ADD COLUMN IF NOT EXISTS media_type text NOT NULL DEFAULT 'movie' CHECK (media_type IN ('movie', 'tv_show'));
|
||||||
|
|
||||||
|
ALTER TABLE public.list_items
|
||||||
|
ADD COLUMN IF NOT EXISTS season_number integer;
|
||||||
|
ALTER TABLE public.list_items
|
||||||
|
ADD COLUMN IF NOT EXISTS episode_number integer;
|
||||||
|
|
||||||
|
-- 4. Likes für Serien erweitern
|
||||||
|
ALTER TABLE public.likes
|
||||||
|
ADD COLUMN IF NOT EXISTS media_type text NOT NULL DEFAULT 'movie' CHECK (media_type IN ('movie', 'tv_show'));
|
||||||
|
|
||||||
|
-- 5. Externe Ratings Cache Tabelle
|
||||||
|
CREATE TABLE IF NOT EXISTS public.external_ratings (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
tmdb_id integer not null,
|
||||||
|
media_type text not null check (media_type IN ('movie', 'tv_show')),
|
||||||
|
imdb_id text,
|
||||||
|
imdb_rating numeric(3,1),
|
||||||
|
imdb_vote_count integer,
|
||||||
|
rotten_tomatoes_rating numeric(3,1),
|
||||||
|
rotten_tomatoes_fresh integer,
|
||||||
|
rotten_tomatoes_rotten integer,
|
||||||
|
metacritic_score integer,
|
||||||
|
last_updated timestamptz default now(),
|
||||||
|
unique(tmdb_id, media_type)
|
||||||
|
);
|
||||||
|
|
||||||
|
alter table public.external_ratings enable row level security;
|
||||||
|
drop policy if exists "external_ratings_select_all" on public.external_ratings;
|
||||||
|
create policy "external_ratings_select_all" on public.external_ratings for select using (true);
|
||||||
|
drop policy if exists "external_ratings_insert_own" on public.external_ratings;
|
||||||
|
create policy "external_ratings_insert_own" on public.external_ratings for insert with check (true);
|
||||||
|
drop policy if exists "external_ratings_update_own" on public.external_ratings;
|
||||||
|
create policy "external_ratings_update_own" on public.external_ratings for update using (true);
|
||||||
|
|
||||||
|
-- 6. Indexes für Performance
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_diary_entries_media_type ON public.diary_entries(media_type);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_watchlist_media_type ON public.watchlist(media_type);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_list_items_media_type ON public.list_items(media_type);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_external_ratings_tmdb ON public.external_ratings(tmdb_id, media_type);
|
||||||
BIN
screenshots/search-page.png
Normal file
BIN
screenshots/search-page.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 355 KiB |
5
scripts/capture-page.bat
Normal file
5
scripts/capture-page.bat
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
@echo off
|
||||||
|
echo Taking screenshot of search page...
|
||||||
|
node scripts/take-screenshot.js http://localhost:3001/search search-page.png
|
||||||
|
echo Screenshot saved to screenshots/search-page.png
|
||||||
|
pause
|
||||||
25
scripts/take-screenshot.js
Normal file
25
scripts/take-screenshot.js
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
const puppeteer = require('puppeteer');
|
||||||
|
const fs = require('fs');
|
||||||
|
|
||||||
|
async function takeScreenshot(url, outputPath) {
|
||||||
|
const browser = await puppeteer.launch({ headless: true });
|
||||||
|
const page = await browser.newPage();
|
||||||
|
|
||||||
|
await page.setViewport({ width: 1200, height: 800 });
|
||||||
|
await page.goto(url, { waitUntil: 'networkidle2' });
|
||||||
|
|
||||||
|
await page.screenshot({
|
||||||
|
path: outputPath,
|
||||||
|
fullPage: true,
|
||||||
|
type: 'png'
|
||||||
|
});
|
||||||
|
|
||||||
|
await browser.close();
|
||||||
|
console.log(`Screenshot saved: ${outputPath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Usage
|
||||||
|
const url = process.argv[2] || 'http://localhost:3000/search';
|
||||||
|
const filename = process.argv[3] || 'search-page.png';
|
||||||
|
|
||||||
|
takeScreenshot(url, `./screenshots/${filename}`).catch(console.error);
|
||||||
13
simple-test.js
Normal file
13
simple-test.js
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
// Super einfacher Test - kopiere in F12 Console
|
||||||
|
console.log('=== SIMPLE TEST ===');
|
||||||
|
|
||||||
|
// Ändere nur eine Glass Card direkt
|
||||||
|
const cards = document.querySelectorAll('.glass-card');
|
||||||
|
if (cards.length > 0) {
|
||||||
|
cards[0].style.background = 'rgba(255, 255, 255, 0.9) !important';
|
||||||
|
cards[0].style.border = '2px solid rgb(59, 130, 246) !important';
|
||||||
|
console.log('✅ Erste Karte geändert zu Ocean Blue');
|
||||||
|
console.log('Karten Hintergrund:', cards[0].style.background);
|
||||||
|
} else {
|
||||||
|
console.log('❌ Keine Glass Cards gefunden');
|
||||||
|
}
|
||||||
28
start_app.bat
Normal file
28
start_app.bat
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
@echo off
|
||||||
|
title InFocus Movie App - Starting...
|
||||||
|
echo.
|
||||||
|
echo ========================================
|
||||||
|
echo InFocus Movie App - Development
|
||||||
|
echo ========================================
|
||||||
|
echo.
|
||||||
|
echo Starting Next.js development server...
|
||||||
|
echo.
|
||||||
|
echo App will be available at: http://localhost:3000
|
||||||
|
echo.
|
||||||
|
echo Press Ctrl+C to stop the server
|
||||||
|
echo.
|
||||||
|
echo ========================================
|
||||||
|
echo.
|
||||||
|
|
||||||
|
REM Check if node_modules exists
|
||||||
|
if not exist "node_modules" (
|
||||||
|
echo Installing dependencies...
|
||||||
|
npm install --legacy-peer-deps
|
||||||
|
echo.
|
||||||
|
)
|
||||||
|
|
||||||
|
REM Start the development server
|
||||||
|
echo Starting server...
|
||||||
|
npm run dev
|
||||||
|
|
||||||
|
pause
|
||||||
18
test-external-ratings.js
Normal file
18
test-external-ratings.js
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
// Test external ratings
|
||||||
|
const { getExternalRatings } = require('./lib/external-ratings.ts');
|
||||||
|
|
||||||
|
async function testExternalRatings() {
|
||||||
|
try {
|
||||||
|
console.log('Testing external ratings for Avatar (movie)...');
|
||||||
|
const movieRatings = await getExternalRatings(19995, 'movie');
|
||||||
|
console.log('Movie ratings:', movieRatings);
|
||||||
|
|
||||||
|
console.log('\nTesting external ratings for Friends (TV show)...');
|
||||||
|
const tvRatings = await getExternalRatings(1668, 'tv');
|
||||||
|
console.log('TV ratings:', tvRatings);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Test failed:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
testExternalRatings();
|
||||||
49
test-logging.js
Normal file
49
test-logging.js
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
// Test script für das Logging
|
||||||
|
const { createBrowserClient } = require('@supabase/ssr');
|
||||||
|
|
||||||
|
async function testMovieLogging() {
|
||||||
|
console.log('Testing movie logging...');
|
||||||
|
|
||||||
|
const supabase = createBrowserClient(
|
||||||
|
'https://ekbpexbhuochrplzorce.supabase.co',
|
||||||
|
'sb_publishable__UII_iKx3pgvLQvc1xrN1w_qnwP6JOv'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Teste aktuellen User
|
||||||
|
const { data: { user }, error: userError } = await supabase.auth.getUser();
|
||||||
|
console.log('Current user:', user?.id, userError);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
console.log('No user found, trying to get session...');
|
||||||
|
const { data: { session }, error: sessionError } = await supabase.auth.getSession();
|
||||||
|
console.log('Session:', session?.user?.id, sessionError);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Teste Insert mit den korrekten Feldnamen
|
||||||
|
console.log('Testing insert with correct field names...');
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from("diary_entries")
|
||||||
|
.insert({
|
||||||
|
user_id: user.id,
|
||||||
|
tmdb_movie_id: 123,
|
||||||
|
movie_title: "Test Film",
|
||||||
|
movie_poster_path: "/test.jpg",
|
||||||
|
rating: 4.5,
|
||||||
|
review: "Test review",
|
||||||
|
watched_on: new Date().toISOString().slice(0, 10)
|
||||||
|
})
|
||||||
|
.select();
|
||||||
|
|
||||||
|
console.log('Insert result:', data, error);
|
||||||
|
|
||||||
|
// Teste Select
|
||||||
|
const { data: entries, error: selectError } = await supabase
|
||||||
|
.from("diary_entries")
|
||||||
|
.select("*")
|
||||||
|
.eq("user_id", user.id);
|
||||||
|
|
||||||
|
console.log('Select result:', entries, selectError);
|
||||||
|
}
|
||||||
|
|
||||||
|
testMovieLogging();
|
||||||
62
test-ocean-theme.js
Normal file
62
test-ocean-theme.js
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
// Theme Test - kopiere diesen Code in F12 Console
|
||||||
|
console.log('=== TESTING THEME FIX ===');
|
||||||
|
|
||||||
|
// Teste Ocean Blue direkt
|
||||||
|
const oceanTheme = {
|
||||||
|
background: 'rgb(240, 249, 255)',
|
||||||
|
foreground: 'rgb(12, 74, 110)',
|
||||||
|
glassBg: 'rgba(255, 255, 255, 0.9)',
|
||||||
|
glassBorder: 'rgba(59, 130, 246, 0.2)',
|
||||||
|
primary: 'rgb(14, 165, 233)'
|
||||||
|
};
|
||||||
|
|
||||||
|
// Entferne alte Styles
|
||||||
|
const oldStyles = document.getElementById('test-theme-styles');
|
||||||
|
if (oldStyles) oldStyles.remove();
|
||||||
|
|
||||||
|
// Erstelle neue Styles
|
||||||
|
const styleElement = document.createElement('style');
|
||||||
|
styleElement.id = 'test-theme-styles';
|
||||||
|
styleElement.textContent = `
|
||||||
|
body {
|
||||||
|
background-color: ${oceanTheme.background} !important;
|
||||||
|
color: ${oceanTheme.foreground} !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass-card,
|
||||||
|
.glass-header,
|
||||||
|
.glass-button,
|
||||||
|
.glass-avatar,
|
||||||
|
.glass-tag,
|
||||||
|
.glass-input {
|
||||||
|
background: ${oceanTheme.glassBg} !important;
|
||||||
|
border: 1px solid ${oceanTheme.glassBorder} !important;
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
-webkit-backdrop-filter: blur(20px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-primary {
|
||||||
|
color: ${oceanTheme.primary} !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg-primary {
|
||||||
|
background-color: ${oceanTheme.primary} !important;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
document.head.appendChild(styleElement);
|
||||||
|
|
||||||
|
console.log('✅ Ocean Blue Theme applied');
|
||||||
|
console.log('Glass BG:', oceanTheme.glassBg);
|
||||||
|
console.log('Glass Border:', oceanTheme.glassBorder);
|
||||||
|
|
||||||
|
// Prüfe ob es funktioniert
|
||||||
|
setTimeout(() => {
|
||||||
|
const glassCards = document.querySelectorAll('.glass-card');
|
||||||
|
if (glassCards.length > 0) {
|
||||||
|
const firstCard = glassCards[0];
|
||||||
|
const styles = getComputedStyle(firstCard);
|
||||||
|
console.log('Erste Glass Card Background:', styles.backgroundColor);
|
||||||
|
console.log('Erste Glass Card Border:', styles.borderColor);
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
38
test-omdb.js
Normal file
38
test-omdb.js
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
// Test external ratings directly
|
||||||
|
async function testExternalRatings() {
|
||||||
|
console.log('Testing OMDB API...');
|
||||||
|
|
||||||
|
const omdbApiKey = '5425f45e';
|
||||||
|
console.log('Using OMDB API Key:', omdbApiKey);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Test with Avatar (19995) -> tt0499549
|
||||||
|
console.log('Testing Avatar IMDB rating...');
|
||||||
|
const response = await fetch(
|
||||||
|
`https://www.omdbapi.com/?i=tt0499549&apikey=${omdbApiKey}`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
console.log('❌ OMDB API request failed:', response.status);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.Error) {
|
||||||
|
console.log('❌ OMDB API error:', data.Error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('✅ OMDB API success!');
|
||||||
|
console.log('Title:', data.Title);
|
||||||
|
console.log('IMDB Rating:', data.imdbRating);
|
||||||
|
console.log('IMDB Votes:', data.imdbVotes);
|
||||||
|
console.log('Year:', data.Year);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Test failed:', error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
testExternalRatings();
|
||||||
33
test-supabase.js
Normal file
33
test-supabase.js
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
// Test script für Supabase Verbindung
|
||||||
|
const { createBrowserClient } = require('@supabase/ssr');
|
||||||
|
|
||||||
|
async function testSupabaseConnection() {
|
||||||
|
console.log('Testing Supabase connection...');
|
||||||
|
|
||||||
|
const supabase = createBrowserClient(
|
||||||
|
'https://ekbpexbhuochrplzorce.supabase.co',
|
||||||
|
'sb_publishable__UII_iKx3pgvLQvc1xrN1w_qnwP6JOv'
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Teste Verbindung mit einer einfachen Abfrage
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('diary_entries')
|
||||||
|
.select('count')
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
console.error('Supabase connection error:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Supabase connection successful!');
|
||||||
|
console.log('Data:', data);
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Unexpected error:', err);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
testSupabaseConnection();
|
||||||
46
test-themes.js
Normal file
46
test-themes.js
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
// Theme Debug Test
|
||||||
|
const { applyTheme, getTheme, themes } = require('./lib/themes.ts');
|
||||||
|
|
||||||
|
console.log('=== THEME DEBUG TEST ===');
|
||||||
|
|
||||||
|
// Test 1: Check if themes are loaded
|
||||||
|
console.log('Available themes:', themes.map(t => t.id));
|
||||||
|
|
||||||
|
// Test 2: Apply Apple Frosted Glass Light
|
||||||
|
const appleTheme = getTheme('apple-frosted-light');
|
||||||
|
if (appleTheme) {
|
||||||
|
console.log('✅ Apple Frosted Light theme found');
|
||||||
|
console.log('Primary color:', appleTheme.colors.primary);
|
||||||
|
console.log('Glass background:', appleTheme.colors.glassBackground);
|
||||||
|
|
||||||
|
// Apply theme
|
||||||
|
applyTheme(appleTheme);
|
||||||
|
console.log('✅ Apple Frosted Light theme applied');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 3: Check CSS variables
|
||||||
|
if (typeof document !== 'undefined') {
|
||||||
|
const rootStyles = getComputedStyle(document.documentElement);
|
||||||
|
const primaryColor = rootStyles.getPropertyValue('--primary');
|
||||||
|
const glassBg = rootStyles.getPropertyValue('--glass-bg');
|
||||||
|
|
||||||
|
console.log('CSS Variables after applying theme:');
|
||||||
|
console.log('--primary:', primaryColor);
|
||||||
|
console.log('--glass-bg:', glassBg);
|
||||||
|
} else {
|
||||||
|
console.log('❌ Document not available (server-side)');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 4: Check problematic themes
|
||||||
|
const oceanTheme = getTheme('ocean-blue');
|
||||||
|
const forestTheme = getTheme('forest-green');
|
||||||
|
|
||||||
|
console.log('Ocean Blue theme:');
|
||||||
|
console.log(' Card background:', oceanTheme?.colors.card);
|
||||||
|
console.log(' Glass background:', oceanTheme?.colors.glassBackground);
|
||||||
|
|
||||||
|
console.log('Forest Green theme:');
|
||||||
|
console.log(' Card background:', forestTheme?.colors.card);
|
||||||
|
console.log(' Glass background:', forestTheme?.colors.glassBackground);
|
||||||
|
|
||||||
|
console.log('=== END THEME DEBUG ===');
|
||||||
@ -1,17 +1,21 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"lib": ["dom", "dom.iterable", "esnext"],
|
"lib": [
|
||||||
|
"dom",
|
||||||
|
"dom.iterable",
|
||||||
|
"esnext"
|
||||||
|
],
|
||||||
"allowJs": true,
|
"allowJs": true,
|
||||||
"target": "ES6",
|
"target": "ES6",
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"strict": true,
|
"strict": false,
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"module": "esnext",
|
"module": "esnext",
|
||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
"jsx": "react-jsx",
|
"jsx": "preserve",
|
||||||
"incremental": true,
|
"incremental": true,
|
||||||
"plugins": [
|
"plugins": [
|
||||||
{
|
{
|
||||||
@ -19,8 +23,11 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"paths": {
|
"paths": {
|
||||||
"@/*": ["./*"]
|
"@/*": [
|
||||||
}
|
"./*"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"allowSyntheticDefaultImports": true
|
||||||
},
|
},
|
||||||
"include": [
|
"include": [
|
||||||
"next-env.d.ts",
|
"next-env.d.ts",
|
||||||
@ -29,5 +36,7 @@
|
|||||||
".next/types/**/*.ts",
|
".next/types/**/*.ts",
|
||||||
".next/dev/types/**/*.ts"
|
".next/dev/types/**/*.ts"
|
||||||
],
|
],
|
||||||
"exclude": ["node_modules"]
|
"exclude": [
|
||||||
|
"node_modules"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
12
update-rating-schema.sql
Normal file
12
update-rating-schema.sql
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
-- Update rating field to allow 1-10 scale
|
||||||
|
ALTER TABLE public.diary_entries
|
||||||
|
ALTER COLUMN rating TYPE numeric(3,1)
|
||||||
|
USING rating::numeric(3,1);
|
||||||
|
|
||||||
|
-- Update constraint to allow 1-10
|
||||||
|
ALTER TABLE public.diary_entries
|
||||||
|
DROP CONSTRAINT IF EXISTS diary_entries_rating_check;
|
||||||
|
|
||||||
|
ALTER TABLE public.diary_entries
|
||||||
|
ADD CONSTRAINT diary_entries_rating_check
|
||||||
|
CHECK (rating >= 0.5 and rating <= 10);
|
||||||
Loading…
x
Reference in New Issue
Block a user