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 { 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() {
|
||||
const supabase = await createClient()
|
||||
const {
|
||||
data: { user },
|
||||
} = 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")
|
||||
.select("*")
|
||||
.eq("user_id", user!.id)
|
||||
.order("watched_at", { ascending: false })
|
||||
.eq("user_id", user.id)
|
||||
.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")
|
||||
.select("*")
|
||||
.eq("user_id", user!.id)
|
||||
.eq("user_id", user.id)
|
||||
.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")
|
||||
.select("*, list_items(count)")
|
||||
.eq("user_id", user!.id)
|
||||
.eq("user_id", user.id)
|
||||
.order("created_at", { ascending: false })
|
||||
|
||||
if (listsError) {
|
||||
console.error("Error fetching lists:", listsError)
|
||||
}
|
||||
|
||||
return (
|
||||
<DiaryContent
|
||||
entries={entries || []}
|
||||
|
||||
@ -22,6 +22,7 @@ export default function ListsPage() {
|
||||
const [newListName, setNewListName] = useState("")
|
||||
const [newListDesc, setNewListDesc] = useState("")
|
||||
const [creatingList, setCreatingList] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
loadLists()
|
||||
@ -66,24 +67,33 @@ export default function ListsPage() {
|
||||
.single()
|
||||
|
||||
if (!error && data) {
|
||||
setLists([data, ...lists])
|
||||
setLists(prev => [data, ...prev])
|
||||
setNewListName("")
|
||||
setNewListDesc("")
|
||||
setShowNewList(false)
|
||||
} else if (error) {
|
||||
setError("Fehler beim Erstellen der Liste")
|
||||
setTimeout(() => setError(null), 3000)
|
||||
}
|
||||
setCreatingList(false)
|
||||
}
|
||||
|
||||
async function deleteList(id: string) {
|
||||
const supabase = createClient()
|
||||
await supabase.from("lists").delete().eq("id", id)
|
||||
setLists(lists.filter((l) => l.id !== id))
|
||||
const { error } = await supabase.from("lists").delete().eq("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) {
|
||||
return (
|
||||
<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">
|
||||
Meine Listen
|
||||
</h1>
|
||||
@ -97,22 +107,28 @@ export default function ListsPage() {
|
||||
|
||||
return (
|
||||
<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">
|
||||
<h1 className="font-heading text-xl font-bold text-foreground">
|
||||
Meine Listen
|
||||
</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}
|
||||
</span>
|
||||
</div>
|
||||
</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">
|
||||
{/* New list button */}
|
||||
<button
|
||||
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"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
@ -121,32 +137,32 @@ export default function ListsPage() {
|
||||
|
||||
{/* New list form */}
|
||||
{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
|
||||
value={newListName}
|
||||
onChange={(e) => setNewListName(e.target.value)}
|
||||
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
|
||||
/>
|
||||
<input
|
||||
value={newListDesc}
|
||||
onChange={(e) => setNewListDesc(e.target.value)}
|
||||
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">
|
||||
<button
|
||||
type="button"
|
||||
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
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
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 ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
@ -160,7 +176,7 @@ export default function ListsPage() {
|
||||
|
||||
{/* Lists */}
|
||||
{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" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
@ -176,7 +192,7 @@ export default function ListsPage() {
|
||||
{lists.map((list) => (
|
||||
<div
|
||||
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
|
||||
href={`/lists/${list.id}`}
|
||||
@ -197,7 +213,7 @@ export default function ListsPage() {
|
||||
)}
|
||||
</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
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
@ -7,9 +7,10 @@ import { useRouter, useSearchParams } from "next/navigation"
|
||||
import Image from "next/image"
|
||||
import { createClient } from "@/lib/supabase/client"
|
||||
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 { Search, Film, Check, Loader2 } from "lucide-react"
|
||||
import { Search, Film, Check, Loader2, Tv } from "lucide-react"
|
||||
|
||||
function LogPageContent() {
|
||||
const router = useRouter()
|
||||
@ -23,19 +24,21 @@ function LogPageContent() {
|
||||
preselectedId ? "rate" : "search"
|
||||
)
|
||||
const [query, setQuery] = useState("")
|
||||
const [results, setResults] = useState<TMDBMovie[]>([])
|
||||
const [results, setResults] = useState<(TMDBMovie | TMDBTVShow | TMDBMultiResult)[]>([])
|
||||
const [searching, setSearching] = useState(false)
|
||||
|
||||
const [selectedMovie, setSelectedMovie] = useState<{
|
||||
id: number
|
||||
title: string
|
||||
poster_path: string | null
|
||||
media_type: 'movie' | 'tv'
|
||||
} | null>(
|
||||
preselectedId
|
||||
? {
|
||||
id: Number(preselectedId),
|
||||
title: preselectedTitle || "",
|
||||
poster_path: preselectedPoster || null,
|
||||
media_type: 'movie' // Default to movie for preselected
|
||||
}
|
||||
: null
|
||||
)
|
||||
@ -45,7 +48,12 @@ function LogPageContent() {
|
||||
const [watchedAt, setWatchedAt] = useState(
|
||||
new Date().toISOString().slice(0, 10)
|
||||
)
|
||||
const [seasonNumber, setSeasonNumber] = useState("")
|
||||
const [episodeNumber, setEpisodeNumber] = useState("")
|
||||
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) => {
|
||||
if (!q.trim()) {
|
||||
@ -68,37 +76,105 @@ function LogPageContent() {
|
||||
return () => clearTimeout(timer)
|
||||
}, [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({
|
||||
id: movie.id,
|
||||
title: movie.title,
|
||||
title: 'title' in movie ? movie.title : movie.name || '',
|
||||
poster_path: movie.poster_path,
|
||||
media_type: isTV ? 'tv' : 'movie'
|
||||
})
|
||||
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) {
|
||||
e.preventDefault()
|
||||
if (!selectedMovie) return
|
||||
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 {
|
||||
data: { user },
|
||||
} = 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,
|
||||
tmdb_id: selectedMovie.id,
|
||||
title: selectedMovie.title,
|
||||
poster_path: selectedMovie.poster_path,
|
||||
tmdb_movie_id: selectedMovie.id,
|
||||
movie_title: selectedMovie.title,
|
||||
movie_poster_path: selectedMovie.poster_path,
|
||||
media_type: selectedMovie.media_type,
|
||||
rating: rating || null,
|
||||
imdb_rating: externalRatings?.imdb_rating || 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.refresh()
|
||||
}
|
||||
@ -113,19 +189,31 @@ function LogPageContent() {
|
||||
return (
|
||||
<main className="mx-auto max-w-lg">
|
||||
<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">
|
||||
Film loggen
|
||||
</h1>
|
||||
<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 className="flex items-center justify-between">
|
||||
<h1 className="mb-3 font-heading text-lg font-bold text-foreground">
|
||||
Film loggen
|
||||
</h1>
|
||||
<button
|
||||
onClick={() => {
|
||||
setStep("search")
|
||||
setSelectedMovie(null)
|
||||
}}
|
||||
className="text-sm text-muted-foreground hover:text-foreground"
|
||||
type="button"
|
||||
>
|
||||
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>
|
||||
</header>
|
||||
|
||||
@ -154,7 +242,7 @@ function LogPageContent() {
|
||||
{url ? (
|
||||
<Image
|
||||
src={url || "/placeholder.svg"}
|
||||
alt={movie.title}
|
||||
alt={'title' in movie ? movie.title : movie.name}
|
||||
fill
|
||||
className="object-cover"
|
||||
sizes="44px"
|
||||
@ -166,11 +254,17 @@ function LogPageContent() {
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
{movie.title}
|
||||
</p>
|
||||
<div className="flex items-center gap-1">
|
||||
{(() => {
|
||||
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">
|
||||
{movie.release_date?.slice(0, 4)}
|
||||
{'release_date' in movie ? movie.release_date?.slice(0, 4) : movie.first_air_date?.slice(0, 4)}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
@ -202,6 +296,12 @@ function LogPageContent() {
|
||||
</header>
|
||||
|
||||
<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 */}
|
||||
<div className="flex gap-4">
|
||||
<div className="relative h-32 w-[86px] shrink-0 overflow-hidden rounded-lg bg-secondary">
|
||||
@ -239,6 +339,88 @@ function LogPageContent() {
|
||||
/>
|
||||
</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 */}
|
||||
<div className="mt-5">
|
||||
<label
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { getMovie } from "@/lib/tmdb"
|
||||
import { getExternalRatings } from "@/lib/external-ratings"
|
||||
import { createClient } from "@/lib/supabase/server"
|
||||
import { MovieDetail } from "@/components/movie-detail"
|
||||
|
||||
@ -9,6 +10,14 @@ export default async function MoviePage({
|
||||
}) {
|
||||
const { id } = await params
|
||||
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 {
|
||||
@ -19,7 +28,7 @@ export default async function MoviePage({
|
||||
const { data: watchlistItem } = await supabase
|
||||
.from("watchlist")
|
||||
.select("id")
|
||||
.eq("user_id", user!.id)
|
||||
.eq("user_id", user?.id || "")
|
||||
.eq("tmdb_id", movie.id)
|
||||
.maybeSingle()
|
||||
|
||||
@ -33,6 +42,7 @@ export default async function MoviePage({
|
||||
return (
|
||||
<MovieDetail
|
||||
movie={movie}
|
||||
externalRatings={externalRatings}
|
||||
isInWatchlist={!!watchlistItem}
|
||||
familyEntries={familyEntries || []}
|
||||
/>
|
||||
|
||||
@ -9,7 +9,7 @@ export default async function ProfilePage() {
|
||||
|
||||
const { data: profile } = await supabase
|
||||
.from("profiles")
|
||||
.select("*")
|
||||
.select("display_name, avatar_url, theme")
|
||||
.eq("id", user!.id)
|
||||
.single()
|
||||
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import { searchMovies } from "@/lib/tmdb"
|
||||
import { searchAll } from "@/lib/tmdb"
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const query = request.nextUrl.searchParams.get("q")
|
||||
@ -10,7 +10,7 @@ export async function GET(request: NextRequest) {
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await searchMovies(query, Number(page))
|
||||
const data = await searchAll(query, Number(page))
|
||||
return NextResponse.json(data)
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Suche fehlgeschlagen" }, { status: 500 })
|
||||
|
||||
@ -7,6 +7,8 @@ import { useRouter } from "next/navigation"
|
||||
import Link from "next/link"
|
||||
import { createClient } from "@/lib/supabase/client"
|
||||
import { Film, Eye, Loader2 } from "lucide-react"
|
||||
import { useLanguage } from "@/contexts/LanguageContext"
|
||||
import { LanguageToggle } from "@/components/language-toggle"
|
||||
|
||||
export default function LoginPage() {
|
||||
const [email, setEmail] = useState("")
|
||||
@ -14,6 +16,7 @@ export default function LoginPage() {
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const router = useRouter()
|
||||
const { t } = useLanguage()
|
||||
|
||||
async function handleLogin(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
@ -27,7 +30,7 @@ export default function LoginPage() {
|
||||
})
|
||||
|
||||
if (error) {
|
||||
setError("E-Mail oder Passwort falsch.")
|
||||
setError(t("auth.invalidCredentials"))
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
@ -39,6 +42,9 @@ export default function LoginPage() {
|
||||
return (
|
||||
<main className="flex min-h-dvh flex-col items-center justify-center px-6">
|
||||
<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="flex h-14 w-14 items-center justify-center rounded-2xl bg-primary">
|
||||
<Film className="h-7 w-7 text-primary-foreground" />
|
||||
@ -47,14 +53,14 @@ export default function LoginPage() {
|
||||
InFocus
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Familienfilm-Tagebuch
|
||||
{t("auth.familyMovieDiary")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleLogin} className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<label htmlFor="email" className="text-sm font-medium text-foreground">
|
||||
E-Mail
|
||||
{t("auth.email")}
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
@ -63,22 +69,22 @@ export default function LoginPage() {
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="familie@example.com"
|
||||
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 className="flex flex-col gap-2">
|
||||
<label htmlFor="password" className="text-sm font-medium text-foreground">
|
||||
Passwort
|
||||
{t("auth.password")}
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="Passwort eingeben"
|
||||
placeholder={t("auth.passwordPlaceholder")}
|
||||
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>
|
||||
|
||||
@ -89,23 +95,23 @@ export default function LoginPage() {
|
||||
<button
|
||||
type="submit"
|
||||
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 ? (
|
||||
<Loader2 className="h-5 w-5 animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
<Eye className="h-5 w-5" />
|
||||
Anmelden
|
||||
{t("auth.signIn")}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<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">
|
||||
Registrieren
|
||||
{t("auth.signUp")}
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@ -7,6 +7,8 @@ import { useRouter } from "next/navigation"
|
||||
import Link from "next/link"
|
||||
import { createClient } from "@/lib/supabase/client"
|
||||
import { Film, UserPlus, Loader2 } from "lucide-react"
|
||||
import { useLanguage } from "@/contexts/LanguageContext"
|
||||
import { LanguageToggle } from "@/components/language-toggle"
|
||||
|
||||
export default function SignUpPage() {
|
||||
const [email, setEmail] = useState("")
|
||||
@ -15,6 +17,7 @@ export default function SignUpPage() {
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const router = useRouter()
|
||||
const { t } = useLanguage()
|
||||
|
||||
async function handleSignUp(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
@ -47,22 +50,25 @@ export default function SignUpPage() {
|
||||
return (
|
||||
<main className="flex min-h-dvh flex-col items-center justify-center px-6">
|
||||
<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="flex h-14 w-14 items-center justify-center rounded-2xl bg-primary">
|
||||
<Film className="h-7 w-7 text-primary-foreground" />
|
||||
</div>
|
||||
<h1 className="font-heading text-3xl font-bold tracking-tight text-foreground">
|
||||
Konto erstellen
|
||||
{t("auth.createAccount")}
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Tritt deinem Familien-Filmclub bei
|
||||
{t("auth.joinFamilyMovieClub")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSignUp} className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<label htmlFor="displayName" className="text-sm font-medium text-foreground">
|
||||
Anzeigename
|
||||
{t("auth.displayName")}
|
||||
</label>
|
||||
<input
|
||||
id="displayName"
|
||||
@ -71,7 +77,7 @@ export default function SignUpPage() {
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
placeholder="z.B. Papa, Mama, Lisa..."
|
||||
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>
|
||||
|
||||
@ -86,7 +92,7 @@ export default function SignUpPage() {
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="familie@example.com"
|
||||
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>
|
||||
|
||||
@ -102,7 +108,7 @@ export default function SignUpPage() {
|
||||
placeholder="Mindestens 6 Zeichen"
|
||||
required
|
||||
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>
|
||||
|
||||
@ -113,7 +119,7 @@ export default function SignUpPage() {
|
||||
<button
|
||||
type="submit"
|
||||
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 ? (
|
||||
<Loader2 className="h-5 w-5 animate-spin" />
|
||||
|
||||
@ -43,9 +43,14 @@
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
body {
|
||||
@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 "./globals.css"
|
||||
import { LanguageProvider } from "@/contexts/LanguageContext"
|
||||
|
||||
const inter = Inter({ subsets: ["latin"], variable: "--font-inter" })
|
||||
const spaceGrotesk = Space_Grotesk({
|
||||
@ -43,7 +44,9 @@ export default function RootLayout({
|
||||
<body
|
||||
className={`${inter.variable} ${spaceGrotesk.variable} font-sans antialiased`}
|
||||
>
|
||||
{children}
|
||||
<LanguageProvider>
|
||||
{children}
|
||||
</LanguageProvider>
|
||||
<script
|
||||
dangerouslySetInnerHTML={{
|
||||
__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,
|
||||
Plus,
|
||||
Loader2,
|
||||
Tv,
|
||||
} from "lucide-react"
|
||||
|
||||
interface DiaryEntry {
|
||||
id: string
|
||||
tmdb_id: number
|
||||
title: string
|
||||
poster_path: string | null
|
||||
tmdb_movie_id: number
|
||||
movie_title: string
|
||||
movie_poster_path: string | null
|
||||
rating: number | null
|
||||
imdb_rating: number | null
|
||||
rotten_tomatoes_rating: number | null
|
||||
review: string | null
|
||||
watched_at: string
|
||||
watched_on: string
|
||||
media_type: 'movie' | 'tv'
|
||||
season_number: number | null
|
||||
episode_number: number | null
|
||||
}
|
||||
|
||||
interface WatchlistItem {
|
||||
@ -108,34 +114,12 @@ export function DiaryContent({
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-lg">
|
||||
<header className="sticky top-0 z-40 border-b border-border bg-background/95 backdrop-blur-md">
|
||||
<h1 className="px-4 pt-3 pb-2 font-heading text-xl font-bold text-foreground">
|
||||
Meine Filme
|
||||
<main className="mx-auto max-w-4xl">
|
||||
<header className="sticky top-0 z-40 glass-header px-4 py-3">
|
||||
<h1 className="font-heading text-xl font-bold text-foreground">
|
||||
Mein Tagebuch
|
||||
</h1>
|
||||
<div className="flex px-4">
|
||||
{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>
|
||||
<div className="w-16" />
|
||||
</header>
|
||||
|
||||
<div className="px-4 pt-4">
|
||||
@ -150,55 +134,100 @@ export function DiaryContent({
|
||||
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) => {
|
||||
const url = posterUrl(entry.poster_path, "w185")
|
||||
const url = posterUrl(entry.movie_poster_path, "w342")
|
||||
return (
|
||||
<div
|
||||
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">
|
||||
<div className="relative h-20 w-[54px] overflow-hidden rounded-lg bg-secondary">
|
||||
<Link href={`/movie/${entry.tmdb_movie_id}`} className="block">
|
||||
<div className="relative h-48 w-full overflow-hidden bg-secondary">
|
||||
{url ? (
|
||||
<Image
|
||||
src={url || "/placeholder.svg"}
|
||||
alt={entry.title}
|
||||
alt={entry.movie_title}
|
||||
fill
|
||||
className="object-cover"
|
||||
sizes="54px"
|
||||
sizes="100%"
|
||||
/>
|
||||
) : (
|
||||
<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>
|
||||
</Link>
|
||||
<div className="flex min-w-0 flex-1 flex-col justify-center gap-1">
|
||||
<Link href={`/movie/${entry.tmdb_id}`}>
|
||||
<h3 className="truncate text-sm font-semibold text-foreground">
|
||||
{entry.title}
|
||||
</h3>
|
||||
</Link>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
{new Date(entry.watched_at).toLocaleDateString(
|
||||
"de-DE",
|
||||
{ day: "numeric", month: "long", year: "numeric" }
|
||||
|
||||
<div className="p-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Link href={`/movie/${entry.tmdb_movie_id}`}>
|
||||
<h3 className="truncate font-heading text-base font-semibold text-foreground">
|
||||
{entry.movie_title}
|
||||
</h3>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
|
||||
{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>
|
||||
<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>
|
||||
)
|
||||
})}
|
||||
@ -264,7 +293,7 @@ export function DiaryContent({
|
||||
<>
|
||||
<button
|
||||
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"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
@ -280,13 +309,13 @@ export function DiaryContent({
|
||||
value={newListName}
|
||||
onChange={(e) => setNewListName(e.target.value)}
|
||||
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
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
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 ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
@ -309,7 +338,7 @@ export function DiaryContent({
|
||||
<Link
|
||||
key={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>
|
||||
<h3 className="text-sm font-semibold text-foreground">
|
||||
@ -321,7 +350,7 @@ export function DiaryContent({
|
||||
</p>
|
||||
)}
|
||||
</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
|
||||
</span>
|
||||
</Link>
|
||||
@ -349,7 +378,7 @@ function EmptyState({
|
||||
href?: string
|
||||
}) {
|
||||
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" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-foreground">{title}</p>
|
||||
@ -358,7 +387,7 @@ function EmptyState({
|
||||
{href && (
|
||||
<Link
|
||||
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
|
||||
</Link>
|
||||
|
||||
@ -5,19 +5,19 @@ import Link from "next/link"
|
||||
import { posterUrl } from "@/lib/tmdb"
|
||||
import { StarRating } from "@/components/star-rating"
|
||||
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 { useState } from "react"
|
||||
|
||||
interface FeedEntry {
|
||||
id: string
|
||||
user_id: string
|
||||
tmdb_id: number
|
||||
title: string
|
||||
poster_path: string | null
|
||||
tmdb_movie_id: number
|
||||
movie_title: string
|
||||
movie_poster_path: string | null
|
||||
rating: number | null
|
||||
review: string | null
|
||||
watched_at: string
|
||||
watched_on: string
|
||||
created_at: string
|
||||
profiles: {
|
||||
display_name: string
|
||||
@ -40,7 +40,7 @@ interface FeedContentProps {
|
||||
|
||||
export function FeedContent({ profile, entries, trending }: FeedContentProps) {
|
||||
return (
|
||||
<main className="mx-auto max-w-lg">
|
||||
<main className="mx-auto max-w-4xl">
|
||||
{/* Header */}
|
||||
<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">
|
||||
@ -94,7 +94,7 @@ export function FeedContent({ profile, entries, trending }: FeedContentProps) {
|
||||
</Link>
|
||||
</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) => (
|
||||
<FeedCard key={entry.id} entry={entry} />
|
||||
))}
|
||||
@ -110,14 +110,21 @@ export function FeedContent({ profile, entries, trending }: FeedContentProps) {
|
||||
function FeedCard({ entry }: { entry: FeedEntry }) {
|
||||
const [liked, setLiked] = useState(false)
|
||||
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() {
|
||||
if (liking) return
|
||||
setLiking(true)
|
||||
|
||||
const supabase = createClient()
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser()
|
||||
if (!user) return
|
||||
if (!user) {
|
||||
setLiking(false)
|
||||
return
|
||||
}
|
||||
|
||||
if (liked) {
|
||||
await supabase
|
||||
@ -135,75 +142,83 @@ function FeedCard({ entry }: { entry: FeedEntry }) {
|
||||
setLiked(true)
|
||||
setLikeCount((c) => c + 1)
|
||||
}
|
||||
setLiking(false)
|
||||
}
|
||||
|
||||
const watchedDate = new Date(entry.watched_at).toLocaleDateString("de-DE", {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
})
|
||||
const watchedDate = entry.watched_on
|
||||
? new Date(entry.watched_on).toLocaleDateString("de-DE", {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
})
|
||||
: "Kein Datum"
|
||||
|
||||
return (
|
||||
<article className="glass-card overflow-hidden">
|
||||
<div className="flex gap-3 p-4">
|
||||
<div className="flex gap-4 p-4">
|
||||
{/* Poster */}
|
||||
<Link href={`/movie/${entry.tmdb_id}`} className="shrink-0">
|
||||
<div className="relative h-28 w-[75px] overflow-hidden rounded-lg bg-secondary">
|
||||
<Link href={`/movie/${entry.tmdb_movie_id}`} className="shrink-0">
|
||||
<div className="relative h-32 w-[120px] overflow-hidden rounded-lg bg-secondary">
|
||||
{url ? (
|
||||
<Image
|
||||
src={url || "/placeholder.svg"}
|
||||
alt={entry.title}
|
||||
alt={entry.movie_title}
|
||||
fill
|
||||
className="object-cover"
|
||||
sizes="75px"
|
||||
sizes="120px"
|
||||
/>
|
||||
) : (
|
||||
<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>
|
||||
</Link>
|
||||
|
||||
{/* 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="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() || "?"}
|
||||
</div>
|
||||
<span className="text-xs font-medium text-foreground">
|
||||
<span className="text-sm font-medium text-foreground truncate">
|
||||
{entry.profiles?.display_name}
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
<span className="text-xs text-muted-foreground flex-shrink-0">
|
||||
{watchedDate}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Link href={`/movie/${entry.tmdb_id}`}>
|
||||
<h3 className="truncate font-heading text-sm font-semibold text-foreground">
|
||||
{entry.title}
|
||||
<Link href={`/movie/${entry.tmdb_movie_id}`}>
|
||||
<h3 className="truncate font-heading text-base font-semibold text-foreground">
|
||||
{entry.movie_title}
|
||||
</h3>
|
||||
</Link>
|
||||
|
||||
{entry.rating && <StarRating rating={entry.rating} size="sm" />}
|
||||
|
||||
{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}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="mt-1 flex items-center gap-4">
|
||||
<div className="mt-2 flex items-center gap-4">
|
||||
<button
|
||||
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"
|
||||
>
|
||||
<Heart
|
||||
className={`h-4 w-4 ${liked ? "fill-primary text-primary" : ""}`}
|
||||
/>
|
||||
{liking ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Heart
|
||||
className={`h-4 w-4 ${liked ? "fill-primary text-primary" : ""}`}
|
||||
/>
|
||||
)}
|
||||
{likeCount > 0 && (
|
||||
<span className="text-[10px]">{likeCount}</span>
|
||||
<span className="text-xs">{likeCount}</span>
|
||||
)}
|
||||
</button>
|
||||
</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 (
|
||||
<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">
|
||||
<ArrowLeft className="h-5 w-5 text-foreground" />
|
||||
</button>
|
||||
@ -123,7 +123,7 @@ export function ListDetailContent({
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
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
|
||||
/>
|
||||
<button
|
||||
@ -176,7 +176,7 @@ export function ListDetailContent({
|
||||
) : (
|
||||
<button
|
||||
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"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
|
||||
@ -29,12 +29,14 @@ interface FamilyEntry {
|
||||
|
||||
interface MovieDetailProps {
|
||||
movie: TMDBMovieDetail
|
||||
externalRatings: any
|
||||
isInWatchlist: boolean
|
||||
familyEntries: FamilyEntry[]
|
||||
}
|
||||
|
||||
export function MovieDetail({
|
||||
movie,
|
||||
externalRatings,
|
||||
isInWatchlist: initialWatchlist,
|
||||
familyEntries,
|
||||
}: MovieDetailProps) {
|
||||
@ -111,7 +113,7 @@ export function MovieDetail({
|
||||
{/* Back button */}
|
||||
<button
|
||||
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"
|
||||
aria-label="Zurueck"
|
||||
>
|
||||
@ -121,10 +123,10 @@ export function MovieDetail({
|
||||
{/* Copy link button */}
|
||||
<button
|
||||
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
|
||||
? "bg-primary/80 text-primary-foreground"
|
||||
: "bg-background/80 text-foreground"
|
||||
: ""
|
||||
}`}
|
||||
type="button"
|
||||
aria-label="Link kopieren"
|
||||
@ -166,7 +168,7 @@ export function MovieDetail({
|
||||
{movie.genres.slice(0, 3).map((g) => (
|
||||
<span
|
||||
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}
|
||||
</span>
|
||||
@ -183,11 +185,43 @@ export function MovieDetail({
|
||||
</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 */}
|
||||
<div className="mt-4 flex gap-3">
|
||||
<Link
|
||||
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" />
|
||||
Loggen
|
||||
@ -195,10 +229,10 @@ export function MovieDetail({
|
||||
<button
|
||||
onClick={toggleWatchlist}
|
||||
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
|
||||
? "border-primary bg-primary/10 text-primary"
|
||||
: "border-border bg-card text-foreground hover:bg-secondary"
|
||||
: ""
|
||||
}`}
|
||||
type="button"
|
||||
>
|
||||
@ -233,10 +267,10 @@ export function MovieDetail({
|
||||
{familyEntries.map((entry) => (
|
||||
<div
|
||||
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 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()}
|
||||
</div>
|
||||
<span className="text-xs font-medium text-foreground">
|
||||
|
||||
@ -3,9 +3,10 @@
|
||||
import { useRouter } from "next/navigation"
|
||||
import { createClient } from "@/lib/supabase/client"
|
||||
import { BookOpen, Bookmark, ListIcon, LogOut, Film } from "lucide-react"
|
||||
import { ThemeSelector } from "@/components/theme-selector"
|
||||
|
||||
interface ProfileContentProps {
|
||||
profile: { display_name: string; avatar_url: string | null } | null
|
||||
profile: { display_name: string; avatar_url: string | null; theme?: string } | null
|
||||
email: string
|
||||
stats: {
|
||||
diary: number
|
||||
@ -34,7 +35,7 @@ export function ProfileContent({
|
||||
|
||||
return (
|
||||
<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">
|
||||
Profil
|
||||
</h1>
|
||||
@ -43,7 +44,7 @@ export function ProfileContent({
|
||||
<div className="px-4 pt-8">
|
||||
{/* Avatar & Name */}
|
||||
<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">
|
||||
{initials}
|
||||
</span>
|
||||
@ -67,7 +68,7 @@ export function ProfileContent({
|
||||
<div className="mt-8 flex flex-col gap-3">
|
||||
<button
|
||||
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"
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
@ -87,6 +88,11 @@ export function ProfileContent({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Theme Selector */}
|
||||
<div className="mt-8">
|
||||
<ThemeSelector currentTheme={profile?.theme} />
|
||||
</div>
|
||||
|
||||
<div className="h-8" />
|
||||
</main>
|
||||
)
|
||||
@ -102,7 +108,7 @@ function StatCard({
|
||||
value: number
|
||||
}) {
|
||||
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" />
|
||||
<span className="font-heading text-xl font-bold text-foreground">
|
||||
{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> = {}) {
|
||||
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}`)
|
||||
url.searchParams.set('api_key', process.env.TMDB_API_KEY!)
|
||||
url.searchParams.set('api_key', apiKey)
|
||||
url.searchParams.set('language', 'de-DE')
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
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) })
|
||||
}
|
||||
|
||||
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) {
|
||||
return tmdbFetch(`/movie/${id}`)
|
||||
}
|
||||
|
||||
export async function getTVShow(id: number) {
|
||||
return tmdbFetch(`/tv/${id}`)
|
||||
}
|
||||
|
||||
export async function getTrending() {
|
||||
return tmdbFetch('/trending/movie/week')
|
||||
}
|
||||
|
||||
export async function getTrendingAll() {
|
||||
return tmdbFetch('/trending/all/week')
|
||||
}
|
||||
|
||||
export async function getPopular(page = 1) {
|
||||
return tmdbFetch('/movie/popular', { page: String(page) })
|
||||
}
|
||||
|
||||
export async function getPopularTVShows(page = 1) {
|
||||
return tmdbFetch('/tv/popular', { page: String(page) })
|
||||
}
|
||||
|
||||
export interface TMDBMovie {
|
||||
id: number
|
||||
title: string
|
||||
@ -50,9 +75,34 @@ export interface TMDBMovie {
|
||||
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 {
|
||||
page: number
|
||||
results: TMDBMovie[]
|
||||
results: TMDBMovie[] | TMDBTVShow[] | TMDBMultiResult[]
|
||||
total_pages: number
|
||||
total_results: number
|
||||
}
|
||||
@ -61,4 +111,14 @@ export interface TMDBMovieDetail extends TMDBMovie {
|
||||
runtime: number
|
||||
genres: { id: number; name: 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 ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@supabase/ssr": "^0.5.2",
|
||||
"@supabase/supabase-js": "^2.47.12",
|
||||
"swr": "^2.2.5",
|
||||
"@hookform/resolvers": "^3.9.1",
|
||||
"@radix-ui/react-accordion": "1.2.2",
|
||||
"@radix-ui/react-alert-dialog": "1.1.4",
|
||||
@ -40,6 +37,8 @@
|
||||
"@radix-ui/react-toggle": "1.1.1",
|
||||
"@radix-ui/react-toggle-group": "1.1.1",
|
||||
"@radix-ui/react-tooltip": "1.1.6",
|
||||
"@supabase/ssr": "^0.5.2",
|
||||
"@supabase/supabase-js": "^2.47.12",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
@ -48,15 +47,17 @@
|
||||
"embla-carousel-react": "8.5.1",
|
||||
"input-otp": "1.4.1",
|
||||
"lucide-react": "^0.544.0",
|
||||
"next": "16.1.6",
|
||||
"next": "14.2.15",
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "^19",
|
||||
"puppeteer": "^24.39.1",
|
||||
"react": "^18",
|
||||
"react-day-picker": "8.10.1",
|
||||
"react-dom": "^19",
|
||||
"react-dom": "^18",
|
||||
"react-hook-form": "^7.54.1",
|
||||
"react-resizable-panels": "^2.1.7",
|
||||
"recharts": "2.15.0",
|
||||
"sonner": "^1.7.1",
|
||||
"swr": "^2.2.5",
|
||||
"tailwind-merge": "^2.5.5",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"vaul": "^1.1.2",
|
||||
@ -65,8 +66,8 @@
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4.1.13",
|
||||
"@types/node": "^22",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"@types/react": "^18",
|
||||
"@types/react-dom": "^18",
|
||||
"postcss": "^8.5",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"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": {
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"esnext"
|
||||
],
|
||||
"allowJs": true,
|
||||
"target": "ES6",
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"strict": false,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "react-jsx",
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
@ -19,8 +23,11 @@
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": ["./*"]
|
||||
}
|
||||
"@/*": [
|
||||
"./*"
|
||||
]
|
||||
},
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
@ -29,5 +36,7 @@
|
||||
".next/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