feat: implement advanced tagging system, tag weighting, and app focus refactoring
- Implemented reusable TagSelector component with i18n support - Added tag weighting system (popularity scores 1-5) - Created admin panel for tag management - Integrated Nebius AI and Brave Search for 'Magic Scan' - Refactored app focus: removed bottle status, updated counters, and displayed extended bottle details - Updated i18n for German and English - Added database migration scripts
This commit is contained in:
686
.aiinstruct2
Normal file
686
.aiinstruct2
Normal file
@@ -0,0 +1,686 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"name": "auth_rls_initplan",
|
||||||
|
"title": "Auth RLS Initialization Plan",
|
||||||
|
"level": "WARN",
|
||||||
|
"facing": "EXTERNAL",
|
||||||
|
"categories": "[\"PERFORMANCE\"]",
|
||||||
|
"description": "Detects if calls to `auth.<function>()` in RLS policies are being unnecessarily re-evaluated for each row",
|
||||||
|
"detail": "Table `public.buddies` has a row level security policy `Manage own buddies` that re-evaluates an auth.<function>() for each row. This produces suboptimal query performance at scale. Resolve the issue by replacing `auth.<function>()` with `(select auth.<function>())`. See [docs](https://supabase.com/docs/guides/database/postgres/row-level-security#call-functions-with-select) for more info.",
|
||||||
|
"remediation": "https://supabase.com/docs/guides/database/database-linter?lint=0003_auth_rls_initplan",
|
||||||
|
"metadata": "{\"name\":\"buddies\",\"type\":\"table\",\"schema\":\"public\"}",
|
||||||
|
"cache_key": "auth_rls_init_plan_public_buddies_Manage own buddies"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "auth_rls_initplan",
|
||||||
|
"title": "Auth RLS Initialization Plan",
|
||||||
|
"level": "WARN",
|
||||||
|
"facing": "EXTERNAL",
|
||||||
|
"categories": "[\"PERFORMANCE\"]",
|
||||||
|
"description": "Detects if calls to `auth.<function>()` in RLS policies are being unnecessarily re-evaluated for each row",
|
||||||
|
"detail": "Table `public.tasting_sessions` has a row level security policy `Manage own sessions` that re-evaluates an auth.<function>() for each row. This produces suboptimal query performance at scale. Resolve the issue by replacing `auth.<function>()` with `(select auth.<function>())`. See [docs](https://supabase.com/docs/guides/database/postgres/row-level-security#call-functions-with-select) for more info.",
|
||||||
|
"remediation": "https://supabase.com/docs/guides/database/database-linter?lint=0003_auth_rls_initplan",
|
||||||
|
"metadata": "{\"name\":\"tasting_sessions\",\"type\":\"table\",\"schema\":\"public\"}",
|
||||||
|
"cache_key": "auth_rls_init_plan_public_tasting_sessions_Manage own sessions"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "auth_rls_initplan",
|
||||||
|
"title": "Auth RLS Initialization Plan",
|
||||||
|
"level": "WARN",
|
||||||
|
"facing": "EXTERNAL",
|
||||||
|
"categories": "[\"PERFORMANCE\"]",
|
||||||
|
"description": "Detects if calls to `auth.<function>()` in RLS policies are being unnecessarily re-evaluated for each row",
|
||||||
|
"detail": "Table `public.tastings` has a row level security policy `tastings_owner_all` that re-evaluates an auth.<function>() for each row. This produces suboptimal query performance at scale. Resolve the issue by replacing `auth.<function>()` with `(select auth.<function>())`. See [docs](https://supabase.com/docs/guides/database/postgres/row-level-security#call-functions-with-select) for more info.",
|
||||||
|
"remediation": "https://supabase.com/docs/guides/database/database-linter?lint=0003_auth_rls_initplan",
|
||||||
|
"metadata": "{\"name\":\"tastings\",\"type\":\"table\",\"schema\":\"public\"}",
|
||||||
|
"cache_key": "auth_rls_init_plan_public_tastings_tastings_owner_all"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "auth_rls_initplan",
|
||||||
|
"title": "Auth RLS Initialization Plan",
|
||||||
|
"level": "WARN",
|
||||||
|
"facing": "EXTERNAL",
|
||||||
|
"categories": "[\"PERFORMANCE\"]",
|
||||||
|
"description": "Detects if calls to `auth.<function>()` in RLS policies are being unnecessarily re-evaluated for each row",
|
||||||
|
"detail": "Table `public.tasting_tags` has a row level security policy `tags_owner_all` that re-evaluates an auth.<function>() for each row. This produces suboptimal query performance at scale. Resolve the issue by replacing `auth.<function>()` with `(select auth.<function>())`. See [docs](https://supabase.com/docs/guides/database/postgres/row-level-security#call-functions-with-select) for more info.",
|
||||||
|
"remediation": "https://supabase.com/docs/guides/database/database-linter?lint=0003_auth_rls_initplan",
|
||||||
|
"metadata": "{\"name\":\"tasting_tags\",\"type\":\"table\",\"schema\":\"public\"}",
|
||||||
|
"cache_key": "auth_rls_init_plan_public_tasting_tags_tags_owner_all"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "auth_rls_initplan",
|
||||||
|
"title": "Auth RLS Initialization Plan",
|
||||||
|
"level": "WARN",
|
||||||
|
"facing": "EXTERNAL",
|
||||||
|
"categories": "[\"PERFORMANCE\"]",
|
||||||
|
"description": "Detects if calls to `auth.<function>()` in RLS policies are being unnecessarily re-evaluated for each row",
|
||||||
|
"detail": "Table `public.session_participants` has a row level security policy `session_owner_all` that re-evaluates an auth.<function>() for each row. This produces suboptimal query performance at scale. Resolve the issue by replacing `auth.<function>()` with `(select auth.<function>())`. See [docs](https://supabase.com/docs/guides/database/postgres/row-level-security#call-functions-with-select) for more info.",
|
||||||
|
"remediation": "https://supabase.com/docs/guides/database/database-linter?lint=0003_auth_rls_initplan",
|
||||||
|
"metadata": "{\"name\":\"session_participants\",\"type\":\"table\",\"schema\":\"public\"}",
|
||||||
|
"cache_key": "auth_rls_init_plan_public_session_participants_session_owner_all"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "multiple_permissive_policies",
|
||||||
|
"title": "Multiple Permissive Policies",
|
||||||
|
"level": "WARN",
|
||||||
|
"facing": "EXTERNAL",
|
||||||
|
"categories": "[\"PERFORMANCE\"]",
|
||||||
|
"description": "Detects if multiple permissive row level security policies are present on a table for the same `role` and `action` (e.g. insert). Multiple permissive policies are suboptimal for performance as each policy must be executed for every relevant query.",
|
||||||
|
"detail": "Table `public.bottles` has multiple permissive policies for role `authenticated` for action `DELETE`. Policies include `{\"Relaxed bottles access\",bottles_owner_policy}`",
|
||||||
|
"remediation": "https://supabase.com/docs/guides/database/database-linter?lint=0006_multiple_permissive_policies",
|
||||||
|
"metadata": "{\"name\":\"bottles\",\"type\":\"table\",\"schema\":\"public\"}",
|
||||||
|
"cache_key": "multiple_permissive_policies_public_bottles_authenticated_DELETE"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "multiple_permissive_policies",
|
||||||
|
"title": "Multiple Permissive Policies",
|
||||||
|
"level": "WARN",
|
||||||
|
"facing": "EXTERNAL",
|
||||||
|
"categories": "[\"PERFORMANCE\"]",
|
||||||
|
"description": "Detects if multiple permissive row level security policies are present on a table for the same `role` and `action` (e.g. insert). Multiple permissive policies are suboptimal for performance as each policy must be executed for every relevant query.",
|
||||||
|
"detail": "Table `public.bottles` has multiple permissive policies for role `authenticated` for action `INSERT`. Policies include `{\"Relaxed bottles access\",bottles_owner_policy}`",
|
||||||
|
"remediation": "https://supabase.com/docs/guides/database/database-linter?lint=0006_multiple_permissive_policies",
|
||||||
|
"metadata": "{\"name\":\"bottles\",\"type\":\"table\",\"schema\":\"public\"}",
|
||||||
|
"cache_key": "multiple_permissive_policies_public_bottles_authenticated_INSERT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "multiple_permissive_policies",
|
||||||
|
"title": "Multiple Permissive Policies",
|
||||||
|
"level": "WARN",
|
||||||
|
"facing": "EXTERNAL",
|
||||||
|
"categories": "[\"PERFORMANCE\"]",
|
||||||
|
"description": "Detects if multiple permissive row level security policies are present on a table for the same `role` and `action` (e.g. insert). Multiple permissive policies are suboptimal for performance as each policy must be executed for every relevant query.",
|
||||||
|
"detail": "Table `public.bottles` has multiple permissive policies for role `authenticated` for action `SELECT`. Policies include `{\"Relaxed bottles access\",bottles_owner_policy}`",
|
||||||
|
"remediation": "https://supabase.com/docs/guides/database/database-linter?lint=0006_multiple_permissive_policies",
|
||||||
|
"metadata": "{\"name\":\"bottles\",\"type\":\"table\",\"schema\":\"public\"}",
|
||||||
|
"cache_key": "multiple_permissive_policies_public_bottles_authenticated_SELECT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "multiple_permissive_policies",
|
||||||
|
"title": "Multiple Permissive Policies",
|
||||||
|
"level": "WARN",
|
||||||
|
"facing": "EXTERNAL",
|
||||||
|
"categories": "[\"PERFORMANCE\"]",
|
||||||
|
"description": "Detects if multiple permissive row level security policies are present on a table for the same `role` and `action` (e.g. insert). Multiple permissive policies are suboptimal for performance as each policy must be executed for every relevant query.",
|
||||||
|
"detail": "Table `public.bottles` has multiple permissive policies for role `authenticated` for action `UPDATE`. Policies include `{\"Relaxed bottles access\",bottles_owner_policy}`",
|
||||||
|
"remediation": "https://supabase.com/docs/guides/database/database-linter?lint=0006_multiple_permissive_policies",
|
||||||
|
"metadata": "{\"name\":\"bottles\",\"type\":\"table\",\"schema\":\"public\"}",
|
||||||
|
"cache_key": "multiple_permissive_policies_public_bottles_authenticated_UPDATE"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "multiple_permissive_policies",
|
||||||
|
"title": "Multiple Permissive Policies",
|
||||||
|
"level": "WARN",
|
||||||
|
"facing": "EXTERNAL",
|
||||||
|
"categories": "[\"PERFORMANCE\"]",
|
||||||
|
"description": "Detects if multiple permissive row level security policies are present on a table for the same `role` and `action` (e.g. insert). Multiple permissive policies are suboptimal for performance as each policy must be executed for every relevant query.",
|
||||||
|
"detail": "Table `public.buddies` has multiple permissive policies for role `anon` for action `DELETE`. Policies include `{\"Manage own buddies\",buddies_access_policy}`",
|
||||||
|
"remediation": "https://supabase.com/docs/guides/database/database-linter?lint=0006_multiple_permissive_policies",
|
||||||
|
"metadata": "{\"name\":\"buddies\",\"type\":\"table\",\"schema\":\"public\"}",
|
||||||
|
"cache_key": "multiple_permissive_policies_public_buddies_anon_DELETE"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "multiple_permissive_policies",
|
||||||
|
"title": "Multiple Permissive Policies",
|
||||||
|
"level": "WARN",
|
||||||
|
"facing": "EXTERNAL",
|
||||||
|
"categories": "[\"PERFORMANCE\"]",
|
||||||
|
"description": "Detects if multiple permissive row level security policies are present on a table for the same `role` and `action` (e.g. insert). Multiple permissive policies are suboptimal for performance as each policy must be executed for every relevant query.",
|
||||||
|
"detail": "Table `public.buddies` has multiple permissive policies for role `anon` for action `INSERT`. Policies include `{\"Manage own buddies\",buddies_access_policy}`",
|
||||||
|
"remediation": "https://supabase.com/docs/guides/database/database-linter?lint=0006_multiple_permissive_policies",
|
||||||
|
"metadata": "{\"name\":\"buddies\",\"type\":\"table\",\"schema\":\"public\"}",
|
||||||
|
"cache_key": "multiple_permissive_policies_public_buddies_anon_INSERT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "multiple_permissive_policies",
|
||||||
|
"title": "Multiple Permissive Policies",
|
||||||
|
"level": "WARN",
|
||||||
|
"facing": "EXTERNAL",
|
||||||
|
"categories": "[\"PERFORMANCE\"]",
|
||||||
|
"description": "Detects if multiple permissive row level security policies are present on a table for the same `role` and `action` (e.g. insert). Multiple permissive policies are suboptimal for performance as each policy must be executed for every relevant query.",
|
||||||
|
"detail": "Table `public.buddies` has multiple permissive policies for role `anon` for action `SELECT`. Policies include `{\"Manage own buddies\",buddies_access_policy}`",
|
||||||
|
"remediation": "https://supabase.com/docs/guides/database/database-linter?lint=0006_multiple_permissive_policies",
|
||||||
|
"metadata": "{\"name\":\"buddies\",\"type\":\"table\",\"schema\":\"public\"}",
|
||||||
|
"cache_key": "multiple_permissive_policies_public_buddies_anon_SELECT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "multiple_permissive_policies",
|
||||||
|
"title": "Multiple Permissive Policies",
|
||||||
|
"level": "WARN",
|
||||||
|
"facing": "EXTERNAL",
|
||||||
|
"categories": "[\"PERFORMANCE\"]",
|
||||||
|
"description": "Detects if multiple permissive row level security policies are present on a table for the same `role` and `action` (e.g. insert). Multiple permissive policies are suboptimal for performance as each policy must be executed for every relevant query.",
|
||||||
|
"detail": "Table `public.buddies` has multiple permissive policies for role `anon` for action `UPDATE`. Policies include `{\"Manage own buddies\",buddies_access_policy}`",
|
||||||
|
"remediation": "https://supabase.com/docs/guides/database/database-linter?lint=0006_multiple_permissive_policies",
|
||||||
|
"metadata": "{\"name\":\"buddies\",\"type\":\"table\",\"schema\":\"public\"}",
|
||||||
|
"cache_key": "multiple_permissive_policies_public_buddies_anon_UPDATE"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "multiple_permissive_policies",
|
||||||
|
"title": "Multiple Permissive Policies",
|
||||||
|
"level": "WARN",
|
||||||
|
"facing": "EXTERNAL",
|
||||||
|
"categories": "[\"PERFORMANCE\"]",
|
||||||
|
"description": "Detects if multiple permissive row level security policies are present on a table for the same `role` and `action` (e.g. insert). Multiple permissive policies are suboptimal for performance as each policy must be executed for every relevant query.",
|
||||||
|
"detail": "Table `public.buddies` has multiple permissive policies for role `authenticated` for action `DELETE`. Policies include `{\"Manage own buddies\",buddies_access_policy}`",
|
||||||
|
"remediation": "https://supabase.com/docs/guides/database/database-linter?lint=0006_multiple_permissive_policies",
|
||||||
|
"metadata": "{\"name\":\"buddies\",\"type\":\"table\",\"schema\":\"public\"}",
|
||||||
|
"cache_key": "multiple_permissive_policies_public_buddies_authenticated_DELETE"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "multiple_permissive_policies",
|
||||||
|
"title": "Multiple Permissive Policies",
|
||||||
|
"level": "WARN",
|
||||||
|
"facing": "EXTERNAL",
|
||||||
|
"categories": "[\"PERFORMANCE\"]",
|
||||||
|
"description": "Detects if multiple permissive row level security policies are present on a table for the same `role` and `action` (e.g. insert). Multiple permissive policies are suboptimal for performance as each policy must be executed for every relevant query.",
|
||||||
|
"detail": "Table `public.buddies` has multiple permissive policies for role `authenticated` for action `INSERT`. Policies include `{\"Manage own buddies\",buddies_access_policy}`",
|
||||||
|
"remediation": "https://supabase.com/docs/guides/database/database-linter?lint=0006_multiple_permissive_policies",
|
||||||
|
"metadata": "{\"name\":\"buddies\",\"type\":\"table\",\"schema\":\"public\"}",
|
||||||
|
"cache_key": "multiple_permissive_policies_public_buddies_authenticated_INSERT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "multiple_permissive_policies",
|
||||||
|
"title": "Multiple Permissive Policies",
|
||||||
|
"level": "WARN",
|
||||||
|
"facing": "EXTERNAL",
|
||||||
|
"categories": "[\"PERFORMANCE\"]",
|
||||||
|
"description": "Detects if multiple permissive row level security policies are present on a table for the same `role` and `action` (e.g. insert). Multiple permissive policies are suboptimal for performance as each policy must be executed for every relevant query.",
|
||||||
|
"detail": "Table `public.buddies` has multiple permissive policies for role `authenticated` for action `SELECT`. Policies include `{\"Manage own buddies\",buddies_access_policy}`",
|
||||||
|
"remediation": "https://supabase.com/docs/guides/database/database-linter?lint=0006_multiple_permissive_policies",
|
||||||
|
"metadata": "{\"name\":\"buddies\",\"type\":\"table\",\"schema\":\"public\"}",
|
||||||
|
"cache_key": "multiple_permissive_policies_public_buddies_authenticated_SELECT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "multiple_permissive_policies",
|
||||||
|
"title": "Multiple Permissive Policies",
|
||||||
|
"level": "WARN",
|
||||||
|
"facing": "EXTERNAL",
|
||||||
|
"categories": "[\"PERFORMANCE\"]",
|
||||||
|
"description": "Detects if multiple permissive row level security policies are present on a table for the same `role` and `action` (e.g. insert). Multiple permissive policies are suboptimal for performance as each policy must be executed for every relevant query.",
|
||||||
|
"detail": "Table `public.buddies` has multiple permissive policies for role `authenticated` for action `UPDATE`. Policies include `{\"Manage own buddies\",buddies_access_policy}`",
|
||||||
|
"remediation": "https://supabase.com/docs/guides/database/database-linter?lint=0006_multiple_permissive_policies",
|
||||||
|
"metadata": "{\"name\":\"buddies\",\"type\":\"table\",\"schema\":\"public\"}",
|
||||||
|
"cache_key": "multiple_permissive_policies_public_buddies_authenticated_UPDATE"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "multiple_permissive_policies",
|
||||||
|
"title": "Multiple Permissive Policies",
|
||||||
|
"level": "WARN",
|
||||||
|
"facing": "EXTERNAL",
|
||||||
|
"categories": "[\"PERFORMANCE\"]",
|
||||||
|
"description": "Detects if multiple permissive row level security policies are present on a table for the same `role` and `action` (e.g. insert). Multiple permissive policies are suboptimal for performance as each policy must be executed for every relevant query.",
|
||||||
|
"detail": "Table `public.buddies` has multiple permissive policies for role `authenticator` for action `DELETE`. Policies include `{\"Manage own buddies\",buddies_access_policy}`",
|
||||||
|
"remediation": "https://supabase.com/docs/guides/database/database-linter?lint=0006_multiple_permissive_policies",
|
||||||
|
"metadata": "{\"name\":\"buddies\",\"type\":\"table\",\"schema\":\"public\"}",
|
||||||
|
"cache_key": "multiple_permissive_policies_public_buddies_authenticator_DELETE"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "multiple_permissive_policies",
|
||||||
|
"title": "Multiple Permissive Policies",
|
||||||
|
"level": "WARN",
|
||||||
|
"facing": "EXTERNAL",
|
||||||
|
"categories": "[\"PERFORMANCE\"]",
|
||||||
|
"description": "Detects if multiple permissive row level security policies are present on a table for the same `role` and `action` (e.g. insert). Multiple permissive policies are suboptimal for performance as each policy must be executed for every relevant query.",
|
||||||
|
"detail": "Table `public.buddies` has multiple permissive policies for role `authenticator` for action `INSERT`. Policies include `{\"Manage own buddies\",buddies_access_policy}`",
|
||||||
|
"remediation": "https://supabase.com/docs/guides/database/database-linter?lint=0006_multiple_permissive_policies",
|
||||||
|
"metadata": "{\"name\":\"buddies\",\"type\":\"table\",\"schema\":\"public\"}",
|
||||||
|
"cache_key": "multiple_permissive_policies_public_buddies_authenticator_INSERT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "multiple_permissive_policies",
|
||||||
|
"title": "Multiple Permissive Policies",
|
||||||
|
"level": "WARN",
|
||||||
|
"facing": "EXTERNAL",
|
||||||
|
"categories": "[\"PERFORMANCE\"]",
|
||||||
|
"description": "Detects if multiple permissive row level security policies are present on a table for the same `role` and `action` (e.g. insert). Multiple permissive policies are suboptimal for performance as each policy must be executed for every relevant query.",
|
||||||
|
"detail": "Table `public.buddies` has multiple permissive policies for role `authenticator` for action `SELECT`. Policies include `{\"Manage own buddies\",buddies_access_policy}`",
|
||||||
|
"remediation": "https://supabase.com/docs/guides/database/database-linter?lint=0006_multiple_permissive_policies",
|
||||||
|
"metadata": "{\"name\":\"buddies\",\"type\":\"table\",\"schema\":\"public\"}",
|
||||||
|
"cache_key": "multiple_permissive_policies_public_buddies_authenticator_SELECT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "multiple_permissive_policies",
|
||||||
|
"title": "Multiple Permissive Policies",
|
||||||
|
"level": "WARN",
|
||||||
|
"facing": "EXTERNAL",
|
||||||
|
"categories": "[\"PERFORMANCE\"]",
|
||||||
|
"description": "Detects if multiple permissive row level security policies are present on a table for the same `role` and `action` (e.g. insert). Multiple permissive policies are suboptimal for performance as each policy must be executed for every relevant query.",
|
||||||
|
"detail": "Table `public.buddies` has multiple permissive policies for role `authenticator` for action `UPDATE`. Policies include `{\"Manage own buddies\",buddies_access_policy}`",
|
||||||
|
"remediation": "https://supabase.com/docs/guides/database/database-linter?lint=0006_multiple_permissive_policies",
|
||||||
|
"metadata": "{\"name\":\"buddies\",\"type\":\"table\",\"schema\":\"public\"}",
|
||||||
|
"cache_key": "multiple_permissive_policies_public_buddies_authenticator_UPDATE"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "multiple_permissive_policies",
|
||||||
|
"title": "Multiple Permissive Policies",
|
||||||
|
"level": "WARN",
|
||||||
|
"facing": "EXTERNAL",
|
||||||
|
"categories": "[\"PERFORMANCE\"]",
|
||||||
|
"description": "Detects if multiple permissive row level security policies are present on a table for the same `role` and `action` (e.g. insert). Multiple permissive policies are suboptimal for performance as each policy must be executed for every relevant query.",
|
||||||
|
"detail": "Table `public.buddies` has multiple permissive policies for role `dashboard_user` for action `DELETE`. Policies include `{\"Manage own buddies\",buddies_access_policy}`",
|
||||||
|
"remediation": "https://supabase.com/docs/guides/database/database-linter?lint=0006_multiple_permissive_policies",
|
||||||
|
"metadata": "{\"name\":\"buddies\",\"type\":\"table\",\"schema\":\"public\"}",
|
||||||
|
"cache_key": "multiple_permissive_policies_public_buddies_dashboard_user_DELETE"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "multiple_permissive_policies",
|
||||||
|
"title": "Multiple Permissive Policies",
|
||||||
|
"level": "WARN",
|
||||||
|
"facing": "EXTERNAL",
|
||||||
|
"categories": "[\"PERFORMANCE\"]",
|
||||||
|
"description": "Detects if multiple permissive row level security policies are present on a table for the same `role` and `action` (e.g. insert). Multiple permissive policies are suboptimal for performance as each policy must be executed for every relevant query.",
|
||||||
|
"detail": "Table `public.buddies` has multiple permissive policies for role `dashboard_user` for action `INSERT`. Policies include `{\"Manage own buddies\",buddies_access_policy}`",
|
||||||
|
"remediation": "https://supabase.com/docs/guides/database/database-linter?lint=0006_multiple_permissive_policies",
|
||||||
|
"metadata": "{\"name\":\"buddies\",\"type\":\"table\",\"schema\":\"public\"}",
|
||||||
|
"cache_key": "multiple_permissive_policies_public_buddies_dashboard_user_INSERT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "multiple_permissive_policies",
|
||||||
|
"title": "Multiple Permissive Policies",
|
||||||
|
"level": "WARN",
|
||||||
|
"facing": "EXTERNAL",
|
||||||
|
"categories": "[\"PERFORMANCE\"]",
|
||||||
|
"description": "Detects if multiple permissive row level security policies are present on a table for the same `role` and `action` (e.g. insert). Multiple permissive policies are suboptimal for performance as each policy must be executed for every relevant query.",
|
||||||
|
"detail": "Table `public.buddies` has multiple permissive policies for role `dashboard_user` for action `SELECT`. Policies include `{\"Manage own buddies\",buddies_access_policy}`",
|
||||||
|
"remediation": "https://supabase.com/docs/guides/database/database-linter?lint=0006_multiple_permissive_policies",
|
||||||
|
"metadata": "{\"name\":\"buddies\",\"type\":\"table\",\"schema\":\"public\"}",
|
||||||
|
"cache_key": "multiple_permissive_policies_public_buddies_dashboard_user_SELECT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "multiple_permissive_policies",
|
||||||
|
"title": "Multiple Permissive Policies",
|
||||||
|
"level": "WARN",
|
||||||
|
"facing": "EXTERNAL",
|
||||||
|
"categories": "[\"PERFORMANCE\"]",
|
||||||
|
"description": "Detects if multiple permissive row level security policies are present on a table for the same `role` and `action` (e.g. insert). Multiple permissive policies are suboptimal for performance as each policy must be executed for every relevant query.",
|
||||||
|
"detail": "Table `public.buddies` has multiple permissive policies for role `dashboard_user` for action `UPDATE`. Policies include `{\"Manage own buddies\",buddies_access_policy}`",
|
||||||
|
"remediation": "https://supabase.com/docs/guides/database/database-linter?lint=0006_multiple_permissive_policies",
|
||||||
|
"metadata": "{\"name\":\"buddies\",\"type\":\"table\",\"schema\":\"public\"}",
|
||||||
|
"cache_key": "multiple_permissive_policies_public_buddies_dashboard_user_UPDATE"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "multiple_permissive_policies",
|
||||||
|
"title": "Multiple Permissive Policies",
|
||||||
|
"level": "WARN",
|
||||||
|
"facing": "EXTERNAL",
|
||||||
|
"categories": "[\"PERFORMANCE\"]",
|
||||||
|
"description": "Detects if multiple permissive row level security policies are present on a table for the same `role` and `action` (e.g. insert). Multiple permissive policies are suboptimal for performance as each policy must be executed for every relevant query.",
|
||||||
|
"detail": "Table `public.session_participants` has multiple permissive policies for role `anon` for action `DELETE`. Policies include `{session_owner_all,session_participants_owner_policy}`",
|
||||||
|
"remediation": "https://supabase.com/docs/guides/database/database-linter?lint=0006_multiple_permissive_policies",
|
||||||
|
"metadata": "{\"name\":\"session_participants\",\"type\":\"table\",\"schema\":\"public\"}",
|
||||||
|
"cache_key": "multiple_permissive_policies_public_session_participants_anon_DELETE"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "multiple_permissive_policies",
|
||||||
|
"title": "Multiple Permissive Policies",
|
||||||
|
"level": "WARN",
|
||||||
|
"facing": "EXTERNAL",
|
||||||
|
"categories": "[\"PERFORMANCE\"]",
|
||||||
|
"description": "Detects if multiple permissive row level security policies are present on a table for the same `role` and `action` (e.g. insert). Multiple permissive policies are suboptimal for performance as each policy must be executed for every relevant query.",
|
||||||
|
"detail": "Table `public.session_participants` has multiple permissive policies for role `anon` for action `INSERT`. Policies include `{session_owner_all,session_participants_owner_policy}`",
|
||||||
|
"remediation": "https://supabase.com/docs/guides/database/database-linter?lint=0006_multiple_permissive_policies",
|
||||||
|
"metadata": "{\"name\":\"session_participants\",\"type\":\"table\",\"schema\":\"public\"}",
|
||||||
|
"cache_key": "multiple_permissive_policies_public_session_participants_anon_INSERT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "multiple_permissive_policies",
|
||||||
|
"title": "Multiple Permissive Policies",
|
||||||
|
"level": "WARN",
|
||||||
|
"facing": "EXTERNAL",
|
||||||
|
"categories": "[\"PERFORMANCE\"]",
|
||||||
|
"description": "Detects if multiple permissive row level security policies are present on a table for the same `role` and `action` (e.g. insert). Multiple permissive policies are suboptimal for performance as each policy must be executed for every relevant query.",
|
||||||
|
"detail": "Table `public.session_participants` has multiple permissive policies for role `anon` for action `SELECT`. Policies include `{session_owner_all,session_participants_owner_policy}`",
|
||||||
|
"remediation": "https://supabase.com/docs/guides/database/database-linter?lint=0006_multiple_permissive_policies",
|
||||||
|
"metadata": "{\"name\":\"session_participants\",\"type\":\"table\",\"schema\":\"public\"}",
|
||||||
|
"cache_key": "multiple_permissive_policies_public_session_participants_anon_SELECT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "multiple_permissive_policies",
|
||||||
|
"title": "Multiple Permissive Policies",
|
||||||
|
"level": "WARN",
|
||||||
|
"facing": "EXTERNAL",
|
||||||
|
"categories": "[\"PERFORMANCE\"]",
|
||||||
|
"description": "Detects if multiple permissive row level security policies are present on a table for the same `role` and `action` (e.g. insert). Multiple permissive policies are suboptimal for performance as each policy must be executed for every relevant query.",
|
||||||
|
"detail": "Table `public.session_participants` has multiple permissive policies for role `anon` for action `UPDATE`. Policies include `{session_owner_all,session_participants_owner_policy}`",
|
||||||
|
"remediation": "https://supabase.com/docs/guides/database/database-linter?lint=0006_multiple_permissive_policies",
|
||||||
|
"metadata": "{\"name\":\"session_participants\",\"type\":\"table\",\"schema\":\"public\"}",
|
||||||
|
"cache_key": "multiple_permissive_policies_public_session_participants_anon_UPDATE"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "multiple_permissive_policies",
|
||||||
|
"title": "Multiple Permissive Policies",
|
||||||
|
"level": "WARN",
|
||||||
|
"facing": "EXTERNAL",
|
||||||
|
"categories": "[\"PERFORMANCE\"]",
|
||||||
|
"description": "Detects if multiple permissive row level security policies are present on a table for the same `role` and `action` (e.g. insert). Multiple permissive policies are suboptimal for performance as each policy must be executed for every relevant query.",
|
||||||
|
"detail": "Table `public.session_participants` has multiple permissive policies for role `authenticated` for action `DELETE`. Policies include `{session_owner_all,session_participants_owner_policy}`",
|
||||||
|
"remediation": "https://supabase.com/docs/guides/database/database-linter?lint=0006_multiple_permissive_policies",
|
||||||
|
"metadata": "{\"name\":\"session_participants\",\"type\":\"table\",\"schema\":\"public\"}",
|
||||||
|
"cache_key": "multiple_permissive_policies_public_session_participants_authenticated_DELETE"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "multiple_permissive_policies",
|
||||||
|
"title": "Multiple Permissive Policies",
|
||||||
|
"level": "WARN",
|
||||||
|
"facing": "EXTERNAL",
|
||||||
|
"categories": "[\"PERFORMANCE\"]",
|
||||||
|
"description": "Detects if multiple permissive row level security policies are present on a table for the same `role` and `action` (e.g. insert). Multiple permissive policies are suboptimal for performance as each policy must be executed for every relevant query.",
|
||||||
|
"detail": "Table `public.session_participants` has multiple permissive policies for role `authenticated` for action `INSERT`. Policies include `{session_owner_all,session_participants_owner_policy}`",
|
||||||
|
"remediation": "https://supabase.com/docs/guides/database/database-linter?lint=0006_multiple_permissive_policies",
|
||||||
|
"metadata": "{\"name\":\"session_participants\",\"type\":\"table\",\"schema\":\"public\"}",
|
||||||
|
"cache_key": "multiple_permissive_policies_public_session_participants_authenticated_INSERT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "multiple_permissive_policies",
|
||||||
|
"title": "Multiple Permissive Policies",
|
||||||
|
"level": "WARN",
|
||||||
|
"facing": "EXTERNAL",
|
||||||
|
"categories": "[\"PERFORMANCE\"]",
|
||||||
|
"description": "Detects if multiple permissive row level security policies are present on a table for the same `role` and `action` (e.g. insert). Multiple permissive policies are suboptimal for performance as each policy must be executed for every relevant query.",
|
||||||
|
"detail": "Table `public.session_participants` has multiple permissive policies for role `authenticated` for action `SELECT`. Policies include `{session_owner_all,session_participants_owner_policy}`",
|
||||||
|
"remediation": "https://supabase.com/docs/guides/database/database-linter?lint=0006_multiple_permissive_policies",
|
||||||
|
"metadata": "{\"name\":\"session_participants\",\"type\":\"table\",\"schema\":\"public\"}",
|
||||||
|
"cache_key": "multiple_permissive_policies_public_session_participants_authenticated_SELECT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "multiple_permissive_policies",
|
||||||
|
"title": "Multiple Permissive Policies",
|
||||||
|
"level": "WARN",
|
||||||
|
"facing": "EXTERNAL",
|
||||||
|
"categories": "[\"PERFORMANCE\"]",
|
||||||
|
"description": "Detects if multiple permissive row level security policies are present on a table for the same `role` and `action` (e.g. insert). Multiple permissive policies are suboptimal for performance as each policy must be executed for every relevant query.",
|
||||||
|
"detail": "Table `public.session_participants` has multiple permissive policies for role `authenticated` for action `UPDATE`. Policies include `{session_owner_all,session_participants_owner_policy}`",
|
||||||
|
"remediation": "https://supabase.com/docs/guides/database/database-linter?lint=0006_multiple_permissive_policies",
|
||||||
|
"metadata": "{\"name\":\"session_participants\",\"type\":\"table\",\"schema\":\"public\"}",
|
||||||
|
"cache_key": "multiple_permissive_policies_public_session_participants_authenticated_UPDATE"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "multiple_permissive_policies",
|
||||||
|
"title": "Multiple Permissive Policies",
|
||||||
|
"level": "WARN",
|
||||||
|
"facing": "EXTERNAL",
|
||||||
|
"categories": "[\"PERFORMANCE\"]",
|
||||||
|
"description": "Detects if multiple permissive row level security policies are present on a table for the same `role` and `action` (e.g. insert). Multiple permissive policies are suboptimal for performance as each policy must be executed for every relevant query.",
|
||||||
|
"detail": "Table `public.session_participants` has multiple permissive policies for role `authenticator` for action `DELETE`. Policies include `{session_owner_all,session_participants_owner_policy}`",
|
||||||
|
"remediation": "https://supabase.com/docs/guides/database/database-linter?lint=0006_multiple_permissive_policies",
|
||||||
|
"metadata": "{\"name\":\"session_participants\",\"type\":\"table\",\"schema\":\"public\"}",
|
||||||
|
"cache_key": "multiple_permissive_policies_public_session_participants_authenticator_DELETE"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "multiple_permissive_policies",
|
||||||
|
"title": "Multiple Permissive Policies",
|
||||||
|
"level": "WARN",
|
||||||
|
"facing": "EXTERNAL",
|
||||||
|
"categories": "[\"PERFORMANCE\"]",
|
||||||
|
"description": "Detects if multiple permissive row level security policies are present on a table for the same `role` and `action` (e.g. insert). Multiple permissive policies are suboptimal for performance as each policy must be executed for every relevant query.",
|
||||||
|
"detail": "Table `public.session_participants` has multiple permissive policies for role `authenticator` for action `INSERT`. Policies include `{session_owner_all,session_participants_owner_policy}`",
|
||||||
|
"remediation": "https://supabase.com/docs/guides/database/database-linter?lint=0006_multiple_permissive_policies",
|
||||||
|
"metadata": "{\"name\":\"session_participants\",\"type\":\"table\",\"schema\":\"public\"}",
|
||||||
|
"cache_key": "multiple_permissive_policies_public_session_participants_authenticator_INSERT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "multiple_permissive_policies",
|
||||||
|
"title": "Multiple Permissive Policies",
|
||||||
|
"level": "WARN",
|
||||||
|
"facing": "EXTERNAL",
|
||||||
|
"categories": "[\"PERFORMANCE\"]",
|
||||||
|
"description": "Detects if multiple permissive row level security policies are present on a table for the same `role` and `action` (e.g. insert). Multiple permissive policies are suboptimal for performance as each policy must be executed for every relevant query.",
|
||||||
|
"detail": "Table `public.session_participants` has multiple permissive policies for role `authenticator` for action `SELECT`. Policies include `{session_owner_all,session_participants_owner_policy}`",
|
||||||
|
"remediation": "https://supabase.com/docs/guides/database/database-linter?lint=0006_multiple_permissive_policies",
|
||||||
|
"metadata": "{\"name\":\"session_participants\",\"type\":\"table\",\"schema\":\"public\"}",
|
||||||
|
"cache_key": "multiple_permissive_policies_public_session_participants_authenticator_SELECT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "multiple_permissive_policies",
|
||||||
|
"title": "Multiple Permissive Policies",
|
||||||
|
"level": "WARN",
|
||||||
|
"facing": "EXTERNAL",
|
||||||
|
"categories": "[\"PERFORMANCE\"]",
|
||||||
|
"description": "Detects if multiple permissive row level security policies are present on a table for the same `role` and `action` (e.g. insert). Multiple permissive policies are suboptimal for performance as each policy must be executed for every relevant query.",
|
||||||
|
"detail": "Table `public.session_participants` has multiple permissive policies for role `authenticator` for action `UPDATE`. Policies include `{session_owner_all,session_participants_owner_policy}`",
|
||||||
|
"remediation": "https://supabase.com/docs/guides/database/database-linter?lint=0006_multiple_permissive_policies",
|
||||||
|
"metadata": "{\"name\":\"session_participants\",\"type\":\"table\",\"schema\":\"public\"}",
|
||||||
|
"cache_key": "multiple_permissive_policies_public_session_participants_authenticator_UPDATE"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "multiple_permissive_policies",
|
||||||
|
"title": "Multiple Permissive Policies",
|
||||||
|
"level": "WARN",
|
||||||
|
"facing": "EXTERNAL",
|
||||||
|
"categories": "[\"PERFORMANCE\"]",
|
||||||
|
"description": "Detects if multiple permissive row level security policies are present on a table for the same `role` and `action` (e.g. insert). Multiple permissive policies are suboptimal for performance as each policy must be executed for every relevant query.",
|
||||||
|
"detail": "Table `public.session_participants` has multiple permissive policies for role `dashboard_user` for action `DELETE`. Policies include `{session_owner_all,session_participants_owner_policy}`",
|
||||||
|
"remediation": "https://supabase.com/docs/guides/database/database-linter?lint=0006_multiple_permissive_policies",
|
||||||
|
"metadata": "{\"name\":\"session_participants\",\"type\":\"table\",\"schema\":\"public\"}",
|
||||||
|
"cache_key": "multiple_permissive_policies_public_session_participants_dashboard_user_DELETE"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "multiple_permissive_policies",
|
||||||
|
"title": "Multiple Permissive Policies",
|
||||||
|
"level": "WARN",
|
||||||
|
"facing": "EXTERNAL",
|
||||||
|
"categories": "[\"PERFORMANCE\"]",
|
||||||
|
"description": "Detects if multiple permissive row level security policies are present on a table for the same `role` and `action` (e.g. insert). Multiple permissive policies are suboptimal for performance as each policy must be executed for every relevant query.",
|
||||||
|
"detail": "Table `public.session_participants` has multiple permissive policies for role `dashboard_user` for action `INSERT`. Policies include `{session_owner_all,session_participants_owner_policy}`",
|
||||||
|
"remediation": "https://supabase.com/docs/guides/database/database-linter?lint=0006_multiple_permissive_policies",
|
||||||
|
"metadata": "{\"name\":\"session_participants\",\"type\":\"table\",\"schema\":\"public\"}",
|
||||||
|
"cache_key": "multiple_permissive_policies_public_session_participants_dashboard_user_INSERT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "multiple_permissive_policies",
|
||||||
|
"title": "Multiple Permissive Policies",
|
||||||
|
"level": "WARN",
|
||||||
|
"facing": "EXTERNAL",
|
||||||
|
"categories": "[\"PERFORMANCE\"]",
|
||||||
|
"description": "Detects if multiple permissive row level security policies are present on a table for the same `role` and `action` (e.g. insert). Multiple permissive policies are suboptimal for performance as each policy must be executed for every relevant query.",
|
||||||
|
"detail": "Table `public.session_participants` has multiple permissive policies for role `dashboard_user` for action `SELECT`. Policies include `{session_owner_all,session_participants_owner_policy}`",
|
||||||
|
"remediation": "https://supabase.com/docs/guides/database/database-linter?lint=0006_multiple_permissive_policies",
|
||||||
|
"metadata": "{\"name\":\"session_participants\",\"type\":\"table\",\"schema\":\"public\"}",
|
||||||
|
"cache_key": "multiple_permissive_policies_public_session_participants_dashboard_user_SELECT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "multiple_permissive_policies",
|
||||||
|
"title": "Multiple Permissive Policies",
|
||||||
|
"level": "WARN",
|
||||||
|
"facing": "EXTERNAL",
|
||||||
|
"categories": "[\"PERFORMANCE\"]",
|
||||||
|
"description": "Detects if multiple permissive row level security policies are present on a table for the same `role` and `action` (e.g. insert). Multiple permissive policies are suboptimal for performance as each policy must be executed for every relevant query.",
|
||||||
|
"detail": "Table `public.session_participants` has multiple permissive policies for role `dashboard_user` for action `UPDATE`. Policies include `{session_owner_all,session_participants_owner_policy}`",
|
||||||
|
"remediation": "https://supabase.com/docs/guides/database/database-linter?lint=0006_multiple_permissive_policies",
|
||||||
|
"metadata": "{\"name\":\"session_participants\",\"type\":\"table\",\"schema\":\"public\"}",
|
||||||
|
"cache_key": "multiple_permissive_policies_public_session_participants_dashboard_user_UPDATE"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "multiple_permissive_policies",
|
||||||
|
"title": "Multiple Permissive Policies",
|
||||||
|
"level": "WARN",
|
||||||
|
"facing": "EXTERNAL",
|
||||||
|
"categories": "[\"PERFORMANCE\"]",
|
||||||
|
"description": "Detects if multiple permissive row level security policies are present on a table for the same `role` and `action` (e.g. insert). Multiple permissive policies are suboptimal for performance as each policy must be executed for every relevant query.",
|
||||||
|
"detail": "Table `public.tasting_sessions` has multiple permissive policies for role `anon` for action `DELETE`. Policies include `{\"Manage own sessions\",session_access_policy}`",
|
||||||
|
"remediation": "https://supabase.com/docs/guides/database/database-linter?lint=0006_multiple_permissive_policies",
|
||||||
|
"metadata": "{\"name\":\"tasting_sessions\",\"type\":\"table\",\"schema\":\"public\"}",
|
||||||
|
"cache_key": "multiple_permissive_policies_public_tasting_sessions_anon_DELETE"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "multiple_permissive_policies",
|
||||||
|
"title": "Multiple Permissive Policies",
|
||||||
|
"level": "WARN",
|
||||||
|
"facing": "EXTERNAL",
|
||||||
|
"categories": "[\"PERFORMANCE\"]",
|
||||||
|
"description": "Detects if multiple permissive row level security policies are present on a table for the same `role` and `action` (e.g. insert). Multiple permissive policies are suboptimal for performance as each policy must be executed for every relevant query.",
|
||||||
|
"detail": "Table `public.tasting_sessions` has multiple permissive policies for role `anon` for action `INSERT`. Policies include `{\"Manage own sessions\",session_access_policy}`",
|
||||||
|
"remediation": "https://supabase.com/docs/guides/database/database-linter?lint=0006_multiple_permissive_policies",
|
||||||
|
"metadata": "{\"name\":\"tasting_sessions\",\"type\":\"table\",\"schema\":\"public\"}",
|
||||||
|
"cache_key": "multiple_permissive_policies_public_tasting_sessions_anon_INSERT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "multiple_permissive_policies",
|
||||||
|
"title": "Multiple Permissive Policies",
|
||||||
|
"level": "WARN",
|
||||||
|
"facing": "EXTERNAL",
|
||||||
|
"categories": "[\"PERFORMANCE\"]",
|
||||||
|
"description": "Detects if multiple permissive row level security policies are present on a table for the same `role` and `action` (e.g. insert). Multiple permissive policies are suboptimal for performance as each policy must be executed for every relevant query.",
|
||||||
|
"detail": "Table `public.tasting_sessions` has multiple permissive policies for role `anon` for action `SELECT`. Policies include `{\"Manage own sessions\",session_access_policy}`",
|
||||||
|
"remediation": "https://supabase.com/docs/guides/database/database-linter?lint=0006_multiple_permissive_policies",
|
||||||
|
"metadata": "{\"name\":\"tasting_sessions\",\"type\":\"table\",\"schema\":\"public\"}",
|
||||||
|
"cache_key": "multiple_permissive_policies_public_tasting_sessions_anon_SELECT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "multiple_permissive_policies",
|
||||||
|
"title": "Multiple Permissive Policies",
|
||||||
|
"level": "WARN",
|
||||||
|
"facing": "EXTERNAL",
|
||||||
|
"categories": "[\"PERFORMANCE\"]",
|
||||||
|
"description": "Detects if multiple permissive row level security policies are present on a table for the same `role` and `action` (e.g. insert). Multiple permissive policies are suboptimal for performance as each policy must be executed for every relevant query.",
|
||||||
|
"detail": "Table `public.tasting_sessions` has multiple permissive policies for role `anon` for action `UPDATE`. Policies include `{\"Manage own sessions\",session_access_policy}`",
|
||||||
|
"remediation": "https://supabase.com/docs/guides/database/database-linter?lint=0006_multiple_permissive_policies",
|
||||||
|
"metadata": "{\"name\":\"tasting_sessions\",\"type\":\"table\",\"schema\":\"public\"}",
|
||||||
|
"cache_key": "multiple_permissive_policies_public_tasting_sessions_anon_UPDATE"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "multiple_permissive_policies",
|
||||||
|
"title": "Multiple Permissive Policies",
|
||||||
|
"level": "WARN",
|
||||||
|
"facing": "EXTERNAL",
|
||||||
|
"categories": "[\"PERFORMANCE\"]",
|
||||||
|
"description": "Detects if multiple permissive row level security policies are present on a table for the same `role` and `action` (e.g. insert). Multiple permissive policies are suboptimal for performance as each policy must be executed for every relevant query.",
|
||||||
|
"detail": "Table `public.tasting_sessions` has multiple permissive policies for role `authenticated` for action `DELETE`. Policies include `{\"Manage own sessions\",session_access_policy}`",
|
||||||
|
"remediation": "https://supabase.com/docs/guides/database/database-linter?lint=0006_multiple_permissive_policies",
|
||||||
|
"metadata": "{\"name\":\"tasting_sessions\",\"type\":\"table\",\"schema\":\"public\"}",
|
||||||
|
"cache_key": "multiple_permissive_policies_public_tasting_sessions_authenticated_DELETE"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "multiple_permissive_policies",
|
||||||
|
"title": "Multiple Permissive Policies",
|
||||||
|
"level": "WARN",
|
||||||
|
"facing": "EXTERNAL",
|
||||||
|
"categories": "[\"PERFORMANCE\"]",
|
||||||
|
"description": "Detects if multiple permissive row level security policies are present on a table for the same `role` and `action` (e.g. insert). Multiple permissive policies are suboptimal for performance as each policy must be executed for every relevant query.",
|
||||||
|
"detail": "Table `public.tasting_sessions` has multiple permissive policies for role `authenticated` for action `INSERT`. Policies include `{\"Manage own sessions\",session_access_policy}`",
|
||||||
|
"remediation": "https://supabase.com/docs/guides/database/database-linter?lint=0006_multiple_permissive_policies",
|
||||||
|
"metadata": "{\"name\":\"tasting_sessions\",\"type\":\"table\",\"schema\":\"public\"}",
|
||||||
|
"cache_key": "multiple_permissive_policies_public_tasting_sessions_authenticated_INSERT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "multiple_permissive_policies",
|
||||||
|
"title": "Multiple Permissive Policies",
|
||||||
|
"level": "WARN",
|
||||||
|
"facing": "EXTERNAL",
|
||||||
|
"categories": "[\"PERFORMANCE\"]",
|
||||||
|
"description": "Detects if multiple permissive row level security policies are present on a table for the same `role` and `action` (e.g. insert). Multiple permissive policies are suboptimal for performance as each policy must be executed for every relevant query.",
|
||||||
|
"detail": "Table `public.tasting_sessions` has multiple permissive policies for role `authenticated` for action `SELECT`. Policies include `{\"Manage own sessions\",session_access_policy}`",
|
||||||
|
"remediation": "https://supabase.com/docs/guides/database/database-linter?lint=0006_multiple_permissive_policies",
|
||||||
|
"metadata": "{\"name\":\"tasting_sessions\",\"type\":\"table\",\"schema\":\"public\"}",
|
||||||
|
"cache_key": "multiple_permissive_policies_public_tasting_sessions_authenticated_SELECT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "multiple_permissive_policies",
|
||||||
|
"title": "Multiple Permissive Policies",
|
||||||
|
"level": "WARN",
|
||||||
|
"facing": "EXTERNAL",
|
||||||
|
"categories": "[\"PERFORMANCE\"]",
|
||||||
|
"description": "Detects if multiple permissive row level security policies are present on a table for the same `role` and `action` (e.g. insert). Multiple permissive policies are suboptimal for performance as each policy must be executed for every relevant query.",
|
||||||
|
"detail": "Table `public.tasting_sessions` has multiple permissive policies for role `authenticated` for action `UPDATE`. Policies include `{\"Manage own sessions\",session_access_policy}`",
|
||||||
|
"remediation": "https://supabase.com/docs/guides/database/database-linter?lint=0006_multiple_permissive_policies",
|
||||||
|
"metadata": "{\"name\":\"tasting_sessions\",\"type\":\"table\",\"schema\":\"public\"}",
|
||||||
|
"cache_key": "multiple_permissive_policies_public_tasting_sessions_authenticated_UPDATE"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "multiple_permissive_policies",
|
||||||
|
"title": "Multiple Permissive Policies",
|
||||||
|
"level": "WARN",
|
||||||
|
"facing": "EXTERNAL",
|
||||||
|
"categories": "[\"PERFORMANCE\"]",
|
||||||
|
"description": "Detects if multiple permissive row level security policies are present on a table for the same `role` and `action` (e.g. insert). Multiple permissive policies are suboptimal for performance as each policy must be executed for every relevant query.",
|
||||||
|
"detail": "Table `public.tasting_sessions` has multiple permissive policies for role `authenticator` for action `DELETE`. Policies include `{\"Manage own sessions\",session_access_policy}`",
|
||||||
|
"remediation": "https://supabase.com/docs/guides/database/database-linter?lint=0006_multiple_permissive_policies",
|
||||||
|
"metadata": "{\"name\":\"tasting_sessions\",\"type\":\"table\",\"schema\":\"public\"}",
|
||||||
|
"cache_key": "multiple_permissive_policies_public_tasting_sessions_authenticator_DELETE"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "multiple_permissive_policies",
|
||||||
|
"title": "Multiple Permissive Policies",
|
||||||
|
"level": "WARN",
|
||||||
|
"facing": "EXTERNAL",
|
||||||
|
"categories": "[\"PERFORMANCE\"]",
|
||||||
|
"description": "Detects if multiple permissive row level security policies are present on a table for the same `role` and `action` (e.g. insert). Multiple permissive policies are suboptimal for performance as each policy must be executed for every relevant query.",
|
||||||
|
"detail": "Table `public.tasting_sessions` has multiple permissive policies for role `authenticator` for action `INSERT`. Policies include `{\"Manage own sessions\",session_access_policy}`",
|
||||||
|
"remediation": "https://supabase.com/docs/guides/database/database-linter?lint=0006_multiple_permissive_policies",
|
||||||
|
"metadata": "{\"name\":\"tasting_sessions\",\"type\":\"table\",\"schema\":\"public\"}",
|
||||||
|
"cache_key": "multiple_permissive_policies_public_tasting_sessions_authenticator_INSERT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "multiple_permissive_policies",
|
||||||
|
"title": "Multiple Permissive Policies",
|
||||||
|
"level": "WARN",
|
||||||
|
"facing": "EXTERNAL",
|
||||||
|
"categories": "[\"PERFORMANCE\"]",
|
||||||
|
"description": "Detects if multiple permissive row level security policies are present on a table for the same `role` and `action` (e.g. insert). Multiple permissive policies are suboptimal for performance as each policy must be executed for every relevant query.",
|
||||||
|
"detail": "Table `public.tasting_sessions` has multiple permissive policies for role `authenticator` for action `SELECT`. Policies include `{\"Manage own sessions\",session_access_policy}`",
|
||||||
|
"remediation": "https://supabase.com/docs/guides/database/database-linter?lint=0006_multiple_permissive_policies",
|
||||||
|
"metadata": "{\"name\":\"tasting_sessions\",\"type\":\"table\",\"schema\":\"public\"}",
|
||||||
|
"cache_key": "multiple_permissive_policies_public_tasting_sessions_authenticator_SELECT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "multiple_permissive_policies",
|
||||||
|
"title": "Multiple Permissive Policies",
|
||||||
|
"level": "WARN",
|
||||||
|
"facing": "EXTERNAL",
|
||||||
|
"categories": "[\"PERFORMANCE\"]",
|
||||||
|
"description": "Detects if multiple permissive row level security policies are present on a table for the same `role` and `action` (e.g. insert). Multiple permissive policies are suboptimal for performance as each policy must be executed for every relevant query.",
|
||||||
|
"detail": "Table `public.tasting_sessions` has multiple permissive policies for role `authenticator` for action `UPDATE`. Policies include `{\"Manage own sessions\",session_access_policy}`",
|
||||||
|
"remediation": "https://supabase.com/docs/guides/database/database-linter?lint=0006_multiple_permissive_policies",
|
||||||
|
"metadata": "{\"name\":\"tasting_sessions\",\"type\":\"table\",\"schema\":\"public\"}",
|
||||||
|
"cache_key": "multiple_permissive_policies_public_tasting_sessions_authenticator_UPDATE"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "multiple_permissive_policies",
|
||||||
|
"title": "Multiple Permissive Policies",
|
||||||
|
"level": "WARN",
|
||||||
|
"facing": "EXTERNAL",
|
||||||
|
"categories": "[\"PERFORMANCE\"]",
|
||||||
|
"description": "Detects if multiple permissive row level security policies are present on a table for the same `role` and `action` (e.g. insert). Multiple permissive policies are suboptimal for performance as each policy must be executed for every relevant query.",
|
||||||
|
"detail": "Table `public.tasting_sessions` has multiple permissive policies for role `dashboard_user` for action `DELETE`. Policies include `{\"Manage own sessions\",session_access_policy}`",
|
||||||
|
"remediation": "https://supabase.com/docs/guides/database/database-linter?lint=0006_multiple_permissive_policies",
|
||||||
|
"metadata": "{\"name\":\"tasting_sessions\",\"type\":\"table\",\"schema\":\"public\"}",
|
||||||
|
"cache_key": "multiple_permissive_policies_public_tasting_sessions_dashboard_user_DELETE"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "multiple_permissive_policies",
|
||||||
|
"title": "Multiple Permissive Policies",
|
||||||
|
"level": "WARN",
|
||||||
|
"facing": "EXTERNAL",
|
||||||
|
"categories": "[\"PERFORMANCE\"]",
|
||||||
|
"description": "Detects if multiple permissive row level security policies are present on a table for the same `role` and `action` (e.g. insert). Multiple permissive policies are suboptimal for performance as each policy must be executed for every relevant query.",
|
||||||
|
"detail": "Table `public.tasting_sessions` has multiple permissive policies for role `dashboard_user` for action `INSERT`. Policies include `{\"Manage own sessions\",session_access_policy}`",
|
||||||
|
"remediation": "https://supabase.com/docs/guides/database/database-linter?lint=0006_multiple_permissive_policies",
|
||||||
|
"metadata": "{\"name\":\"tasting_sessions\",\"type\":\"table\",\"schema\":\"public\"}",
|
||||||
|
"cache_key": "multiple_permissive_policies_public_tasting_sessions_dashboard_user_INSERT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "multiple_permissive_policies",
|
||||||
|
"title": "Multiple Permissive Policies",
|
||||||
|
"level": "WARN",
|
||||||
|
"facing": "EXTERNAL",
|
||||||
|
"categories": "[\"PERFORMANCE\"]",
|
||||||
|
"description": "Detects if multiple permissive row level security policies are present on a table for the same `role` and `action` (e.g. insert). Multiple permissive policies are suboptimal for performance as each policy must be executed for every relevant query.",
|
||||||
|
"detail": "Table `public.tasting_sessions` has multiple permissive policies for role `dashboard_user` for action `SELECT`. Policies include `{\"Manage own sessions\",session_access_policy}`",
|
||||||
|
"remediation": "https://supabase.com/docs/guides/database/database-linter?lint=0006_multiple_permissive_policies",
|
||||||
|
"metadata": "{\"name\":\"tasting_sessions\",\"type\":\"table\",\"schema\":\"public\"}",
|
||||||
|
"cache_key": "multiple_permissive_policies_public_tasting_sessions_dashboard_user_SELECT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "multiple_permissive_policies",
|
||||||
|
"title": "Multiple Permissive Policies",
|
||||||
|
"level": "WARN",
|
||||||
|
"facing": "EXTERNAL",
|
||||||
|
"categories": "[\"PERFORMANCE\"]",
|
||||||
|
"description": "Detects if multiple permissive row level security policies are present on a table for the same `role` and `action` (e.g. insert). Multiple permissive policies are suboptimal for performance as each policy must be executed for every relevant query.",
|
||||||
|
"detail": "Table `public.tasting_sessions` has multiple permissive policies for role `dashboard_user` for action `UPDATE`. Policies include `{\"Manage own sessions\",session_access_policy}`",
|
||||||
|
"remediation": "https://supabase.com/docs/guides/database/database-linter?lint=0006_multiple_permissive_policies",
|
||||||
|
"metadata": "{\"name\":\"tasting_sessions\",\"type\":\"table\",\"schema\":\"public\"}",
|
||||||
|
"cache_key": "multiple_permissive_policies_public_tasting_sessions_dashboard_user_UPDATE"
|
||||||
|
}
|
||||||
|
]
|
||||||
114
.nebiusbravetasks
Normal file
114
.nebiusbravetasks
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
# IMPLEMENTIERUNGS-PLAN: Whisky Scanner App
|
||||||
|
## Stack: Next.js | Supabase | Nebius AI | Brave Search | Coolify
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 1. SICHERHEIT & INFRASTRUKTUR (Fundament)
|
||||||
|
|
||||||
|
**Ziel:** Sicherstellen, dass keine API-Keys leaken und keine User-Daten direkt an US-Anbieter fließen.
|
||||||
|
|
||||||
|
- [ ] **Environment Variablen säubern**
|
||||||
|
- Prüfe deine `.env` Datei.
|
||||||
|
- [ ] `NEBIUS_API_KEY`: Darf NICHT mit `NEXT_PUBLIC_` beginnen.
|
||||||
|
- [ ] `BRAVE_API_KEY`: Darf NICHT mit `NEXT_PUBLIC_` beginnen.
|
||||||
|
- [ ] `SUPABASE_SERVICE_ROLE_KEY`: Darf NICHT mit `NEXT_PUBLIC_` beginnen (für Admin-Schreibrechte).
|
||||||
|
- [ ] `NEXT_PUBLIC_SUPABASE_ANON_KEY`: Darf öffentlich sein (nur für RLS Lesezugriffe).
|
||||||
|
|
||||||
|
- [ ] **Server-Side Architektur erzwingen**
|
||||||
|
- Stelle sicher, dass alle Aufrufe zu Nebius und Brave ausschließlich über **Server Actions** (`'use server'`) oder **API Routes** (`app/api/...`) laufen.
|
||||||
|
- *Test:* Öffne die Entwicklertools im Browser (Netzwerk-Tab). Beim Scannen darf KEIN Aufruf zu `api.brave.com` oder `api.studio.nebius.ai` sichtbar sein. Nur Aufrufe zu deinem eigenen Backend (localhost/deine-domain).
|
||||||
|
|
||||||
|
- [ ] **Supabase Admin Client einrichten**
|
||||||
|
- Erstelle eine Utility-Datei (z.B. `lib/supabase-admin.ts`), die `createClient` mit dem `SUPABASE_SERVICE_ROLE_KEY` initialisiert.
|
||||||
|
- Nutze diesen Client NUR in Server Actions für das Schreiben in den globalen Cache.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. NEBIUS AI STUDIO (Das "Auge")
|
||||||
|
|
||||||
|
**Ziel:** Bildanalyse und Extraktion strukturierter Daten für ca. 0,03 Cent pro Bild.
|
||||||
|
|
||||||
|
- [ ] **OpenAI SDK konfigurieren**
|
||||||
|
- Installiere das SDK: `npm install openai`
|
||||||
|
- Initialisiere den Client in `lib/ai-client.ts`:
|
||||||
|
```typescript
|
||||||
|
import { OpenAI } from "openai";
|
||||||
|
export const aiClient = new OpenAI({
|
||||||
|
baseURL: "[https://api.studio.nebius.ai/v1](https://api.studio.nebius.ai/v1)",
|
||||||
|
apiKey: process.env.NEBIUS_API_KEY,
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Prompt Engineering (JSON Mode)**
|
||||||
|
- Erstelle den Prompt für das Modell `Qwen/Qwen2.5-VL-72B-Instruct` (oder Qwen2-VL-72B).
|
||||||
|
- Anweisung: "Extrahiere: Distillery, Name/Edition, Age/Vintage, ABV. Formatiere als reines JSON."
|
||||||
|
- WICHTIG: Setze `response_format: { type: "json_object" }` im API-Call, damit du valides JSON erhältst.
|
||||||
|
|
||||||
|
- [ ] **Such-Query Generierung**
|
||||||
|
- Lass die AI zusätzlich ein Feld `search_string` generieren.
|
||||||
|
- Format-Vorgabe an AI: `"site:whiskybase.com [Distillery] [Name] [Vintage]"`
|
||||||
|
- *Grund:* Das spart Logik im Code, die AI weiß am besten, was auf dem Etikett wichtig ist.
|
||||||
|
|
||||||
|
Bitte für den Adminuser einen Schalter zwischen Gemini und Nebius implementieren. Idealerweise im Magic Scan Formular.
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. BRAVE SEARCH API (Der "Notfall-Plan")
|
||||||
|
|
||||||
|
**Ziel:** Finden der Whiskybase-ID, wenn der Cache leer ist (Prepaid, keine Fixkosten).
|
||||||
|
|
||||||
|
- [ ] **Fetch-Funktion bauen**
|
||||||
|
- Erstelle eine Server-Funktion, die Brave aufruft.
|
||||||
|
- URL: `https://api.search.brave.com/res/v1/web/search?q=...`
|
||||||
|
- Header: `X-Subscription-Token: process.env.BRAVE_API_KEY`
|
||||||
|
|
||||||
|
- [ ] **Ergebnis-Parser implementieren**
|
||||||
|
- Nimm das erste Ergebnis (`results[0]`).
|
||||||
|
- Prüfe per Regex, ob der Link valide ist: `whiskybase.com/whiskies/whisky/([0-9]+)/...`
|
||||||
|
- Extrahiere die ID (z.B. "12345").
|
||||||
|
|
||||||
|
- [ ] **Fallback-Logik**
|
||||||
|
- Wenn Brave nichts findet (array leer), gib einen Fehler zurück oder markiere das Produkt als "Manuelle Prüfung nötig".
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4. DATENBANK & CACHING (Die "Spar-Maschine")
|
||||||
|
|
||||||
|
**Ziel:** Suchkosten minimieren und Datenqualität sichern.
|
||||||
|
|
||||||
|
- [ ] **Datenbank-Schema (Tabelle `global_products`)**
|
||||||
|
- `id`: uuid (PK)
|
||||||
|
- `wb_id`: text (Whiskybase ID, unique)
|
||||||
|
- `full_name`: text (z.B. "Lagavulin 16")
|
||||||
|
- `search_vector`: tsvector (Für unscharfe Suche!)
|
||||||
|
- `image_hash`: text (Optional, falls du Hash-Vergleiche machst)
|
||||||
|
- `created_at`: timestamp
|
||||||
|
|
||||||
|
- [ ] **Row Level Security (RLS) Policies**
|
||||||
|
- Tabelle `global_products`:
|
||||||
|
- Policy "Enable Read Access for all users": `true` (für SELECT).
|
||||||
|
- Policy "Disable Insert/Update for users": `false` (für INSERT/UPDATE).
|
||||||
|
- *Grund:* Nur dein Server (Service Role) darf hier schreiben, um "Cache Poisoning" durch User zu verhindern.
|
||||||
|
|
||||||
|
- [ ] **Der "Main Loop" (Server Action)**
|
||||||
|
1. **AI:** Bild an Nebius -> JSON erhalten.
|
||||||
|
2. **DB Check:** Suche in `global_products` mit `.textSearch('search_vector', ai_name)`.
|
||||||
|
3. **Entscheidung:**
|
||||||
|
- *Treffer:* Nimm ID aus DB (Kosten: 0€). Return an Client.
|
||||||
|
- *Kein Treffer:* Rufe Brave Search auf (Kosten: 0,3 Cent).
|
||||||
|
4. **Cache Write:** Wenn Brave erfolgreich war, nutze `supabaseAdmin`, um den neuen Whisky in `global_products` zu speichern.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5. DSGVO / PRIVACY CHECKLISTE
|
||||||
|
|
||||||
|
**Ziel:** Rechtssicherer Betrieb in der EU.
|
||||||
|
|
||||||
|
- [ ] **Data Minimization**
|
||||||
|
- Schicke an Brave NUR den Suchstring (z.B. "Lagavulin 16").
|
||||||
|
- Schicke NIEMALS User-IDs, E-Mails oder IP-Adressen im Header oder Body an Brave.
|
||||||
|
|
||||||
|
- [ ] **Proxy-Check**
|
||||||
|
- Bestätige nochmals, dass Brave nur die IP deines Hetzner-Servers sieht, nie die des Endnutzers (siehe Punkt 1).
|
||||||
|
|
||||||
|
- [ ] **Nebius Region**
|
||||||
|
- Prüfe im Nebius Dashboard, dass dein Projekt in der Region **eu-north1** (Finnland) oder einer anderen EU-Region läuft, falls wählbar.
|
||||||
117
.tagidea
Normal file
117
.tagidea
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
|
||||||
|
--------------------------------------------------------------------------------
|
||||||
|
2. ERWEITERTES TAGGING-SYSTEM (NOSE, TASTE, FINISH)
|
||||||
|
--------------------------------------------------------------------------------
|
||||||
|
Das Ziel ist es, Textwüsten zu vermeiden und schnelles Erfassen zu ermöglichen. Das muss möglichst mobile friendly und easy für den Nutzer sein!
|
||||||
|
|
||||||
|
DATENBANK-LOGIK (SUPABASE EMPFEHLUNG):
|
||||||
|
Tabelle `tags`:
|
||||||
|
- id (uuid)
|
||||||
|
- name (string)
|
||||||
|
- category (enum: 'nose', 'taste', 'finish', 'texture')
|
||||||
|
- is_system_default (boolean) -> True = Admin Tag, False = User Custom Tag
|
||||||
|
- created_by (uuid, nullable) -> User ID, falls Custom Tag
|
||||||
|
|
||||||
|
FUNKTIONSWEISE:
|
||||||
|
1. Standard-Tags: Beim Erstellen einer Notiz werden die System-Tags als Chips vorgeschlagen (durchsuchbar). Bitte denke an i18n und übersetze die System-Tags bereits.
|
||||||
|
2. Custom Tags: Findet der User ein Aroma nicht, tippt er es ein -> Es wird als "User Tag" nur für ihn gespeichert.
|
||||||
|
3. Vorauswahl durch AI: Gemini könnte beim Scan basierend auf der Whisky-Datenbank schon Tags "pre-selecten" (highlighten).
|
||||||
|
|
||||||
|
--------------------------------------------------------------------------------
|
||||||
|
3. DIE "PROFUNDE" TAG-LISTE (MASTER LIST FÜR ADMIN PANEL)
|
||||||
|
--------------------------------------------------------------------------------
|
||||||
|
Diese Liste deckt das Spektrum von milden Lowlands bis zu schweren Islay Whiskys ab.
|
||||||
|
|
||||||
|
KATEGORIE A: FRUCHTIG (FRUITY)
|
||||||
|
- Apfel (Grüner Apfel / Bratapfel)
|
||||||
|
- Birne
|
||||||
|
- Zitrone / Zitrus
|
||||||
|
- Orange / Orangenschale
|
||||||
|
- Pfirsich / Aprikose
|
||||||
|
- Banane
|
||||||
|
- Ananas / Tropische Früchte
|
||||||
|
- Kirsche
|
||||||
|
- Beeren (Brombeere / Himbeere)
|
||||||
|
- Pflaume
|
||||||
|
- Trockenfrüchte (Rosinen / Datteln / Feigen) -> Typisch für Sherry-Fässer!
|
||||||
|
|
||||||
|
KATEGORIE B: SÜSS & CREMIG (SWEET & CREAMY)
|
||||||
|
- Vanille
|
||||||
|
- Honig
|
||||||
|
- Karamell / Toffee
|
||||||
|
- Schokolade (Zartbitter / Milch)
|
||||||
|
- Malz / Müsli
|
||||||
|
- Butter / Butterkeks
|
||||||
|
- Marzipan / Mandel
|
||||||
|
- Sahnebonbon
|
||||||
|
|
||||||
|
KATEGORIE C: WÜRZIG & NUSSIG (SPICY & NUTTY)
|
||||||
|
- Eiche (frisch / alt)
|
||||||
|
- Zimt
|
||||||
|
- Pfeffer (schwarz / weiß)
|
||||||
|
- Muskatnuss
|
||||||
|
- Ingwer
|
||||||
|
- Nelke
|
||||||
|
- Walnuss
|
||||||
|
- Haselnuss
|
||||||
|
- Geröstete Nüsse
|
||||||
|
|
||||||
|
KATEGORIE D: RAUCHIG & TORFIG (PEATY & SMOKY)
|
||||||
|
- Lagerfeuer / Holzkohle
|
||||||
|
- Torfrauch
|
||||||
|
- Asche
|
||||||
|
- Medizinisch (Jod / Verbandszeug) -> Typisch Laphroaig
|
||||||
|
- Teer / Asphalt
|
||||||
|
- Geräucherter Schinken / Speck
|
||||||
|
- Grillfleisch
|
||||||
|
|
||||||
|
KATEGORIE E: MARITIM & SALZIG (COASTAL)
|
||||||
|
- Meersalz / Salzlake
|
||||||
|
- Seetang / Algen
|
||||||
|
- Austern
|
||||||
|
- Frische Meeresbrise
|
||||||
|
|
||||||
|
KATEGORIE F: FLORAL & KRÄUTER (FLORAL & HERBAL)
|
||||||
|
- Heidekraut (Heather)
|
||||||
|
- Gras / Heu (frisch gemäht)
|
||||||
|
- Minze / Menthol
|
||||||
|
- Eukalyptus
|
||||||
|
- Tabak (frisch / Pfeife)
|
||||||
|
- Leder
|
||||||
|
- Tee (Schwarztee / Grüntee)
|
||||||
|
|
||||||
|
KATEGORIE G: FEHLNOTEN / SPEZIELLES (OFF-NOTES / SPECIAL)
|
||||||
|
- Schwefel (Streichholz)
|
||||||
|
- Gummi
|
||||||
|
- Seife (Lavendel)
|
||||||
|
- Klebstoff (Uhu) -> oft bei jungen Grain Whiskys
|
||||||
|
- Pilze / Waldboden (erdig)
|
||||||
|
|
||||||
|
--------------------------------------------------------------------------------
|
||||||
|
4. DAS FINISH (ABGANG) - SPEZIAL TAGS
|
||||||
|
--------------------------------------------------------------------------------
|
||||||
|
Beim Abgang geht es oft weniger um neuen Geschmack, sondern um Gefühl und Dauer.
|
||||||
|
|
||||||
|
DAUER:
|
||||||
|
- Kurz & Knackig
|
||||||
|
- Mittellang
|
||||||
|
- Lang anhaltend
|
||||||
|
- Ewig
|
||||||
|
|
||||||
|
TEXTUR & GEFÜHL (MOUTHFEEL):
|
||||||
|
- Ölig / Viskos
|
||||||
|
- Trocken (Adstringierend - zieht den Mund zusammen)
|
||||||
|
- Wärmend (Alkoholisch)
|
||||||
|
- Scharf / Beißend
|
||||||
|
- Weich / Samtig
|
||||||
|
- Wässrig
|
||||||
|
|
||||||
|
--------------------------------------------------------------------------------
|
||||||
|
5. ADMIN PANEL IDEEN
|
||||||
|
--------------------------------------------------------------------------------
|
||||||
|
Um diese Tags zu verwalten, brauchst du im Admin-Bereich (`/admin/tags`) folgende Features:
|
||||||
|
|
||||||
|
1. Bulk Import: Ein Script oder Textfeld, um obige Liste einmalig reinzuladen.
|
||||||
|
2. Tag Merging: Wenn 5 User "Apfelkuchen" als Custom Tag anlegen, kannst du entscheiden: "Okay, das wird ein offizieller System-Tag".
|
||||||
|
3. Farb-Codierung: Weise den Kategorien Farben zu (z.B. Frucht = Grün/Rot, Rauch = Grau, Süß = Gelb). Das macht die UI im Frontend viel schöner.
|
||||||
|
4. Synonym-Matching: (Advanced) Wenn User "Smoke" tippt, schlage "Rauch" vor.
|
||||||
110
.whiskytagweight
Normal file
110
.whiskytagweight
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
Ich habe die Liste für dich überarbeitet und gewichtet. Wir gehen hier am besten zweigleisig vor:
|
||||||
|
|
||||||
|
Allgemeine Wahrscheinlichkeit: Wie oft kommt eine Note generell bei Whisky vor? (Vanille = sehr oft, Lavendel = sehr selten).
|
||||||
|
|
||||||
|
User-spezifische Sortierung (Tech-Tipp): In deiner Supabase-Query solltest du später die Tags nach usage_count (wie oft hat dieser User den Tag schon benutzt) sortieren.
|
||||||
|
|
||||||
|
Hier ist die aktualisierte Liste als Textdatei-Inhalt. Ich habe einen "Popularity Score" (1-5) hinzugefügt.
|
||||||
|
|
||||||
|
5 = Fast immer dabei (Standard-Aromen) -> Diese müssen ganz oben stehen.
|
||||||
|
|
||||||
|
1 = Exoten / Fehlnoten -> Diese können weiter unten in der Liste auftauchen.
|
||||||
|
|
||||||
|
Hier ist der Datei-Inhalt für deinen Import:
|
||||||
|
|
||||||
|
# WHISKY TAGS - GEWICHTET NACH HÄUFIGKEIT (1-5)
|
||||||
|
# 5 = Sehr häufig (Top Level)
|
||||||
|
# 1 = Sehr selten (Nische)
|
||||||
|
|
||||||
|
--------------------------------------------------------------------------------
|
||||||
|
KATEGORIE: SÜSS & FASS (SWEET & CASK)
|
||||||
|
--------------------------------------------------------------------------------
|
||||||
|
(5) Vanille # Der Klassiker (Ex-Bourbon Fässer)
|
||||||
|
(5) Eiche (würzig) # Basis fast aller Whiskys
|
||||||
|
(5) Karamell / Toffee # Durch Fassausbrennung
|
||||||
|
(4) Honig # Sehr häufig bei Speyside/Highland
|
||||||
|
(4) Malz / Getreide # Der Rohstoff selbst
|
||||||
|
(3) Schokolade (Dunkel) # Oft bei Sherry-Fässern
|
||||||
|
(3) Nüsse (Allgemein) # Oxidative Note
|
||||||
|
(2) Marzipan # Eher spezifisch
|
||||||
|
(2) Butterkeks # Typisch für junge Malts
|
||||||
|
(1) Kokosnuss # Speziell (oft bei alter US-Eiche)
|
||||||
|
|
||||||
|
--------------------------------------------------------------------------------
|
||||||
|
KATEGORIE: FRUCHT (FRUITY)
|
||||||
|
--------------------------------------------------------------------------------
|
||||||
|
(5) Zitrus / Zitrone # Frische Kopfnote
|
||||||
|
(4) Apfel (Grün/Rot) # Standard Ester-Note
|
||||||
|
(4) Birne # Typisch für Glenfiddich etc.
|
||||||
|
(4) Trockenfrüchte # Rosinen/Sultaninen (Sherry-Einfluss)
|
||||||
|
(3) Orange / Schale # Häufig
|
||||||
|
(3) Beeren (Dunkel) # Brombeere/Johannisbeere
|
||||||
|
(2) Pfirsich / Aprikose # Steinobst
|
||||||
|
(2) Banane # Spezielle Hefe-Note (z.B. Jack Daniels)
|
||||||
|
(2) Pflaume # Reife Note
|
||||||
|
(1) Ananas / Tropisch # Eher bei alten Malts / Iren
|
||||||
|
(1) Melone # Selten
|
||||||
|
|
||||||
|
--------------------------------------------------------------------------------
|
||||||
|
KATEGORIE: WÜRZE & KRÄUTER (SPICE & HERBAL)
|
||||||
|
--------------------------------------------------------------------------------
|
||||||
|
(4) Pfeffer (Schwarz) # Der "Biss" (Talisker)
|
||||||
|
(4) Zimt # Weihnachtsgewürze (Sherry/Virgin Oak)
|
||||||
|
(3) Ingwer # Schärfe & Frische
|
||||||
|
(3) Gras / Heu # Lowland Stil / Junger Whisky
|
||||||
|
(2) Muskatnuss # Holzwürze
|
||||||
|
(2) Minze / Menthol # Oft im Finish
|
||||||
|
(2) Nelke # Intensiv würzig
|
||||||
|
(1) Eukalyptus # Sehr frisch
|
||||||
|
(1) Anis / Lakritz # Speziell
|
||||||
|
|
||||||
|
--------------------------------------------------------------------------------
|
||||||
|
KATEGORIE: RAUCH & MARITIM (SMOKE & COASTAL)
|
||||||
|
--------------------------------------------------------------------------------
|
||||||
|
# Achtung: Diese sind bei Islay-Fans eine (5), global gesehen eher eine (3).
|
||||||
|
# Ich gewichte sie hier hoch, da "Kenner" sie oft suchen.
|
||||||
|
|
||||||
|
(4) Torfrauch # Der Klassiker
|
||||||
|
(4) Lagerfeuer # Holzrauch
|
||||||
|
(3) Meersalz / Brine # Küsten-Whiskys
|
||||||
|
(3) Asche # Trockener Rauch
|
||||||
|
(3) Speck / Schinken # Fleischiger Rauch
|
||||||
|
(2) Medizinisch / Jod # Laphroaig-Style
|
||||||
|
(2) Teer # Schwerer Rauch
|
||||||
|
(1) Seetang / Alge # Sehr speziell
|
||||||
|
(1) Gummistiefel # Industrie-Note
|
||||||
|
|
||||||
|
--------------------------------------------------------------------------------
|
||||||
|
KATEGORIE: TEXTUR & FINISH (FEELING)
|
||||||
|
--------------------------------------------------------------------------------
|
||||||
|
(5) Wärmend # Alkohol-Wirkung
|
||||||
|
(4) Trocken # Adstringierend (Eiche)
|
||||||
|
(4) Lang anhaltend # Qualitätsmerkmal
|
||||||
|
(3) Ölig / Cremig # Mundgefühl
|
||||||
|
(3) Würzig # Prickeln
|
||||||
|
(2) Kurz # Eher negativ / leicht
|
||||||
|
(1) Wässrig # Negativ
|
||||||
|
|
||||||
|
--------------------------------------------------------------------------------
|
||||||
|
KATEGORIE: SPEZIELLES / OFF-NOTES
|
||||||
|
--------------------------------------------------------------------------------
|
||||||
|
(2) Leder # Alter Whisky / Sherry
|
||||||
|
(2) Tabak # Alter Whisky
|
||||||
|
(2) Schwefel # Kann Fehlrnote sein, oder gewollt "dreckig"
|
||||||
|
(1) Pilze / Erdig # Dunnage Warehouse Funk
|
||||||
|
(1) Seife # Oft als Fehler wahrgenommen
|
||||||
|
(1) Metallisch # Fehler bei der Destillation
|
||||||
|
|
||||||
|
Tipp zur Implementierung der "Smart Sorting" Logik
|
||||||
|
|
||||||
|
Damit der User sofort das Richtige findet, würde ich im Frontend (oder in der API) eine kombinierte Sortierung bauen.
|
||||||
|
|
||||||
|
Stell dir vor, Gemini hat erkannt: "Das ist ein Ardbeg 10". Dann weiß dein System (via Gemini oder Datenbank): Kategorie = Islay / Rauchig.
|
||||||
|
|
||||||
|
Deine Sortierlogik für die Tags sollte dann so aussehen:
|
||||||
|
|
||||||
|
Priorität A (Der "AI-Kontext"): Wenn Gemini sagt "Likely Peated", dann booste alle Tags aus der Kategorie "RAUCH" temporär nach ganz oben. User sieht sofort: Torf, Rauch, Asche.
|
||||||
|
|
||||||
|
Priorität B (Global Weight): Danach kommen die Tags mit Score 5 und 4 (Vanille, Eiche, Apfel). User sieht die Standards.
|
||||||
|
|
||||||
|
Priorität C (Der Rest): Ganz unten kommen die Exoten (Ananas, Seife).
|
||||||
17
add_tag_weights_incremental.sql
Normal file
17
add_tag_weights_incremental.sql
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
-- Incremental Migration for Tag Popularity Scores
|
||||||
|
ALTER TABLE tags ADD COLUMN IF NOT EXISTS popularity_score INTEGER DEFAULT 3;
|
||||||
|
|
||||||
|
-- Update scores for common aromas (5)
|
||||||
|
UPDATE tags SET popularity_score = 5 WHERE name IN ('Vanille', 'Eiche', 'Karamell', 'Zitrone', 'Zitrus', 'Wärmend');
|
||||||
|
|
||||||
|
-- Update scores for frequent aromas (4)
|
||||||
|
UPDATE tags SET popularity_score = 4 WHERE name IN ('Honig', 'Malz', 'Apfel', 'Grüner Apfel', 'Bratapfel', 'Birne', 'Trockenfrüchte', 'Pfeffer', 'Zimt', 'Torfrauch', 'Lagerfeuer', 'Trocken', 'Lang anhaltend');
|
||||||
|
|
||||||
|
-- Update scores for regular aromas (3)
|
||||||
|
UPDATE tags SET popularity_score = 3 WHERE name IN ('Schokolade', 'Zartbitterschokolade', 'Milchschokolade', 'Nüsse', 'Orange', 'Orangenschale', 'Beeren', 'Brombeere', 'Himbeere', 'Ingwer', 'Gras', 'Heu', 'Meersalz', 'Salzlake', 'Asche', 'Speck', 'Geräucherter Schinken', 'Grillfleisch', 'Ölig', 'Viskos', 'Würzig');
|
||||||
|
|
||||||
|
-- Update scores for specific aromas (2)
|
||||||
|
UPDATE tags SET popularity_score = 2 WHERE name IN ('Marzipan', 'Butterkeks', 'Pfirsich', 'Aprikose', 'Banane', 'Pflaume', 'Muskatnuss', 'Minze', 'Menthol', 'Nelke', 'Medizinisch', 'Jod', 'Teer', 'Asphalt', 'Leder', 'Tabak', 'Schwefel', 'Kurz');
|
||||||
|
|
||||||
|
-- Update scores for rare/exotic aromas (1)
|
||||||
|
UPDATE tags SET popularity_score = 1 WHERE name IN ('Kokosnuss', 'Ananas', 'Tropische Früchte', 'Melone', 'Eukalyptus', 'Seetang', 'Algen', 'Austern', 'Wässrig', 'Pilze', 'Seife', 'Metallisch');
|
||||||
29
global_products.sql
Normal file
29
global_products.sql
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
-- Global Products for caching searches
|
||||||
|
CREATE TABLE IF NOT EXISTS global_products (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
wb_id TEXT UNIQUE NOT NULL,
|
||||||
|
full_name TEXT NOT NULL,
|
||||||
|
search_vector tsvector GENERATED ALWAYS AS (to_tsvector('simple', full_name)) STORED,
|
||||||
|
image_hash TEXT,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('utc'::text, now())
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Index for search vector
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_global_products_search_vector ON global_products USING GIN (search_vector);
|
||||||
|
|
||||||
|
-- Enable RLS
|
||||||
|
ALTER TABLE global_products ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
-- Policies for global_products
|
||||||
|
-- Enable Read Access for all users (SELECT)
|
||||||
|
DROP POLICY IF EXISTS "Enable Read Access for all users" ON global_products;
|
||||||
|
CREATE POLICY "Enable Read Access for all users"
|
||||||
|
ON global_products FOR SELECT
|
||||||
|
USING (true);
|
||||||
|
|
||||||
|
-- Disable Insert/Update for normal users (only Service Role/Admins)
|
||||||
|
DROP POLICY IF EXISTS "Enable Admin Insert/Update" ON global_products;
|
||||||
|
CREATE POLICY "Enable Admin Insert/Update"
|
||||||
|
ON global_products FOR ALL
|
||||||
|
USING (EXISTS (SELECT 1 FROM admin_users WHERE user_id = (SELECT auth.uid())))
|
||||||
|
WITH CHECK (EXISTS (SELECT 1 FROM admin_users WHERE user_id = (SELECT auth.uid())));
|
||||||
@@ -19,6 +19,7 @@
|
|||||||
"heic2any": "^0.0.4",
|
"heic2any": "^0.0.4",
|
||||||
"lucide-react": "^0.300.0",
|
"lucide-react": "^0.300.0",
|
||||||
"next": "14.2.23",
|
"next": "14.2.23",
|
||||||
|
"openai": "^6.15.0",
|
||||||
"react": "^18",
|
"react": "^18",
|
||||||
"react-dom": "^18",
|
"react-dom": "^18",
|
||||||
"sharp": "^0.34.5",
|
"sharp": "^0.34.5",
|
||||||
|
|||||||
36
pnpm-lock.yaml
generated
36
pnpm-lock.yaml
generated
@@ -32,6 +32,9 @@ importers:
|
|||||||
next:
|
next:
|
||||||
specifier: 14.2.23
|
specifier: 14.2.23
|
||||||
version: 14.2.23(@babel/core@7.28.5)(@playwright/test@1.57.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
version: 14.2.23(@babel/core@7.28.5)(@playwright/test@1.57.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||||
|
openai:
|
||||||
|
specifier: ^6.15.0
|
||||||
|
version: 6.15.0(ws@8.18.3)(zod@3.25.76)
|
||||||
react:
|
react:
|
||||||
specifier: ^18
|
specifier: ^18
|
||||||
version: 18.3.1
|
version: 18.3.1
|
||||||
@@ -2233,6 +2236,18 @@ packages:
|
|||||||
once@1.4.0:
|
once@1.4.0:
|
||||||
resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
|
resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
|
||||||
|
|
||||||
|
openai@6.15.0:
|
||||||
|
resolution: {integrity: sha512-F1Lvs5BoVvmZtzkUEVyh8mDQPPFolq4F+xdsx/DO8Hee8YF3IGAlZqUIsF+DVGhqf4aU0a3bTghsxB6OIsRy1g==}
|
||||||
|
hasBin: true
|
||||||
|
peerDependencies:
|
||||||
|
ws: ^8.18.0
|
||||||
|
zod: ^3.25 || ^4.0
|
||||||
|
peerDependenciesMeta:
|
||||||
|
ws:
|
||||||
|
optional: true
|
||||||
|
zod:
|
||||||
|
optional: true
|
||||||
|
|
||||||
optionator@0.9.4:
|
optionator@0.9.4:
|
||||||
resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
|
resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
|
||||||
engines: {node: '>= 0.8.0'}
|
engines: {node: '>= 0.8.0'}
|
||||||
@@ -4357,8 +4372,8 @@ snapshots:
|
|||||||
'@typescript-eslint/parser': 6.21.0(eslint@8.57.1)(typescript@5.9.3)
|
'@typescript-eslint/parser': 6.21.0(eslint@8.57.1)(typescript@5.9.3)
|
||||||
eslint: 8.57.1
|
eslint: 8.57.1
|
||||||
eslint-import-resolver-node: 0.3.9
|
eslint-import-resolver-node: 0.3.9
|
||||||
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1)
|
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@8.57.1)
|
||||||
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1)
|
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1)
|
||||||
eslint-plugin-jsx-a11y: 6.10.2(eslint@8.57.1)
|
eslint-plugin-jsx-a11y: 6.10.2(eslint@8.57.1)
|
||||||
eslint-plugin-react: 7.37.5(eslint@8.57.1)
|
eslint-plugin-react: 7.37.5(eslint@8.57.1)
|
||||||
eslint-plugin-react-hooks: 5.0.0-canary-7118f5dd7-20230705(eslint@8.57.1)
|
eslint-plugin-react-hooks: 5.0.0-canary-7118f5dd7-20230705(eslint@8.57.1)
|
||||||
@@ -4377,7 +4392,7 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1):
|
eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@8.57.1):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@nolyfill/is-core-module': 1.0.39
|
'@nolyfill/is-core-module': 1.0.39
|
||||||
debug: 4.4.3
|
debug: 4.4.3
|
||||||
@@ -4388,22 +4403,22 @@ snapshots:
|
|||||||
tinyglobby: 0.2.15
|
tinyglobby: 0.2.15
|
||||||
unrs-resolver: 1.11.1
|
unrs-resolver: 1.11.1
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1)
|
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1)
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
eslint-module-utils@2.12.1(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1):
|
eslint-module-utils@2.12.1(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1):
|
||||||
dependencies:
|
dependencies:
|
||||||
debug: 3.2.7
|
debug: 3.2.7
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@typescript-eslint/parser': 6.21.0(eslint@8.57.1)(typescript@5.9.3)
|
'@typescript-eslint/parser': 6.21.0(eslint@8.57.1)(typescript@5.9.3)
|
||||||
eslint: 8.57.1
|
eslint: 8.57.1
|
||||||
eslint-import-resolver-node: 0.3.9
|
eslint-import-resolver-node: 0.3.9
|
||||||
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1)
|
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@8.57.1)
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
eslint-plugin-import@2.32.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1):
|
eslint-plugin-import@2.32.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@rtsao/scc': 1.1.0
|
'@rtsao/scc': 1.1.0
|
||||||
array-includes: 3.1.9
|
array-includes: 3.1.9
|
||||||
@@ -4414,7 +4429,7 @@ snapshots:
|
|||||||
doctrine: 2.1.0
|
doctrine: 2.1.0
|
||||||
eslint: 8.57.1
|
eslint: 8.57.1
|
||||||
eslint-import-resolver-node: 0.3.9
|
eslint-import-resolver-node: 0.3.9
|
||||||
eslint-module-utils: 2.12.1(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1)
|
eslint-module-utils: 2.12.1(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1)
|
||||||
hasown: 2.0.2
|
hasown: 2.0.2
|
||||||
is-core-module: 2.16.1
|
is-core-module: 2.16.1
|
||||||
is-glob: 4.0.3
|
is-glob: 4.0.3
|
||||||
@@ -5152,6 +5167,11 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
wrappy: 1.0.2
|
wrappy: 1.0.2
|
||||||
|
|
||||||
|
openai@6.15.0(ws@8.18.3)(zod@3.25.76):
|
||||||
|
optionalDependencies:
|
||||||
|
ws: 8.18.3
|
||||||
|
zod: 3.25.76
|
||||||
|
|
||||||
optionator@0.9.4:
|
optionator@0.9.4:
|
||||||
dependencies:
|
dependencies:
|
||||||
deep-is: 0.1.4
|
deep-is: 0.1.4
|
||||||
|
|||||||
@@ -100,6 +100,12 @@ export default async function AdminPage() {
|
|||||||
>
|
>
|
||||||
Manage Plans
|
Manage Plans
|
||||||
</Link>
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/admin/tags"
|
||||||
|
className="px-4 py-2 bg-pink-600 hover:bg-pink-700 text-white rounded-xl font-bold transition-colors"
|
||||||
|
>
|
||||||
|
Manage Tags
|
||||||
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
href="/admin/users"
|
href="/admin/users"
|
||||||
className="px-4 py-2 bg-amber-600 hover:bg-amber-700 text-white rounded-xl font-bold transition-colors"
|
className="px-4 py-2 bg-amber-600 hover:bg-amber-700 text-white rounded-xl font-bold transition-colors"
|
||||||
|
|||||||
196
src/app/admin/tags/page.tsx
Normal file
196
src/app/admin/tags/page.tsx
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { createClientComponentClient } from '@supabase/auth-helpers-nextjs';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { ChevronLeft, Tag as TagIcon, Plus, Search, Trash2, Shield, User, Filter, Download } from 'lucide-react';
|
||||||
|
import { Tag, TagCategory, getTagsByCategory } from '@/services/tags';
|
||||||
|
import { useI18n } from '@/i18n/I18nContext';
|
||||||
|
|
||||||
|
export default function AdminTagsPage() {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const supabase = createClientComponentClient();
|
||||||
|
const [tags, setTags] = useState<Tag[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [categoryFilter, setCategoryFilter] = useState<TagCategory | 'all'>('all');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchTags();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchTags = async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('tags')
|
||||||
|
.select('*')
|
||||||
|
.order('popularity_score', { ascending: false })
|
||||||
|
.order('name');
|
||||||
|
|
||||||
|
if (data) setTags(data);
|
||||||
|
setIsLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const filteredTags = tags.filter(tag => {
|
||||||
|
const matchesSearch = tag.name.toLowerCase().includes(search.toLowerCase());
|
||||||
|
const matchesCategory = categoryFilter === 'all' || tag.category === categoryFilter;
|
||||||
|
return matchesSearch && matchesCategory;
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleDelete = async (id: string) => {
|
||||||
|
if (!confirm('Tag wirklich löschen?')) return;
|
||||||
|
const { error } = await supabase.from('tags').delete().eq('id', id);
|
||||||
|
if (!error) setTags(prev => prev.filter(t => t.id !== id));
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleSystemDefault = async (tag: Tag) => {
|
||||||
|
const { error } = await supabase
|
||||||
|
.from('tags')
|
||||||
|
.update({ is_system_default: !tag.is_system_default })
|
||||||
|
.eq('id', tag.id);
|
||||||
|
|
||||||
|
if (!error) {
|
||||||
|
setTags(prev => prev.map(t => t.id === tag.id ? { ...t, is_system_default: !t.is_system_default } : t));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const updatePopularity = async (tagId: string, score: number) => {
|
||||||
|
const { error } = await supabase
|
||||||
|
.from('tags')
|
||||||
|
.update({ popularity_score: score })
|
||||||
|
.eq('id', tagId);
|
||||||
|
|
||||||
|
if (!error) {
|
||||||
|
setTags(prev => prev.map(t => t.id === tagId ? { ...t, popularity_score: score } : t));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="min-h-screen bg-zinc-50 dark:bg-black p-4 md:p-12">
|
||||||
|
<div className="max-w-6xl mx-auto space-y-8">
|
||||||
|
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
|
||||||
|
<div>
|
||||||
|
<Link href="/admin" className="text-zinc-500 hover:text-amber-600 transition-colors flex items-center gap-2 text-sm font-bold mb-2">
|
||||||
|
<ChevronLeft size={16} /> Admin Dashboard
|
||||||
|
</Link>
|
||||||
|
<h1 className="text-3xl font-black text-zinc-900 dark:text-white tracking-tighter flex items-center gap-3">
|
||||||
|
<TagIcon className="text-amber-600" /> Aroma Tags verwalten
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white dark:bg-zinc-900 rounded-3xl border border-zinc-200 dark:border-zinc-800 shadow-xl overflow-hidden">
|
||||||
|
<div className="p-6 border-b border-zinc-200 dark:border-zinc-800 bg-zinc-50/50 dark:bg-zinc-900/50 flex flex-col md:flex-row gap-4 justify-between">
|
||||||
|
<div className="relative flex-1 max-w-md">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-400" size={18} />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
placeholder="Tags suchen..."
|
||||||
|
className="w-full pl-10 pr-4 py-2 bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-xl text-sm focus:ring-2 focus:ring-amber-500 outline-none transition-all dark:text-zinc-200"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Filter size={18} className="text-zinc-400" />
|
||||||
|
<select
|
||||||
|
value={categoryFilter}
|
||||||
|
onChange={(e) => setCategoryFilter(e.target.value as any)}
|
||||||
|
className="bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-xl px-3 py-2 text-sm font-bold uppercase tracking-tight outline-none focus:ring-2 focus:ring-amber-500 dark:text-zinc-200"
|
||||||
|
>
|
||||||
|
<option value="all">Alle Kategorien</option>
|
||||||
|
<option value="nose">Nose</option>
|
||||||
|
<option value="taste">Taste</option>
|
||||||
|
<option value="finish">Finish</option>
|
||||||
|
<option value="texture">Texture</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-left">
|
||||||
|
<thead>
|
||||||
|
<tr className="text-[10px] font-black text-zinc-400 uppercase tracking-[0.15em] border-b border-zinc-100 dark:border-zinc-800">
|
||||||
|
<th className="px-6 py-4">Name</th>
|
||||||
|
<th className="px-6 py-4">Kategorie</th>
|
||||||
|
<th className="px-6 py-4">Popularität</th>
|
||||||
|
<th className="px-6 py-4">Typ</th>
|
||||||
|
<th className="px-6 py-4 text-right">Aktionen</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-zinc-100 dark:divide-zinc-800">
|
||||||
|
{isLoading ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={4} className="px-6 py-12 text-center text-zinc-400 italic">Lade Tags...</td>
|
||||||
|
</tr>
|
||||||
|
) : filteredTags.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={4} className="px-6 py-12 text-center text-zinc-400 italic">Keine Tags gefunden.</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
filteredTags.map((tag) => (
|
||||||
|
<tr key={tag.id} className="hover:bg-zinc-50 dark:hover:bg-zinc-800/30 transition-colors group">
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<div className="font-bold text-zinc-900 dark:text-zinc-100">
|
||||||
|
{tag.is_system_default ? t(`aroma.${tag.name}`) : tag.name}
|
||||||
|
</div>
|
||||||
|
{tag.is_system_default && (
|
||||||
|
<div className="text-[10px] text-zinc-400 font-mono">{tag.name} (Key)</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<span className={`text-[10px] font-black px-2 py-1 rounded-lg uppercase tracking-widest ${tag.category === 'nose' ? 'bg-green-100 text-green-700' :
|
||||||
|
tag.category === 'taste' ? 'bg-blue-100 text-blue-700' :
|
||||||
|
tag.category === 'finish' ? 'bg-amber-100 text-amber-700' :
|
||||||
|
'bg-zinc-100 text-zinc-700'
|
||||||
|
}`}>
|
||||||
|
{tag.category}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{[1, 2, 3, 4, 5].map((score) => (
|
||||||
|
<button
|
||||||
|
key={score}
|
||||||
|
onClick={() => updatePopularity(tag.id, score)}
|
||||||
|
className={`w-6 h-6 rounded-md flex items-center justify-center text-[10px] font-black transition-all ${tag.popularity_score === score
|
||||||
|
? 'bg-amber-600 text-white shadow-sm'
|
||||||
|
: 'bg-zinc-100 text-zinc-400 dark:bg-zinc-800 hover:bg-zinc-200 dark:hover:bg-zinc-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{score}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<button
|
||||||
|
onClick={() => toggleSystemDefault(tag)}
|
||||||
|
className={`inline-flex items-center gap-1.5 px-2 py-1 rounded-lg text-[10px] font-black uppercase tracking-widest transition-all ${tag.is_system_default
|
||||||
|
? 'bg-amber-600 text-white'
|
||||||
|
: 'bg-zinc-100 text-zinc-400 dark:bg-zinc-800 hover:bg-zinc-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{tag.is_system_default ? <Shield size={10} /> : <User size={10} />}
|
||||||
|
{tag.is_system_default ? 'System' : 'Custom'}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 text-right">
|
||||||
|
<button
|
||||||
|
onClick={() => handleDelete(tag.id)}
|
||||||
|
className="p-2 text-zinc-400 hover:text-red-600 transition-colors opacity-0 group-hover:opacity-100"
|
||||||
|
>
|
||||||
|
<Trash2 size={16} />
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,10 +2,9 @@ import { createServerComponentClient } from '@supabase/auth-helpers-nextjs';
|
|||||||
import { cookies } from 'next/headers';
|
import { cookies } from 'next/headers';
|
||||||
import { notFound } from 'next/navigation';
|
import { notFound } from 'next/navigation';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { ChevronLeft, Calendar, Award, Droplets, MapPin, Tag, ExternalLink, Package, PlusCircle } from 'lucide-react';
|
import { ChevronLeft, Calendar, Award, Droplets, MapPin, Tag, ExternalLink, Package, PlusCircle, Info } from 'lucide-react';
|
||||||
import { getStorageUrl } from '@/lib/supabase';
|
import { getStorageUrl } from '@/lib/supabase';
|
||||||
import TastingNoteForm from '@/components/TastingNoteForm';
|
import TastingNoteForm from '@/components/TastingNoteForm';
|
||||||
import StatusSwitcher from '@/components/StatusSwitcher';
|
|
||||||
import TastingList from '@/components/TastingList';
|
import TastingList from '@/components/TastingList';
|
||||||
import DeleteBottleButton from '@/components/DeleteBottleButton';
|
import DeleteBottleButton from '@/components/DeleteBottleButton';
|
||||||
import EditBottleForm from '@/components/EditBottleForm';
|
import EditBottleForm from '@/components/EditBottleForm';
|
||||||
@@ -48,11 +47,19 @@ export default async function BottlePage({
|
|||||||
id,
|
id,
|
||||||
name
|
name
|
||||||
),
|
),
|
||||||
tasting_tags (
|
tasting_buddies (
|
||||||
buddies (
|
buddies (
|
||||||
id,
|
id,
|
||||||
name
|
name
|
||||||
)
|
)
|
||||||
|
),
|
||||||
|
tasting_tags (
|
||||||
|
tags (
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
category,
|
||||||
|
is_system_default
|
||||||
|
)
|
||||||
)
|
)
|
||||||
`)
|
`)
|
||||||
.eq('bottle_id', params.id)
|
.eq('bottle_id', params.id)
|
||||||
@@ -68,9 +75,11 @@ export default async function BottlePage({
|
|||||||
.select(`
|
.select(`
|
||||||
*,
|
*,
|
||||||
tasting_tags (
|
tasting_tags (
|
||||||
buddies (
|
tags (
|
||||||
id,
|
id,
|
||||||
name
|
name,
|
||||||
|
category,
|
||||||
|
is_system_default
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
`)
|
`)
|
||||||
@@ -125,32 +134,60 @@ export default async function BottlePage({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
|
||||||
<div className="p-4 bg-white dark:bg-zinc-900 rounded-2xl border border-zinc-100 dark:border-zinc-800 shadow-sm flex flex-col justify-between">
|
<div className="p-4 bg-white dark:bg-zinc-900 rounded-2xl border border-zinc-100 dark:border-zinc-800 shadow-sm flex flex-col justify-between">
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-2 text-zinc-400 text-xs font-bold uppercase mb-1">
|
<div className="flex items-center gap-2 text-zinc-400 text-[10px] font-black uppercase mb-1">
|
||||||
<Tag size={14} /> Kategorie
|
<Tag size={12} /> Kategorie
|
||||||
</div>
|
</div>
|
||||||
<div className="font-semibold dark:text-zinc-200">{bottle.category || '-'}</div>
|
<div className="font-bold text-sm dark:text-zinc-200">{bottle.category || '-'}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-4 bg-white dark:bg-zinc-900 rounded-2xl border border-zinc-100 dark:border-zinc-800 shadow-sm">
|
<div className="p-4 bg-white dark:bg-zinc-900 rounded-2xl border border-zinc-100 dark:border-zinc-800 shadow-sm">
|
||||||
<div className="flex items-center gap-2 text-zinc-400 text-xs font-bold uppercase mb-1">
|
<div className="flex items-center gap-2 text-zinc-400 text-[10px] font-black uppercase mb-1">
|
||||||
<Droplets size={14} /> Alkoholgehalt
|
<Droplets size={12} /> Alkoholgehalt
|
||||||
</div>
|
</div>
|
||||||
<div className="font-semibold dark:text-zinc-200">{bottle.abv}% Vol.</div>
|
<div className="font-bold text-sm dark:text-zinc-200">{bottle.abv}% Vol.</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-4 bg-white dark:bg-zinc-900 rounded-2xl border border-zinc-100 dark:border-zinc-800 shadow-sm">
|
<div className="p-4 bg-white dark:bg-zinc-900 rounded-2xl border border-zinc-100 dark:border-zinc-800 shadow-sm">
|
||||||
<div className="flex items-center gap-2 text-zinc-400 text-xs font-bold uppercase mb-1">
|
<div className="flex items-center gap-2 text-zinc-400 text-[10px] font-black uppercase mb-1">
|
||||||
<Award size={14} /> Alter
|
<Award size={12} /> Alter
|
||||||
</div>
|
</div>
|
||||||
<div className="font-semibold dark:text-zinc-200">{bottle.age ? `${bottle.age} Jahre` : '-'}</div>
|
<div className="font-bold text-sm dark:text-zinc-200">{bottle.age ? `${bottle.age} J.` : '-'}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{bottle.distilled_at && (
|
||||||
<div className="p-4 bg-white dark:bg-zinc-900 rounded-2xl border border-zinc-100 dark:border-zinc-800 shadow-sm">
|
<div className="p-4 bg-white dark:bg-zinc-900 rounded-2xl border border-zinc-100 dark:border-zinc-800 shadow-sm">
|
||||||
<div className="flex items-center gap-2 text-zinc-400 text-xs font-bold uppercase mb-1">
|
<div className="flex items-center gap-2 text-zinc-400 text-[10px] font-black uppercase mb-1">
|
||||||
<Calendar size={14} /> Zuletzt verkostet
|
<Calendar size={12} /> Destilliert
|
||||||
</div>
|
</div>
|
||||||
<div className="font-semibold dark:text-zinc-200">
|
<div className="font-bold text-sm dark:text-zinc-200">{bottle.distilled_at}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{bottle.bottled_at && (
|
||||||
|
<div className="p-4 bg-white dark:bg-zinc-900 rounded-2xl border border-zinc-100 dark:border-zinc-800 shadow-sm">
|
||||||
|
<div className="flex items-center gap-2 text-zinc-400 text-[10px] font-black uppercase mb-1">
|
||||||
|
<Package size={12} /> Abgefüllt
|
||||||
|
</div>
|
||||||
|
<div className="font-bold text-sm dark:text-zinc-200">{bottle.bottled_at}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{bottle.batch_info && (
|
||||||
|
<div className="p-4 bg-zinc-50 dark:bg-zinc-800/30 rounded-2xl border border-dashed border-zinc-200 dark:border-zinc-700/50 md:col-span-1">
|
||||||
|
<div className="flex items-center gap-2 text-zinc-400 text-[10px] font-black uppercase mb-1">
|
||||||
|
<Info size={12} /> Batch / Code
|
||||||
|
</div>
|
||||||
|
<div className="font-mono text-xs dark:text-zinc-300 truncate" title={bottle.batch_info}>{bottle.batch_info}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="p-4 bg-white dark:bg-zinc-900 rounded-2xl border border-zinc-100 dark:border-zinc-800 shadow-sm">
|
||||||
|
<div className="flex items-center gap-2 text-zinc-400 text-[10px] font-black uppercase mb-1">
|
||||||
|
<Calendar size={12} /> Letzter Dram
|
||||||
|
</div>
|
||||||
|
<div className="font-bold text-sm dark:text-zinc-200">
|
||||||
{tastings && tastings.length > 0
|
{tastings && tastings.length > 0
|
||||||
? new Date(tastings[0].created_at).toLocaleDateString('de-DE')
|
? new Date(tastings[0].created_at).toLocaleDateString('de-DE')
|
||||||
: 'Noch nie'}
|
: 'Noch nie'}
|
||||||
@@ -158,8 +195,7 @@ export default async function BottlePage({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="pt-2 space-y-4">
|
<div className="pt-2 flex flex-wrap gap-4">
|
||||||
<StatusSwitcher bottleId={bottle.id} currentStatus={bottle.status} />
|
|
||||||
<EditBottleForm bottle={bottle} />
|
<EditBottleForm bottle={bottle} />
|
||||||
<DeleteBottleButton bottleId={bottle.id} />
|
<DeleteBottleButton bottleId={bottle.id} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import React, { useState, useMemo } from 'react';
|
import React, { useState, useMemo } from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { Search, Filter, X, Calendar, Clock, Package, Lock, Unlock, Ghost, FlaskConical, AlertCircle, Trash2, AlertTriangle, PlusCircle } from 'lucide-react';
|
import { Search, Filter, X, Calendar, Clock, Package, FlaskConical, AlertCircle, Trash2, AlertTriangle, PlusCircle } from 'lucide-react';
|
||||||
import { getStorageUrl } from '@/lib/supabase';
|
import { getStorageUrl } from '@/lib/supabase';
|
||||||
import { useSearchParams } from 'next/navigation';
|
import { useSearchParams } from 'next/navigation';
|
||||||
import { validateSession } from '@/services/validate-session';
|
import { validateSession } from '@/services/validate-session';
|
||||||
@@ -19,7 +19,6 @@ interface Bottle {
|
|||||||
age: number;
|
age: number;
|
||||||
image_url: string;
|
image_url: string;
|
||||||
purchase_price?: number | null;
|
purchase_price?: number | null;
|
||||||
status: 'sealed' | 'open' | 'sampled' | 'empty';
|
|
||||||
created_at: string;
|
created_at: string;
|
||||||
last_tasted?: string | null;
|
last_tasted?: string | null;
|
||||||
is_whisky?: boolean;
|
is_whisky?: boolean;
|
||||||
@@ -33,15 +32,6 @@ interface BottleCardProps {
|
|||||||
|
|
||||||
function BottleCard({ bottle, sessionId }: BottleCardProps) {
|
function BottleCard({ bottle, sessionId }: BottleCardProps) {
|
||||||
const { t, locale } = useI18n();
|
const { t, locale } = useI18n();
|
||||||
const statusConfig = {
|
|
||||||
open: { icon: Unlock, color: 'bg-amber-500/80 border-amber-400/50', label: t('bottle.status.open') },
|
|
||||||
sampled: { icon: FlaskConical, color: 'bg-purple-500/80 border-purple-400/50', label: t('bottle.status.sampled') },
|
|
||||||
empty: { icon: Ghost, color: 'bg-zinc-500/80 border-zinc-400/50', label: t('bottle.status.empty') },
|
|
||||||
sealed: { icon: Lock, color: 'bg-blue-600/80 border-blue-400/50', label: t('bottle.status.sealed') },
|
|
||||||
};
|
|
||||||
|
|
||||||
const StatusIcon = statusConfig[bottle.status as keyof typeof statusConfig]?.icon || Lock;
|
|
||||||
const statusStyle = statusConfig[bottle.status as keyof typeof statusConfig] || statusConfig.sealed;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
@@ -71,10 +61,6 @@ function BottleCard({ bottle, sessionId }: BottleCardProps) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className={`absolute bottom-3 left-3 px-3 py-1.5 rounded-xl text-[10px] font-black uppercase flex items-center gap-2 backdrop-blur-md border shadow-lg ${statusStyle.color}`}>
|
|
||||||
<StatusIcon size={12} />
|
|
||||||
{statusStyle.label}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-3 md:p-5 space-y-3 md:space-y-4">
|
<div className="p-3 md:p-5 space-y-3 md:space-y-4">
|
||||||
@@ -144,7 +130,6 @@ export default function BottleGrid({ bottles }: BottleGridProps) {
|
|||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [selectedCategory, setSelectedCategory] = useState<string | null>(null);
|
const [selectedCategory, setSelectedCategory] = useState<string | null>(null);
|
||||||
const [selectedDistillery, setSelectedDistillery] = useState<string | null>(null);
|
const [selectedDistillery, setSelectedDistillery] = useState<string | null>(null);
|
||||||
const [selectedStatus, setSelectedStatus] = useState<string | null>(null);
|
|
||||||
const [sortBy, setSortBy] = useState<'name' | 'last_tasted' | 'created_at'>('created_at');
|
const [sortBy, setSortBy] = useState<'name' | 'last_tasted' | 'created_at'>('created_at');
|
||||||
|
|
||||||
const categories = useMemo(() => {
|
const categories = useMemo(() => {
|
||||||
@@ -174,9 +159,8 @@ export default function BottleGrid({ bottles }: BottleGridProps) {
|
|||||||
|
|
||||||
const matchesCategory = !selectedCategory || bottle.category === selectedCategory;
|
const matchesCategory = !selectedCategory || bottle.category === selectedCategory;
|
||||||
const matchesDistillery = !selectedDistillery || bottle.distillery === selectedDistillery;
|
const matchesDistillery = !selectedDistillery || bottle.distillery === selectedDistillery;
|
||||||
const matchesStatus = !selectedStatus || bottle.status === selectedStatus;
|
|
||||||
|
|
||||||
return matchesSearch && matchesCategory && matchesDistillery && matchesStatus;
|
return matchesSearch && matchesCategory && matchesDistillery;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Sorting logic
|
// Sorting logic
|
||||||
@@ -191,7 +175,7 @@ export default function BottleGrid({ bottles }: BottleGridProps) {
|
|||||||
return new Date(b.created_at).getTime() - new Date(a.created_at).getTime();
|
return new Date(b.created_at).getTime() - new Date(a.created_at).getTime();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}, [bottles, searchQuery, selectedCategory, selectedDistillery, selectedStatus, sortBy]);
|
}, [bottles, searchQuery, selectedCategory, selectedDistillery, sortBy]);
|
||||||
|
|
||||||
const [isFiltersOpen, setIsFiltersOpen] = useState(false);
|
const [isFiltersOpen, setIsFiltersOpen] = useState(false);
|
||||||
|
|
||||||
@@ -203,7 +187,7 @@ export default function BottleGrid({ bottles }: BottleGridProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const activeFiltersCount = (selectedCategory ? 1 : 0) + (selectedDistillery ? 1 : 0) + (selectedStatus ? 1 : 0);
|
const activeFiltersCount = (selectedCategory ? 1 : 0) + (selectedDistillery ? 1 : 0);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full space-y-8">
|
<div className="w-full space-y-8">
|
||||||
@@ -314,23 +298,6 @@ export default function BottleGrid({ bottles }: BottleGridProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-3">
|
|
||||||
<label className="text-[10px] font-black uppercase tracking-[0.2em] text-zinc-400 px-1">{t('grid.filter.status')}</label>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{['sealed', 'open', 'sampled', 'empty'].map((status) => (
|
|
||||||
<button
|
|
||||||
key={status}
|
|
||||||
onClick={() => setSelectedStatus(selectedStatus === status ? null : status)}
|
|
||||||
className={`px-3 py-1.5 rounded-xl text-[10px] font-black transition-all border ${selectedStatus === status
|
|
||||||
? status === 'open' ? 'bg-amber-500 border-amber-500 text-white' : status === 'sampled' ? 'bg-purple-500 border-purple-500 text-white' : status === 'empty' ? 'bg-zinc-500 border-zinc-500 text-white' : 'bg-blue-600 border-blue-600 text-white'
|
|
||||||
: 'bg-zinc-50 dark:bg-zinc-800 border-zinc-200 dark:border-zinc-700 text-zinc-500'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{t(`bottle.status.${status}`).toUpperCase()}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="pt-4 border-t border-zinc-100 dark:border-zinc-800 flex justify-between items-center">
|
<div className="pt-4 border-t border-zinc-100 dark:border-zinc-800 flex justify-between items-center">
|
||||||
@@ -338,7 +305,6 @@ export default function BottleGrid({ bottles }: BottleGridProps) {
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSelectedCategory(null);
|
setSelectedCategory(null);
|
||||||
setSelectedDistillery(null);
|
setSelectedDistillery(null);
|
||||||
setSelectedStatus(null);
|
|
||||||
setSearchQuery('');
|
setSearchQuery('');
|
||||||
}}
|
}}
|
||||||
className="text-[10px] font-black text-red-500 uppercase tracking-widest hover:underline"
|
className="text-[10px] font-black text-red-500 uppercase tracking-widest hover:underline"
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React, { useRef, useState } from 'react';
|
import React, { useRef, useState } from 'react';
|
||||||
import { Camera, Upload, CheckCircle2, AlertCircle, X, Search, ExternalLink, ArrowRight, Loader2, Wand2, Plus, Sparkles, Droplets, ChevronRight } from 'lucide-react';
|
import { Camera, Upload, CheckCircle2, AlertCircle, X, Search, ExternalLink, ArrowRight, Loader2, Wand2, Plus, Sparkles, Droplets, ChevronRight, User } from 'lucide-react';
|
||||||
|
|
||||||
import { createClientComponentClient } from '@supabase/auth-helpers-nextjs';
|
import { createClientComponentClient } from '@supabase/auth-helpers-nextjs';
|
||||||
import { useRouter, useSearchParams } from 'next/navigation';
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
import { analyzeBottle } from '@/services/analyze-bottle';
|
import { analyzeBottle } from '@/services/analyze-bottle';
|
||||||
@@ -17,7 +18,7 @@ import Link from 'next/link';
|
|||||||
import { useI18n } from '@/i18n/I18nContext';
|
import { useI18n } from '@/i18n/I18nContext';
|
||||||
import { useSession } from '@/context/SessionContext';
|
import { useSession } from '@/context/SessionContext';
|
||||||
import { shortenCategory } from '@/lib/format';
|
import { shortenCategory } from '@/lib/format';
|
||||||
|
import { magicScan } from '@/services/magic-scan';
|
||||||
interface CameraCaptureProps {
|
interface CameraCaptureProps {
|
||||||
onImageCaptured?: (base64Image: string) => void;
|
onImageCaptured?: (base64Image: string) => void;
|
||||||
onAnalysisComplete?: (data: BottleMetadata) => void;
|
onAnalysisComplete?: (data: BottleMetadata) => void;
|
||||||
@@ -63,6 +64,23 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS
|
|||||||
const [wbDiscovery, setWbDiscovery] = useState<{ id: string; url: string; title: string } | null>(null);
|
const [wbDiscovery, setWbDiscovery] = useState<{ id: string; url: string; title: string } | null>(null);
|
||||||
const [isDiscovering, setIsDiscovering] = useState(false);
|
const [isDiscovering, setIsDiscovering] = useState(false);
|
||||||
const [originalFile, setOriginalFile] = useState<File | null>(null);
|
const [originalFile, setOriginalFile] = useState<File | null>(null);
|
||||||
|
const [isAdmin, setIsAdmin] = useState(false);
|
||||||
|
const [aiProvider, setAiProvider] = useState<'gemini' | 'nebius'>('gemini');
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const checkAdmin = async () => {
|
||||||
|
const { data: { user } } = await supabase.auth.getUser();
|
||||||
|
if (user) {
|
||||||
|
const { data } = await supabase
|
||||||
|
.from('admin_users')
|
||||||
|
.select('role')
|
||||||
|
.eq('user_id', user.id)
|
||||||
|
.maybeSingle();
|
||||||
|
setIsAdmin(!!data);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
checkAdmin();
|
||||||
|
}, [supabase]);
|
||||||
|
|
||||||
const handleCapture = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
const handleCapture = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const file = event.target.files?.[0];
|
const file = event.target.files?.[0];
|
||||||
@@ -116,11 +134,19 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await analyzeBottle(compressedBase64);
|
const response = await magicScan(compressedBase64, aiProvider);
|
||||||
|
|
||||||
if (response.success && response.data) {
|
if (response.success && response.data) {
|
||||||
setAnalysisResult(response.data);
|
setAnalysisResult(response.data);
|
||||||
|
|
||||||
|
if (response.wb_id) {
|
||||||
|
setWbDiscovery({
|
||||||
|
id: response.wb_id,
|
||||||
|
url: `https://www.whiskybase.com/whiskies/whisky/${response.wb_id}`,
|
||||||
|
title: `${response.data.distillery || ''} ${response.data.name || ''}`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Duplicate Check
|
// Duplicate Check
|
||||||
const match = await findMatchingBottle(response.data);
|
const match = await findMatchingBottle(response.data);
|
||||||
if (match) {
|
if (match) {
|
||||||
@@ -299,8 +325,27 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center gap-4 md:gap-6 w-full max-w-md mx-auto p-4 md:p-6 bg-white dark:bg-zinc-900 rounded-3xl shadow-2xl border border-zinc-200 dark:border-zinc-800 transition-all hover:shadow-whisky-amber/20">
|
<div className="flex flex-col items-center gap-4 md:gap-6 w-full max-w-md mx-auto p-4 md:p-6 bg-white dark:bg-zinc-900 rounded-3xl shadow-2xl border border-zinc-200 dark:border-zinc-800 transition-all hover:shadow-whisky-amber/20">
|
||||||
|
<div className="flex items-center justify-between w-full">
|
||||||
<h2 className="text-xl md:text-2xl font-bold text-zinc-800 dark:text-zinc-100 italic">{t('camera.magicShot')}</h2>
|
<h2 className="text-xl md:text-2xl font-bold text-zinc-800 dark:text-zinc-100 italic">{t('camera.magicShot')}</h2>
|
||||||
|
|
||||||
|
{isAdmin && (
|
||||||
|
<div className="flex items-center gap-1 bg-zinc-100 dark:bg-zinc-800 p-1 rounded-xl border border-zinc-200 dark:border-zinc-700">
|
||||||
|
<button
|
||||||
|
onClick={() => setAiProvider('gemini')}
|
||||||
|
className={`px-3 py-1 text-[10px] font-black uppercase tracking-widest rounded-lg transition-all ${aiProvider === 'gemini' ? 'bg-white dark:bg-zinc-700 text-amber-600 shadow-sm' : 'text-zinc-400'}`}
|
||||||
|
>
|
||||||
|
Gemini
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setAiProvider('nebius')}
|
||||||
|
className={`px-3 py-1 text-[10px] font-black uppercase tracking-widest rounded-lg transition-all ${aiProvider === 'nebius' ? 'bg-white dark:bg-zinc-700 text-amber-600 shadow-sm' : 'text-zinc-400'}`}
|
||||||
|
>
|
||||||
|
Nebius
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className="relative group cursor-pointer w-full aspect-square rounded-2xl border-2 border-dashed border-zinc-300 dark:border-zinc-700 overflow-hidden flex items-center justify-center bg-zinc-50 dark:bg-zinc-800/50 hover:border-amber-500 transition-colors"
|
className="relative group cursor-pointer w-full aspect-square rounded-2xl border-2 border-dashed border-zinc-300 dark:border-zinc-700 overflow-hidden flex items-center justify-center bg-zinc-50 dark:bg-zinc-800/50 hover:border-amber-500 transition-colors"
|
||||||
onClick={triggerUpload}
|
onClick={triggerUpload}
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import { useI18n } from '@/i18n/I18nContext';
|
|||||||
interface Bottle {
|
interface Bottle {
|
||||||
id: string;
|
id: string;
|
||||||
purchase_price?: number | null;
|
purchase_price?: number | null;
|
||||||
status: 'sealed' | 'open' | 'sampled' | 'empty';
|
|
||||||
distillery?: string;
|
distillery?: string;
|
||||||
tastings?: { rating: number }[];
|
tastings?: { rating: number }[];
|
||||||
}
|
}
|
||||||
@@ -19,7 +18,6 @@ interface StatsDashboardProps {
|
|||||||
export default function StatsDashboard({ bottles }: StatsDashboardProps) {
|
export default function StatsDashboard({ bottles }: StatsDashboardProps) {
|
||||||
const { t, locale } = useI18n();
|
const { t, locale } = useI18n();
|
||||||
const stats = useMemo(() => {
|
const stats = useMemo(() => {
|
||||||
const activeBottles = bottles.filter(b => b.status !== 'empty');
|
|
||||||
const totalValue = bottles.reduce((sum, b) => sum + (Number(b.purchase_price) || 0), 0);
|
const totalValue = bottles.reduce((sum, b) => sum + (Number(b.purchase_price) || 0), 0);
|
||||||
|
|
||||||
const ratings = bottles.flatMap(b => b.tastings?.map(t => t.rating) || []);
|
const ratings = bottles.flatMap(b => b.tastings?.map(t => t.rating) || []);
|
||||||
@@ -38,7 +36,6 @@ export default function StatsDashboard({ bottles }: StatsDashboardProps) {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
totalValue,
|
totalValue,
|
||||||
activeCount: activeBottles.length,
|
|
||||||
avgRating,
|
avgRating,
|
||||||
topDistillery,
|
topDistillery,
|
||||||
totalCount: bottles.length
|
totalCount: bottles.length
|
||||||
@@ -55,7 +52,7 @@ export default function StatsDashboard({ bottles }: StatsDashboardProps) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: t('home.stats.activeBottles'),
|
label: t('home.stats.activeBottles'),
|
||||||
value: stats.activeCount,
|
value: stats.totalCount,
|
||||||
icon: Home,
|
icon: Home,
|
||||||
color: 'text-blue-600',
|
color: 'text-blue-600',
|
||||||
bg: 'bg-blue-50 dark:bg-blue-900/20'
|
bg: 'bg-blue-50 dark:bg-blue-900/20'
|
||||||
|
|||||||
@@ -1,72 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import React, { useState } from 'react';
|
|
||||||
import { updateBottleStatus } from '@/services/update-bottle-status';
|
|
||||||
import { Loader2, Package, Play, CheckCircle, FlaskConical } from 'lucide-react';
|
|
||||||
import { useI18n } from '@/i18n/I18nContext';
|
|
||||||
|
|
||||||
interface StatusSwitcherProps {
|
|
||||||
bottleId: string;
|
|
||||||
currentStatus: 'sealed' | 'open' | 'sampled' | 'empty';
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function StatusSwitcher({ bottleId, currentStatus }: StatusSwitcherProps) {
|
|
||||||
const { t } = useI18n();
|
|
||||||
const [status, setStatus] = useState(currentStatus);
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
|
|
||||||
const handleStatusChange = async (newStatus: 'sealed' | 'open' | 'sampled' | 'empty') => {
|
|
||||||
if (newStatus === status || loading) return;
|
|
||||||
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
const result = await updateBottleStatus(bottleId, newStatus);
|
|
||||||
if (result.success) {
|
|
||||||
setStatus(newStatus);
|
|
||||||
} else {
|
|
||||||
alert(result.error || t('common.error'));
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
alert(t('common.error'));
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const options = [
|
|
||||||
{ id: 'sealed', label: t('bottle.status.sealed'), icon: Package, color: 'hover:bg-blue-500' },
|
|
||||||
{ id: 'open', label: t('bottle.status.open'), icon: Play, color: 'hover:bg-amber-500' },
|
|
||||||
{ id: 'sampled', label: t('bottle.status.sampled'), icon: FlaskConical, color: 'hover:bg-purple-500' },
|
|
||||||
{ id: 'empty', label: t('bottle.status.empty'), icon: CheckCircle, color: 'hover:bg-zinc-500' },
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<label className="text-[11px] font-black text-zinc-400 uppercase tracking-widest">{t('bottle.bottleStatus')}</label>
|
|
||||||
{loading && <Loader2 className="animate-spin text-amber-600" size={14} />}
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-4 gap-2 p-1 bg-zinc-100 dark:bg-zinc-900/50 rounded-2xl border border-zinc-200/50 dark:border-zinc-800/50">
|
|
||||||
{options.map((opt) => {
|
|
||||||
const Icon = opt.icon;
|
|
||||||
const isActive = status === opt.id;
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={opt.id}
|
|
||||||
type="button"
|
|
||||||
disabled={loading}
|
|
||||||
onClick={() => handleStatusChange(opt.id)}
|
|
||||||
className={`flex flex-col items-center gap-1.5 py-3 px-1 rounded-xl text-[9px] font-black uppercase tracking-tight transition-all border-2 ${isActive
|
|
||||||
? 'bg-white dark:bg-zinc-700 border-amber-500 text-amber-600 shadow-sm ring-1 ring-black/5'
|
|
||||||
: 'border-transparent text-zinc-400 hover:text-zinc-600 dark:hover:text-zinc-200'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<Icon size={18} className={isActive ? 'text-amber-500' : 'text-zinc-400'} />
|
|
||||||
{opt.label}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
151
src/components/TagSelector.tsx
Normal file
151
src/components/TagSelector.tsx
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState, useEffect, useMemo } from 'react';
|
||||||
|
import { Tag, TagCategory, getTagsByCategory, createCustomTag } from '@/services/tags';
|
||||||
|
import { X, Plus, Search, Check, Loader2, Sparkles } from 'lucide-react';
|
||||||
|
import { useI18n } from '@/i18n/I18nContext';
|
||||||
|
|
||||||
|
interface TagSelectorProps {
|
||||||
|
category: TagCategory;
|
||||||
|
selectedTagIds: string[];
|
||||||
|
onToggleTag: (tagId: string) => void;
|
||||||
|
label?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TagSelector({ category, selectedTagIds, onToggleTag, label }: TagSelectorProps) {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const [tags, setTags] = useState<Tag[]>([]);
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [isCreating, setIsCreating] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchTags = async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
const data = await getTagsByCategory(category);
|
||||||
|
setTags(data);
|
||||||
|
setIsLoading(false);
|
||||||
|
};
|
||||||
|
fetchTags();
|
||||||
|
}, [category]);
|
||||||
|
|
||||||
|
const filteredTags = useMemo(() => {
|
||||||
|
if (!search) return tags;
|
||||||
|
const s = search.toLowerCase();
|
||||||
|
return tags.filter(tag => {
|
||||||
|
const rawMatch = tag.name.toLowerCase().includes(s);
|
||||||
|
const translatedMatch = tag.is_system_default && t(`aroma.${tag.name}`).toLowerCase().includes(s);
|
||||||
|
return rawMatch || translatedMatch;
|
||||||
|
});
|
||||||
|
}, [tags, search, t]);
|
||||||
|
|
||||||
|
const handleCreateTag = async () => {
|
||||||
|
if (!search || isCreating) return;
|
||||||
|
|
||||||
|
setIsCreating(true);
|
||||||
|
const result = await createCustomTag(search, category);
|
||||||
|
if (result.success && result.tag) {
|
||||||
|
setTags(prev => [...prev, result.tag!]);
|
||||||
|
onToggleTag(result.tag!.id);
|
||||||
|
setSearch('');
|
||||||
|
}
|
||||||
|
setIsCreating(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectedTags = tags.filter(t => selectedTagIds.includes(t.id));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{label && (
|
||||||
|
<label className="text-[11px] font-black text-zinc-400 uppercase tracking-widest block">{label}</label>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Selected Tags */}
|
||||||
|
<div className="flex flex-wrap gap-2 min-h-[32px]">
|
||||||
|
{selectedTags.length > 0 ? (
|
||||||
|
selectedTags.map(tag => (
|
||||||
|
<button
|
||||||
|
key={tag.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onToggleTag(tag.id)}
|
||||||
|
className="inline-flex items-center gap-1.5 px-3 py-1.5 bg-amber-600 text-white rounded-full text-[10px] font-black uppercase tracking-tight shadow-sm shadow-amber-600/20 animate-in fade-in zoom-in-95"
|
||||||
|
>
|
||||||
|
{tag.is_system_default ? t(`aroma.${tag.name}`) : tag.name}
|
||||||
|
<X size={12} />
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<span className="text-[10px] italic text-zinc-400 font-medium">Noch keine Tags gewählt...</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search and Suggest */}
|
||||||
|
<div className="relative">
|
||||||
|
<div className="relative flex items-center">
|
||||||
|
<Search className="absolute left-3 text-zinc-400" size={14} />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
placeholder="Tag suchen oder hinzufügen..."
|
||||||
|
className="w-full pl-9 pr-4 py-2 bg-zinc-50 dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-xl text-xs focus:ring-2 focus:ring-amber-500 outline-none transition-all dark:text-zinc-200 placeholder:text-zinc-400"
|
||||||
|
/>
|
||||||
|
{isCreating && (
|
||||||
|
<Loader2 className="absolute right-3 animate-spin text-amber-600" size={14} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{search && (
|
||||||
|
<div className="absolute z-10 w-full mt-2 bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-2xl shadow-xl overflow-hidden animate-in fade-in slide-in-from-top-2">
|
||||||
|
<div className="max-h-48 overflow-y-auto">
|
||||||
|
{filteredTags.length > 0 ? (
|
||||||
|
filteredTags.map(tag => (
|
||||||
|
<button
|
||||||
|
key={tag.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
onToggleTag(tag.id);
|
||||||
|
setSearch('');
|
||||||
|
}}
|
||||||
|
className="w-full px-4 py-2.5 text-left text-xs font-bold text-zinc-700 dark:text-zinc-300 hover:bg-zinc-50 dark:hover:bg-zinc-700/50 flex items-center justify-between border-b border-zinc-100 dark:border-zinc-700 last:border-0"
|
||||||
|
>
|
||||||
|
{tag.is_system_default ? t(`aroma.${tag.name}`) : tag.name}
|
||||||
|
{selectedTagIds.includes(tag.id) && <Check size={12} className="text-amber-600" />}
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleCreateTag}
|
||||||
|
className="w-full px-4 py-3 text-left text-xs font-bold text-amber-600 hover:bg-amber-50 dark:hover:bg-amber-900/10 flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<Plus size={14} />
|
||||||
|
"{search}" als neuen Tag hinzufügen
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Suggestions Chips (limit to 6 random or most common) */}
|
||||||
|
{!search && tags.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1.5 pt-1">
|
||||||
|
{tags
|
||||||
|
.filter(t => !selectedTagIds.includes(t.id))
|
||||||
|
.slice(0, 8)
|
||||||
|
.map(tag => (
|
||||||
|
<button
|
||||||
|
key={tag.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onToggleTag(tag.id)}
|
||||||
|
className="px-2.5 py-1 rounded-lg bg-zinc-100 dark:bg-zinc-800 text-zinc-500 dark:text-zinc-400 text-[10px] font-bold uppercase tracking-tight hover:bg-zinc-200 dark:hover:bg-zinc-700 hover:text-zinc-700 dark:hover:text-zinc-200 transition-colors border border-zinc-200/50 dark:border-zinc-700/50"
|
||||||
|
>
|
||||||
|
{tag.is_system_default ? t(`aroma.${tag.name}`) : tag.name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -16,7 +16,7 @@ interface Tasting {
|
|||||||
is_sample?: boolean;
|
is_sample?: boolean;
|
||||||
bottle_id: string;
|
bottle_id: string;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
tasting_tags?: {
|
tasting_buddies?: {
|
||||||
buddies: {
|
buddies: {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -26,6 +26,14 @@ interface Tasting {
|
|||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
};
|
};
|
||||||
|
tasting_tags?: {
|
||||||
|
tags: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
category: string;
|
||||||
|
is_system_default: boolean;
|
||||||
|
}
|
||||||
|
}[];
|
||||||
user_id: string;
|
user_id: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -188,13 +196,30 @@ export default function TastingList({ initialTastings, currentUserId }: TastingL
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{note.tasting_tags && note.tasting_tags.length > 0 && (
|
{note.tasting_tags && note.tasting_tags.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1.5 pt-2">
|
||||||
|
{note.tasting_tags.map(tt => (
|
||||||
|
<span
|
||||||
|
key={tt.tags.id}
|
||||||
|
className={`px-2 py-0.5 rounded-lg text-[10px] font-bold uppercase tracking-tight border ${tt.tags.category === 'nose' ? 'bg-green-50 text-green-700 border-green-100 dark:bg-green-900/20 dark:text-green-400 dark:border-green-800/50' :
|
||||||
|
tt.tags.category === 'taste' ? 'bg-blue-50 text-blue-700 border-blue-100 dark:bg-blue-900/20 dark:text-blue-400 dark:border-blue-800/50' :
|
||||||
|
tt.tags.category === 'finish' ? 'bg-amber-50 text-amber-700 border-amber-100 dark:bg-amber-900/20 dark:text-amber-400 dark:border-amber-800/50' :
|
||||||
|
'bg-zinc-50 text-zinc-700 border-zinc-100 dark:bg-zinc-900/20 dark:text-zinc-400 dark:border-zinc-800/50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{tt.tags.is_system_default ? t(`aroma.${tt.tags.name}`) : tt.tags.name}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{note.tasting_buddies && note.tasting_buddies.length > 0 && (
|
||||||
<div className="pt-3 flex items-center justify-between border-t border-zinc-100 dark:border-zinc-800">
|
<div className="pt-3 flex items-center justify-between border-t border-zinc-100 dark:border-zinc-800">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-[10px] font-black text-zinc-400 uppercase tracking-widest flex items-center gap-1.5 mr-1">
|
<span className="text-[10px] font-black text-zinc-400 uppercase tracking-widest flex items-center gap-1.5 mr-1">
|
||||||
<Users size={12} className="text-amber-500" />
|
<Users size={12} className="text-amber-500" />
|
||||||
{t('tasting.with') || 'Mit'}:
|
{t('tasting.with') || 'Mit'}:
|
||||||
</span>
|
</span>
|
||||||
<AvatarStack names={note.tasting_tags.map(tag => tag.buddies.name)} />
|
<AvatarStack names={note.tasting_buddies.map(tag => tag.buddies.name)} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -2,10 +2,11 @@
|
|||||||
|
|
||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { saveTasting } from '@/services/save-tasting';
|
import { saveTasting } from '@/services/save-tasting';
|
||||||
import { Loader2, Send, Star, Users, Check, Sparkles, Droplets } from 'lucide-react';
|
import { Loader2, Send, Star, Users, Check, Sparkles, Droplets, Wind, Utensils, Zap } from 'lucide-react';
|
||||||
import { createClientComponentClient } from '@supabase/auth-helpers-nextjs';
|
import { createClientComponentClient } from '@supabase/auth-helpers-nextjs';
|
||||||
import { useI18n } from '@/i18n/I18nContext';
|
import { useI18n } from '@/i18n/I18nContext';
|
||||||
import { useSession } from '@/context/SessionContext';
|
import { useSession } from '@/context/SessionContext';
|
||||||
|
import TagSelector from './TagSelector';
|
||||||
|
|
||||||
interface Buddy {
|
interface Buddy {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -29,6 +30,9 @@ export default function TastingNoteForm({ bottleId, sessionId }: TastingNoteForm
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [buddies, setBuddies] = useState<Buddy[]>([]);
|
const [buddies, setBuddies] = useState<Buddy[]>([]);
|
||||||
const [selectedBuddyIds, setSelectedBuddyIds] = useState<string[]>([]);
|
const [selectedBuddyIds, setSelectedBuddyIds] = useState<string[]>([]);
|
||||||
|
const [noseTagIds, setNoseTagIds] = useState<string[]>([]);
|
||||||
|
const [palateTagIds, setPalateTagIds] = useState<string[]>([]);
|
||||||
|
const [finishTagIds, setFinishTagIds] = useState<string[]>([]);
|
||||||
const { activeSession } = useSession();
|
const { activeSession } = useSession();
|
||||||
|
|
||||||
const effectiveSessionId = sessionId || activeSession?.id;
|
const effectiveSessionId = sessionId || activeSession?.id;
|
||||||
@@ -62,6 +66,18 @@ export default function TastingNoteForm({ bottleId, sessionId }: TastingNoteForm
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const toggleNoseTag = (id: string) => {
|
||||||
|
setNoseTagIds(prev => prev.includes(id) ? prev.filter(tid => tid !== id) : [...prev, id]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const togglePalateTag = (id: string) => {
|
||||||
|
setPalateTagIds(prev => prev.includes(id) ? prev.filter(tid => tid !== id) : [...prev, id]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleFinishTag = (id: string) => {
|
||||||
|
setFinishTagIds(prev => prev.includes(id) ? prev.filter(tid => tid !== id) : [...prev, id]);
|
||||||
|
};
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -77,6 +93,7 @@ export default function TastingNoteForm({ bottleId, sessionId }: TastingNoteForm
|
|||||||
finish_notes: finish,
|
finish_notes: finish,
|
||||||
is_sample: isSample,
|
is_sample: isSample,
|
||||||
buddy_ids: selectedBuddyIds,
|
buddy_ids: selectedBuddyIds,
|
||||||
|
tag_ids: [...noseTagIds, ...palateTagIds, ...finishTagIds],
|
||||||
});
|
});
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
@@ -84,6 +101,9 @@ export default function TastingNoteForm({ bottleId, sessionId }: TastingNoteForm
|
|||||||
setPalate('');
|
setPalate('');
|
||||||
setFinish('');
|
setFinish('');
|
||||||
setSelectedBuddyIds([]);
|
setSelectedBuddyIds([]);
|
||||||
|
setNoseTagIds([]);
|
||||||
|
setPalateTagIds([]);
|
||||||
|
setFinishTagIds([]);
|
||||||
// We don't need to manually refresh because of revalidatePath in the server action
|
// We don't need to manually refresh because of revalidatePath in the server action
|
||||||
} else {
|
} else {
|
||||||
setError(result.error || t('common.error'));
|
setError(result.error || t('common.error'));
|
||||||
@@ -158,38 +178,72 @@ export default function TastingNoteForm({ bottleId, sessionId }: TastingNoteForm
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="bg-zinc-50 dark:bg-zinc-800/50 p-4 rounded-2xl border border-zinc-200 dark:border-zinc-700/50 space-y-4">
|
||||||
|
<TagSelector
|
||||||
|
category="nose"
|
||||||
|
selectedTagIds={noseTagIds}
|
||||||
|
onToggleTag={toggleNoseTag}
|
||||||
|
label={t('tasting.nose')}
|
||||||
|
/>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-xs font-bold text-zinc-400 uppercase tracking-tighter">{t('tasting.nose')}</label>
|
<label className="text-[10px] font-black text-zinc-400 uppercase tracking-widest block opacity-50">Zusätzliche Notizen</label>
|
||||||
<textarea
|
<textarea
|
||||||
value={nose}
|
value={nose}
|
||||||
onChange={(e) => setNose(e.target.value)}
|
onChange={(e) => setNose(e.target.value)}
|
||||||
placeholder={t('tasting.notesPlaceholder')}
|
placeholder={t('tasting.notesPlaceholder')}
|
||||||
rows={2}
|
rows={2}
|
||||||
className="w-full p-3 bg-zinc-50 dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-xl text-sm focus:ring-2 focus:ring-amber-500 outline-none resize-none transition-all dark:text-zinc-200"
|
className="w-full p-3 bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-700 rounded-xl text-sm focus:ring-2 focus:ring-amber-500 outline-none resize-none transition-all dark:text-zinc-200"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-zinc-50 dark:bg-zinc-800/50 p-4 rounded-2xl border border-zinc-200 dark:border-zinc-700/50 space-y-4">
|
||||||
|
<TagSelector
|
||||||
|
category="taste"
|
||||||
|
selectedTagIds={palateTagIds}
|
||||||
|
onToggleTag={togglePalateTag}
|
||||||
|
label={t('tasting.palate')}
|
||||||
|
/>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-xs font-bold text-zinc-400 uppercase tracking-tighter">{t('tasting.palate')}</label>
|
<label className="text-[10px] font-black text-zinc-400 uppercase tracking-widest block opacity-50">Zusätzliche Notizen</label>
|
||||||
<textarea
|
<textarea
|
||||||
value={palate}
|
value={palate}
|
||||||
onChange={(e) => setPalate(e.target.value)}
|
onChange={(e) => setPalate(e.target.value)}
|
||||||
placeholder={t('tasting.notesPlaceholder')}
|
placeholder={t('tasting.notesPlaceholder')}
|
||||||
rows={2}
|
rows={2}
|
||||||
className="w-full p-3 bg-zinc-50 dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-xl text-sm focus:ring-2 focus:ring-amber-500 outline-none resize-none transition-all dark:text-zinc-200"
|
className="w-full p-3 bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-700 rounded-xl text-sm focus:ring-2 focus:ring-amber-500 outline-none resize-none transition-all dark:text-zinc-200"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-zinc-50 dark:bg-zinc-800/50 p-4 rounded-2xl border border-zinc-200 dark:border-zinc-700/50 space-y-6">
|
||||||
|
<TagSelector
|
||||||
|
category="finish"
|
||||||
|
selectedTagIds={finishTagIds}
|
||||||
|
onToggleTag={toggleFinishTag}
|
||||||
|
label={t('tasting.finish')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TagSelector
|
||||||
|
category="texture"
|
||||||
|
selectedTagIds={finishTagIds} // Using finish state for texture for now, or separate if needed
|
||||||
|
onToggleTag={toggleFinishTag}
|
||||||
|
label="Textur & Mundgefühl"
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-xs font-bold text-zinc-400 uppercase tracking-tighter">{t('tasting.finish')}</label>
|
<label className="text-[10px] font-black text-zinc-400 uppercase tracking-widest block opacity-50">Zusätzliche Notizen</label>
|
||||||
<textarea
|
<textarea
|
||||||
value={finish}
|
value={finish}
|
||||||
onChange={(e) => setFinish(e.target.value)}
|
onChange={(e) => setFinish(e.target.value)}
|
||||||
placeholder={t('tasting.notesPlaceholder')}
|
placeholder={t('tasting.notesPlaceholder')}
|
||||||
rows={2}
|
rows={2}
|
||||||
className="w-full p-3 bg-zinc-50 dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-xl text-sm focus:ring-2 focus:ring-amber-500 outline-none resize-none transition-all dark:text-zinc-200"
|
className="w-full p-3 bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-700 rounded-xl text-sm focus:ring-2 focus:ring-amber-500 outline-none resize-none transition-all dark:text-zinc-200"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{buddies.length > 0 && (
|
{buddies.length > 0 && (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
|
|||||||
@@ -165,4 +165,91 @@ export const de: TranslationKeys = {
|
|||||||
noSessions: 'Noch keine Sessions vorhanden.',
|
noSessions: 'Noch keine Sessions vorhanden.',
|
||||||
expiryWarning: 'Diese Session läuft bald ab.',
|
expiryWarning: 'Diese Session läuft bald ab.',
|
||||||
},
|
},
|
||||||
|
aroma: {
|
||||||
|
'Apfel': 'Apfel',
|
||||||
|
'Grüner Apfel': 'Grüner Apfel',
|
||||||
|
'Bratapfel': 'Bratapfel',
|
||||||
|
'Birne': 'Birne',
|
||||||
|
'Zitrus': 'Zitrus',
|
||||||
|
'Zitrone': 'Zitrone',
|
||||||
|
'Orange': 'Orange',
|
||||||
|
'Orangenschale': 'Orangenschale',
|
||||||
|
'Pfirsich': 'Pfirsich',
|
||||||
|
'Aprikose': 'Aprikose',
|
||||||
|
'Banane': 'Banane',
|
||||||
|
'Ananas': 'Ananas',
|
||||||
|
'Tropische Früchte': 'Tropische Früchte',
|
||||||
|
'Kirsche': 'Kirsche',
|
||||||
|
'Beeren': 'Beeren',
|
||||||
|
'Brombeere': 'Brombeere',
|
||||||
|
'Himbeere': 'Himbeere',
|
||||||
|
'Pflaume': 'Pflaume',
|
||||||
|
'Trockenfrüchte': 'Trockenfrüchte',
|
||||||
|
'Rosinen': 'Rosinen',
|
||||||
|
'Datteln': 'Datteln',
|
||||||
|
'Feigen': 'Feigen',
|
||||||
|
'Vanille': 'Vanille',
|
||||||
|
'Honig': 'Honig',
|
||||||
|
'Karamell': 'Karamell',
|
||||||
|
'Toffee': 'Toffee',
|
||||||
|
'Schokolade': 'Schokolade',
|
||||||
|
'Zartbitterschokolade': 'Zartbitterschokolade',
|
||||||
|
'Milchschokolade': 'Milchschokolade',
|
||||||
|
'Malz': 'Malz',
|
||||||
|
'Müsli': 'Müsli',
|
||||||
|
'Butter': 'Butter',
|
||||||
|
'Butterkeks': 'Butterkeks',
|
||||||
|
'Marzipan': 'Marzipan',
|
||||||
|
'Mandel': 'Mandel',
|
||||||
|
'Sahnebonbon': 'Sahnebonbon',
|
||||||
|
'Eiche': 'Eiche',
|
||||||
|
'Zimt': 'Zimt',
|
||||||
|
'Pfeffer': 'Pfeffer',
|
||||||
|
'Muskatnuss': 'Muskatnuss',
|
||||||
|
'Ingwer': 'Ingwer',
|
||||||
|
'Nelke': 'Nelke',
|
||||||
|
'Walnuss': 'Walnuss',
|
||||||
|
'Haselnuss': 'Haselnuss',
|
||||||
|
'Geröstete Nüsse': 'Geröstete Nüsse',
|
||||||
|
'Lagerfeuer': 'Lagerfeuer',
|
||||||
|
'Holzkohle': 'Holzkohle',
|
||||||
|
'Torfrauch': 'Torfrauch',
|
||||||
|
'Asche': 'Asche',
|
||||||
|
'Jod': 'Jod',
|
||||||
|
'Medizinisch': 'Medizinisch',
|
||||||
|
'Teer': 'Teer',
|
||||||
|
'Asphalt': 'Asphalt',
|
||||||
|
'Geräucherter Schinken': 'Geräucherter Schinken',
|
||||||
|
'Speck': 'Speck',
|
||||||
|
'Grillfleisch': 'Grillfleisch',
|
||||||
|
'Meersalz': 'Meersalz',
|
||||||
|
'Salzlake': 'Salzlake',
|
||||||
|
'Seetang': 'Seetang',
|
||||||
|
'Algen': 'Algen',
|
||||||
|
'Austern': 'Austern',
|
||||||
|
'Meeresbrise': 'Meeresbrise',
|
||||||
|
'Heidekraut': 'Heidekraut',
|
||||||
|
'Gras': 'Gras',
|
||||||
|
'Heu': 'Heu',
|
||||||
|
'Minze': 'Minze',
|
||||||
|
'Menthol': 'Menthol',
|
||||||
|
'Eukalyptus': 'Eukalyptus',
|
||||||
|
'Tabak': 'Tabak',
|
||||||
|
'Leder': 'Leder',
|
||||||
|
'Tee': 'Tee',
|
||||||
|
'Kurz & Knackig': 'Kurz & Knackig',
|
||||||
|
'Mittellang': 'Mittellang',
|
||||||
|
'Lang anhaltend': 'Lang anhaltend',
|
||||||
|
'Ewig': 'Ewig',
|
||||||
|
'Ölig': 'Ölig',
|
||||||
|
'Viskos': 'Viskos',
|
||||||
|
'Trocken': 'Trocken',
|
||||||
|
'Adstringierend': 'Adstringierend',
|
||||||
|
'Wärmend': 'Wärmend',
|
||||||
|
'Scharf': 'Scharf',
|
||||||
|
'Beißend': 'Beißend',
|
||||||
|
'Weich': 'Weich',
|
||||||
|
'Samtig': 'Samtig',
|
||||||
|
'Wässrig': 'Wässrig',
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -165,4 +165,91 @@ export const en: TranslationKeys = {
|
|||||||
noSessions: 'No sessions yet.',
|
noSessions: 'No sessions yet.',
|
||||||
expiryWarning: 'This session will expire soon.',
|
expiryWarning: 'This session will expire soon.',
|
||||||
},
|
},
|
||||||
|
aroma: {
|
||||||
|
'Apfel': 'Apple',
|
||||||
|
'Grüner Apfel': 'Green Apple',
|
||||||
|
'Bratapfel': 'Baked Apple',
|
||||||
|
'Birne': 'Pear',
|
||||||
|
'Zitrus': 'Citrus',
|
||||||
|
'Zitrone': 'Lemon',
|
||||||
|
'Orange': 'Orange',
|
||||||
|
'Orangenschale': 'Orange Peel',
|
||||||
|
'Pfirsich': 'Peach',
|
||||||
|
'Aprikose': 'Apricot',
|
||||||
|
'Banane': 'Banana',
|
||||||
|
'Ananas': 'Pineapple',
|
||||||
|
'Tropische Früchte': 'Tropical Fruits',
|
||||||
|
'Kirsche': 'Cherry',
|
||||||
|
'Beeren': 'Berries',
|
||||||
|
'Brombeere': 'Blackberry',
|
||||||
|
'Himbeere': 'Raspberry',
|
||||||
|
'Pflaume': 'Plum',
|
||||||
|
'Trockenfrüchte': 'Dried Fruits',
|
||||||
|
'Rosinen': 'Raisins',
|
||||||
|
'Datteln': 'Dates',
|
||||||
|
'Feigen': 'Figs',
|
||||||
|
'Vanille': 'Vanilla',
|
||||||
|
'Honig': 'Honey',
|
||||||
|
'Karamell': 'Caramel',
|
||||||
|
'Toffee': 'Toffee',
|
||||||
|
'Schokolade': 'Chocolate',
|
||||||
|
'Zartbitterschokolade': 'Dark Chocolate',
|
||||||
|
'Milchschokolade': 'Milk Chocolate',
|
||||||
|
'Malz': 'Malt',
|
||||||
|
'Müsli': 'Cereal',
|
||||||
|
'Butter': 'Butter',
|
||||||
|
'Butterkeks': 'Butter Cookie',
|
||||||
|
'Marzipan': 'Marzipan',
|
||||||
|
'Mandel': 'Almond',
|
||||||
|
'Sahnebonbon': 'Butterscotch',
|
||||||
|
'Eiche': 'Oak',
|
||||||
|
'Zimt': 'Cinnamon',
|
||||||
|
'Pfeffer': 'Pepper',
|
||||||
|
'Muskatnuss': 'Nutmeg',
|
||||||
|
'Ingwer': 'Ginger',
|
||||||
|
'Nelke': 'Clove',
|
||||||
|
'Walnuss': 'Walnut',
|
||||||
|
'Haselnuss': 'Hazelnut',
|
||||||
|
'Geröstete Nüsse': 'Roasted Nuts',
|
||||||
|
'Lagerfeuer': 'Campfire',
|
||||||
|
'Holzkohle': 'Charcoal',
|
||||||
|
'Torfrauch': 'Peat Smoke',
|
||||||
|
'Asche': 'Ash',
|
||||||
|
'Jod': 'Iodine',
|
||||||
|
'Medizinisch': 'Medicinal',
|
||||||
|
'Teer': 'Tar',
|
||||||
|
'Asphalt': 'Asphalt',
|
||||||
|
'Geräucherter Schinken': 'Smoked Ham',
|
||||||
|
'Speck': 'Bacon',
|
||||||
|
'Grillfleisch': 'Grilled Meat',
|
||||||
|
'Meersalz': 'Sea Salt',
|
||||||
|
'Salzlake': 'Brine',
|
||||||
|
'Seetang': 'Seaweed',
|
||||||
|
'Algen': 'Algae',
|
||||||
|
'Austern': 'Oysters',
|
||||||
|
'Meeresbrise': 'Sea Breeze',
|
||||||
|
'Heidekraut': 'Heather',
|
||||||
|
'Gras': 'Grass',
|
||||||
|
'Heu': 'Hay',
|
||||||
|
'Minze': 'Mint',
|
||||||
|
'Menthol': 'Menthol',
|
||||||
|
'Eukalyptus': 'Eucalyptus',
|
||||||
|
'Tabak': 'Tobacco',
|
||||||
|
'Leder': 'Leather',
|
||||||
|
'Tee': 'Tea',
|
||||||
|
'Kurz & Knackig': 'Short & Sharp',
|
||||||
|
'Mittellang': 'Medium',
|
||||||
|
'Lang anhaltend': 'Long Finish',
|
||||||
|
'Ewig': 'Eternal',
|
||||||
|
'Ölig': 'Oily',
|
||||||
|
'Viskos': 'Viscous',
|
||||||
|
'Trocken': 'Dry',
|
||||||
|
'Adstringierend': 'Astringent',
|
||||||
|
'Wärmend': 'Warming',
|
||||||
|
'Scharf': 'Sharp',
|
||||||
|
'Beißend': 'Biting',
|
||||||
|
'Weich': 'Soft',
|
||||||
|
'Samtig': 'Velvety',
|
||||||
|
'Wässrig': 'Watery',
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -163,4 +163,5 @@ export type TranslationKeys = {
|
|||||||
noSessions: string;
|
noSessions: string;
|
||||||
expiryWarning: string;
|
expiryWarning: string;
|
||||||
};
|
};
|
||||||
|
aroma: Record<string, string>;
|
||||||
};
|
};
|
||||||
|
|||||||
6
src/lib/ai-client.ts
Normal file
6
src/lib/ai-client.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { OpenAI } from 'openai';
|
||||||
|
|
||||||
|
export const aiClient = new OpenAI({
|
||||||
|
baseURL: 'https://api.tokenfactory.nebius.com/v1/',
|
||||||
|
apiKey: process.env.NEBIUS_API_KEY,
|
||||||
|
});
|
||||||
19
src/lib/supabase-admin.ts
Normal file
19
src/lib/supabase-admin.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { createClient } from '@supabase/supabase-js';
|
||||||
|
|
||||||
|
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||||
|
const supabaseServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
|
||||||
|
|
||||||
|
if (!supabaseUrl || !supabaseServiceKey) {
|
||||||
|
if (process.env.NODE_ENV !== 'production') {
|
||||||
|
console.warn('Supabase Admin Error: SUPABASE_SERVICE_ROLE_KEY is missing.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const supabaseAdmin = (supabaseUrl && supabaseServiceKey)
|
||||||
|
? createClient(supabaseUrl, supabaseServiceKey, {
|
||||||
|
auth: {
|
||||||
|
autoRefreshToken: false,
|
||||||
|
persistSession: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
: null as any;
|
||||||
124
src/services/analyze-bottle-nebius.ts
Normal file
124
src/services/analyze-bottle-nebius.ts
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
'use server';
|
||||||
|
|
||||||
|
import { aiClient } from '@/lib/ai-client';
|
||||||
|
import { SYSTEM_INSTRUCTION as GEMINI_SYSTEM_INSTRUCTION } from '@/lib/gemini';
|
||||||
|
import { BottleMetadataSchema, AnalysisResponse, BottleMetadata } from '@/types/whisky';
|
||||||
|
import { createServerActionClient } from '@supabase/auth-helpers-nextjs';
|
||||||
|
import { cookies } from 'next/headers';
|
||||||
|
import { createHash } from 'crypto';
|
||||||
|
import { trackApiUsage } from './track-api-usage';
|
||||||
|
import { checkCreditBalance, deductCredits } from './credit-service';
|
||||||
|
|
||||||
|
export async function analyzeBottleNebius(base64Image: string): Promise<AnalysisResponse & { search_string?: string }> {
|
||||||
|
const supabase = createServerActionClient({ cookies });
|
||||||
|
|
||||||
|
if (!process.env.NEBIUS_API_KEY) {
|
||||||
|
return { success: false, error: 'NEBIUS_API_KEY is not configured.' };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { data: { session } } = await supabase.auth.getSession();
|
||||||
|
if (!session || !session.user) {
|
||||||
|
return { success: false, error: 'Nicht autorisiert oder Session abgelaufen.' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = session.user.id;
|
||||||
|
|
||||||
|
// Check credit balance (using same gemini_ai type for now or create new one)
|
||||||
|
const creditCheck = await checkCreditBalance(userId, 'gemini_ai');
|
||||||
|
if (!creditCheck.allowed) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: `Nicht genügend Credits. Du benötigst ${creditCheck.cost} Credits, hast aber nur ${creditCheck.balance}.`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const base64Data = base64Image.split(',')[1] || base64Image;
|
||||||
|
const imageHash = createHash('sha256').update(base64Data).digest('hex');
|
||||||
|
|
||||||
|
// Check Cache (Optional: skip if you want fresh AI results for testing)
|
||||||
|
const { data: cachedResult } = await supabase
|
||||||
|
.from('vision_cache')
|
||||||
|
.select('result')
|
||||||
|
.eq('hash', imageHash)
|
||||||
|
.maybeSingle();
|
||||||
|
|
||||||
|
if (cachedResult) {
|
||||||
|
console.log(`[Nebius Cache] Hit! hash: ${imageHash}`);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: cachedResult.result as any,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[Nebius AI] Calling Qwen2.5-VL...`);
|
||||||
|
|
||||||
|
const response = await aiClient.chat.completions.create({
|
||||||
|
model: "Qwen/Qwen2.5-VL-72B-Instruct",
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
role: "system",
|
||||||
|
content: GEMINI_SYSTEM_INSTRUCTION + "\nAdditionally, generate a 'search_string' field for Whiskybase in this format: 'site:whiskybase.com [Distillery] [Name] [Vintage]'. Include this field in the JSON object."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: "user",
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: "Extract whisky metadata from this image."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "image_url",
|
||||||
|
image_url: {
|
||||||
|
url: `data:image/jpeg;base64,${base64Data}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
response_format: { type: "json_object" }
|
||||||
|
});
|
||||||
|
|
||||||
|
const content = response.choices[0].message.content;
|
||||||
|
if (!content) throw new Error('Empty response from Nebius AI');
|
||||||
|
|
||||||
|
// Extract JSON content in case the model wraps it in markdown blocks
|
||||||
|
const jsonContent = content.match(/\{[\s\S]*\}/)?.[0] || content;
|
||||||
|
const jsonData = JSON.parse(jsonContent);
|
||||||
|
|
||||||
|
// Extract search_string before validation if it's not in schema
|
||||||
|
const searchString = jsonData.search_string;
|
||||||
|
delete jsonData.search_string;
|
||||||
|
|
||||||
|
const validatedData = BottleMetadataSchema.parse(jsonData);
|
||||||
|
|
||||||
|
// Track usage
|
||||||
|
await trackApiUsage({
|
||||||
|
userId: userId,
|
||||||
|
apiType: 'gemini_ai', // Keep tracking as gemini_ai for budget or separate later
|
||||||
|
endpoint: 'nebius/qwen2.5-vl',
|
||||||
|
success: true
|
||||||
|
});
|
||||||
|
|
||||||
|
// Deduct credits
|
||||||
|
await deductCredits(userId, 'gemini_ai', 'Nebius AI analysis');
|
||||||
|
|
||||||
|
// Store in Cache
|
||||||
|
await supabase
|
||||||
|
.from('vision_cache')
|
||||||
|
.insert({ hash: imageHash, result: validatedData });
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: validatedData,
|
||||||
|
search_string: searchString
|
||||||
|
};
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Nebius Analysis Error:', error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : 'Nebius AI analysis failed.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
60
src/services/brave-search.ts
Normal file
60
src/services/brave-search.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
'use server';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service to search Brave for a Whiskybase link and extract the ID.
|
||||||
|
*/
|
||||||
|
export async function searchBraveForWhiskybase(query: string) {
|
||||||
|
const apiKey = process.env.BRAVE_API_KEY;
|
||||||
|
|
||||||
|
if (!apiKey) {
|
||||||
|
console.error('BRAVE_API_KEY is not configured.');
|
||||||
|
return { success: false, error: 'Brave Search API Key missing.' };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = `https://api.search.brave.com/res/v1/web/search?q=${encodeURIComponent(query + ' site:whiskybase.com')}`;
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
headers: {
|
||||||
|
'X-Subscription-Token': apiKey,
|
||||||
|
'Accept': 'application/json',
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json();
|
||||||
|
throw new Error(errorData.message || `Brave API error: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (!data.web || !data.web.results || data.web.results.length === 0) {
|
||||||
|
return { success: false, error: 'No results found on Brave.' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to find a Whiskybase ID in the results
|
||||||
|
const wbRegex = /whiskybase\.com\/whiskies\/whisky\/(\d+)\//;
|
||||||
|
|
||||||
|
for (const result of data.web.results) {
|
||||||
|
const url = result.url;
|
||||||
|
const match = url.match(wbRegex);
|
||||||
|
|
||||||
|
if (match && match[1]) {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
id: match[1],
|
||||||
|
url: url,
|
||||||
|
title: result.title
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: false, error: 'No valid Whiskybase ID found in results.' };
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Brave Search Error:', error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : 'Unknown error during Brave search.'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
87
src/services/magic-scan.ts
Normal file
87
src/services/magic-scan.ts
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
'use server';
|
||||||
|
|
||||||
|
import { analyzeBottle } from './analyze-bottle';
|
||||||
|
import { analyzeBottleNebius } from './analyze-bottle-nebius';
|
||||||
|
import { searchBraveForWhiskybase } from './brave-search';
|
||||||
|
import { supabase } from '@/lib/supabase';
|
||||||
|
import { supabaseAdmin } from '@/lib/supabase-admin';
|
||||||
|
import { AnalysisResponse, BottleMetadata } from '@/types/whisky';
|
||||||
|
|
||||||
|
export async function magicScan(base64Image: string, provider: 'gemini' | 'nebius' = 'gemini'): Promise<AnalysisResponse & { wb_id?: string }> {
|
||||||
|
try {
|
||||||
|
// 1. AI Analysis
|
||||||
|
let aiResponse: any;
|
||||||
|
if (provider === 'nebius') {
|
||||||
|
aiResponse = await analyzeBottleNebius(base64Image);
|
||||||
|
} else {
|
||||||
|
aiResponse = await analyzeBottle(base64Image);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!aiResponse.success || !aiResponse.data) {
|
||||||
|
return aiResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data: BottleMetadata = aiResponse.data;
|
||||||
|
const searchString = aiResponse.search_string || `${data.distillery || ''} ${data.name || ''} ${data.vintage || data.age || ''}`.trim();
|
||||||
|
|
||||||
|
if (!searchString) {
|
||||||
|
return { ...aiResponse, wb_id: undefined };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. DB Cache Check (global_products)
|
||||||
|
// We use the regular supabase client for reading
|
||||||
|
const { data: cacheHit } = await supabase
|
||||||
|
.from('global_products')
|
||||||
|
.select('wb_id')
|
||||||
|
.textSearch('search_vector', `'${searchString}'`, { config: 'simple' })
|
||||||
|
.limit(1)
|
||||||
|
.maybeSingle();
|
||||||
|
|
||||||
|
if (cacheHit) {
|
||||||
|
console.log(`[Magic Scan] Cache Hit for ${searchString}: ${cacheHit.wb_id}`);
|
||||||
|
return {
|
||||||
|
...aiResponse,
|
||||||
|
wb_id: cacheHit.wb_id
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Fallback to Brave Search
|
||||||
|
console.log(`[Magic Scan] Cache Miss for ${searchString}. Calling Brave...`);
|
||||||
|
const braveResult = await searchBraveForWhiskybase(searchString);
|
||||||
|
|
||||||
|
if (braveResult.success && braveResult.id) {
|
||||||
|
console.log(`[Magic Scan] Brave found ID: ${braveResult.id}`);
|
||||||
|
|
||||||
|
// 4. Cache Write (using Admin client)
|
||||||
|
if (supabaseAdmin) {
|
||||||
|
const { error: saveError } = await supabaseAdmin
|
||||||
|
.from('global_products')
|
||||||
|
.insert({
|
||||||
|
wb_id: braveResult.id,
|
||||||
|
full_name: searchString, // We save the search string as the name for future hits
|
||||||
|
});
|
||||||
|
|
||||||
|
if (saveError) {
|
||||||
|
console.warn(`[Magic Scan] Failed to save to global_products: ${saveError.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...aiResponse,
|
||||||
|
wb_id: braveResult.id
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...aiResponse,
|
||||||
|
wb_id: undefined
|
||||||
|
};
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Magic Scan Error:', error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : 'Magic Scan failed.'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ export async function saveTasting(data: {
|
|||||||
finish_notes?: string;
|
finish_notes?: string;
|
||||||
is_sample?: boolean;
|
is_sample?: boolean;
|
||||||
buddy_ids?: string[];
|
buddy_ids?: string[];
|
||||||
|
tag_ids?: string[];
|
||||||
}) {
|
}) {
|
||||||
const supabase = createServerActionClient({ cookies });
|
const supabase = createServerActionClient({ cookies });
|
||||||
|
|
||||||
@@ -48,22 +49,38 @@ export async function saveTasting(data: {
|
|||||||
|
|
||||||
// Add buddy tags if any
|
// Add buddy tags if any
|
||||||
if (data.buddy_ids && data.buddy_ids.length > 0) {
|
if (data.buddy_ids && data.buddy_ids.length > 0) {
|
||||||
const tags = data.buddy_ids.map(buddyId => ({
|
const buddies = data.buddy_ids.map(buddyId => ({
|
||||||
tasting_id: tasting.id,
|
tasting_id: tasting.id,
|
||||||
buddy_id: buddyId,
|
buddy_id: buddyId,
|
||||||
user_id: session.user.id
|
user_id: session.user.id
|
||||||
}));
|
}));
|
||||||
const { error: tagError } = await supabase
|
const { error: tagError } = await supabase
|
||||||
.from('tasting_tags')
|
.from('tasting_buddies')
|
||||||
.insert(tags);
|
.insert(buddies);
|
||||||
|
|
||||||
if (tagError) {
|
if (tagError) {
|
||||||
console.error('Error adding tasting tags:', tagError);
|
console.error('Error adding tasting buddies:', tagError);
|
||||||
// We don't throw here to not fail the whole tasting save,
|
// We don't throw here to not fail the whole tasting save,
|
||||||
// but in a real app we might want more robust error handling
|
// but in a real app we might want more robust error handling
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add aroma tags if any
|
||||||
|
if (data.tag_ids && data.tag_ids.length > 0) {
|
||||||
|
const aromaTags = data.tag_ids.map(tagId => ({
|
||||||
|
tasting_id: tasting.id,
|
||||||
|
tag_id: tagId,
|
||||||
|
user_id: session.user.id
|
||||||
|
}));
|
||||||
|
const { error: aromaTagError } = await supabase
|
||||||
|
.from('tasting_tags')
|
||||||
|
.insert(aromaTags);
|
||||||
|
|
||||||
|
if (aromaTagError) {
|
||||||
|
console.error('Error adding aroma tags:', aromaTagError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
revalidatePath(`/bottles/${data.bottle_id}`);
|
revalidatePath(`/bottles/${data.bottle_id}`);
|
||||||
|
|
||||||
return { success: true, data: tasting };
|
return { success: true, data: tasting };
|
||||||
|
|||||||
79
src/services/tags.ts
Normal file
79
src/services/tags.ts
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
'use server';
|
||||||
|
|
||||||
|
import { createServerActionClient } from '@supabase/auth-helpers-nextjs';
|
||||||
|
import { cookies } from 'next/headers';
|
||||||
|
|
||||||
|
export type TagCategory = 'nose' | 'taste' | 'finish' | 'texture';
|
||||||
|
|
||||||
|
export interface Tag {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
category: TagCategory;
|
||||||
|
is_system_default: boolean;
|
||||||
|
popularity_score: number;
|
||||||
|
created_by?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch tags by category
|
||||||
|
*/
|
||||||
|
export async function getTagsByCategory(category: TagCategory): Promise<Tag[]> {
|
||||||
|
const supabase = createServerActionClient({ cookies });
|
||||||
|
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('tags')
|
||||||
|
.select('*')
|
||||||
|
.eq('category', category)
|
||||||
|
.order('popularity_score', { ascending: false })
|
||||||
|
.order('name');
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
console.error(`Error fetching tags for ${category}:`, error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return data || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a custom user tag
|
||||||
|
*/
|
||||||
|
export async function createCustomTag(name: string, category: TagCategory): Promise<{ success: boolean; tag?: Tag; error?: string }> {
|
||||||
|
const supabase = createServerActionClient({ cookies });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { data: { session } } = await supabase.auth.getSession();
|
||||||
|
if (!session) throw new Error('Nicht autorisiert');
|
||||||
|
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('tags')
|
||||||
|
.insert({
|
||||||
|
name,
|
||||||
|
category,
|
||||||
|
is_system_default: false,
|
||||||
|
created_by: session.user.id
|
||||||
|
})
|
||||||
|
.select()
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
if (error.code === '23505') { // Unique constraint violation
|
||||||
|
// Try to fetch the existing tag
|
||||||
|
const { data: existingTag } = await supabase
|
||||||
|
.from('tags')
|
||||||
|
.select('*')
|
||||||
|
.eq('name', name)
|
||||||
|
.eq('category', category)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
return { success: true, tag: existingTag || undefined };
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true, tag: data };
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating custom tag:', error);
|
||||||
|
return { success: false, error: 'Tag konnte nicht erstellt werden' };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,17 +1,17 @@
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
export const BottleMetadataSchema = z.object({
|
export const BottleMetadataSchema = z.object({
|
||||||
name: z.string().nullable(),
|
name: z.string().nullish(),
|
||||||
distillery: z.string().nullable(),
|
distillery: z.string().nullish(),
|
||||||
category: z.string().nullable(),
|
category: z.string().nullish(),
|
||||||
abv: z.number().nullable(),
|
abv: z.number().nullish(),
|
||||||
age: z.number().nullable(),
|
age: z.number().nullish(),
|
||||||
vintage: z.string().nullable(),
|
vintage: z.string().nullish(),
|
||||||
bottleCode: z.string().nullable(),
|
bottleCode: z.string().nullish(),
|
||||||
whiskybaseId: z.string().nullable(),
|
whiskybaseId: z.string().nullish(),
|
||||||
distilled_at: z.string().nullable(),
|
distilled_at: z.string().nullish(),
|
||||||
bottled_at: z.string().nullable(),
|
bottled_at: z.string().nullish(),
|
||||||
batch_info: z.string().nullable(),
|
batch_info: z.string().nullish(),
|
||||||
is_whisky: z.boolean().default(true),
|
is_whisky: z.boolean().default(true),
|
||||||
confidence: z.number().min(0).max(100).default(100),
|
confidence: z.number().min(0).max(100).default(100),
|
||||||
});
|
});
|
||||||
|
|||||||
161
tagging_system_migration.sql
Normal file
161
tagging_system_migration.sql
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
-- Migration for Advanced Tagging System
|
||||||
|
|
||||||
|
-- 1. Rename existing 'tasting_tags' to 'tasting_buddies'
|
||||||
|
-- This table currently stores which buddies are part of a tasting.
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF EXISTS (SELECT FROM pg_tables WHERE schemaname = 'public' AND tablename = 'tasting_tags') THEN
|
||||||
|
ALTER TABLE tasting_tags RENAME TO tasting_buddies;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- 2. Create 'tags' table
|
||||||
|
CREATE TYPE tag_category AS ENUM ('nose', 'taste', 'finish', 'texture');
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS tags (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
category tag_category NOT NULL,
|
||||||
|
is_system_default BOOLEAN DEFAULT false,
|
||||||
|
popularity_score INTEGER DEFAULT 3, -- 1 to 5
|
||||||
|
created_by UUID REFERENCES profiles(id) ON DELETE CASCADE,
|
||||||
|
UNIQUE(name, category)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 3. Create new 'tasting_tags' junction table (for aroma tags)
|
||||||
|
CREATE TABLE IF NOT EXISTS tasting_tags (
|
||||||
|
tasting_id UUID REFERENCES tastings(id) ON DELETE CASCADE NOT NULL,
|
||||||
|
tag_id UUID REFERENCES tags(id) ON DELETE CASCADE NOT NULL,
|
||||||
|
user_id UUID REFERENCES profiles(id) ON DELETE CASCADE NOT NULL,
|
||||||
|
PRIMARY KEY (tasting_id, tag_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 4. Enable RLS
|
||||||
|
ALTER TABLE tags ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE tasting_tags ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
-- 5. Policies for 'tags'
|
||||||
|
-- Everyone can read system default tags and their own custom tags
|
||||||
|
DROP POLICY IF EXISTS "tags_select_policy" ON tags;
|
||||||
|
CREATE POLICY "tags_select_policy" ON tags
|
||||||
|
FOR SELECT USING (
|
||||||
|
is_system_default = true OR
|
||||||
|
(SELECT auth.uid()) = created_by OR
|
||||||
|
EXISTS (SELECT 1 FROM admin_users WHERE user_id = (SELECT auth.uid()))
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Users can insert their own custom tags
|
||||||
|
DROP POLICY IF EXISTS "tags_insert_policy" ON tags;
|
||||||
|
CREATE POLICY "tags_insert_policy" ON tags
|
||||||
|
FOR INSERT WITH CHECK (
|
||||||
|
(SELECT auth.uid()) = created_by
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 6. Policies for 'tasting_tags' (junction)
|
||||||
|
DROP POLICY IF EXISTS "tasting_tags_owner_policy" ON tasting_tags;
|
||||||
|
CREATE POLICY "tasting_tags_owner_policy" ON tasting_tags
|
||||||
|
FOR ALL USING ((SELECT auth.uid()) = user_id);
|
||||||
|
|
||||||
|
-- 7. Insert Master List of System Tags
|
||||||
|
INSERT INTO tags (name, category, is_system_default) VALUES
|
||||||
|
-- FRUCHTIG (FRUITY)
|
||||||
|
('Apfel', 'nose', true), ('Apfel', 'taste', true),
|
||||||
|
('Grüner Apfel', 'nose', true), ('Grüner Apfel', 'taste', true),
|
||||||
|
('Bratapfel', 'nose', true), ('Bratapfel', 'taste', true),
|
||||||
|
('Birne', 'nose', true), ('Birne', 'taste', true),
|
||||||
|
('Zitrone', 'nose', true), ('Zitrone', 'taste', true),
|
||||||
|
('Zitrus', 'nose', true), ('Zitrus', 'taste', true),
|
||||||
|
('Orange', 'nose', true), ('Orange', 'taste', true),
|
||||||
|
('Orangenschale', 'nose', true), ('Orangenschale', 'taste', true),
|
||||||
|
('Pfirsich', 'nose', true), ('Pfirsich', 'taste', true),
|
||||||
|
('Aprikose', 'nose', true), ('Aprikose', 'taste', true),
|
||||||
|
('Banane', 'nose', true), ('Banane', 'taste', true),
|
||||||
|
('Ananas', 'nose', true), ('Ananas', 'taste', true),
|
||||||
|
('Tropische Früchte', 'nose', true), ('Tropische Früchte', 'taste', true),
|
||||||
|
('Kirsche', 'nose', true), ('Kirsche', 'taste', true),
|
||||||
|
('Beeren', 'nose', true), ('Beeren', 'taste', true),
|
||||||
|
('Brombeere', 'nose', true), ('Brombeere', 'taste', true),
|
||||||
|
('Himbeere', 'nose', true), ('Himbeere', 'taste', true),
|
||||||
|
('Pflaume', 'nose', true), ('Pflaume', 'taste', true),
|
||||||
|
('Trockenfrüchte', 'nose', true), ('Trockenfrüchte', 'taste', true),
|
||||||
|
('Rosinen', 'nose', true), ('Rosinen', 'taste', true),
|
||||||
|
('Datteln', 'nose', true), ('Datteln', 'taste', true),
|
||||||
|
('Feigen', 'nose', true), ('Feigen', 'taste', true),
|
||||||
|
|
||||||
|
-- SÜSS & CREMIG (SWEET & CREAMY)
|
||||||
|
('Vanille', 'nose', true), ('Vanille', 'taste', true),
|
||||||
|
('Honig', 'nose', true), ('Honig', 'taste', true),
|
||||||
|
('Karamell', 'nose', true), ('Karamell', 'taste', true),
|
||||||
|
('Toffee', 'nose', true), ('Toffee', 'taste', true),
|
||||||
|
('Schokolade', 'nose', true), ('Schokolade', 'taste', true),
|
||||||
|
('Zartbitterschokolade', 'nose', true), ('Zartbitterschokolade', 'taste', true),
|
||||||
|
('Milchschokolade', 'nose', true), ('Milchschokolade', 'taste', true),
|
||||||
|
('Malz', 'nose', true), ('Malz', 'taste', true),
|
||||||
|
('Müsli', 'nose', true), ('Müsli', 'taste', true),
|
||||||
|
('Butter', 'nose', true), ('Butter', 'taste', true),
|
||||||
|
('Butterkeks', 'nose', true), ('Butterkeks', 'taste', true),
|
||||||
|
('Marzipan', 'nose', true), ('Marzipan', 'taste', true),
|
||||||
|
('Mandel', 'nose', true), ('Mandel', 'taste', true),
|
||||||
|
('Sahnebonbon', 'nose', true), ('Sahnebonbon', 'taste', true),
|
||||||
|
|
||||||
|
-- WÜRZIG & NUSSIG (SPICY & NUTTY)
|
||||||
|
('Eiche', 'nose', true), ('Eiche', 'taste', true),
|
||||||
|
('Zimt', 'nose', true), ('Zimt', 'taste', true),
|
||||||
|
('Pfeffer', 'nose', true), ('Pfeffer', 'taste', true),
|
||||||
|
('Muskatnuss', 'nose', true), ('Muskatnuss', 'taste', true),
|
||||||
|
('Ingwer', 'nose', true), ('Ingwer', 'taste', true),
|
||||||
|
('Nelke', 'nose', true), ('Nelke', 'taste', true),
|
||||||
|
('Walnuss', 'nose', true), ('Walnuss', 'taste', true),
|
||||||
|
('Haselnuss', 'nose', true), ('Haselnuss', 'taste', true),
|
||||||
|
('Geröstete Nüsse', 'nose', true), ('Geröstete Nüsse', 'taste', true),
|
||||||
|
|
||||||
|
-- RAUCHIG & TORFIG (PEATY & SMOKY)
|
||||||
|
('Lagerfeuer', 'nose', true), ('Lagerfeuer', 'taste', true),
|
||||||
|
('Holzkohle', 'nose', true), ('Holzkohle', 'taste', true),
|
||||||
|
('Torfrauch', 'nose', true), ('Torfrauch', 'taste', true),
|
||||||
|
('Asche', 'nose', true), ('Asche', 'taste', true),
|
||||||
|
('Jod', 'nose', true), ('Jod', 'taste', true),
|
||||||
|
('Medizinisch', 'nose', true), ('Medizinisch', 'taste', true),
|
||||||
|
('Teer', 'nose', true), ('Teer', 'taste', true),
|
||||||
|
('Asphalt', 'nose', true), ('Asphalt', 'taste', true),
|
||||||
|
('Geräucherter Schinken', 'nose', true), ('Geräucherter Schinken', 'taste', true),
|
||||||
|
('Speck', 'nose', true), ('Speck', 'taste', true),
|
||||||
|
('Grillfleisch', 'nose', true), ('Grillfleisch', 'taste', true),
|
||||||
|
|
||||||
|
-- MARITIM & SALZIG (COASTAL)
|
||||||
|
('Meersalz', 'nose', true), ('Meersalz', 'taste', true),
|
||||||
|
('Salzlake', 'nose', true), ('Salzlake', 'taste', true),
|
||||||
|
('Seetang', 'nose', true), ('Seetang', 'taste', true),
|
||||||
|
('Algen', 'nose', true), ('Algen', 'taste', true),
|
||||||
|
('Austern', 'nose', true), ('Austern', 'taste', true),
|
||||||
|
('Meeresbrise', 'nose', true), ('Meeresbrise', 'taste', true),
|
||||||
|
|
||||||
|
-- FLORAL & KRÄUTER (FLORAL & HERBAL)
|
||||||
|
('Heidekraut', 'nose', true), ('Heidekraut', 'taste', true),
|
||||||
|
('Gras', 'nose', true), ('Gras', 'taste', true),
|
||||||
|
('Heu', 'nose', true), ('Heu', 'taste', true),
|
||||||
|
('Minze', 'nose', true), ('Minze', 'taste', true),
|
||||||
|
('Menthol', 'nose', true), ('Menthol', 'taste', true),
|
||||||
|
('Eukalyptus', 'nose', true), ('Eukalyptus', 'taste', true),
|
||||||
|
('Tabak', 'nose', true), ('Tabak', 'taste', true),
|
||||||
|
('Leder', 'nose', true), ('Leder', 'taste', true),
|
||||||
|
('Tee', 'nose', true), ('Tee', 'taste', true),
|
||||||
|
|
||||||
|
-- FINISH DAUER
|
||||||
|
('Kurz & Knackig', 'finish', true),
|
||||||
|
('Mittellang', 'finish', true),
|
||||||
|
('Lang anhaltend', 'finish', true),
|
||||||
|
('Ewig', 'finish', true),
|
||||||
|
|
||||||
|
-- TEXTUR & GEFÜHL
|
||||||
|
('Ölig', 'texture', true),
|
||||||
|
('Viskos', 'texture', true),
|
||||||
|
('Trocken', 'texture', true),
|
||||||
|
('Adstringierend', 'texture', true),
|
||||||
|
('Wärmend', 'texture', true),
|
||||||
|
('Scharf', 'texture', true),
|
||||||
|
('Beißend', 'texture', true),
|
||||||
|
('Weich', 'texture', true),
|
||||||
|
('Samtig', 'texture', true),
|
||||||
|
('Wässrig', 'texture', true)
|
||||||
|
ON CONFLICT (name, category) DO NOTHING;
|
||||||
Reference in New Issue
Block a user