Major updates: LinkedIn auto-posting, timezone fixes, and Docker improvements
Features: - Add LinkedIn OAuth integration and auto-posting functionality - Add scheduler service for automated post publishing - Add metadata field to generated_posts for LinkedIn URLs - Add privacy policy page for LinkedIn API compliance - Add company management features and employee accounts - Add license key system for company registrations Fixes: - Fix timezone issues (use UTC consistently across app) - Fix datetime serialization errors in database operations - Fix scheduling timezone conversion (local time to UTC) - Fix import errors (get_database -> db) Infrastructure: - Update Docker setup to use port 8001 (avoid conflicts) - Add SSL support with nginx-proxy and Let's Encrypt - Add LinkedIn setup documentation - Add migration scripts for schema updates Services: - Add linkedin_service.py for LinkedIn API integration - Add scheduler_service.py for background job processing - Add storage_service.py for Supabase Storage - Add email_service.py improvements - Add encryption utilities for token storage Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
23
.env.linkedin.example
Normal file
23
.env.linkedin.example
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
# LinkedIn Auto-Posting Configuration
|
||||||
|
# Copy these lines to your .env file and fill in the values
|
||||||
|
|
||||||
|
# ==================== LinkedIn API ====================
|
||||||
|
# Create a LinkedIn app at: https://www.linkedin.com/developers/apps
|
||||||
|
# Required OAuth scopes: openid, profile, email, w_member_social
|
||||||
|
|
||||||
|
LINKEDIN_CLIENT_ID=your_linkedin_app_client_id
|
||||||
|
LINKEDIN_CLIENT_SECRET=your_linkedin_app_client_secret
|
||||||
|
LINKEDIN_REDIRECT_URI=https://yourdomain.com/settings/linkedin/callback
|
||||||
|
|
||||||
|
# ==================== Token Encryption ====================
|
||||||
|
# Generate with: python scripts/generate_encryption_key.py
|
||||||
|
# ⚠️ IMPORTANT: Never change this key after storing tokens!
|
||||||
|
|
||||||
|
ENCRYPTION_KEY=your_generated_fernet_key_here
|
||||||
|
|
||||||
|
# ==================== Notes ====================
|
||||||
|
# 1. Replace "yourdomain.com" with your actual domain
|
||||||
|
# 2. Ensure HTTPS is enabled (required for OAuth)
|
||||||
|
# 3. The redirect URI must match exactly in LinkedIn app settings
|
||||||
|
# 4. Keep these values secure and never commit to git
|
||||||
|
# 5. After setup, restart your application to load new variables
|
||||||
12
Dockerfile
12
Dockerfile
@@ -28,12 +28,12 @@ RUN useradd --create-home --shell /bin/bash appuser && \
|
|||||||
chown -R appuser:appuser /app
|
chown -R appuser:appuser /app
|
||||||
USER appuser
|
USER appuser
|
||||||
|
|
||||||
# Expose port
|
# Expose port (default 8001, can be overridden by PORT env var)
|
||||||
EXPOSE 8000
|
EXPOSE 8001
|
||||||
|
|
||||||
# Health check
|
# Health check (uses PORT env var with 8001 fallback)
|
||||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||||
CMD python -c "import httpx; httpx.get('http://localhost:8000/login', timeout=5)" || exit 1
|
CMD python -c "import os, httpx; port = os.getenv('PORT', '8001'); httpx.get(f'http://localhost:{port}/login', timeout=5)" || exit 1
|
||||||
|
|
||||||
# Run the application
|
# Run the application (uses PORT env var with 8001 fallback)
|
||||||
CMD ["python", "-m", "uvicorn", "src.web.app:app", "--host", "0.0.0.0", "--port", "8000"]
|
CMD sh -c "python -m uvicorn src.web.app:app --host 0.0.0.0 --port ${PORT:-8001}"
|
||||||
|
|||||||
217
LINKEDIN_SETUP.md
Normal file
217
LINKEDIN_SETUP.md
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
# LinkedIn Auto-Posting Setup Guide
|
||||||
|
|
||||||
|
This guide walks you through setting up the LinkedIn auto-posting feature.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Employees can link their LinkedIn accounts to automatically post scheduled content directly to LinkedIn, eliminating manual copy-paste work.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
1. A LinkedIn Developer account
|
||||||
|
2. Access to your server's environment variables
|
||||||
|
3. HTTPS domain (required for OAuth)
|
||||||
|
|
||||||
|
## Step 1: Create LinkedIn App
|
||||||
|
|
||||||
|
1. Go to [LinkedIn Developers](https://www.linkedin.com/developers/apps)
|
||||||
|
2. Click "Create app"
|
||||||
|
3. Fill in app details:
|
||||||
|
- **App name**: Your app name (e.g., "LinkedInWorkflow Auto-Poster")
|
||||||
|
- **LinkedIn Page**: Select your company page
|
||||||
|
- **App logo**: Upload a logo (optional)
|
||||||
|
4. Check the agreement and create the app
|
||||||
|
|
||||||
|
## Step 2: Configure OAuth Settings
|
||||||
|
|
||||||
|
1. In your app dashboard, go to the **Auth** tab
|
||||||
|
2. Add OAuth 2.0 redirect URLs:
|
||||||
|
```
|
||||||
|
https://yourdomain.com/settings/linkedin/callback
|
||||||
|
```
|
||||||
|
3. Request the following **OAuth 2.0 scopes**:
|
||||||
|
- `openid` - For user identity
|
||||||
|
- `profile` - For user profile data
|
||||||
|
- `email` - For user email
|
||||||
|
- `w_member_social` - **CRITICAL** - For posting on user's behalf
|
||||||
|
|
||||||
|
Note: `w_member_social` requires app review by LinkedIn. Submit for review if not already approved.
|
||||||
|
|
||||||
|
4. Note your **Client ID** and **Client Secret** (found in Auth tab)
|
||||||
|
|
||||||
|
## Step 3: Generate Encryption Key
|
||||||
|
|
||||||
|
Run the helper script to generate a secure encryption key:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python scripts/generate_encryption_key.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Copy the generated key for the next step.
|
||||||
|
|
||||||
|
## Step 4: Configure Environment Variables
|
||||||
|
|
||||||
|
Add these variables to your `.env` file:
|
||||||
|
|
||||||
|
```env
|
||||||
|
# LinkedIn API (Custom OAuth for auto-posting)
|
||||||
|
LINKEDIN_CLIENT_ID=your_client_id_here
|
||||||
|
LINKEDIN_CLIENT_SECRET=your_client_secret_here
|
||||||
|
LINKEDIN_REDIRECT_URI=https://yourdomain.com/settings/linkedin/callback
|
||||||
|
|
||||||
|
# Token Encryption
|
||||||
|
ENCRYPTION_KEY=your_generated_fernet_key_here
|
||||||
|
```
|
||||||
|
|
||||||
|
⚠️ **Security**: Never commit these values to git! Keep them secure.
|
||||||
|
|
||||||
|
## Step 5: Run Database Migration
|
||||||
|
|
||||||
|
Apply the database migration to create the `linkedin_accounts` table:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Using psql
|
||||||
|
psql $DATABASE_URL -f config/migrate_add_linkedin_accounts.sql
|
||||||
|
|
||||||
|
# Or via Supabase dashboard SQL editor
|
||||||
|
# Copy and paste the contents of config/migrate_add_linkedin_accounts.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
## Step 6: Install Dependencies
|
||||||
|
|
||||||
|
Install the new dependencies:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
## Step 7: Restart Application
|
||||||
|
|
||||||
|
Restart your application to load the new environment variables:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# If using systemd
|
||||||
|
sudo systemctl restart linkedinworkflow
|
||||||
|
|
||||||
|
# If using docker
|
||||||
|
docker-compose restart
|
||||||
|
|
||||||
|
# If running directly
|
||||||
|
# Stop the current process and restart
|
||||||
|
```
|
||||||
|
|
||||||
|
## Step 8: Test the Integration
|
||||||
|
|
||||||
|
1. Log in as an employee user
|
||||||
|
2. Go to **Settings** (`/settings`)
|
||||||
|
3. Click **"Mit LinkedIn verbinden"**
|
||||||
|
4. Complete the LinkedIn OAuth flow
|
||||||
|
5. Verify the account shows as connected
|
||||||
|
6. Schedule a test post
|
||||||
|
7. Wait for the scheduled time (or manually trigger the scheduler)
|
||||||
|
8. Verify the post appears on LinkedIn
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### OAuth Errors
|
||||||
|
|
||||||
|
**"Invalid redirect_uri"**
|
||||||
|
- Make sure the redirect URI in your .env matches exactly with the one configured in LinkedIn app settings
|
||||||
|
- Include the protocol (`https://`) and no trailing slash
|
||||||
|
|
||||||
|
**"Insufficient permissions"**
|
||||||
|
- Ensure `w_member_social` scope is requested and approved
|
||||||
|
- Some scopes require LinkedIn app review
|
||||||
|
|
||||||
|
### Token Errors
|
||||||
|
|
||||||
|
**"Token expired"**
|
||||||
|
- LinkedIn tokens typically last 60 days
|
||||||
|
- The system will attempt to refresh automatically
|
||||||
|
- If refresh fails, user needs to reconnect
|
||||||
|
|
||||||
|
**"Encryption error"**
|
||||||
|
- Verify `ENCRYPTION_KEY` is set in environment
|
||||||
|
- Never change the encryption key after storing tokens (or all tokens become unreadable)
|
||||||
|
|
||||||
|
### Posting Errors
|
||||||
|
|
||||||
|
**"Rate limit exceeded"**
|
||||||
|
- LinkedIn has rate limits on posting
|
||||||
|
- The system will fall back to email notification
|
||||||
|
- Wait before retrying
|
||||||
|
|
||||||
|
**"Image upload failed"**
|
||||||
|
- Check that the image URL is publicly accessible
|
||||||
|
- Supabase storage URLs must be publicly readable
|
||||||
|
- System will post without image if upload fails
|
||||||
|
|
||||||
|
## Rate Limits
|
||||||
|
|
||||||
|
LinkedIn API has the following rate limits (as of 2024):
|
||||||
|
|
||||||
|
- **Posts**: ~100 per day per user
|
||||||
|
- **API calls**: Varies by endpoint
|
||||||
|
|
||||||
|
Plan your posting schedule accordingly.
|
||||||
|
|
||||||
|
## Security Best Practices
|
||||||
|
|
||||||
|
1. **Never log tokens**: Tokens are encrypted at rest and should never be logged in plaintext
|
||||||
|
2. **HTTPS only**: OAuth requires HTTPS in production
|
||||||
|
3. **Secure cookies**: Session cookies use httponly, secure, and samesite flags
|
||||||
|
4. **Token rotation**: Encourage users to reconnect periodically
|
||||||
|
5. **Audit logging**: Monitor for suspicious activity in `linkedin_accounts.last_error`
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────┐
|
||||||
|
│ Employee │
|
||||||
|
│ (Settings Page) │
|
||||||
|
└────────┬────────┘
|
||||||
|
│ 1. OAuth Flow
|
||||||
|
▼
|
||||||
|
┌─────────────────────────┐
|
||||||
|
│ linkedin_accounts table │
|
||||||
|
│ (encrypted tokens) │
|
||||||
|
└────────┬────────────────┘
|
||||||
|
│ 2. Scheduler checks
|
||||||
|
▼
|
||||||
|
┌─────────────────────────┐
|
||||||
|
│ LinkedIn API Service │
|
||||||
|
│ (UGC Posts API) │
|
||||||
|
└─────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## Monitoring
|
||||||
|
|
||||||
|
Key metrics to monitor:
|
||||||
|
|
||||||
|
- **Token expiry**: Check `linkedin_accounts.token_expires_at`
|
||||||
|
- **API errors**: Check `linkedin_accounts.last_error`
|
||||||
|
- **Success rate**: Compare scheduled posts vs. published posts
|
||||||
|
- **Fallback rate**: How often email fallback is used
|
||||||
|
|
||||||
|
Query to check accounts needing attention:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT
|
||||||
|
user_id,
|
||||||
|
linkedin_name,
|
||||||
|
last_error,
|
||||||
|
last_error_at,
|
||||||
|
token_expires_at
|
||||||
|
FROM linkedin_accounts
|
||||||
|
WHERE
|
||||||
|
(last_error IS NOT NULL OR token_expires_at < NOW() + INTERVAL '7 days')
|
||||||
|
AND is_active = true;
|
||||||
|
```
|
||||||
|
|
||||||
|
## Support
|
||||||
|
|
||||||
|
For issues or questions:
|
||||||
|
1. Check logs for detailed error messages
|
||||||
|
2. Review LinkedIn API documentation
|
||||||
|
3. Verify environment configuration
|
||||||
|
4. Test with a single user account first
|
||||||
536
config/fresh_migration.sql
Normal file
536
config/fresh_migration.sql
Normal file
@@ -0,0 +1,536 @@
|
|||||||
|
-- ==================== FRESH DATABASE MIGRATION ====================
|
||||||
|
-- LinkedIn Workflow - Complete Schema for new Supabase Database
|
||||||
|
-- Run this in the Supabase SQL Editor
|
||||||
|
|
||||||
|
-- Enable UUID extension
|
||||||
|
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||||
|
|
||||||
|
-- ==================== CORE TABLES ====================
|
||||||
|
|
||||||
|
-- Customers/Clients Table
|
||||||
|
CREATE TABLE IF NOT EXISTS customers (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
|
|
||||||
|
-- Basic Info
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
email TEXT,
|
||||||
|
company_name TEXT,
|
||||||
|
|
||||||
|
-- LinkedIn Profile
|
||||||
|
linkedin_url TEXT NOT NULL,
|
||||||
|
|
||||||
|
-- Metadata
|
||||||
|
metadata JSONB DEFAULT '{}'::JSONB,
|
||||||
|
|
||||||
|
-- Email workflow fields
|
||||||
|
creator_email TEXT,
|
||||||
|
customer_email TEXT,
|
||||||
|
|
||||||
|
-- Relations
|
||||||
|
user_id UUID, -- FK added after auth setup
|
||||||
|
company_id UUID, -- FK added after companies table
|
||||||
|
writing_style_notes TEXT,
|
||||||
|
is_active BOOLEAN DEFAULT TRUE,
|
||||||
|
profile_picture TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Post Types Table (for categorizing posts by type)
|
||||||
|
CREATE TABLE IF NOT EXISTS post_types (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
customer_id UUID NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
|
|
||||||
|
-- Type Definition
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
identifying_hashtags TEXT[] DEFAULT '{}',
|
||||||
|
identifying_keywords TEXT[] DEFAULT '{}',
|
||||||
|
semantic_properties JSONB DEFAULT '{}'::JSONB,
|
||||||
|
|
||||||
|
-- Analysis Results
|
||||||
|
analysis JSONB,
|
||||||
|
analysis_generated_at TIMESTAMP WITH TIME ZONE,
|
||||||
|
analyzed_post_count INTEGER DEFAULT 0,
|
||||||
|
|
||||||
|
-- Status
|
||||||
|
is_active BOOLEAN DEFAULT TRUE,
|
||||||
|
|
||||||
|
UNIQUE(customer_id, name)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- LinkedIn Profiles Table (scraped data)
|
||||||
|
CREATE TABLE IF NOT EXISTS linkedin_profiles (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
customer_id UUID NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
|
||||||
|
scraped_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
|
|
||||||
|
-- Profile Data
|
||||||
|
profile_data JSONB NOT NULL,
|
||||||
|
|
||||||
|
-- Extracted Information
|
||||||
|
name TEXT,
|
||||||
|
headline TEXT,
|
||||||
|
summary TEXT,
|
||||||
|
location TEXT,
|
||||||
|
industry TEXT,
|
||||||
|
|
||||||
|
UNIQUE(customer_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- LinkedIn Posts Table (scraped posts)
|
||||||
|
CREATE TABLE IF NOT EXISTS linkedin_posts (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
customer_id UUID NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
|
||||||
|
scraped_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
|
|
||||||
|
-- Post Data
|
||||||
|
post_url TEXT,
|
||||||
|
post_text TEXT NOT NULL,
|
||||||
|
post_date TIMESTAMP WITH TIME ZONE,
|
||||||
|
likes INTEGER DEFAULT 0,
|
||||||
|
comments INTEGER DEFAULT 0,
|
||||||
|
shares INTEGER DEFAULT 0,
|
||||||
|
|
||||||
|
-- Raw Data
|
||||||
|
raw_data JSONB,
|
||||||
|
|
||||||
|
-- Post type classification
|
||||||
|
post_type_id UUID REFERENCES post_types(id) ON DELETE SET NULL,
|
||||||
|
classification_method TEXT,
|
||||||
|
classification_confidence FLOAT,
|
||||||
|
|
||||||
|
UNIQUE(customer_id, post_url)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Topics Table (extracted from posts)
|
||||||
|
CREATE TABLE IF NOT EXISTS topics (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
customer_id UUID NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
|
|
||||||
|
-- Topic Info
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
category TEXT,
|
||||||
|
|
||||||
|
-- AI Extraction
|
||||||
|
extracted_from_post_id UUID REFERENCES linkedin_posts(id),
|
||||||
|
extraction_confidence FLOAT,
|
||||||
|
|
||||||
|
-- Status
|
||||||
|
is_used BOOLEAN DEFAULT FALSE,
|
||||||
|
used_at TIMESTAMP WITH TIME ZONE,
|
||||||
|
|
||||||
|
-- Target post type
|
||||||
|
target_post_type_id UUID REFERENCES post_types(id) ON DELETE SET NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Profile Analysis Table (AI-generated insights)
|
||||||
|
CREATE TABLE IF NOT EXISTS profile_analyses (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
customer_id UUID NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
|
|
||||||
|
-- Analysis Results
|
||||||
|
writing_style JSONB NOT NULL,
|
||||||
|
tone_analysis JSONB NOT NULL,
|
||||||
|
topic_patterns JSONB NOT NULL,
|
||||||
|
audience_insights JSONB NOT NULL,
|
||||||
|
|
||||||
|
-- Full Analysis
|
||||||
|
full_analysis JSONB NOT NULL,
|
||||||
|
|
||||||
|
UNIQUE(customer_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Research Results Table
|
||||||
|
CREATE TABLE IF NOT EXISTS research_results (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
customer_id UUID NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
|
|
||||||
|
-- Research Data
|
||||||
|
query TEXT NOT NULL,
|
||||||
|
results JSONB NOT NULL,
|
||||||
|
|
||||||
|
-- Topic Suggestions
|
||||||
|
suggested_topics JSONB NOT NULL,
|
||||||
|
|
||||||
|
-- Metadata
|
||||||
|
source TEXT DEFAULT 'perplexity',
|
||||||
|
|
||||||
|
-- Target post type
|
||||||
|
target_post_type_id UUID REFERENCES post_types(id) ON DELETE SET NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Generated Posts Table
|
||||||
|
CREATE TABLE IF NOT EXISTS generated_posts (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
customer_id UUID NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
|
|
||||||
|
-- Topic
|
||||||
|
topic_id UUID REFERENCES topics(id),
|
||||||
|
topic_title TEXT NOT NULL,
|
||||||
|
|
||||||
|
-- Post Content
|
||||||
|
post_content TEXT NOT NULL,
|
||||||
|
|
||||||
|
-- Generation Metadata
|
||||||
|
iterations INTEGER DEFAULT 0,
|
||||||
|
writer_versions JSONB DEFAULT '[]'::JSONB,
|
||||||
|
critic_feedback JSONB DEFAULT '[]'::JSONB,
|
||||||
|
|
||||||
|
-- Status: draft -> approved -> ready -> scheduled -> published
|
||||||
|
-- draft: AI generated, waiting for review
|
||||||
|
-- approved: User edited, email sent to customer
|
||||||
|
-- ready: Customer approved via email, can be scheduled
|
||||||
|
-- scheduled: Scheduled in calendar
|
||||||
|
-- published: Actually published to LinkedIn
|
||||||
|
-- rejected: Customer rejected, back to draft
|
||||||
|
status TEXT DEFAULT 'draft' CHECK (status IN ('draft', 'approved', 'ready', 'scheduled', 'published', 'rejected')),
|
||||||
|
approved_at TIMESTAMP WITH TIME ZONE,
|
||||||
|
published_at TIMESTAMP WITH TIME ZONE,
|
||||||
|
|
||||||
|
-- Post type
|
||||||
|
post_type_id UUID REFERENCES post_types(id) ON DELETE SET NULL,
|
||||||
|
|
||||||
|
-- Scheduling
|
||||||
|
scheduled_at TIMESTAMP WITH TIME ZONE,
|
||||||
|
scheduled_by_user_id UUID -- FK added after auth setup
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ==================== COMPANIES & PROFILES (Supabase Auth) ====================
|
||||||
|
|
||||||
|
-- Companies Table
|
||||||
|
CREATE TABLE IF NOT EXISTS companies (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
|
|
||||||
|
-- Company Data
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
website TEXT,
|
||||||
|
industry TEXT,
|
||||||
|
|
||||||
|
-- Strategy
|
||||||
|
company_strategy JSONB DEFAULT '{}'::JSONB,
|
||||||
|
|
||||||
|
owner_user_id UUID NOT NULL, -- FK to auth.users
|
||||||
|
onboarding_completed BOOLEAN DEFAULT FALSE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Profiles Table (extends auth.users)
|
||||||
|
CREATE TABLE IF NOT EXISTS profiles (
|
||||||
|
id UUID PRIMARY KEY, -- Same as auth.users.id
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
|
|
||||||
|
-- Account Type
|
||||||
|
account_type TEXT NOT NULL DEFAULT 'ghostwriter',
|
||||||
|
|
||||||
|
-- Display name
|
||||||
|
display_name TEXT,
|
||||||
|
|
||||||
|
-- Onboarding
|
||||||
|
onboarding_status TEXT DEFAULT 'pending',
|
||||||
|
onboarding_data JSONB DEFAULT '{}'::JSONB,
|
||||||
|
|
||||||
|
-- Links
|
||||||
|
customer_id UUID REFERENCES customers(id) ON DELETE SET NULL,
|
||||||
|
company_id UUID REFERENCES companies(id) ON DELETE SET NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Invitations Table
|
||||||
|
CREATE TABLE IF NOT EXISTS invitations (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
|
|
||||||
|
email TEXT NOT NULL,
|
||||||
|
token TEXT NOT NULL UNIQUE,
|
||||||
|
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||||
|
|
||||||
|
company_id UUID NOT NULL REFERENCES companies(id) ON DELETE CASCADE,
|
||||||
|
invited_by_user_id UUID NOT NULL, -- FK to auth.users
|
||||||
|
|
||||||
|
status TEXT DEFAULT 'pending',
|
||||||
|
accepted_at TIMESTAMP WITH TIME ZONE,
|
||||||
|
accepted_by_user_id UUID -- FK to auth.users
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Example Posts Table
|
||||||
|
CREATE TABLE IF NOT EXISTS example_posts (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
customer_id UUID NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
|
|
||||||
|
post_text TEXT NOT NULL,
|
||||||
|
source TEXT DEFAULT 'manual',
|
||||||
|
source_linkedin_url TEXT,
|
||||||
|
post_type_id UUID REFERENCES post_types(id) ON DELETE SET NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Reference Profiles Table
|
||||||
|
CREATE TABLE IF NOT EXISTS reference_profiles (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
customer_id UUID NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
|
|
||||||
|
linkedin_url TEXT NOT NULL,
|
||||||
|
name TEXT,
|
||||||
|
posts_scraped INTEGER DEFAULT 0,
|
||||||
|
|
||||||
|
UNIQUE(customer_id, linkedin_url)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ==================== ADD FOREIGN KEYS (after all tables exist) ====================
|
||||||
|
|
||||||
|
-- Add FK from profiles to auth.users
|
||||||
|
ALTER TABLE profiles
|
||||||
|
ADD CONSTRAINT fk_profiles_auth_users
|
||||||
|
FOREIGN KEY (id) REFERENCES auth.users(id) ON DELETE CASCADE;
|
||||||
|
|
||||||
|
-- Add FK from companies to auth.users
|
||||||
|
ALTER TABLE companies
|
||||||
|
ADD CONSTRAINT fk_companies_owner
|
||||||
|
FOREIGN KEY (owner_user_id) REFERENCES auth.users(id) ON DELETE CASCADE;
|
||||||
|
|
||||||
|
-- Add FK from customers to auth.users and companies
|
||||||
|
ALTER TABLE customers
|
||||||
|
ADD CONSTRAINT fk_customers_user
|
||||||
|
FOREIGN KEY (user_id) REFERENCES auth.users(id) ON DELETE SET NULL;
|
||||||
|
|
||||||
|
ALTER TABLE customers
|
||||||
|
ADD CONSTRAINT fk_customers_company
|
||||||
|
FOREIGN KEY (company_id) REFERENCES companies(id) ON DELETE SET NULL;
|
||||||
|
|
||||||
|
-- Add FK from generated_posts to auth.users
|
||||||
|
ALTER TABLE generated_posts
|
||||||
|
ADD CONSTRAINT fk_generated_posts_scheduled_by
|
||||||
|
FOREIGN KEY (scheduled_by_user_id) REFERENCES auth.users(id) ON DELETE SET NULL;
|
||||||
|
|
||||||
|
-- Add FK from invitations to auth.users
|
||||||
|
ALTER TABLE invitations
|
||||||
|
ADD CONSTRAINT fk_invitations_invited_by
|
||||||
|
FOREIGN KEY (invited_by_user_id) REFERENCES auth.users(id) ON DELETE CASCADE;
|
||||||
|
|
||||||
|
ALTER TABLE invitations
|
||||||
|
ADD CONSTRAINT fk_invitations_accepted_by
|
||||||
|
FOREIGN KEY (accepted_by_user_id) REFERENCES auth.users(id) ON DELETE SET NULL;
|
||||||
|
|
||||||
|
-- ==================== INDEXES ====================
|
||||||
|
|
||||||
|
-- Customers
|
||||||
|
CREATE INDEX idx_customers_linkedin_url ON customers(linkedin_url);
|
||||||
|
CREATE INDEX idx_customers_user_id ON customers(user_id);
|
||||||
|
CREATE INDEX idx_customers_company_id ON customers(company_id);
|
||||||
|
|
||||||
|
-- LinkedIn data
|
||||||
|
CREATE INDEX idx_linkedin_profiles_customer_id ON linkedin_profiles(customer_id);
|
||||||
|
CREATE INDEX idx_linkedin_posts_customer_id ON linkedin_posts(customer_id);
|
||||||
|
CREATE INDEX idx_linkedin_posts_post_type_id ON linkedin_posts(post_type_id);
|
||||||
|
|
||||||
|
-- Topics
|
||||||
|
CREATE INDEX idx_topics_customer_id ON topics(customer_id);
|
||||||
|
CREATE INDEX idx_topics_is_used ON topics(is_used);
|
||||||
|
CREATE INDEX idx_topics_target_post_type_id ON topics(target_post_type_id);
|
||||||
|
|
||||||
|
-- Profile analyses & research
|
||||||
|
CREATE INDEX idx_profile_analyses_customer_id ON profile_analyses(customer_id);
|
||||||
|
CREATE INDEX idx_research_results_customer_id ON research_results(customer_id);
|
||||||
|
CREATE INDEX idx_research_results_target_post_type_id ON research_results(target_post_type_id);
|
||||||
|
|
||||||
|
-- Generated posts
|
||||||
|
CREATE INDEX idx_generated_posts_customer_id ON generated_posts(customer_id);
|
||||||
|
CREATE INDEX idx_generated_posts_status ON generated_posts(status);
|
||||||
|
CREATE INDEX idx_generated_posts_post_type_id ON generated_posts(post_type_id);
|
||||||
|
CREATE INDEX idx_generated_posts_scheduled_at ON generated_posts(scheduled_at)
|
||||||
|
WHERE scheduled_at IS NOT NULL AND status = 'scheduled';
|
||||||
|
|
||||||
|
-- Post types
|
||||||
|
CREATE INDEX idx_post_types_customer_id ON post_types(customer_id);
|
||||||
|
CREATE INDEX idx_post_types_is_active ON post_types(is_active);
|
||||||
|
|
||||||
|
-- Profiles
|
||||||
|
CREATE INDEX idx_profiles_account_type ON profiles(account_type);
|
||||||
|
CREATE INDEX idx_profiles_onboarding_status ON profiles(onboarding_status);
|
||||||
|
CREATE INDEX idx_profiles_customer_id ON profiles(customer_id);
|
||||||
|
CREATE INDEX idx_profiles_company_id ON profiles(company_id);
|
||||||
|
|
||||||
|
-- Companies
|
||||||
|
CREATE INDEX idx_companies_owner_user_id ON companies(owner_user_id);
|
||||||
|
|
||||||
|
-- Invitations
|
||||||
|
CREATE INDEX idx_invitations_token ON invitations(token);
|
||||||
|
CREATE INDEX idx_invitations_company_id ON invitations(company_id);
|
||||||
|
CREATE INDEX idx_invitations_email ON invitations(email);
|
||||||
|
CREATE INDEX idx_invitations_status ON invitations(status);
|
||||||
|
|
||||||
|
-- Example posts & reference profiles
|
||||||
|
CREATE INDEX idx_example_posts_customer_id ON example_posts(customer_id);
|
||||||
|
CREATE INDEX idx_reference_profiles_customer_id ON reference_profiles(customer_id);
|
||||||
|
|
||||||
|
-- ==================== TRIGGERS ====================
|
||||||
|
|
||||||
|
-- Updated_at trigger function
|
||||||
|
CREATE OR REPLACE FUNCTION update_updated_at_column()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
NEW.updated_at = NOW();
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
-- Add triggers
|
||||||
|
CREATE TRIGGER update_customers_updated_at
|
||||||
|
BEFORE UPDATE ON customers
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION update_updated_at_column();
|
||||||
|
|
||||||
|
CREATE TRIGGER update_post_types_updated_at
|
||||||
|
BEFORE UPDATE ON post_types
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION update_updated_at_column();
|
||||||
|
|
||||||
|
CREATE TRIGGER update_profiles_updated_at
|
||||||
|
BEFORE UPDATE ON profiles
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION update_updated_at_column();
|
||||||
|
|
||||||
|
CREATE TRIGGER update_companies_updated_at
|
||||||
|
BEFORE UPDATE ON companies
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION update_updated_at_column();
|
||||||
|
|
||||||
|
-- ==================== AUTO-CREATE PROFILE ON SIGNUP ====================
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION public.handle_new_user()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
INSERT INTO public.profiles (id, account_type, onboarding_status)
|
||||||
|
VALUES (
|
||||||
|
NEW.id,
|
||||||
|
COALESCE(NEW.raw_user_meta_data->>'account_type', 'ghostwriter'),
|
||||||
|
'pending'
|
||||||
|
);
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS on_auth_user_created ON auth.users;
|
||||||
|
CREATE TRIGGER on_auth_user_created
|
||||||
|
AFTER INSERT ON auth.users
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION public.handle_new_user();
|
||||||
|
|
||||||
|
-- ==================== ROW LEVEL SECURITY ====================
|
||||||
|
|
||||||
|
-- Enable RLS
|
||||||
|
ALTER TABLE profiles ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE companies ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE customers ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE generated_posts ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE invitations ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
-- Profiles policies
|
||||||
|
CREATE POLICY "Users can view own profile"
|
||||||
|
ON profiles FOR SELECT
|
||||||
|
USING (auth.uid() = id);
|
||||||
|
|
||||||
|
CREATE POLICY "Users can update own profile"
|
||||||
|
ON profiles FOR UPDATE
|
||||||
|
USING (auth.uid() = id);
|
||||||
|
|
||||||
|
CREATE POLICY "Service role has full access to profiles"
|
||||||
|
ON profiles FOR ALL
|
||||||
|
USING (auth.jwt() ->> 'role' = 'service_role');
|
||||||
|
|
||||||
|
-- Companies policies
|
||||||
|
CREATE POLICY "Company owners can manage their company"
|
||||||
|
ON companies FOR ALL
|
||||||
|
USING (auth.uid() = owner_user_id);
|
||||||
|
|
||||||
|
CREATE POLICY "Employees can view their company"
|
||||||
|
ON companies FOR SELECT
|
||||||
|
USING (
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM profiles
|
||||||
|
WHERE profiles.id = auth.uid()
|
||||||
|
AND profiles.company_id = companies.id
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE POLICY "Service role has full access to companies"
|
||||||
|
ON companies FOR ALL
|
||||||
|
USING (auth.jwt() ->> 'role' = 'service_role');
|
||||||
|
|
||||||
|
-- Customers policies
|
||||||
|
CREATE POLICY "Users can manage own customers"
|
||||||
|
ON customers FOR ALL
|
||||||
|
USING (auth.uid() = user_id);
|
||||||
|
|
||||||
|
CREATE POLICY "Company members can view company customers"
|
||||||
|
ON customers FOR SELECT
|
||||||
|
USING (
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM profiles
|
||||||
|
WHERE profiles.id = auth.uid()
|
||||||
|
AND profiles.company_id = customers.company_id
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE POLICY "Service role has full access to customers"
|
||||||
|
ON customers FOR ALL
|
||||||
|
USING (auth.jwt() ->> 'role' = 'service_role');
|
||||||
|
|
||||||
|
-- Generated posts policies
|
||||||
|
CREATE POLICY "Users can manage posts of their customers"
|
||||||
|
ON generated_posts FOR ALL
|
||||||
|
USING (
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM customers
|
||||||
|
WHERE customers.id = generated_posts.customer_id
|
||||||
|
AND customers.user_id = auth.uid()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE POLICY "Company members can view company posts"
|
||||||
|
ON generated_posts FOR SELECT
|
||||||
|
USING (
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM customers
|
||||||
|
JOIN profiles ON profiles.company_id = customers.company_id
|
||||||
|
WHERE customers.id = generated_posts.customer_id
|
||||||
|
AND profiles.id = auth.uid()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE POLICY "Service role has full access to generated_posts"
|
||||||
|
ON generated_posts FOR ALL
|
||||||
|
USING (auth.jwt() ->> 'role' = 'service_role');
|
||||||
|
|
||||||
|
-- Invitations policies
|
||||||
|
CREATE POLICY "Company owners can manage invitations"
|
||||||
|
ON invitations FOR ALL
|
||||||
|
USING (
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM companies
|
||||||
|
WHERE companies.id = invitations.company_id
|
||||||
|
AND companies.owner_user_id = auth.uid()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE POLICY "Anyone can view invitation by token"
|
||||||
|
ON invitations FOR SELECT
|
||||||
|
USING (true);
|
||||||
|
|
||||||
|
CREATE POLICY "Service role has full access to invitations"
|
||||||
|
ON invitations FOR ALL
|
||||||
|
USING (auth.jwt() ->> 'role' = 'service_role');
|
||||||
|
|
||||||
|
-- ==================== DONE ====================
|
||||||
|
-- Run this script in the Supabase SQL Editor
|
||||||
|
-- Make sure to configure your .env with the new Supabase URL and keys
|
||||||
94
config/migrate_add_license_system.sql
Normal file
94
config/migrate_add_license_system.sql
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
-- Migration: Add License Key System with Company Limits
|
||||||
|
-- This migration adds:
|
||||||
|
-- 1. license_keys table for managing license keys
|
||||||
|
-- 2. company_daily_quotas table for tracking daily usage
|
||||||
|
-- 3. license_key_id reference in companies table
|
||||||
|
|
||||||
|
-- ==================== LICENSE KEYS TABLE ====================
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS license_keys (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
key TEXT UNIQUE NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
|
||||||
|
-- Limits (stored here, NOT in companies table)
|
||||||
|
max_employees INT NOT NULL DEFAULT 5,
|
||||||
|
max_posts_per_day INT NOT NULL DEFAULT 10,
|
||||||
|
max_researches_per_day INT NOT NULL DEFAULT 5,
|
||||||
|
|
||||||
|
-- Usage tracking
|
||||||
|
used BOOLEAN DEFAULT FALSE,
|
||||||
|
company_id UUID REFERENCES companies(id) ON DELETE SET NULL,
|
||||||
|
used_at TIMESTAMP WITH TIME ZONE,
|
||||||
|
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Index for fast lookups
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_license_keys_key ON license_keys(key);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_license_keys_used ON license_keys(used);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_license_keys_company_id ON license_keys(company_id);
|
||||||
|
|
||||||
|
-- RLS Policies (Admin only)
|
||||||
|
ALTER TABLE license_keys ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
CREATE POLICY "Service role has full access to license_keys"
|
||||||
|
ON license_keys FOR ALL
|
||||||
|
USING (auth.jwt()->>'role' = 'service_role');
|
||||||
|
|
||||||
|
-- ==================== EXTEND COMPANIES TABLE ====================
|
||||||
|
|
||||||
|
-- Add license key reference to companies table
|
||||||
|
-- (limits are stored in license_keys table, not duplicated here)
|
||||||
|
ALTER TABLE companies ADD COLUMN IF NOT EXISTS license_key_id UUID REFERENCES license_keys(id);
|
||||||
|
|
||||||
|
-- Index
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_companies_license_key_id ON companies(license_key_id);
|
||||||
|
|
||||||
|
-- ==================== COMPANY DAILY QUOTAS TABLE ====================
|
||||||
|
|
||||||
|
-- Track daily usage per company
|
||||||
|
CREATE TABLE IF NOT EXISTS company_daily_quotas (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
company_id UUID NOT NULL REFERENCES companies(id) ON DELETE CASCADE,
|
||||||
|
date DATE NOT NULL,
|
||||||
|
posts_created INT DEFAULT 0,
|
||||||
|
researches_created INT DEFAULT 0,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
|
|
||||||
|
UNIQUE(company_id, date)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Index for fast daily lookups
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_company_daily_quotas_company_date ON company_daily_quotas(company_id, date);
|
||||||
|
|
||||||
|
-- RLS Policies
|
||||||
|
ALTER TABLE company_daily_quotas ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
CREATE POLICY "Users can view own company quotas"
|
||||||
|
ON company_daily_quotas FOR SELECT
|
||||||
|
USING (
|
||||||
|
company_id IN (
|
||||||
|
SELECT company_id FROM profiles WHERE id = auth.uid()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE POLICY "Service role has full access to company_daily_quotas"
|
||||||
|
ON company_daily_quotas FOR ALL
|
||||||
|
USING (auth.jwt()->>'role' = 'service_role');
|
||||||
|
|
||||||
|
-- ==================== TRIGGERS ====================
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS update_license_keys_updated_at ON license_keys;
|
||||||
|
CREATE TRIGGER update_license_keys_updated_at
|
||||||
|
BEFORE UPDATE ON license_keys
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION update_updated_at_column();
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS update_company_daily_quotas_updated_at ON company_daily_quotas;
|
||||||
|
CREATE TRIGGER update_company_daily_quotas_updated_at
|
||||||
|
BEFORE UPDATE ON company_daily_quotas
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION update_updated_at_column();
|
||||||
68
config/migrate_add_linkedin_accounts.sql
Normal file
68
config/migrate_add_linkedin_accounts.sql
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
-- Migration: Add LinkedIn accounts for auto-posting
|
||||||
|
-- Description: Allows employees to link their LinkedIn accounts for automatic post publishing
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS linkedin_accounts (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
|
|
||||||
|
-- LinkedIn Identity
|
||||||
|
linkedin_user_id TEXT NOT NULL,
|
||||||
|
linkedin_vanity_name TEXT,
|
||||||
|
linkedin_name TEXT,
|
||||||
|
linkedin_picture TEXT,
|
||||||
|
|
||||||
|
-- OAuth Tokens (encrypted)
|
||||||
|
access_token TEXT NOT NULL,
|
||||||
|
refresh_token TEXT,
|
||||||
|
token_expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||||
|
granted_scopes TEXT[] DEFAULT '{}',
|
||||||
|
|
||||||
|
-- Status
|
||||||
|
is_active BOOLEAN DEFAULT TRUE,
|
||||||
|
last_used_at TIMESTAMP WITH TIME ZONE,
|
||||||
|
last_error TEXT,
|
||||||
|
last_error_at TIMESTAMP WITH TIME ZONE,
|
||||||
|
|
||||||
|
UNIQUE(user_id) -- Only one account per user
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_linkedin_accounts_user_id ON linkedin_accounts(user_id);
|
||||||
|
|
||||||
|
-- Enable RLS
|
||||||
|
ALTER TABLE linkedin_accounts ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
-- Policy: Users can only see their own LinkedIn account
|
||||||
|
CREATE POLICY "Users can view own linkedin account"
|
||||||
|
ON linkedin_accounts FOR SELECT
|
||||||
|
USING (auth.uid() = user_id);
|
||||||
|
|
||||||
|
-- Policy: Users can insert their own LinkedIn account
|
||||||
|
CREATE POLICY "Users can insert own linkedin account"
|
||||||
|
ON linkedin_accounts FOR INSERT
|
||||||
|
WITH CHECK (auth.uid() = user_id);
|
||||||
|
|
||||||
|
-- Policy: Users can update their own LinkedIn account
|
||||||
|
CREATE POLICY "Users can update own linkedin account"
|
||||||
|
ON linkedin_accounts FOR UPDATE
|
||||||
|
USING (auth.uid() = user_id);
|
||||||
|
|
||||||
|
-- Policy: Users can delete their own LinkedIn account
|
||||||
|
CREATE POLICY "Users can delete own linkedin account"
|
||||||
|
ON linkedin_accounts FOR DELETE
|
||||||
|
USING (auth.uid() = user_id);
|
||||||
|
|
||||||
|
-- Function to update updated_at timestamp
|
||||||
|
CREATE OR REPLACE FUNCTION update_linkedin_accounts_updated_at()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
NEW.updated_at = NOW();
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
CREATE TRIGGER update_linkedin_accounts_updated_at
|
||||||
|
BEFORE UPDATE ON linkedin_accounts
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION update_linkedin_accounts_updated_at();
|
||||||
8
config/migrate_add_metadata_to_posts.sql
Normal file
8
config/migrate_add_metadata_to_posts.sql
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
-- Add metadata field to generated_posts table
|
||||||
|
-- This field stores additional information like LinkedIn post URLs, auto-posting status, etc.
|
||||||
|
|
||||||
|
ALTER TABLE generated_posts
|
||||||
|
ADD COLUMN IF NOT EXISTS metadata JSONB DEFAULT '{}'::JSONB;
|
||||||
|
|
||||||
|
-- Add comment
|
||||||
|
COMMENT ON COLUMN generated_posts.metadata IS 'Additional metadata (LinkedIn post URL, auto-posting status, etc.)';
|
||||||
314
config/migrate_remove_customers.sql
Normal file
314
config/migrate_remove_customers.sql
Normal file
@@ -0,0 +1,314 @@
|
|||||||
|
-- Migration: Remove customers table, move everything to user_id on profiles
|
||||||
|
-- BACKUP YOUR DATABASE BEFORE RUNNING THIS!
|
||||||
|
-- Run with: psql -d your_db -f config/migrate_remove_customers.sql
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- 1. Add new columns to profiles (from customers)
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
ALTER TABLE profiles
|
||||||
|
ADD COLUMN IF NOT EXISTS linkedin_url TEXT,
|
||||||
|
ADD COLUMN IF NOT EXISTS writing_style_notes TEXT,
|
||||||
|
ADD COLUMN IF NOT EXISTS metadata JSONB DEFAULT '{}'::JSONB,
|
||||||
|
ADD COLUMN IF NOT EXISTS profile_picture TEXT,
|
||||||
|
ADD COLUMN IF NOT EXISTS creator_email TEXT,
|
||||||
|
ADD COLUMN IF NOT EXISTS customer_email TEXT,
|
||||||
|
ADD COLUMN IF NOT EXISTS is_active BOOLEAN DEFAULT TRUE;
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- 2. Migrate data from customers to profiles
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
UPDATE profiles
|
||||||
|
SET
|
||||||
|
linkedin_url = c.linkedin_url,
|
||||||
|
writing_style_notes = c.writing_style_notes,
|
||||||
|
metadata = c.metadata,
|
||||||
|
profile_picture = c.profile_picture,
|
||||||
|
creator_email = c.creator_email,
|
||||||
|
customer_email = c.customer_email,
|
||||||
|
is_active = c.is_active
|
||||||
|
FROM customers c
|
||||||
|
WHERE profiles.customer_id = c.id;
|
||||||
|
|
||||||
|
-- Also populate display_name from customer.name where profiles.display_name is null
|
||||||
|
UPDATE profiles
|
||||||
|
SET display_name = c.name
|
||||||
|
FROM customers c
|
||||||
|
WHERE profiles.customer_id = c.id
|
||||||
|
AND profiles.display_name IS NULL;
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- 3. Add user_id column to all content tables
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
ALTER TABLE linkedin_profiles
|
||||||
|
ADD COLUMN IF NOT EXISTS user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE;
|
||||||
|
|
||||||
|
ALTER TABLE linkedin_posts
|
||||||
|
ADD COLUMN IF NOT EXISTS user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE;
|
||||||
|
|
||||||
|
ALTER TABLE topics
|
||||||
|
ADD COLUMN IF NOT EXISTS user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE;
|
||||||
|
|
||||||
|
ALTER TABLE post_types
|
||||||
|
ADD COLUMN IF NOT EXISTS user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE;
|
||||||
|
|
||||||
|
ALTER TABLE profile_analyses
|
||||||
|
ADD COLUMN IF NOT EXISTS user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE;
|
||||||
|
|
||||||
|
ALTER TABLE research_results
|
||||||
|
ADD COLUMN IF NOT EXISTS user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE;
|
||||||
|
|
||||||
|
ALTER TABLE generated_posts
|
||||||
|
ADD COLUMN IF NOT EXISTS user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE;
|
||||||
|
|
||||||
|
ALTER TABLE example_posts
|
||||||
|
ADD COLUMN IF NOT EXISTS user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE;
|
||||||
|
|
||||||
|
ALTER TABLE reference_profiles
|
||||||
|
ADD COLUMN IF NOT EXISTS user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE;
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- 4. Populate user_id via profiles.customer_id mapping
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
UPDATE linkedin_profiles lp
|
||||||
|
SET user_id = p.id
|
||||||
|
FROM profiles p
|
||||||
|
WHERE lp.customer_id = p.customer_id
|
||||||
|
AND p.customer_id IS NOT NULL;
|
||||||
|
|
||||||
|
UPDATE linkedin_posts lp
|
||||||
|
SET user_id = p.id
|
||||||
|
FROM profiles p
|
||||||
|
WHERE lp.customer_id = p.customer_id
|
||||||
|
AND p.customer_id IS NOT NULL;
|
||||||
|
|
||||||
|
UPDATE topics t
|
||||||
|
SET user_id = p.id
|
||||||
|
FROM profiles p
|
||||||
|
WHERE t.customer_id = p.customer_id
|
||||||
|
AND p.customer_id IS NOT NULL;
|
||||||
|
|
||||||
|
UPDATE post_types pt
|
||||||
|
SET user_id = p.id
|
||||||
|
FROM profiles p
|
||||||
|
WHERE pt.customer_id = p.customer_id
|
||||||
|
AND p.customer_id IS NOT NULL;
|
||||||
|
|
||||||
|
UPDATE profile_analyses pa
|
||||||
|
SET user_id = p.id
|
||||||
|
FROM profiles p
|
||||||
|
WHERE pa.customer_id = p.customer_id
|
||||||
|
AND p.customer_id IS NOT NULL;
|
||||||
|
|
||||||
|
UPDATE research_results rr
|
||||||
|
SET user_id = p.id
|
||||||
|
FROM profiles p
|
||||||
|
WHERE rr.customer_id = p.customer_id
|
||||||
|
AND p.customer_id IS NOT NULL;
|
||||||
|
|
||||||
|
UPDATE generated_posts gp
|
||||||
|
SET user_id = p.id
|
||||||
|
FROM profiles p
|
||||||
|
WHERE gp.customer_id = p.customer_id
|
||||||
|
AND p.customer_id IS NOT NULL;
|
||||||
|
|
||||||
|
UPDATE example_posts ep
|
||||||
|
SET user_id = p.id
|
||||||
|
FROM profiles p
|
||||||
|
WHERE ep.customer_id = p.customer_id
|
||||||
|
AND p.customer_id IS NOT NULL;
|
||||||
|
|
||||||
|
UPDATE reference_profiles rp
|
||||||
|
SET user_id = p.id
|
||||||
|
FROM profiles p
|
||||||
|
WHERE rp.customer_id = p.customer_id
|
||||||
|
AND p.customer_id IS NOT NULL;
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- 5. Drop old unique constraints (customer_id based)
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
ALTER TABLE linkedin_profiles DROP CONSTRAINT IF EXISTS linkedin_profiles_customer_id_key;
|
||||||
|
ALTER TABLE profile_analyses DROP CONSTRAINT IF EXISTS profile_analyses_customer_id_key;
|
||||||
|
ALTER TABLE post_types DROP CONSTRAINT IF EXISTS post_types_customer_id_name_key;
|
||||||
|
ALTER TABLE linkedin_posts DROP CONSTRAINT IF EXISTS linkedin_posts_customer_id_post_url_key;
|
||||||
|
ALTER TABLE reference_profiles DROP CONSTRAINT IF EXISTS reference_profiles_customer_id_linkedin_url_key;
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- 6. Set user_id NOT NULL (after data migration)
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
-- Delete orphan rows that have no user_id (data without a profile link)
|
||||||
|
DELETE FROM linkedin_profiles WHERE user_id IS NULL;
|
||||||
|
DELETE FROM linkedin_posts WHERE user_id IS NULL;
|
||||||
|
DELETE FROM topics WHERE user_id IS NULL;
|
||||||
|
DELETE FROM post_types WHERE user_id IS NULL;
|
||||||
|
DELETE FROM profile_analyses WHERE user_id IS NULL;
|
||||||
|
DELETE FROM research_results WHERE user_id IS NULL;
|
||||||
|
DELETE FROM generated_posts WHERE user_id IS NULL;
|
||||||
|
DELETE FROM example_posts WHERE user_id IS NULL;
|
||||||
|
DELETE FROM reference_profiles WHERE user_id IS NULL;
|
||||||
|
|
||||||
|
ALTER TABLE linkedin_profiles ALTER COLUMN user_id SET NOT NULL;
|
||||||
|
ALTER TABLE linkedin_posts ALTER COLUMN user_id SET NOT NULL;
|
||||||
|
ALTER TABLE topics ALTER COLUMN user_id SET NOT NULL;
|
||||||
|
ALTER TABLE post_types ALTER COLUMN user_id SET NOT NULL;
|
||||||
|
ALTER TABLE profile_analyses ALTER COLUMN user_id SET NOT NULL;
|
||||||
|
ALTER TABLE research_results ALTER COLUMN user_id SET NOT NULL;
|
||||||
|
ALTER TABLE generated_posts ALTER COLUMN user_id SET NOT NULL;
|
||||||
|
ALTER TABLE example_posts ALTER COLUMN user_id SET NOT NULL;
|
||||||
|
ALTER TABLE reference_profiles ALTER COLUMN user_id SET NOT NULL;
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- 7. Add new unique constraints (user_id based)
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
ALTER TABLE linkedin_profiles ADD CONSTRAINT linkedin_profiles_user_id_key UNIQUE (user_id);
|
||||||
|
ALTER TABLE profile_analyses ADD CONSTRAINT profile_analyses_user_id_key UNIQUE (user_id);
|
||||||
|
ALTER TABLE post_types ADD CONSTRAINT post_types_user_id_name_key UNIQUE (user_id, name);
|
||||||
|
ALTER TABLE linkedin_posts ADD CONSTRAINT linkedin_posts_user_id_post_url_key UNIQUE (user_id, post_url);
|
||||||
|
ALTER TABLE reference_profiles ADD CONSTRAINT reference_profiles_user_id_linkedin_url_key UNIQUE (user_id, linkedin_url);
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- 8. Drop RLS policies that reference customer_id
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
-- Drop ALL policies on generated_posts (we'll recreate them with user_id)
|
||||||
|
DROP POLICY IF EXISTS "Users can manage posts of their customers" ON generated_posts;
|
||||||
|
DROP POLICY IF EXISTS "Company members can view company posts" ON generated_posts;
|
||||||
|
DROP POLICY IF EXISTS "Service role has full access to generated_posts" ON generated_posts;
|
||||||
|
|
||||||
|
-- Drop policies on customers table (will be dropped anyway)
|
||||||
|
DROP POLICY IF EXISTS "Users can manage own customers" ON customers;
|
||||||
|
DROP POLICY IF EXISTS "Company members can view company customers" ON customers;
|
||||||
|
DROP POLICY IF EXISTS "Service role has full access to customers" ON customers;
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- 8b. Drop users view (depends on profiles.customer_id)
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
DROP VIEW IF EXISTS users;
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- 9. Drop old customer_id columns from content tables
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
ALTER TABLE linkedin_profiles DROP COLUMN IF EXISTS customer_id;
|
||||||
|
ALTER TABLE linkedin_posts DROP COLUMN IF EXISTS customer_id;
|
||||||
|
ALTER TABLE topics DROP COLUMN IF EXISTS customer_id;
|
||||||
|
ALTER TABLE post_types DROP COLUMN IF EXISTS customer_id;
|
||||||
|
ALTER TABLE profile_analyses DROP COLUMN IF EXISTS customer_id;
|
||||||
|
ALTER TABLE research_results DROP COLUMN IF EXISTS customer_id;
|
||||||
|
ALTER TABLE generated_posts DROP COLUMN IF EXISTS customer_id;
|
||||||
|
ALTER TABLE example_posts DROP COLUMN IF EXISTS customer_id;
|
||||||
|
ALTER TABLE reference_profiles DROP COLUMN IF EXISTS customer_id;
|
||||||
|
|
||||||
|
-- Drop customer_id from profiles
|
||||||
|
ALTER TABLE profiles DROP COLUMN IF EXISTS customer_id;
|
||||||
|
|
||||||
|
-- Drop customer_id from api_usage_logs
|
||||||
|
ALTER TABLE api_usage_logs DROP COLUMN IF EXISTS customer_id;
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- 10. Create new RLS policies using user_id
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
-- Generated posts policies (using user_id instead of customer_id)
|
||||||
|
CREATE POLICY "Users can manage own posts"
|
||||||
|
ON generated_posts FOR ALL
|
||||||
|
USING (user_id = auth.uid());
|
||||||
|
|
||||||
|
CREATE POLICY "Company members can view company posts"
|
||||||
|
ON generated_posts FOR SELECT
|
||||||
|
USING (
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM profiles p1
|
||||||
|
JOIN profiles p2 ON p1.company_id = p2.company_id
|
||||||
|
WHERE p1.id = auth.uid()
|
||||||
|
AND p2.id = generated_posts.user_id
|
||||||
|
AND p1.company_id IS NOT NULL
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE POLICY "Service role has full access to generated_posts"
|
||||||
|
ON generated_posts FOR ALL
|
||||||
|
USING (auth.jwt() ->> 'role' = 'service_role');
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- 11. Drop old indexes and create new ones
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
DROP INDEX IF EXISTS idx_linkedin_profiles_customer_id;
|
||||||
|
DROP INDEX IF EXISTS idx_linkedin_posts_customer_id;
|
||||||
|
DROP INDEX IF EXISTS idx_topics_customer_id;
|
||||||
|
DROP INDEX IF EXISTS idx_post_types_customer_id;
|
||||||
|
DROP INDEX IF EXISTS idx_profile_analyses_customer_id;
|
||||||
|
DROP INDEX IF EXISTS idx_research_results_customer_id;
|
||||||
|
DROP INDEX IF EXISTS idx_generated_posts_customer_id;
|
||||||
|
DROP INDEX IF EXISTS idx_example_posts_customer_id;
|
||||||
|
DROP INDEX IF EXISTS idx_reference_profiles_customer_id;
|
||||||
|
DROP INDEX IF EXISTS idx_profiles_customer_id;
|
||||||
|
DROP INDEX IF EXISTS idx_customers_linkedin_url;
|
||||||
|
DROP INDEX IF EXISTS idx_customers_user_id;
|
||||||
|
DROP INDEX IF EXISTS idx_customers_company_id;
|
||||||
|
DROP INDEX IF EXISTS idx_api_usage_logs_customer_id;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_linkedin_profiles_user_id ON linkedin_profiles(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_linkedin_posts_user_id ON linkedin_posts(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_topics_user_id ON topics(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_post_types_user_id ON post_types(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_profile_analyses_user_id ON profile_analyses(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_research_results_user_id ON research_results(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_generated_posts_user_id ON generated_posts(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_example_posts_user_id ON example_posts(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_reference_profiles_user_id ON reference_profiles(user_id);
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- 12. Drop customers table
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
-- Remove trigger first
|
||||||
|
DROP TRIGGER IF EXISTS update_customers_updated_at ON customers;
|
||||||
|
|
||||||
|
DROP TABLE IF EXISTS customers CASCADE;
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- 13. Recreate users view (without customer_id)
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
CREATE OR REPLACE VIEW users AS
|
||||||
|
SELECT
|
||||||
|
au.id,
|
||||||
|
au.created_at,
|
||||||
|
au.updated_at,
|
||||||
|
au.email,
|
||||||
|
au.raw_user_meta_data->>'sub' AS linkedin_sub,
|
||||||
|
au.raw_user_meta_data->>'vanityName' AS linkedin_vanity_name,
|
||||||
|
au.raw_user_meta_data->>'name' AS linkedin_name,
|
||||||
|
au.raw_user_meta_data->>'picture' AS linkedin_picture,
|
||||||
|
CASE
|
||||||
|
WHEN au.raw_user_meta_data->>'iss' LIKE '%linkedin%' THEN 'linkedin_oauth'
|
||||||
|
ELSE 'email_password'
|
||||||
|
END AS auth_method,
|
||||||
|
COALESCE(p.account_type, 'ghostwriter') AS account_type,
|
||||||
|
p.display_name,
|
||||||
|
COALESCE(p.onboarding_status, 'pending') AS onboarding_status,
|
||||||
|
COALESCE(p.onboarding_data, '{}'::jsonb) AS onboarding_data,
|
||||||
|
p.company_id,
|
||||||
|
p.linkedin_url,
|
||||||
|
p.writing_style_notes,
|
||||||
|
p.metadata,
|
||||||
|
p.profile_picture,
|
||||||
|
p.creator_email,
|
||||||
|
p.customer_email,
|
||||||
|
p.is_active,
|
||||||
|
au.email_confirmed_at IS NOT NULL AS email_verified
|
||||||
|
FROM auth.users au
|
||||||
|
LEFT JOIN profiles p ON au.id = p.id;
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
7
config/migrate_remove_linkedin_unique.sql
Normal file
7
config/migrate_remove_linkedin_unique.sql
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
-- Migration: Remove UNIQUE constraint from customers.linkedin_url
|
||||||
|
-- This allows multiple customers (different ghostwriters/companies) to share the same LinkedIn URL.
|
||||||
|
-- Each user context gets its own independent Customer record.
|
||||||
|
|
||||||
|
ALTER TABLE customers DROP CONSTRAINT IF EXISTS customers_linkedin_url_key;
|
||||||
|
|
||||||
|
-- The existing index idx_customers_linkedin_url remains for fast lookups (non-unique).
|
||||||
210
config/migrate_to_supabase_auth.sql
Normal file
210
config/migrate_to_supabase_auth.sql
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
-- Migration Script: From custom users table to Supabase Auth
|
||||||
|
-- ============================================================
|
||||||
|
-- IMPORTANT: Run these steps IN ORDER, one at a time!
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- STEP 1: Backup the old users table (run this first!)
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
-- Create backup of existing users table
|
||||||
|
CREATE TABLE IF NOT EXISTS users_backup AS SELECT * FROM users;
|
||||||
|
|
||||||
|
-- Verify backup was created
|
||||||
|
SELECT COUNT(*) as backup_count FROM users_backup;
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- STEP 2: Create profiles table
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS profiles (
|
||||||
|
id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
|
account_type TEXT NOT NULL DEFAULT 'ghostwriter',
|
||||||
|
display_name TEXT,
|
||||||
|
onboarding_status TEXT DEFAULT 'pending',
|
||||||
|
onboarding_data JSONB DEFAULT '{}'::JSONB,
|
||||||
|
customer_id UUID,
|
||||||
|
company_id UUID
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- STEP 3: Drop constraints that reference the old users table
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
-- Drop FK constraints from companies table
|
||||||
|
ALTER TABLE companies DROP CONSTRAINT IF EXISTS companies_owner_user_id_fkey;
|
||||||
|
|
||||||
|
-- Drop FK constraints from invitations table
|
||||||
|
ALTER TABLE invitations DROP CONSTRAINT IF EXISTS invitations_invited_by_user_id_fkey;
|
||||||
|
ALTER TABLE invitations DROP CONSTRAINT IF EXISTS invitations_accepted_by_user_id_fkey;
|
||||||
|
|
||||||
|
-- Drop FK constraints from customers table
|
||||||
|
ALTER TABLE customers DROP CONSTRAINT IF EXISTS customers_user_id_fkey;
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- STEP 4: Rename old users table
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
ALTER TABLE IF EXISTS users RENAME TO users_old;
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- STEP 5: Create the users VIEW
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
CREATE OR REPLACE VIEW users AS
|
||||||
|
SELECT
|
||||||
|
p.id,
|
||||||
|
p.created_at,
|
||||||
|
p.updated_at,
|
||||||
|
au.email,
|
||||||
|
NULL::TEXT as password_hash,
|
||||||
|
CASE
|
||||||
|
WHEN au.raw_app_meta_data->>'provider' = 'linkedin_oidc' THEN 'linkedin_oauth'
|
||||||
|
ELSE 'email_password'
|
||||||
|
END as auth_method,
|
||||||
|
au.raw_user_meta_data->>'sub' as linkedin_sub,
|
||||||
|
au.raw_user_meta_data->>'vanityName' as linkedin_vanity_name,
|
||||||
|
COALESCE(au.raw_user_meta_data->>'name', au.raw_user_meta_data->>'full_name') as linkedin_name,
|
||||||
|
au.raw_user_meta_data->>'picture' as linkedin_picture,
|
||||||
|
p.account_type,
|
||||||
|
p.display_name,
|
||||||
|
p.onboarding_status,
|
||||||
|
p.onboarding_data,
|
||||||
|
p.customer_id,
|
||||||
|
p.company_id,
|
||||||
|
au.email_confirmed_at IS NOT NULL as email_verified,
|
||||||
|
NULL::TEXT as email_verification_token,
|
||||||
|
NULL::TIMESTAMP WITH TIME ZONE as email_verification_expires_at
|
||||||
|
FROM profiles p
|
||||||
|
JOIN auth.users au ON p.id = au.id;
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- STEP 6: Create the trigger for new users
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION public.handle_new_user()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
INSERT INTO public.profiles (id, account_type, onboarding_status, display_name)
|
||||||
|
VALUES (
|
||||||
|
NEW.id,
|
||||||
|
COALESCE(NEW.raw_user_meta_data->>'account_type', 'ghostwriter'),
|
||||||
|
'pending',
|
||||||
|
NEW.raw_user_meta_data->>'display_name'
|
||||||
|
)
|
||||||
|
ON CONFLICT (id) DO NOTHING;
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS on_auth_user_created ON auth.users;
|
||||||
|
CREATE TRIGGER on_auth_user_created
|
||||||
|
AFTER INSERT ON auth.users
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION public.handle_new_user();
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- STEP 7: Re-add FK constraints pointing to auth.users
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
-- Add FK to companies
|
||||||
|
ALTER TABLE companies
|
||||||
|
ADD CONSTRAINT companies_owner_user_id_fkey
|
||||||
|
FOREIGN KEY (owner_user_id) REFERENCES auth.users(id) ON DELETE CASCADE;
|
||||||
|
|
||||||
|
-- Add FK to invitations
|
||||||
|
ALTER TABLE invitations
|
||||||
|
ADD CONSTRAINT invitations_invited_by_user_id_fkey
|
||||||
|
FOREIGN KEY (invited_by_user_id) REFERENCES auth.users(id) ON DELETE CASCADE;
|
||||||
|
|
||||||
|
ALTER TABLE invitations
|
||||||
|
ADD CONSTRAINT invitations_accepted_by_user_id_fkey
|
||||||
|
FOREIGN KEY (accepted_by_user_id) REFERENCES auth.users(id) ON DELETE SET NULL;
|
||||||
|
|
||||||
|
-- Add FK to customers
|
||||||
|
ALTER TABLE customers
|
||||||
|
ADD CONSTRAINT customers_user_id_fkey
|
||||||
|
FOREIGN KEY (user_id) REFERENCES auth.users(id) ON DELETE SET NULL;
|
||||||
|
|
||||||
|
-- Add FK from profiles to companies
|
||||||
|
ALTER TABLE profiles
|
||||||
|
ADD CONSTRAINT fk_profiles_company
|
||||||
|
FOREIGN KEY (company_id) REFERENCES companies(id) ON DELETE SET NULL;
|
||||||
|
|
||||||
|
-- Add FK from profiles to customers
|
||||||
|
ALTER TABLE profiles
|
||||||
|
ADD CONSTRAINT fk_profiles_customer
|
||||||
|
FOREIGN KEY (customer_id) REFERENCES customers(id) ON DELETE SET NULL;
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- STEP 8: Create indexes on profiles
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_profiles_account_type ON profiles(account_type);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_profiles_onboarding_status ON profiles(onboarding_status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_profiles_customer_id ON profiles(customer_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_profiles_company_id ON profiles(company_id);
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- STEP 9: Create updated_at trigger for profiles
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
-- Make sure the function exists
|
||||||
|
CREATE OR REPLACE FUNCTION update_updated_at_column()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
NEW.updated_at = NOW();
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS update_profiles_updated_at ON profiles;
|
||||||
|
CREATE TRIGGER update_profiles_updated_at
|
||||||
|
BEFORE UPDATE ON profiles
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION update_updated_at_column();
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- STEP 10: Enable RLS on profiles
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
ALTER TABLE profiles ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
-- Drop existing policies if they exist
|
||||||
|
DROP POLICY IF EXISTS "Users can view own profile" ON profiles;
|
||||||
|
DROP POLICY IF EXISTS "Users can update own profile" ON profiles;
|
||||||
|
DROP POLICY IF EXISTS "Service role has full access to profiles" ON profiles;
|
||||||
|
|
||||||
|
-- Profiles: Users can read/update their own profile
|
||||||
|
CREATE POLICY "Users can view own profile"
|
||||||
|
ON profiles FOR SELECT
|
||||||
|
USING (auth.uid() = id);
|
||||||
|
|
||||||
|
CREATE POLICY "Users can update own profile"
|
||||||
|
ON profiles FOR UPDATE
|
||||||
|
USING (auth.uid() = id);
|
||||||
|
|
||||||
|
-- Service role can do everything (for admin operations)
|
||||||
|
CREATE POLICY "Service role has full access to profiles"
|
||||||
|
ON profiles FOR ALL
|
||||||
|
USING (true);
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- DONE!
|
||||||
|
--
|
||||||
|
-- The old users table has been renamed to 'users_old'.
|
||||||
|
-- A backup was created as 'users_backup'.
|
||||||
|
--
|
||||||
|
-- New users will be created via Supabase Auth and will
|
||||||
|
-- automatically get a profile via the trigger.
|
||||||
|
--
|
||||||
|
-- To clean up later (after verifying everything works):
|
||||||
|
-- DROP TABLE IF EXISTS users_old;
|
||||||
|
-- DROP TABLE IF EXISTS users_backup;
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
-- Verify the migration
|
||||||
|
SELECT 'Migration complete!' as status;
|
||||||
|
SELECT COUNT(*) as profiles_count FROM profiles;
|
||||||
|
SELECT COUNT(*) as auth_users_count FROM auth.users;
|
||||||
257
config/migrate_to_supabase_auth_v2.sql
Normal file
257
config/migrate_to_supabase_auth_v2.sql
Normal file
@@ -0,0 +1,257 @@
|
|||||||
|
-- Migration Script v2: From custom users table to Supabase Auth
|
||||||
|
-- ============================================================
|
||||||
|
-- This version handles orphaned references properly
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- STEP 1: Backup tables
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS users_backup AS SELECT * FROM users;
|
||||||
|
CREATE TABLE IF NOT EXISTS customers_backup AS SELECT * FROM customers;
|
||||||
|
CREATE TABLE IF NOT EXISTS companies_backup AS SELECT * FROM companies;
|
||||||
|
CREATE TABLE IF NOT EXISTS invitations_backup AS SELECT * FROM invitations;
|
||||||
|
|
||||||
|
SELECT 'Backups created' as status;
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- STEP 2: Create profiles table
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS profiles (
|
||||||
|
id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
|
account_type TEXT NOT NULL DEFAULT 'ghostwriter',
|
||||||
|
display_name TEXT,
|
||||||
|
onboarding_status TEXT DEFAULT 'pending',
|
||||||
|
onboarding_data JSONB DEFAULT '{}'::JSONB,
|
||||||
|
customer_id UUID,
|
||||||
|
company_id UUID
|
||||||
|
);
|
||||||
|
|
||||||
|
SELECT 'Profiles table created' as status;
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- STEP 3: Drop ALL constraints that reference the old users table
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
ALTER TABLE companies DROP CONSTRAINT IF EXISTS companies_owner_user_id_fkey;
|
||||||
|
ALTER TABLE invitations DROP CONSTRAINT IF EXISTS invitations_invited_by_user_id_fkey;
|
||||||
|
ALTER TABLE invitations DROP CONSTRAINT IF EXISTS invitations_accepted_by_user_id_fkey;
|
||||||
|
ALTER TABLE customers DROP CONSTRAINT IF EXISTS customers_user_id_fkey;
|
||||||
|
|
||||||
|
-- Also drop the FK from users to companies if it exists
|
||||||
|
ALTER TABLE users DROP CONSTRAINT IF EXISTS fk_users_company;
|
||||||
|
ALTER TABLE users DROP CONSTRAINT IF EXISTS users_company_id_fkey;
|
||||||
|
|
||||||
|
SELECT 'Old constraints dropped' as status;
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- STEP 4: Clear orphaned references BEFORE adding new constraints
|
||||||
|
-- Set user_id to NULL where user doesn't exist in auth.users
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
-- Clear customers.user_id where user doesn't exist in auth.users
|
||||||
|
UPDATE customers
|
||||||
|
SET user_id = NULL
|
||||||
|
WHERE user_id IS NOT NULL
|
||||||
|
AND user_id NOT IN (SELECT id FROM auth.users);
|
||||||
|
|
||||||
|
-- Clear companies.owner_user_id - but we can't set to NULL (NOT NULL constraint)
|
||||||
|
-- So we need to delete companies with orphaned owners, or skip FK for now
|
||||||
|
-- Let's check if there are any orphaned companies first
|
||||||
|
SELECT 'Orphaned companies:' as info, COUNT(*) as count
|
||||||
|
FROM companies
|
||||||
|
WHERE owner_user_id NOT IN (SELECT id FROM auth.users);
|
||||||
|
|
||||||
|
-- Delete companies with orphaned owner_user_id (they can't be used anyway)
|
||||||
|
DELETE FROM companies
|
||||||
|
WHERE owner_user_id NOT IN (SELECT id FROM auth.users);
|
||||||
|
|
||||||
|
-- Clear invitations with orphaned user references
|
||||||
|
UPDATE invitations
|
||||||
|
SET accepted_by_user_id = NULL
|
||||||
|
WHERE accepted_by_user_id IS NOT NULL
|
||||||
|
AND accepted_by_user_id NOT IN (SELECT id FROM auth.users);
|
||||||
|
|
||||||
|
DELETE FROM invitations
|
||||||
|
WHERE invited_by_user_id NOT IN (SELECT id FROM auth.users);
|
||||||
|
|
||||||
|
SELECT 'Orphaned references cleaned' as status;
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- STEP 5: Rename old users table
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
ALTER TABLE IF EXISTS users RENAME TO users_old;
|
||||||
|
|
||||||
|
SELECT 'Old users table renamed to users_old' as status;
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- STEP 6: Create the users VIEW
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
CREATE OR REPLACE VIEW users AS
|
||||||
|
SELECT
|
||||||
|
p.id,
|
||||||
|
p.created_at,
|
||||||
|
p.updated_at,
|
||||||
|
au.email,
|
||||||
|
NULL::TEXT as password_hash,
|
||||||
|
CASE
|
||||||
|
WHEN au.raw_app_meta_data->>'provider' = 'linkedin_oidc' THEN 'linkedin_oauth'
|
||||||
|
ELSE 'email_password'
|
||||||
|
END as auth_method,
|
||||||
|
au.raw_user_meta_data->>'sub' as linkedin_sub,
|
||||||
|
au.raw_user_meta_data->>'vanityName' as linkedin_vanity_name,
|
||||||
|
COALESCE(au.raw_user_meta_data->>'name', au.raw_user_meta_data->>'full_name') as linkedin_name,
|
||||||
|
au.raw_user_meta_data->>'picture' as linkedin_picture,
|
||||||
|
p.account_type,
|
||||||
|
p.display_name,
|
||||||
|
p.onboarding_status,
|
||||||
|
p.onboarding_data,
|
||||||
|
p.customer_id,
|
||||||
|
p.company_id,
|
||||||
|
au.email_confirmed_at IS NOT NULL as email_verified,
|
||||||
|
NULL::TEXT as email_verification_token,
|
||||||
|
NULL::TIMESTAMP WITH TIME ZONE as email_verification_expires_at
|
||||||
|
FROM profiles p
|
||||||
|
JOIN auth.users au ON p.id = au.id;
|
||||||
|
|
||||||
|
SELECT 'Users VIEW created' as status;
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- STEP 7: Create the trigger for new users
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION public.handle_new_user()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
INSERT INTO public.profiles (id, account_type, onboarding_status, display_name)
|
||||||
|
VALUES (
|
||||||
|
NEW.id,
|
||||||
|
COALESCE(NEW.raw_user_meta_data->>'account_type', 'ghostwriter'),
|
||||||
|
'pending',
|
||||||
|
NEW.raw_user_meta_data->>'display_name'
|
||||||
|
)
|
||||||
|
ON CONFLICT (id) DO NOTHING;
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS on_auth_user_created ON auth.users;
|
||||||
|
CREATE TRIGGER on_auth_user_created
|
||||||
|
AFTER INSERT ON auth.users
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION public.handle_new_user();
|
||||||
|
|
||||||
|
SELECT 'Trigger created' as status;
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- STEP 8: Add NEW FK constraints pointing to auth.users
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
-- Add FK to customers (user_id can be NULL)
|
||||||
|
ALTER TABLE customers
|
||||||
|
ADD CONSTRAINT customers_user_id_fkey
|
||||||
|
FOREIGN KEY (user_id) REFERENCES auth.users(id) ON DELETE SET NULL;
|
||||||
|
|
||||||
|
-- Add FK to companies (only if there are no orphaned references)
|
||||||
|
ALTER TABLE companies
|
||||||
|
ADD CONSTRAINT companies_owner_user_id_fkey
|
||||||
|
FOREIGN KEY (owner_user_id) REFERENCES auth.users(id) ON DELETE CASCADE;
|
||||||
|
|
||||||
|
-- Add FK to invitations
|
||||||
|
ALTER TABLE invitations
|
||||||
|
ADD CONSTRAINT invitations_invited_by_user_id_fkey
|
||||||
|
FOREIGN KEY (invited_by_user_id) REFERENCES auth.users(id) ON DELETE CASCADE;
|
||||||
|
|
||||||
|
ALTER TABLE invitations
|
||||||
|
ADD CONSTRAINT invitations_accepted_by_user_id_fkey
|
||||||
|
FOREIGN KEY (accepted_by_user_id) REFERENCES auth.users(id) ON DELETE SET NULL;
|
||||||
|
|
||||||
|
-- Add FK from profiles to companies
|
||||||
|
ALTER TABLE profiles
|
||||||
|
ADD CONSTRAINT fk_profiles_company
|
||||||
|
FOREIGN KEY (company_id) REFERENCES companies(id) ON DELETE SET NULL;
|
||||||
|
|
||||||
|
-- Add FK from profiles to customers
|
||||||
|
ALTER TABLE profiles
|
||||||
|
ADD CONSTRAINT fk_profiles_customer
|
||||||
|
FOREIGN KEY (customer_id) REFERENCES customers(id) ON DELETE SET NULL;
|
||||||
|
|
||||||
|
SELECT 'New FK constraints added' as status;
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- STEP 9: Create indexes on profiles
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_profiles_account_type ON profiles(account_type);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_profiles_onboarding_status ON profiles(onboarding_status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_profiles_customer_id ON profiles(customer_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_profiles_company_id ON profiles(company_id);
|
||||||
|
|
||||||
|
SELECT 'Indexes created' as status;
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- STEP 10: Create updated_at trigger for profiles
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION update_updated_at_column()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
NEW.updated_at = NOW();
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS update_profiles_updated_at ON profiles;
|
||||||
|
CREATE TRIGGER update_profiles_updated_at
|
||||||
|
BEFORE UPDATE ON profiles
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION update_updated_at_column();
|
||||||
|
|
||||||
|
SELECT 'Updated_at trigger created' as status;
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- STEP 11: Enable RLS on profiles
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
ALTER TABLE profiles ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
DROP POLICY IF EXISTS "Users can view own profile" ON profiles;
|
||||||
|
DROP POLICY IF EXISTS "Users can update own profile" ON profiles;
|
||||||
|
DROP POLICY IF EXISTS "Service role has full access to profiles" ON profiles;
|
||||||
|
|
||||||
|
CREATE POLICY "Users can view own profile"
|
||||||
|
ON profiles FOR SELECT
|
||||||
|
USING (auth.uid() = id);
|
||||||
|
|
||||||
|
CREATE POLICY "Users can update own profile"
|
||||||
|
ON profiles FOR UPDATE
|
||||||
|
USING (auth.uid() = id);
|
||||||
|
|
||||||
|
CREATE POLICY "Service role has full access to profiles"
|
||||||
|
ON profiles FOR ALL
|
||||||
|
USING (true);
|
||||||
|
|
||||||
|
SELECT 'RLS enabled' as status;
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- VERIFICATION
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
SELECT 'Migration complete!' as status;
|
||||||
|
SELECT 'Profiles count:' as info, COUNT(*) as count FROM profiles;
|
||||||
|
SELECT 'Auth users count:' as info, COUNT(*) as count FROM auth.users;
|
||||||
|
SELECT 'Customers with user_id:' as info, COUNT(*) as count FROM customers WHERE user_id IS NOT NULL;
|
||||||
|
SELECT 'Companies count:' as info, COUNT(*) as count FROM companies;
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- CLEANUP (run later after verifying everything works)
|
||||||
|
-- ============================================================
|
||||||
|
DROP TABLE IF EXISTS users_old;
|
||||||
|
DROP TABLE IF EXISTS users_backup;
|
||||||
|
DROP TABLE IF EXISTS customers_backup;
|
||||||
|
DROP TABLE IF EXISTS companies_backup;
|
||||||
|
DROP TABLE IF EXISTS invitations_backup;
|
||||||
@@ -1,30 +1,92 @@
|
|||||||
-- LinkedIn Workflow Database Schema for Supabase
|
-- LinkedIn Workflow Database Schema for Supabase
|
||||||
|
-- Post-migration schema: No customers table, all content uses user_id
|
||||||
|
|
||||||
-- Enable UUID extension
|
-- Enable UUID extension
|
||||||
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||||
|
|
||||||
-- Customers/Clients Table
|
-- ==================== GHOSTWRITER & COMPANY ACCOUNTS ====================
|
||||||
CREATE TABLE IF NOT EXISTS customers (
|
-- Uses Supabase Auth (auth.users) for authentication
|
||||||
id UUID PRIMARY wKEY DEFAULT uuid_generate_v4(),
|
-- The profiles table stores application-specific user data
|
||||||
|
|
||||||
|
-- Profiles Table (extends auth.users with app-specific data)
|
||||||
|
CREATE TABLE IF NOT EXISTS profiles (
|
||||||
|
id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
|
|
||||||
-- Basic Info
|
-- Account Type
|
||||||
name TEXT NOT NULL,
|
account_type TEXT NOT NULL DEFAULT 'ghostwriter', -- 'ghostwriter' | 'company' | 'employee'
|
||||||
email TEXT,
|
|
||||||
company_name TEXT,
|
|
||||||
|
|
||||||
-- LinkedIn Profile
|
-- Display name
|
||||||
linkedin_url TEXT NOT NULL UNIQUE,
|
display_name TEXT,
|
||||||
|
|
||||||
-- Metadata
|
-- Onboarding
|
||||||
metadata JSONB DEFAULT '{}'::JSONB
|
onboarding_status TEXT DEFAULT 'pending', -- 'pending' | 'profile_setup' | 'posts_scraped' | 'categorizing' | 'completed'
|
||||||
|
onboarding_data JSONB DEFAULT '{}'::JSONB,
|
||||||
|
|
||||||
|
-- Company link
|
||||||
|
company_id UUID, -- FK added after companies table
|
||||||
|
|
||||||
|
-- Fields migrated from customers
|
||||||
|
linkedin_url TEXT,
|
||||||
|
writing_style_notes TEXT,
|
||||||
|
metadata JSONB DEFAULT '{}'::JSONB,
|
||||||
|
profile_picture TEXT,
|
||||||
|
creator_email TEXT,
|
||||||
|
customer_email TEXT,
|
||||||
|
is_active BOOLEAN DEFAULT TRUE
|
||||||
);
|
);
|
||||||
|
|
||||||
|
-- Function to automatically create profile on user signup
|
||||||
|
CREATE OR REPLACE FUNCTION public.handle_new_user()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
INSERT INTO public.profiles (id, account_type, onboarding_status)
|
||||||
|
VALUES (
|
||||||
|
NEW.id,
|
||||||
|
COALESCE(NEW.raw_user_meta_data->>'account_type', 'ghostwriter'),
|
||||||
|
'pending'
|
||||||
|
);
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
||||||
|
|
||||||
|
-- Trigger to create profile on auth.users insert
|
||||||
|
DROP TRIGGER IF EXISTS on_auth_user_created ON auth.users;
|
||||||
|
CREATE TRIGGER on_auth_user_created
|
||||||
|
AFTER INSERT ON auth.users
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION public.handle_new_user();
|
||||||
|
|
||||||
|
-- Companies Table (company accounts)
|
||||||
|
CREATE TABLE IF NOT EXISTS companies (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
|
|
||||||
|
-- Company Data
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
website TEXT,
|
||||||
|
industry TEXT,
|
||||||
|
|
||||||
|
-- Strategy (used during post creation)
|
||||||
|
company_strategy JSONB DEFAULT '{}'::JSONB,
|
||||||
|
|
||||||
|
owner_user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||||
|
onboarding_completed BOOLEAN DEFAULT FALSE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Add FK from profiles to companies (after companies table exists)
|
||||||
|
ALTER TABLE profiles
|
||||||
|
ADD CONSTRAINT fk_profiles_company
|
||||||
|
FOREIGN KEY (company_id) REFERENCES companies(id) ON DELETE SET NULL;
|
||||||
|
|
||||||
|
-- ==================== CONTENT TABLES (all use user_id) ====================
|
||||||
|
|
||||||
-- LinkedIn Profiles Table (scraped data)
|
-- LinkedIn Profiles Table (scraped data)
|
||||||
CREATE TABLE IF NOT EXISTS linkedin_profiles (
|
CREATE TABLE IF NOT EXISTS linkedin_profiles (
|
||||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
customer_id UUID NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
|
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||||
scraped_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
scraped_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
|
|
||||||
-- Profile Data
|
-- Profile Data
|
||||||
@@ -37,123 +99,13 @@ CREATE TABLE IF NOT EXISTS linkedin_profiles (
|
|||||||
location TEXT,
|
location TEXT,
|
||||||
industry TEXT,
|
industry TEXT,
|
||||||
|
|
||||||
UNIQUE(customer_id)
|
UNIQUE(user_id)
|
||||||
);
|
);
|
||||||
|
|
||||||
-- LinkedIn Posts Table (scraped posts)
|
|
||||||
CREATE TABLE IF NOT EXISTS linkedin_posts (
|
|
||||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
|
||||||
customer_id UUID NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
|
|
||||||
scraped_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
|
||||||
|
|
||||||
-- Post Data
|
|
||||||
post_url TEXT,
|
|
||||||
post_text TEXT NOT NULL,
|
|
||||||
post_date TIMESTAMP WITH TIME ZONE,
|
|
||||||
likes INTEGER DEFAULT 0,
|
|
||||||
comments INTEGER DEFAULT 0,
|
|
||||||
shares INTEGER DEFAULT 0,
|
|
||||||
|
|
||||||
-- Raw Data
|
|
||||||
raw_data JSONB,
|
|
||||||
|
|
||||||
UNIQUE(customer_id, post_url)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Topics Table (extracted from posts)
|
|
||||||
CREATE TABLE IF NOT EXISTS topics (
|
|
||||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
|
||||||
customer_id UUID NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
|
|
||||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
|
||||||
|
|
||||||
-- Topic Info
|
|
||||||
title TEXT NOT NULL,
|
|
||||||
description TEXT,
|
|
||||||
category TEXT,
|
|
||||||
|
|
||||||
-- AI Extraction
|
|
||||||
extracted_from_post_id UUID REFERENCES linkedin_posts(id),
|
|
||||||
extraction_confidence FLOAT,
|
|
||||||
|
|
||||||
-- Status
|
|
||||||
is_used BOOLEAN DEFAULT FALSE,
|
|
||||||
used_at TIMESTAMP WITH TIME ZONE
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Profile Analysis Table (AI-generated insights)
|
|
||||||
CREATE TABLE IF NOT EXISTS profile_analyses (
|
|
||||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
|
||||||
customer_id UUID NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
|
|
||||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
|
||||||
|
|
||||||
-- Analysis Results
|
|
||||||
writing_style JSONB NOT NULL,
|
|
||||||
tone_analysis JSONB NOT NULL,
|
|
||||||
topic_patterns JSONB NOT NULL,
|
|
||||||
audience_insights JSONB NOT NULL,
|
|
||||||
|
|
||||||
-- Full Analysis
|
|
||||||
full_analysis JSONB NOT NULL,
|
|
||||||
|
|
||||||
UNIQUE(customer_id)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Research Results Table
|
|
||||||
CREATE TABLE IF NOT EXISTS research_results (
|
|
||||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
|
||||||
customer_id UUID NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
|
|
||||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
|
||||||
|
|
||||||
-- Research Data
|
|
||||||
query TEXT NOT NULL,
|
|
||||||
results JSONB NOT NULL,
|
|
||||||
|
|
||||||
-- Topic Suggestions
|
|
||||||
suggested_topics JSONB NOT NULL,
|
|
||||||
|
|
||||||
-- Metadata
|
|
||||||
source TEXT DEFAULT 'perplexity'
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Generated Posts Table
|
|
||||||
CREATE TABLE IF NOT EXISTS generated_posts (
|
|
||||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
|
||||||
customer_id UUID NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
|
|
||||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
|
||||||
|
|
||||||
-- Topic
|
|
||||||
topic_id UUID REFERENCES topics(id),
|
|
||||||
topic_title TEXT NOT NULL,
|
|
||||||
|
|
||||||
-- Post Content
|
|
||||||
post_content TEXT NOT NULL,
|
|
||||||
|
|
||||||
-- Generation Metadata
|
|
||||||
iterations INTEGER DEFAULT 0,
|
|
||||||
writer_versions JSONB DEFAULT '[]'::JSONB,
|
|
||||||
critic_feedback JSONB DEFAULT '[]'::JSONB,
|
|
||||||
|
|
||||||
-- Status
|
|
||||||
status TEXT DEFAULT 'draft' CHECK (status IN ('draft', 'approved', 'published', 'rejected')),
|
|
||||||
approved_at TIMESTAMP WITH TIME ZONE,
|
|
||||||
published_at TIMESTAMP WITH TIME ZONE
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Create Indexes
|
|
||||||
CREATE INDEX idx_customers_linkedin_url ON customers(linkedin_url);
|
|
||||||
CREATE INDEX idx_linkedin_profiles_customer_id ON linkedin_profiles(customer_id);
|
|
||||||
CREATE INDEX idx_linkedin_posts_customer_id ON linkedin_posts(customer_id);
|
|
||||||
CREATE INDEX idx_topics_customer_id ON topics(customer_id);
|
|
||||||
CREATE INDEX idx_topics_is_used ON topics(is_used);
|
|
||||||
CREATE INDEX idx_profile_analyses_customer_id ON profile_analyses(customer_id);
|
|
||||||
CREATE INDEX idx_research_results_customer_id ON research_results(customer_id);
|
|
||||||
CREATE INDEX idx_generated_posts_customer_id ON generated_posts(customer_id);
|
|
||||||
CREATE INDEX idx_generated_posts_status ON generated_posts(status);
|
|
||||||
|
|
||||||
-- Post Types Table (for categorizing posts by type)
|
-- Post Types Table (for categorizing posts by type)
|
||||||
CREATE TABLE IF NOT EXISTS post_types (
|
CREATE TABLE IF NOT EXISTS post_types (
|
||||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
customer_id UUID NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
|
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
|
|
||||||
@@ -172,36 +124,228 @@ CREATE TABLE IF NOT EXISTS post_types (
|
|||||||
-- Status
|
-- Status
|
||||||
is_active BOOLEAN DEFAULT TRUE,
|
is_active BOOLEAN DEFAULT TRUE,
|
||||||
|
|
||||||
UNIQUE(customer_id, name)
|
UNIQUE(user_id, name)
|
||||||
);
|
);
|
||||||
|
|
||||||
-- Add post_type_id to linkedin_posts
|
-- LinkedIn Posts Table (scraped posts)
|
||||||
ALTER TABLE linkedin_posts
|
CREATE TABLE IF NOT EXISTS linkedin_posts (
|
||||||
ADD COLUMN IF NOT EXISTS post_type_id UUID REFERENCES post_types(id) ON DELETE SET NULL,
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
ADD COLUMN IF NOT EXISTS classification_method TEXT,
|
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||||
ADD COLUMN IF NOT EXISTS classification_confidence FLOAT;
|
scraped_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
|
|
||||||
-- Add target_post_type_id to topics
|
-- Post Data
|
||||||
ALTER TABLE topics
|
post_url TEXT,
|
||||||
ADD COLUMN IF NOT EXISTS target_post_type_id UUID REFERENCES post_types(id) ON DELETE SET NULL;
|
post_text TEXT NOT NULL,
|
||||||
|
post_date TIMESTAMP WITH TIME ZONE,
|
||||||
|
likes INTEGER DEFAULT 0,
|
||||||
|
comments INTEGER DEFAULT 0,
|
||||||
|
shares INTEGER DEFAULT 0,
|
||||||
|
|
||||||
-- Add target_post_type_id to research_results
|
-- Raw Data
|
||||||
ALTER TABLE research_results
|
raw_data JSONB,
|
||||||
ADD COLUMN IF NOT EXISTS target_post_type_id UUID REFERENCES post_types(id) ON DELETE SET NULL;
|
|
||||||
|
|
||||||
-- Add post_type_id to generated_posts
|
-- Classification
|
||||||
ALTER TABLE generated_posts
|
post_type_id UUID REFERENCES post_types(id) ON DELETE SET NULL,
|
||||||
ADD COLUMN IF NOT EXISTS post_type_id UUID REFERENCES post_types(id) ON DELETE SET NULL;
|
classification_method TEXT,
|
||||||
|
classification_confidence FLOAT,
|
||||||
|
|
||||||
-- Create indexes for post_types
|
UNIQUE(user_id, post_url)
|
||||||
CREATE INDEX IF NOT EXISTS idx_post_types_customer_id ON post_types(customer_id);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_post_types_is_active ON post_types(is_active);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_linkedin_posts_post_type_id ON linkedin_posts(post_type_id);
|
-- Topics Table (extracted from posts)
|
||||||
|
CREATE TABLE IF NOT EXISTS topics (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
|
|
||||||
|
-- Topic Info
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
category TEXT,
|
||||||
|
|
||||||
|
-- AI Extraction
|
||||||
|
extracted_from_post_id UUID REFERENCES linkedin_posts(id),
|
||||||
|
extraction_confidence FLOAT,
|
||||||
|
|
||||||
|
-- Status
|
||||||
|
is_used BOOLEAN DEFAULT FALSE,
|
||||||
|
used_at TIMESTAMP WITH TIME ZONE,
|
||||||
|
|
||||||
|
-- Target post type
|
||||||
|
target_post_type_id UUID REFERENCES post_types(id) ON DELETE SET NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Profile Analysis Table (AI-generated insights)
|
||||||
|
CREATE TABLE IF NOT EXISTS profile_analyses (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
|
|
||||||
|
-- Analysis Results
|
||||||
|
writing_style JSONB NOT NULL,
|
||||||
|
tone_analysis JSONB NOT NULL,
|
||||||
|
topic_patterns JSONB NOT NULL,
|
||||||
|
audience_insights JSONB NOT NULL,
|
||||||
|
|
||||||
|
-- Full Analysis
|
||||||
|
full_analysis JSONB NOT NULL,
|
||||||
|
|
||||||
|
UNIQUE(user_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Research Results Table
|
||||||
|
CREATE TABLE IF NOT EXISTS research_results (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
|
|
||||||
|
-- Research Data
|
||||||
|
query TEXT NOT NULL,
|
||||||
|
results JSONB NOT NULL,
|
||||||
|
|
||||||
|
-- Topic Suggestions
|
||||||
|
suggested_topics JSONB NOT NULL,
|
||||||
|
|
||||||
|
-- Metadata
|
||||||
|
source TEXT DEFAULT 'perplexity',
|
||||||
|
|
||||||
|
-- Target post type
|
||||||
|
target_post_type_id UUID REFERENCES post_types(id) ON DELETE SET NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Generated Posts Table
|
||||||
|
CREATE TABLE IF NOT EXISTS generated_posts (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
|
|
||||||
|
-- Topic
|
||||||
|
topic_id UUID REFERENCES topics(id),
|
||||||
|
topic_title TEXT NOT NULL,
|
||||||
|
|
||||||
|
-- Post Content
|
||||||
|
post_content TEXT NOT NULL,
|
||||||
|
|
||||||
|
-- Generation Metadata
|
||||||
|
iterations INTEGER DEFAULT 0,
|
||||||
|
writer_versions JSONB DEFAULT '[]'::JSONB,
|
||||||
|
critic_feedback JSONB DEFAULT '[]'::JSONB,
|
||||||
|
|
||||||
|
-- Status
|
||||||
|
status TEXT DEFAULT 'draft' CHECK (status IN ('draft', 'approved', 'scheduled', 'published', 'rejected')),
|
||||||
|
approved_at TIMESTAMP WITH TIME ZONE,
|
||||||
|
published_at TIMESTAMP WITH TIME ZONE,
|
||||||
|
|
||||||
|
-- Post type
|
||||||
|
post_type_id UUID REFERENCES post_types(id) ON DELETE SET NULL,
|
||||||
|
|
||||||
|
-- Scheduling
|
||||||
|
scheduled_at TIMESTAMP WITH TIME ZONE,
|
||||||
|
scheduled_by_user_id UUID REFERENCES auth.users(id) ON DELETE SET NULL,
|
||||||
|
|
||||||
|
-- Image
|
||||||
|
image_url TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Invitations Table (employee invitations)
|
||||||
|
CREATE TABLE IF NOT EXISTS invitations (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
|
|
||||||
|
email TEXT NOT NULL,
|
||||||
|
token TEXT NOT NULL UNIQUE,
|
||||||
|
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||||
|
|
||||||
|
company_id UUID NOT NULL REFERENCES companies(id) ON DELETE CASCADE,
|
||||||
|
invited_by_user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
status TEXT DEFAULT 'pending', -- 'pending' | 'accepted' | 'expired' | 'cancelled'
|
||||||
|
accepted_at TIMESTAMP WITH TIME ZONE,
|
||||||
|
accepted_by_user_id UUID REFERENCES auth.users(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Example Posts Table (manual example posts when <10 scraped)
|
||||||
|
CREATE TABLE IF NOT EXISTS example_posts (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
|
|
||||||
|
post_text TEXT NOT NULL,
|
||||||
|
source TEXT DEFAULT 'manual', -- 'manual' | 'reference_profile'
|
||||||
|
source_linkedin_url TEXT,
|
||||||
|
post_type_id UUID REFERENCES post_types(id) ON DELETE SET NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Reference Profiles Table (alternative LinkedIn profiles for scraping)
|
||||||
|
CREATE TABLE IF NOT EXISTS reference_profiles (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
|
|
||||||
|
linkedin_url TEXT NOT NULL,
|
||||||
|
name TEXT,
|
||||||
|
posts_scraped INTEGER DEFAULT 0,
|
||||||
|
|
||||||
|
UNIQUE(user_id, linkedin_url)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ==================== API USAGE LOGS ====================
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS api_usage_logs (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID REFERENCES auth.users(id) ON DELETE SET NULL,
|
||||||
|
company_id UUID REFERENCES companies(id) ON DELETE SET NULL,
|
||||||
|
provider TEXT NOT NULL,
|
||||||
|
model TEXT NOT NULL,
|
||||||
|
operation TEXT NOT NULL,
|
||||||
|
prompt_tokens INT NOT NULL DEFAULT 0,
|
||||||
|
completion_tokens INT NOT NULL DEFAULT 0,
|
||||||
|
total_tokens INT NOT NULL DEFAULT 0,
|
||||||
|
estimated_cost_usd DECIMAL(10,6) NOT NULL DEFAULT 0,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ==================== INDEXES ====================
|
||||||
|
|
||||||
|
-- Profiles
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_profiles_account_type ON profiles(account_type);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_profiles_onboarding_status ON profiles(onboarding_status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_profiles_company_id ON profiles(company_id);
|
||||||
|
|
||||||
|
-- Content tables
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_linkedin_profiles_user_id ON linkedin_profiles(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_linkedin_posts_user_id ON linkedin_posts(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_topics_user_id ON topics(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_topics_is_used ON topics(is_used);
|
||||||
CREATE INDEX IF NOT EXISTS idx_topics_target_post_type_id ON topics(target_post_type_id);
|
CREATE INDEX IF NOT EXISTS idx_topics_target_post_type_id ON topics(target_post_type_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_post_types_user_id ON post_types(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_post_types_is_active ON post_types(is_active);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_profile_analyses_user_id ON profile_analyses(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_research_results_user_id ON research_results(user_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_research_results_target_post_type_id ON research_results(target_post_type_id);
|
CREATE INDEX IF NOT EXISTS idx_research_results_target_post_type_id ON research_results(target_post_type_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_generated_posts_user_id ON generated_posts(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_generated_posts_status ON generated_posts(status);
|
||||||
CREATE INDEX IF NOT EXISTS idx_generated_posts_post_type_id ON generated_posts(post_type_id);
|
CREATE INDEX IF NOT EXISTS idx_generated_posts_post_type_id ON generated_posts(post_type_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_generated_posts_scheduled_at ON generated_posts(scheduled_at)
|
||||||
|
WHERE scheduled_at IS NOT NULL AND status = 'scheduled';
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_linkedin_posts_post_type_id ON linkedin_posts(post_type_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_example_posts_user_id ON example_posts(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_reference_profiles_user_id ON reference_profiles(user_id);
|
||||||
|
|
||||||
|
-- Companies & Invitations
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_companies_owner_user_id ON companies(owner_user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_invitations_token ON invitations(token);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_invitations_company_id ON invitations(company_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_invitations_email ON invitations(email);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_invitations_status ON invitations(status);
|
||||||
|
|
||||||
|
-- API usage
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_api_usage_logs_user_id ON api_usage_logs(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_api_usage_logs_company_id ON api_usage_logs(company_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_api_usage_logs_created_at ON api_usage_logs(created_at);
|
||||||
|
|
||||||
|
-- ==================== TRIGGERS ====================
|
||||||
|
|
||||||
-- Create updated_at trigger function
|
|
||||||
CREATE OR REPLACE FUNCTION update_updated_at_column()
|
CREATE OR REPLACE FUNCTION update_updated_at_column()
|
||||||
RETURNS TRIGGER AS $$
|
RETURNS TRIGGER AS $$
|
||||||
BEGIN
|
BEGIN
|
||||||
@@ -210,15 +354,196 @@ BEGIN
|
|||||||
END;
|
END;
|
||||||
$$ LANGUAGE plpgsql;
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
-- Add trigger to customers table
|
DROP TRIGGER IF EXISTS update_profiles_updated_at ON profiles;
|
||||||
CREATE TRIGGER update_customers_updated_at
|
CREATE TRIGGER update_profiles_updated_at
|
||||||
BEFORE UPDATE ON customers
|
BEFORE UPDATE ON profiles
|
||||||
FOR EACH ROW
|
FOR EACH ROW
|
||||||
EXECUTE FUNCTION update_updated_at_column();
|
EXECUTE FUNCTION update_updated_at_column();
|
||||||
|
|
||||||
-- Add trigger to post_types table
|
|
||||||
DROP TRIGGER IF EXISTS update_post_types_updated_at ON post_types;
|
DROP TRIGGER IF EXISTS update_post_types_updated_at ON post_types;
|
||||||
CREATE TRIGGER update_post_types_updated_at
|
CREATE TRIGGER update_post_types_updated_at
|
||||||
BEFORE UPDATE ON post_types
|
BEFORE UPDATE ON post_types
|
||||||
FOR EACH ROW
|
FOR EACH ROW
|
||||||
EXECUTE FUNCTION update_updated_at_column();
|
EXECUTE FUNCTION update_updated_at_column();
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS update_companies_updated_at ON companies;
|
||||||
|
CREATE TRIGGER update_companies_updated_at
|
||||||
|
BEFORE UPDATE ON companies
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION update_updated_at_column();
|
||||||
|
|
||||||
|
-- ==================== ROW LEVEL SECURITY ====================
|
||||||
|
|
||||||
|
ALTER TABLE profiles ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
CREATE POLICY "Users can view own profile"
|
||||||
|
ON profiles FOR SELECT
|
||||||
|
USING (auth.uid() = id);
|
||||||
|
|
||||||
|
CREATE POLICY "Users can update own profile"
|
||||||
|
ON profiles FOR UPDATE
|
||||||
|
USING (auth.uid() = id);
|
||||||
|
|
||||||
|
CREATE POLICY "Service role has full access to profiles"
|
||||||
|
ON profiles
|
||||||
|
USING (auth.jwt() ->> 'role' = 'service_role');
|
||||||
|
|
||||||
|
ALTER TABLE companies ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
CREATE POLICY "Company owners can manage their company"
|
||||||
|
ON companies
|
||||||
|
USING (auth.uid() = owner_user_id);
|
||||||
|
|
||||||
|
CREATE POLICY "Employees can view their company"
|
||||||
|
ON companies FOR SELECT
|
||||||
|
USING (
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM profiles
|
||||||
|
WHERE profiles.id = auth.uid()
|
||||||
|
AND profiles.company_id = companies.id
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE POLICY "Service role has full access to companies"
|
||||||
|
ON companies
|
||||||
|
USING (auth.jwt() ->> 'role' = 'service_role');
|
||||||
|
|
||||||
|
-- Generated posts RLS
|
||||||
|
ALTER TABLE generated_posts ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
CREATE POLICY "Users can manage own posts"
|
||||||
|
ON generated_posts FOR ALL
|
||||||
|
USING (user_id = auth.uid());
|
||||||
|
|
||||||
|
CREATE POLICY "Company members can view company posts"
|
||||||
|
ON generated_posts FOR SELECT
|
||||||
|
USING (
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM profiles p1
|
||||||
|
JOIN profiles p2 ON p1.company_id = p2.company_id
|
||||||
|
WHERE p1.id = auth.uid()
|
||||||
|
AND p2.id = generated_posts.user_id
|
||||||
|
AND p1.company_id IS NOT NULL
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE POLICY "Service role has full access to generated_posts"
|
||||||
|
ON generated_posts FOR ALL
|
||||||
|
USING (auth.jwt() ->> 'role' = 'service_role');
|
||||||
|
|
||||||
|
-- ==================== USERS VIEW ====================
|
||||||
|
|
||||||
|
CREATE OR REPLACE VIEW users AS
|
||||||
|
SELECT
|
||||||
|
au.id,
|
||||||
|
au.created_at,
|
||||||
|
au.updated_at,
|
||||||
|
au.email,
|
||||||
|
au.raw_user_meta_data->>'sub' AS linkedin_sub,
|
||||||
|
au.raw_user_meta_data->>'vanityName' AS linkedin_vanity_name,
|
||||||
|
au.raw_user_meta_data->>'name' AS linkedin_name,
|
||||||
|
au.raw_user_meta_data->>'picture' AS linkedin_picture,
|
||||||
|
CASE
|
||||||
|
WHEN au.raw_user_meta_data->>'iss' LIKE '%linkedin%' THEN 'linkedin_oauth'
|
||||||
|
ELSE 'email_password'
|
||||||
|
END AS auth_method,
|
||||||
|
COALESCE(p.account_type, 'ghostwriter') AS account_type,
|
||||||
|
p.display_name,
|
||||||
|
COALESCE(p.onboarding_status, 'pending') AS onboarding_status,
|
||||||
|
COALESCE(p.onboarding_data, '{}'::jsonb) AS onboarding_data,
|
||||||
|
p.company_id,
|
||||||
|
p.linkedin_url,
|
||||||
|
p.writing_style_notes,
|
||||||
|
p.metadata,
|
||||||
|
p.profile_picture,
|
||||||
|
p.creator_email,
|
||||||
|
p.customer_email,
|
||||||
|
p.is_active,
|
||||||
|
au.email_confirmed_at IS NOT NULL AS email_verified
|
||||||
|
FROM auth.users au
|
||||||
|
LEFT JOIN profiles p ON au.id = p.id;
|
||||||
|
|
||||||
|
-- ==================== LICENSE KEY SYSTEM ====================
|
||||||
|
|
||||||
|
-- License Keys Table
|
||||||
|
CREATE TABLE IF NOT EXISTS license_keys (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
key TEXT UNIQUE NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
|
||||||
|
-- Limits (stored here, NOT in companies table)
|
||||||
|
max_employees INT NOT NULL DEFAULT 5,
|
||||||
|
max_posts_per_day INT NOT NULL DEFAULT 10,
|
||||||
|
max_researches_per_day INT NOT NULL DEFAULT 5,
|
||||||
|
|
||||||
|
-- Usage tracking
|
||||||
|
used BOOLEAN DEFAULT FALSE,
|
||||||
|
company_id UUID REFERENCES companies(id) ON DELETE SET NULL,
|
||||||
|
used_at TIMESTAMP WITH TIME ZONE,
|
||||||
|
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Index for fast lookups
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_license_keys_key ON license_keys(key);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_license_keys_used ON license_keys(used);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_license_keys_company_id ON license_keys(company_id);
|
||||||
|
|
||||||
|
-- RLS Policies (Admin only)
|
||||||
|
ALTER TABLE license_keys ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
CREATE POLICY "Service role has full access to license_keys"
|
||||||
|
ON license_keys FOR ALL
|
||||||
|
USING (auth.jwt()->>'role' = 'service_role');
|
||||||
|
|
||||||
|
-- Add license key reference to companies table
|
||||||
|
-- (limits are stored in license_keys table, not duplicated here)
|
||||||
|
ALTER TABLE companies ADD COLUMN IF NOT EXISTS license_key_id UUID REFERENCES license_keys(id);
|
||||||
|
|
||||||
|
-- Index
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_companies_license_key_id ON companies(license_key_id);
|
||||||
|
|
||||||
|
-- Company Daily Quotas Table
|
||||||
|
CREATE TABLE IF NOT EXISTS company_daily_quotas (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
company_id UUID NOT NULL REFERENCES companies(id) ON DELETE CASCADE,
|
||||||
|
date DATE NOT NULL,
|
||||||
|
posts_created INT DEFAULT 0,
|
||||||
|
researches_created INT DEFAULT 0,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
|
|
||||||
|
UNIQUE(company_id, date)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Index for fast daily lookups
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_company_daily_quotas_company_date ON company_daily_quotas(company_id, date);
|
||||||
|
|
||||||
|
-- RLS Policies
|
||||||
|
ALTER TABLE company_daily_quotas ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
CREATE POLICY "Users can view own company quotas"
|
||||||
|
ON company_daily_quotas FOR SELECT
|
||||||
|
USING (
|
||||||
|
company_id IN (
|
||||||
|
SELECT company_id FROM profiles WHERE id = auth.uid()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE POLICY "Service role has full access to company_daily_quotas"
|
||||||
|
ON company_daily_quotas FOR ALL
|
||||||
|
USING (auth.jwt()->>'role' = 'service_role');
|
||||||
|
|
||||||
|
-- Triggers for updated_at
|
||||||
|
DROP TRIGGER IF EXISTS update_license_keys_updated_at ON license_keys;
|
||||||
|
CREATE TRIGGER update_license_keys_updated_at
|
||||||
|
BEFORE UPDATE ON license_keys
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION update_updated_at_column();
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS update_company_daily_quotas_updated_at ON company_daily_quotas;
|
||||||
|
CREATE TRIGGER update_company_daily_quotas_updated_at
|
||||||
|
BEFORE UPDATE ON company_daily_quotas
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION update_updated_at_column();
|
||||||
|
|||||||
@@ -6,18 +6,19 @@ services:
|
|||||||
container_name: linkedin-posts
|
container_name: linkedin-posts
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
expose:
|
expose:
|
||||||
- "8000"
|
- "8001"
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
environment:
|
environment:
|
||||||
- PYTHONPATH=/app
|
- PYTHONPATH=/app
|
||||||
|
- PORT=8001
|
||||||
- VIRTUAL_HOST=linkedin.onyva.dev
|
- VIRTUAL_HOST=linkedin.onyva.dev
|
||||||
- VIRTUAL_PORT=8000
|
- VIRTUAL_PORT=8001
|
||||||
- LETSENCRYPT_HOST=linkedin.onyva.dev
|
- LETSENCRYPT_HOST=linkedin.onyva.dev
|
||||||
volumes:
|
volumes:
|
||||||
- ./logs:/app/logs
|
- ./logs:/app/logs
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "python", "-c", "import httpx; httpx.get('http://localhost:8000/login', timeout=5)"]
|
test: ["CMD", "python", "-c", "import httpx; httpx.get('http://localhost:8001/login', timeout=5)"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
timeout: 10s
|
timeout: 10s
|
||||||
retries: 3
|
retries: 3
|
||||||
|
|||||||
@@ -6,16 +6,17 @@ services:
|
|||||||
container_name: linkedin-posts
|
container_name: linkedin-posts
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "8000:8000"
|
- "8001:8001"
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
environment:
|
environment:
|
||||||
- PYTHONPATH=/app
|
- PYTHONPATH=/app
|
||||||
|
- PORT=8001
|
||||||
volumes:
|
volumes:
|
||||||
# Optional: Mount logs directory
|
# Optional: Mount logs directory
|
||||||
- ./logs:/app/logs
|
- ./logs:/app/logs
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "python", "-c", "import httpx; httpx.get('http://localhost:8000/login', timeout=5)"]
|
test: ["CMD", "python", "-c", "import httpx; httpx.get('http://localhost:8001/login', timeout=5)"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
timeout: 10s
|
timeout: 10s
|
||||||
retries: 3
|
retries: 3
|
||||||
|
|||||||
694
localhost.har
Normal file
694
localhost.har
Normal file
File diff suppressed because one or more lines are too long
@@ -18,6 +18,7 @@ rich==13.7.0
|
|||||||
tenacity==8.2.3
|
tenacity==8.2.3
|
||||||
loguru==0.7.2
|
loguru==0.7.2
|
||||||
httpx==0.27.0
|
httpx==0.27.0
|
||||||
|
cryptography==41.0.7
|
||||||
|
|
||||||
# Web Frontend
|
# Web Frontend
|
||||||
fastapi==0.115.0
|
fastapi==0.115.0
|
||||||
|
|||||||
145
scripts/backfill_usage_logs.py
Normal file
145
scripts/backfill_usage_logs.py
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
"""Backfill api_usage_logs for all generated posts not yet tracked.
|
||||||
|
|
||||||
|
Assumes per post:
|
||||||
|
- 1x gpt-4o call: ~20,000 tokens (14,000 prompt + 6,000 completion)
|
||||||
|
- 1x gpt-4o-mini call: ~17,000 tokens (13,000 prompt + 4,000 completion)
|
||||||
|
|
||||||
|
Only processes posts older than 20 minutes whose created_at is not already
|
||||||
|
covered by an existing api_usage_log entry for the same customer.
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
# Add project root to path
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
|
||||||
|
from src.config import estimate_cost
|
||||||
|
from src.database.client import db
|
||||||
|
|
||||||
|
|
||||||
|
# ── Estimated token splits per post ──────────────────────────────
|
||||||
|
GPT4O_PROMPT = 14_000
|
||||||
|
GPT4O_COMP = 6_000
|
||||||
|
GPT4O_TOTAL = GPT4O_PROMPT + GPT4O_COMP # 20 000
|
||||||
|
|
||||||
|
MINI_PROMPT = 13_000
|
||||||
|
MINI_COMP = 4_000
|
||||||
|
MINI_TOTAL = MINI_PROMPT + MINI_COMP # 17 000
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
cutoff = datetime.now(timezone.utc) - timedelta(minutes=20)
|
||||||
|
print(f"Cutoff: posts created before {cutoff.isoformat()}")
|
||||||
|
|
||||||
|
# ── 1. Load all generated posts ──────────────────────────────
|
||||||
|
customers = await db.list_customers()
|
||||||
|
print(f"Found {len(customers)} customers")
|
||||||
|
|
||||||
|
all_posts = []
|
||||||
|
for cust in customers:
|
||||||
|
posts = await db.get_generated_posts(cust.id)
|
||||||
|
all_posts.extend(posts)
|
||||||
|
print(f"Found {len(all_posts)} total generated posts")
|
||||||
|
|
||||||
|
# Filter to posts older than 20 min
|
||||||
|
eligible = [
|
||||||
|
p for p in all_posts
|
||||||
|
if p.created_at and p.created_at.replace(tzinfo=timezone.utc) < cutoff
|
||||||
|
]
|
||||||
|
print(f"{len(eligible)} posts older than 20 min")
|
||||||
|
|
||||||
|
# ── 2. Load existing logs to avoid duplicates ────────────────
|
||||||
|
try:
|
||||||
|
existing_logs = await asyncio.to_thread(
|
||||||
|
lambda: db.client.table("api_usage_logs")
|
||||||
|
.select("customer_id, created_at")
|
||||||
|
.eq("operation", "post_creation_backfill")
|
||||||
|
.execute()
|
||||||
|
)
|
||||||
|
already_logged = set()
|
||||||
|
for log in existing_logs.data:
|
||||||
|
key = (log.get("customer_id"), log.get("created_at", "")[:19])
|
||||||
|
already_logged.add(key)
|
||||||
|
print(f"{len(already_logged)} existing backfill entries found")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Could not read existing logs (table may be new): {e}")
|
||||||
|
already_logged = set()
|
||||||
|
|
||||||
|
# ── 3. Build customer → user_id / company_id map ─────────────
|
||||||
|
cust_map = {}
|
||||||
|
for cust in customers:
|
||||||
|
cust_map[str(cust.id)] = {
|
||||||
|
"user_id": str(cust.user_id) if cust.user_id else None,
|
||||||
|
"company_id": str(cust.company_id) if cust.company_id else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── 4. Insert two log rows per post ──────────────────────────
|
||||||
|
inserted = 0
|
||||||
|
skipped = 0
|
||||||
|
|
||||||
|
for post in eligible:
|
||||||
|
cid = str(post.customer_id)
|
||||||
|
ts = post.created_at.isoformat()[:19] if post.created_at else ""
|
||||||
|
key = (cid, ts)
|
||||||
|
if key in already_logged:
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
ids = cust_map.get(cid, {})
|
||||||
|
user_id = ids.get("user_id")
|
||||||
|
company_id = ids.get("company_id")
|
||||||
|
|
||||||
|
base = {
|
||||||
|
"customer_id": cid,
|
||||||
|
"operation": "post_creation_backfill",
|
||||||
|
"created_at": post.created_at.isoformat() if post.created_at else None,
|
||||||
|
}
|
||||||
|
if user_id:
|
||||||
|
base["user_id"] = user_id
|
||||||
|
if company_id:
|
||||||
|
base["company_id"] = company_id
|
||||||
|
|
||||||
|
# Row 1: gpt-4o
|
||||||
|
gpt4o_cost = estimate_cost("gpt-4o", GPT4O_PROMPT, GPT4O_COMP)
|
||||||
|
row_4o = {
|
||||||
|
**base,
|
||||||
|
"provider": "openai",
|
||||||
|
"model": "gpt-4o",
|
||||||
|
"prompt_tokens": GPT4O_PROMPT,
|
||||||
|
"completion_tokens": GPT4O_COMP,
|
||||||
|
"total_tokens": GPT4O_TOTAL,
|
||||||
|
"estimated_cost_usd": round(gpt4o_cost, 6),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Row 2: gpt-4o-mini
|
||||||
|
mini_cost = estimate_cost("gpt-4o-mini", MINI_PROMPT, MINI_COMP)
|
||||||
|
row_mini = {
|
||||||
|
**base,
|
||||||
|
"provider": "openai",
|
||||||
|
"model": "gpt-4o-mini",
|
||||||
|
"prompt_tokens": MINI_PROMPT,
|
||||||
|
"completion_tokens": MINI_COMP,
|
||||||
|
"total_tokens": MINI_TOTAL,
|
||||||
|
"estimated_cost_usd": round(mini_cost, 6),
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
await asyncio.to_thread(
|
||||||
|
lambda r1=row_4o, r2=row_mini: db.client.table("api_usage_logs")
|
||||||
|
.insert([r1, r2]).execute()
|
||||||
|
)
|
||||||
|
inserted += 2
|
||||||
|
name = post.topic_title[:40] if post.topic_title else "?"
|
||||||
|
print(f" + {name} (gpt-4o ${gpt4o_cost:.4f} + mini ${mini_cost:.4f})")
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ! Error for post {post.id}: {e}")
|
||||||
|
|
||||||
|
print(f"\nDone: {inserted} log rows inserted, {skipped} posts skipped (already backfilled)")
|
||||||
|
print(f"Estimated totals per post: gpt-4o ${estimate_cost('gpt-4o', GPT4O_PROMPT, GPT4O_COMP):.4f} + mini ${estimate_cost('gpt-4o-mini', MINI_PROMPT, MINI_COMP):.4f}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
21
scripts/generate_encryption_key.py
Normal file
21
scripts/generate_encryption_key.py
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Generate a Fernet encryption key for token encryption.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python scripts/generate_encryption_key.py
|
||||||
|
|
||||||
|
Add the output to your .env file as ENCRYPTION_KEY.
|
||||||
|
"""
|
||||||
|
from cryptography.fernet import Fernet
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
key = Fernet.generate_key().decode()
|
||||||
|
print("=" * 70)
|
||||||
|
print("Generated Encryption Key:")
|
||||||
|
print("=" * 70)
|
||||||
|
print(key)
|
||||||
|
print("=" * 70)
|
||||||
|
print("\nAdd this to your .env file:")
|
||||||
|
print(f"ENCRYPTION_KEY={key}")
|
||||||
|
print("\n⚠️ Keep this key secure and never commit it to git!")
|
||||||
171
scripts/setup_linkedin_auth.py
Executable file
171
scripts/setup_linkedin_auth.py
Executable file
@@ -0,0 +1,171 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Script to configure LinkedIn (OIDC) authentication for self-hosted Supabase.
|
||||||
|
This script updates your .env file or docker-compose.yml with the necessary GoTrue environment variables.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# ==================== CONFIGURATION ====================
|
||||||
|
# TODO: Fill in your LinkedIn credentials below
|
||||||
|
|
||||||
|
LINKEDIN_CLIENT_ID = "your-linkedin-client-id"
|
||||||
|
LINKEDIN_CLIENT_SECRET = "your-linkedin-client-secret"
|
||||||
|
|
||||||
|
# Your Supabase instance URL (where your self-hosted instance is accessible)
|
||||||
|
SUPABASE_URL = "https://your-supabase-domain.com" # e.g., https://supabase.example.com
|
||||||
|
|
||||||
|
# Path to your Supabase docker-compose directory (where .env or docker-compose.yml is located)
|
||||||
|
DOCKER_COMPOSE_DIR = "/path/to/your/supabase" # e.g., /home/user/supabase
|
||||||
|
|
||||||
|
# =======================================================
|
||||||
|
|
||||||
|
|
||||||
|
def validate_config():
|
||||||
|
"""Validate that all required configuration is set."""
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
if LINKEDIN_CLIENT_ID.startswith("your-"):
|
||||||
|
errors.append("LINKEDIN_CLIENT_ID")
|
||||||
|
if LINKEDIN_CLIENT_SECRET.startswith("your-"):
|
||||||
|
errors.append("LINKEDIN_CLIENT_SECRET")
|
||||||
|
if SUPABASE_URL.startswith("https://your-"):
|
||||||
|
errors.append("SUPABASE_URL")
|
||||||
|
if DOCKER_COMPOSE_DIR.startswith("/path/to/"):
|
||||||
|
errors.append("DOCKER_COMPOSE_DIR")
|
||||||
|
|
||||||
|
if errors:
|
||||||
|
print("❌ Error: Please update the following configuration variables in this script:")
|
||||||
|
for var in errors:
|
||||||
|
print(f" - {var}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
def find_env_file():
|
||||||
|
"""Find the .env file in the docker-compose directory."""
|
||||||
|
compose_dir = Path(DOCKER_COMPOSE_DIR)
|
||||||
|
|
||||||
|
if not compose_dir.exists():
|
||||||
|
print(f"❌ Error: Directory not found: {DOCKER_COMPOSE_DIR}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Look for common .env file locations
|
||||||
|
env_files = [
|
||||||
|
compose_dir / ".env",
|
||||||
|
compose_dir / "docker" / ".env",
|
||||||
|
compose_dir / ".env.local",
|
||||||
|
]
|
||||||
|
|
||||||
|
for env_file in env_files:
|
||||||
|
if env_file.exists():
|
||||||
|
return env_file
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def update_env_file(env_file):
|
||||||
|
"""Update the .env file with LinkedIn OAuth configuration."""
|
||||||
|
|
||||||
|
# LinkedIn OAuth configuration for GoTrue
|
||||||
|
linkedin_config = f"""
|
||||||
|
# LinkedIn OAuth Configuration (added by setup script)
|
||||||
|
GOTRUE_EXTERNAL_LINKEDIN_ENABLED=true
|
||||||
|
GOTRUE_EXTERNAL_LINKEDIN_CLIENT_ID={LINKEDIN_CLIENT_ID}
|
||||||
|
GOTRUE_EXTERNAL_LINKEDIN_SECRET={LINKEDIN_CLIENT_SECRET}
|
||||||
|
GOTRUE_EXTERNAL_LINKEDIN_REDIRECT_URI={SUPABASE_URL}/auth/v1/callback
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Read existing content
|
||||||
|
if env_file:
|
||||||
|
with open(env_file, 'r') as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
# Check if LinkedIn config already exists
|
||||||
|
if "GOTRUE_EXTERNAL_LINKEDIN_ENABLED" in content:
|
||||||
|
print(f"⚠️ LinkedIn OAuth configuration already exists in {env_file}")
|
||||||
|
print(" Please update it manually or remove the existing lines first.")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Backup original file
|
||||||
|
backup_file = env_file.with_suffix('.env.backup')
|
||||||
|
with open(backup_file, 'w') as f:
|
||||||
|
f.write(content)
|
||||||
|
print(f"📋 Backup created: {backup_file}")
|
||||||
|
|
||||||
|
# Append LinkedIn config
|
||||||
|
with open(env_file, 'a') as f:
|
||||||
|
f.write(linkedin_config)
|
||||||
|
|
||||||
|
print(f"✅ LinkedIn OAuth configuration added to {env_file}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def create_env_snippet():
|
||||||
|
"""Create a snippet file if no .env file is found."""
|
||||||
|
snippet_file = Path(DOCKER_COMPOSE_DIR) / "linkedin_oauth_snippet.env"
|
||||||
|
|
||||||
|
linkedin_config = f"""# LinkedIn OAuth Configuration for GoTrue
|
||||||
|
# Add these lines to your .env file or docker-compose.yml environment section
|
||||||
|
|
||||||
|
GOTRUE_EXTERNAL_LINKEDIN_ENABLED=true
|
||||||
|
GOTRUE_EXTERNAL_LINKEDIN_CLIENT_ID={LINKEDIN_CLIENT_ID}
|
||||||
|
GOTRUE_EXTERNAL_LINKEDIN_SECRET={LINKEDIN_CLIENT_SECRET}
|
||||||
|
GOTRUE_EXTERNAL_LINKEDIN_REDIRECT_URI={SUPABASE_URL}/auth/v1/callback
|
||||||
|
"""
|
||||||
|
|
||||||
|
with open(snippet_file, 'w') as f:
|
||||||
|
f.write(linkedin_config)
|
||||||
|
|
||||||
|
print(f"📄 Created configuration snippet: {snippet_file}")
|
||||||
|
print(" Copy these variables to your .env file or docker-compose.yml")
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def print_next_steps():
|
||||||
|
"""Print instructions for completing the setup."""
|
||||||
|
print("\n" + "="*60)
|
||||||
|
print("✅ Configuration complete!")
|
||||||
|
print("="*60)
|
||||||
|
print("\nNext steps:\n")
|
||||||
|
print("1. Restart your Supabase services:")
|
||||||
|
print(f" cd {DOCKER_COMPOSE_DIR}")
|
||||||
|
print(" docker-compose down")
|
||||||
|
print(" docker-compose up -d")
|
||||||
|
print()
|
||||||
|
print("2. Add redirect URL in LinkedIn Developer Portal:")
|
||||||
|
print(f" {SUPABASE_URL}/auth/v1/callback")
|
||||||
|
print()
|
||||||
|
print("3. Test the authentication in your application")
|
||||||
|
print()
|
||||||
|
print("4. Check GoTrue logs for any errors:")
|
||||||
|
print(" docker-compose logs -f auth")
|
||||||
|
print()
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Main function."""
|
||||||
|
print("🔧 Configuring LinkedIn OAuth for self-hosted Supabase")
|
||||||
|
print("="*60)
|
||||||
|
|
||||||
|
validate_config()
|
||||||
|
|
||||||
|
env_file = find_env_file()
|
||||||
|
|
||||||
|
if env_file:
|
||||||
|
print(f"📁 Found .env file: {env_file}")
|
||||||
|
if update_env_file(env_file):
|
||||||
|
print_next_steps()
|
||||||
|
else:
|
||||||
|
print("\n⚠️ Please update your configuration manually.")
|
||||||
|
else:
|
||||||
|
print("⚠️ No .env file found in the docker-compose directory")
|
||||||
|
create_env_snippet()
|
||||||
|
print_next_steps()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
131
scripts/test_linkedin_setup.py
Normal file
131
scripts/test_linkedin_setup.py
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Test script to verify LinkedIn auto-posting setup.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python scripts/test_linkedin_setup.py
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Add project root to path
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||||
|
|
||||||
|
from src.config import settings
|
||||||
|
from src.database.client import db
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
"""Run setup checks."""
|
||||||
|
print("=" * 70)
|
||||||
|
print("LinkedIn Auto-Posting Setup Verification")
|
||||||
|
print("=" * 70)
|
||||||
|
print()
|
||||||
|
|
||||||
|
checks_passed = 0
|
||||||
|
checks_total = 0
|
||||||
|
|
||||||
|
# Check 1: Environment variables
|
||||||
|
print("📋 Checking environment variables...")
|
||||||
|
checks_total += 4
|
||||||
|
|
||||||
|
if settings.linkedin_client_id:
|
||||||
|
print(" ✅ LINKEDIN_CLIENT_ID is set")
|
||||||
|
checks_passed += 1
|
||||||
|
else:
|
||||||
|
print(" ❌ LINKEDIN_CLIENT_ID is missing")
|
||||||
|
|
||||||
|
if settings.linkedin_client_secret:
|
||||||
|
print(" ✅ LINKEDIN_CLIENT_SECRET is set")
|
||||||
|
checks_passed += 1
|
||||||
|
else:
|
||||||
|
print(" ❌ LINKEDIN_CLIENT_SECRET is missing")
|
||||||
|
|
||||||
|
if settings.linkedin_redirect_uri:
|
||||||
|
print(" ✅ LINKEDIN_REDIRECT_URI is set")
|
||||||
|
checks_passed += 1
|
||||||
|
print(f" URI: {settings.linkedin_redirect_uri}")
|
||||||
|
else:
|
||||||
|
print(" ❌ LINKEDIN_REDIRECT_URI is missing")
|
||||||
|
|
||||||
|
if settings.encryption_key:
|
||||||
|
print(" ✅ ENCRYPTION_KEY is set")
|
||||||
|
checks_passed += 1
|
||||||
|
else:
|
||||||
|
print(" ❌ ENCRYPTION_KEY is missing")
|
||||||
|
|
||||||
|
print()
|
||||||
|
|
||||||
|
# Check 2: Encryption
|
||||||
|
print("🔐 Testing encryption...")
|
||||||
|
checks_total += 1
|
||||||
|
|
||||||
|
try:
|
||||||
|
from src.utils.encryption import encrypt_token, decrypt_token
|
||||||
|
test_token = "test_access_token_12345"
|
||||||
|
encrypted = encrypt_token(test_token)
|
||||||
|
decrypted = decrypt_token(encrypted)
|
||||||
|
|
||||||
|
if decrypted == test_token:
|
||||||
|
print(" ✅ Encryption/decryption working")
|
||||||
|
checks_passed += 1
|
||||||
|
else:
|
||||||
|
print(" ❌ Encryption/decryption mismatch")
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ❌ Encryption error: {e}")
|
||||||
|
|
||||||
|
print()
|
||||||
|
|
||||||
|
# Check 3: Database table
|
||||||
|
print("💾 Checking database schema...")
|
||||||
|
checks_total += 1
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Try to query the table (will fail if it doesn't exist)
|
||||||
|
result = await asyncio.to_thread(
|
||||||
|
lambda: db.client.table("linkedin_accounts").select("id").limit(0).execute()
|
||||||
|
)
|
||||||
|
print(" ✅ linkedin_accounts table exists")
|
||||||
|
checks_passed += 1
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ❌ linkedin_accounts table not found: {e}")
|
||||||
|
print(" Run: psql $DATABASE_URL -f config/migrate_add_linkedin_accounts.sql")
|
||||||
|
|
||||||
|
print()
|
||||||
|
|
||||||
|
# Check 4: LinkedIn service
|
||||||
|
print("🔧 Checking LinkedIn service...")
|
||||||
|
checks_total += 1
|
||||||
|
|
||||||
|
try:
|
||||||
|
from src.services.linkedin_service import linkedin_service
|
||||||
|
print(" ✅ LinkedIn service initialized")
|
||||||
|
checks_passed += 1
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ❌ LinkedIn service error: {e}")
|
||||||
|
|
||||||
|
print()
|
||||||
|
|
||||||
|
# Summary
|
||||||
|
print("=" * 70)
|
||||||
|
print(f"Summary: {checks_passed}/{checks_total} checks passed")
|
||||||
|
print("=" * 70)
|
||||||
|
|
||||||
|
if checks_passed == checks_total:
|
||||||
|
print("✅ All checks passed! LinkedIn auto-posting is ready.")
|
||||||
|
print("\nNext steps:")
|
||||||
|
print("1. Restart your application")
|
||||||
|
print("2. Log in as an employee")
|
||||||
|
print("3. Go to Settings and connect your LinkedIn account")
|
||||||
|
print("4. Schedule a test post")
|
||||||
|
return 0
|
||||||
|
else:
|
||||||
|
print("❌ Some checks failed. Please fix the issues above.")
|
||||||
|
print("\nSetup guide: See LINKEDIN_SETUP.md for detailed instructions")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
exit_code = asyncio.run(main())
|
||||||
|
sys.exit(exit_code)
|
||||||
@@ -1,12 +1,13 @@
|
|||||||
"""Base agent class."""
|
"""Base agent class."""
|
||||||
import asyncio
|
import asyncio
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
from typing import Any, Dict, Optional
|
from typing import Any, Dict, List, Optional, Tuple
|
||||||
from openai import OpenAI
|
from openai import OpenAI
|
||||||
import httpx
|
import httpx
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
from src.config import settings
|
from src.config import settings, estimate_cost
|
||||||
|
from src.database.client import db
|
||||||
|
|
||||||
|
|
||||||
class BaseAgent(ABC):
|
class BaseAgent(ABC):
|
||||||
@@ -21,13 +22,59 @@ class BaseAgent(ABC):
|
|||||||
"""
|
"""
|
||||||
self.name = name
|
self.name = name
|
||||||
self.openai_client = OpenAI(api_key=settings.openai_api_key)
|
self.openai_client = OpenAI(api_key=settings.openai_api_key)
|
||||||
|
self._usage_logs: List[Dict[str, Any]] = []
|
||||||
|
self._user_id: Optional[str] = None
|
||||||
|
self._company_id: Optional[str] = None
|
||||||
|
self._operation: str = "unknown"
|
||||||
logger.info(f"Initialized {name} agent")
|
logger.info(f"Initialized {name} agent")
|
||||||
|
|
||||||
|
def set_tracking_context(
|
||||||
|
self,
|
||||||
|
operation: str,
|
||||||
|
user_id: Optional[str] = None,
|
||||||
|
company_id: Optional[str] = None
|
||||||
|
):
|
||||||
|
"""Set context for usage tracking."""
|
||||||
|
self._operation = operation
|
||||||
|
self._user_id = user_id
|
||||||
|
self._company_id = company_id
|
||||||
|
self._usage_logs = []
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
async def process(self, *args, **kwargs) -> Any:
|
async def process(self, *args, **kwargs) -> Any:
|
||||||
"""Process the agent's task."""
|
"""Process the agent's task."""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
async def _log_usage(self, provider: str, model: str, prompt_tokens: int, completion_tokens: int, total_tokens: int):
|
||||||
|
"""Log API usage to database."""
|
||||||
|
cost = estimate_cost(model, prompt_tokens, completion_tokens)
|
||||||
|
usage = {
|
||||||
|
"provider": provider,
|
||||||
|
"model": model,
|
||||||
|
"operation": self._operation,
|
||||||
|
"prompt_tokens": prompt_tokens,
|
||||||
|
"completion_tokens": completion_tokens,
|
||||||
|
"total_tokens": total_tokens,
|
||||||
|
"estimated_cost_usd": cost
|
||||||
|
}
|
||||||
|
self._usage_logs.append(usage)
|
||||||
|
|
||||||
|
try:
|
||||||
|
from uuid import UUID
|
||||||
|
await db.log_api_usage(
|
||||||
|
provider=provider,
|
||||||
|
model=model,
|
||||||
|
operation=self._operation,
|
||||||
|
prompt_tokens=prompt_tokens,
|
||||||
|
completion_tokens=completion_tokens,
|
||||||
|
total_tokens=total_tokens,
|
||||||
|
estimated_cost_usd=cost,
|
||||||
|
user_id=UUID(self._user_id) if self._user_id else None,
|
||||||
|
company_id=UUID(self._company_id) if self._company_id else None
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to log usage to DB: {e}")
|
||||||
|
|
||||||
async def call_openai(
|
async def call_openai(
|
||||||
self,
|
self,
|
||||||
system_prompt: str,
|
system_prompt: str,
|
||||||
@@ -74,6 +121,16 @@ class BaseAgent(ABC):
|
|||||||
result = response.choices[0].message.content
|
result = response.choices[0].message.content
|
||||||
logger.debug(f"[{self.name}] Received response (length: {len(result)})")
|
logger.debug(f"[{self.name}] Received response (length: {len(result)})")
|
||||||
|
|
||||||
|
# Track usage
|
||||||
|
if response.usage:
|
||||||
|
await self._log_usage(
|
||||||
|
provider="openai",
|
||||||
|
model=model,
|
||||||
|
prompt_tokens=response.usage.prompt_tokens,
|
||||||
|
completion_tokens=response.usage.completion_tokens,
|
||||||
|
total_tokens=response.usage.total_tokens
|
||||||
|
)
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
async def call_perplexity(
|
async def call_perplexity(
|
||||||
@@ -117,4 +174,15 @@ class BaseAgent(ABC):
|
|||||||
content = result["choices"][0]["message"]["content"]
|
content = result["choices"][0]["message"]["content"]
|
||||||
logger.debug(f"[{self.name}] Received Perplexity response (length: {len(content)})")
|
logger.debug(f"[{self.name}] Received Perplexity response (length: {len(content)})")
|
||||||
|
|
||||||
|
# Track usage
|
||||||
|
usage = result.get("usage", {})
|
||||||
|
if usage:
|
||||||
|
await self._log_usage(
|
||||||
|
provider="perplexity",
|
||||||
|
model=model,
|
||||||
|
prompt_tokens=usage.get("prompt_tokens", 0),
|
||||||
|
completion_tokens=usage.get("completion_tokens", 0),
|
||||||
|
total_tokens=usage.get("prompt_tokens", 0) + usage.get("completion_tokens", 0)
|
||||||
|
)
|
||||||
|
|
||||||
return content
|
return content
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"""Research agent using Perplexity."""
|
"""Research agent using Perplexity."""
|
||||||
import json
|
import json
|
||||||
import random
|
import random
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta, timezone
|
||||||
from typing import Dict, Any, List
|
from typing import Dict, Any, List
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
@@ -258,7 +258,7 @@ Gib deine Antwort im folgenden JSON-Format zurück:
|
|||||||
pain_points_text = ", ".join(pain_points) if pain_points else "Allgemeine Business-Probleme"
|
pain_points_text = ", ".join(pain_points) if pain_points else "Allgemeine Business-Probleme"
|
||||||
|
|
||||||
# Current date for time-specific searches
|
# Current date for time-specific searches
|
||||||
today = datetime.now()
|
today = datetime.now(timezone.utc)
|
||||||
date_str = today.strftime("%d. %B %Y")
|
date_str = today.strftime("%d. %B %Y")
|
||||||
week_ago = (today - timedelta(days=7)).strftime("%d. %B %Y")
|
week_ago = (today - timedelta(days=7)).strftime("%d. %B %Y")
|
||||||
|
|
||||||
|
|||||||
@@ -27,7 +27,10 @@ class WriterAgent(BaseAgent):
|
|||||||
critic_result: Optional[Dict[str, Any]] = None,
|
critic_result: Optional[Dict[str, Any]] = None,
|
||||||
learned_lessons: Optional[Dict[str, Any]] = None,
|
learned_lessons: Optional[Dict[str, Any]] = None,
|
||||||
post_type: Any = None,
|
post_type: Any = None,
|
||||||
post_type_analysis: Optional[Dict[str, Any]] = None
|
post_type_analysis: Optional[Dict[str, Any]] = None,
|
||||||
|
user_thoughts: str = "",
|
||||||
|
selected_hook: str = "",
|
||||||
|
company_strategy: Optional[Dict[str, Any]] = None
|
||||||
) -> str:
|
) -> str:
|
||||||
"""
|
"""
|
||||||
Write a LinkedIn post.
|
Write a LinkedIn post.
|
||||||
@@ -42,6 +45,7 @@ class WriterAgent(BaseAgent):
|
|||||||
learned_lessons: Optional lessons learned from past critic feedback
|
learned_lessons: Optional lessons learned from past critic feedback
|
||||||
post_type: Optional PostType object for type-specific writing
|
post_type: Optional PostType object for type-specific writing
|
||||||
post_type_analysis: Optional analysis of the post type
|
post_type_analysis: Optional analysis of the post type
|
||||||
|
company_strategy: Optional company strategy to consider during writing
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Written LinkedIn post
|
Written LinkedIn post
|
||||||
@@ -58,12 +62,19 @@ class WriterAgent(BaseAgent):
|
|||||||
critic_result=critic_result,
|
critic_result=critic_result,
|
||||||
learned_lessons=learned_lessons,
|
learned_lessons=learned_lessons,
|
||||||
post_type=post_type,
|
post_type=post_type,
|
||||||
post_type_analysis=post_type_analysis
|
post_type_analysis=post_type_analysis,
|
||||||
|
user_thoughts=user_thoughts,
|
||||||
|
selected_hook=selected_hook,
|
||||||
|
company_strategy=company_strategy
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
logger.info(f"Writing initial post for topic: {topic.get('title', 'Unknown')}")
|
logger.info(f"Writing initial post for topic: {topic.get('title', 'Unknown')}")
|
||||||
if post_type:
|
if post_type:
|
||||||
logger.info(f"Using post type: {post_type.name}")
|
logger.info(f"Using post type: {post_type.name}")
|
||||||
|
if user_thoughts:
|
||||||
|
logger.info(f"Including user thoughts ({len(user_thoughts)} chars)")
|
||||||
|
if selected_hook:
|
||||||
|
logger.info(f"Using selected hook: {selected_hook[:50]}...")
|
||||||
|
|
||||||
# Select example posts - use semantic matching if enabled
|
# Select example posts - use semantic matching if enabled
|
||||||
selected_examples = self._select_example_posts(topic, example_posts, profile_analysis)
|
selected_examples = self._select_example_posts(topic, example_posts, profile_analysis)
|
||||||
@@ -76,7 +87,10 @@ class WriterAgent(BaseAgent):
|
|||||||
example_posts=selected_examples,
|
example_posts=selected_examples,
|
||||||
learned_lessons=learned_lessons,
|
learned_lessons=learned_lessons,
|
||||||
post_type=post_type,
|
post_type=post_type,
|
||||||
post_type_analysis=post_type_analysis
|
post_type_analysis=post_type_analysis,
|
||||||
|
user_thoughts=user_thoughts,
|
||||||
|
selected_hook=selected_hook,
|
||||||
|
company_strategy=company_strategy
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
return await self._write_single_draft(
|
return await self._write_single_draft(
|
||||||
@@ -85,7 +99,10 @@ class WriterAgent(BaseAgent):
|
|||||||
example_posts=selected_examples,
|
example_posts=selected_examples,
|
||||||
learned_lessons=learned_lessons,
|
learned_lessons=learned_lessons,
|
||||||
post_type=post_type,
|
post_type=post_type,
|
||||||
post_type_analysis=post_type_analysis
|
post_type_analysis=post_type_analysis,
|
||||||
|
user_thoughts=user_thoughts,
|
||||||
|
selected_hook=selected_hook,
|
||||||
|
company_strategy=company_strategy
|
||||||
)
|
)
|
||||||
|
|
||||||
def _select_example_posts(
|
def _select_example_posts(
|
||||||
@@ -164,10 +181,14 @@ class WriterAgent(BaseAgent):
|
|||||||
|
|
||||||
# If we still don't have enough, fill with top scored
|
# If we still don't have enough, fill with top scored
|
||||||
while len(selected) < 3 and len(selected) < len(example_posts):
|
while len(selected) < 3 and len(selected) < len(example_posts):
|
||||||
|
found = False
|
||||||
for item in scored_posts:
|
for item in scored_posts:
|
||||||
if item["post"] not in selected:
|
if item["post"] not in selected:
|
||||||
selected.append(item["post"])
|
selected.append(item["post"])
|
||||||
|
found = True
|
||||||
break
|
break
|
||||||
|
if not found:
|
||||||
|
break # no more unique posts available
|
||||||
|
|
||||||
logger.info(f"Selected {len(selected)} example posts via semantic matching")
|
logger.info(f"Selected {len(selected)} example posts via semantic matching")
|
||||||
return selected
|
return selected
|
||||||
@@ -212,7 +233,10 @@ class WriterAgent(BaseAgent):
|
|||||||
example_posts: List[str],
|
example_posts: List[str],
|
||||||
learned_lessons: Optional[Dict[str, Any]] = None,
|
learned_lessons: Optional[Dict[str, Any]] = None,
|
||||||
post_type: Any = None,
|
post_type: Any = None,
|
||||||
post_type_analysis: Optional[Dict[str, Any]] = None
|
post_type_analysis: Optional[Dict[str, Any]] = None,
|
||||||
|
user_thoughts: str = "",
|
||||||
|
selected_hook: str = "",
|
||||||
|
company_strategy: Optional[Dict[str, Any]] = None
|
||||||
) -> str:
|
) -> str:
|
||||||
"""
|
"""
|
||||||
Generate multiple drafts and select the best one.
|
Generate multiple drafts and select the best one.
|
||||||
@@ -224,6 +248,7 @@ class WriterAgent(BaseAgent):
|
|||||||
learned_lessons: Lessons learned from past feedback
|
learned_lessons: Lessons learned from past feedback
|
||||||
post_type: Optional PostType object
|
post_type: Optional PostType object
|
||||||
post_type_analysis: Optional post type analysis
|
post_type_analysis: Optional post type analysis
|
||||||
|
company_strategy: Optional company strategy to consider
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Best selected draft
|
Best selected draft
|
||||||
@@ -231,7 +256,7 @@ class WriterAgent(BaseAgent):
|
|||||||
num_drafts = min(max(settings.writer_multi_draft_count, 2), 5) # Clamp between 2-5
|
num_drafts = min(max(settings.writer_multi_draft_count, 2), 5) # Clamp between 2-5
|
||||||
logger.info(f"Generating {num_drafts} drafts for selection")
|
logger.info(f"Generating {num_drafts} drafts for selection")
|
||||||
|
|
||||||
system_prompt = self._get_system_prompt(profile_analysis, example_posts, learned_lessons, post_type, post_type_analysis)
|
system_prompt = self._get_system_prompt(profile_analysis, example_posts, learned_lessons, post_type, post_type_analysis, company_strategy)
|
||||||
|
|
||||||
# Generate drafts in parallel with different temperatures/approaches
|
# Generate drafts in parallel with different temperatures/approaches
|
||||||
draft_configs = [
|
draft_configs = [
|
||||||
@@ -244,7 +269,7 @@ class WriterAgent(BaseAgent):
|
|||||||
|
|
||||||
# Create draft tasks
|
# Create draft tasks
|
||||||
async def generate_draft(config: Dict, draft_num: int) -> Dict[str, Any]:
|
async def generate_draft(config: Dict, draft_num: int) -> Dict[str, Any]:
|
||||||
user_prompt = self._get_user_prompt_for_draft(topic, draft_num, config["approach"])
|
user_prompt = self._get_user_prompt_for_draft(topic, draft_num, config["approach"], user_thoughts, selected_hook)
|
||||||
try:
|
try:
|
||||||
draft = await self.call_openai(
|
draft = await self.call_openai(
|
||||||
system_prompt=system_prompt,
|
system_prompt=system_prompt,
|
||||||
@@ -286,7 +311,9 @@ class WriterAgent(BaseAgent):
|
|||||||
self,
|
self,
|
||||||
topic: Dict[str, Any],
|
topic: Dict[str, Any],
|
||||||
draft_num: int,
|
draft_num: int,
|
||||||
approach: str
|
approach: str,
|
||||||
|
user_thoughts: str = "",
|
||||||
|
selected_hook: str = ""
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Get user prompt with slight variations for different drafts."""
|
"""Get user prompt with slight variations for different drafts."""
|
||||||
# Different emphasis for each draft
|
# Different emphasis for each draft
|
||||||
@@ -305,8 +332,11 @@ class WriterAgent(BaseAgent):
|
|||||||
if topic.get('angle'):
|
if topic.get('angle'):
|
||||||
angle_section = f"\n**ANGLE/PERSPEKTIVE:**\n{topic.get('angle')}\n"
|
angle_section = f"\n**ANGLE/PERSPEKTIVE:**\n{topic.get('angle')}\n"
|
||||||
|
|
||||||
|
# User-selected hook takes priority
|
||||||
hook_section = ""
|
hook_section = ""
|
||||||
if topic.get('hook_idea'):
|
if selected_hook:
|
||||||
|
hook_section = f"\n**HOOK (VERWENDE DIESEN ALS EINSTIEG!):**\n\"{selected_hook}\"\n"
|
||||||
|
elif topic.get('hook_idea'):
|
||||||
hook_section = f"\n**HOOK-IDEE (als Inspiration):**\n\"{topic.get('hook_idea')}\"\n"
|
hook_section = f"\n**HOOK-IDEE (als Inspiration):**\n\"{topic.get('hook_idea')}\"\n"
|
||||||
|
|
||||||
facts_section = ""
|
facts_section = ""
|
||||||
@@ -318,6 +348,34 @@ class WriterAgent(BaseAgent):
|
|||||||
if topic.get('why_this_person'):
|
if topic.get('why_this_person'):
|
||||||
why_section = f"\n**WARUM DU DARÜBER SCHREIBEN SOLLTEST:**\n{topic.get('why_this_person')}\n"
|
why_section = f"\n**WARUM DU DARÜBER SCHREIBEN SOLLTEST:**\n{topic.get('why_this_person')}\n"
|
||||||
|
|
||||||
|
# User thoughts section
|
||||||
|
thoughts_section = ""
|
||||||
|
if user_thoughts:
|
||||||
|
thoughts_section = f"""
|
||||||
|
**PERSÖNLICHE GEDANKEN (UNBEDINGT einbauen!):**
|
||||||
|
{user_thoughts}
|
||||||
|
|
||||||
|
Diese Gedanken MÜSSEN im Post verarbeitet werden!
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Adjust task based on hook
|
||||||
|
if selected_hook:
|
||||||
|
task_text = """**AUFGABE:**
|
||||||
|
Schreibe einen authentischen LinkedIn-Post, der:
|
||||||
|
1. Mit dem VORGEGEBENEN HOOK beginnt (exakt oder leicht angepasst)
|
||||||
|
2. Den Fakt/das Thema aufgreift und Mehrwert bietet
|
||||||
|
3. Die persönlichen Gedanken organisch einbaut
|
||||||
|
4. Die Key Facts verwendet wo es passt
|
||||||
|
5. Mit einem passenden CTA endet"""
|
||||||
|
else:
|
||||||
|
task_text = """**AUFGABE:**
|
||||||
|
Schreibe einen authentischen LinkedIn-Post, der:
|
||||||
|
1. Mit einem STARKEN, unerwarteten Hook beginnt (nutze die Hook-Idee als Inspiration, NICHT wörtlich!)
|
||||||
|
2. Den Fakt/das Thema aufgreift und Mehrwert bietet
|
||||||
|
3. Die persönlichen Gedanken organisch einbaut (falls vorhanden)
|
||||||
|
4. Die Key Facts einbaut wo es passt
|
||||||
|
5. Mit einem passenden CTA endet"""
|
||||||
|
|
||||||
return f"""Schreibe einen LinkedIn-Post zu folgendem Thema:
|
return f"""Schreibe einen LinkedIn-Post zu folgendem Thema:
|
||||||
|
|
||||||
**THEMA:** {topic.get('title', 'Unbekanntes Thema')}
|
**THEMA:** {topic.get('title', 'Unbekanntes Thema')}
|
||||||
@@ -326,26 +384,19 @@ class WriterAgent(BaseAgent):
|
|||||||
{angle_section}{hook_section}
|
{angle_section}{hook_section}
|
||||||
**KERN-FAKT / INHALT:**
|
**KERN-FAKT / INHALT:**
|
||||||
{topic.get('fact', topic.get('description', ''))}
|
{topic.get('fact', topic.get('description', ''))}
|
||||||
{facts_section}
|
{facts_section}{thoughts_section}
|
||||||
**WARUM RELEVANT:**
|
**WARUM RELEVANT:**
|
||||||
{topic.get('relevance', 'Aktuelles Thema für die Zielgruppe')}
|
{topic.get('relevance', 'Aktuelles Thema für die Zielgruppe')}
|
||||||
{why_section}
|
{why_section}
|
||||||
**DEIN ANSATZ FÜR DIESEN ENTWURF ({approach}):**
|
**DEIN ANSATZ FÜR DIESEN ENTWURF ({approach}):**
|
||||||
{emphasis}
|
{emphasis}
|
||||||
|
|
||||||
**AUFGABE:**
|
{task_text}
|
||||||
Schreibe einen authentischen LinkedIn-Post, der:
|
|
||||||
1. Mit einem STARKEN, unerwarteten Hook beginnt (nutze die Hook-Idee als Inspiration, NICHT wörtlich!)
|
|
||||||
2. Den Fakt/das Thema aufgreift und Mehrwert bietet
|
|
||||||
3. Die Key Facts einbaut wo es passt
|
|
||||||
4. Eine persönliche Note oder Meinung enthält
|
|
||||||
5. Mit einem passenden CTA endet
|
|
||||||
|
|
||||||
WICHTIG:
|
WICHTIG:
|
||||||
- Vermeide KI-typische Formulierungen ("In der heutigen Zeit", "Tauchen Sie ein", etc.)
|
- Vermeide KI-typische Formulierungen ("In der heutigen Zeit", "Tauchen Sie ein", etc.)
|
||||||
- Schreibe natürlich und menschlich
|
- Schreibe natürlich und menschlich
|
||||||
- Der Post soll SOFORT 85+ Punkte im Review erreichen
|
- Der Post soll SOFORT 85+ Punkte im Review erreichen
|
||||||
- Die Hook-Idee ist nur INSPIRATION - mach etwas Eigenes daraus!
|
|
||||||
|
|
||||||
Gib NUR den fertigen Post zurück."""
|
Gib NUR den fertigen Post zurück."""
|
||||||
|
|
||||||
@@ -446,7 +497,10 @@ Analysiere jeden Entwurf kurz und wähle den besten. Antworte im JSON-Format:
|
|||||||
critic_result: Optional[Dict[str, Any]] = None,
|
critic_result: Optional[Dict[str, Any]] = None,
|
||||||
learned_lessons: Optional[Dict[str, Any]] = None,
|
learned_lessons: Optional[Dict[str, Any]] = None,
|
||||||
post_type: Any = None,
|
post_type: Any = None,
|
||||||
post_type_analysis: Optional[Dict[str, Any]] = None
|
post_type_analysis: Optional[Dict[str, Any]] = None,
|
||||||
|
user_thoughts: str = "",
|
||||||
|
selected_hook: str = "",
|
||||||
|
company_strategy: Optional[Dict[str, Any]] = None
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Write a single draft (original behavior)."""
|
"""Write a single draft (original behavior)."""
|
||||||
# Select examples if not already selected
|
# Select examples if not already selected
|
||||||
@@ -461,8 +515,8 @@ Analysiere jeden Entwurf kurz und wähle den besten. Antworte im JSON-Format:
|
|||||||
elif len(selected_examples) > 3:
|
elif len(selected_examples) > 3:
|
||||||
selected_examples = random.sample(selected_examples, 3)
|
selected_examples = random.sample(selected_examples, 3)
|
||||||
|
|
||||||
system_prompt = self._get_system_prompt(profile_analysis, selected_examples, learned_lessons, post_type, post_type_analysis)
|
system_prompt = self._get_system_prompt(profile_analysis, selected_examples, learned_lessons, post_type, post_type_analysis, company_strategy)
|
||||||
user_prompt = self._get_user_prompt(topic, feedback, previous_version, critic_result)
|
user_prompt = self._get_user_prompt(topic, feedback, previous_version, critic_result, user_thoughts, selected_hook)
|
||||||
|
|
||||||
# Lower temperature for more consistent style matching
|
# Lower temperature for more consistent style matching
|
||||||
post = await self.call_openai(
|
post = await self.call_openai(
|
||||||
@@ -481,7 +535,8 @@ Analysiere jeden Entwurf kurz und wähle den besten. Antworte im JSON-Format:
|
|||||||
example_posts: List[str] = None,
|
example_posts: List[str] = None,
|
||||||
learned_lessons: Optional[Dict[str, Any]] = None,
|
learned_lessons: Optional[Dict[str, Any]] = None,
|
||||||
post_type: Any = None,
|
post_type: Any = None,
|
||||||
post_type_analysis: Optional[Dict[str, Any]] = None
|
post_type_analysis: Optional[Dict[str, Any]] = None,
|
||||||
|
company_strategy: Optional[Dict[str, Any]] = None
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Get system prompt for writer - orientiert an bewährten n8n-Prompts."""
|
"""Get system prompt for writer - orientiert an bewährten n8n-Prompts."""
|
||||||
# Extract key profile information
|
# Extract key profile information
|
||||||
@@ -670,16 +725,65 @@ Vermeide IMMER diese KI-typischen Muster:
|
|||||||
- Zu perfekte, glatte Formulierungen - echte Menschen schreiben mit Ecken und Kanten
|
- Zu perfekte, glatte Formulierungen - echte Menschen schreiben mit Ecken und Kanten
|
||||||
{lessons_section}
|
{lessons_section}
|
||||||
{post_type_section}
|
{post_type_section}
|
||||||
|
{self._get_company_strategy_section(company_strategy)}
|
||||||
DEIN AUFTRAG: Schreibe den Post so, dass er für die Zielgruppe ({audience.get('target_audience', 'Professionals')}) einen klaren Mehrwert bietet und ihre Pain Points ({pain_points_str}) adressiert. Mach die Persönlichkeit des linguistischen Fingerabdrucks spürbar.
|
DEIN AUFTRAG: Schreibe den Post so, dass er für die Zielgruppe ({audience.get('target_audience', 'Professionals')}) einen klaren Mehrwert bietet und ihre Pain Points ({pain_points_str}) adressiert. Mach die Persönlichkeit des linguistischen Fingerabdrucks spürbar.
|
||||||
|
|
||||||
Beginne DIREKT mit dem Hook. Keine einleitenden Sätze, kein "Hier ist der Post"."""
|
Beginne DIREKT mit dem Hook. Keine einleitenden Sätze, kein "Hier ist der Post"."""
|
||||||
|
|
||||||
|
def _get_company_strategy_section(self, company_strategy: Optional[Dict[str, Any]] = None) -> str:
|
||||||
|
"""Build the company strategy section for the system prompt."""
|
||||||
|
if not company_strategy:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
# Extract strategy elements
|
||||||
|
mission = company_strategy.get("mission", "")
|
||||||
|
vision = company_strategy.get("vision", "")
|
||||||
|
brand_voice = company_strategy.get("brand_voice", "")
|
||||||
|
tone_guidelines = company_strategy.get("tone_guidelines", "")
|
||||||
|
content_pillars = company_strategy.get("content_pillars", [])
|
||||||
|
target_audience = company_strategy.get("target_audience", "")
|
||||||
|
dos = company_strategy.get("dos", [])
|
||||||
|
donts = company_strategy.get("donts", [])
|
||||||
|
|
||||||
|
# Build section only if there's meaningful content
|
||||||
|
if not any([mission, vision, brand_voice, content_pillars, dos, donts]):
|
||||||
|
return ""
|
||||||
|
|
||||||
|
section = """
|
||||||
|
|
||||||
|
8. UNTERNEHMENSSTRATEGIE (WICHTIG - IMMER BEACHTEN!):
|
||||||
|
|
||||||
|
Der Post muss zur Unternehmensstrategie passen, aber authentisch im Stil des Autors bleiben!
|
||||||
|
"""
|
||||||
|
if mission:
|
||||||
|
section += f"\nMISSION: {mission}"
|
||||||
|
if vision:
|
||||||
|
section += f"\nVISION: {vision}"
|
||||||
|
if brand_voice:
|
||||||
|
section += f"\nBRAND VOICE: {brand_voice}"
|
||||||
|
if tone_guidelines:
|
||||||
|
section += f"\nTONE GUIDELINES: {tone_guidelines}"
|
||||||
|
if content_pillars:
|
||||||
|
section += f"\nCONTENT PILLARS: {', '.join(content_pillars)}"
|
||||||
|
if target_audience:
|
||||||
|
section += f"\nZIELGRUPPE: {target_audience}"
|
||||||
|
if dos:
|
||||||
|
section += f"\n\nDO's (empfohlen):\n" + "\n".join([f" + {d}" for d in dos])
|
||||||
|
if donts:
|
||||||
|
section += f"\n\nDON'Ts (vermeiden):\n" + "\n".join([f" - {d}" for d in donts])
|
||||||
|
|
||||||
|
section += "\n\nWICHTIG: Integriere die Unternehmenswerte subtil - der Post soll nicht wie eine Werbung klingen!"
|
||||||
|
|
||||||
|
return section
|
||||||
|
|
||||||
def _get_user_prompt(
|
def _get_user_prompt(
|
||||||
self,
|
self,
|
||||||
topic: Dict[str, Any],
|
topic: Dict[str, Any],
|
||||||
feedback: Optional[str] = None,
|
feedback: Optional[str] = None,
|
||||||
previous_version: Optional[str] = None,
|
previous_version: Optional[str] = None,
|
||||||
critic_result: Optional[Dict[str, Any]] = None
|
critic_result: Optional[Dict[str, Any]] = None,
|
||||||
|
user_thoughts: str = "",
|
||||||
|
selected_hook: str = ""
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Get user prompt for writer."""
|
"""Get user prompt for writer."""
|
||||||
if feedback and previous_version:
|
if feedback and previous_version:
|
||||||
@@ -727,8 +831,11 @@ Gib NUR den überarbeiteten Post zurück - keine Kommentare."""
|
|||||||
if topic.get('angle'):
|
if topic.get('angle'):
|
||||||
angle_section = f"\n**ANGLE/PERSPEKTIVE:**\n{topic.get('angle')}\n"
|
angle_section = f"\n**ANGLE/PERSPEKTIVE:**\n{topic.get('angle')}\n"
|
||||||
|
|
||||||
|
# User-selected hook takes priority over topic hook_idea
|
||||||
hook_section = ""
|
hook_section = ""
|
||||||
if topic.get('hook_idea'):
|
if selected_hook:
|
||||||
|
hook_section = f"\n**HOOK (VERWENDE DIESEN ALS EINSTIEG!):**\n\"{selected_hook}\"\n"
|
||||||
|
elif topic.get('hook_idea'):
|
||||||
hook_section = f"\n**HOOK-IDEE (als Inspiration):**\n\"{topic.get('hook_idea')}\"\n"
|
hook_section = f"\n**HOOK-IDEE (als Inspiration):**\n\"{topic.get('hook_idea')}\"\n"
|
||||||
|
|
||||||
facts_section = ""
|
facts_section = ""
|
||||||
@@ -736,6 +843,34 @@ Gib NUR den überarbeiteten Post zurück - keine Kommentare."""
|
|||||||
if key_facts and isinstance(key_facts, list) and len(key_facts) > 0:
|
if key_facts and isinstance(key_facts, list) and len(key_facts) > 0:
|
||||||
facts_section = "\n**KEY FACTS (nutze diese!):**\n" + "\n".join([f"- {f}" for f in key_facts]) + "\n"
|
facts_section = "\n**KEY FACTS (nutze diese!):**\n" + "\n".join([f"- {f}" for f in key_facts]) + "\n"
|
||||||
|
|
||||||
|
# User thoughts section
|
||||||
|
thoughts_section = ""
|
||||||
|
if user_thoughts:
|
||||||
|
thoughts_section = f"""
|
||||||
|
**PERSÖNLICHE GEDANKEN (UNBEDINGT einbauen!):**
|
||||||
|
{user_thoughts}
|
||||||
|
|
||||||
|
Diese Gedanken MÜSSEN im Post verarbeitet werden - sie spiegeln die echte Meinung der Person wider!
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Adjust task based on whether hook is provided
|
||||||
|
if selected_hook:
|
||||||
|
task_section = """**AUFGABE:**
|
||||||
|
Schreibe einen authentischen LinkedIn-Post, der:
|
||||||
|
1. Mit dem VORGEGEBENEN HOOK beginnt (exakt oder leicht angepasst)
|
||||||
|
2. Den Fakt/das Thema aufgreift und Mehrwert bietet
|
||||||
|
3. Die persönlichen Gedanken organisch einbaut
|
||||||
|
4. Die Key Facts verwendet wo es passt
|
||||||
|
5. Mit einem passenden CTA endet"""
|
||||||
|
else:
|
||||||
|
task_section = """**AUFGABE:**
|
||||||
|
Schreibe einen authentischen LinkedIn-Post, der:
|
||||||
|
1. Mit einem STARKEN, unerwarteten Hook beginnt (nutze Hook-Idee als Inspiration!)
|
||||||
|
2. Den Fakt/das Thema aufgreift und Mehrwert bietet
|
||||||
|
3. Die persönlichen Gedanken organisch einbaut (falls vorhanden)
|
||||||
|
4. Die Key Facts verwendet wo es passt
|
||||||
|
5. Mit einem passenden CTA endet"""
|
||||||
|
|
||||||
return f"""Schreibe einen LinkedIn-Post zu folgendem Thema:
|
return f"""Schreibe einen LinkedIn-Post zu folgendem Thema:
|
||||||
|
|
||||||
**THEMA:** {topic.get('title', 'Unbekanntes Thema')}
|
**THEMA:** {topic.get('title', 'Unbekanntes Thema')}
|
||||||
@@ -744,17 +879,11 @@ Gib NUR den überarbeiteten Post zurück - keine Kommentare."""
|
|||||||
{angle_section}{hook_section}
|
{angle_section}{hook_section}
|
||||||
**KERN-FAKT / INHALT:**
|
**KERN-FAKT / INHALT:**
|
||||||
{topic.get('fact', topic.get('description', ''))}
|
{topic.get('fact', topic.get('description', ''))}
|
||||||
{facts_section}
|
{facts_section}{thoughts_section}
|
||||||
**WARUM RELEVANT:**
|
**WARUM RELEVANT:**
|
||||||
{topic.get('relevance', 'Aktuelles Thema für die Zielgruppe')}
|
{topic.get('relevance', 'Aktuelles Thema für die Zielgruppe')}
|
||||||
|
|
||||||
**AUFGABE:**
|
{task_section}
|
||||||
Schreibe einen authentischen LinkedIn-Post, der:
|
|
||||||
1. Mit einem STARKEN, unerwarteten Hook beginnt (nutze Hook-Idee als Inspiration!)
|
|
||||||
2. Den Fakt/das Thema aufgreift und Mehrwert bietet
|
|
||||||
3. Die Key Facts einbaut wo es passt
|
|
||||||
4. Eine persönliche Note oder Meinung enthält
|
|
||||||
5. Mit einem passenden CTA endet
|
|
||||||
|
|
||||||
WICHTIG:
|
WICHTIG:
|
||||||
- Vermeide KI-typische Formulierungen ("In der heutigen Zeit", "Tauchen Sie ein", etc.)
|
- Vermeide KI-typische Formulierungen ("In der heutigen Zeit", "Tauchen Sie ein", etc.)
|
||||||
@@ -762,3 +891,263 @@ WICHTIG:
|
|||||||
- Der Post soll SOFORT 85+ Punkte im Review erreichen
|
- Der Post soll SOFORT 85+ Punkte im Review erreichen
|
||||||
|
|
||||||
Gib NUR den fertigen Post zurück."""
|
Gib NUR den fertigen Post zurück."""
|
||||||
|
|
||||||
|
async def generate_hooks(
|
||||||
|
self,
|
||||||
|
topic: Dict[str, Any],
|
||||||
|
profile_analysis: Dict[str, Any],
|
||||||
|
user_thoughts: str = "",
|
||||||
|
post_type: Any = None
|
||||||
|
) -> List[Dict[str, str]]:
|
||||||
|
"""
|
||||||
|
Generate 4 different hook options for a topic.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
topic: Topic dictionary with title, fact, etc.
|
||||||
|
profile_analysis: Profile analysis for style matching
|
||||||
|
user_thoughts: User's personal thoughts about the topic
|
||||||
|
post_type: Optional PostType object
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of {"hook": "...", "style": "..."} dictionaries
|
||||||
|
"""
|
||||||
|
logger.info(f"Generating hooks for topic: {topic.get('title', 'Unknown')}")
|
||||||
|
|
||||||
|
# Extract style info from profile
|
||||||
|
tone = profile_analysis.get('tone_analysis', {}).get('primary_tone', 'Professionell')
|
||||||
|
energy = profile_analysis.get('linguistic_fingerprint', {}).get('energy_level', 7)
|
||||||
|
address = profile_analysis.get('writing_style', {}).get('form_of_address', 'Du')
|
||||||
|
hook_phrases = profile_analysis.get('phrase_library', {}).get('hook_phrases', [])
|
||||||
|
|
||||||
|
# Build example hooks section
|
||||||
|
example_hooks = ""
|
||||||
|
if hook_phrases:
|
||||||
|
example_hooks = "\n\nBeispiel-Hooks dieser Person (zur Inspiration):\n" + "\n".join([f"- {h}" for h in hook_phrases[:5]])
|
||||||
|
|
||||||
|
system_prompt = f"""Du bist ein Hook-Spezialist für LinkedIn Posts. Deine Aufgabe ist es, 4 verschiedene, aufmerksamkeitsstarke Hooks zu generieren.
|
||||||
|
|
||||||
|
STIL DER PERSON:
|
||||||
|
- Tonalität: {tone}
|
||||||
|
- Energie-Level: {energy}/10 (höher = emotionaler, niedriger = sachlicher)
|
||||||
|
- Ansprache: {address}
|
||||||
|
{example_hooks}
|
||||||
|
|
||||||
|
GENERIERE 4 VERSCHIEDENE HOOKS:
|
||||||
|
1. **Provokant** - Eine kontroverse These oder überraschende Aussage
|
||||||
|
2. **Storytelling** - Beginn einer persönlichen Geschichte oder Anekdote
|
||||||
|
3. **Fakten-basiert** - Eine überraschende Statistik oder Fakt
|
||||||
|
4. **Neugier-weckend** - Eine Frage oder ein Cliffhanger
|
||||||
|
|
||||||
|
REGELN:
|
||||||
|
- Jeder Hook sollte 1-2 Sätze lang sein
|
||||||
|
- Hooks müssen zum Energie-Level und Ton der Person passen
|
||||||
|
- Keine KI-typischen Phrasen ("In der heutigen Zeit", "Stellen Sie sich vor")
|
||||||
|
- Die Hooks sollen SOFORT Aufmerksamkeit erregen
|
||||||
|
- Bei Energie 8+ darf es emotional/leidenschaftlich sein
|
||||||
|
- Bei Energie 5-7 eher sachlich-professionell
|
||||||
|
|
||||||
|
Antworte im JSON-Format:
|
||||||
|
{{"hooks": [
|
||||||
|
{{"hook": "Der Hook-Text hier", "style": "Provokant"}},
|
||||||
|
{{"hook": "Der Hook-Text hier", "style": "Storytelling"}},
|
||||||
|
{{"hook": "Der Hook-Text hier", "style": "Fakten-basiert"}},
|
||||||
|
{{"hook": "Der Hook-Text hier", "style": "Neugier-weckend"}}
|
||||||
|
]}}"""
|
||||||
|
|
||||||
|
# Build user prompt
|
||||||
|
thoughts_section = ""
|
||||||
|
if user_thoughts:
|
||||||
|
thoughts_section = f"\n\nPERSÖNLICHE GEDANKEN DER PERSON ZUM THEMA:\n{user_thoughts}\n\nIntegriere diese Perspektive in die Hooks!"
|
||||||
|
|
||||||
|
post_type_section = ""
|
||||||
|
if post_type:
|
||||||
|
post_type_section = f"\n\nPOST-TYP: {post_type.name}"
|
||||||
|
if post_type.description:
|
||||||
|
post_type_section += f"\n{post_type.description}"
|
||||||
|
|
||||||
|
user_prompt = f"""Generiere 4 Hooks für dieses Thema:
|
||||||
|
|
||||||
|
THEMA: {topic.get('title', 'Unbekanntes Thema')}
|
||||||
|
|
||||||
|
KATEGORIE: {topic.get('category', 'Allgemein')}
|
||||||
|
|
||||||
|
KERN-FAKT/INHALT:
|
||||||
|
{topic.get('fact', topic.get('description', 'Keine Details verfügbar'))}
|
||||||
|
{thoughts_section}{post_type_section}
|
||||||
|
|
||||||
|
Generiere jetzt die 4 verschiedenen Hooks im JSON-Format."""
|
||||||
|
|
||||||
|
response = await self.call_openai(
|
||||||
|
system_prompt=system_prompt,
|
||||||
|
user_prompt=user_prompt,
|
||||||
|
model="gpt-4o-mini",
|
||||||
|
temperature=0.8,
|
||||||
|
response_format={"type": "json_object"}
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = json.loads(response)
|
||||||
|
hooks = result.get("hooks", [])
|
||||||
|
logger.info(f"Generated {len(hooks)} hooks successfully")
|
||||||
|
return hooks
|
||||||
|
except (json.JSONDecodeError, KeyError) as e:
|
||||||
|
logger.error(f"Failed to parse hooks response: {e}")
|
||||||
|
# Return fallback hooks
|
||||||
|
return [
|
||||||
|
{"hook": f"Was wäre, wenn {topic.get('title', 'dieses Thema')} alles verändert?", "style": "Neugier-weckend"},
|
||||||
|
{"hook": f"Letzte Woche habe ich etwas über {topic.get('title', 'dieses Thema')} gelernt, das mich nicht mehr loslässt.", "style": "Storytelling"},
|
||||||
|
{"hook": f"Die meisten unterschätzen {topic.get('title', 'dieses Thema')}. Ein Fehler.", "style": "Provokant"},
|
||||||
|
{"hook": f"Eine Zahl hat mich diese Woche überrascht: {topic.get('fact', 'Ein überraschender Fakt')}.", "style": "Fakten-basiert"}
|
||||||
|
]
|
||||||
|
|
||||||
|
async def generate_improvement_suggestions(
|
||||||
|
self,
|
||||||
|
post_content: str,
|
||||||
|
profile_analysis: Dict[str, Any],
|
||||||
|
critic_feedback: Optional[Dict[str, Any]] = None
|
||||||
|
) -> List[Dict[str, str]]:
|
||||||
|
"""
|
||||||
|
Generate improvement suggestions for an existing post based on critic feedback.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
post_content: The current post content
|
||||||
|
profile_analysis: Profile analysis for style matching
|
||||||
|
critic_feedback: Optional feedback from the critic with improvements list
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of {"label": "...", "action": "..."} dictionaries
|
||||||
|
"""
|
||||||
|
logger.info("Generating improvement suggestions for post")
|
||||||
|
|
||||||
|
# Extract style info from profile
|
||||||
|
tone = profile_analysis.get('tone_analysis', {}).get('primary_tone', 'Professionell')
|
||||||
|
energy = profile_analysis.get('linguistic_fingerprint', {}).get('energy_level', 7)
|
||||||
|
address = profile_analysis.get('writing_style', {}).get('form_of_address', 'Du')
|
||||||
|
|
||||||
|
# Build feedback context
|
||||||
|
feedback_context = ""
|
||||||
|
if critic_feedback:
|
||||||
|
feedback_text = critic_feedback.get('feedback', '')
|
||||||
|
improvements = critic_feedback.get('improvements', [])
|
||||||
|
strengths = critic_feedback.get('strengths', [])
|
||||||
|
score = critic_feedback.get('overall_score', 0)
|
||||||
|
|
||||||
|
feedback_context = f"""
|
||||||
|
LETZTES KRITIKER-FEEDBACK (Score: {score}/100):
|
||||||
|
{feedback_text}
|
||||||
|
|
||||||
|
IDENTIFIZIERTE VERBESSERUNGSPUNKTE:
|
||||||
|
{chr(10).join(['- ' + imp for imp in improvements]) if improvements else '- Keine spezifischen Verbesserungen genannt'}
|
||||||
|
|
||||||
|
STÄRKEN (diese beibehalten!):
|
||||||
|
{chr(10).join(['- ' + s for s in strengths]) if strengths else '- Keine spezifischen Stärken genannt'}
|
||||||
|
"""
|
||||||
|
|
||||||
|
system_prompt = f"""Du generierst kurze, klickbare Verbesserungsvorschläge für LinkedIn-Posts.
|
||||||
|
|
||||||
|
PROFIL DER PERSON:
|
||||||
|
- Tonalität: {tone}
|
||||||
|
- Energie: {energy}/10
|
||||||
|
- Ansprache: {address}
|
||||||
|
{feedback_context}
|
||||||
|
|
||||||
|
DEINE AUFGABE:
|
||||||
|
Generiere 4 KURZE Verbesserungsvorschläge (max 5-6 Worte pro Label).
|
||||||
|
Jeder Vorschlag muss:
|
||||||
|
1. Auf dem Kritiker-Feedback basieren (falls vorhanden)
|
||||||
|
2. Zum Stil der Person passen
|
||||||
|
3. Eine konkrete, umsetzbare Änderung beschreiben
|
||||||
|
|
||||||
|
FORMAT - Kurze Labels wie:
|
||||||
|
- "Hook emotionaler machen"
|
||||||
|
- "Persönliche Anekdote einbauen"
|
||||||
|
- "Absätze kürzen"
|
||||||
|
- "Call-to-Action verstärken"
|
||||||
|
|
||||||
|
Antworte im JSON-Format:
|
||||||
|
{{"suggestions": [
|
||||||
|
{{"label": "Kurzes Label (max 6 Worte)", "action": "Detaillierte Anweisung für die KI, was genau geändert werden soll"}},
|
||||||
|
...
|
||||||
|
]}}"""
|
||||||
|
|
||||||
|
user_prompt = f"""POST:
|
||||||
|
{post_content[:1500]}
|
||||||
|
|
||||||
|
Generiere 4 kurze, spezifische Verbesserungsvorschläge basierend auf dem Feedback und Profil."""
|
||||||
|
|
||||||
|
response = await self.call_openai(
|
||||||
|
system_prompt=system_prompt,
|
||||||
|
user_prompt=user_prompt,
|
||||||
|
model="gpt-4o-mini",
|
||||||
|
temperature=0.7,
|
||||||
|
response_format={"type": "json_object"}
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = json.loads(response)
|
||||||
|
suggestions = result.get("suggestions", [])
|
||||||
|
logger.info(f"Generated {len(suggestions)} improvement suggestions")
|
||||||
|
return suggestions
|
||||||
|
except (json.JSONDecodeError, KeyError) as e:
|
||||||
|
logger.error(f"Failed to parse suggestions response: {e}")
|
||||||
|
return [
|
||||||
|
{"label": "Hook verstärken", "action": "Mache den ersten Satz emotionaler und aufmerksamkeitsstärker"},
|
||||||
|
{"label": "Storytelling einbauen", "action": "Füge eine kurze persönliche Anekdote oder Erfahrung hinzu"},
|
||||||
|
{"label": "Call-to-Action ergänzen", "action": "Füge am Ende eine Frage oder Handlungsaufforderung hinzu"},
|
||||||
|
{"label": "Struktur verbessern", "action": "Kürze lange Absätze und verbessere die Lesbarkeit"}
|
||||||
|
]
|
||||||
|
|
||||||
|
async def apply_suggestion(
|
||||||
|
self,
|
||||||
|
post_content: str,
|
||||||
|
suggestion: str,
|
||||||
|
profile_analysis: Dict[str, Any]
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
Apply a specific improvement suggestion to a post.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
post_content: The current post content
|
||||||
|
suggestion: The suggestion to apply
|
||||||
|
profile_analysis: Profile analysis for style matching
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The improved post content
|
||||||
|
"""
|
||||||
|
logger.info(f"Applying suggestion to post: {suggestion[:50]}...")
|
||||||
|
|
||||||
|
# Extract style info from profile
|
||||||
|
tone = profile_analysis.get('tone_analysis', {}).get('primary_tone', 'Professionell')
|
||||||
|
energy = profile_analysis.get('linguistic_fingerprint', {}).get('energy_level', 7)
|
||||||
|
address = profile_analysis.get('writing_style', {}).get('form_of_address', 'Du')
|
||||||
|
|
||||||
|
system_prompt = f"""Du bist ein LinkedIn-Experte. Deine Aufgabe ist es, einen Post basierend auf einem konkreten Verbesserungsvorschlag zu überarbeiten.
|
||||||
|
|
||||||
|
STIL DER PERSON:
|
||||||
|
- Tonalität: {tone}
|
||||||
|
- Energie-Level: {energy}/10
|
||||||
|
- Ansprache: {address}
|
||||||
|
|
||||||
|
WICHTIG:
|
||||||
|
- Behalte den Kern und die Hauptaussage des Posts bei
|
||||||
|
- Wende NUR die angegebene Verbesserung an
|
||||||
|
- Behalte die Länge ungefähr bei
|
||||||
|
- Antworte NUR mit dem überarbeiteten Post, keine Erklärungen"""
|
||||||
|
|
||||||
|
user_prompt = f"""ORIGINAL-POST:
|
||||||
|
{post_content}
|
||||||
|
|
||||||
|
ANZUWENDENDE VERBESSERUNG:
|
||||||
|
{suggestion}
|
||||||
|
|
||||||
|
Schreibe jetzt den überarbeiteten Post:"""
|
||||||
|
|
||||||
|
response = await self.call_openai(
|
||||||
|
system_prompt=system_prompt,
|
||||||
|
user_prompt=user_prompt,
|
||||||
|
model="gpt-4o-mini",
|
||||||
|
temperature=0.5
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info("Successfully applied suggestion to post")
|
||||||
|
return response.strip()
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ from pathlib import Path
|
|||||||
|
|
||||||
class Settings(BaseSettings):
|
class Settings(BaseSettings):
|
||||||
"""Application settings loaded from environment variables."""
|
"""Application settings loaded from environment variables."""
|
||||||
|
|
||||||
# API Keys
|
# API Keys
|
||||||
openai_api_key: str
|
openai_api_key: str
|
||||||
perplexity_api_key: str
|
perplexity_api_key: str
|
||||||
@@ -15,6 +14,7 @@ class Settings(BaseSettings):
|
|||||||
# Supabase
|
# Supabase
|
||||||
supabase_url: str
|
supabase_url: str
|
||||||
supabase_key: str
|
supabase_key: str
|
||||||
|
supabase_service_role_key: str = "" # Required for admin operations like deleting users
|
||||||
|
|
||||||
# Apify
|
# Apify
|
||||||
apify_actor_id: str = "apimaestro~linkedin-profile-posts"
|
apify_actor_id: str = "apimaestro~linkedin-profile-posts"
|
||||||
@@ -46,6 +46,14 @@ class Settings(BaseSettings):
|
|||||||
user_frontend_enabled: bool = True # Enable user frontend with LinkedIn OAuth
|
user_frontend_enabled: bool = True # Enable user frontend with LinkedIn OAuth
|
||||||
supabase_redirect_url: str = "" # OAuth Callback URL (e.g., https://linkedin.onyva.dev/auth/callback)
|
supabase_redirect_url: str = "" # OAuth Callback URL (e.g., https://linkedin.onyva.dev/auth/callback)
|
||||||
|
|
||||||
|
# LinkedIn API (Custom OAuth for auto-posting)
|
||||||
|
linkedin_client_id: str = ""
|
||||||
|
linkedin_client_secret: str = ""
|
||||||
|
linkedin_redirect_uri: str = "" # e.g., https://yourdomain.com/settings/linkedin/callback
|
||||||
|
|
||||||
|
# Token Encryption
|
||||||
|
encryption_key: str = "" # Generate with: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
|
||||||
|
|
||||||
model_config = SettingsConfigDict(
|
model_config = SettingsConfigDict(
|
||||||
env_file=".env",
|
env_file=".env",
|
||||||
env_file_encoding="utf-8",
|
env_file_encoding="utf-8",
|
||||||
@@ -55,3 +63,18 @@ class Settings(BaseSettings):
|
|||||||
|
|
||||||
# Global settings instance
|
# Global settings instance
|
||||||
settings = Settings()
|
settings = Settings()
|
||||||
|
|
||||||
|
# API pricing per 1M tokens (input, output)
|
||||||
|
API_PRICING = {
|
||||||
|
"gpt-4o": {"input": 2.50, "output": 10.00},
|
||||||
|
"gpt-4o-mini": {"input": 0.15, "output": 0.60},
|
||||||
|
"sonar": {"input": 1.00, "output": 1.00},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def estimate_cost(model: str, prompt_tokens: int, completion_tokens: int) -> float:
|
||||||
|
"""Estimate cost in USD for an API call."""
|
||||||
|
pricing = API_PRICING.get(model, {"input": 1.00, "output": 1.00})
|
||||||
|
input_cost = (prompt_tokens / 1_000_000) * pricing["input"]
|
||||||
|
output_cost = (completion_tokens / 1_000_000) * pricing["output"]
|
||||||
|
return input_cost + output_cost
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
"""Database module."""
|
"""Database module."""
|
||||||
from src.database.client import DatabaseClient, db
|
from src.database.client import DatabaseClient, db
|
||||||
from src.database.models import (
|
from src.database.models import (
|
||||||
Customer,
|
|
||||||
LinkedInProfile,
|
LinkedInProfile,
|
||||||
LinkedInPost,
|
LinkedInPost,
|
||||||
Topic,
|
Topic,
|
||||||
@@ -9,12 +8,24 @@ from src.database.models import (
|
|||||||
ResearchResult,
|
ResearchResult,
|
||||||
GeneratedPost,
|
GeneratedPost,
|
||||||
PostType,
|
PostType,
|
||||||
|
User,
|
||||||
|
Profile,
|
||||||
|
Company,
|
||||||
|
Invitation,
|
||||||
|
ExamplePost,
|
||||||
|
ReferenceProfile,
|
||||||
|
AccountType,
|
||||||
|
OnboardingStatus,
|
||||||
|
AuthMethod,
|
||||||
|
InvitationStatus,
|
||||||
|
ApiUsageLog,
|
||||||
|
LicenseKey,
|
||||||
|
CompanyDailyQuota,
|
||||||
)
|
)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"DatabaseClient",
|
"DatabaseClient",
|
||||||
"db",
|
"db",
|
||||||
"Customer",
|
|
||||||
"LinkedInProfile",
|
"LinkedInProfile",
|
||||||
"LinkedInPost",
|
"LinkedInPost",
|
||||||
"Topic",
|
"Topic",
|
||||||
@@ -22,4 +33,17 @@ __all__ = [
|
|||||||
"ResearchResult",
|
"ResearchResult",
|
||||||
"GeneratedPost",
|
"GeneratedPost",
|
||||||
"PostType",
|
"PostType",
|
||||||
|
"User",
|
||||||
|
"Profile",
|
||||||
|
"Company",
|
||||||
|
"Invitation",
|
||||||
|
"ExamplePost",
|
||||||
|
"ReferenceProfile",
|
||||||
|
"AccountType",
|
||||||
|
"OnboardingStatus",
|
||||||
|
"AuthMethod",
|
||||||
|
"InvitationStatus",
|
||||||
|
"ApiUsageLog",
|
||||||
|
"LicenseKey",
|
||||||
|
"CompanyDailyQuota",
|
||||||
]
|
]
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,6 @@
|
|||||||
"""Pydantic models for database entities."""
|
"""Pydantic models for database entities."""
|
||||||
from datetime import datetime
|
from datetime import datetime, date
|
||||||
|
from enum import Enum
|
||||||
from typing import Optional, Dict, Any, List
|
from typing import Optional, Dict, Any, List
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
from pydantic import BaseModel, Field, ConfigDict
|
from pydantic import BaseModel, Field, ConfigDict
|
||||||
@@ -10,22 +11,210 @@ class DBModel(BaseModel):
|
|||||||
model_config = ConfigDict(extra='ignore')
|
model_config = ConfigDict(extra='ignore')
|
||||||
|
|
||||||
|
|
||||||
class Customer(DBModel):
|
# ==================== ENUMS ====================
|
||||||
"""Customer/Client model."""
|
|
||||||
|
class AccountType(str, Enum):
|
||||||
|
"""User account type."""
|
||||||
|
GHOSTWRITER = "ghostwriter"
|
||||||
|
COMPANY = "company"
|
||||||
|
EMPLOYEE = "employee"
|
||||||
|
|
||||||
|
|
||||||
|
class OnboardingStatus(str, Enum):
|
||||||
|
"""Onboarding status for users."""
|
||||||
|
PENDING = "pending"
|
||||||
|
PROFILE_SETUP = "profile_setup"
|
||||||
|
POSTS_SCRAPED = "posts_scraped"
|
||||||
|
CATEGORIZING = "categorizing"
|
||||||
|
COMPLETED = "completed"
|
||||||
|
|
||||||
|
|
||||||
|
class AuthMethod(str, Enum):
|
||||||
|
"""Authentication method for users."""
|
||||||
|
LINKEDIN_OAUTH = "linkedin_oauth"
|
||||||
|
EMAIL_PASSWORD = "email_password"
|
||||||
|
|
||||||
|
|
||||||
|
class InvitationStatus(str, Enum):
|
||||||
|
"""Status of an invitation."""
|
||||||
|
PENDING = "pending"
|
||||||
|
ACCEPTED = "accepted"
|
||||||
|
EXPIRED = "expired"
|
||||||
|
CANCELLED = "cancelled"
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== USER & COMPANY MODELS ====================
|
||||||
|
|
||||||
|
class Profile(DBModel):
|
||||||
|
"""Profile model - extends auth.users with app-specific data.
|
||||||
|
|
||||||
|
The id is the same as auth.users.id (Supabase handles authentication).
|
||||||
|
"""
|
||||||
id: Optional[UUID] = None
|
id: Optional[UUID] = None
|
||||||
created_at: Optional[datetime] = None
|
created_at: Optional[datetime] = None
|
||||||
updated_at: Optional[datetime] = None
|
updated_at: Optional[datetime] = None
|
||||||
name: str
|
|
||||||
email: Optional[str] = None
|
# Account Type
|
||||||
company_name: Optional[str] = None
|
account_type: AccountType = AccountType.GHOSTWRITER
|
||||||
linkedin_url: str
|
|
||||||
|
# Display name
|
||||||
|
display_name: Optional[str] = None
|
||||||
|
|
||||||
|
# Onboarding
|
||||||
|
onboarding_status: OnboardingStatus = OnboardingStatus.PENDING
|
||||||
|
onboarding_data: Dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
# Links
|
||||||
|
company_id: Optional[UUID] = None
|
||||||
|
|
||||||
|
# Fields migrated from customers table
|
||||||
|
linkedin_url: Optional[str] = None
|
||||||
|
writing_style_notes: Optional[str] = None
|
||||||
metadata: Dict[str, Any] = Field(default_factory=dict)
|
metadata: Dict[str, Any] = Field(default_factory=dict)
|
||||||
|
profile_picture: Optional[str] = None
|
||||||
|
creator_email: Optional[str] = None
|
||||||
|
customer_email: Optional[str] = None
|
||||||
|
is_active: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
class LinkedInAccount(DBModel):
|
||||||
|
"""LinkedIn account connection for auto-posting."""
|
||||||
|
id: Optional[UUID] = None
|
||||||
|
user_id: UUID
|
||||||
|
created_at: Optional[datetime] = None
|
||||||
|
updated_at: Optional[datetime] = None
|
||||||
|
|
||||||
|
# LinkedIn Identity
|
||||||
|
linkedin_user_id: str
|
||||||
|
linkedin_vanity_name: Optional[str] = None
|
||||||
|
linkedin_name: Optional[str] = None
|
||||||
|
linkedin_picture: Optional[str] = None
|
||||||
|
|
||||||
|
# OAuth Tokens (encrypted)
|
||||||
|
access_token: str
|
||||||
|
refresh_token: Optional[str] = None
|
||||||
|
token_expires_at: datetime
|
||||||
|
granted_scopes: List[str] = Field(default_factory=list)
|
||||||
|
|
||||||
|
# Status
|
||||||
|
is_active: bool = True
|
||||||
|
last_used_at: Optional[datetime] = None
|
||||||
|
last_error: Optional[str] = None
|
||||||
|
last_error_at: Optional[datetime] = None
|
||||||
|
|
||||||
|
|
||||||
|
class User(DBModel):
|
||||||
|
"""User model - combines auth.users data with profile data.
|
||||||
|
|
||||||
|
This is a view model that combines data from Supabase auth.users
|
||||||
|
and our profiles table. Used for compatibility with existing code.
|
||||||
|
"""
|
||||||
|
id: Optional[UUID] = None
|
||||||
|
created_at: Optional[datetime] = None
|
||||||
|
updated_at: Optional[datetime] = None
|
||||||
|
|
||||||
|
# From auth.users
|
||||||
|
email: str = ""
|
||||||
|
password_hash: Optional[str] = None # Not stored, Supabase handles it
|
||||||
|
auth_method: AuthMethod = AuthMethod.LINKEDIN_OAUTH
|
||||||
|
|
||||||
|
# LinkedIn OAuth Data (from auth.users.raw_user_meta_data)
|
||||||
|
linkedin_sub: Optional[str] = None
|
||||||
|
linkedin_vanity_name: Optional[str] = None
|
||||||
|
linkedin_name: Optional[str] = None
|
||||||
|
linkedin_picture: Optional[str] = None
|
||||||
|
|
||||||
|
# From profiles table
|
||||||
|
account_type: AccountType = AccountType.GHOSTWRITER
|
||||||
|
display_name: Optional[str] = None
|
||||||
|
onboarding_status: OnboardingStatus = OnboardingStatus.PENDING
|
||||||
|
onboarding_data: Dict[str, Any] = Field(default_factory=dict)
|
||||||
|
company_id: Optional[UUID] = None
|
||||||
|
|
||||||
|
# Fields migrated from customers table
|
||||||
|
linkedin_url: Optional[str] = None
|
||||||
|
writing_style_notes: Optional[str] = None
|
||||||
|
metadata: Dict[str, Any] = Field(default_factory=dict)
|
||||||
|
profile_picture: Optional[str] = None
|
||||||
|
creator_email: Optional[str] = None
|
||||||
|
customer_email: Optional[str] = None
|
||||||
|
is_active: bool = True
|
||||||
|
|
||||||
|
# Email verification (from auth.users)
|
||||||
|
email_verified: bool = False
|
||||||
|
email_verification_token: Optional[str] = None
|
||||||
|
email_verification_expires_at: Optional[datetime] = None
|
||||||
|
|
||||||
|
|
||||||
|
class Company(DBModel):
|
||||||
|
"""Company model for company accounts."""
|
||||||
|
id: Optional[UUID] = None
|
||||||
|
created_at: Optional[datetime] = None
|
||||||
|
updated_at: Optional[datetime] = None
|
||||||
|
|
||||||
|
# Company Data
|
||||||
|
name: str
|
||||||
|
description: Optional[str] = None
|
||||||
|
website: Optional[str] = None
|
||||||
|
industry: Optional[str] = None
|
||||||
|
|
||||||
|
# Strategy
|
||||||
|
company_strategy: Dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
owner_user_id: UUID
|
||||||
|
onboarding_completed: bool = False
|
||||||
|
|
||||||
|
# License key reference (limits are stored in license_keys table)
|
||||||
|
license_key_id: Optional[UUID] = None
|
||||||
|
|
||||||
|
|
||||||
|
class Invitation(DBModel):
|
||||||
|
"""Invitation model for employee invitations."""
|
||||||
|
id: Optional[UUID] = None
|
||||||
|
created_at: Optional[datetime] = None
|
||||||
|
|
||||||
|
email: str
|
||||||
|
token: str
|
||||||
|
expires_at: datetime
|
||||||
|
|
||||||
|
company_id: UUID
|
||||||
|
invited_by_user_id: UUID
|
||||||
|
|
||||||
|
status: InvitationStatus = InvitationStatus.PENDING
|
||||||
|
accepted_at: Optional[datetime] = None
|
||||||
|
accepted_by_user_id: Optional[UUID] = None
|
||||||
|
|
||||||
|
|
||||||
|
class ExamplePost(DBModel):
|
||||||
|
"""Example post model for manual posts during onboarding."""
|
||||||
|
id: Optional[UUID] = None
|
||||||
|
user_id: UUID
|
||||||
|
created_at: Optional[datetime] = None
|
||||||
|
|
||||||
|
post_text: str
|
||||||
|
source: str = "manual" # 'manual' | 'reference_profile'
|
||||||
|
source_linkedin_url: Optional[str] = None
|
||||||
|
post_type_id: Optional[UUID] = None
|
||||||
|
|
||||||
|
|
||||||
|
class ReferenceProfile(DBModel):
|
||||||
|
"""Reference profile for scraping alternative LinkedIn profiles."""
|
||||||
|
id: Optional[UUID] = None
|
||||||
|
user_id: UUID
|
||||||
|
created_at: Optional[datetime] = None
|
||||||
|
|
||||||
|
linkedin_url: str
|
||||||
|
name: Optional[str] = None
|
||||||
|
posts_scraped: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== CONTENT MODELS ====================
|
||||||
|
|
||||||
|
|
||||||
class PostType(DBModel):
|
class PostType(DBModel):
|
||||||
"""Post type model for categorizing different types of posts."""
|
"""Post type model for categorizing different types of posts."""
|
||||||
id: Optional[UUID] = None
|
id: Optional[UUID] = None
|
||||||
customer_id: UUID
|
user_id: UUID
|
||||||
created_at: Optional[datetime] = None
|
created_at: Optional[datetime] = None
|
||||||
updated_at: Optional[datetime] = None
|
updated_at: Optional[datetime] = None
|
||||||
name: str
|
name: str
|
||||||
@@ -42,7 +231,7 @@ class PostType(DBModel):
|
|||||||
class LinkedInProfile(DBModel):
|
class LinkedInProfile(DBModel):
|
||||||
"""LinkedIn profile model."""
|
"""LinkedIn profile model."""
|
||||||
id: Optional[UUID] = None
|
id: Optional[UUID] = None
|
||||||
customer_id: UUID
|
user_id: UUID
|
||||||
scraped_at: Optional[datetime] = None
|
scraped_at: Optional[datetime] = None
|
||||||
profile_data: Dict[str, Any]
|
profile_data: Dict[str, Any]
|
||||||
name: Optional[str] = None
|
name: Optional[str] = None
|
||||||
@@ -55,7 +244,7 @@ class LinkedInProfile(DBModel):
|
|||||||
class LinkedInPost(DBModel):
|
class LinkedInPost(DBModel):
|
||||||
"""LinkedIn post model."""
|
"""LinkedIn post model."""
|
||||||
id: Optional[UUID] = None
|
id: Optional[UUID] = None
|
||||||
customer_id: UUID
|
user_id: UUID
|
||||||
scraped_at: Optional[datetime] = None
|
scraped_at: Optional[datetime] = None
|
||||||
post_url: Optional[str] = None
|
post_url: Optional[str] = None
|
||||||
post_text: str
|
post_text: str
|
||||||
@@ -73,7 +262,7 @@ class LinkedInPost(DBModel):
|
|||||||
class Topic(DBModel):
|
class Topic(DBModel):
|
||||||
"""Topic model."""
|
"""Topic model."""
|
||||||
id: Optional[UUID] = None
|
id: Optional[UUID] = None
|
||||||
customer_id: UUID
|
user_id: UUID
|
||||||
created_at: Optional[datetime] = None
|
created_at: Optional[datetime] = None
|
||||||
title: str
|
title: str
|
||||||
description: Optional[str] = None
|
description: Optional[str] = None
|
||||||
@@ -82,13 +271,13 @@ class Topic(DBModel):
|
|||||||
extraction_confidence: Optional[float] = None
|
extraction_confidence: Optional[float] = None
|
||||||
is_used: bool = False
|
is_used: bool = False
|
||||||
used_at: Optional[datetime] = None
|
used_at: Optional[datetime] = None
|
||||||
target_post_type_id: Optional[UUID] = None # Target post type for this topic
|
target_post_type_id: Optional[UUID] = None
|
||||||
|
|
||||||
|
|
||||||
class ProfileAnalysis(DBModel):
|
class ProfileAnalysis(DBModel):
|
||||||
"""Profile analysis model."""
|
"""Profile analysis model."""
|
||||||
id: Optional[UUID] = None
|
id: Optional[UUID] = None
|
||||||
customer_id: UUID
|
user_id: UUID
|
||||||
created_at: Optional[datetime] = None
|
created_at: Optional[datetime] = None
|
||||||
writing_style: Dict[str, Any]
|
writing_style: Dict[str, Any]
|
||||||
tone_analysis: Dict[str, Any]
|
tone_analysis: Dict[str, Any]
|
||||||
@@ -100,19 +289,33 @@ class ProfileAnalysis(DBModel):
|
|||||||
class ResearchResult(DBModel):
|
class ResearchResult(DBModel):
|
||||||
"""Research result model."""
|
"""Research result model."""
|
||||||
id: Optional[UUID] = None
|
id: Optional[UUID] = None
|
||||||
customer_id: UUID
|
user_id: UUID
|
||||||
created_at: Optional[datetime] = None
|
created_at: Optional[datetime] = None
|
||||||
query: str
|
query: str
|
||||||
results: Dict[str, Any]
|
results: Dict[str, Any]
|
||||||
suggested_topics: List[Dict[str, Any]]
|
suggested_topics: List[Dict[str, Any]]
|
||||||
source: str = "perplexity"
|
source: str = "perplexity"
|
||||||
target_post_type_id: Optional[UUID] = None # Target post type for this research
|
target_post_type_id: Optional[UUID] = None
|
||||||
|
|
||||||
|
|
||||||
|
class ApiUsageLog(DBModel):
|
||||||
|
"""API usage log for tracking token usage and costs."""
|
||||||
|
id: Optional[UUID] = None
|
||||||
|
user_id: Optional[UUID] = None
|
||||||
|
provider: str # 'openai' | 'perplexity'
|
||||||
|
model: str # 'gpt-4o', 'gpt-4o-mini', 'sonar'
|
||||||
|
operation: str # 'post_creation', 'research', 'profile_analysis', etc.
|
||||||
|
prompt_tokens: int = 0
|
||||||
|
completion_tokens: int = 0
|
||||||
|
total_tokens: int = 0
|
||||||
|
estimated_cost_usd: float = 0.0
|
||||||
|
created_at: Optional[datetime] = None
|
||||||
|
|
||||||
|
|
||||||
class GeneratedPost(DBModel):
|
class GeneratedPost(DBModel):
|
||||||
"""Generated post model."""
|
"""Generated post model."""
|
||||||
id: Optional[UUID] = None
|
id: Optional[UUID] = None
|
||||||
customer_id: UUID
|
user_id: UUID
|
||||||
created_at: Optional[datetime] = None
|
created_at: Optional[datetime] = None
|
||||||
topic_id: Optional[UUID] = None
|
topic_id: Optional[UUID] = None
|
||||||
topic_title: str
|
topic_title: str
|
||||||
@@ -120,7 +323,48 @@ class GeneratedPost(DBModel):
|
|||||||
iterations: int = 0
|
iterations: int = 0
|
||||||
writer_versions: List[str] = Field(default_factory=list)
|
writer_versions: List[str] = Field(default_factory=list)
|
||||||
critic_feedback: List[Dict[str, Any]] = Field(default_factory=list)
|
critic_feedback: List[Dict[str, Any]] = Field(default_factory=list)
|
||||||
status: str = "draft" # draft, approved, published, rejected
|
status: str = "draft" # draft, approved, ready, scheduled, published, rejected
|
||||||
approved_at: Optional[datetime] = None
|
approved_at: Optional[datetime] = None
|
||||||
published_at: Optional[datetime] = None
|
published_at: Optional[datetime] = None
|
||||||
post_type_id: Optional[UUID] = None # Post type used for this generated post
|
post_type_id: Optional[UUID] = None
|
||||||
|
# Image
|
||||||
|
image_url: Optional[str] = None
|
||||||
|
# Scheduling fields
|
||||||
|
scheduled_at: Optional[datetime] = None
|
||||||
|
scheduled_by_user_id: Optional[UUID] = None
|
||||||
|
# Metadata for additional info (e.g., LinkedIn post URL, auto-posting status)
|
||||||
|
metadata: Optional[Dict[str, Any]] = None
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== LICENSE KEY MODELS ====================
|
||||||
|
|
||||||
|
|
||||||
|
class LicenseKey(DBModel):
|
||||||
|
"""License key for company registration."""
|
||||||
|
id: Optional[UUID] = None
|
||||||
|
key: str
|
||||||
|
description: Optional[str] = None
|
||||||
|
|
||||||
|
# Limits
|
||||||
|
max_employees: int = 5
|
||||||
|
max_posts_per_day: int = 10
|
||||||
|
max_researches_per_day: int = 5
|
||||||
|
|
||||||
|
# Usage
|
||||||
|
used: bool = False
|
||||||
|
company_id: Optional[UUID] = None
|
||||||
|
used_at: Optional[datetime] = None
|
||||||
|
|
||||||
|
created_at: Optional[datetime] = None
|
||||||
|
updated_at: Optional[datetime] = None
|
||||||
|
|
||||||
|
|
||||||
|
class CompanyDailyQuota(DBModel):
|
||||||
|
"""Daily usage quota for a company."""
|
||||||
|
id: Optional[UUID] = None
|
||||||
|
company_id: UUID
|
||||||
|
date: date
|
||||||
|
posts_created: int = 0
|
||||||
|
researches_created: int = 0
|
||||||
|
created_at: Optional[datetime] = None
|
||||||
|
updated_at: Optional[datetime] = None
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ from uuid import UUID
|
|||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
from src.config import settings
|
from src.config import settings
|
||||||
from src.database import db, Customer, LinkedInProfile, LinkedInPost, Topic
|
from src.database import db, LinkedInProfile, LinkedInPost, Topic
|
||||||
from src.scraper import scraper
|
from src.scraper import scraper
|
||||||
from src.agents import (
|
from src.agents import (
|
||||||
ProfileAnalyzerAgent,
|
ProfileAnalyzerAgent,
|
||||||
@@ -31,65 +31,80 @@ class WorkflowOrchestrator:
|
|||||||
self.critic = CriticAgent()
|
self.critic = CriticAgent()
|
||||||
self.post_classifier = PostClassifierAgent()
|
self.post_classifier = PostClassifierAgent()
|
||||||
self.post_type_analyzer = PostTypeAnalyzerAgent()
|
self.post_type_analyzer = PostTypeAnalyzerAgent()
|
||||||
|
self._all_agents = [
|
||||||
|
self.profile_analyzer, self.topic_extractor, self.researcher,
|
||||||
|
self.writer, self.critic, self.post_classifier, self.post_type_analyzer
|
||||||
|
]
|
||||||
logger.info("WorkflowOrchestrator initialized")
|
logger.info("WorkflowOrchestrator initialized")
|
||||||
|
|
||||||
|
def _set_tracking(self, operation: str, user_id: Optional[str] = None,
|
||||||
|
company_id: Optional[str] = None):
|
||||||
|
"""Set tracking context on all agents."""
|
||||||
|
uid = str(user_id) if user_id else None
|
||||||
|
comp_id = str(company_id) if company_id else None
|
||||||
|
for agent in self._all_agents:
|
||||||
|
agent.set_tracking_context(operation=operation, user_id=uid, company_id=comp_id)
|
||||||
|
|
||||||
|
async def _resolve_tracking_ids(self, user_id: UUID) -> dict:
|
||||||
|
"""Resolve company_id from a user_id for tracking."""
|
||||||
|
try:
|
||||||
|
profile = await db.get_profile(user_id)
|
||||||
|
if profile:
|
||||||
|
return {
|
||||||
|
"user_id": str(user_id),
|
||||||
|
"company_id": str(profile.company_id) if profile.company_id else None
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"Could not resolve tracking IDs for user {user_id}: {e}")
|
||||||
|
return {"user_id": str(user_id), "company_id": None}
|
||||||
|
|
||||||
async def run_initial_setup(
|
async def run_initial_setup(
|
||||||
self,
|
self,
|
||||||
|
user_id: UUID,
|
||||||
linkedin_url: str,
|
linkedin_url: str,
|
||||||
customer_name: str,
|
profile_data: Dict[str, Any],
|
||||||
customer_data: Dict[str, Any],
|
|
||||||
post_types_data: Optional[List[Dict[str, Any]]] = None
|
post_types_data: Optional[List[Dict[str, Any]]] = None
|
||||||
) -> Customer:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
Run initial setup for a new customer.
|
Run initial setup for a user.
|
||||||
|
|
||||||
This includes:
|
This includes:
|
||||||
1. Creating customer record
|
1. Updating profile with linkedin_url and metadata
|
||||||
2. Creating post types (if provided)
|
2. Creating post types (if provided)
|
||||||
3. Scraping LinkedIn posts (NO profile scraping)
|
3. Scraping LinkedIn posts (NO profile scraping)
|
||||||
4. Creating profile from customer_data
|
4. Creating profile from profile_data
|
||||||
5. Analyzing profile
|
5. Analyzing profile
|
||||||
6. Extracting topics from existing posts
|
6. Extracting topics from existing posts
|
||||||
7. Classifying posts by type (if post types exist)
|
7. Classifying posts by type (if post types exist)
|
||||||
8. Analyzing post types (if enough posts)
|
8. Analyzing post types (if enough posts)
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
|
user_id: User UUID
|
||||||
linkedin_url: LinkedIn profile URL
|
linkedin_url: LinkedIn profile URL
|
||||||
customer_name: Customer name
|
profile_data: Profile data (writing style notes, etc.)
|
||||||
customer_data: Complete customer data (company, persona, style_guide, etc.)
|
|
||||||
post_types_data: Optional list of post type definitions
|
post_types_data: Optional list of post type definitions
|
||||||
|
|
||||||
Returns:
|
|
||||||
Customer object
|
|
||||||
"""
|
"""
|
||||||
logger.info(f"=== INITIAL SETUP for {customer_name} ===")
|
logger.info(f"=== INITIAL SETUP for user {user_id} ===")
|
||||||
|
ids = await self._resolve_tracking_ids(user_id)
|
||||||
|
self._set_tracking("initial_setup", **ids)
|
||||||
|
|
||||||
# Step 1: Check if customer already exists
|
# Step 1: Update profile with linkedin_url
|
||||||
existing_customer = await db.get_customer_by_linkedin(linkedin_url)
|
|
||||||
if existing_customer:
|
|
||||||
logger.warning(f"Customer already exists: {existing_customer.id}")
|
|
||||||
return existing_customer
|
|
||||||
|
|
||||||
# Step 2: Create customer
|
|
||||||
total_steps = 7 if post_types_data else 5
|
total_steps = 7 if post_types_data else 5
|
||||||
logger.info(f"Step 1/{total_steps}: Creating customer record")
|
logger.info(f"Step 1/{total_steps}: Updating profile")
|
||||||
customer = Customer(
|
await db.update_profile(user_id, {
|
||||||
name=customer_name,
|
"linkedin_url": linkedin_url,
|
||||||
linkedin_url=linkedin_url,
|
"writing_style_notes": profile_data.get("writing_style_notes"),
|
||||||
company_name=customer_data.get("company_name"),
|
"metadata": profile_data
|
||||||
email=customer_data.get("email"),
|
})
|
||||||
metadata=customer_data
|
logger.info(f"Profile updated for user: {user_id}")
|
||||||
)
|
|
||||||
customer = await db.create_customer(customer)
|
|
||||||
logger.info(f"Customer created: {customer.id}")
|
|
||||||
|
|
||||||
# Step 2.5: Create post types if provided
|
# Step 2: Create post types if provided
|
||||||
created_post_types = []
|
created_post_types = []
|
||||||
if post_types_data:
|
if post_types_data:
|
||||||
logger.info(f"Step 2/{total_steps}: Creating {len(post_types_data)} post types")
|
logger.info(f"Step 2/{total_steps}: Creating {len(post_types_data)} post types")
|
||||||
for pt_data in post_types_data:
|
for pt_data in post_types_data:
|
||||||
post_type = PostType(
|
post_type = PostType(
|
||||||
customer_id=customer.id,
|
user_id=user_id,
|
||||||
name=pt_data.get("name", "Unnamed"),
|
name=pt_data.get("name", "Unnamed"),
|
||||||
description=pt_data.get("description"),
|
description=pt_data.get("description"),
|
||||||
identifying_hashtags=pt_data.get("identifying_hashtags", []),
|
identifying_hashtags=pt_data.get("identifying_hashtags", []),
|
||||||
@@ -102,19 +117,20 @@ class WorkflowOrchestrator:
|
|||||||
created_post_types = await db.create_post_types_bulk(created_post_types)
|
created_post_types = await db.create_post_types_bulk(created_post_types)
|
||||||
logger.info(f"Created {len(created_post_types)} post types")
|
logger.info(f"Created {len(created_post_types)} post types")
|
||||||
|
|
||||||
# Step 3: Create LinkedIn profile from customer data (NO scraping)
|
# Step 3: Create LinkedIn profile from profile data (NO scraping)
|
||||||
step_num = 3 if post_types_data else 2
|
step_num = 3 if post_types_data else 2
|
||||||
logger.info(f"Step {step_num}/{total_steps}: Creating LinkedIn profile from provided data")
|
logger.info(f"Step {step_num}/{total_steps}: Creating LinkedIn profile from provided data")
|
||||||
|
profile = await db.get_profile(user_id)
|
||||||
linkedin_profile = LinkedInProfile(
|
linkedin_profile = LinkedInProfile(
|
||||||
customer_id=customer.id,
|
user_id=user_id,
|
||||||
profile_data={
|
profile_data={
|
||||||
"persona": customer_data.get("persona"),
|
"persona": profile_data.get("persona"),
|
||||||
"form_of_address": customer_data.get("form_of_address"),
|
"form_of_address": profile_data.get("form_of_address"),
|
||||||
"style_guide": customer_data.get("style_guide"),
|
"style_guide": profile_data.get("style_guide"),
|
||||||
"linkedin_url": linkedin_url
|
"linkedin_url": linkedin_url
|
||||||
},
|
},
|
||||||
name=customer_name,
|
name=profile.display_name or "",
|
||||||
headline=customer_data.get("persona", "")[:100] if customer_data.get("persona") else None
|
headline=profile_data.get("persona", "")[:100] if profile_data.get("persona") else None
|
||||||
)
|
)
|
||||||
await db.save_linkedin_profile(linkedin_profile)
|
await db.save_linkedin_profile(linkedin_profile)
|
||||||
logger.info("LinkedIn profile saved")
|
logger.info("LinkedIn profile saved")
|
||||||
@@ -129,7 +145,7 @@ class WorkflowOrchestrator:
|
|||||||
linkedin_posts = []
|
linkedin_posts = []
|
||||||
for post_data in parsed_posts:
|
for post_data in parsed_posts:
|
||||||
post = LinkedInPost(
|
post = LinkedInPost(
|
||||||
customer_id=customer.id,
|
user_id=user_id,
|
||||||
**post_data
|
**post_data
|
||||||
)
|
)
|
||||||
linkedin_posts.append(post)
|
linkedin_posts.append(post)
|
||||||
@@ -151,13 +167,13 @@ class WorkflowOrchestrator:
|
|||||||
profile_analysis = await self.profile_analyzer.process(
|
profile_analysis = await self.profile_analyzer.process(
|
||||||
profile=linkedin_profile,
|
profile=linkedin_profile,
|
||||||
posts=linkedin_posts,
|
posts=linkedin_posts,
|
||||||
customer_data=customer_data
|
customer_data=profile_data
|
||||||
)
|
)
|
||||||
|
|
||||||
# Save profile analysis
|
# Save profile analysis
|
||||||
from src.database.models import ProfileAnalysis
|
from src.database.models import ProfileAnalysis
|
||||||
analysis_record = ProfileAnalysis(
|
analysis_record = ProfileAnalysis(
|
||||||
customer_id=customer.id,
|
user_id=user_id,
|
||||||
writing_style=profile_analysis.get("writing_style", {}),
|
writing_style=profile_analysis.get("writing_style", {}),
|
||||||
tone_analysis=profile_analysis.get("tone_analysis", {}),
|
tone_analysis=profile_analysis.get("tone_analysis", {}),
|
||||||
topic_patterns=profile_analysis.get("topic_patterns", {}),
|
topic_patterns=profile_analysis.get("topic_patterns", {}),
|
||||||
@@ -177,7 +193,7 @@ class WorkflowOrchestrator:
|
|||||||
try:
|
try:
|
||||||
topics = await self.topic_extractor.process(
|
topics = await self.topic_extractor.process(
|
||||||
posts=linkedin_posts,
|
posts=linkedin_posts,
|
||||||
customer_id=customer.id # Pass UUID directly
|
user_id=user_id
|
||||||
)
|
)
|
||||||
if topics:
|
if topics:
|
||||||
await db.save_topics(topics)
|
await db.save_topics(topics)
|
||||||
@@ -192,40 +208,41 @@ class WorkflowOrchestrator:
|
|||||||
# Step 7: Classify posts
|
# Step 7: Classify posts
|
||||||
logger.info(f"Step {total_steps - 1}/{total_steps}: Classifying posts by type")
|
logger.info(f"Step {total_steps - 1}/{total_steps}: Classifying posts by type")
|
||||||
try:
|
try:
|
||||||
await self.classify_posts(customer.id)
|
await self.classify_posts(user_id)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Post classification failed: {e}", exc_info=True)
|
logger.error(f"Post classification failed: {e}", exc_info=True)
|
||||||
|
|
||||||
# Step 8: Analyze post types
|
# Step 8: Analyze post types
|
||||||
logger.info(f"Step {total_steps}/{total_steps}: Analyzing post types")
|
logger.info(f"Step {total_steps}/{total_steps}: Analyzing post types")
|
||||||
try:
|
try:
|
||||||
await self.analyze_post_types(customer.id)
|
await self.analyze_post_types(user_id)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Post type analysis failed: {e}", exc_info=True)
|
logger.error(f"Post type analysis failed: {e}", exc_info=True)
|
||||||
|
|
||||||
logger.info(f"Step {total_steps}/{total_steps}: Initial setup complete!")
|
logger.info(f"Step {total_steps}/{total_steps}: Initial setup complete!")
|
||||||
return customer
|
|
||||||
|
|
||||||
async def classify_posts(self, customer_id: UUID) -> int:
|
async def classify_posts(self, user_id: UUID) -> int:
|
||||||
"""
|
"""
|
||||||
Classify unclassified posts for a customer.
|
Classify unclassified posts for a user.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
customer_id: Customer UUID
|
user_id: User UUID
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Number of posts classified
|
Number of posts classified
|
||||||
"""
|
"""
|
||||||
logger.info(f"=== CLASSIFYING POSTS for customer {customer_id} ===")
|
logger.info(f"=== CLASSIFYING POSTS for user {user_id} ===")
|
||||||
|
ids = await self._resolve_tracking_ids(user_id)
|
||||||
|
self._set_tracking("classify_posts", **ids)
|
||||||
|
|
||||||
# Get post types
|
# Get post types
|
||||||
post_types = await db.get_post_types(customer_id)
|
post_types = await db.get_post_types(user_id)
|
||||||
if not post_types:
|
if not post_types:
|
||||||
logger.info("No post types defined, skipping classification")
|
logger.info("No post types defined, skipping classification")
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
# Get unclassified posts
|
# Get unclassified posts
|
||||||
posts = await db.get_unclassified_posts(customer_id)
|
posts = await db.get_unclassified_posts(user_id)
|
||||||
if not posts:
|
if not posts:
|
||||||
logger.info("No unclassified posts found")
|
logger.info("No unclassified posts found")
|
||||||
return 0
|
return 0
|
||||||
@@ -243,20 +260,22 @@ class WorkflowOrchestrator:
|
|||||||
|
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
async def analyze_post_types(self, customer_id: UUID) -> Dict[str, Any]:
|
async def analyze_post_types(self, user_id: UUID) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Analyze all post types for a customer.
|
Analyze all post types for a user.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
customer_id: Customer UUID
|
user_id: User UUID
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dictionary with analysis results per post type
|
Dictionary with analysis results per post type
|
||||||
"""
|
"""
|
||||||
logger.info(f"=== ANALYZING POST TYPES for customer {customer_id} ===")
|
logger.info(f"=== ANALYZING POST TYPES for user {user_id} ===")
|
||||||
|
ids = await self._resolve_tracking_ids(user_id)
|
||||||
|
self._set_tracking("analyze_post_types", **ids)
|
||||||
|
|
||||||
# Get post types
|
# Get post types
|
||||||
post_types = await db.get_post_types(customer_id)
|
post_types = await db.get_post_types(user_id)
|
||||||
if not post_types:
|
if not post_types:
|
||||||
logger.info("No post types defined")
|
logger.info("No post types defined")
|
||||||
return {}
|
return {}
|
||||||
@@ -264,7 +283,7 @@ class WorkflowOrchestrator:
|
|||||||
results = {}
|
results = {}
|
||||||
for post_type in post_types:
|
for post_type in post_types:
|
||||||
# Get posts for this type
|
# Get posts for this type
|
||||||
posts = await db.get_posts_by_type(customer_id, post_type.id)
|
posts = await db.get_posts_by_type(user_id, post_type.id)
|
||||||
|
|
||||||
if len(posts) < self.post_type_analyzer.MIN_POSTS_FOR_ANALYSIS:
|
if len(posts) < self.post_type_analyzer.MIN_POSTS_FOR_ANALYSIS:
|
||||||
logger.info(f"Post type '{post_type.name}' has only {len(posts)} posts, skipping analysis")
|
logger.info(f"Post type '{post_type.name}' has only {len(posts)} posts, skipping analysis")
|
||||||
@@ -292,22 +311,24 @@ class WorkflowOrchestrator:
|
|||||||
|
|
||||||
async def research_new_topics(
|
async def research_new_topics(
|
||||||
self,
|
self,
|
||||||
customer_id: UUID,
|
user_id: UUID,
|
||||||
progress_callback: Optional[Callable[[str, int, int], None]] = None,
|
progress_callback: Optional[Callable[[str, int, int], None]] = None,
|
||||||
post_type_id: Optional[UUID] = None
|
post_type_id: Optional[UUID] = None
|
||||||
) -> List[Dict[str, Any]]:
|
) -> List[Dict[str, Any]]:
|
||||||
"""
|
"""
|
||||||
Research new content topics for a customer.
|
Research new content topics for a user.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
customer_id: Customer UUID
|
user_id: User UUID
|
||||||
progress_callback: Optional callback(message, current_step, total_steps)
|
progress_callback: Optional callback(message, current_step, total_steps)
|
||||||
post_type_id: Optional post type to target research for
|
post_type_id: Optional post type to target research for
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
List of suggested topics
|
List of suggested topics
|
||||||
"""
|
"""
|
||||||
logger.info(f"=== RESEARCHING NEW TOPICS for customer {customer_id} ===")
|
logger.info(f"=== RESEARCHING NEW TOPICS for user {user_id} ===")
|
||||||
|
ids = await self._resolve_tracking_ids(user_id)
|
||||||
|
self._set_tracking("research", **ids)
|
||||||
|
|
||||||
# Get post type context if specified
|
# Get post type context if specified
|
||||||
post_type = None
|
post_type = None
|
||||||
@@ -324,7 +345,7 @@ class WorkflowOrchestrator:
|
|||||||
|
|
||||||
# Step 1: Get profile analysis
|
# Step 1: Get profile analysis
|
||||||
report_progress("Lade Profil-Analyse...", 1)
|
report_progress("Lade Profil-Analyse...", 1)
|
||||||
profile_analysis = await db.get_profile_analysis(customer_id)
|
profile_analysis = await db.get_profile_analysis(user_id)
|
||||||
if not profile_analysis:
|
if not profile_analysis:
|
||||||
raise ValueError("Profile analysis not found. Run initial setup first.")
|
raise ValueError("Profile analysis not found. Run initial setup first.")
|
||||||
|
|
||||||
@@ -333,12 +354,12 @@ class WorkflowOrchestrator:
|
|||||||
existing_topics = set()
|
existing_topics = set()
|
||||||
|
|
||||||
# From topics table
|
# From topics table
|
||||||
existing_topics_records = await db.get_topics(customer_id)
|
existing_topics_records = await db.get_topics(user_id)
|
||||||
for t in existing_topics_records:
|
for t in existing_topics_records:
|
||||||
existing_topics.add(t.title)
|
existing_topics.add(t.title)
|
||||||
|
|
||||||
# From previous research results
|
# From previous research results
|
||||||
all_research = await db.get_all_research(customer_id)
|
all_research = await db.get_all_research(user_id)
|
||||||
for research in all_research:
|
for research in all_research:
|
||||||
if research.suggested_topics:
|
if research.suggested_topics:
|
||||||
for topic in research.suggested_topics:
|
for topic in research.suggested_topics:
|
||||||
@@ -346,7 +367,7 @@ class WorkflowOrchestrator:
|
|||||||
existing_topics.add(topic["title"])
|
existing_topics.add(topic["title"])
|
||||||
|
|
||||||
# From generated posts
|
# From generated posts
|
||||||
generated_posts = await db.get_generated_posts(customer_id)
|
generated_posts = await db.get_generated_posts(user_id)
|
||||||
for post in generated_posts:
|
for post in generated_posts:
|
||||||
if post.topic_title:
|
if post.topic_title:
|
||||||
existing_topics.add(post.topic_title)
|
existing_topics.add(post.topic_title)
|
||||||
@@ -354,15 +375,15 @@ class WorkflowOrchestrator:
|
|||||||
existing_topics = list(existing_topics)
|
existing_topics = list(existing_topics)
|
||||||
logger.info(f"Found {len(existing_topics)} existing topics to avoid")
|
logger.info(f"Found {len(existing_topics)} existing topics to avoid")
|
||||||
|
|
||||||
# Get customer data
|
# Get profile data
|
||||||
customer = await db.get_customer(customer_id)
|
profile = await db.get_profile(user_id)
|
||||||
|
|
||||||
# Get example posts to understand the person's actual content style
|
# Get example posts to understand the person's actual content style
|
||||||
# If post_type_id is specified, only use posts of that type
|
# If post_type_id is specified, only use posts of that type
|
||||||
if post_type_id:
|
if post_type_id:
|
||||||
linkedin_posts = await db.get_posts_by_type(customer_id, post_type_id)
|
linkedin_posts = await db.get_posts_by_type(user_id, post_type_id)
|
||||||
else:
|
else:
|
||||||
linkedin_posts = await db.get_linkedin_posts(customer_id)
|
linkedin_posts = await db.get_linkedin_posts(user_id)
|
||||||
|
|
||||||
example_post_texts = [
|
example_post_texts = [
|
||||||
post.post_text for post in linkedin_posts
|
post.post_text for post in linkedin_posts
|
||||||
@@ -376,7 +397,7 @@ class WorkflowOrchestrator:
|
|||||||
research_results = await self.researcher.process(
|
research_results = await self.researcher.process(
|
||||||
profile_analysis=profile_analysis.full_analysis,
|
profile_analysis=profile_analysis.full_analysis,
|
||||||
existing_topics=existing_topics,
|
existing_topics=existing_topics,
|
||||||
customer_data=customer.metadata,
|
customer_data=profile.metadata,
|
||||||
example_posts=example_post_texts,
|
example_posts=example_post_texts,
|
||||||
post_type=post_type,
|
post_type=post_type,
|
||||||
post_type_analysis=post_type_analysis
|
post_type_analysis=post_type_analysis
|
||||||
@@ -386,8 +407,8 @@ class WorkflowOrchestrator:
|
|||||||
report_progress("Speichere Ergebnisse...", 4)
|
report_progress("Speichere Ergebnisse...", 4)
|
||||||
from src.database.models import ResearchResult
|
from src.database.models import ResearchResult
|
||||||
research_record = ResearchResult(
|
research_record = ResearchResult(
|
||||||
customer_id=customer_id,
|
user_id=user_id,
|
||||||
query=f"New topics for {customer.name}" + (f" ({post_type.name})" if post_type else ""),
|
query=f"New topics for {profile.display_name}" + (f" ({post_type.name})" if post_type else ""),
|
||||||
results={"raw_response": research_results["raw_response"]},
|
results={"raw_response": research_results["raw_response"]},
|
||||||
suggested_topics=research_results["suggested_topics"],
|
suggested_topics=research_results["suggested_topics"],
|
||||||
target_post_type_id=post_type_id
|
target_post_type_id=post_type_id
|
||||||
@@ -397,19 +418,135 @@ class WorkflowOrchestrator:
|
|||||||
|
|
||||||
return research_results["suggested_topics"]
|
return research_results["suggested_topics"]
|
||||||
|
|
||||||
|
async def generate_hooks(
|
||||||
|
self,
|
||||||
|
user_id: UUID,
|
||||||
|
topic: Dict[str, Any],
|
||||||
|
user_thoughts: str = "",
|
||||||
|
post_type_id: Optional[UUID] = None
|
||||||
|
) -> List[Dict[str, str]]:
|
||||||
|
"""
|
||||||
|
Generate 4 hook options for a topic.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: User UUID
|
||||||
|
topic: Topic dictionary
|
||||||
|
user_thoughts: User's personal thoughts about the topic
|
||||||
|
post_type_id: Optional post type for context
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of {"hook": "...", "style": "..."} dictionaries
|
||||||
|
"""
|
||||||
|
logger.info(f"=== GENERATING HOOKS for topic: {topic.get('title')} ===")
|
||||||
|
ids = await self._resolve_tracking_ids(user_id)
|
||||||
|
self._set_tracking("generate_hooks", **ids)
|
||||||
|
|
||||||
|
# Get profile analysis for style matching
|
||||||
|
profile_analysis = await db.get_profile_analysis(user_id)
|
||||||
|
if not profile_analysis:
|
||||||
|
raise ValueError("Profile analysis not found. Run initial setup first.")
|
||||||
|
|
||||||
|
# Get post type context if specified
|
||||||
|
post_type = None
|
||||||
|
if post_type_id:
|
||||||
|
post_type = await db.get_post_type(post_type_id)
|
||||||
|
|
||||||
|
# Generate hooks via writer agent
|
||||||
|
hooks = await self.writer.generate_hooks(
|
||||||
|
topic=topic,
|
||||||
|
profile_analysis=profile_analysis.full_analysis,
|
||||||
|
user_thoughts=user_thoughts,
|
||||||
|
post_type=post_type
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(f"Generated {len(hooks)} hooks")
|
||||||
|
return hooks
|
||||||
|
|
||||||
|
async def generate_improvement_suggestions(
|
||||||
|
self,
|
||||||
|
user_id: UUID,
|
||||||
|
post_content: str,
|
||||||
|
critic_feedback: Optional[Dict[str, Any]] = None
|
||||||
|
) -> List[Dict[str, str]]:
|
||||||
|
"""
|
||||||
|
Generate improvement suggestions for an existing post.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: User UUID
|
||||||
|
post_content: The current post content
|
||||||
|
critic_feedback: Optional feedback from the critic
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of {"label": "...", "action": "..."} dictionaries
|
||||||
|
"""
|
||||||
|
logger.info("=== GENERATING IMPROVEMENT SUGGESTIONS ===")
|
||||||
|
ids = await self._resolve_tracking_ids(user_id)
|
||||||
|
self._set_tracking("improvement_suggestions", **ids)
|
||||||
|
|
||||||
|
# Get profile analysis for style matching
|
||||||
|
profile_analysis = await db.get_profile_analysis(user_id)
|
||||||
|
if not profile_analysis:
|
||||||
|
raise ValueError("Profile analysis not found.")
|
||||||
|
|
||||||
|
suggestions = await self.writer.generate_improvement_suggestions(
|
||||||
|
post_content=post_content,
|
||||||
|
profile_analysis=profile_analysis.full_analysis,
|
||||||
|
critic_feedback=critic_feedback
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(f"Generated {len(suggestions)} improvement suggestions")
|
||||||
|
return suggestions
|
||||||
|
|
||||||
|
async def apply_suggestion_to_post(
|
||||||
|
self,
|
||||||
|
user_id: UUID,
|
||||||
|
post_content: str,
|
||||||
|
suggestion: str
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
Apply a suggestion to a post and return the improved version.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: User UUID
|
||||||
|
post_content: The current post content
|
||||||
|
suggestion: The suggestion to apply
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The improved post content
|
||||||
|
"""
|
||||||
|
logger.info(f"=== APPLYING SUGGESTION TO POST ===")
|
||||||
|
ids = await self._resolve_tracking_ids(user_id)
|
||||||
|
self._set_tracking("apply_suggestion", **ids)
|
||||||
|
|
||||||
|
# Get profile analysis for style matching
|
||||||
|
profile_analysis = await db.get_profile_analysis(user_id)
|
||||||
|
if not profile_analysis:
|
||||||
|
raise ValueError("Profile analysis not found.")
|
||||||
|
|
||||||
|
improved_post = await self.writer.apply_suggestion(
|
||||||
|
post_content=post_content,
|
||||||
|
suggestion=suggestion,
|
||||||
|
profile_analysis=profile_analysis.full_analysis
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info("Successfully applied suggestion to post")
|
||||||
|
return improved_post
|
||||||
|
|
||||||
async def create_post(
|
async def create_post(
|
||||||
self,
|
self,
|
||||||
customer_id: UUID,
|
user_id: UUID,
|
||||||
topic: Dict[str, Any],
|
topic: Dict[str, Any],
|
||||||
max_iterations: int = 3,
|
max_iterations: int = 3,
|
||||||
progress_callback: Optional[Callable[[str, int, int, Optional[int], Optional[List], Optional[List]], None]] = None,
|
progress_callback: Optional[Callable[[str, int, int, Optional[int], Optional[List], Optional[List]], None]] = None,
|
||||||
post_type_id: Optional[UUID] = None
|
post_type_id: Optional[UUID] = None,
|
||||||
|
user_thoughts: str = "",
|
||||||
|
selected_hook: str = ""
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Create a LinkedIn post through writer-critic iteration.
|
Create a LinkedIn post through writer-critic iteration.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
customer_id: Customer UUID
|
user_id: User UUID
|
||||||
topic: Topic dictionary
|
topic: Topic dictionary
|
||||||
max_iterations: Maximum number of writer-critic iterations
|
max_iterations: Maximum number of writer-critic iterations
|
||||||
progress_callback: Optional callback(message, iteration, max_iterations, score, versions, feedback_list)
|
progress_callback: Optional callback(message, iteration, max_iterations, score, versions, feedback_list)
|
||||||
@@ -419,6 +556,8 @@ class WorkflowOrchestrator:
|
|||||||
Dictionary with final post and metadata
|
Dictionary with final post and metadata
|
||||||
"""
|
"""
|
||||||
logger.info(f"=== CREATING POST for topic: {topic.get('title')} ===")
|
logger.info(f"=== CREATING POST for topic: {topic.get('title')} ===")
|
||||||
|
ids = await self._resolve_tracking_ids(user_id)
|
||||||
|
self._set_tracking("post_creation", **ids)
|
||||||
|
|
||||||
def report_progress(message: str, iteration: int, score: Optional[int] = None,
|
def report_progress(message: str, iteration: int, score: Optional[int] = None,
|
||||||
versions: Optional[List] = None, feedback_list: Optional[List] = None):
|
versions: Optional[List] = None, feedback_list: Optional[List] = None):
|
||||||
@@ -427,7 +566,7 @@ class WorkflowOrchestrator:
|
|||||||
|
|
||||||
# Get profile analysis
|
# Get profile analysis
|
||||||
report_progress("Lade Profil-Analyse...", 0, None, [], [])
|
report_progress("Lade Profil-Analyse...", 0, None, [], [])
|
||||||
profile_analysis = await db.get_profile_analysis(customer_id)
|
profile_analysis = await db.get_profile_analysis(user_id)
|
||||||
if not profile_analysis:
|
if not profile_analysis:
|
||||||
raise ValueError("Profile analysis not found. Run initial setup first.")
|
raise ValueError("Profile analysis not found. Run initial setup first.")
|
||||||
|
|
||||||
@@ -440,16 +579,16 @@ class WorkflowOrchestrator:
|
|||||||
post_type_analysis = post_type.analysis
|
post_type_analysis = post_type.analysis
|
||||||
logger.info(f"Using post type '{post_type.name}' for writing")
|
logger.info(f"Using post type '{post_type.name}' for writing")
|
||||||
|
|
||||||
# Load customer's real posts as style examples
|
# Load user's real posts as style examples
|
||||||
# If post_type_id is specified, only use posts of that type
|
# If post_type_id is specified, only use posts of that type
|
||||||
if post_type_id:
|
if post_type_id:
|
||||||
linkedin_posts = await db.get_posts_by_type(customer_id, post_type_id)
|
linkedin_posts = await db.get_posts_by_type(user_id, post_type_id)
|
||||||
if len(linkedin_posts) < 3:
|
if len(linkedin_posts) < 3:
|
||||||
# Fall back to all posts if not enough type-specific posts
|
# Fall back to all posts if not enough type-specific posts
|
||||||
linkedin_posts = await db.get_linkedin_posts(customer_id)
|
linkedin_posts = await db.get_linkedin_posts(user_id)
|
||||||
logger.info("Not enough type-specific posts, using all posts")
|
logger.info("Not enough type-specific posts, using all posts")
|
||||||
else:
|
else:
|
||||||
linkedin_posts = await db.get_linkedin_posts(customer_id)
|
linkedin_posts = await db.get_linkedin_posts(user_id)
|
||||||
|
|
||||||
example_post_texts = [
|
example_post_texts = [
|
||||||
post.post_text for post in linkedin_posts
|
post.post_text for post in linkedin_posts
|
||||||
@@ -458,7 +597,7 @@ class WorkflowOrchestrator:
|
|||||||
logger.info(f"Loaded {len(example_post_texts)} example posts for style reference")
|
logger.info(f"Loaded {len(example_post_texts)} example posts for style reference")
|
||||||
|
|
||||||
# Extract lessons from past feedback (if enabled)
|
# Extract lessons from past feedback (if enabled)
|
||||||
feedback_lessons = await self._extract_recurring_feedback(customer_id)
|
feedback_lessons = await self._extract_recurring_feedback(user_id)
|
||||||
|
|
||||||
# Initialize tracking
|
# Initialize tracking
|
||||||
writer_versions = []
|
writer_versions = []
|
||||||
@@ -467,6 +606,15 @@ class WorkflowOrchestrator:
|
|||||||
approved = False
|
approved = False
|
||||||
iteration = 0
|
iteration = 0
|
||||||
|
|
||||||
|
# Load company strategy if user belongs to a company
|
||||||
|
company_strategy = None
|
||||||
|
profile = await db.get_profile(user_id)
|
||||||
|
if profile and profile.company_id:
|
||||||
|
company = await db.get_company(profile.company_id)
|
||||||
|
if company and company.company_strategy:
|
||||||
|
company_strategy = company.company_strategy
|
||||||
|
logger.info(f"Loaded company strategy for post creation: {company.name}")
|
||||||
|
|
||||||
# Writer-Critic loop
|
# Writer-Critic loop
|
||||||
while iteration < max_iterations and not approved:
|
while iteration < max_iterations and not approved:
|
||||||
iteration += 1
|
iteration += 1
|
||||||
@@ -482,7 +630,10 @@ class WorkflowOrchestrator:
|
|||||||
example_posts=example_post_texts,
|
example_posts=example_post_texts,
|
||||||
learned_lessons=feedback_lessons, # Pass lessons from past feedback
|
learned_lessons=feedback_lessons, # Pass lessons from past feedback
|
||||||
post_type=post_type,
|
post_type=post_type,
|
||||||
post_type_analysis=post_type_analysis
|
post_type_analysis=post_type_analysis,
|
||||||
|
user_thoughts=user_thoughts,
|
||||||
|
selected_hook=selected_hook,
|
||||||
|
company_strategy=company_strategy # NEW: Pass company strategy
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
# Revision based on feedback - pass full critic result for structured changes
|
# Revision based on feedback - pass full critic result for structured changes
|
||||||
@@ -497,7 +648,10 @@ class WorkflowOrchestrator:
|
|||||||
critic_result=last_feedback, # Pass full critic result with specific_changes
|
critic_result=last_feedback, # Pass full critic result with specific_changes
|
||||||
learned_lessons=feedback_lessons, # Also for revisions
|
learned_lessons=feedback_lessons, # Also for revisions
|
||||||
post_type=post_type,
|
post_type=post_type,
|
||||||
post_type_analysis=post_type_analysis
|
post_type_analysis=post_type_analysis,
|
||||||
|
user_thoughts=user_thoughts,
|
||||||
|
selected_hook=selected_hook,
|
||||||
|
company_strategy=company_strategy # NEW: Pass company strategy
|
||||||
)
|
)
|
||||||
|
|
||||||
writer_versions.append(current_post)
|
writer_versions.append(current_post)
|
||||||
@@ -538,19 +692,14 @@ class WorkflowOrchestrator:
|
|||||||
if iteration < max_iterations:
|
if iteration < max_iterations:
|
||||||
logger.info("Post needs revision, continuing...")
|
logger.info("Post needs revision, continuing...")
|
||||||
|
|
||||||
# Determine final status based on score
|
# All new posts start as draft - user moves them via Kanban board
|
||||||
final_score = critic_feedback_list[-1].get("overall_score", 0) if critic_feedback_list else 0
|
final_score = critic_feedback_list[-1].get("overall_score", 0) if critic_feedback_list else 0
|
||||||
if approved and final_score >= 85:
|
status = "draft"
|
||||||
status = "approved"
|
|
||||||
elif approved and final_score >= 80:
|
|
||||||
status = "approved" # Auto-approved
|
|
||||||
else:
|
|
||||||
status = "draft"
|
|
||||||
|
|
||||||
# Save generated post
|
# Save generated post
|
||||||
from src.database.models import GeneratedPost
|
from src.database.models import GeneratedPost
|
||||||
generated_post = GeneratedPost(
|
generated_post = GeneratedPost(
|
||||||
customer_id=customer_id,
|
user_id=user_id,
|
||||||
topic_title=topic.get("title", "Unknown"),
|
topic_title=topic.get("title", "Unknown"),
|
||||||
post_content=current_post,
|
post_content=current_post,
|
||||||
iterations=iteration,
|
iterations=iteration,
|
||||||
@@ -573,12 +722,12 @@ class WorkflowOrchestrator:
|
|||||||
"critic_feedback": critic_feedback_list
|
"critic_feedback": critic_feedback_list
|
||||||
}
|
}
|
||||||
|
|
||||||
async def _extract_recurring_feedback(self, customer_id: UUID) -> Dict[str, Any]:
|
async def _extract_recurring_feedback(self, user_id: UUID) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Extract recurring feedback patterns from past generated posts.
|
Extract recurring feedback patterns from past generated posts.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
customer_id: Customer UUID
|
user_id: User UUID
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dictionary with recurring improvements and lessons learned
|
Dictionary with recurring improvements and lessons learned
|
||||||
@@ -587,7 +736,7 @@ class WorkflowOrchestrator:
|
|||||||
return {"lessons": [], "patterns": {}}
|
return {"lessons": [], "patterns": {}}
|
||||||
|
|
||||||
# Get recent generated posts with their critic feedback
|
# Get recent generated posts with their critic feedback
|
||||||
generated_posts = await db.get_generated_posts(customer_id)
|
generated_posts = await db.get_generated_posts(user_id)
|
||||||
|
|
||||||
if not generated_posts:
|
if not generated_posts:
|
||||||
return {"lessons": [], "patterns": {}}
|
return {"lessons": [], "patterns": {}}
|
||||||
@@ -683,26 +832,26 @@ class WorkflowOrchestrator:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async def get_customer_status(self, customer_id: UUID) -> Dict[str, Any]:
|
async def get_user_status(self, user_id: UUID) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Get status information for a customer.
|
Get status information for a user.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
customer_id: Customer UUID
|
user_id: User UUID
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Status dictionary
|
Status dictionary
|
||||||
"""
|
"""
|
||||||
customer = await db.get_customer(customer_id)
|
profile = await db.get_profile(user_id)
|
||||||
if not customer:
|
if not profile:
|
||||||
raise ValueError("Customer not found")
|
raise ValueError("User not found")
|
||||||
|
|
||||||
profile = await db.get_linkedin_profile(customer_id)
|
linkedin_profile = await db.get_linkedin_profile(user_id)
|
||||||
posts = await db.get_linkedin_posts(customer_id)
|
posts = await db.get_linkedin_posts(user_id)
|
||||||
analysis = await db.get_profile_analysis(customer_id)
|
analysis = await db.get_profile_analysis(user_id)
|
||||||
generated_posts = await db.get_generated_posts(customer_id)
|
generated_posts = await db.get_generated_posts(user_id)
|
||||||
all_research = await db.get_all_research(customer_id)
|
all_research = await db.get_all_research(user_id)
|
||||||
post_types = await db.get_post_types(customer_id)
|
post_types = await db.get_post_types(user_id)
|
||||||
|
|
||||||
# Count total research entries
|
# Count total research entries
|
||||||
research_count = len(all_research)
|
research_count = len(all_research)
|
||||||
|
|||||||
0
src/services/__init__.py
Normal file
0
src/services/__init__.py
Normal file
481
src/services/background_jobs.py
Normal file
481
src/services/background_jobs.py
Normal file
@@ -0,0 +1,481 @@
|
|||||||
|
"""Background job system for running AI analyses without blocking the UI."""
|
||||||
|
import asyncio
|
||||||
|
from datetime import datetime
|
||||||
|
from enum import Enum
|
||||||
|
from typing import Dict, Any, Optional, Callable, Awaitable
|
||||||
|
from uuid import UUID, uuid4
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
|
||||||
|
class JobStatus(str, Enum):
|
||||||
|
PENDING = "pending"
|
||||||
|
RUNNING = "running"
|
||||||
|
COMPLETED = "completed"
|
||||||
|
FAILED = "failed"
|
||||||
|
|
||||||
|
|
||||||
|
class JobType(str, Enum):
|
||||||
|
POST_SCRAPING = "post_scraping"
|
||||||
|
PROFILE_ANALYSIS = "profile_analysis"
|
||||||
|
POST_CATEGORIZATION = "post_categorization"
|
||||||
|
POST_TYPE_ANALYSIS = "post_type_analysis"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class BackgroundJob:
|
||||||
|
id: str
|
||||||
|
job_type: JobType
|
||||||
|
user_id: str
|
||||||
|
status: JobStatus = JobStatus.PENDING
|
||||||
|
progress: int = 0
|
||||||
|
message: str = ""
|
||||||
|
created_at: datetime = field(default_factory=datetime.utcnow)
|
||||||
|
started_at: Optional[datetime] = None
|
||||||
|
completed_at: Optional[datetime] = None
|
||||||
|
error: Optional[str] = None
|
||||||
|
result: Optional[Dict[str, Any]] = None
|
||||||
|
|
||||||
|
|
||||||
|
class BackgroundJobManager:
|
||||||
|
"""Manages background jobs for AI analyses."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._jobs: Dict[str, BackgroundJob] = {}
|
||||||
|
self._user_jobs: Dict[str, list] = {} # user_id -> list of job_ids
|
||||||
|
self._listeners: Dict[str, list] = {} # user_id -> list of callbacks
|
||||||
|
|
||||||
|
def create_job(self, job_type: JobType, user_id: str) -> BackgroundJob:
|
||||||
|
"""Create a new background job."""
|
||||||
|
job_id = str(uuid4())
|
||||||
|
job = BackgroundJob(
|
||||||
|
id=job_id,
|
||||||
|
job_type=job_type,
|
||||||
|
user_id=user_id
|
||||||
|
)
|
||||||
|
self._jobs[job_id] = job
|
||||||
|
|
||||||
|
if user_id not in self._user_jobs:
|
||||||
|
self._user_jobs[user_id] = []
|
||||||
|
self._user_jobs[user_id].append(job_id)
|
||||||
|
|
||||||
|
logger.info(f"Created background job {job_id} of type {job_type} for user {user_id}")
|
||||||
|
return job
|
||||||
|
|
||||||
|
def get_job(self, job_id: str) -> Optional[BackgroundJob]:
|
||||||
|
"""Get a job by ID."""
|
||||||
|
return self._jobs.get(job_id)
|
||||||
|
|
||||||
|
def get_user_jobs(self, user_id: str) -> list[BackgroundJob]:
|
||||||
|
"""Get all jobs for a user."""
|
||||||
|
job_ids = self._user_jobs.get(user_id, [])
|
||||||
|
return [self._jobs[jid] for jid in job_ids if jid in self._jobs]
|
||||||
|
|
||||||
|
def get_active_jobs(self, user_id: str) -> list[BackgroundJob]:
|
||||||
|
"""Get running/pending jobs for a user."""
|
||||||
|
return [
|
||||||
|
j for j in self.get_user_jobs(user_id)
|
||||||
|
if j.status in (JobStatus.PENDING, JobStatus.RUNNING)
|
||||||
|
]
|
||||||
|
|
||||||
|
async def update_job(
|
||||||
|
self,
|
||||||
|
job_id: str,
|
||||||
|
status: Optional[JobStatus] = None,
|
||||||
|
progress: Optional[int] = None,
|
||||||
|
message: Optional[str] = None,
|
||||||
|
error: Optional[str] = None,
|
||||||
|
result: Optional[Dict[str, Any]] = None
|
||||||
|
):
|
||||||
|
"""Update a job's status and notify listeners."""
|
||||||
|
job = self._jobs.get(job_id)
|
||||||
|
if not job:
|
||||||
|
return
|
||||||
|
|
||||||
|
if status:
|
||||||
|
job.status = status
|
||||||
|
if status == JobStatus.RUNNING and not job.started_at:
|
||||||
|
job.started_at = datetime.utcnow()
|
||||||
|
elif status in (JobStatus.COMPLETED, JobStatus.FAILED):
|
||||||
|
job.completed_at = datetime.utcnow()
|
||||||
|
|
||||||
|
if progress is not None:
|
||||||
|
job.progress = progress
|
||||||
|
if message:
|
||||||
|
job.message = message
|
||||||
|
if error:
|
||||||
|
job.error = error
|
||||||
|
if result:
|
||||||
|
job.result = result
|
||||||
|
|
||||||
|
# Notify listeners
|
||||||
|
await self._notify_listeners(job.user_id, job)
|
||||||
|
|
||||||
|
async def _notify_listeners(self, user_id: str, job: BackgroundJob):
|
||||||
|
"""Notify all listeners for a user about job updates."""
|
||||||
|
listeners = self._listeners.get(user_id, [])
|
||||||
|
for callback in listeners:
|
||||||
|
try:
|
||||||
|
await callback(job)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error notifying listener: {e}")
|
||||||
|
|
||||||
|
def add_listener(self, user_id: str, callback: Callable[[BackgroundJob], Awaitable[None]]):
|
||||||
|
"""Add a listener for job updates."""
|
||||||
|
if user_id not in self._listeners:
|
||||||
|
self._listeners[user_id] = []
|
||||||
|
self._listeners[user_id].append(callback)
|
||||||
|
|
||||||
|
def remove_listener(self, user_id: str, callback: Callable[[BackgroundJob], Awaitable[None]]):
|
||||||
|
"""Remove a listener."""
|
||||||
|
if user_id in self._listeners:
|
||||||
|
try:
|
||||||
|
self._listeners[user_id].remove(callback)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def cleanup_old_jobs(self, max_age_hours: int = 24):
|
||||||
|
"""Remove completed jobs older than max_age_hours."""
|
||||||
|
cutoff = datetime.utcnow()
|
||||||
|
to_remove = []
|
||||||
|
|
||||||
|
for job_id, job in self._jobs.items():
|
||||||
|
if job.completed_at:
|
||||||
|
age = (cutoff - job.completed_at).total_seconds() / 3600
|
||||||
|
if age > max_age_hours:
|
||||||
|
to_remove.append(job_id)
|
||||||
|
|
||||||
|
for job_id in to_remove:
|
||||||
|
job = self._jobs.pop(job_id, None)
|
||||||
|
if job:
|
||||||
|
user_jobs = self._user_jobs.get(job.user_id, [])
|
||||||
|
if job_id in user_jobs:
|
||||||
|
user_jobs.remove(job_id)
|
||||||
|
|
||||||
|
if to_remove:
|
||||||
|
logger.info(f"Cleaned up {len(to_remove)} old background jobs")
|
||||||
|
|
||||||
|
|
||||||
|
# Global instance
|
||||||
|
job_manager = BackgroundJobManager()
|
||||||
|
|
||||||
|
|
||||||
|
async def run_post_scraping(user_id: UUID, linkedin_url: str, job_id: str):
|
||||||
|
"""Run LinkedIn post scraping in background."""
|
||||||
|
from src.database.client import DatabaseClient
|
||||||
|
from src.scraper import scraper
|
||||||
|
from src.database.models import LinkedInPost
|
||||||
|
|
||||||
|
db = DatabaseClient()
|
||||||
|
|
||||||
|
try:
|
||||||
|
await job_manager.update_job(
|
||||||
|
job_id,
|
||||||
|
status=JobStatus.RUNNING,
|
||||||
|
progress=10,
|
||||||
|
message="Starte LinkedIn-Scraping..."
|
||||||
|
)
|
||||||
|
|
||||||
|
# Scrape posts
|
||||||
|
await job_manager.update_job(
|
||||||
|
job_id,
|
||||||
|
progress=30,
|
||||||
|
message="Lade Posts von LinkedIn..."
|
||||||
|
)
|
||||||
|
|
||||||
|
raw_posts = await scraper.scrape_posts(linkedin_url, limit=50)
|
||||||
|
parsed_posts = scraper.parse_posts_data(raw_posts)
|
||||||
|
|
||||||
|
await job_manager.update_job(
|
||||||
|
job_id,
|
||||||
|
progress=60,
|
||||||
|
message=f"{len(parsed_posts)} Posts gefunden, speichere..."
|
||||||
|
)
|
||||||
|
|
||||||
|
linkedin_posts = []
|
||||||
|
profile_picture = None
|
||||||
|
|
||||||
|
for post_data in parsed_posts:
|
||||||
|
post = LinkedInPost(user_id=user_id, **post_data)
|
||||||
|
linkedin_posts.append(post)
|
||||||
|
|
||||||
|
# Extract profile picture from first post with author data
|
||||||
|
if not profile_picture and post_data.get("raw_data"):
|
||||||
|
author = post_data["raw_data"].get("author", {})
|
||||||
|
if author and isinstance(author, dict):
|
||||||
|
profile_picture = author.get("profile_picture")
|
||||||
|
|
||||||
|
if linkedin_posts:
|
||||||
|
await db.save_linkedin_posts(linkedin_posts)
|
||||||
|
|
||||||
|
# Save profile picture to profile
|
||||||
|
if profile_picture:
|
||||||
|
await db.update_profile(user_id, {"profile_picture": profile_picture})
|
||||||
|
|
||||||
|
await job_manager.update_job(
|
||||||
|
job_id,
|
||||||
|
status=JobStatus.COMPLETED,
|
||||||
|
progress=100,
|
||||||
|
message=f"{len(linkedin_posts)} Posts gespeichert!",
|
||||||
|
result={"posts_count": len(linkedin_posts), "profile_picture": profile_picture}
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(f"Post scraping completed for user {user_id}: {len(linkedin_posts)} posts")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Post scraping failed: {e}")
|
||||||
|
await job_manager.update_job(
|
||||||
|
job_id,
|
||||||
|
status=JobStatus.FAILED,
|
||||||
|
error=str(e)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def run_profile_analysis(user_id: UUID, job_id: str):
|
||||||
|
"""Run profile analysis in background."""
|
||||||
|
from src.database.client import DatabaseClient
|
||||||
|
from src.agents.profile_analyzer import ProfileAnalyzerAgent
|
||||||
|
from src.database.models import ProfileAnalysis
|
||||||
|
|
||||||
|
db = DatabaseClient()
|
||||||
|
|
||||||
|
try:
|
||||||
|
await job_manager.update_job(
|
||||||
|
job_id,
|
||||||
|
status=JobStatus.RUNNING,
|
||||||
|
progress=10,
|
||||||
|
message="Lade LinkedIn-Posts..."
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get posts and profile
|
||||||
|
posts = await db.get_linkedin_posts(user_id)
|
||||||
|
if not posts:
|
||||||
|
await job_manager.update_job(
|
||||||
|
job_id,
|
||||||
|
status=JobStatus.FAILED,
|
||||||
|
error="Keine Posts gefunden"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
linkedin_profile = await db.get_linkedin_profile(user_id)
|
||||||
|
user_profile = await db.get_profile(user_id)
|
||||||
|
|
||||||
|
await job_manager.update_job(
|
||||||
|
job_id,
|
||||||
|
progress=30,
|
||||||
|
message=f"Analysiere {len(posts)} Posts..."
|
||||||
|
)
|
||||||
|
|
||||||
|
# Run analysis
|
||||||
|
analyzer = ProfileAnalyzerAgent()
|
||||||
|
|
||||||
|
# Prepare user data
|
||||||
|
user_data = {
|
||||||
|
"name": user_profile.display_name if user_profile else "",
|
||||||
|
"company": "",
|
||||||
|
"writing_style_notes": user_profile.writing_style_notes if user_profile else ""
|
||||||
|
}
|
||||||
|
|
||||||
|
# Create a minimal linkedin profile if none exists
|
||||||
|
if not linkedin_profile:
|
||||||
|
from src.database.models import LinkedInProfile
|
||||||
|
linkedin_profile = LinkedInProfile(
|
||||||
|
user_id=user_id,
|
||||||
|
profile_data={},
|
||||||
|
name=user_profile.display_name if user_profile else ""
|
||||||
|
)
|
||||||
|
|
||||||
|
analysis_result = await analyzer.process(linkedin_profile, posts, user_data)
|
||||||
|
|
||||||
|
await job_manager.update_job(
|
||||||
|
job_id,
|
||||||
|
progress=80,
|
||||||
|
message="Speichere Analyse..."
|
||||||
|
)
|
||||||
|
|
||||||
|
# Save analysis
|
||||||
|
profile_analysis = ProfileAnalysis(
|
||||||
|
user_id=user_id,
|
||||||
|
writing_style=analysis_result.get("writing_style", {}),
|
||||||
|
tone_analysis=analysis_result.get("tone_analysis", {}),
|
||||||
|
topic_patterns=analysis_result.get("topic_patterns", {}),
|
||||||
|
audience_insights=analysis_result.get("audience_insights", {}),
|
||||||
|
full_analysis=analysis_result
|
||||||
|
)
|
||||||
|
await db.save_profile_analysis(profile_analysis)
|
||||||
|
|
||||||
|
await job_manager.update_job(
|
||||||
|
job_id,
|
||||||
|
status=JobStatus.COMPLETED,
|
||||||
|
progress=100,
|
||||||
|
message="Profil-Analyse abgeschlossen!"
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(f"Profile analysis completed for user {user_id}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Profile analysis failed: {e}")
|
||||||
|
await job_manager.update_job(
|
||||||
|
job_id,
|
||||||
|
status=JobStatus.FAILED,
|
||||||
|
error=str(e)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def run_post_categorization(user_id: UUID, job_id: str):
|
||||||
|
"""Run automatic post categorization in background."""
|
||||||
|
from src.database.client import DatabaseClient
|
||||||
|
from src.agents.post_classifier import PostClassifierAgent
|
||||||
|
|
||||||
|
db = DatabaseClient()
|
||||||
|
|
||||||
|
try:
|
||||||
|
await job_manager.update_job(
|
||||||
|
job_id,
|
||||||
|
status=JobStatus.RUNNING,
|
||||||
|
progress=10,
|
||||||
|
message="Lade Posts und Typen..."
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get posts and types
|
||||||
|
posts = await db.get_unclassified_posts(user_id)
|
||||||
|
post_types = await db.get_post_types(user_id)
|
||||||
|
|
||||||
|
if not posts:
|
||||||
|
await job_manager.update_job(
|
||||||
|
job_id,
|
||||||
|
status=JobStatus.COMPLETED,
|
||||||
|
progress=100,
|
||||||
|
message="Alle Posts sind bereits kategorisiert!"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
if not post_types:
|
||||||
|
await job_manager.update_job(
|
||||||
|
job_id,
|
||||||
|
status=JobStatus.FAILED,
|
||||||
|
error="Keine Post-Typen definiert"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
await job_manager.update_job(
|
||||||
|
job_id,
|
||||||
|
progress=30,
|
||||||
|
message=f"Kategorisiere {len(posts)} Posts..."
|
||||||
|
)
|
||||||
|
|
||||||
|
# Run classification
|
||||||
|
classifier = PostClassifierAgent()
|
||||||
|
classifications = await classifier.process(posts, post_types)
|
||||||
|
|
||||||
|
await job_manager.update_job(
|
||||||
|
job_id,
|
||||||
|
progress=70,
|
||||||
|
message="Speichere Kategorisierungen..."
|
||||||
|
)
|
||||||
|
|
||||||
|
# Save classifications
|
||||||
|
if classifications:
|
||||||
|
await db.update_posts_classification_bulk(classifications)
|
||||||
|
|
||||||
|
await job_manager.update_job(
|
||||||
|
job_id,
|
||||||
|
status=JobStatus.COMPLETED,
|
||||||
|
progress=100,
|
||||||
|
message=f"{len(classifications)} Posts kategorisiert!"
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(f"Post categorization completed for user {user_id}: {len(classifications)} posts")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Post categorization failed: {e}")
|
||||||
|
await job_manager.update_job(
|
||||||
|
job_id,
|
||||||
|
status=JobStatus.FAILED,
|
||||||
|
error=str(e)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def run_post_type_analysis(user_id: UUID, job_id: str):
|
||||||
|
"""Run post type analysis in background."""
|
||||||
|
from src.database.client import DatabaseClient
|
||||||
|
from src.agents.post_type_analyzer import PostTypeAnalyzerAgent
|
||||||
|
|
||||||
|
db = DatabaseClient()
|
||||||
|
|
||||||
|
try:
|
||||||
|
await job_manager.update_job(
|
||||||
|
job_id,
|
||||||
|
status=JobStatus.RUNNING,
|
||||||
|
progress=10,
|
||||||
|
message="Lade Post-Typen..."
|
||||||
|
)
|
||||||
|
|
||||||
|
post_types = await db.get_post_types(user_id)
|
||||||
|
|
||||||
|
if not post_types:
|
||||||
|
await job_manager.update_job(
|
||||||
|
job_id,
|
||||||
|
status=JobStatus.FAILED,
|
||||||
|
error="Keine Post-Typen definiert"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
analyzer = PostTypeAnalyzerAgent()
|
||||||
|
total = len(post_types)
|
||||||
|
|
||||||
|
for i, post_type in enumerate(post_types):
|
||||||
|
await job_manager.update_job(
|
||||||
|
job_id,
|
||||||
|
progress=int(10 + (80 * i / total)),
|
||||||
|
message=f"Analysiere '{post_type.name}'..."
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get posts for this type
|
||||||
|
posts = await db.get_posts_by_type(user_id, post_type.id)
|
||||||
|
|
||||||
|
if posts:
|
||||||
|
analysis = await analyzer.process(post_type, posts)
|
||||||
|
await db.update_post_type_analysis(post_type.id, analysis, len(posts))
|
||||||
|
|
||||||
|
await job_manager.update_job(
|
||||||
|
job_id,
|
||||||
|
status=JobStatus.COMPLETED,
|
||||||
|
progress=100,
|
||||||
|
message=f"{total} Post-Typen analysiert!"
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(f"Post type analysis completed for user {user_id}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Post type analysis failed: {e}")
|
||||||
|
await job_manager.update_job(
|
||||||
|
job_id,
|
||||||
|
status=JobStatus.FAILED,
|
||||||
|
error=str(e)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def run_full_analysis_pipeline(user_id: UUID):
|
||||||
|
"""Run the full analysis pipeline in sequence: Profile -> Categorization -> Post Types."""
|
||||||
|
logger.info(f"Starting full analysis pipeline for user {user_id}")
|
||||||
|
|
||||||
|
# 1. Profile Analysis
|
||||||
|
job1 = job_manager.create_job(JobType.PROFILE_ANALYSIS, str(user_id))
|
||||||
|
await run_profile_analysis(user_id, job1.id)
|
||||||
|
|
||||||
|
if job1.status == JobStatus.FAILED:
|
||||||
|
logger.warning(f"Profile analysis failed, continuing with categorization")
|
||||||
|
|
||||||
|
# 2. Post Categorization
|
||||||
|
job2 = job_manager.create_job(JobType.POST_CATEGORIZATION, str(user_id))
|
||||||
|
await run_post_categorization(user_id, job2.id)
|
||||||
|
|
||||||
|
if job2.status == JobStatus.FAILED:
|
||||||
|
logger.warning(f"Post categorization failed, continuing with post type analysis")
|
||||||
|
|
||||||
|
# 3. Post Type Analysis
|
||||||
|
job3 = job_manager.create_job(JobType.POST_TYPE_ANALYSIS, str(user_id))
|
||||||
|
await run_post_type_analysis(user_id, job3.id)
|
||||||
|
|
||||||
|
logger.info(f"Full analysis pipeline completed for user {user_id}")
|
||||||
467
src/services/email_service.py
Normal file
467
src/services/email_service.py
Normal file
@@ -0,0 +1,467 @@
|
|||||||
|
"""Email service for sending post approval emails."""
|
||||||
|
import smtplib
|
||||||
|
import secrets
|
||||||
|
from email.mime.text import MIMEText
|
||||||
|
from email.mime.multipart import MIMEMultipart
|
||||||
|
from typing import Optional, Dict, Any
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from uuid import UUID
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
from src.config import settings
|
||||||
|
|
||||||
|
# In-memory token store (in production, use Redis or database)
|
||||||
|
_email_tokens: Dict[str, Dict[str, Any]] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def generate_token(post_id: UUID, action: str, expires_hours: int = 72) -> str:
|
||||||
|
"""Generate a unique token for email action."""
|
||||||
|
token = secrets.token_urlsafe(32)
|
||||||
|
_email_tokens[token] = {
|
||||||
|
"post_id": str(post_id),
|
||||||
|
"action": action,
|
||||||
|
"expires_at": datetime.utcnow() + timedelta(hours=expires_hours),
|
||||||
|
"used": False
|
||||||
|
}
|
||||||
|
return token
|
||||||
|
|
||||||
|
|
||||||
|
def validate_token(token: str) -> Optional[Dict[str, Any]]:
|
||||||
|
"""Validate and return token data if valid."""
|
||||||
|
if token not in _email_tokens:
|
||||||
|
return None
|
||||||
|
|
||||||
|
token_data = _email_tokens[token]
|
||||||
|
|
||||||
|
if token_data["used"]:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if datetime.utcnow() > token_data["expires_at"]:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return token_data
|
||||||
|
|
||||||
|
|
||||||
|
def mark_token_used(token: str):
|
||||||
|
"""Mark a token as used."""
|
||||||
|
if token in _email_tokens:
|
||||||
|
_email_tokens[token]["used"] = True
|
||||||
|
|
||||||
|
|
||||||
|
def send_email(to_email: str, subject: str, html_content: str) -> bool:
|
||||||
|
"""Send an email using SMTP."""
|
||||||
|
if not settings.smtp_host or not settings.smtp_user:
|
||||||
|
logger.warning("SMTP not configured, skipping email send")
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
msg = MIMEMultipart("alternative")
|
||||||
|
msg["Subject"] = subject
|
||||||
|
msg["From"] = f"{settings.smtp_from_name} <{settings.smtp_user}>"
|
||||||
|
msg["To"] = to_email
|
||||||
|
|
||||||
|
html_part = MIMEText(html_content, "html")
|
||||||
|
msg.attach(html_part)
|
||||||
|
|
||||||
|
with smtplib.SMTP(settings.smtp_host, settings.smtp_port) as server:
|
||||||
|
server.starttls()
|
||||||
|
server.login(settings.smtp_user, settings.smtp_password)
|
||||||
|
server.sendmail(settings.smtp_user, to_email, msg.as_string())
|
||||||
|
|
||||||
|
logger.info(f"Email sent to {to_email}: {subject}")
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to send email to {to_email}: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def send_approval_request_email(
|
||||||
|
to_email: str,
|
||||||
|
post_id: UUID,
|
||||||
|
post_title: str,
|
||||||
|
post_content: str,
|
||||||
|
base_url: str,
|
||||||
|
image_url: Optional[str] = None
|
||||||
|
) -> bool:
|
||||||
|
"""Send email to customer requesting approval of a post."""
|
||||||
|
approve_token = generate_token(post_id, "approve")
|
||||||
|
reject_token = generate_token(post_id, "reject")
|
||||||
|
|
||||||
|
approve_url = f"{base_url}/api/email-action/{approve_token}"
|
||||||
|
reject_url = f"{base_url}/api/email-action/{reject_token}"
|
||||||
|
|
||||||
|
html_content = f"""
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<style>
|
||||||
|
body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f5f5f5; padding: 20px; }}
|
||||||
|
.container {{ max-width: 600px; margin: 0 auto; background: white; border-radius: 12px; overflow: hidden; box-shadow: 0 2px 8px rgba(0,0,0,0.1); }}
|
||||||
|
.header {{ background: linear-gradient(135deg, #2d3838 0%, #1a2424 100%); color: white; padding: 24px; }}
|
||||||
|
.header h1 {{ margin: 0; font-size: 20px; }}
|
||||||
|
.content {{ padding: 24px; }}
|
||||||
|
.post-preview {{ background: #f8f9fa; border-left: 4px solid #ffc700; padding: 16px; margin: 20px 0; border-radius: 0 8px 8px 0; white-space: pre-wrap; font-size: 14px; line-height: 1.6; color: #333; }}
|
||||||
|
.buttons {{ display: flex; gap: 12px; margin-top: 24px; }}
|
||||||
|
.btn {{ display: inline-block; padding: 14px 28px; border-radius: 8px; text-decoration: none; font-weight: 600; font-size: 14px; text-align: center; }}
|
||||||
|
.btn-approve {{ background: #22c55e; color: white; }}
|
||||||
|
.btn-reject {{ background: #6b7280; color: white; }}
|
||||||
|
.footer {{ padding: 16px 24px; background: #f8f9fa; font-size: 12px; color: #666; text-align: center; }}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="header">
|
||||||
|
<h1>Neuer LinkedIn Post zur Freigabe</h1>
|
||||||
|
</div>
|
||||||
|
<div class="content">
|
||||||
|
<p>Hallo,</p>
|
||||||
|
<p>Ein neuer LinkedIn Post wurde erstellt und wartet auf deine Freigabe:</p>
|
||||||
|
|
||||||
|
<p><strong>{post_title}</strong></p>
|
||||||
|
|
||||||
|
<div class="post-preview">{post_content}</div>
|
||||||
|
|
||||||
|
{f'<img src="{image_url}" alt="Post-Bild" style="width: 100%; max-height: 400px; object-fit: cover; border-radius: 8px; margin: 16px 0;" />' if image_url else ''}
|
||||||
|
|
||||||
|
<p>Bitte entscheide, ob der Post veröffentlicht werden soll:</p>
|
||||||
|
|
||||||
|
<div class="buttons">
|
||||||
|
<a href="{approve_url}" class="btn btn-approve">Freigeben</a>
|
||||||
|
<a href="{reject_url}" class="btn btn-reject">Nochmal bearbeiten</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="footer">
|
||||||
|
Diese Email wurde automatisch generiert. Die Links sind 72 Stunden gültig.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
|
||||||
|
return send_email(
|
||||||
|
to_email=to_email,
|
||||||
|
subject=f"Post zur Freigabe: {post_title}",
|
||||||
|
html_content=html_content
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def send_invitation_email(
|
||||||
|
to_email: str,
|
||||||
|
company_name: str,
|
||||||
|
inviter_name: str,
|
||||||
|
token: str,
|
||||||
|
base_url: str
|
||||||
|
) -> bool:
|
||||||
|
"""Send invitation email to a new employee."""
|
||||||
|
invite_url = f"{base_url}/invite/{token}"
|
||||||
|
|
||||||
|
html_content = f"""
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<style>
|
||||||
|
body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f5f5f5; padding: 20px; }}
|
||||||
|
.container {{ max-width: 600px; margin: 0 auto; background: white; border-radius: 12px; overflow: hidden; box-shadow: 0 2px 8px rgba(0,0,0,0.1); }}
|
||||||
|
.header {{ background: linear-gradient(135deg, #2d3838 0%, #1a2424 100%); color: white; padding: 24px; }}
|
||||||
|
.header h1 {{ margin: 0; font-size: 20px; }}
|
||||||
|
.content {{ padding: 24px; }}
|
||||||
|
.company-badge {{ display: inline-block; padding: 8px 16px; border-radius: 20px; background: #ffc700; color: #1a2424; font-weight: 600; margin: 16px 0; }}
|
||||||
|
.btn {{ display: inline-block; padding: 14px 28px; border-radius: 8px; text-decoration: none; font-weight: 600; font-size: 14px; background: #ffc700; color: #1a2424; margin-top: 16px; }}
|
||||||
|
.footer {{ padding: 16px 24px; background: #f8f9fa; font-size: 12px; color: #666; text-align: center; }}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="header">
|
||||||
|
<h1>Du wurdest eingeladen!</h1>
|
||||||
|
</div>
|
||||||
|
<div class="content">
|
||||||
|
<p>Hallo,</p>
|
||||||
|
<p><strong>{inviter_name}</strong> hat dich eingeladen, dem Team beizutreten:</p>
|
||||||
|
|
||||||
|
<p class="company-badge">{company_name}</p>
|
||||||
|
|
||||||
|
<p>Als Teammitglied kannst du LinkedIn-Posts erstellen, die der Unternehmensstrategie entsprechen.</p>
|
||||||
|
|
||||||
|
<a href="{invite_url}" class="btn">Einladung annehmen</a>
|
||||||
|
|
||||||
|
<p style="margin-top: 24px; font-size: 14px; color: #666;">
|
||||||
|
Oder kopiere diesen Link in deinen Browser:<br>
|
||||||
|
<code style="font-size: 12px; color: #999;">{invite_url}</code>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="footer">
|
||||||
|
Diese Einladung ist 7 Tage gueltig.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
|
||||||
|
return send_email(
|
||||||
|
to_email=to_email,
|
||||||
|
subject=f"Einladung von {company_name}",
|
||||||
|
html_content=html_content
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def send_email_verification(
|
||||||
|
to_email: str,
|
||||||
|
token: str,
|
||||||
|
base_url: str
|
||||||
|
) -> bool:
|
||||||
|
"""Send email verification link."""
|
||||||
|
verify_url = f"{base_url}/verify-email/{token}"
|
||||||
|
|
||||||
|
html_content = f"""
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<style>
|
||||||
|
body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f5f5f5; padding: 20px; }}
|
||||||
|
.container {{ max-width: 600px; margin: 0 auto; background: white; border-radius: 12px; overflow: hidden; box-shadow: 0 2px 8px rgba(0,0,0,0.1); }}
|
||||||
|
.header {{ background: linear-gradient(135deg, #2d3838 0%, #1a2424 100%); color: white; padding: 24px; }}
|
||||||
|
.header h1 {{ margin: 0; font-size: 20px; }}
|
||||||
|
.content {{ padding: 24px; }}
|
||||||
|
.btn {{ display: inline-block; padding: 14px 28px; border-radius: 8px; text-decoration: none; font-weight: 600; font-size: 14px; background: #ffc700; color: #1a2424; margin-top: 16px; }}
|
||||||
|
.footer {{ padding: 16px 24px; background: #f8f9fa; font-size: 12px; color: #666; text-align: center; }}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="header">
|
||||||
|
<h1>E-Mail bestaetigen</h1>
|
||||||
|
</div>
|
||||||
|
<div class="content">
|
||||||
|
<p>Hallo,</p>
|
||||||
|
<p>Bitte bestaetigen deine E-Mail-Adresse, um dein Konto zu aktivieren:</p>
|
||||||
|
|
||||||
|
<a href="{verify_url}" class="btn">E-Mail bestaetigen</a>
|
||||||
|
|
||||||
|
<p style="margin-top: 24px; font-size: 14px; color: #666;">
|
||||||
|
Oder kopiere diesen Link in deinen Browser:<br>
|
||||||
|
<code style="font-size: 12px; color: #999;">{verify_url}</code>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="footer">
|
||||||
|
Dieser Link ist 24 Stunden gueltig. Falls du dich nicht registriert hast, ignoriere diese E-Mail.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
|
||||||
|
return send_email(
|
||||||
|
to_email=to_email,
|
||||||
|
subject="E-Mail-Adresse bestaetigen",
|
||||||
|
html_content=html_content
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def send_welcome_email(
|
||||||
|
to_email: str,
|
||||||
|
user_name: str,
|
||||||
|
account_type: str,
|
||||||
|
base_url: str
|
||||||
|
) -> bool:
|
||||||
|
"""Send welcome email after registration/onboarding completion."""
|
||||||
|
login_url = f"{base_url}/login"
|
||||||
|
|
||||||
|
if account_type == "company":
|
||||||
|
account_type_text = "Unternehmens-Konto"
|
||||||
|
next_steps = """
|
||||||
|
<ul>
|
||||||
|
<li>Lade deine Teammitglieder ein</li>
|
||||||
|
<li>Verfeinere deine Unternehmensstrategie</li>
|
||||||
|
<li>Beginne mit der Post-Erstellung</li>
|
||||||
|
</ul>
|
||||||
|
"""
|
||||||
|
elif account_type == "employee":
|
||||||
|
account_type_text = "Mitarbeiter-Konto"
|
||||||
|
next_steps = """
|
||||||
|
<ul>
|
||||||
|
<li>Recherchiere neue Topics</li>
|
||||||
|
<li>Erstelle deinen ersten Post</li>
|
||||||
|
</ul>
|
||||||
|
"""
|
||||||
|
else:
|
||||||
|
account_type_text = "Ghostwriter-Konto"
|
||||||
|
next_steps = """
|
||||||
|
<ul>
|
||||||
|
<li>Recherchiere neue Topics</li>
|
||||||
|
<li>Erstelle deinen ersten Post</li>
|
||||||
|
<li>Passe deine Einstellungen an</li>
|
||||||
|
</ul>
|
||||||
|
"""
|
||||||
|
|
||||||
|
html_content = f"""
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<style>
|
||||||
|
body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f5f5f5; padding: 20px; }}
|
||||||
|
.container {{ max-width: 600px; margin: 0 auto; background: white; border-radius: 12px; overflow: hidden; box-shadow: 0 2px 8px rgba(0,0,0,0.1); }}
|
||||||
|
.header {{ background: linear-gradient(135deg, #2d3838 0%, #1a2424 100%); color: white; padding: 24px; }}
|
||||||
|
.header h1 {{ margin: 0; font-size: 20px; }}
|
||||||
|
.content {{ padding: 24px; }}
|
||||||
|
.account-badge {{ display: inline-block; padding: 8px 16px; border-radius: 20px; background: #22c55e; color: white; font-weight: 600; margin: 8px 0; }}
|
||||||
|
.btn {{ display: inline-block; padding: 14px 28px; border-radius: 8px; text-decoration: none; font-weight: 600; font-size: 14px; background: #ffc700; color: #1a2424; margin-top: 16px; }}
|
||||||
|
.footer {{ padding: 16px 24px; background: #f8f9fa; font-size: 12px; color: #666; text-align: center; }}
|
||||||
|
ul {{ padding-left: 20px; }}
|
||||||
|
li {{ margin: 8px 0; }}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="header">
|
||||||
|
<h1>Willkommen bei LinkedIn Posts!</h1>
|
||||||
|
</div>
|
||||||
|
<div class="content">
|
||||||
|
<p>Hallo {user_name},</p>
|
||||||
|
<p>Dein Konto wurde erfolgreich eingerichtet!</p>
|
||||||
|
|
||||||
|
<p class="account-badge">{account_type_text}</p>
|
||||||
|
|
||||||
|
<p><strong>Naechste Schritte:</strong></p>
|
||||||
|
{next_steps}
|
||||||
|
|
||||||
|
<a href="{login_url}" class="btn">Zum Dashboard</a>
|
||||||
|
</div>
|
||||||
|
<div class="footer">
|
||||||
|
Viel Erfolg mit deinen LinkedIn-Posts!
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
|
||||||
|
return send_email(
|
||||||
|
to_email=to_email,
|
||||||
|
subject="Willkommen bei LinkedIn Posts!",
|
||||||
|
html_content=html_content
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def send_employee_removal_email(
|
||||||
|
to_email: str,
|
||||||
|
employee_name: str,
|
||||||
|
company_name: str
|
||||||
|
) -> bool:
|
||||||
|
"""Send email to notify an employee they have been removed from the company."""
|
||||||
|
html_content = f"""
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<style>
|
||||||
|
body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f5f5f5; padding: 20px; }}
|
||||||
|
.container {{ max-width: 600px; margin: 0 auto; background: white; border-radius: 12px; overflow: hidden; box-shadow: 0 2px 8px rgba(0,0,0,0.1); }}
|
||||||
|
.header {{ background: linear-gradient(135deg, #dc2626 0%, #991b1b 100%); color: white; padding: 24px; }}
|
||||||
|
.header h1 {{ margin: 0; font-size: 20px; }}
|
||||||
|
.content {{ padding: 24px; }}
|
||||||
|
.company-badge {{ display: inline-block; padding: 8px 16px; border-radius: 20px; background: #f3f4f6; color: #374151; font-weight: 600; margin: 16px 0; }}
|
||||||
|
.footer {{ padding: 16px 24px; background: #f8f9fa; font-size: 12px; color: #666; text-align: center; }}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="header">
|
||||||
|
<h1>Konto entfernt</h1>
|
||||||
|
</div>
|
||||||
|
<div class="content">
|
||||||
|
<p>Hallo {employee_name},</p>
|
||||||
|
<p>dein Konto wurde aus dem Unternehmen entfernt:</p>
|
||||||
|
|
||||||
|
<p class="company-badge">{company_name}</p>
|
||||||
|
|
||||||
|
<p>Dein Zugang wurde deaktiviert und alle zugehoerigen Daten wurden geloescht.</p>
|
||||||
|
|
||||||
|
<p>Falls du Fragen hast, wende dich bitte an deinen ehemaligen Arbeitgeber.</p>
|
||||||
|
</div>
|
||||||
|
<div class="footer">
|
||||||
|
Diese E-Mail wurde automatisch generiert.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
|
||||||
|
return send_email(
|
||||||
|
to_email=to_email,
|
||||||
|
subject=f"Dein Konto bei {company_name} wurde entfernt",
|
||||||
|
html_content=html_content
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def send_decision_notification_email(
|
||||||
|
to_email: str,
|
||||||
|
post_title: str,
|
||||||
|
decision: str, # "approved" or "rejected"
|
||||||
|
base_url: str,
|
||||||
|
post_id: UUID,
|
||||||
|
image_url: Optional[str] = None
|
||||||
|
) -> bool:
|
||||||
|
"""Send email to creator notifying them of the customer's decision."""
|
||||||
|
post_url = f"{base_url}/posts/{post_id}"
|
||||||
|
|
||||||
|
if decision == "approved":
|
||||||
|
decision_text = "freigegeben"
|
||||||
|
decision_color = "#22c55e"
|
||||||
|
action_text = "Der Post wurde in die Spalte 'Freigegeben' verschoben und kann nun im Kalender eingeplant werden."
|
||||||
|
else:
|
||||||
|
decision_text = "zur Überarbeitung zurückgeschickt"
|
||||||
|
decision_color = "#f59e0b"
|
||||||
|
action_text = "Der Post wurde zurück in die Spalte 'Vorschläge' verschoben. Bitte überarbeite ihn und sende ihn erneut zur Freigabe."
|
||||||
|
|
||||||
|
html_content = f"""
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<style>
|
||||||
|
body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f5f5f5; padding: 20px; }}
|
||||||
|
.container {{ max-width: 600px; margin: 0 auto; background: white; border-radius: 12px; overflow: hidden; box-shadow: 0 2px 8px rgba(0,0,0,0.1); }}
|
||||||
|
.header {{ background: linear-gradient(135deg, #2d3838 0%, #1a2424 100%); color: white; padding: 24px; }}
|
||||||
|
.header h1 {{ margin: 0; font-size: 20px; }}
|
||||||
|
.content {{ padding: 24px; }}
|
||||||
|
.decision {{ display: inline-block; padding: 8px 16px; border-radius: 20px; font-weight: 600; color: white; background: {decision_color}; margin: 16px 0; }}
|
||||||
|
.btn {{ display: inline-block; padding: 14px 28px; border-radius: 8px; text-decoration: none; font-weight: 600; font-size: 14px; background: #ffc700; color: #1a2424; margin-top: 16px; }}
|
||||||
|
.footer {{ padding: 16px 24px; background: #f8f9fa; font-size: 12px; color: #666; text-align: center; }}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="header">
|
||||||
|
<h1>Entscheidung zu deinem Post</h1>
|
||||||
|
</div>
|
||||||
|
<div class="content">
|
||||||
|
<p>Hallo,</p>
|
||||||
|
<p>Der Kunde hat eine Entscheidung zu deinem Post getroffen:</p>
|
||||||
|
|
||||||
|
<p><strong>{post_title}</strong></p>
|
||||||
|
|
||||||
|
<p class="decision">{decision_text.upper()}</p>
|
||||||
|
|
||||||
|
{f'<img src="{image_url}" alt="Post-Bild" style="width: 100%; max-height: 400px; object-fit: cover; border-radius: 8px; margin: 16px 0;" />' if image_url else ''}
|
||||||
|
|
||||||
|
<p>{action_text}</p>
|
||||||
|
|
||||||
|
<a href="{post_url}" class="btn">Post ansehen</a>
|
||||||
|
</div>
|
||||||
|
<div class="footer">
|
||||||
|
Diese Email wurde automatisch generiert.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
|
||||||
|
return send_email(
|
||||||
|
to_email=to_email,
|
||||||
|
subject=f"Post {decision_text}: {post_title}",
|
||||||
|
html_content=html_content
|
||||||
|
)
|
||||||
314
src/services/linkedin_service.py
Normal file
314
src/services/linkedin_service.py
Normal file
@@ -0,0 +1,314 @@
|
|||||||
|
"""
|
||||||
|
LinkedIn API service for auto-posting.
|
||||||
|
|
||||||
|
Handles:
|
||||||
|
- UGC Posts API (v2) for posting content
|
||||||
|
- OAuth token refresh
|
||||||
|
- Image upload for posts with media
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from typing import Dict, Optional
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from src.config import settings
|
||||||
|
from src.database.client import db
|
||||||
|
from src.utils.encryption import decrypt_token, encrypt_token
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class LinkedInService:
|
||||||
|
"""Service for LinkedIn API interactions."""
|
||||||
|
|
||||||
|
BASE_URL = "https://api.linkedin.com/v2"
|
||||||
|
TIMEOUT = 30.0
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.db = db
|
||||||
|
|
||||||
|
async def post_to_linkedin(
|
||||||
|
self,
|
||||||
|
linkedin_account_id: UUID,
|
||||||
|
text: str,
|
||||||
|
image_url: Optional[str] = None
|
||||||
|
) -> Dict:
|
||||||
|
"""
|
||||||
|
Post content to LinkedIn using UGC Posts API.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
linkedin_account_id: ID of the linkedin_accounts record
|
||||||
|
text: Post text content
|
||||||
|
image_url: Optional image URL from Supabase storage
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with 'url' key containing the LinkedIn post URL
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
Exception: On API errors
|
||||||
|
"""
|
||||||
|
# Get account with decrypted token
|
||||||
|
account = await self._get_account_with_token(linkedin_account_id)
|
||||||
|
if not account:
|
||||||
|
raise ValueError(f"LinkedIn account {linkedin_account_id} not found")
|
||||||
|
|
||||||
|
# Check token expiry
|
||||||
|
if account['token_expires_at'] < datetime.now(timezone.utc):
|
||||||
|
raise ValueError("Access token expired - refresh needed")
|
||||||
|
|
||||||
|
access_token = account['access_token']
|
||||||
|
linkedin_user_id = account['linkedin_user_id']
|
||||||
|
|
||||||
|
# Build post payload
|
||||||
|
payload = {
|
||||||
|
"author": f"urn:li:person:{linkedin_user_id}",
|
||||||
|
"lifecycleState": "PUBLISHED",
|
||||||
|
"specificContent": {
|
||||||
|
"com.linkedin.ugc.ShareContent": {
|
||||||
|
"shareCommentary": {
|
||||||
|
"text": text
|
||||||
|
},
|
||||||
|
"shareMediaCategory": "NONE"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"visibility": {
|
||||||
|
"com.linkedin.ugc.MemberNetworkVisibility": "PUBLIC"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Handle image upload if provided
|
||||||
|
if image_url:
|
||||||
|
try:
|
||||||
|
media_urn = await self._upload_image(access_token, linkedin_user_id, image_url)
|
||||||
|
if media_urn:
|
||||||
|
payload["specificContent"]["com.linkedin.ugc.ShareContent"]["shareMediaCategory"] = "IMAGE"
|
||||||
|
payload["specificContent"]["com.linkedin.ugc.ShareContent"]["media"] = [
|
||||||
|
{
|
||||||
|
"status": "READY",
|
||||||
|
"media": media_urn
|
||||||
|
}
|
||||||
|
]
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Image upload failed for {linkedin_account_id}: {e}. Posting without image.")
|
||||||
|
|
||||||
|
# Post to LinkedIn
|
||||||
|
async with httpx.AsyncClient(timeout=self.TIMEOUT) as client:
|
||||||
|
response = await client.post(
|
||||||
|
f"{self.BASE_URL}/ugcPosts",
|
||||||
|
headers={
|
||||||
|
"Authorization": f"Bearer {access_token}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"X-Restli-Protocol-Version": "2.0.0"
|
||||||
|
},
|
||||||
|
json=payload
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code == 201:
|
||||||
|
# Success
|
||||||
|
post_id = response.json().get("id")
|
||||||
|
await self._update_account_last_used(linkedin_account_id)
|
||||||
|
|
||||||
|
# LinkedIn post URL format (best effort)
|
||||||
|
post_url = f"https://www.linkedin.com/feed/update/{post_id}/" if post_id else None
|
||||||
|
|
||||||
|
logger.info(f"Posted to LinkedIn: {post_url}")
|
||||||
|
return {"url": post_url, "post_id": post_id}
|
||||||
|
|
||||||
|
elif response.status_code == 401:
|
||||||
|
# Token expired or invalid
|
||||||
|
await self._mark_account_error(
|
||||||
|
linkedin_account_id,
|
||||||
|
f"Token expired or invalid (401)"
|
||||||
|
)
|
||||||
|
raise Exception("LinkedIn authentication failed - token may be expired")
|
||||||
|
|
||||||
|
elif response.status_code == 429:
|
||||||
|
# Rate limit
|
||||||
|
await self._mark_account_error(
|
||||||
|
linkedin_account_id,
|
||||||
|
"Rate limit exceeded (429)"
|
||||||
|
)
|
||||||
|
raise Exception("LinkedIn rate limit exceeded")
|
||||||
|
|
||||||
|
else:
|
||||||
|
# Other error
|
||||||
|
error_msg = f"LinkedIn API error {response.status_code}: {response.text}"
|
||||||
|
await self._mark_account_error(linkedin_account_id, error_msg)
|
||||||
|
raise Exception(error_msg)
|
||||||
|
|
||||||
|
async def _upload_image(
|
||||||
|
self,
|
||||||
|
access_token: str,
|
||||||
|
linkedin_user_id: str,
|
||||||
|
image_url: str
|
||||||
|
) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
Upload image to LinkedIn for use in post.
|
||||||
|
|
||||||
|
Returns media URN if successful, None otherwise.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Step 1: Register upload
|
||||||
|
register_payload = {
|
||||||
|
"registerUploadRequest": {
|
||||||
|
"recipes": ["urn:li:digitalmediaRecipe:feedshare-image"],
|
||||||
|
"owner": f"urn:li:person:{linkedin_user_id}",
|
||||||
|
"serviceRelationships": [
|
||||||
|
{
|
||||||
|
"relationshipType": "OWNER",
|
||||||
|
"identifier": "urn:li:userGeneratedContent"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(timeout=self.TIMEOUT) as client:
|
||||||
|
register_response = await client.post(
|
||||||
|
f"{self.BASE_URL}/assets?action=registerUpload",
|
||||||
|
headers={
|
||||||
|
"Authorization": f"Bearer {access_token}",
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
},
|
||||||
|
json=register_payload
|
||||||
|
)
|
||||||
|
|
||||||
|
if register_response.status_code != 200:
|
||||||
|
logger.error(f"Image register failed: {register_response.status_code}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
register_data = register_response.json()
|
||||||
|
upload_url = register_data["value"]["uploadMechanism"]["com.linkedin.digitalmedia.uploading.MediaUploadHttpRequest"]["uploadUrl"]
|
||||||
|
asset_urn = register_data["value"]["asset"]
|
||||||
|
|
||||||
|
# Step 2: Download image from Supabase
|
||||||
|
image_response = await client.get(image_url)
|
||||||
|
if image_response.status_code != 200:
|
||||||
|
logger.error(f"Failed to download image from {image_url}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
image_data = image_response.content
|
||||||
|
|
||||||
|
# Step 3: Upload to LinkedIn
|
||||||
|
upload_response = await client.put(
|
||||||
|
upload_url,
|
||||||
|
headers={
|
||||||
|
"Authorization": f"Bearer {access_token}",
|
||||||
|
"Content-Type": "application/octet-stream"
|
||||||
|
},
|
||||||
|
content=image_data
|
||||||
|
)
|
||||||
|
|
||||||
|
if upload_response.status_code in [200, 201]:
|
||||||
|
logger.info(f"Image uploaded successfully: {asset_urn}")
|
||||||
|
return asset_urn
|
||||||
|
else:
|
||||||
|
logger.error(f"Image upload failed: {upload_response.status_code}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Image upload error: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def refresh_access_token(self, linkedin_account_id: UUID) -> bool:
|
||||||
|
"""
|
||||||
|
Attempt to refresh the access token using refresh token.
|
||||||
|
|
||||||
|
Note: LinkedIn's OAuth 2.0 may not support refresh tokens for all scopes.
|
||||||
|
Check LinkedIn documentation for current support.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if refresh succeeded, False otherwise
|
||||||
|
"""
|
||||||
|
account = await self._get_account_with_token(linkedin_account_id)
|
||||||
|
if not account or not account.get('refresh_token'):
|
||||||
|
logger.warning(f"No refresh token for account {linkedin_account_id}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
refresh_token = account['refresh_token']
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=self.TIMEOUT) as client:
|
||||||
|
response = await client.post(
|
||||||
|
"https://www.linkedin.com/oauth/v2/accessToken",
|
||||||
|
data={
|
||||||
|
"grant_type": "refresh_token",
|
||||||
|
"refresh_token": refresh_token,
|
||||||
|
"client_id": settings.linkedin_client_id,
|
||||||
|
"client_secret": settings.linkedin_client_secret
|
||||||
|
},
|
||||||
|
headers={"Content-Type": "application/x-www-form-urlencoded"}
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code == 200:
|
||||||
|
token_data = response.json()
|
||||||
|
new_access_token = token_data["access_token"]
|
||||||
|
expires_in = token_data.get("expires_in", 5184000) # Default 60 days
|
||||||
|
|
||||||
|
# Encrypt and update tokens
|
||||||
|
encrypted_access = encrypt_token(new_access_token)
|
||||||
|
new_refresh = token_data.get("refresh_token")
|
||||||
|
encrypted_refresh = encrypt_token(new_refresh) if new_refresh else account['refresh_token']
|
||||||
|
|
||||||
|
await self.db.update_linkedin_account(
|
||||||
|
linkedin_account_id,
|
||||||
|
{
|
||||||
|
"access_token": encrypted_access,
|
||||||
|
"refresh_token": encrypted_refresh,
|
||||||
|
"token_expires_at": datetime.now(timezone.utc) + timedelta(seconds=expires_in),
|
||||||
|
"last_error": None,
|
||||||
|
"last_error_at": None
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(f"Token refreshed for account {linkedin_account_id}")
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
logger.error(f"Token refresh failed: {response.status_code} - {response.text}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Token refresh error: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def _get_account_with_token(self, linkedin_account_id: UUID) -> Optional[Dict]:
|
||||||
|
"""Get account and decrypt tokens."""
|
||||||
|
account = await self.db.get_linkedin_account_by_id(linkedin_account_id)
|
||||||
|
if not account:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Decrypt tokens
|
||||||
|
account_dict = account.model_dump()
|
||||||
|
account_dict['access_token'] = decrypt_token(account.access_token)
|
||||||
|
if account.refresh_token:
|
||||||
|
account_dict['refresh_token'] = decrypt_token(account.refresh_token)
|
||||||
|
|
||||||
|
return account_dict
|
||||||
|
|
||||||
|
async def _update_account_last_used(self, linkedin_account_id: UUID):
|
||||||
|
"""Update last_used_at timestamp."""
|
||||||
|
await self.db.update_linkedin_account(
|
||||||
|
linkedin_account_id,
|
||||||
|
{
|
||||||
|
"last_used_at": datetime.now(timezone.utc),
|
||||||
|
"last_error": None,
|
||||||
|
"last_error_at": None
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _mark_account_error(self, linkedin_account_id: UUID, error_msg: str):
|
||||||
|
"""Mark account with error."""
|
||||||
|
await self.db.update_linkedin_account(
|
||||||
|
linkedin_account_id,
|
||||||
|
{
|
||||||
|
"last_error": error_msg,
|
||||||
|
"last_error_at": datetime.now(timezone.utc)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Global instance
|
||||||
|
linkedin_service = LinkedInService()
|
||||||
288
src/services/scheduler_service.py
Normal file
288
src/services/scheduler_service.py
Normal file
@@ -0,0 +1,288 @@
|
|||||||
|
"""Background scheduler for handling scheduled posts.
|
||||||
|
|
||||||
|
This service runs in the background and:
|
||||||
|
1. Checks for posts that are due for publishing
|
||||||
|
2. Marks them as published
|
||||||
|
3. Sends notification emails to employees
|
||||||
|
|
||||||
|
Future extension: Could integrate with LinkedIn API for automatic posting.
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Optional
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
from src.database.client import DatabaseClient
|
||||||
|
from src.services.email_service import send_email
|
||||||
|
|
||||||
|
|
||||||
|
class SchedulerService:
|
||||||
|
"""Background scheduler for post publishing."""
|
||||||
|
|
||||||
|
def __init__(self, db: DatabaseClient, check_interval_seconds: int = 60):
|
||||||
|
"""Initialize the scheduler.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database client instance
|
||||||
|
check_interval_seconds: How often to check for due posts (default: 60s)
|
||||||
|
"""
|
||||||
|
self.db = db
|
||||||
|
self.check_interval = check_interval_seconds
|
||||||
|
self._running = False
|
||||||
|
self._task: Optional[asyncio.Task] = None
|
||||||
|
|
||||||
|
async def start(self):
|
||||||
|
"""Start the background scheduler."""
|
||||||
|
if self._running:
|
||||||
|
logger.warning("Scheduler already running")
|
||||||
|
return
|
||||||
|
|
||||||
|
self._running = True
|
||||||
|
self._task = asyncio.create_task(self._run_loop())
|
||||||
|
logger.info(f"Scheduler started (checking every {self.check_interval}s)")
|
||||||
|
|
||||||
|
async def stop(self):
|
||||||
|
"""Stop the background scheduler."""
|
||||||
|
self._running = False
|
||||||
|
if self._task:
|
||||||
|
self._task.cancel()
|
||||||
|
try:
|
||||||
|
await self._task
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
logger.info("Scheduler stopped")
|
||||||
|
|
||||||
|
async def _run_loop(self):
|
||||||
|
"""Main scheduler loop."""
|
||||||
|
while self._running:
|
||||||
|
try:
|
||||||
|
await self._process_due_posts()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Scheduler error: {e}")
|
||||||
|
|
||||||
|
await asyncio.sleep(self.check_interval)
|
||||||
|
|
||||||
|
async def _process_due_posts(self):
|
||||||
|
"""Process all posts that are due for publishing."""
|
||||||
|
due_posts = await self.db.get_scheduled_posts_due()
|
||||||
|
|
||||||
|
if not due_posts:
|
||||||
|
return
|
||||||
|
|
||||||
|
logger.info(f"Processing {len(due_posts)} due posts")
|
||||||
|
|
||||||
|
for post in due_posts:
|
||||||
|
try:
|
||||||
|
await self._publish_post(post)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to publish post {post.id}: {e}")
|
||||||
|
|
||||||
|
async def _publish_post(self, post):
|
||||||
|
"""Publish a single post - with LinkedIn API if account linked, otherwise email."""
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
# Get LinkedIn account for user
|
||||||
|
linkedin_account = await self.db.get_linkedin_account(post.user_id)
|
||||||
|
|
||||||
|
if linkedin_account:
|
||||||
|
# User has LinkedIn account linked -> Try auto-posting
|
||||||
|
try:
|
||||||
|
await self._auto_post_to_linkedin(post, linkedin_account)
|
||||||
|
return # Success - no need for email fallback
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"LinkedIn auto-post failed for post {post.id}: {e}")
|
||||||
|
# Fall through to email notification
|
||||||
|
|
||||||
|
# No LinkedIn account or auto-post failed -> Send email notification
|
||||||
|
await self._publish_post_via_email(post)
|
||||||
|
|
||||||
|
async def _auto_post_to_linkedin(self, post, linkedin_account):
|
||||||
|
"""Auto-post to LinkedIn using linked account."""
|
||||||
|
from src.services.linkedin_service import linkedin_service
|
||||||
|
from src.utils.encryption import decrypt_token
|
||||||
|
|
||||||
|
# Check token expiry
|
||||||
|
if linkedin_account.token_expires_at < datetime.now(timezone.utc):
|
||||||
|
logger.warning(f"LinkedIn token expired for account {linkedin_account.id}")
|
||||||
|
|
||||||
|
# Try to refresh token
|
||||||
|
refreshed = await linkedin_service.refresh_access_token(linkedin_account.id)
|
||||||
|
if not refreshed:
|
||||||
|
# Token refresh failed -> Fall back to email
|
||||||
|
raise Exception("Token expired and refresh failed")
|
||||||
|
|
||||||
|
# Post to LinkedIn
|
||||||
|
result = await linkedin_service.post_to_linkedin(
|
||||||
|
linkedin_account_id=linkedin_account.id,
|
||||||
|
text=post.post_content,
|
||||||
|
image_url=post.image_url
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update post as published with LinkedIn metadata
|
||||||
|
await self.db.update_generated_post(
|
||||||
|
post.id,
|
||||||
|
{
|
||||||
|
"status": "published",
|
||||||
|
"published_at": datetime.now(timezone.utc),
|
||||||
|
"metadata": {
|
||||||
|
**(post.metadata or {}),
|
||||||
|
"linkedin_post_url": result.get("url"),
|
||||||
|
"auto_posted": True,
|
||||||
|
"posted_at": datetime.now(timezone.utc).isoformat()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(f"✅ Post {post.id} auto-posted to LinkedIn: {result.get('url')}")
|
||||||
|
|
||||||
|
# Send success notification
|
||||||
|
profile = await self.db.get_profile(post.user_id)
|
||||||
|
if profile:
|
||||||
|
await self._send_auto_post_success_notification(post, profile, result)
|
||||||
|
|
||||||
|
async def _publish_post_via_email(self, post):
|
||||||
|
"""Fallback: Mark as published and send email notification."""
|
||||||
|
# Update post status to published
|
||||||
|
await self.db.update_generated_post(
|
||||||
|
post.id,
|
||||||
|
{
|
||||||
|
"status": "published",
|
||||||
|
"published_at": datetime.now(timezone.utc)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(f"Post {post.id} marked as published (email notification)")
|
||||||
|
|
||||||
|
# Get profile info for notification
|
||||||
|
profile = await self.db.get_profile(post.user_id)
|
||||||
|
if not profile:
|
||||||
|
logger.warning(f"No profile found for post {post.id}")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Send notification email
|
||||||
|
await self._send_publish_notification(post, profile)
|
||||||
|
|
||||||
|
async def _send_publish_notification(self, post, profile):
|
||||||
|
"""Send email notification that a post is ready to be published.
|
||||||
|
|
||||||
|
Future: When LinkedIn API is integrated, this could instead
|
||||||
|
confirm that the post was automatically published.
|
||||||
|
"""
|
||||||
|
# Try to get employee email
|
||||||
|
recipient_email = profile.creator_email or profile.customer_email
|
||||||
|
if not recipient_email:
|
||||||
|
logger.warning(f"No email for profile {profile.id}, skipping notification")
|
||||||
|
return
|
||||||
|
|
||||||
|
subject = "LinkedIn Post bereit zum Veröffentlichen"
|
||||||
|
|
||||||
|
display_name = profile.display_name or "dort"
|
||||||
|
|
||||||
|
html_content = f"""
|
||||||
|
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
|
||||||
|
<h2 style="color: #0a66c2;">Dein LinkedIn Post ist bereit! 🚀</h2>
|
||||||
|
|
||||||
|
<p>Hallo {display_name},</p>
|
||||||
|
|
||||||
|
<p>Dein geplanter LinkedIn Post ist jetzt zur Veröffentlichung bereit:</p>
|
||||||
|
|
||||||
|
<div style="background: #f3f4f6; border-radius: 8px; padding: 20px; margin: 20px 0;">
|
||||||
|
<p style="font-weight: bold; margin-bottom: 10px;">Thema: {post.topic_title}</p>
|
||||||
|
<div style="background: white; border-radius: 4px; padding: 15px; white-space: pre-wrap; font-size: 14px; line-height: 1.5;">
|
||||||
|
{post.post_content[:500]}{'...' if len(post.post_content) > 500 else ''}
|
||||||
|
</div>
|
||||||
|
{f'<img src="{post.image_url}" alt="Post-Bild" style="width: 100%; max-height: 400px; object-fit: cover; border-radius: 8px; margin-top: 15px;" />' if post.image_url else ''}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p><strong>Nächste Schritte:</strong></p>
|
||||||
|
<ol>
|
||||||
|
<li>Öffne LinkedIn</li>
|
||||||
|
<li>Erstelle einen neuen Post</li>
|
||||||
|
<li>Kopiere den Text oben und füge ihn ein</li>
|
||||||
|
<li>Veröffentliche den Post</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<p style="color: #666; font-size: 12px; margin-top: 30px;">
|
||||||
|
Diese Nachricht wurde automatisch generiert.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
send_email(
|
||||||
|
to_email=recipient_email,
|
||||||
|
subject=subject,
|
||||||
|
html_content=html_content
|
||||||
|
)
|
||||||
|
logger.info(f"Publish notification sent to {recipient_email}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to send publish notification: {e}")
|
||||||
|
|
||||||
|
async def _send_auto_post_success_notification(self, post, profile, result):
|
||||||
|
"""Send success email after auto-posting to LinkedIn."""
|
||||||
|
recipient_email = profile.creator_email or profile.customer_email
|
||||||
|
if not recipient_email:
|
||||||
|
logger.warning(f"No email for profile {profile.id}, skipping success notification")
|
||||||
|
return
|
||||||
|
|
||||||
|
subject = "✅ LinkedIn Post automatisch veröffentlicht"
|
||||||
|
display_name = profile.display_name or "dort"
|
||||||
|
linkedin_url = result.get('url', 'https://www.linkedin.com/feed/')
|
||||||
|
|
||||||
|
html_content = f"""
|
||||||
|
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
|
||||||
|
<h2 style="color: #0a66c2;">Post erfolgreich veröffentlicht! 🎉</h2>
|
||||||
|
|
||||||
|
<p>Hallo {display_name},</p>
|
||||||
|
|
||||||
|
<p>Dein geplanter LinkedIn Post wurde automatisch veröffentlicht:</p>
|
||||||
|
|
||||||
|
<div style="background: #f3f4f6; border-radius: 8px; padding: 20px; margin: 20px 0;">
|
||||||
|
<p style="font-weight: bold; margin-bottom: 10px;">Thema: {post.topic_title}</p>
|
||||||
|
<div style="background: white; border-radius: 4px; padding: 15px; white-space: pre-wrap; font-size: 14px; line-height: 1.5;">
|
||||||
|
{post.post_content[:500]}{'...' if len(post.post_content) > 500 else ''}
|
||||||
|
</div>
|
||||||
|
{f'<img src="{post.image_url}" alt="Post-Bild" style="width: 100%; max-height: 400px; object-fit: cover; border-radius: 8px; margin-top: 15px;" />' if post.image_url else ''}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
<a href="{linkedin_url}" style="display: inline-block; background: #0a66c2; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; font-weight: bold;">
|
||||||
|
Auf LinkedIn ansehen →
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p style="color: #666; margin-top: 30px;">
|
||||||
|
Dein Post wurde automatisch über dein verbundenes LinkedIn-Konto veröffentlicht.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p style="color: #666; font-size: 12px; margin-top: 30px;">
|
||||||
|
Diese Nachricht wurde automatisch generiert.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
send_email(
|
||||||
|
to_email=recipient_email,
|
||||||
|
subject=subject,
|
||||||
|
html_content=html_content
|
||||||
|
)
|
||||||
|
logger.info(f"Auto-post success notification sent to {recipient_email}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to send success notification: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
# Global scheduler instance
|
||||||
|
_scheduler: Optional[SchedulerService] = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_scheduler() -> Optional[SchedulerService]:
|
||||||
|
"""Get the global scheduler instance."""
|
||||||
|
return _scheduler
|
||||||
|
|
||||||
|
|
||||||
|
def init_scheduler(db: DatabaseClient, check_interval: int = 60) -> SchedulerService:
|
||||||
|
"""Initialize and return the global scheduler instance."""
|
||||||
|
global _scheduler
|
||||||
|
_scheduler = SchedulerService(db, check_interval)
|
||||||
|
return _scheduler
|
||||||
103
src/services/storage_service.py
Normal file
103
src/services/storage_service.py
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
"""Supabase Storage service for post image uploads."""
|
||||||
|
import asyncio
|
||||||
|
import uuid
|
||||||
|
from typing import Optional
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
from src.config import settings
|
||||||
|
|
||||||
|
|
||||||
|
ALLOWED_CONTENT_TYPES = {"image/jpeg", "image/png", "image/gif", "image/webp"}
|
||||||
|
MAX_FILE_SIZE = 5 * 1024 * 1024 # 5MB
|
||||||
|
BUCKET_NAME = "post-images"
|
||||||
|
|
||||||
|
CONTENT_TYPE_EXTENSIONS = {
|
||||||
|
"image/jpeg": "jpg",
|
||||||
|
"image/png": "png",
|
||||||
|
"image/gif": "gif",
|
||||||
|
"image/webp": "webp",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class StorageService:
|
||||||
|
"""Handles image uploads to Supabase Storage."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
from supabase import create_client
|
||||||
|
key = settings.supabase_service_role_key or settings.supabase_key
|
||||||
|
self.client = create_client(settings.supabase_url, key)
|
||||||
|
self._bucket_ensured = False
|
||||||
|
|
||||||
|
def _ensure_bucket(self):
|
||||||
|
"""Create the post-images bucket if it doesn't exist."""
|
||||||
|
if self._bucket_ensured:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
self.client.storage.get_bucket(BUCKET_NAME)
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
self.client.storage.create_bucket(
|
||||||
|
BUCKET_NAME,
|
||||||
|
options={"public": True}
|
||||||
|
)
|
||||||
|
logger.info(f"Created storage bucket: {BUCKET_NAME}")
|
||||||
|
except Exception as e:
|
||||||
|
if "already exists" in str(e).lower() or "Duplicate" in str(e):
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
logger.error(f"Failed to create bucket: {e}")
|
||||||
|
raise
|
||||||
|
self._bucket_ensured = True
|
||||||
|
|
||||||
|
async def upload_image(
|
||||||
|
self,
|
||||||
|
file_content: bytes,
|
||||||
|
content_type: str,
|
||||||
|
user_id: str,
|
||||||
|
) -> str:
|
||||||
|
"""Upload an image to Supabase Storage.
|
||||||
|
|
||||||
|
Returns the public URL of the uploaded image.
|
||||||
|
"""
|
||||||
|
if content_type not in ALLOWED_CONTENT_TYPES:
|
||||||
|
raise ValueError(f"Unzulässiger Dateityp: {content_type}. Erlaubt: JPEG, PNG, GIF, WebP")
|
||||||
|
|
||||||
|
if len(file_content) > MAX_FILE_SIZE:
|
||||||
|
raise ValueError(f"Datei zu groß (max. {MAX_FILE_SIZE // 1024 // 1024} MB)")
|
||||||
|
|
||||||
|
ext = CONTENT_TYPE_EXTENSIONS[content_type]
|
||||||
|
file_name = f"{user_id}/{uuid.uuid4()}.{ext}"
|
||||||
|
|
||||||
|
def _upload():
|
||||||
|
self._ensure_bucket()
|
||||||
|
self.client.storage.from_(BUCKET_NAME).upload(
|
||||||
|
path=file_name,
|
||||||
|
file=file_content,
|
||||||
|
file_options={"content-type": content_type},
|
||||||
|
)
|
||||||
|
|
||||||
|
await asyncio.to_thread(_upload)
|
||||||
|
|
||||||
|
public_url = f"{settings.supabase_url}/storage/v1/object/public/{BUCKET_NAME}/{file_name}"
|
||||||
|
logger.info(f"Uploaded image: {file_name}")
|
||||||
|
return public_url
|
||||||
|
|
||||||
|
async def delete_image(self, image_url: str) -> None:
|
||||||
|
"""Delete an image from Supabase Storage by its public URL."""
|
||||||
|
prefix = f"{settings.supabase_url}/storage/v1/object/public/{BUCKET_NAME}/"
|
||||||
|
if not image_url.startswith(prefix):
|
||||||
|
logger.warning(f"Cannot delete image, URL doesn't match bucket: {image_url}")
|
||||||
|
return
|
||||||
|
|
||||||
|
file_path = image_url[len(prefix):]
|
||||||
|
|
||||||
|
def _delete():
|
||||||
|
self._ensure_bucket()
|
||||||
|
self.client.storage.from_(BUCKET_NAME).remove([file_path])
|
||||||
|
|
||||||
|
await asyncio.to_thread(_delete)
|
||||||
|
logger.info(f"Deleted image: {file_path}")
|
||||||
|
|
||||||
|
|
||||||
|
# Global singleton
|
||||||
|
storage = StorageService()
|
||||||
@@ -717,7 +717,7 @@ class StatusScreen(Screen):
|
|||||||
|
|
||||||
output = ""
|
output = ""
|
||||||
for customer in customers:
|
for customer in customers:
|
||||||
status = await orchestrator.get_customer_status(customer.id)
|
status = await orchestrator.get_user_status(customer.id)
|
||||||
|
|
||||||
output += f"[bold cyan]╔═══ {customer.name} ═══╗[/]\n"
|
output += f"[bold cyan]╔═══ {customer.name} ═══╗[/]\n"
|
||||||
output += f"[bold]Customer ID:[/] {customer.id}\n"
|
output += f"[bold]Customer ID:[/] {customer.id}\n"
|
||||||
|
|||||||
1
src/utils/__init__.py
Normal file
1
src/utils/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Utility modules."""
|
||||||
38
src/utils/encryption.py
Normal file
38
src/utils/encryption.py
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
"""Token encryption utilities using Fernet symmetric encryption."""
|
||||||
|
|
||||||
|
from cryptography.fernet import Fernet
|
||||||
|
from src.config import settings
|
||||||
|
|
||||||
|
|
||||||
|
def encrypt_token(token: str) -> str:
|
||||||
|
"""
|
||||||
|
Encrypt a token string using Fernet symmetric encryption.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
token: The plaintext token to encrypt
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Base64-encoded encrypted token string
|
||||||
|
"""
|
||||||
|
if not settings.encryption_key:
|
||||||
|
raise ValueError("ENCRYPTION_KEY not configured in environment")
|
||||||
|
|
||||||
|
cipher = Fernet(settings.encryption_key.encode())
|
||||||
|
return cipher.encrypt(token.encode()).decode()
|
||||||
|
|
||||||
|
|
||||||
|
def decrypt_token(encrypted: str) -> str:
|
||||||
|
"""
|
||||||
|
Decrypt a Fernet-encrypted token string.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
encrypted: The base64-encoded encrypted token
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The decrypted plaintext token
|
||||||
|
"""
|
||||||
|
if not settings.encryption_key:
|
||||||
|
raise ValueError("ENCRYPTION_KEY not configured in environment")
|
||||||
|
|
||||||
|
cipher = Fernet(settings.encryption_key.encode())
|
||||||
|
return cipher.decrypt(encrypted.encode()).decode()
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,15 +1,47 @@
|
|||||||
"""FastAPI web frontend for LinkedIn Post Creation System."""
|
"""FastAPI web frontend for LinkedIn Post Creation System."""
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from fastapi.responses import RedirectResponse
|
from fastapi.responses import RedirectResponse
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
from src.config import settings
|
from src.config import settings
|
||||||
from src.web.admin import admin_router
|
from src.web.admin import admin_router
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI):
|
||||||
|
"""Manage application lifecycle - startup and shutdown."""
|
||||||
|
# Startup
|
||||||
|
logger.info("Starting LinkedIn Post Creation System...")
|
||||||
|
|
||||||
|
# Initialize and start scheduler if enabled
|
||||||
|
scheduler = None
|
||||||
|
if settings.user_frontend_enabled:
|
||||||
|
try:
|
||||||
|
from src.database.client import DatabaseClient
|
||||||
|
from src.services.scheduler_service import init_scheduler
|
||||||
|
|
||||||
|
db = DatabaseClient()
|
||||||
|
scheduler = init_scheduler(db, check_interval=60) # Check every 60 seconds
|
||||||
|
await scheduler.start()
|
||||||
|
logger.info("Scheduler service started")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to start scheduler: {e}")
|
||||||
|
|
||||||
|
yield # Application runs here
|
||||||
|
|
||||||
|
# Shutdown
|
||||||
|
logger.info("Shutting down LinkedIn Post Creation System...")
|
||||||
|
if scheduler:
|
||||||
|
await scheduler.stop()
|
||||||
|
logger.info("Scheduler service stopped")
|
||||||
|
|
||||||
|
|
||||||
# Setup
|
# Setup
|
||||||
app = FastAPI(title="LinkedIn Post Creation System")
|
app = FastAPI(title="LinkedIn Post Creation System", lifespan=lifespan)
|
||||||
|
|
||||||
# Static files
|
# Static files
|
||||||
app.mount("/static", StaticFiles(directory=Path(__file__).parent / "static"), name="static")
|
app.mount("/static", StaticFiles(directory=Path(__file__).parent / "static"), name="static")
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>{% block title %}Admin - LinkedIn Posts{% endblock %}</title>
|
<title>{% block title %}Admin Panel{% endblock %}</title>
|
||||||
<script src="https://cdn.tailwindcss.com"></script>
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
<script>
|
<script>
|
||||||
tailwind.config = {
|
tailwind.config = {
|
||||||
@@ -26,7 +26,6 @@
|
|||||||
body { background-color: #3d4848; }
|
body { background-color: #3d4848; }
|
||||||
.nav-link.active { background-color: #ffc700; color: #2d3838; }
|
.nav-link.active { background-color: #ffc700; color: #2d3838; }
|
||||||
.nav-link.active svg { stroke: #2d3838; }
|
.nav-link.active svg { stroke: #2d3838; }
|
||||||
.post-content { white-space: pre-wrap; word-wrap: break-word; }
|
|
||||||
.btn-primary { background-color: #ffc700; color: #2d3838; }
|
.btn-primary { background-color: #ffc700; color: #2d3838; }
|
||||||
.btn-primary:hover { background-color: #e6b300; }
|
.btn-primary:hover { background-color: #e6b300; }
|
||||||
.sidebar-bg { background-color: #2d3838; }
|
.sidebar-bg { background-color: #2d3838; }
|
||||||
@@ -55,33 +54,19 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav class="flex-1 p-4 space-y-2">
|
<nav class="flex-1 p-4 space-y-2">
|
||||||
<a href="/admin" class="nav-link flex items-center gap-3 px-4 py-3 rounded-lg text-gray-300 hover:bg-brand-bg-light transition-colors {% if page == 'home' %}active{% endif %}">
|
<a href="/admin" class="nav-link flex items-center gap-3 px-4 py-3 rounded-lg text-gray-300 hover:bg-brand-bg-light transition-colors {% if page == 'users' %}active{% endif %}">
|
||||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"/></svg>
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"/></svg>
|
||||||
Dashboard
|
Nutzerverwaltung
|
||||||
</a>
|
</a>
|
||||||
<a href="/admin/customers/new" class="nav-link flex items-center gap-3 px-4 py-3 rounded-lg text-gray-300 hover:bg-brand-bg-light transition-colors {% if page == 'new_customer' %}active{% endif %}">
|
<a href="/admin/statistics" class="nav-link flex items-center gap-3 px-4 py-3 rounded-lg text-gray-300 hover:bg-brand-bg-light transition-colors {% if page == 'statistics' %}active{% endif %}">
|
||||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18 9v3m0 0v3m0-3h3m-3 0h-3m-2-5a4 4 0 11-8 0 4 4 0 018 0zM3 20a6 6 0 0112 0v1H3v-1z"/></svg>
|
|
||||||
Neuer Kunde
|
|
||||||
</a>
|
|
||||||
<a href="/admin/research" class="nav-link flex items-center gap-3 px-4 py-3 rounded-lg text-gray-300 hover:bg-brand-bg-light transition-colors {% if page == 'research' %}active{% endif %}">
|
|
||||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg>
|
|
||||||
Research Topics
|
|
||||||
</a>
|
|
||||||
<a href="/admin/create" class="nav-link flex items-center gap-3 px-4 py-3 rounded-lg text-gray-300 hover:bg-brand-bg-light transition-colors {% if page == 'create' %}active{% endif %}">
|
|
||||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>
|
|
||||||
Post erstellen
|
|
||||||
</a>
|
|
||||||
<a href="/admin/posts" class="nav-link flex items-center gap-3 px-4 py-3 rounded-lg text-gray-300 hover:bg-brand-bg-light transition-colors {% if page == 'posts' %}active{% endif %}">
|
|
||||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"/></svg>
|
|
||||||
Alle Posts
|
|
||||||
</a>
|
|
||||||
<a href="/admin/scraped-posts" class="nav-link flex items-center gap-3 px-4 py-3 rounded-lg text-gray-300 hover:bg-brand-bg-light transition-colors {% if page == 'scraped_posts' %}active{% endif %}">
|
|
||||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z"/></svg>
|
|
||||||
Post-Typen
|
|
||||||
</a>
|
|
||||||
<a href="/admin/status" class="nav-link flex items-center gap-3 px-4 py-3 rounded-lg text-gray-300 hover:bg-brand-bg-light transition-colors {% if page == 'status' %}active{% endif %}">
|
|
||||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"/></svg>
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"/></svg>
|
||||||
Status
|
Statistiken
|
||||||
|
</a>
|
||||||
|
<a href="/admin/license-keys" class="nav-link flex items-center gap-3 px-4 py-3 rounded-lg text-gray-300 hover:bg-brand-bg-light transition-colors {% if page == 'license_keys' %}active{% endif %}">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z"/>
|
||||||
|
</svg>
|
||||||
|
Lizenzschlüssel
|
||||||
</a>
|
</a>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
|||||||
@@ -1,539 +0,0 @@
|
|||||||
{% extends "base.html" %}
|
|
||||||
{% block title %}Post erstellen - LinkedIn Posts{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<div class="mb-8">
|
|
||||||
<h1 class="text-3xl font-bold text-white mb-2">Post erstellen</h1>
|
|
||||||
<p class="text-gray-400">Generiere einen neuen LinkedIn Post mit AI</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
|
||||||
<!-- Left: Form -->
|
|
||||||
<div>
|
|
||||||
<form id="createPostForm" class="card-bg rounded-xl border p-6 space-y-6">
|
|
||||||
<!-- Customer Selection -->
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-gray-300 mb-2">Kunde auswählen</label>
|
|
||||||
<select name="customer_id" id="customerSelect" required class="w-full input-bg border rounded-lg px-4 py-3 text-white">
|
|
||||||
<option value="">-- Kunde wählen --</option>
|
|
||||||
{% for customer in customers %}
|
|
||||||
<option value="{{ customer.id }}">{{ customer.name }} - {{ customer.company_name or 'Kein Unternehmen' }}</option>
|
|
||||||
{% endfor %}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Post Type Selection -->
|
|
||||||
<div id="postTypeSelectionArea" class="hidden">
|
|
||||||
<label class="block text-sm font-medium text-gray-300 mb-2">Post-Typ auswählen (optional)</label>
|
|
||||||
<div id="postTypeCards" class="flex flex-wrap gap-2 mb-2">
|
|
||||||
<!-- Post type cards will be loaded here -->
|
|
||||||
</div>
|
|
||||||
<input type="hidden" id="selectedPostTypeId" value="">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Topic Selection -->
|
|
||||||
<div id="topicSelectionArea" class="hidden">
|
|
||||||
<label class="block text-sm font-medium text-gray-300 mb-2">Topic auswählen</label>
|
|
||||||
<div id="topicsList" class="space-y-2 max-h-64 overflow-y-auto">
|
|
||||||
<p class="text-gray-500">Lade Topics...</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Custom Topic -->
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-gray-300 mb-2">
|
|
||||||
<span>Oder eigenes Topic eingeben</span>
|
|
||||||
</label>
|
|
||||||
<div class="space-y-3">
|
|
||||||
<input type="text" id="customTopicTitle" placeholder="Topic Titel" class="w-full input-bg border rounded-lg px-4 py-2 text-white">
|
|
||||||
<textarea id="customTopicFact" rows="3" placeholder="Fakt / Kernaussage zum Topic..." class="w-full input-bg border rounded-lg px-4 py-2 text-white"></textarea>
|
|
||||||
<input type="text" id="customTopicSource" placeholder="Quelle (optional)" class="w-full input-bg border rounded-lg px-4 py-2 text-white">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Progress Area -->
|
|
||||||
<div id="progressArea" class="hidden">
|
|
||||||
<div class="bg-brand-bg rounded-lg p-4">
|
|
||||||
<div class="flex items-center justify-between mb-2">
|
|
||||||
<span id="progressMessage" class="text-gray-300">Starte Post-Erstellung...</span>
|
|
||||||
<span id="progressPercent" class="text-gray-400">0%</span>
|
|
||||||
</div>
|
|
||||||
<div class="w-full bg-brand-bg-dark rounded-full h-2">
|
|
||||||
<div id="progressBar" class="bg-brand-highlight h-2 rounded-full transition-all duration-300" style="width: 0%"></div>
|
|
||||||
</div>
|
|
||||||
<div id="iterationInfo" class="mt-2 text-sm text-gray-400"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button type="submit" id="submitBtn" class="w-full btn-primary font-medium py-3 rounded-lg transition-colors flex items-center justify-center gap-2">
|
|
||||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>
|
|
||||||
Post generieren
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
{% if not customers %}
|
|
||||||
<div class="mt-4 bg-yellow-900/30 border border-yellow-600 rounded-lg p-4">
|
|
||||||
<p class="text-yellow-300">Noch keine Kunden vorhanden. <a href="/admin/customers/new" class="underline">Erstelle zuerst einen Kunden</a>.</p>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Right: Result -->
|
|
||||||
<div>
|
|
||||||
<div id="resultArea" class="card-bg rounded-xl border p-6">
|
|
||||||
<h3 class="text-lg font-semibold text-white mb-4">Generierter Post</h3>
|
|
||||||
|
|
||||||
<!-- Live Versions Display -->
|
|
||||||
<div id="liveVersions" class="hidden space-y-4 mb-6">
|
|
||||||
<div class="flex items-center gap-2 mb-2">
|
|
||||||
<span class="text-sm text-gray-400">Live-Vorschau der Iterationen:</span>
|
|
||||||
</div>
|
|
||||||
<div id="versionsContainer" class="space-y-4"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="postResult">
|
|
||||||
<p class="text-gray-400">Wähle einen Kunden und ein Topic, dann klicke auf "Post generieren"...</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block scripts %}
|
|
||||||
<script>
|
|
||||||
const form = document.getElementById('createPostForm');
|
|
||||||
const customerSelect = document.getElementById('customerSelect');
|
|
||||||
const topicSelectionArea = document.getElementById('topicSelectionArea');
|
|
||||||
const topicsList = document.getElementById('topicsList');
|
|
||||||
const submitBtn = document.getElementById('submitBtn');
|
|
||||||
const progressArea = document.getElementById('progressArea');
|
|
||||||
const progressBar = document.getElementById('progressBar');
|
|
||||||
const progressMessage = document.getElementById('progressMessage');
|
|
||||||
const progressPercent = document.getElementById('progressPercent');
|
|
||||||
const iterationInfo = document.getElementById('iterationInfo');
|
|
||||||
const postResult = document.getElementById('postResult');
|
|
||||||
const liveVersions = document.getElementById('liveVersions');
|
|
||||||
const versionsContainer = document.getElementById('versionsContainer');
|
|
||||||
const postTypeSelectionArea = document.getElementById('postTypeSelectionArea');
|
|
||||||
const postTypeCards = document.getElementById('postTypeCards');
|
|
||||||
const selectedPostTypeIdInput = document.getElementById('selectedPostTypeId');
|
|
||||||
|
|
||||||
let selectedTopic = null;
|
|
||||||
let currentVersionIndex = 0;
|
|
||||||
let currentPostTypes = [];
|
|
||||||
let currentTopics = [];
|
|
||||||
|
|
||||||
function renderVersions(versions, feedbackList) {
|
|
||||||
if (!versions || versions.length === 0) {
|
|
||||||
liveVersions.classList.add('hidden');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
liveVersions.classList.remove('hidden');
|
|
||||||
|
|
||||||
// Build version tabs and content
|
|
||||||
let html = `
|
|
||||||
<div class="flex gap-2 mb-4 flex-wrap">
|
|
||||||
${versions.map((_, i) => `
|
|
||||||
<button onclick="showVersion(${i})" id="versionTab${i}"
|
|
||||||
class="px-3 py-1.5 rounded-lg text-sm font-medium transition-colors
|
|
||||||
${i === currentVersionIndex ? 'bg-brand-highlight text-brand-bg-dark' : 'bg-brand-bg text-gray-300 hover:bg-brand-bg-light'}">
|
|
||||||
V${i + 1}
|
|
||||||
${feedbackList[i] ? `<span class="ml-1 text-xs opacity-75">(${feedbackList[i].overall_score || '?'})</span>` : ''}
|
|
||||||
</button>
|
|
||||||
`).join('')}
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
// Show current version
|
|
||||||
const currentVersion = versions[currentVersionIndex];
|
|
||||||
const currentFeedback = feedbackList[currentVersionIndex];
|
|
||||||
|
|
||||||
html += `
|
|
||||||
<div class="grid grid-cols-1 ${currentFeedback ? 'lg:grid-cols-2' : ''} gap-4">
|
|
||||||
<div class="bg-brand-bg/50 rounded-lg p-4">
|
|
||||||
<div class="flex items-center justify-between mb-2">
|
|
||||||
<span class="text-sm font-medium text-gray-300">Version ${currentVersionIndex + 1}</span>
|
|
||||||
${currentFeedback ? `
|
|
||||||
<span class="px-2 py-0.5 text-xs rounded ${currentFeedback.approved ? 'bg-green-600/30 text-green-300' : 'bg-yellow-600/30 text-yellow-300'}">
|
|
||||||
${currentFeedback.approved ? 'Approved' : `Score: ${currentFeedback.overall_score}/100`}
|
|
||||||
</span>
|
|
||||||
` : '<span class="text-xs text-gray-500">Wird bewertet...</span>'}
|
|
||||||
</div>
|
|
||||||
<pre class="whitespace-pre-wrap text-gray-200 font-sans text-sm max-h-96 overflow-y-auto">${currentVersion}</pre>
|
|
||||||
</div>
|
|
||||||
${currentFeedback ? `
|
|
||||||
<div class="bg-brand-bg/30 rounded-lg p-4 border border-brand-bg-light">
|
|
||||||
<span class="text-sm font-medium text-gray-300 block mb-2">Kritik</span>
|
|
||||||
<p class="text-sm text-gray-400 mb-3">${currentFeedback.feedback || 'Keine Kritik'}</p>
|
|
||||||
${currentFeedback.improvements && currentFeedback.improvements.length > 0 ? `
|
|
||||||
<div class="mt-2">
|
|
||||||
<span class="text-xs font-medium text-gray-400">Verbesserungen:</span>
|
|
||||||
<ul class="mt-1 space-y-1">
|
|
||||||
${currentFeedback.improvements.map(imp => `
|
|
||||||
<li class="text-xs text-gray-500 flex items-start gap-1">
|
|
||||||
<span class="text-yellow-500">•</span> ${imp}
|
|
||||||
</li>
|
|
||||||
`).join('')}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
` : ''}
|
|
||||||
${currentFeedback.scores ? `
|
|
||||||
<div class="mt-3 pt-3 border-t border-brand-bg-light">
|
|
||||||
<div class="grid grid-cols-3 gap-2 text-xs">
|
|
||||||
<div class="text-center">
|
|
||||||
<div class="text-gray-500">Authentizität</div>
|
|
||||||
<div class="font-medium text-gray-300">${currentFeedback.scores.authenticity_and_style || '?'}/40</div>
|
|
||||||
</div>
|
|
||||||
<div class="text-center">
|
|
||||||
<div class="text-gray-500">Content</div>
|
|
||||||
<div class="font-medium text-gray-300">${currentFeedback.scores.content_quality || '?'}/35</div>
|
|
||||||
</div>
|
|
||||||
<div class="text-center">
|
|
||||||
<div class="text-gray-500">Technik</div>
|
|
||||||
<div class="font-medium text-gray-300">${currentFeedback.scores.technical_execution || '?'}/25</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
` : ''}
|
|
||||||
</div>
|
|
||||||
` : ''}
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
versionsContainer.innerHTML = html;
|
|
||||||
}
|
|
||||||
|
|
||||||
function showVersion(index) {
|
|
||||||
currentVersionIndex = index;
|
|
||||||
// Get cached versions from progress store
|
|
||||||
const cachedData = window.lastProgressData;
|
|
||||||
if (cachedData) {
|
|
||||||
renderVersions(cachedData.versions, cachedData.feedback_list);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load topics and post types when customer is selected
|
|
||||||
customerSelect.addEventListener('change', async () => {
|
|
||||||
const customerId = customerSelect.value;
|
|
||||||
selectedPostTypeIdInput.value = '';
|
|
||||||
|
|
||||||
if (!customerId) {
|
|
||||||
topicSelectionArea.classList.add('hidden');
|
|
||||||
postTypeSelectionArea.classList.add('hidden');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
topicSelectionArea.classList.remove('hidden');
|
|
||||||
topicsList.innerHTML = '<p class="text-gray-500">Lade Topics...</p>';
|
|
||||||
|
|
||||||
// Load post types
|
|
||||||
try {
|
|
||||||
const ptResponse = await fetch(`/admin/api/customers/${customerId}/post-types`);
|
|
||||||
const ptData = await ptResponse.json();
|
|
||||||
|
|
||||||
if (ptData.post_types && ptData.post_types.length > 0) {
|
|
||||||
currentPostTypes = ptData.post_types;
|
|
||||||
postTypeSelectionArea.classList.remove('hidden');
|
|
||||||
|
|
||||||
postTypeCards.innerHTML = `
|
|
||||||
<button type="button" onclick="selectPostTypeForCreate('')" id="ptc_all"
|
|
||||||
class="px-3 py-2 rounded-lg border text-sm transition-colors bg-brand-highlight/20 border-brand-highlight text-white">
|
|
||||||
Alle Typen
|
|
||||||
</button>
|
|
||||||
` + ptData.post_types.map(pt => `
|
|
||||||
<button type="button" onclick="selectPostTypeForCreate('${pt.id}')" id="ptc_${pt.id}"
|
|
||||||
class="px-3 py-2 rounded-lg border text-sm transition-colors bg-brand-bg border-brand-bg-light hover:border-brand-highlight/50 text-white">
|
|
||||||
${pt.name}
|
|
||||||
${pt.has_analysis ? '<span class="ml-1 text-green-400 text-xs">*</span>' : ''}
|
|
||||||
</button>
|
|
||||||
`).join('');
|
|
||||||
} else {
|
|
||||||
postTypeSelectionArea.classList.add('hidden');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to load post types:', error);
|
|
||||||
postTypeSelectionArea.classList.add('hidden');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load topics
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/admin/api/customers/${customerId}/topics`);
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
if (data.topics && data.topics.length > 0) {
|
|
||||||
renderTopicsList(data);
|
|
||||||
} else {
|
|
||||||
// No topics available - show helpful message
|
|
||||||
let message = '';
|
|
||||||
if (data.used_count > 0) {
|
|
||||||
message = `<div class="text-center py-4">
|
|
||||||
<p class="text-gray-400 mb-2">Alle ${data.used_count} Topics wurden bereits verwendet.</p>
|
|
||||||
<a href="/admin/research" class="text-brand-highlight hover:underline">Neue Topics recherchieren</a>
|
|
||||||
<p class="text-gray-500 text-sm mt-2">oder gib unten ein eigenes Topic ein.</p>
|
|
||||||
</div>`;
|
|
||||||
} else {
|
|
||||||
message = `<div class="text-center py-4">
|
|
||||||
<p class="text-gray-400 mb-2">Keine Topics gefunden.</p>
|
|
||||||
<a href="/admin/research" class="text-brand-highlight hover:underline">Recherche starten</a>
|
|
||||||
<p class="text-gray-500 text-sm mt-2">oder gib unten ein eigenes Topic ein.</p>
|
|
||||||
</div>`;
|
|
||||||
}
|
|
||||||
topicsList.innerHTML = message;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
topicsList.innerHTML = `<p class="text-red-400">Fehler beim Laden: ${error.message}</p>`;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Clear selected topic when custom topic is entered
|
|
||||||
['customTopicTitle', 'customTopicFact', 'customTopicSource'].forEach(id => {
|
|
||||||
document.getElementById(id).addEventListener('input', () => {
|
|
||||||
selectedTopic = null;
|
|
||||||
document.querySelectorAll('input[name="topic"]').forEach(radio => radio.checked = false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
function selectPostTypeForCreate(typeId) {
|
|
||||||
selectedPostTypeIdInput.value = typeId;
|
|
||||||
|
|
||||||
// Update card styles
|
|
||||||
document.querySelectorAll('[id^="ptc_"]').forEach(card => {
|
|
||||||
if (card.id === `ptc_${typeId}` || (typeId === '' && card.id === 'ptc_all')) {
|
|
||||||
card.className = 'px-3 py-2 rounded-lg border text-sm transition-colors bg-brand-highlight/20 border-brand-highlight text-white';
|
|
||||||
} else {
|
|
||||||
card.className = 'px-3 py-2 rounded-lg border text-sm transition-colors bg-brand-bg border-brand-bg-light hover:border-brand-highlight/50 text-white';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Optionally reload topics filtered by post type
|
|
||||||
const customerId = customerSelect.value;
|
|
||||||
if (customerId) {
|
|
||||||
loadTopicsForPostType(customerId, typeId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadTopicsForPostType(customerId, postTypeId) {
|
|
||||||
topicsList.innerHTML = '<p class="text-gray-500">Lade Topics...</p>';
|
|
||||||
|
|
||||||
try {
|
|
||||||
let url = `/api/customers/${customerId}/topics`;
|
|
||||||
if (postTypeId) {
|
|
||||||
url += `?post_type_id=${postTypeId}`;
|
|
||||||
}
|
|
||||||
const response = await fetch(url);
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
if (data.topics && data.topics.length > 0) {
|
|
||||||
renderTopicsList(data);
|
|
||||||
} else {
|
|
||||||
let message = '';
|
|
||||||
if (data.used_count > 0) {
|
|
||||||
message = `<div class="text-center py-4">
|
|
||||||
<p class="text-gray-400 mb-2">Alle ${data.used_count} Topics wurden bereits verwendet.</p>
|
|
||||||
<a href="/admin/research" class="text-brand-highlight hover:underline">Neue Topics recherchieren</a>
|
|
||||||
<p class="text-gray-500 text-sm mt-2">oder gib unten ein eigenes Topic ein.</p>
|
|
||||||
</div>`;
|
|
||||||
} else {
|
|
||||||
message = `<div class="text-center py-4">
|
|
||||||
<p class="text-gray-400 mb-2">Keine Topics gefunden${postTypeId ? ' für diesen Post-Typ' : ''}.</p>
|
|
||||||
<a href="/admin/research" class="text-brand-highlight hover:underline">Recherche starten</a>
|
|
||||||
<p class="text-gray-500 text-sm mt-2">oder gib unten ein eigenes Topic ein.</p>
|
|
||||||
</div>`;
|
|
||||||
}
|
|
||||||
topicsList.innerHTML = message;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
topicsList.innerHTML = `<p class="text-red-400">Fehler beim Laden: ${error.message}</p>`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderTopicsList(data) {
|
|
||||||
// Store topics in global array for safe access
|
|
||||||
currentTopics = data.topics;
|
|
||||||
|
|
||||||
// Reset selected topic when list is re-rendered
|
|
||||||
selectedTopic = null;
|
|
||||||
|
|
||||||
let statsHtml = '';
|
|
||||||
if (data.used_count > 0) {
|
|
||||||
statsHtml = `<p class="text-xs text-gray-500 mb-3">${data.available_count} verfügbar · ${data.used_count} bereits verwendet</p>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
topicsList.innerHTML = statsHtml + data.topics.map((topic, i) => `
|
|
||||||
<label class="flex items-start gap-3 p-3 bg-brand-bg/50 rounded-lg cursor-pointer hover:bg-brand-bg transition-colors border border-transparent hover:border-brand-highlight/30">
|
|
||||||
<input type="radio" name="topic" value="${i}" class="mt-1 text-brand-highlight" data-topic-index="${i}">
|
|
||||||
<div class="flex-1">
|
|
||||||
<div class="flex items-center gap-2 mb-1 flex-wrap">
|
|
||||||
<span class="inline-block px-2 py-0.5 text-xs font-medium bg-brand-highlight/20 text-brand-highlight rounded">${escapeHtml(topic.category || 'Topic')}</span>
|
|
||||||
${topic.target_post_type_id ? `<span class="text-xs text-gray-500">Typ-spezifisch</span>` : ''}
|
|
||||||
${topic.source ? `<span class="text-xs text-gray-500">🔗 ${escapeHtml(topic.source.substring(0, 30))}${topic.source.length > 30 ? '...' : ''}</span>` : ''}
|
|
||||||
</div>
|
|
||||||
<p class="font-medium text-white">${escapeHtml(topic.title)}</p>
|
|
||||||
${topic.angle ? `<p class="text-xs text-brand-highlight/80 mt-1">→ ${escapeHtml(topic.angle)}</p>` : ''}
|
|
||||||
${topic.hook_idea ? `<p class="text-sm text-gray-300 mt-2 italic border-l-2 border-brand-highlight/30 pl-2">"${escapeHtml(topic.hook_idea.substring(0, 120))}${topic.hook_idea.length > 120 ? '...' : ''}"</p>` : ''}
|
|
||||||
${topic.key_facts && topic.key_facts.length > 0 ? `
|
|
||||||
<div class="mt-2 flex flex-wrap gap-1">
|
|
||||||
${topic.key_facts.slice(0, 2).map(f => `<span class="text-xs bg-brand-bg-dark px-2 py-0.5 rounded text-gray-400">📊 ${escapeHtml(f.substring(0, 40))}${f.length > 40 ? '...' : ''}</span>`).join('')}
|
|
||||||
</div>
|
|
||||||
` : (topic.fact ? `<p class="text-sm text-gray-400 mt-1">${escapeHtml(topic.fact.substring(0, 100))}...</p>` : '')}
|
|
||||||
${topic.why_this_person ? `<p class="text-xs text-gray-500 mt-2">💡 ${escapeHtml(topic.why_this_person.substring(0, 80))}${topic.why_this_person.length > 80 ? '...' : ''}</p>` : ''}
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
`).join('');
|
|
||||||
|
|
||||||
// Add event listeners to radio buttons
|
|
||||||
document.querySelectorAll('input[name="topic"]').forEach(radio => {
|
|
||||||
radio.addEventListener('change', () => {
|
|
||||||
const index = parseInt(radio.dataset.topicIndex, 10);
|
|
||||||
selectedTopic = currentTopics[index];
|
|
||||||
// Clear custom topic fields
|
|
||||||
document.getElementById('customTopicTitle').value = '';
|
|
||||||
document.getElementById('customTopicFact').value = '';
|
|
||||||
document.getElementById('customTopicSource').value = '';
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper function to escape HTML special characters
|
|
||||||
function escapeHtml(text) {
|
|
||||||
if (!text) return '';
|
|
||||||
const div = document.createElement('div');
|
|
||||||
div.textContent = text;
|
|
||||||
return div.innerHTML;
|
|
||||||
}
|
|
||||||
|
|
||||||
form.addEventListener('submit', async (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
const customerId = customerSelect.value;
|
|
||||||
if (!customerId) {
|
|
||||||
alert('Bitte wähle einen Kunden aus.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get topic (either selected or custom)
|
|
||||||
let topic;
|
|
||||||
const customTitle = document.getElementById('customTopicTitle').value.trim();
|
|
||||||
const customFact = document.getElementById('customTopicFact').value.trim();
|
|
||||||
|
|
||||||
if (customTitle && customFact) {
|
|
||||||
topic = {
|
|
||||||
title: customTitle,
|
|
||||||
fact: customFact,
|
|
||||||
source: document.getElementById('customTopicSource').value.trim() || null,
|
|
||||||
category: 'Custom'
|
|
||||||
};
|
|
||||||
} else if (selectedTopic) {
|
|
||||||
topic = selectedTopic;
|
|
||||||
} else {
|
|
||||||
alert('Bitte wähle ein Topic aus oder gib ein eigenes ein.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
submitBtn.disabled = true;
|
|
||||||
submitBtn.innerHTML = '<svg class="w-5 h-5 animate-spin" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path></svg> Generiert...';
|
|
||||||
progressArea.classList.remove('hidden');
|
|
||||||
postResult.innerHTML = '<p class="text-gray-400">Post wird generiert...</p>';
|
|
||||||
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append('customer_id', customerId);
|
|
||||||
formData.append('topic_json', JSON.stringify(topic));
|
|
||||||
if (selectedPostTypeIdInput.value) {
|
|
||||||
formData.append('post_type_id', selectedPostTypeIdInput.value);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch('/admin/api/posts', {
|
|
||||||
method: 'POST',
|
|
||||||
body: formData
|
|
||||||
});
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
const taskId = data.task_id;
|
|
||||||
currentVersionIndex = 0;
|
|
||||||
window.lastProgressData = null;
|
|
||||||
|
|
||||||
const pollInterval = setInterval(async () => {
|
|
||||||
const statusResponse = await fetch(`/admin/api/tasks/${taskId}`);
|
|
||||||
const status = await statusResponse.json();
|
|
||||||
|
|
||||||
progressBar.style.width = `${status.progress}%`;
|
|
||||||
progressPercent.textContent = `${status.progress}%`;
|
|
||||||
progressMessage.textContent = status.message;
|
|
||||||
|
|
||||||
if (status.iteration !== undefined) {
|
|
||||||
iterationInfo.textContent = `Iteration ${status.iteration}/${status.max_iterations}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update live versions display
|
|
||||||
if (status.versions && status.versions.length > 0) {
|
|
||||||
window.lastProgressData = status;
|
|
||||||
// Auto-select latest version
|
|
||||||
if (status.versions.length > currentVersionIndex + 1) {
|
|
||||||
currentVersionIndex = status.versions.length - 1;
|
|
||||||
}
|
|
||||||
renderVersions(status.versions, status.feedback_list || []);
|
|
||||||
postResult.innerHTML = '<p class="text-gray-400">Siehe Live-Vorschau oben...</p>';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (status.status === 'completed') {
|
|
||||||
clearInterval(pollInterval);
|
|
||||||
progressArea.classList.add('hidden');
|
|
||||||
submitBtn.disabled = false;
|
|
||||||
submitBtn.innerHTML = '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg> Post generieren';
|
|
||||||
|
|
||||||
// Keep live versions visible but update header
|
|
||||||
const result = status.result;
|
|
||||||
|
|
||||||
postResult.innerHTML = `
|
|
||||||
<div class="space-y-4">
|
|
||||||
<div class="flex items-center gap-2 text-sm flex-wrap">
|
|
||||||
<span class="px-2 py-1 rounded ${result.approved ? 'bg-green-600/30 text-green-300' : 'bg-yellow-600/30 text-yellow-300'}">
|
|
||||||
${result.approved ? 'Approved' : 'Review needed'}
|
|
||||||
</span>
|
|
||||||
<span class="text-gray-400">Score: ${result.final_score}/100</span>
|
|
||||||
<span class="text-gray-400">Iterations: ${result.iterations}</span>
|
|
||||||
</div>
|
|
||||||
<div class="text-sm text-gray-400 mb-2">Finaler Post:</div>
|
|
||||||
<div class="bg-brand-bg/50 rounded-lg p-4">
|
|
||||||
<pre class="whitespace-pre-wrap text-gray-200 font-sans">${result.final_post}</pre>
|
|
||||||
</div>
|
|
||||||
<div class="flex gap-2">
|
|
||||||
<button onclick="copyPost()" class="px-4 py-2 bg-brand-bg hover:bg-brand-bg-light rounded-lg text-sm text-white transition-colors">
|
|
||||||
In Zwischenablage kopieren
|
|
||||||
</button>
|
|
||||||
<button onclick="toggleVersions()" class="px-4 py-2 bg-brand-bg hover:bg-brand-bg-light rounded-lg text-sm text-white transition-colors">
|
|
||||||
Versionen ${liveVersions.classList.contains('hidden') ? 'anzeigen' : 'ausblenden'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
} else if (status.status === 'error') {
|
|
||||||
clearInterval(pollInterval);
|
|
||||||
progressArea.classList.add('hidden');
|
|
||||||
submitBtn.disabled = false;
|
|
||||||
submitBtn.innerHTML = '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg> Post generieren';
|
|
||||||
postResult.innerHTML = `<p class="text-red-400">Fehler: ${status.message}</p>`;
|
|
||||||
}
|
|
||||||
}, 1000);
|
|
||||||
} catch (error) {
|
|
||||||
progressArea.classList.add('hidden');
|
|
||||||
submitBtn.disabled = false;
|
|
||||||
submitBtn.innerHTML = '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg> Post generieren';
|
|
||||||
postResult.innerHTML = `<p class="text-red-400">Fehler: ${error.message}</p>`;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
function copyPost() {
|
|
||||||
const postText = document.querySelector('#postResult pre').textContent;
|
|
||||||
navigator.clipboard.writeText(postText).then(() => {
|
|
||||||
alert('Post in Zwischenablage kopiert!');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleVersions() {
|
|
||||||
liveVersions.classList.toggle('hidden');
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
{% endblock %}
|
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% block title %}Dashboard - LinkedIn Posts{% endblock %}
|
{% block title %}Nutzerverwaltung - Admin{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="mb-8">
|
<div class="mb-8">
|
||||||
<h1 class="text-3xl font-bold text-white mb-2">Dashboard</h1>
|
<h1 class="text-3xl font-bold text-white mb-2">Nutzerverwaltung</h1>
|
||||||
<p class="text-gray-400">Willkommen zum LinkedIn Post Creation System</p>
|
<p class="text-gray-400">Alle Benutzer und Firmen verwalten</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if error %}
|
{% if error %}
|
||||||
@@ -13,85 +13,277 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<!-- Stats -->
|
<!-- Summary Cards -->
|
||||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
|
<div class="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8">
|
||||||
<div class="card-bg rounded-xl border p-6">
|
<div class="card-bg rounded-xl border p-6">
|
||||||
<div class="flex items-center gap-4">
|
<div class="flex items-center gap-4">
|
||||||
<div class="w-12 h-12 bg-brand-highlight/20 rounded-lg flex items-center justify-center">
|
<div class="w-12 h-12 bg-brand-highlight/20 rounded-lg flex items-center justify-center">
|
||||||
<svg class="w-6 h-6 text-brand-highlight" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"/></svg>
|
<svg class="w-6 h-6 text-brand-highlight" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"/></svg>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p class="text-gray-400 text-sm">Kunden</p>
|
<p class="text-gray-400 text-sm">Gesamt Nutzer</p>
|
||||||
<p class="text-2xl font-bold text-white">{{ customers_count or 0 }}</p>
|
<p class="text-2xl font-bold text-white">{{ total_users }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-bg rounded-xl border p-6">
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<div class="w-12 h-12 bg-blue-600/20 rounded-lg flex items-center justify-center">
|
||||||
|
<svg class="w-6 h-6 text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"/></svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-gray-400 text-sm">Firmen</p>
|
||||||
|
<p class="text-2xl font-bold text-white">{{ total_companies }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card-bg rounded-xl border p-6">
|
<div class="card-bg rounded-xl border p-6">
|
||||||
<div class="flex items-center gap-4">
|
<div class="flex items-center gap-4">
|
||||||
<div class="w-12 h-12 bg-green-600/20 rounded-lg flex items-center justify-center">
|
<div class="w-12 h-12 bg-green-600/20 rounded-lg flex items-center justify-center">
|
||||||
<svg class="w-6 h-6 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/></svg>
|
<svg class="w-6 h-6 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"/></svg>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p class="text-gray-400 text-sm">Generierte Posts</p>
|
<p class="text-gray-400 text-sm">Ghostwriter</p>
|
||||||
<p class="text-2xl font-bold text-white">{{ total_posts or 0 }}</p>
|
<p class="text-2xl font-bold text-white">{{ total_ghostwriters }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card-bg rounded-xl border p-6">
|
<div class="card-bg rounded-xl border p-6">
|
||||||
<div class="flex items-center gap-4">
|
<div class="flex items-center gap-4">
|
||||||
<div class="w-12 h-12 bg-brand-highlight/20 rounded-lg flex items-center justify-center">
|
<div class="w-12 h-12 bg-purple-600/20 rounded-lg flex items-center justify-center">
|
||||||
<svg class="w-6 h-6 text-brand-highlight" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"/></svg>
|
<svg class="w-6 h-6 text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197m13.5-9a2.5 2.5 0 11-5 0 2.5 2.5 0 015 0z"/></svg>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p class="text-gray-400 text-sm">AI Agents</p>
|
<p class="text-gray-400 text-sm">Mitarbeiter</p>
|
||||||
<p class="text-2xl font-bold text-white">5</p>
|
<p class="text-2xl font-bold text-white">{{ total_employees }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Quick Actions -->
|
<!-- Search -->
|
||||||
<div class="card-bg rounded-xl border p-6">
|
<div class="mb-6">
|
||||||
<h2 class="text-xl font-semibold text-white mb-4">Schnellaktionen</h2>
|
<input type="text" id="searchInput" placeholder="Suche nach Name oder E-Mail..."
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
class="w-full max-w-md input-bg border rounded-lg px-4 py-2 text-white placeholder-gray-400"
|
||||||
<a href="/admin/customers/new" class="flex items-center gap-3 p-4 bg-brand-bg rounded-lg hover:bg-brand-bg-light transition-colors">
|
oninput="filterUsers()">
|
||||||
<div class="w-10 h-10 btn-primary rounded-lg flex items-center justify-center">
|
</div>
|
||||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"/></svg>
|
|
||||||
|
<!-- Tabs -->
|
||||||
|
<div class="flex gap-2 mb-6">
|
||||||
|
<button onclick="showTab('companies')" id="tab-companies"
|
||||||
|
class="px-5 py-2 rounded-lg font-medium transition-colors btn-primary">
|
||||||
|
Firmen ({{ total_companies }})
|
||||||
|
</button>
|
||||||
|
<button onclick="showTab('ghostwriters')" id="tab-ghostwriters"
|
||||||
|
class="px-5 py-2 rounded-lg font-medium transition-colors bg-brand-bg-light text-gray-300 hover:bg-brand-bg">
|
||||||
|
Ghostwriter ({{ total_ghostwriters }})
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Companies Tab -->
|
||||||
|
<div id="content-companies" class="space-y-4">
|
||||||
|
{% for cd in company_data %}
|
||||||
|
<div class="card-bg rounded-xl border overflow-hidden user-card" data-search="{{ cd.company.name|lower }} {{ cd.owner.email|lower if cd.owner else '' }} {{ cd.owner.display_name|lower if cd.owner and cd.owner.display_name else '' }}">
|
||||||
|
<!-- Company Header (clickable to expand) -->
|
||||||
|
<div class="p-5 cursor-pointer hover:bg-brand-bg-light/50 transition-colors" onclick="toggleCompany('company-{{ cd.company.id }}')">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<div class="w-10 h-10 bg-blue-600/30 rounded-lg flex items-center justify-center">
|
||||||
|
<svg class="w-5 h-5 text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"/></svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 class="text-lg font-semibold text-white">{{ cd.company.name }}</h3>
|
||||||
|
<p class="text-sm text-gray-400">
|
||||||
|
Owner: {{ cd.owner.display_name or cd.owner.email if cd.owner else 'Unbekannt' }}
|
||||||
|
| {{ cd.employee_count }} Mitarbeiter
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
{% if cd.owner %}
|
||||||
|
<a href="/admin/impersonate/{{ cd.owner.id }}" class="px-3 py-1.5 text-xs bg-brand-highlight/20 text-brand-highlight rounded-lg hover:bg-brand-highlight/30 transition-colors"
|
||||||
|
title="Als Owner einloggen" onclick="event.stopPropagation()">
|
||||||
|
Impersonate
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
<svg class="w-5 h-5 text-gray-400 transition-transform" id="chevron-company-{{ cd.company.id }}" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/></svg>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
</div>
|
||||||
<p class="font-medium text-white">Neuer Kunde</p>
|
|
||||||
<p class="text-sm text-gray-400">Setup starten</p>
|
<!-- Expandable Details -->
|
||||||
|
<div id="company-{{ cd.company.id }}" class="hidden border-t border-gray-600">
|
||||||
|
<!-- Owner -->
|
||||||
|
{% if cd.owner %}
|
||||||
|
<div class="px-5 py-3 bg-brand-bg/30 border-b border-gray-600/50">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<span class="text-xs font-medium px-2 py-0.5 bg-blue-600/30 text-blue-300 rounded">OWNER</span>
|
||||||
|
<span class="text-sm text-white">{{ cd.owner.display_name or cd.owner.email }}</span>
|
||||||
|
<span class="text-xs text-gray-400">{{ cd.owner.email }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="text-xs text-gray-400">{{ cd.owner.onboarding_status.value if cd.owner.onboarding_status and cd.owner.onboarding_status.value else cd.owner.onboarding_status }}</span>
|
||||||
|
<button onclick="deleteUser('{{ cd.owner.id }}', '{{ cd.owner.display_name or cd.owner.email }}')"
|
||||||
|
class="text-red-400 hover:text-red-300 p-1" title="Nutzer löschen">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
{% endif %}
|
||||||
<a href="/admin/research" class="flex items-center gap-3 p-4 bg-brand-bg rounded-lg hover:bg-brand-bg-light transition-colors">
|
|
||||||
<div class="w-10 h-10 btn-primary rounded-lg flex items-center justify-center">
|
<!-- Employees -->
|
||||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg>
|
{% for emp in cd.employees %}
|
||||||
|
<div class="px-5 py-3 border-b border-gray-600/30 hover:bg-brand-bg/20">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<span class="text-xs font-medium px-2 py-0.5 bg-purple-600/30 text-purple-300 rounded">EMPLOYEE</span>
|
||||||
|
<span class="text-sm text-white">{{ emp.display_name or emp.email }}</span>
|
||||||
|
<span class="text-xs text-gray-400">{{ emp.email }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="text-xs text-gray-400">{{ emp.onboarding_status.value if emp.onboarding_status and emp.onboarding_status.value else emp.onboarding_status }}</span>
|
||||||
|
<a href="/admin/impersonate/{{ emp.id }}" class="text-brand-highlight hover:text-brand-highlight-dark p-1" title="Als Mitarbeiter einloggen">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 16l-4-4m0 0l4-4m-4 4h14m-5 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h7a3 3 0 013 3v1"/></svg>
|
||||||
|
</a>
|
||||||
|
<button onclick="deleteUser('{{ emp.id }}', '{{ emp.display_name or emp.email }}')"
|
||||||
|
class="text-red-400 hover:text-red-300 p-1" title="Nutzer löschen">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
{% endfor %}
|
||||||
<p class="font-medium text-white">Research</p>
|
|
||||||
<p class="text-sm text-gray-400">Topics finden</p>
|
{% if cd.employees|length == 0 %}
|
||||||
|
<div class="px-5 py-3 text-sm text-gray-500 italic">Keine Mitarbeiter</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
{% if company_data|length == 0 %}
|
||||||
|
<div class="card-bg rounded-xl border p-8 text-center text-gray-400">
|
||||||
|
Keine Firmen vorhanden
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Ghostwriters Tab -->
|
||||||
|
<div id="content-ghostwriters" class="hidden space-y-3">
|
||||||
|
{% for gw in ghostwriters %}
|
||||||
|
{% set profile = gw %}
|
||||||
|
<div class="card-bg rounded-xl border p-5 user-card" data-search="{{ gw.display_name|lower if gw.display_name else '' }} {{ gw.email|lower }}">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<div class="w-10 h-10 bg-green-600/30 rounded-lg flex items-center justify-center">
|
||||||
|
<svg class="w-5 h-5 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"/></svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 class="text-white font-medium">{{ gw.display_name or gw.email }}</h3>
|
||||||
|
<p class="text-sm text-gray-400">
|
||||||
|
{{ gw.email }}
|
||||||
|
| Status: {{ gw.onboarding_status.value if gw.onboarding_status and gw.onboarding_status.value else gw.onboarding_status }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
<div class="flex items-center gap-2">
|
||||||
<a href="/admin/create" class="flex items-center gap-3 p-4 bg-brand-bg rounded-lg hover:bg-brand-bg-light transition-colors">
|
<a href="/admin/impersonate/{{ gw.id }}" class="px-3 py-1.5 text-xs bg-brand-highlight/20 text-brand-highlight rounded-lg hover:bg-brand-highlight/30 transition-colors">
|
||||||
<div class="w-10 h-10 btn-primary rounded-lg flex items-center justify-center">
|
Impersonate
|
||||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>
|
</a>
|
||||||
|
<button onclick="deleteUser('{{ gw.id }}', '{{ gw.display_name or gw.email }}')"
|
||||||
|
class="text-red-400 hover:text-red-300 p-1.5 rounded-lg hover:bg-red-400/10 transition-colors" title="Nutzer löschen">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
</div>
|
||||||
<p class="font-medium text-white">Post erstellen</p>
|
</div>
|
||||||
<p class="text-sm text-gray-400">Content generieren</p>
|
{% endfor %}
|
||||||
</div>
|
|
||||||
</a>
|
{% if ghostwriters|length == 0 %}
|
||||||
<a href="/admin/posts" class="flex items-center gap-3 p-4 bg-brand-bg rounded-lg hover:bg-brand-bg-light transition-colors">
|
<div class="card-bg rounded-xl border p-8 text-center text-gray-400">
|
||||||
<div class="w-10 h-10 btn-primary rounded-lg flex items-center justify-center">
|
Keine Ghostwriter vorhanden
|
||||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"/></svg>
|
</div>
|
||||||
</div>
|
{% endif %}
|
||||||
<div>
|
</div>
|
||||||
<p class="font-medium text-white">Alle Posts</p>
|
|
||||||
<p class="text-sm text-gray-400">Übersicht anzeigen</p>
|
<!-- Delete Confirmation Modal -->
|
||||||
</div>
|
<div id="deleteModal" class="hidden fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||||
</a>
|
<div class="card-bg rounded-xl border p-6 max-w-md w-full mx-4">
|
||||||
|
<h3 class="text-lg font-semibold text-white mb-2">Nutzer löschen</h3>
|
||||||
|
<p class="text-gray-300 mb-4">Bist du sicher, dass du <strong id="deleteUserName" class="text-red-400"></strong> und alle zugehörigen Daten löschen möchtest? Dies kann nicht rückgängig gemacht werden.</p>
|
||||||
|
<div class="flex justify-end gap-3">
|
||||||
|
<button onclick="closeDeleteModal()" class="px-4 py-2 rounded-lg bg-brand-bg-light text-gray-300 hover:bg-brand-bg transition-colors">Abbrechen</button>
|
||||||
|
<button onclick="confirmDelete()" class="px-4 py-2 rounded-lg bg-red-600 text-white hover:bg-red-700 transition-colors">Löschen</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script>
|
||||||
|
let currentTab = 'companies';
|
||||||
|
let deleteUserId = null;
|
||||||
|
|
||||||
|
function showTab(tab) {
|
||||||
|
document.getElementById('content-companies').classList.toggle('hidden', tab !== 'companies');
|
||||||
|
document.getElementById('content-ghostwriters').classList.toggle('hidden', tab !== 'ghostwriters');
|
||||||
|
|
||||||
|
document.getElementById('tab-companies').className = tab === 'companies'
|
||||||
|
? 'px-5 py-2 rounded-lg font-medium transition-colors btn-primary'
|
||||||
|
: 'px-5 py-2 rounded-lg font-medium transition-colors bg-brand-bg-light text-gray-300 hover:bg-brand-bg';
|
||||||
|
document.getElementById('tab-ghostwriters').className = tab === 'ghostwriters'
|
||||||
|
? 'px-5 py-2 rounded-lg font-medium transition-colors btn-primary'
|
||||||
|
: 'px-5 py-2 rounded-lg font-medium transition-colors bg-brand-bg-light text-gray-300 hover:bg-brand-bg';
|
||||||
|
|
||||||
|
currentTab = tab;
|
||||||
|
filterUsers();
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleCompany(id) {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
const chevron = document.getElementById('chevron-' + id);
|
||||||
|
el.classList.toggle('hidden');
|
||||||
|
chevron.style.transform = el.classList.contains('hidden') ? '' : 'rotate(180deg)';
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterUsers() {
|
||||||
|
const query = document.getElementById('searchInput').value.toLowerCase().trim();
|
||||||
|
const container = document.getElementById('content-' + currentTab);
|
||||||
|
const cards = container.querySelectorAll('.user-card');
|
||||||
|
|
||||||
|
cards.forEach(card => {
|
||||||
|
const search = card.getAttribute('data-search') || '';
|
||||||
|
card.style.display = !query || search.includes(query) ? '' : 'none';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteUser(userId, name) {
|
||||||
|
deleteUserId = userId;
|
||||||
|
document.getElementById('deleteUserName').textContent = name;
|
||||||
|
document.getElementById('deleteModal').classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeDeleteModal() {
|
||||||
|
document.getElementById('deleteModal').classList.add('hidden');
|
||||||
|
deleteUserId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmDelete() {
|
||||||
|
if (!deleteUserId) return;
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`/admin/api/users/${deleteUserId}`, { method: 'DELETE' });
|
||||||
|
if (resp.ok) {
|
||||||
|
window.location.reload();
|
||||||
|
} else {
|
||||||
|
const data = await resp.json();
|
||||||
|
alert('Fehler: ' + (data.detail || 'Unbekannter Fehler'));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
alert('Fehler: ' + e.message);
|
||||||
|
}
|
||||||
|
closeDeleteModal();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
|
|||||||
304
src/web/templates/admin/license_keys.html
Normal file
304
src/web/templates/admin/license_keys.html
Normal file
@@ -0,0 +1,304 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Lizenzschlüssel - Admin{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="mb-8">
|
||||||
|
<h1 class="text-3xl font-bold text-white mb-2">Lizenzschlüssel</h1>
|
||||||
|
<p class="text-gray-400">Verwalte Lizenzschlüssel für Unternehmensregistrierungen</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if error %}
|
||||||
|
<div class="bg-red-900/50 border border-red-500 text-red-200 px-4 py-3 rounded-lg mb-6">
|
||||||
|
<strong>Error:</strong> {{ error }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Summary Cards -->
|
||||||
|
<div class="grid grid-cols-3 gap-4 mb-6">
|
||||||
|
<div class="card-bg rounded-xl border p-6">
|
||||||
|
<p class="text-gray-400 text-sm mb-1">Gesamt</p>
|
||||||
|
<p class="text-3xl font-bold text-white">{{ total_keys }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="card-bg rounded-xl border p-6">
|
||||||
|
<p class="text-gray-400 text-sm mb-1">Verfügbar</p>
|
||||||
|
<p class="text-3xl font-bold text-green-400">{{ available_keys }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="card-bg rounded-xl border p-6">
|
||||||
|
<p class="text-gray-400 text-sm mb-1">Verwendet</p>
|
||||||
|
<p class="text-3xl font-bold text-gray-500">{{ used_keys }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Generate Button -->
|
||||||
|
<button onclick="openGenerateModal()"
|
||||||
|
class="px-6 py-3 rounded-lg font-medium transition-colors btn-primary mb-6 flex items-center gap-2">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/>
|
||||||
|
</svg>
|
||||||
|
Neuen Schlüssel generieren
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- License Keys Table -->
|
||||||
|
<div class="card-bg rounded-xl border overflow-hidden">
|
||||||
|
<table class="w-full">
|
||||||
|
<thead class="bg-brand-bg-dark border-b border-brand-bg-light">
|
||||||
|
<tr>
|
||||||
|
<th class="px-6 py-4 text-left text-xs font-medium text-gray-400 uppercase">Schlüssel</th>
|
||||||
|
<th class="px-6 py-4 text-left text-xs font-medium text-gray-400 uppercase">Limits</th>
|
||||||
|
<th class="px-6 py-4 text-left text-xs font-medium text-gray-400 uppercase">Status</th>
|
||||||
|
<th class="px-6 py-4 text-left text-xs font-medium text-gray-400 uppercase">Erstellt</th>
|
||||||
|
<th class="px-6 py-4 text-right text-xs font-medium text-gray-400 uppercase">Aktionen</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-brand-bg-light">
|
||||||
|
{% for key in keys %}
|
||||||
|
<tr class="hover:bg-brand-bg/30">
|
||||||
|
<td class="px-6 py-4">
|
||||||
|
<div class="font-mono text-white font-medium">{{ key.key }}</div>
|
||||||
|
{% if key.description %}
|
||||||
|
<div class="text-sm text-gray-400 mt-1">{{ key.description }}</div>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4">
|
||||||
|
<div class="text-sm text-gray-300 space-y-1">
|
||||||
|
<div>👥 {{ key.max_employees }} Mitarbeiter</div>
|
||||||
|
<div>📝 {{ key.max_posts_per_day }} Posts/Tag</div>
|
||||||
|
<div>🔍 {{ key.max_researches_per_day }} Researches/Tag</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4">
|
||||||
|
{% if key.used %}
|
||||||
|
<span class="px-3 py-1 bg-gray-600/30 text-gray-400 rounded-lg text-sm">
|
||||||
|
Verwendet
|
||||||
|
</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="px-3 py-1 bg-green-600/30 text-green-400 rounded-lg text-sm">
|
||||||
|
Verfügbar
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 text-sm text-gray-400">
|
||||||
|
{{ key.created_at.strftime('%d.%m.%Y') if key.created_at else '-' }}
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 text-right">
|
||||||
|
<button onclick="editKey('{{ key.id }}', {{ key.max_employees }}, {{ key.max_posts_per_day }}, {{ key.max_researches_per_day }}, '{{ key.description or '' }}')"
|
||||||
|
class="text-blue-400 hover:text-blue-300 p-2 rounded transition-colors"
|
||||||
|
title="Bearbeiten">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{% if not key.used %}
|
||||||
|
<button onclick="copyKey('{{ key.key }}')"
|
||||||
|
class="text-brand-highlight hover:text-brand-highlight-dark p-2 rounded transition-colors"
|
||||||
|
title="Kopieren">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button onclick="deleteKey('{{ key.id }}')"
|
||||||
|
class="text-red-400 hover:text-red-300 p-2 rounded transition-colors"
|
||||||
|
title="Löschen">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Edit Modal -->
|
||||||
|
<div id="editModal" class="hidden fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||||
|
<div class="card-bg rounded-xl border p-8 max-w-md w-full mx-4">
|
||||||
|
<h2 class="text-2xl font-bold text-white mb-6">Lizenzschlüssel bearbeiten</h2>
|
||||||
|
|
||||||
|
<form id="editForm" class="space-y-4">
|
||||||
|
<input type="hidden" id="edit_key_id" name="key_id">
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-300 mb-2">
|
||||||
|
Max. Mitarbeiter
|
||||||
|
</label>
|
||||||
|
<input type="number" id="edit_max_employees" name="max_employees" min="1" required
|
||||||
|
class="w-full px-4 py-3 bg-brand-bg border border-brand-bg-light rounded-lg text-white">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-300 mb-2">
|
||||||
|
Max. Posts pro Tag
|
||||||
|
</label>
|
||||||
|
<input type="number" id="edit_max_posts_per_day" name="max_posts_per_day" min="1" required
|
||||||
|
class="w-full px-4 py-3 bg-brand-bg border border-brand-bg-light rounded-lg text-white">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-300 mb-2">
|
||||||
|
Max. Researches pro Tag
|
||||||
|
</label>
|
||||||
|
<input type="number" id="edit_max_researches_per_day" name="max_researches_per_day" min="1" required
|
||||||
|
class="w-full px-4 py-3 bg-brand-bg border border-brand-bg-light rounded-lg text-white">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-300 mb-2">
|
||||||
|
Beschreibung (optional)
|
||||||
|
</label>
|
||||||
|
<input type="text" id="edit_description" name="description" placeholder="z.B. Starter Plan, Premium Plan..."
|
||||||
|
class="w-full px-4 py-3 bg-brand-bg border border-brand-bg-light rounded-lg text-white">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-3 pt-4">
|
||||||
|
<button type="submit" class="flex-1 px-6 py-3 btn-primary rounded-lg font-medium">
|
||||||
|
Speichern
|
||||||
|
</button>
|
||||||
|
<button type="button" onclick="closeEditModal()"
|
||||||
|
class="px-6 py-3 bg-gray-600 hover:bg-gray-700 text-white rounded-lg font-medium transition-colors">
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Generate Modal -->
|
||||||
|
<div id="generateModal" class="hidden fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||||
|
<div class="card-bg rounded-xl border p-8 max-w-md w-full mx-4">
|
||||||
|
<h2 class="text-2xl font-bold text-white mb-6">Neuen Schlüssel generieren</h2>
|
||||||
|
|
||||||
|
<form id="generateForm" class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-300 mb-2">
|
||||||
|
Max. Mitarbeiter
|
||||||
|
</label>
|
||||||
|
<input type="number" name="max_employees" min="1" value="5" required
|
||||||
|
class="w-full px-4 py-3 bg-brand-bg border border-brand-bg-light rounded-lg text-white">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-300 mb-2">
|
||||||
|
Max. Posts pro Tag
|
||||||
|
</label>
|
||||||
|
<input type="number" name="max_posts_per_day" min="1" value="10" required
|
||||||
|
class="w-full px-4 py-3 bg-brand-bg border border-brand-bg-light rounded-lg text-white">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-300 mb-2">
|
||||||
|
Max. Researches pro Tag
|
||||||
|
</label>
|
||||||
|
<input type="number" name="max_researches_per_day" min="1" value="5" required
|
||||||
|
class="w-full px-4 py-3 bg-brand-bg border border-brand-bg-light rounded-lg text-white">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-300 mb-2">
|
||||||
|
Beschreibung (optional)
|
||||||
|
</label>
|
||||||
|
<input type="text" name="description" placeholder="z.B. Starter Plan, Premium Plan..."
|
||||||
|
class="w-full px-4 py-3 bg-brand-bg border border-brand-bg-light rounded-lg text-white">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-3 pt-4">
|
||||||
|
<button type="submit" class="flex-1 px-6 py-3 btn-primary rounded-lg font-medium">
|
||||||
|
Generieren
|
||||||
|
</button>
|
||||||
|
<button type="button" onclick="closeGenerateModal()"
|
||||||
|
class="px-6 py-3 bg-gray-600 hover:bg-gray-700 text-white rounded-lg font-medium transition-colors">
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script>
|
||||||
|
function openGenerateModal() {
|
||||||
|
document.getElementById('generateModal').classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeGenerateModal() {
|
||||||
|
document.getElementById('generateModal').classList.add('hidden');
|
||||||
|
document.getElementById('generateForm').reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
function editKey(keyId, maxEmployees, maxPostsPerDay, maxResearchesPerDay, description) {
|
||||||
|
document.getElementById('edit_key_id').value = keyId;
|
||||||
|
document.getElementById('edit_max_employees').value = maxEmployees;
|
||||||
|
document.getElementById('edit_max_posts_per_day').value = maxPostsPerDay;
|
||||||
|
document.getElementById('edit_max_researches_per_day').value = maxResearchesPerDay;
|
||||||
|
document.getElementById('edit_description').value = description;
|
||||||
|
document.getElementById('editModal').classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeEditModal() {
|
||||||
|
document.getElementById('editModal').classList.add('hidden');
|
||||||
|
document.getElementById('editForm').reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('editForm').addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const formData = new FormData(e.target);
|
||||||
|
const keyId = formData.get('key_id');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/admin/api/license-keys/${keyId}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error('Update failed');
|
||||||
|
|
||||||
|
alert('Lizenzschlüssel aktualisiert!');
|
||||||
|
location.reload();
|
||||||
|
} catch (error) {
|
||||||
|
alert('Fehler beim Aktualisieren: ' + error.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('generateForm').addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const formData = new FormData(e.target);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/admin/api/license-keys/generate', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error('Generation failed');
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
alert(`Schlüssel generiert: ${data.key.key}\n\nBitte kopiere diesen Schlüssel jetzt!`);
|
||||||
|
location.reload();
|
||||||
|
} catch (error) {
|
||||||
|
alert('Fehler beim Generieren: ' + error.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function copyKey(key) {
|
||||||
|
navigator.clipboard.writeText(key);
|
||||||
|
alert('Schlüssel kopiert: ' + key);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteKey(keyId) {
|
||||||
|
if (!confirm('Schlüssel wirklich löschen?')) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/admin/api/license-keys/${keyId}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error('Delete failed');
|
||||||
|
location.reload();
|
||||||
|
} catch (error) {
|
||||||
|
alert('Fehler beim Löschen: ' + error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -1,274 +0,0 @@
|
|||||||
{% extends "base.html" %}
|
|
||||||
{% block title %}Neuer Kunde - LinkedIn Posts{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<div class="mb-8">
|
|
||||||
<h1 class="text-3xl font-bold text-white mb-2">Neuer Kunde</h1>
|
|
||||||
<p class="text-gray-400">Richte einen neuen Kunden ein und starte das initiale Setup</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="max-w-2xl">
|
|
||||||
<form id="customerForm" class="card-bg rounded-xl border p-6 space-y-6">
|
|
||||||
<!-- Basic Info -->
|
|
||||||
<div>
|
|
||||||
<h3 class="text-lg font-semibold text-white mb-4">Basis-Informationen</h3>
|
|
||||||
<div class="grid gap-4">
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-gray-300 mb-2">Name *</label>
|
|
||||||
<input type="text" name="name" required class="w-full input-bg border rounded-lg px-4 py-2 text-white">
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-gray-300 mb-2">LinkedIn URL *</label>
|
|
||||||
<input type="url" name="linkedin_url" required placeholder="https://www.linkedin.com/in/username" class="w-full input-bg border rounded-lg px-4 py-2 text-white">
|
|
||||||
</div>
|
|
||||||
<div class="grid grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-gray-300 mb-2">Firma</label>
|
|
||||||
<input type="text" name="company_name" class="w-full input-bg border rounded-lg px-4 py-2 text-white">
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-gray-300 mb-2">E-Mail</label>
|
|
||||||
<input type="email" name="email" class="w-full input-bg border rounded-lg px-4 py-2 text-white">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Persona -->
|
|
||||||
<div>
|
|
||||||
<h3 class="text-lg font-semibold text-white mb-4">Persona & Stil</h3>
|
|
||||||
<div class="grid gap-4">
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-gray-300 mb-2">Persona</label>
|
|
||||||
<textarea name="persona" rows="3" placeholder="Beschreibe die Expertise, Positionierung und den Charakter der Person..." class="w-full input-bg border rounded-lg px-4 py-2 text-white"></textarea>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-gray-300 mb-2">Ansprache</label>
|
|
||||||
<input type="text" name="form_of_address" placeholder="z.B. Duzen (Du/Euch) oder Siezen (Sie)" class="w-full input-bg border rounded-lg px-4 py-2 text-white">
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-gray-300 mb-2">Style Guide</label>
|
|
||||||
<textarea name="style_guide" rows="3" placeholder="Beschreibe den Schreibstil, Tonalität und Richtlinien..." class="w-full input-bg border rounded-lg px-4 py-2 text-white"></textarea>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Post Types -->
|
|
||||||
<div>
|
|
||||||
<div class="flex items-center justify-between mb-4">
|
|
||||||
<h3 class="text-lg font-semibold text-white">Post-Typen</h3>
|
|
||||||
<button type="button" id="addPostTypeBtn" class="text-sm text-brand-highlight hover:underline">+ Post-Typ hinzufügen</button>
|
|
||||||
</div>
|
|
||||||
<p class="text-sm text-gray-400 mb-4">Definiere verschiedene Arten von Posts (z.B. "Thought Leader", "Case Study", "How-To"). Diese werden zur Kategorisierung und typ-spezifischen Analyse verwendet.</p>
|
|
||||||
|
|
||||||
<div id="postTypesContainer" class="space-y-4">
|
|
||||||
<!-- Post type entries will be added here -->
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Progress Area -->
|
|
||||||
<div id="progressArea" class="hidden">
|
|
||||||
<div class="bg-brand-bg rounded-lg p-4">
|
|
||||||
<div class="flex items-center justify-between mb-2">
|
|
||||||
<span id="progressMessage" class="text-gray-300">Starte Setup...</span>
|
|
||||||
<span id="progressPercent" class="text-gray-400">0%</span>
|
|
||||||
</div>
|
|
||||||
<div class="w-full bg-brand-bg-dark rounded-full h-2">
|
|
||||||
<div id="progressBar" class="bg-brand-highlight h-2 rounded-full transition-all duration-300" style="width: 0%"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Result Area -->
|
|
||||||
<div id="resultArea" class="hidden">
|
|
||||||
<div id="successResult" class="hidden bg-green-900/30 border border-green-500 rounded-lg p-4">
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<svg class="w-6 h-6 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg>
|
|
||||||
<span class="text-green-300">Setup erfolgreich abgeschlossen!</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div id="errorResult" class="hidden bg-red-900/30 border border-red-500 rounded-lg p-4">
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<svg class="w-6 h-6 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>
|
|
||||||
<span id="errorMessage" class="text-red-300">Fehler beim Setup</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Submit -->
|
|
||||||
<div class="flex gap-4">
|
|
||||||
<button type="submit" id="submitBtn" class="flex-1 btn-primary font-medium py-3 rounded-lg transition-colors">
|
|
||||||
Setup starten
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block scripts %}
|
|
||||||
<script>
|
|
||||||
const form = document.getElementById('customerForm');
|
|
||||||
const submitBtn = document.getElementById('submitBtn');
|
|
||||||
const progressArea = document.getElementById('progressArea');
|
|
||||||
const resultArea = document.getElementById('resultArea');
|
|
||||||
const progressBar = document.getElementById('progressBar');
|
|
||||||
const progressMessage = document.getElementById('progressMessage');
|
|
||||||
const progressPercent = document.getElementById('progressPercent');
|
|
||||||
const postTypesContainer = document.getElementById('postTypesContainer');
|
|
||||||
const addPostTypeBtn = document.getElementById('addPostTypeBtn');
|
|
||||||
|
|
||||||
let postTypeIndex = 0;
|
|
||||||
|
|
||||||
function createPostTypeEntry() {
|
|
||||||
const index = postTypeIndex++;
|
|
||||||
const entry = document.createElement('div');
|
|
||||||
entry.className = 'bg-brand-bg rounded-lg p-4 border border-brand-bg-light';
|
|
||||||
entry.id = `postType_${index}`;
|
|
||||||
entry.innerHTML = `
|
|
||||||
<div class="flex justify-between items-start mb-3">
|
|
||||||
<span class="text-sm font-medium text-gray-300">Post-Typ ${index + 1}</span>
|
|
||||||
<button type="button" onclick="removePostType(${index})" class="text-red-400 hover:text-red-300 text-sm">Entfernen</button>
|
|
||||||
</div>
|
|
||||||
<div class="grid gap-3">
|
|
||||||
<div class="grid grid-cols-2 gap-3">
|
|
||||||
<div>
|
|
||||||
<label class="block text-xs text-gray-400 mb-1">Name *</label>
|
|
||||||
<input type="text" data-pt-field="name" data-pt-index="${index}" required placeholder="z.B. Thought Leader" class="w-full input-bg border rounded-lg px-3 py-2 text-white text-sm">
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="block text-xs text-gray-400 mb-1">Beschreibung</label>
|
|
||||||
<input type="text" data-pt-field="description" data-pt-index="${index}" placeholder="Kurze Beschreibung" class="w-full input-bg border rounded-lg px-3 py-2 text-white text-sm">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="block text-xs text-gray-400 mb-1">Identifizierende Hashtags (kommagetrennt)</label>
|
|
||||||
<input type="text" data-pt-field="hashtags" data-pt-index="${index}" placeholder="#ThoughtLeader, #Insight, #Leadership" class="w-full input-bg border rounded-lg px-3 py-2 text-white text-sm">
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="block text-xs text-gray-400 mb-1">Keywords (kommagetrennt)</label>
|
|
||||||
<input type="text" data-pt-field="keywords" data-pt-index="${index}" placeholder="Erfahrung, Learnings, Meinung" class="w-full input-bg border rounded-lg px-3 py-2 text-white text-sm">
|
|
||||||
</div>
|
|
||||||
<details class="mt-2">
|
|
||||||
<summary class="text-xs text-gray-400 cursor-pointer hover:text-gray-300">Erweiterte Eigenschaften</summary>
|
|
||||||
<div class="mt-3 grid gap-3">
|
|
||||||
<div>
|
|
||||||
<label class="block text-xs text-gray-400 mb-1">Zweck</label>
|
|
||||||
<input type="text" data-pt-field="purpose" data-pt-index="${index}" placeholder="z.B. Expertise zeigen, Meinungsführerschaft etablieren" class="w-full input-bg border rounded-lg px-3 py-2 text-white text-sm">
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="block text-xs text-gray-400 mb-1">Typische Tonalität</label>
|
|
||||||
<input type="text" data-pt-field="tone" data-pt-index="${index}" placeholder="z.B. reflektiert, provokativ, inspirierend" class="w-full input-bg border rounded-lg px-3 py-2 text-white text-sm">
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="block text-xs text-gray-400 mb-1">Zielgruppe</label>
|
|
||||||
<input type="text" data-pt-field="target_audience" data-pt-index="${index}" placeholder="z.B. Führungskräfte, Entscheider" class="w-full input-bg border rounded-lg px-3 py-2 text-white text-sm">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</details>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
postTypesContainer.appendChild(entry);
|
|
||||||
}
|
|
||||||
|
|
||||||
function removePostType(index) {
|
|
||||||
const entry = document.getElementById(`postType_${index}`);
|
|
||||||
if (entry) {
|
|
||||||
entry.remove();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function collectPostTypes() {
|
|
||||||
const postTypes = [];
|
|
||||||
const entries = postTypesContainer.querySelectorAll('[id^="postType_"]');
|
|
||||||
|
|
||||||
entries.forEach(entry => {
|
|
||||||
const index = entry.id.split('_')[1];
|
|
||||||
const name = entry.querySelector(`[data-pt-field="name"][data-pt-index="${index}"]`)?.value?.trim();
|
|
||||||
|
|
||||||
if (name) {
|
|
||||||
const hashtagsRaw = entry.querySelector(`[data-pt-field="hashtags"][data-pt-index="${index}"]`)?.value || '';
|
|
||||||
const keywordsRaw = entry.querySelector(`[data-pt-field="keywords"][data-pt-index="${index}"]`)?.value || '';
|
|
||||||
|
|
||||||
postTypes.push({
|
|
||||||
name: name,
|
|
||||||
description: entry.querySelector(`[data-pt-field="description"][data-pt-index="${index}"]`)?.value?.trim() || null,
|
|
||||||
identifying_hashtags: hashtagsRaw.split(',').map(h => h.trim()).filter(h => h),
|
|
||||||
identifying_keywords: keywordsRaw.split(',').map(k => k.trim()).filter(k => k),
|
|
||||||
semantic_properties: {
|
|
||||||
purpose: entry.querySelector(`[data-pt-field="purpose"][data-pt-index="${index}"]`)?.value?.trim() || null,
|
|
||||||
typical_tone: entry.querySelector(`[data-pt-field="tone"][data-pt-index="${index}"]`)?.value?.trim() || null,
|
|
||||||
target_audience: entry.querySelector(`[data-pt-field="target_audience"][data-pt-index="${index}"]`)?.value?.trim() || null
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return postTypes;
|
|
||||||
}
|
|
||||||
|
|
||||||
addPostTypeBtn.addEventListener('click', createPostTypeEntry);
|
|
||||||
|
|
||||||
form.addEventListener('submit', async (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
submitBtn.disabled = true;
|
|
||||||
submitBtn.textContent = 'Wird gestartet...';
|
|
||||||
progressArea.classList.remove('hidden');
|
|
||||||
resultArea.classList.add('hidden');
|
|
||||||
|
|
||||||
const formData = new FormData(form);
|
|
||||||
|
|
||||||
// Add post types as JSON
|
|
||||||
const postTypes = collectPostTypes();
|
|
||||||
if (postTypes.length > 0) {
|
|
||||||
formData.append('post_types_json', JSON.stringify(postTypes));
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch('/admin/api/customers', {
|
|
||||||
method: 'POST',
|
|
||||||
body: formData
|
|
||||||
});
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
// Poll for progress
|
|
||||||
const taskId = data.task_id;
|
|
||||||
const pollInterval = setInterval(async () => {
|
|
||||||
const statusResponse = await fetch(`/admin/api/tasks/${taskId}`);
|
|
||||||
const status = await statusResponse.json();
|
|
||||||
|
|
||||||
progressBar.style.width = `${status.progress}%`;
|
|
||||||
progressPercent.textContent = `${status.progress}%`;
|
|
||||||
progressMessage.textContent = status.message;
|
|
||||||
|
|
||||||
if (status.status === 'completed') {
|
|
||||||
clearInterval(pollInterval);
|
|
||||||
progressArea.classList.add('hidden');
|
|
||||||
resultArea.classList.remove('hidden');
|
|
||||||
document.getElementById('successResult').classList.remove('hidden');
|
|
||||||
submitBtn.textContent = 'Setup starten';
|
|
||||||
submitBtn.disabled = false;
|
|
||||||
form.reset();
|
|
||||||
postTypesContainer.innerHTML = '';
|
|
||||||
postTypeIndex = 0;
|
|
||||||
} else if (status.status === 'error') {
|
|
||||||
clearInterval(pollInterval);
|
|
||||||
progressArea.classList.add('hidden');
|
|
||||||
resultArea.classList.remove('hidden');
|
|
||||||
document.getElementById('errorResult').classList.remove('hidden');
|
|
||||||
document.getElementById('errorMessage').textContent = status.message;
|
|
||||||
submitBtn.textContent = 'Setup starten';
|
|
||||||
submitBtn.disabled = false;
|
|
||||||
}
|
|
||||||
}, 1000);
|
|
||||||
} catch (error) {
|
|
||||||
progressArea.classList.add('hidden');
|
|
||||||
resultArea.classList.remove('hidden');
|
|
||||||
document.getElementById('errorResult').classList.remove('hidden');
|
|
||||||
document.getElementById('errorMessage').textContent = error.message;
|
|
||||||
submitBtn.textContent = 'Setup starten';
|
|
||||||
submitBtn.disabled = false;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
{% endblock %}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,152 +0,0 @@
|
|||||||
{% extends "base.html" %}
|
|
||||||
{% block title %}Alle Posts - LinkedIn Posts{% endblock %}
|
|
||||||
|
|
||||||
{% block head %}
|
|
||||||
<style>
|
|
||||||
.post-card {
|
|
||||||
background: linear-gradient(135deg, rgba(61, 72, 72, 0.3) 0%, rgba(45, 56, 56, 0.4) 100%);
|
|
||||||
border: 1px solid rgba(61, 72, 72, 0.6);
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
}
|
|
||||||
.post-card:hover {
|
|
||||||
border-color: rgba(255, 199, 0, 0.3);
|
|
||||||
transform: translateY(-1px);
|
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
|
||||||
}
|
|
||||||
.customer-header {
|
|
||||||
background: linear-gradient(90deg, rgba(255, 199, 0, 0.1) 0%, transparent 100%);
|
|
||||||
}
|
|
||||||
.score-ring {
|
|
||||||
width: 44px;
|
|
||||||
height: 44px;
|
|
||||||
border-radius: 50%;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
font-weight: 700;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
}
|
|
||||||
.score-high { background: rgba(34, 197, 94, 0.2); border: 2px solid rgba(34, 197, 94, 0.5); color: #86efac; }
|
|
||||||
.score-medium { background: rgba(234, 179, 8, 0.2); border: 2px solid rgba(234, 179, 8, 0.5); color: #fde047; }
|
|
||||||
.score-low { background: rgba(239, 68, 68, 0.2); border: 2px solid rgba(239, 68, 68, 0.5); color: #fca5a5; }
|
|
||||||
</style>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<div class="mb-8 flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<h1 class="text-3xl font-bold text-white mb-2">Alle Posts</h1>
|
|
||||||
<p class="text-gray-400">{{ total_posts }} generierte Posts</p>
|
|
||||||
</div>
|
|
||||||
<a href="/admin/create" class="px-4 py-2.5 btn-primary rounded-lg font-medium flex items-center gap-2">
|
|
||||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"/></svg>
|
|
||||||
Neuer Post
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% if error %}
|
|
||||||
<div class="bg-red-900/50 border border-red-500 text-red-200 px-4 py-3 rounded-lg mb-6">
|
|
||||||
<strong>Error:</strong> {{ error }}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if customers_with_posts %}
|
|
||||||
<div class="space-y-8">
|
|
||||||
{% for item in customers_with_posts %}
|
|
||||||
{% if item.posts %}
|
|
||||||
<div class="card-bg rounded-xl border overflow-hidden">
|
|
||||||
<!-- Customer Header -->
|
|
||||||
<div class="customer-header px-6 py-4 border-b border-brand-bg-light">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<div class="flex items-center gap-4">
|
|
||||||
<div class="w-12 h-12 rounded-xl flex items-center justify-center shadow-lg overflow-hidden {{ 'bg-brand-highlight' if not item.profile_picture else '' }}">
|
|
||||||
{% if item.profile_picture %}
|
|
||||||
<img src="{{ item.profile_picture }}" alt="{{ item.customer.name }}" class="w-full h-full object-cover" loading="lazy" referrerpolicy="no-referrer">
|
|
||||||
{% else %}
|
|
||||||
<span class="text-brand-bg-dark font-bold text-lg">{{ item.customer.name[0] | upper }}</span>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h3 class="font-semibold text-white text-lg">{{ item.customer.name }}</h3>
|
|
||||||
<p class="text-sm text-gray-400">{{ item.customer.company_name or 'Kein Unternehmen' }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-4">
|
|
||||||
<span class="px-4 py-1.5 bg-brand-bg rounded-full text-sm text-gray-300 font-medium">
|
|
||||||
{{ item.post_count }} Post{{ 's' if item.post_count != 1 else '' }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Posts Grid -->
|
|
||||||
<div class="p-4">
|
|
||||||
<div class="grid gap-3">
|
|
||||||
{% for post in item.posts %}
|
|
||||||
<a href="/admin/posts/{{ post.id }}" class="post-card rounded-xl p-4 block group">
|
|
||||||
<div class="flex items-center gap-4">
|
|
||||||
<!-- Score Circle -->
|
|
||||||
{% if post.critic_feedback and post.critic_feedback | length > 0 %}
|
|
||||||
{% set score = post.critic_feedback[-1].overall_score %}
|
|
||||||
<div class="score-ring flex-shrink-0 {{ 'score-high' if score >= 85 else 'score-medium' if score >= 70 else 'score-low' }}">
|
|
||||||
{{ score }}
|
|
||||||
</div>
|
|
||||||
{% else %}
|
|
||||||
<div class="score-ring flex-shrink-0 bg-brand-bg-dark border-2 border-brand-bg-light text-gray-500">
|
|
||||||
—
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<!-- Content -->
|
|
||||||
<div class="flex-1 min-w-0">
|
|
||||||
<div class="flex items-start justify-between gap-3">
|
|
||||||
<h4 class="font-medium text-white group-hover:text-brand-highlight transition-colors truncate">
|
|
||||||
{{ post.topic_title or 'Untitled' }}
|
|
||||||
</h4>
|
|
||||||
<span class="flex-shrink-0 px-2 py-0.5 text-xs rounded font-medium {{ 'bg-green-600/20 text-green-400 border border-green-600/30' if post.status == 'approved' else 'bg-yellow-600/20 text-yellow-400 border border-yellow-600/30' }}">
|
|
||||||
{{ post.status | capitalize }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-4 mt-1.5 text-sm text-gray-500">
|
|
||||||
<span class="flex items-center gap-1.5">
|
|
||||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/></svg>
|
|
||||||
{{ post.created_at.strftime('%d.%m.%Y') if post.created_at else 'N/A' }}
|
|
||||||
</span>
|
|
||||||
<span class="flex items-center gap-1.5">
|
|
||||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
|
|
||||||
{{ post.created_at.strftime('%H:%M') if post.created_at else '' }}
|
|
||||||
</span>
|
|
||||||
<span class="flex items-center gap-1.5">
|
|
||||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/></svg>
|
|
||||||
{{ post.iterations }} Iteration{{ 's' if post.iterations != 1 else '' }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Arrow -->
|
|
||||||
<svg class="w-5 h-5 text-gray-600 group-hover:text-brand-highlight transition-colors flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
{% else %}
|
|
||||||
<div class="card-bg rounded-xl border p-12 text-center">
|
|
||||||
<div class="w-20 h-20 bg-brand-bg rounded-2xl flex items-center justify-center mx-auto mb-6">
|
|
||||||
<svg class="w-10 h-10 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/></svg>
|
|
||||||
</div>
|
|
||||||
<h3 class="text-xl font-semibold text-white mb-2">Noch keine Posts</h3>
|
|
||||||
<p class="text-gray-400 mb-6 max-w-md mx-auto">Erstelle deinen ersten LinkedIn Post mit KI-Unterstützung.</p>
|
|
||||||
<a href="/admin/create" class="inline-flex items-center gap-2 px-6 py-3 btn-primary font-medium rounded-lg transition-colors">
|
|
||||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"/></svg>
|
|
||||||
Post erstellen
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
{% endblock %}
|
|
||||||
@@ -1,571 +0,0 @@
|
|||||||
{% extends "base.html" %}
|
|
||||||
{% block title %}Gescrapte Posts - LinkedIn Posts{% endblock %}
|
|
||||||
|
|
||||||
{% block head %}
|
|
||||||
<style>
|
|
||||||
.post-card {
|
|
||||||
background: linear-gradient(135deg, rgba(61, 72, 72, 0.3) 0%, rgba(45, 56, 56, 0.4) 100%);
|
|
||||||
border: 1px solid rgba(61, 72, 72, 0.6);
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
}
|
|
||||||
.post-card:hover {
|
|
||||||
border-color: rgba(255, 199, 0, 0.3);
|
|
||||||
}
|
|
||||||
.post-card.selected {
|
|
||||||
border-color: rgba(255, 199, 0, 0.6);
|
|
||||||
background: linear-gradient(135deg, rgba(255, 199, 0, 0.05) 0%, rgba(45, 56, 56, 0.4) 100%);
|
|
||||||
}
|
|
||||||
.type-badge {
|
|
||||||
transition: all 0.15s ease;
|
|
||||||
}
|
|
||||||
.type-badge:hover {
|
|
||||||
transform: scale(1.02);
|
|
||||||
}
|
|
||||||
.type-badge.active {
|
|
||||||
background-color: rgba(255, 199, 0, 0.2);
|
|
||||||
border-color: #ffc700;
|
|
||||||
}
|
|
||||||
.post-content-preview {
|
|
||||||
max-height: 150px;
|
|
||||||
overflow: hidden;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
.post-content-preview::after {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
bottom: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
height: 40px;
|
|
||||||
background: linear-gradient(transparent, rgba(45, 56, 56, 0.9));
|
|
||||||
}
|
|
||||||
.post-content-expanded {
|
|
||||||
max-height: none;
|
|
||||||
}
|
|
||||||
.post-content-expanded::after {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<div class="mb-8">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<h1 class="text-3xl font-bold text-white mb-2">Gescrapte Posts verwalten</h1>
|
|
||||||
<p class="text-gray-400">Posts manuell kategorisieren und Post-Typ-Analyse triggern</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Customer Selection -->
|
|
||||||
<div class="card-bg rounded-xl border p-6 mb-6">
|
|
||||||
<div class="flex flex-wrap items-end gap-4">
|
|
||||||
<div class="flex-1 min-w-64">
|
|
||||||
<label class="block text-sm font-medium text-gray-300 mb-2">Kunde auswählen</label>
|
|
||||||
<select id="customerSelect" class="w-full input-bg border rounded-lg px-4 py-3 text-white">
|
|
||||||
<option value="">-- Kunde wählen --</option>
|
|
||||||
{% for customer in customers %}
|
|
||||||
<option value="{{ customer.id }}">{{ customer.name }} - {{ customer.company_name or 'Kein Unternehmen' }}</option>
|
|
||||||
{% endfor %}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="flex gap-2">
|
|
||||||
<button id="classifyAllBtn" class="hidden px-4 py-3 bg-brand-bg hover:bg-brand-bg-light border border-brand-bg-light rounded-lg text-white transition-colors">
|
|
||||||
<span class="flex items-center gap-2">
|
|
||||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/></svg>
|
|
||||||
Auto-Klassifizieren
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
<button id="analyzeTypesBtn" class="hidden px-4 py-3 btn-primary rounded-lg font-medium transition-colors">
|
|
||||||
<span class="flex items-center gap-2">
|
|
||||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"/></svg>
|
|
||||||
Post-Typen analysieren
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Progress Area -->
|
|
||||||
<div id="progressArea" class="hidden card-bg rounded-xl border p-6 mb-6">
|
|
||||||
<div class="flex items-center justify-between mb-2">
|
|
||||||
<span id="progressMessage" class="text-gray-300">Arbeite...</span>
|
|
||||||
<span id="progressPercent" class="text-gray-400">0%</span>
|
|
||||||
</div>
|
|
||||||
<div class="w-full bg-brand-bg-dark rounded-full h-2">
|
|
||||||
<div id="progressBar" class="bg-brand-highlight h-2 rounded-full transition-all duration-300" style="width: 0%"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Stats & Post Types -->
|
|
||||||
<div id="statsArea" class="hidden mb-6">
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-4">
|
|
||||||
<div class="card-bg rounded-xl border p-4">
|
|
||||||
<div class="text-2xl font-bold text-white" id="totalPostsCount">0</div>
|
|
||||||
<div class="text-sm text-gray-400">Gesamt Posts</div>
|
|
||||||
</div>
|
|
||||||
<div class="card-bg rounded-xl border p-4">
|
|
||||||
<div class="text-2xl font-bold text-green-400" id="classifiedCount">0</div>
|
|
||||||
<div class="text-sm text-gray-400">Klassifiziert</div>
|
|
||||||
</div>
|
|
||||||
<div class="card-bg rounded-xl border p-4">
|
|
||||||
<div class="text-2xl font-bold text-yellow-400" id="unclassifiedCount">0</div>
|
|
||||||
<div class="text-sm text-gray-400">Nicht klassifiziert</div>
|
|
||||||
</div>
|
|
||||||
<div class="card-bg rounded-xl border p-4">
|
|
||||||
<div class="text-2xl font-bold text-brand-highlight" id="postTypesCount">0</div>
|
|
||||||
<div class="text-sm text-gray-400">Post-Typen</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Post Type Filter -->
|
|
||||||
<div class="flex flex-wrap gap-2 mb-4">
|
|
||||||
<button onclick="filterByType(null)" id="filter_all" class="type-badge px-3 py-2 rounded-lg border text-sm transition-colors bg-brand-highlight/20 border-brand-highlight text-white">
|
|
||||||
Alle
|
|
||||||
</button>
|
|
||||||
<button onclick="filterByType('unclassified')" id="filter_unclassified" class="type-badge px-3 py-2 rounded-lg border text-sm transition-colors bg-brand-bg border-brand-bg-light hover:border-brand-highlight/50 text-white">
|
|
||||||
Nicht klassifiziert
|
|
||||||
</button>
|
|
||||||
<div id="postTypeFilters" class="contents">
|
|
||||||
<!-- Post type filter buttons will be added here -->
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Posts List -->
|
|
||||||
<div id="postsArea" class="hidden">
|
|
||||||
<div id="postsList" class="space-y-4">
|
|
||||||
<p class="text-gray-400">Lade Posts...</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Empty State -->
|
|
||||||
<div id="emptyState" class="hidden card-bg rounded-xl border p-12 text-center">
|
|
||||||
<div class="w-20 h-20 bg-brand-bg rounded-2xl flex items-center justify-center mx-auto mb-6">
|
|
||||||
<svg class="w-10 h-10 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"/></svg>
|
|
||||||
</div>
|
|
||||||
<h3 class="text-xl font-semibold text-white mb-2">Keine gescrapten Posts</h3>
|
|
||||||
<p class="text-gray-400 mb-6 max-w-md mx-auto">Für diesen Kunden wurden noch keine LinkedIn Posts gescrapet.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% if not customers %}
|
|
||||||
<div class="bg-yellow-900/30 border border-yellow-600 rounded-lg p-4">
|
|
||||||
<p class="text-yellow-300">Noch keine Kunden vorhanden. <a href="/admin/customers/new" class="underline">Erstelle zuerst einen Kunden</a>.</p>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<!-- Post Detail Modal -->
|
|
||||||
<div id="postModal" class="fixed inset-0 bg-black/70 hidden items-center justify-center z-50 p-4">
|
|
||||||
<div class="bg-brand-bg-dark rounded-xl border border-brand-bg-light max-w-4xl w-full max-h-[90vh] overflow-hidden flex flex-col shadow-2xl">
|
|
||||||
<div class="p-4 border-b border-brand-bg-light flex items-center justify-between bg-brand-bg">
|
|
||||||
<h3 class="text-lg font-semibold text-white">Post Details</h3>
|
|
||||||
<button onclick="closeModal()" class="text-gray-400 hover:text-white p-1 hover:bg-brand-bg-light rounded">
|
|
||||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="p-6 overflow-y-auto flex-1">
|
|
||||||
<div id="modalContent"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block scripts %}
|
|
||||||
<script>
|
|
||||||
const customerSelect = document.getElementById('customerSelect');
|
|
||||||
const classifyAllBtn = document.getElementById('classifyAllBtn');
|
|
||||||
const analyzeTypesBtn = document.getElementById('analyzeTypesBtn');
|
|
||||||
const progressArea = document.getElementById('progressArea');
|
|
||||||
const progressBar = document.getElementById('progressBar');
|
|
||||||
const progressMessage = document.getElementById('progressMessage');
|
|
||||||
const progressPercent = document.getElementById('progressPercent');
|
|
||||||
const statsArea = document.getElementById('statsArea');
|
|
||||||
const postsArea = document.getElementById('postsArea');
|
|
||||||
const postsList = document.getElementById('postsList');
|
|
||||||
const emptyState = document.getElementById('emptyState');
|
|
||||||
const postTypeFilters = document.getElementById('postTypeFilters');
|
|
||||||
const postModal = document.getElementById('postModal');
|
|
||||||
const modalContent = document.getElementById('modalContent');
|
|
||||||
|
|
||||||
let currentPosts = [];
|
|
||||||
let currentPostTypes = [];
|
|
||||||
let currentFilter = null;
|
|
||||||
|
|
||||||
customerSelect.addEventListener('change', async () => {
|
|
||||||
const customerId = customerSelect.value;
|
|
||||||
|
|
||||||
if (!customerId) {
|
|
||||||
statsArea.classList.add('hidden');
|
|
||||||
postsArea.classList.add('hidden');
|
|
||||||
emptyState.classList.add('hidden');
|
|
||||||
classifyAllBtn.classList.add('hidden');
|
|
||||||
analyzeTypesBtn.classList.add('hidden');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await loadCustomerData(customerId);
|
|
||||||
});
|
|
||||||
|
|
||||||
async function loadCustomerData(customerId) {
|
|
||||||
// Load post types
|
|
||||||
try {
|
|
||||||
const ptResponse = await fetch(`/admin/api/customers/${customerId}/post-types`);
|
|
||||||
const ptData = await ptResponse.json();
|
|
||||||
currentPostTypes = ptData.post_types || [];
|
|
||||||
|
|
||||||
// Update post type filters
|
|
||||||
postTypeFilters.innerHTML = currentPostTypes.map(pt => `
|
|
||||||
<button onclick="filterByType('${pt.id}')" id="filter_${pt.id}"
|
|
||||||
class="type-badge px-3 py-2 rounded-lg border text-sm transition-colors bg-brand-bg border-brand-bg-light hover:border-brand-highlight/50 text-white">
|
|
||||||
${escapeHtml(pt.name)}
|
|
||||||
<span class="ml-1 text-xs text-gray-400">(${pt.analyzed_post_count || 0})</span>
|
|
||||||
${pt.has_analysis ? '<span class="ml-1 text-green-400">*</span>' : ''}
|
|
||||||
</button>
|
|
||||||
`).join('');
|
|
||||||
|
|
||||||
document.getElementById('postTypesCount').textContent = currentPostTypes.length;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to load post types:', error);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load posts
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/admin/api/customers/${customerId}/linkedin-posts`);
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
console.log('API Response:', data);
|
|
||||||
|
|
||||||
if (data.error) {
|
|
||||||
console.error('API Error:', data.error);
|
|
||||||
postsList.innerHTML = `<p class="text-red-400">API Fehler: ${escapeHtml(data.error)}</p>`;
|
|
||||||
postsArea.classList.remove('hidden');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
currentPosts = data.posts || [];
|
|
||||||
console.log(`Loaded ${currentPosts.length} posts`);
|
|
||||||
|
|
||||||
if (currentPosts.length === 0) {
|
|
||||||
statsArea.classList.add('hidden');
|
|
||||||
postsArea.classList.add('hidden');
|
|
||||||
emptyState.classList.remove('hidden');
|
|
||||||
classifyAllBtn.classList.add('hidden');
|
|
||||||
analyzeTypesBtn.classList.add('hidden');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update stats
|
|
||||||
const classified = currentPosts.filter(p => p.post_type_id).length;
|
|
||||||
const unclassified = currentPosts.length - classified;
|
|
||||||
|
|
||||||
document.getElementById('totalPostsCount').textContent = currentPosts.length;
|
|
||||||
document.getElementById('classifiedCount').textContent = classified;
|
|
||||||
document.getElementById('unclassifiedCount').textContent = unclassified;
|
|
||||||
|
|
||||||
statsArea.classList.remove('hidden');
|
|
||||||
postsArea.classList.remove('hidden');
|
|
||||||
emptyState.classList.add('hidden');
|
|
||||||
classifyAllBtn.classList.remove('hidden');
|
|
||||||
analyzeTypesBtn.classList.remove('hidden');
|
|
||||||
|
|
||||||
currentFilter = null;
|
|
||||||
filterByType(null);
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to load posts:', error);
|
|
||||||
postsList.innerHTML = `<p class="text-red-400">Fehler beim Laden: ${error.message}</p>`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function filterByType(typeId) {
|
|
||||||
currentFilter = typeId;
|
|
||||||
|
|
||||||
// Update filter button styles
|
|
||||||
document.querySelectorAll('.type-badge').forEach(btn => {
|
|
||||||
const btnId = btn.id.replace('filter_', '');
|
|
||||||
const isActive = (typeId === null && btnId === 'all') ||
|
|
||||||
(typeId === 'unclassified' && btnId === 'unclassified') ||
|
|
||||||
(btnId === typeId);
|
|
||||||
|
|
||||||
if (isActive) {
|
|
||||||
btn.classList.add('active', 'bg-brand-highlight/20', 'border-brand-highlight');
|
|
||||||
btn.classList.remove('bg-brand-bg', 'border-brand-bg-light');
|
|
||||||
} else {
|
|
||||||
btn.classList.remove('active', 'bg-brand-highlight/20', 'border-brand-highlight');
|
|
||||||
btn.classList.add('bg-brand-bg', 'border-brand-bg-light');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Filter posts
|
|
||||||
let filteredPosts = currentPosts;
|
|
||||||
if (typeId === 'unclassified') {
|
|
||||||
filteredPosts = currentPosts.filter(p => !p.post_type_id);
|
|
||||||
} else if (typeId) {
|
|
||||||
filteredPosts = currentPosts.filter(p => p.post_type_id === typeId);
|
|
||||||
}
|
|
||||||
|
|
||||||
renderPosts(filteredPosts);
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderPosts(posts) {
|
|
||||||
if (posts.length === 0) {
|
|
||||||
postsList.innerHTML = '<p class="text-gray-400 text-center py-8">Keine Posts in dieser Kategorie.</p>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
postsList.innerHTML = posts.map((post, index) => {
|
|
||||||
const postType = currentPostTypes.find(pt => pt.id === post.post_type_id);
|
|
||||||
const postText = post.post_text || '';
|
|
||||||
const previewText = postText.substring(0, 300);
|
|
||||||
|
|
||||||
return `
|
|
||||||
<div class="post-card rounded-xl p-4 cursor-pointer" data-post-id="${post.id}" onclick="openPostModal('${post.id}')">
|
|
||||||
<div class="flex items-start gap-4">
|
|
||||||
<div class="flex-1 min-w-0">
|
|
||||||
<!-- Header -->
|
|
||||||
<div class="flex items-center gap-2 mb-3 flex-wrap">
|
|
||||||
${postType ? `
|
|
||||||
<span class="px-2 py-1 text-xs font-medium bg-brand-highlight/20 text-brand-highlight rounded">
|
|
||||||
${escapeHtml(postType.name)}
|
|
||||||
</span>
|
|
||||||
<span class="text-xs text-gray-500">${post.classification_method || 'unknown'} (${Math.round((post.classification_confidence || 0) * 100)}%)</span>
|
|
||||||
` : `
|
|
||||||
<span class="px-2 py-1 text-xs font-medium bg-gray-600/30 text-gray-400 rounded">
|
|
||||||
Nicht klassifiziert
|
|
||||||
</span>
|
|
||||||
`}
|
|
||||||
${post.posted_at ? `
|
|
||||||
<span class="text-xs text-gray-500">${new Date(post.posted_at).toLocaleDateString('de-DE')}</span>
|
|
||||||
` : ''}
|
|
||||||
${post.engagement_score ? `
|
|
||||||
<span class="text-xs text-gray-500">Engagement: ${post.engagement_score}</span>
|
|
||||||
` : ''}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Content Preview -->
|
|
||||||
<div class="post-content-preview text-gray-300 text-sm whitespace-pre-wrap mb-3">
|
|
||||||
${escapeHtml(previewText)}${postText.length > 300 ? '...' : ''}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Click hint & Type badges -->
|
|
||||||
<div class="flex items-center justify-between flex-wrap gap-2" onclick="event.stopPropagation()">
|
|
||||||
<span class="text-xs text-gray-500 italic">Klicken für Vollansicht</span>
|
|
||||||
<div class="flex items-center gap-2 flex-wrap">
|
|
||||||
${currentPostTypes.map(pt => `
|
|
||||||
<button onclick="event.stopPropagation(); classifyPost('${post.id}', '${pt.id}')"
|
|
||||||
class="px-2 py-1 text-xs rounded transition-colors ${post.post_type_id === pt.id ? 'bg-brand-highlight/30 text-brand-highlight border border-brand-highlight' : 'bg-brand-bg hover:bg-brand-bg-light text-gray-300 border border-brand-bg-light'}">
|
|
||||||
${escapeHtml(pt.name)}
|
|
||||||
</button>
|
|
||||||
`).join('')}
|
|
||||||
${post.post_type_id ? `
|
|
||||||
<button onclick="event.stopPropagation(); classifyPost('${post.id}', null)" class="px-2 py-1 text-xs rounded bg-red-900/30 hover:bg-red-900/50 text-red-300 border border-red-900/50">
|
|
||||||
✕
|
|
||||||
</button>
|
|
||||||
` : ''}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}).join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
async function classifyPost(postId, postTypeId) {
|
|
||||||
const customerId = customerSelect.value;
|
|
||||||
if (!customerId) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/admin/api/linkedin-posts/${postId}/classify`, {
|
|
||||||
method: 'PATCH',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ post_type_id: postTypeId })
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error('Failed to classify post');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update local data
|
|
||||||
const post = currentPosts.find(p => p.id === postId);
|
|
||||||
if (post) {
|
|
||||||
post.post_type_id = postTypeId;
|
|
||||||
post.classification_method = 'manual';
|
|
||||||
post.classification_confidence = 1.0;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update stats
|
|
||||||
const classified = currentPosts.filter(p => p.post_type_id).length;
|
|
||||||
document.getElementById('classifiedCount').textContent = classified;
|
|
||||||
document.getElementById('unclassifiedCount').textContent = currentPosts.length - classified;
|
|
||||||
|
|
||||||
// Re-render
|
|
||||||
filterByType(currentFilter);
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to classify post:', error);
|
|
||||||
alert('Fehler beim Klassifizieren: ' + error.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function openPostModal(postId) {
|
|
||||||
const post = currentPosts.find(p => p.id === postId);
|
|
||||||
if (!post) return;
|
|
||||||
|
|
||||||
const postType = currentPostTypes.find(pt => pt.id === post.post_type_id);
|
|
||||||
|
|
||||||
modalContent.innerHTML = `
|
|
||||||
<div class="mb-4 flex items-center gap-3 flex-wrap">
|
|
||||||
${postType ? `
|
|
||||||
<span class="px-3 py-1.5 text-sm font-medium bg-brand-highlight/20 text-brand-highlight rounded-lg">
|
|
||||||
${escapeHtml(postType.name)}
|
|
||||||
</span>
|
|
||||||
` : `
|
|
||||||
<span class="px-3 py-1.5 text-sm font-medium bg-gray-600/30 text-gray-400 rounded-lg">
|
|
||||||
Nicht klassifiziert
|
|
||||||
</span>
|
|
||||||
`}
|
|
||||||
${post.posted_at ? `
|
|
||||||
<span class="text-sm text-gray-500">${new Date(post.posted_at).toLocaleDateString('de-DE')}</span>
|
|
||||||
` : ''}
|
|
||||||
${post.engagement_score ? `
|
|
||||||
<span class="text-sm text-gray-500">Engagement: ${post.engagement_score}</span>
|
|
||||||
` : ''}
|
|
||||||
</div>
|
|
||||||
<div class="bg-brand-bg rounded-xl p-6 mb-6 border border-brand-bg-light max-h-[50vh] overflow-y-auto">
|
|
||||||
<div class="whitespace-pre-wrap text-gray-200 font-sans text-base leading-relaxed">${escapeHtml(post.post_text || '')}</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-3 flex-wrap border-t border-brand-bg-light pt-4">
|
|
||||||
<span class="text-sm text-gray-400 font-medium">Typ zuweisen:</span>
|
|
||||||
${currentPostTypes.map(pt => `
|
|
||||||
<button onclick="classifyPost('${post.id}', '${pt.id}'); closeModal();"
|
|
||||||
class="px-4 py-2 text-sm rounded-lg transition-colors ${post.post_type_id === pt.id ? 'bg-brand-highlight text-brand-bg-dark font-medium' : 'bg-brand-bg hover:bg-brand-bg-light text-gray-300 border border-brand-bg-light'}">
|
|
||||||
${escapeHtml(pt.name)}
|
|
||||||
</button>
|
|
||||||
`).join('')}
|
|
||||||
${post.post_type_id ? `
|
|
||||||
<button onclick="classifyPost('${post.id}', null); closeModal();" class="px-4 py-2 text-sm rounded-lg bg-red-900/30 hover:bg-red-900/50 text-red-300 border border-red-900/50">
|
|
||||||
Klassifizierung entfernen
|
|
||||||
</button>
|
|
||||||
` : ''}
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
postModal.classList.remove('hidden');
|
|
||||||
postModal.classList.add('flex');
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeModal() {
|
|
||||||
postModal.classList.add('hidden');
|
|
||||||
postModal.classList.remove('flex');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Close modal on backdrop click
|
|
||||||
postModal.addEventListener('click', (e) => {
|
|
||||||
if (e.target === postModal) {
|
|
||||||
closeModal();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Close modal on Escape key
|
|
||||||
document.addEventListener('keydown', (e) => {
|
|
||||||
if (e.key === 'Escape' && !postModal.classList.contains('hidden')) {
|
|
||||||
closeModal();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Auto-classify button
|
|
||||||
classifyAllBtn.addEventListener('click', async () => {
|
|
||||||
const customerId = customerSelect.value;
|
|
||||||
if (!customerId) return;
|
|
||||||
|
|
||||||
classifyAllBtn.disabled = true;
|
|
||||||
progressArea.classList.remove('hidden');
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/admin/api/customers/${customerId}/classify-posts`, {
|
|
||||||
method: 'POST'
|
|
||||||
});
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
const taskId = data.task_id;
|
|
||||||
await pollTask(taskId, async () => {
|
|
||||||
await loadCustomerData(customerId);
|
|
||||||
});
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Classification failed:', error);
|
|
||||||
alert('Fehler bei der Klassifizierung: ' + error.message);
|
|
||||||
} finally {
|
|
||||||
classifyAllBtn.disabled = false;
|
|
||||||
progressArea.classList.add('hidden');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Analyze post types button
|
|
||||||
analyzeTypesBtn.addEventListener('click', async () => {
|
|
||||||
const customerId = customerSelect.value;
|
|
||||||
if (!customerId) return;
|
|
||||||
|
|
||||||
analyzeTypesBtn.disabled = true;
|
|
||||||
progressArea.classList.remove('hidden');
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/admin/api/customers/${customerId}/analyze-post-types`, {
|
|
||||||
method: 'POST'
|
|
||||||
});
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
const taskId = data.task_id;
|
|
||||||
await pollTask(taskId, async () => {
|
|
||||||
await loadCustomerData(customerId);
|
|
||||||
alert('Post-Typ-Analyse abgeschlossen! Die Analysen wurden aktualisiert.');
|
|
||||||
});
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Analysis failed:', error);
|
|
||||||
alert('Fehler bei der Analyse: ' + error.message);
|
|
||||||
} finally {
|
|
||||||
analyzeTypesBtn.disabled = false;
|
|
||||||
progressArea.classList.add('hidden');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
async function pollTask(taskId, onComplete) {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
const interval = setInterval(async () => {
|
|
||||||
try {
|
|
||||||
const statusResponse = await fetch(`/admin/api/tasks/${taskId}`);
|
|
||||||
const status = await statusResponse.json();
|
|
||||||
|
|
||||||
progressBar.style.width = `${status.progress}%`;
|
|
||||||
progressPercent.textContent = `${status.progress}%`;
|
|
||||||
progressMessage.textContent = status.message;
|
|
||||||
|
|
||||||
if (status.status === 'completed') {
|
|
||||||
clearInterval(interval);
|
|
||||||
await onComplete();
|
|
||||||
resolve();
|
|
||||||
} else if (status.status === 'error') {
|
|
||||||
clearInterval(interval);
|
|
||||||
alert('Fehler: ' + status.message);
|
|
||||||
resolve();
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
clearInterval(interval);
|
|
||||||
console.error('Polling error:', error);
|
|
||||||
resolve();
|
|
||||||
}
|
|
||||||
}, 1000);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function escapeHtml(text) {
|
|
||||||
if (!text) return '';
|
|
||||||
const div = document.createElement('div');
|
|
||||||
div.textContent = text;
|
|
||||||
return div.innerHTML;
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
{% endblock %}
|
|
||||||
436
src/web/templates/admin/statistics.html
Normal file
436
src/web/templates/admin/statistics.html
Normal file
@@ -0,0 +1,436 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Statistiken - Admin{% endblock %}
|
||||||
|
|
||||||
|
{% block head %}
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/apexcharts"></script>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="mb-8">
|
||||||
|
<h1 class="text-3xl font-bold text-white mb-2">Statistiken</h1>
|
||||||
|
<p class="text-gray-400">API-Nutzung und Kosten</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if error %}
|
||||||
|
<div class="bg-red-900/50 border border-red-500 text-red-200 px-4 py-3 rounded-lg mb-6">
|
||||||
|
<strong>Error:</strong> {{ error }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Filters -->
|
||||||
|
<div class="flex flex-wrap items-center gap-4 mb-6">
|
||||||
|
<!-- Period Selector -->
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button onclick="loadStats('today')" id="period-today"
|
||||||
|
class="px-4 py-2 rounded-lg text-sm font-medium transition-colors bg-brand-bg-light text-gray-300 hover:bg-brand-bg">
|
||||||
|
Heute
|
||||||
|
</button>
|
||||||
|
<button onclick="loadStats('week')" id="period-week"
|
||||||
|
class="px-4 py-2 rounded-lg text-sm font-medium transition-colors bg-brand-bg-light text-gray-300 hover:bg-brand-bg">
|
||||||
|
7 Tage
|
||||||
|
</button>
|
||||||
|
<button onclick="loadStats('month')" id="period-month"
|
||||||
|
class="px-4 py-2 rounded-lg text-sm font-medium transition-colors btn-primary">
|
||||||
|
30 Tage
|
||||||
|
</button>
|
||||||
|
<button onclick="loadStats('all')" id="period-all"
|
||||||
|
class="px-4 py-2 rounded-lg text-sm font-medium transition-colors bg-brand-bg-light text-gray-300 hover:bg-brand-bg">
|
||||||
|
Gesamt
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- User/Company Filter -->
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<label for="entityFilter" class="text-sm text-gray-400">Filter:</label>
|
||||||
|
<select id="entityFilter" onchange="onEntityFilterChange()"
|
||||||
|
class="input-bg border rounded-lg px-3 py-2 text-sm text-white min-w-[250px]">
|
||||||
|
<option value="">Alle</option>
|
||||||
|
{% if companies %}
|
||||||
|
<optgroup label="Firmen">
|
||||||
|
{% for cd in companies %}
|
||||||
|
<option value="company:{{ cd.company.id }}">{{ cd.company.name }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</optgroup>
|
||||||
|
{% endif %}
|
||||||
|
{% if ghostwriters %}
|
||||||
|
<optgroup label="Ghostwriter">
|
||||||
|
{% for gw in ghostwriters %}
|
||||||
|
<option value="user:{{ gw.id }}">{{ gw.display_name or gw.email }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</optgroup>
|
||||||
|
{% endif %}
|
||||||
|
{% if companies %}
|
||||||
|
<optgroup label="Firmen-Mitglieder">
|
||||||
|
{% for cd in companies %}
|
||||||
|
{% for emp in cd.employees %}
|
||||||
|
<option value="user:{{ emp.id }}">{{ emp.display_name or emp.email }} ({{ cd.company.name }})</option>
|
||||||
|
{% endfor %}
|
||||||
|
{% endfor %}
|
||||||
|
</optgroup>
|
||||||
|
{% endif %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Overview Cards Row 1 -->
|
||||||
|
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4 mb-4">
|
||||||
|
<div class="card-bg rounded-xl border p-5 text-center">
|
||||||
|
<div class="w-10 h-10 bg-brand-highlight/20 rounded-lg flex items-center justify-center mx-auto mb-2">
|
||||||
|
<svg class="w-5 h-5 text-brand-highlight" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z"/></svg>
|
||||||
|
</div>
|
||||||
|
<p class="text-gray-400 text-xs">Tokens</p>
|
||||||
|
<p class="text-xl font-bold text-white" id="stat-tokens">-</p>
|
||||||
|
</div>
|
||||||
|
<div class="card-bg rounded-xl border p-5 text-center">
|
||||||
|
<div class="w-10 h-10 bg-green-600/20 rounded-lg flex items-center justify-center mx-auto mb-2">
|
||||||
|
<svg class="w-5 h-5 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
|
||||||
|
</div>
|
||||||
|
<p class="text-gray-400 text-xs">Kosten (USD)</p>
|
||||||
|
<p class="text-xl font-bold text-white" id="stat-cost">-</p>
|
||||||
|
</div>
|
||||||
|
<div class="card-bg rounded-xl border p-5 text-center">
|
||||||
|
<div class="w-10 h-10 bg-blue-600/20 rounded-lg flex items-center justify-center mx-auto mb-2">
|
||||||
|
<svg class="w-5 h-5 text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"/></svg>
|
||||||
|
</div>
|
||||||
|
<p class="text-gray-400 text-xs">API Calls</p>
|
||||||
|
<p class="text-xl font-bold text-white" id="stat-calls">-</p>
|
||||||
|
</div>
|
||||||
|
<div class="card-bg rounded-xl border p-5 text-center">
|
||||||
|
<div class="w-10 h-10 bg-purple-600/20 rounded-lg flex items-center justify-center mx-auto mb-2">
|
||||||
|
<svg class="w-5 h-5 text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>
|
||||||
|
</div>
|
||||||
|
<p class="text-gray-400 text-xs">Erstellt</p>
|
||||||
|
<p class="text-xl font-bold text-white" id="stat-created">-</p>
|
||||||
|
</div>
|
||||||
|
<div class="card-bg rounded-xl border p-5 text-center">
|
||||||
|
<div class="w-10 h-10 bg-amber-600/20 rounded-lg flex items-center justify-center mx-auto mb-2">
|
||||||
|
<svg class="w-5 h-5 text-amber-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
|
||||||
|
</div>
|
||||||
|
<p class="text-gray-400 text-xs">Freigegeben</p>
|
||||||
|
<p class="text-xl font-bold text-white" id="stat-approved">-</p>
|
||||||
|
</div>
|
||||||
|
<div class="card-bg rounded-xl border p-5 text-center">
|
||||||
|
<div class="w-10 h-10 bg-emerald-600/20 rounded-lg flex items-center justify-center mx-auto mb-2">
|
||||||
|
<svg class="w-5 h-5 text-emerald-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/></svg>
|
||||||
|
</div>
|
||||||
|
<p class="text-gray-400 text-xs">Veröffentlicht</p>
|
||||||
|
<p class="text-xl font-bold text-white" id="stat-published">-</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Overview Cards Row 2 -->
|
||||||
|
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-4 mb-8">
|
||||||
|
<div class="card-bg rounded-xl border p-5 text-center">
|
||||||
|
<div class="w-10 h-10 bg-orange-600/20 rounded-lg flex items-center justify-center mx-auto mb-2">
|
||||||
|
<svg class="w-5 h-5 text-orange-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 7h6m0 10v-3m-3 3h.01M9 17h.01M9 14h.01M12 14h.01M15 11h.01M12 11h.01M9 11h.01M7 21h10a2 2 0 002-2V5a2 2 0 00-2-2H7a2 2 0 00-2 2v14a2 2 0 002 2z"/></svg>
|
||||||
|
</div>
|
||||||
|
<p class="text-gray-400 text-xs">Kosten/Post</p>
|
||||||
|
<p class="text-xl font-bold text-white" id="stat-avg-cost">-</p>
|
||||||
|
</div>
|
||||||
|
<div class="card-bg rounded-xl border p-5 text-center">
|
||||||
|
<div class="w-10 h-10 bg-rose-600/20 rounded-lg flex items-center justify-center mx-auto mb-2">
|
||||||
|
<svg class="w-5 h-5 text-rose-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6"/></svg>
|
||||||
|
</div>
|
||||||
|
<p class="text-gray-400 text-xs">Monatsprognose</p>
|
||||||
|
<p class="text-xl font-bold text-white" id="stat-projection">-</p>
|
||||||
|
</div>
|
||||||
|
<div class="card-bg rounded-xl border p-5 text-center">
|
||||||
|
<div class="w-10 h-10 bg-cyan-600/20 rounded-lg flex items-center justify-center mx-auto mb-2">
|
||||||
|
<svg class="w-5 h-5 text-cyan-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z"/></svg>
|
||||||
|
</div>
|
||||||
|
<p class="text-gray-400 text-xs">Tokens/Post</p>
|
||||||
|
<p class="text-xl font-bold text-white" id="stat-avg-tokens">-</p>
|
||||||
|
</div>
|
||||||
|
<div class="card-bg rounded-xl border p-5 text-center">
|
||||||
|
<div class="w-10 h-10 bg-amber-600/20 rounded-lg flex items-center justify-center mx-auto mb-2">
|
||||||
|
<svg class="w-5 h-5 text-amber-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
|
||||||
|
</div>
|
||||||
|
<p class="text-gray-400 text-xs">Bis Freigabe</p>
|
||||||
|
<p class="text-xl font-bold text-white" id="stat-time-approval">-</p>
|
||||||
|
</div>
|
||||||
|
<div class="card-bg rounded-xl border p-5 text-center">
|
||||||
|
<div class="w-10 h-10 bg-emerald-600/20 rounded-lg flex items-center justify-center mx-auto mb-2">
|
||||||
|
<svg class="w-5 h-5 text-emerald-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
|
||||||
|
</div>
|
||||||
|
<p class="text-gray-400 text-xs">Bis Veröffentl.</p>
|
||||||
|
<p class="text-xl font-bold text-white" id="stat-time-publish">-</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Charts Row 1 -->
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
|
||||||
|
<!-- Daily Token Usage -->
|
||||||
|
<div class="card-bg rounded-xl border p-6">
|
||||||
|
<h3 class="text-lg font-semibold text-white mb-4">Token-Verbrauch pro Tag</h3>
|
||||||
|
<div id="chart-daily-tokens"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Daily Cost -->
|
||||||
|
<div class="card-bg rounded-xl border p-6">
|
||||||
|
<h3 class="text-lg font-semibold text-white mb-4">Kosten pro Tag (USD)</h3>
|
||||||
|
<div id="chart-daily-cost"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Charts Row 2 -->
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
|
||||||
|
<!-- By Model (Pie) -->
|
||||||
|
<div class="card-bg rounded-xl border p-6">
|
||||||
|
<h3 class="text-lg font-semibold text-white mb-4">Nutzung nach Modell</h3>
|
||||||
|
<div id="chart-by-model"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- By Operation (Bar) -->
|
||||||
|
<div class="card-bg rounded-xl border p-6">
|
||||||
|
<h3 class="text-lg font-semibold text-white mb-4">Nutzung nach Operation</h3>
|
||||||
|
<div id="chart-by-operation"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Charts Row 3: Posts -->
|
||||||
|
<div class="grid grid-cols-1 gap-6 mb-6">
|
||||||
|
<div class="card-bg rounded-xl border p-6">
|
||||||
|
<h3 class="text-lg font-semibold text-white mb-4">Posts pro Tag</h3>
|
||||||
|
<div id="chart-posts-daily"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Post Pipeline Funnel -->
|
||||||
|
<div class="card-bg rounded-xl border p-6 mb-6">
|
||||||
|
<h3 class="text-lg font-semibold text-white mb-4">Post-Pipeline</h3>
|
||||||
|
<div class="flex items-center justify-center gap-2">
|
||||||
|
<div class="text-center flex-1">
|
||||||
|
<div class="bg-purple-600/20 rounded-lg py-4 px-2">
|
||||||
|
<p class="text-2xl font-bold text-purple-400" id="funnel-created">-</p>
|
||||||
|
<p class="text-xs text-gray-400 mt-1">Erstellt</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-center flex-shrink-0">
|
||||||
|
<p class="text-sm font-semibold text-amber-400" id="funnel-approval-rate">-</p>
|
||||||
|
<svg class="w-6 h-6 text-gray-500 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/></svg>
|
||||||
|
</div>
|
||||||
|
<div class="text-center flex-1">
|
||||||
|
<div class="bg-amber-600/20 rounded-lg py-4 px-2">
|
||||||
|
<p class="text-2xl font-bold text-amber-400" id="funnel-approved">-</p>
|
||||||
|
<p class="text-xs text-gray-400 mt-1">Freigegeben</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-center flex-shrink-0">
|
||||||
|
<p class="text-sm font-semibold text-emerald-400" id="funnel-publish-rate">-</p>
|
||||||
|
<svg class="w-6 h-6 text-gray-500 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/></svg>
|
||||||
|
</div>
|
||||||
|
<div class="text-center flex-1">
|
||||||
|
<div class="bg-emerald-600/20 rounded-lg py-4 px-2">
|
||||||
|
<p class="text-2xl font-bold text-emerald-400" id="funnel-published">-</p>
|
||||||
|
<p class="text-xs text-gray-400 mt-1">Veröffentlicht</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- No Data Message -->
|
||||||
|
<div id="no-data-msg" class="hidden card-bg rounded-xl border p-8 text-center text-gray-400 mb-6">
|
||||||
|
Noch keine API-Nutzungsdaten vorhanden. Statistiken werden automatisch erfasst, sobald Posts erstellt oder Research durchgeführt wird.
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script>
|
||||||
|
let currentPeriod = 'month';
|
||||||
|
let filterType = ''; // '' | 'user' | 'company'
|
||||||
|
let filterId = ''; // UUID
|
||||||
|
let charts = {};
|
||||||
|
|
||||||
|
const chartTheme = {
|
||||||
|
chart: { background: 'transparent', foreColor: '#9CA3AF' },
|
||||||
|
grid: { borderColor: '#4a5858' },
|
||||||
|
tooltip: { theme: 'dark' },
|
||||||
|
colors: ['#ffc700', '#60A5FA', '#34D399', '#A78BFA', '#F87171']
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatTokens(n) {
|
||||||
|
if (n >= 1000000) return (n / 1000000).toFixed(1) + 'M';
|
||||||
|
if (n >= 1000) return (n / 1000).toFixed(1) + 'K';
|
||||||
|
return n.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatHours(h) {
|
||||||
|
if (h === null || h === undefined || h === 0) return '0 Std';
|
||||||
|
if (h < 1) return Math.round(h * 60) + ' Min';
|
||||||
|
if (h >= 24) return (h / 24).toFixed(1) + ' Tage';
|
||||||
|
return h.toFixed(1) + ' Std';
|
||||||
|
}
|
||||||
|
|
||||||
|
function destroyCharts() {
|
||||||
|
Object.values(charts).forEach(c => { try { c.destroy(); } catch(e) {} });
|
||||||
|
charts = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCharts(data) {
|
||||||
|
destroyCharts();
|
||||||
|
|
||||||
|
const hasData = data.total_calls > 0 || data.total_created > 0;
|
||||||
|
document.getElementById('no-data-msg').classList.toggle('hidden', hasData);
|
||||||
|
|
||||||
|
// Update summary cards
|
||||||
|
document.getElementById('stat-tokens').textContent = formatTokens(data.total_tokens);
|
||||||
|
document.getElementById('stat-cost').textContent = '$' + data.total_cost.toFixed(4);
|
||||||
|
document.getElementById('stat-calls').textContent = data.total_calls.toString();
|
||||||
|
document.getElementById('stat-created').textContent = (data.total_created || 0).toString();
|
||||||
|
document.getElementById('stat-approved').textContent = (data.total_approved || 0).toString();
|
||||||
|
document.getElementById('stat-published').textContent = (data.total_published || 0).toString();
|
||||||
|
|
||||||
|
// Derived metrics cards
|
||||||
|
document.getElementById('stat-avg-cost').textContent = '$' + (data.avg_cost_per_post || 0).toFixed(4);
|
||||||
|
document.getElementById('stat-projection').textContent = '$' + (data.monthly_projection || 0).toFixed(2);
|
||||||
|
document.getElementById('stat-avg-tokens').textContent = formatTokens(data.avg_tokens_per_post || 0);
|
||||||
|
document.getElementById('stat-time-approval').textContent = formatHours(data.avg_hours_to_approval);
|
||||||
|
document.getElementById('stat-time-publish').textContent = formatHours(data.avg_hours_to_publish);
|
||||||
|
|
||||||
|
// Funnel
|
||||||
|
document.getElementById('funnel-created').textContent = (data.total_created || 0).toString();
|
||||||
|
document.getElementById('funnel-approved').textContent = (data.total_approved || 0).toString();
|
||||||
|
document.getElementById('funnel-published').textContent = (data.total_published || 0).toString();
|
||||||
|
document.getElementById('funnel-approval-rate').textContent = (data.approval_rate || 0) + '%';
|
||||||
|
document.getElementById('funnel-publish-rate').textContent = (data.publish_rate || 0) + '%';
|
||||||
|
|
||||||
|
// Daily tokens line chart (total + per model)
|
||||||
|
if (data.daily.length > 0) {
|
||||||
|
const tokenSeries = [{ name: 'Gesamt', data: data.daily.map(d => ({ x: d.date, y: d.tokens })) }];
|
||||||
|
const costSeries = [{ name: 'Gesamt', data: data.daily.map(d => ({ x: d.date, y: parseFloat(d.cost.toFixed(4)) })) }];
|
||||||
|
const modelColors = { 'gpt-4o': '#60A5FA', 'gpt-4o-mini': '#A78BFA', 'sonar': '#F87171' };
|
||||||
|
|
||||||
|
if (data.daily_by_model) {
|
||||||
|
for (const [model, days] of Object.entries(data.daily_by_model)) {
|
||||||
|
tokenSeries.push({ name: model, data: days.map(d => ({ x: d.date, y: d.tokens })) });
|
||||||
|
costSeries.push({ name: model, data: days.map(d => ({ x: d.date, y: parseFloat(d.cost.toFixed(4)) })) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const seriesColors = ['#ffc700'].concat(tokenSeries.slice(1).map(s => modelColors[s.name] || '#9CA3AF'));
|
||||||
|
const strokeWidths = [2].concat(tokenSeries.slice(1).map(() => 2));
|
||||||
|
// Gesamt = filled gradient, model lines = no fill
|
||||||
|
const fillOpacity = [0.3].concat(tokenSeries.slice(1).map(() => 0));
|
||||||
|
|
||||||
|
charts.dailyTokens = new ApexCharts(document.getElementById('chart-daily-tokens'), {
|
||||||
|
...chartTheme,
|
||||||
|
chart: { ...chartTheme.chart, type: 'area', height: 300 },
|
||||||
|
series: tokenSeries,
|
||||||
|
xaxis: { type: 'category', labels: { rotate: -45 } },
|
||||||
|
yaxis: { labels: { formatter: formatTokens } },
|
||||||
|
stroke: { curve: 'smooth', width: strokeWidths },
|
||||||
|
fill: { opacity: fillOpacity },
|
||||||
|
colors: seriesColors,
|
||||||
|
legend: { position: 'top', labels: { colors: '#9CA3AF' } },
|
||||||
|
dataLabels: { enabled: false }
|
||||||
|
});
|
||||||
|
charts.dailyTokens.render();
|
||||||
|
|
||||||
|
const costColors = ['#34D399'].concat(costSeries.slice(1).map(s => modelColors[s.name] || '#9CA3AF'));
|
||||||
|
|
||||||
|
charts.dailyCost = new ApexCharts(document.getElementById('chart-daily-cost'), {
|
||||||
|
...chartTheme,
|
||||||
|
chart: { ...chartTheme.chart, type: 'area', height: 300 },
|
||||||
|
series: costSeries,
|
||||||
|
xaxis: { type: 'category', labels: { rotate: -45 } },
|
||||||
|
yaxis: { labels: { formatter: v => '$' + v.toFixed(4) } },
|
||||||
|
stroke: { curve: 'smooth', width: strokeWidths },
|
||||||
|
fill: { opacity: fillOpacity },
|
||||||
|
colors: costColors,
|
||||||
|
legend: { position: 'top', labels: { colors: '#9CA3AF' } },
|
||||||
|
dataLabels: { enabled: false }
|
||||||
|
});
|
||||||
|
charts.dailyCost.render();
|
||||||
|
}
|
||||||
|
|
||||||
|
// By model pie chart
|
||||||
|
if (data.by_model.length > 0) {
|
||||||
|
charts.byModel = new ApexCharts(document.getElementById('chart-by-model'), {
|
||||||
|
...chartTheme,
|
||||||
|
chart: { ...chartTheme.chart, type: 'donut', height: 280 },
|
||||||
|
series: data.by_model.map(m => m.tokens),
|
||||||
|
labels: data.by_model.map(m => m.model),
|
||||||
|
legend: { position: 'bottom', labels: { colors: '#9CA3AF' } },
|
||||||
|
plotOptions: { pie: { donut: { size: '60%' } } }
|
||||||
|
});
|
||||||
|
charts.byModel.render();
|
||||||
|
}
|
||||||
|
|
||||||
|
// By operation bar chart
|
||||||
|
if (data.by_operation.length > 0) {
|
||||||
|
charts.byOperation = new ApexCharts(document.getElementById('chart-by-operation'), {
|
||||||
|
...chartTheme,
|
||||||
|
chart: { ...chartTheme.chart, type: 'bar', height: 280 },
|
||||||
|
series: [{ name: 'Tokens', data: data.by_operation.map(o => o.tokens) }],
|
||||||
|
xaxis: { categories: data.by_operation.map(o => o.operation) },
|
||||||
|
yaxis: { labels: { formatter: formatTokens } },
|
||||||
|
plotOptions: { bar: { borderRadius: 4, horizontal: false } },
|
||||||
|
dataLabels: { enabled: false }
|
||||||
|
});
|
||||||
|
charts.byOperation.render();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Posts daily chart (created / approved / published)
|
||||||
|
if (data.posts_daily && data.posts_daily.length > 0) {
|
||||||
|
charts.postsByDay = new ApexCharts(document.getElementById('chart-posts-daily'), {
|
||||||
|
...chartTheme,
|
||||||
|
chart: { ...chartTheme.chart, type: 'bar', height: 300 },
|
||||||
|
series: [
|
||||||
|
{ name: 'Erstellt', data: data.posts_daily.map(d => ({ x: d.date, y: d.created })) },
|
||||||
|
{ name: 'Freigegeben', data: data.posts_daily.map(d => ({ x: d.date, y: d.approved })) },
|
||||||
|
{ name: 'Veröffentlicht', data: data.posts_daily.map(d => ({ x: d.date, y: d.published })) }
|
||||||
|
],
|
||||||
|
xaxis: { type: 'category', labels: { rotate: -45 } },
|
||||||
|
yaxis: { labels: { formatter: v => Math.round(v).toString() }, forceNiceScale: true },
|
||||||
|
plotOptions: { bar: { borderRadius: 3, columnWidth: '60%' } },
|
||||||
|
colors: ['#A78BFA', '#FBBF24', '#34D399'],
|
||||||
|
legend: { position: 'top', labels: { colors: '#9CA3AF' } },
|
||||||
|
dataLabels: { enabled: false }
|
||||||
|
});
|
||||||
|
charts.postsByDay.render();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onEntityFilterChange() {
|
||||||
|
const val = document.getElementById('entityFilter').value;
|
||||||
|
if (!val) {
|
||||||
|
filterType = '';
|
||||||
|
filterId = '';
|
||||||
|
} else {
|
||||||
|
const parts = val.split(':');
|
||||||
|
filterType = parts[0]; // 'user' or 'company'
|
||||||
|
filterId = parts[1];
|
||||||
|
}
|
||||||
|
loadStats(currentPeriod);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadStats(period) {
|
||||||
|
currentPeriod = period;
|
||||||
|
|
||||||
|
// Update period button styles
|
||||||
|
['today', 'week', 'month', 'all'].forEach(p => {
|
||||||
|
const btn = document.getElementById('period-' + p);
|
||||||
|
btn.className = p === period
|
||||||
|
? 'px-4 py-2 rounded-lg text-sm font-medium transition-colors btn-primary'
|
||||||
|
: 'px-4 py-2 rounded-lg text-sm font-medium transition-colors bg-brand-bg-light text-gray-300 hover:bg-brand-bg';
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
let url = `/admin/api/statistics?period=${period}`;
|
||||||
|
if (filterType === 'user' && filterId) {
|
||||||
|
url += `&user_id=${filterId}`;
|
||||||
|
} else if (filterType === 'company' && filterId) {
|
||||||
|
url += `&company_id=${filterId}`;
|
||||||
|
}
|
||||||
|
const resp = await fetch(url);
|
||||||
|
const data = await resp.json();
|
||||||
|
renderCharts(data);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to load stats:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load initial data
|
||||||
|
loadStats('month');
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -1,159 +0,0 @@
|
|||||||
{% extends "base.html" %}
|
|
||||||
{% block title %}Status - LinkedIn Posts{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<div class="mb-8">
|
|
||||||
<h1 class="text-3xl font-bold text-white mb-2">Status</h1>
|
|
||||||
<p class="text-gray-400">Übersicht über alle Kunden und deren Setup-Status</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% if error %}
|
|
||||||
<div class="bg-red-900/50 border border-red-500 text-red-200 px-4 py-3 rounded-lg mb-6">
|
|
||||||
<strong>Error:</strong> {{ error }}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if customer_statuses %}
|
|
||||||
<div class="grid gap-6">
|
|
||||||
{% for item in customer_statuses %}
|
|
||||||
<div class="card-bg rounded-xl border overflow-hidden">
|
|
||||||
<!-- Customer Header -->
|
|
||||||
<div class="px-6 py-4 border-b border-brand-bg-light">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<div class="flex items-center gap-4">
|
|
||||||
<div class="w-12 h-12 rounded-full flex items-center justify-center overflow-hidden {{ 'bg-brand-highlight' if not item.profile_picture else '' }}">
|
|
||||||
{% if item.profile_picture %}
|
|
||||||
<img src="{{ item.profile_picture }}" alt="{{ item.customer.name }}" class="w-full h-full object-cover" loading="lazy" referrerpolicy="no-referrer">
|
|
||||||
{% else %}
|
|
||||||
<span class="text-brand-bg-dark font-bold text-lg">{{ item.customer.name[0] | upper }}</span>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h3 class="font-semibold text-white text-lg">{{ item.customer.name }}</h3>
|
|
||||||
<p class="text-sm text-gray-400">{{ item.customer.company_name or 'Kein Unternehmen' }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
{% if item.status.ready_for_posts %}
|
|
||||||
<span class="px-3 py-1.5 bg-green-600/30 text-green-300 rounded-lg text-sm font-medium flex items-center gap-2">
|
|
||||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg>
|
|
||||||
Bereit für Posts
|
|
||||||
</span>
|
|
||||||
{% else %}
|
|
||||||
<span class="px-3 py-1.5 bg-yellow-600/30 text-yellow-300 rounded-lg text-sm font-medium flex items-center gap-2">
|
|
||||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/></svg>
|
|
||||||
Setup unvollständig
|
|
||||||
</span>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Status Grid -->
|
|
||||||
<div class="p-6">
|
|
||||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
|
|
||||||
<!-- Scraped Posts -->
|
|
||||||
<div class="bg-brand-bg/30 rounded-lg p-4">
|
|
||||||
<div class="flex items-center gap-2 mb-2">
|
|
||||||
{% if item.status.has_scraped_posts %}
|
|
||||||
<svg class="w-5 h-5 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg>
|
|
||||||
{% else %}
|
|
||||||
<svg class="w-5 h-5 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>
|
|
||||||
{% endif %}
|
|
||||||
<span class="text-sm text-gray-400">Scraped Posts</span>
|
|
||||||
</div>
|
|
||||||
<p class="text-2xl font-bold text-white">{{ item.status.scraped_posts_count }}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Profile Analysis -->
|
|
||||||
<div class="bg-brand-bg/30 rounded-lg p-4">
|
|
||||||
<div class="flex items-center gap-2 mb-2">
|
|
||||||
{% if item.status.has_profile_analysis %}
|
|
||||||
<svg class="w-5 h-5 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg>
|
|
||||||
{% else %}
|
|
||||||
<svg class="w-5 h-5 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>
|
|
||||||
{% endif %}
|
|
||||||
<span class="text-sm text-gray-400">Profil Analyse</span>
|
|
||||||
</div>
|
|
||||||
<p class="text-lg font-semibold text-white">{{ 'Vorhanden' if item.status.has_profile_analysis else 'Fehlt' }}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Research Topics -->
|
|
||||||
<div class="bg-brand-bg/30 rounded-lg p-4">
|
|
||||||
<div class="flex items-center gap-2 mb-2">
|
|
||||||
{% if item.status.research_count > 0 %}
|
|
||||||
<svg class="w-5 h-5 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg>
|
|
||||||
{% else %}
|
|
||||||
<svg class="w-5 h-5 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>
|
|
||||||
{% endif %}
|
|
||||||
<span class="text-sm text-gray-400">Research Topics</span>
|
|
||||||
</div>
|
|
||||||
<p class="text-2xl font-bold text-white">{{ item.status.research_count }}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Generated Posts -->
|
|
||||||
<div class="bg-brand-bg/30 rounded-lg p-4">
|
|
||||||
<div class="flex items-center gap-2 mb-2">
|
|
||||||
<svg class="w-5 h-5 text-brand-highlight" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/></svg>
|
|
||||||
<span class="text-sm text-gray-400">Generierte Posts</span>
|
|
||||||
</div>
|
|
||||||
<p class="text-2xl font-bold text-white">{{ item.status.posts_count }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Missing Items -->
|
|
||||||
{% if item.status.missing_items %}
|
|
||||||
<div class="bg-yellow-900/20 border border-yellow-600/50 rounded-lg p-4">
|
|
||||||
<h4 class="font-medium text-yellow-300 mb-2 flex items-center gap-2">
|
|
||||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/></svg>
|
|
||||||
Fehlende Elemente
|
|
||||||
</h4>
|
|
||||||
<ul class="space-y-1">
|
|
||||||
{% for item_missing in item.status.missing_items %}
|
|
||||||
<li class="text-yellow-200/80 text-sm flex items-center gap-2">
|
|
||||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/></svg>
|
|
||||||
{{ item_missing }}
|
|
||||||
</li>
|
|
||||||
{% endfor %}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<!-- Quick Actions -->
|
|
||||||
<div class="flex flex-wrap gap-3 mt-4">
|
|
||||||
{% if not item.status.has_profile_analysis %}
|
|
||||||
<a href="/admin/customers/new" class="px-4 py-2 btn-primary rounded-lg text-sm transition-colors">
|
|
||||||
Setup wiederholen
|
|
||||||
</a>
|
|
||||||
{% endif %}
|
|
||||||
{% if item.status.research_count == 0 %}
|
|
||||||
<a href="/admin/research" class="px-4 py-2 bg-green-600 hover:bg-green-700 rounded-lg text-sm text-white transition-colors">
|
|
||||||
Recherche starten
|
|
||||||
</a>
|
|
||||||
{% endif %}
|
|
||||||
{% if item.status.ready_for_posts %}
|
|
||||||
<a href="/admin/create" class="px-4 py-2 btn-primary rounded-lg text-sm transition-colors">
|
|
||||||
Post erstellen
|
|
||||||
</a>
|
|
||||||
{% endif %}
|
|
||||||
<a href="/admin/impersonate/{{ item.customer.id }}" class="px-4 py-2 bg-purple-600 hover:bg-purple-700 rounded-lg text-sm text-white transition-colors flex items-center gap-2">
|
|
||||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"/></svg>
|
|
||||||
Als User einloggen
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
{% else %}
|
|
||||||
<div class="card-bg rounded-xl border p-12 text-center">
|
|
||||||
<svg class="w-16 h-16 text-gray-600 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"/></svg>
|
|
||||||
<h3 class="text-xl font-semibold text-white mb-2">Noch keine Kunden</h3>
|
|
||||||
<p class="text-gray-400 mb-6">Erstelle deinen ersten Kunden, um den Status zu sehen.</p>
|
|
||||||
<a href="/admin/customers/new" class="inline-flex items-center gap-2 px-6 py-3 btn-primary font-medium rounded-lg transition-colors">
|
|
||||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"/></svg>
|
|
||||||
Neuer Kunde
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
{% endblock %}
|
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
<!-- Customer Selection -->
|
<!-- Customer Selection -->
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-300 mb-2">Kunde auswählen</label>
|
<label class="block text-sm font-medium text-gray-300 mb-2">Kunde auswählen</label>
|
||||||
<select name="customer_id" id="customerSelect" required class="w-full input-bg border rounded-lg px-4 py-3 text-white">
|
<select name="user_id" id="customerSelect" required class="w-full input-bg border rounded-lg px-4 py-3 text-white">
|
||||||
<option value="">-- Kunde wählen --</option>
|
<option value="">-- Kunde wählen --</option>
|
||||||
{% for customer in customers %}
|
{% for customer in customers %}
|
||||||
<option value="{{ customer.id }}">{{ customer.name }} - {{ customer.company_name or 'Kein Unternehmen' }}</option>
|
<option value="{{ customer.id }}">{{ customer.name }} - {{ customer.company_name or 'Kein Unternehmen' }}</option>
|
||||||
@@ -229,7 +229,7 @@ customerSelect.addEventListener('change', async () => {
|
|||||||
|
|
||||||
// Load post types
|
// Load post types
|
||||||
try {
|
try {
|
||||||
const ptResponse = await fetch(`/api/customers/${customerId}/post-types`);
|
const ptResponse = await fetch(`/api/users/${customerId}/post-types`);
|
||||||
const ptData = await ptResponse.json();
|
const ptData = await ptResponse.json();
|
||||||
|
|
||||||
if (ptData.post_types && ptData.post_types.length > 0) {
|
if (ptData.post_types && ptData.post_types.length > 0) {
|
||||||
@@ -258,7 +258,7 @@ customerSelect.addEventListener('change', async () => {
|
|||||||
|
|
||||||
// Load topics
|
// Load topics
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/customers/${customerId}/topics`);
|
const response = await fetch(`/api/users/${customerId}/topics`);
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
if (data.topics && data.topics.length > 0) {
|
if (data.topics && data.topics.length > 0) {
|
||||||
@@ -317,7 +317,7 @@ async function loadTopicsForPostType(customerId, postTypeId) {
|
|||||||
topicsList.innerHTML = '<p class="text-gray-500">Lade Topics...</p>';
|
topicsList.innerHTML = '<p class="text-gray-500">Lade Topics...</p>';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let url = `/api/customers/${customerId}/topics`;
|
let url = `/api/users/${customerId}/topics`;
|
||||||
if (postTypeId) {
|
if (postTypeId) {
|
||||||
url += `?post_type_id=${postTypeId}`;
|
url += `?post_type_id=${postTypeId}`;
|
||||||
}
|
}
|
||||||
@@ -437,7 +437,7 @@ form.addEventListener('submit', async (e) => {
|
|||||||
postResult.innerHTML = '<p class="text-gray-400">Post wird generiert...</p>';
|
postResult.innerHTML = '<p class="text-gray-400">Post wird generiert...</p>';
|
||||||
|
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('customer_id', customerId);
|
formData.append('user_id', customerId);
|
||||||
formData.append('topic_json', JSON.stringify(topic));
|
formData.append('topic_json', JSON.stringify(topic));
|
||||||
if (selectedPostTypeIdInput.value) {
|
if (selectedPostTypeIdInput.value) {
|
||||||
formData.append('post_type_id', selectedPostTypeIdInput.value);
|
formData.append('post_type_id', selectedPostTypeIdInput.value);
|
||||||
|
|||||||
260
src/web/templates/privacy_policy.html
Normal file
260
src/web/templates/privacy_policy.html
Normal file
@@ -0,0 +1,260 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Datenschutzerklärung - LinkedIn Workflow</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||||
|
line-height: 1.6;
|
||||||
|
max-width: 800px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 20px;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
h1 { color: #0a66c2; }
|
||||||
|
h2 { color: #0a66c2; margin-top: 30px; }
|
||||||
|
.last-updated { color: #666; font-style: italic; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>Datenschutzerklärung / Privacy Policy</h1>
|
||||||
|
<p class="last-updated">Letzte Aktualisierung: {{ current_date }}</p>
|
||||||
|
|
||||||
|
<h2>1. Überblick</h2>
|
||||||
|
<p>
|
||||||
|
Diese Datenschutzerklärung beschreibt, wie LinkedIn Workflow ("wir", "uns", "unsere App")
|
||||||
|
Ihre Informationen sammelt, verwendet und schützt, wenn Sie unsere LinkedIn-Content-Scheduling-Plattform nutzen.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>2. Welche Daten sammeln wir?</h2>
|
||||||
|
|
||||||
|
<h3>2.1 LinkedIn-Profildaten (via LinkedIn OAuth)</h3>
|
||||||
|
<ul>
|
||||||
|
<li>Ihr Name und LinkedIn-Profil-ID</li>
|
||||||
|
<li>Ihre E-Mail-Adresse</li>
|
||||||
|
<li>Ihr Profilbild (optional)</li>
|
||||||
|
<li>LinkedIn Access Token (verschlüsselt gespeichert)</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3>2.2 Von Ihnen erstellte Inhalte</h3>
|
||||||
|
<ul>
|
||||||
|
<li>Entwürfe und geplante LinkedIn-Posts</li>
|
||||||
|
<li>Hochgeladene Bilder für Posts</li>
|
||||||
|
<li>Zeitpläne für Veröffentlichungen</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3>2.3 Nutzungsdaten</h3>
|
||||||
|
<ul>
|
||||||
|
<li>Anmeldezeitpunkte</li>
|
||||||
|
<li>Erstellte und veröffentlichte Posts</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>3. Wie verwenden wir Ihre Daten?</h2>
|
||||||
|
<p>Wir verwenden Ihre Daten ausschließlich für folgende Zwecke:</p>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Authentifizierung:</strong> Um Sie sicher anzumelden</li>
|
||||||
|
<li><strong>Content-Posting:</strong> Um Ihre genehmigten Posts auf LinkedIn zu veröffentlichen</li>
|
||||||
|
<li><strong>Content-Management:</strong> Um Ihre Post-Entwürfe und Zeitpläne zu speichern</li>
|
||||||
|
<li><strong>Service-Bereitstellung:</strong> Um die Kernfunktionen unserer App bereitzustellen</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>4. Datenspeicherung und Sicherheit</h2>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Verschlüsselung:</strong> LinkedIn Access Tokens werden verschlüsselt gespeichert (AES-256)</li>
|
||||||
|
<li><strong>Sichere Datenbank:</strong> Alle Daten werden in einer sicheren Supabase-Datenbank gespeichert</li>
|
||||||
|
<li><strong>Zugriffskontrolle:</strong> Nur Sie können auf Ihre eigenen Daten zugreifen</li>
|
||||||
|
<li><strong>HTTPS:</strong> Alle Datenübertragungen erfolgen verschlüsselt über HTTPS</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>5. Datenweitergabe an Dritte</h2>
|
||||||
|
<p>Wir geben Ihre Daten <strong>NICHT</strong> an Dritte weiter, mit folgenden Ausnahmen:</p>
|
||||||
|
<ul>
|
||||||
|
<li><strong>LinkedIn:</strong> Wenn Sie einen Post planen, senden wir den Inhalt an LinkedIn's API, um ihn zu veröffentlichen (nur auf Ihre ausdrückliche Anweisung)</li>
|
||||||
|
<li><strong>Infrastruktur-Anbieter:</strong> Supabase (Datenbank-Hosting) und andere technische Dienstleister, die unsere App betreiben</li>
|
||||||
|
<li><strong>Gesetzliche Verpflichtungen:</strong> Nur wenn gesetzlich vorgeschrieben</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>6. Verwendung der LinkedIn-Daten</h2>
|
||||||
|
<p>
|
||||||
|
Unsere App verwendet die LinkedIn-API, um Posts in Ihrem Namen zu veröffentlichen.
|
||||||
|
Wir halten uns strikt an die
|
||||||
|
<a href="https://legal.linkedin.com/api-terms-of-use" target="_blank">LinkedIn API Terms of Use</a>.
|
||||||
|
</p>
|
||||||
|
<ul>
|
||||||
|
<li>Posts werden NUR veröffentlicht, wenn Sie dies ausdrücklich genehmigt haben</li>
|
||||||
|
<li>Wir posten KEINEN automatischen oder Spam-Inhalt</li>
|
||||||
|
<li>Sie behalten die volle Kontrolle über alle veröffentlichten Inhalte</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>7. Ihre Rechte</h2>
|
||||||
|
<p>Sie haben folgende Rechte bezüglich Ihrer Daten:</p>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Zugriff:</strong> Sie können alle Ihre gespeicherten Daten jederzeit einsehen</li>
|
||||||
|
<li><strong>Bearbeitung:</strong> Sie können Ihre Daten jederzeit ändern oder löschen</li>
|
||||||
|
<li><strong>Löschung:</strong> Sie können Ihr Konto und alle zugehörigen Daten vollständig löschen</li>
|
||||||
|
<li><strong>Widerruf:</strong> Sie können die LinkedIn-Verbindung jederzeit in den Einstellungen trennen</li>
|
||||||
|
<li><strong>Export:</strong> Sie können Ihre Daten exportieren (auf Anfrage)</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>8. LinkedIn-Verbindung trennen</h2>
|
||||||
|
<p>
|
||||||
|
Sie können die LinkedIn-Verbindung jederzeit trennen durch:
|
||||||
|
</p>
|
||||||
|
<ul>
|
||||||
|
<li>Einstellungen → LinkedIn-Konto → "Verbindung trennen"</li>
|
||||||
|
<li>LinkedIn.com → Einstellungen → Apps → Unsere App entfernen</li>
|
||||||
|
</ul>
|
||||||
|
<p>
|
||||||
|
Nach der Trennung löschen wir Ihre LinkedIn Access Tokens sofort.
|
||||||
|
Ihre gespeicherten Post-Entwürfe bleiben erhalten (können aber nicht mehr veröffentlicht werden).
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>9. Cookies</h2>
|
||||||
|
<p>Wir verwenden folgende Cookies:</p>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Session-Cookie:</strong> Zur Authentifizierung (erforderlich)</li>
|
||||||
|
<li><strong>OAuth-State-Cookie:</strong> Zur sicheren LinkedIn-Authentifizierung (temporär)</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>10. Kinder</h2>
|
||||||
|
<p>
|
||||||
|
Unsere App richtet sich nicht an Personen unter 16 Jahren.
|
||||||
|
Wir sammeln wissentlich keine Daten von Kindern.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>11. Änderungen dieser Datenschutzerklärung</h2>
|
||||||
|
<p>
|
||||||
|
Wir können diese Datenschutzerklärung gelegentlich aktualisieren.
|
||||||
|
Änderungen werden auf dieser Seite veröffentlicht und das "Letzte Aktualisierung"-Datum wird aktualisiert.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>12. Kontakt</h2>
|
||||||
|
<p>
|
||||||
|
Bei Fragen zu dieser Datenschutzerklärung oder Ihren Daten kontaktieren Sie uns bitte:
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>E-Mail:</strong> [IHRE-EMAIL-ADRESSE]<br>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<h1>Privacy Policy (English)</h1>
|
||||||
|
<p class="last-updated">Last Updated: {{ current_date }}</p>
|
||||||
|
|
||||||
|
<h2>1. Overview</h2>
|
||||||
|
<p>
|
||||||
|
This Privacy Policy describes how LinkedIn Workflow ("we", "us", "our app")
|
||||||
|
collects, uses, and protects your information when you use our LinkedIn content scheduling platform.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>2. What Data We Collect</h2>
|
||||||
|
|
||||||
|
<h3>2.1 LinkedIn Profile Data (via LinkedIn OAuth)</h3>
|
||||||
|
<ul>
|
||||||
|
<li>Your name and LinkedIn profile ID</li>
|
||||||
|
<li>Your email address</li>
|
||||||
|
<li>Your profile picture (optional)</li>
|
||||||
|
<li>LinkedIn access token (encrypted)</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3>2.2 Content You Create</h3>
|
||||||
|
<ul>
|
||||||
|
<li>Draft and scheduled LinkedIn posts</li>
|
||||||
|
<li>Uploaded images for posts</li>
|
||||||
|
<li>Publishing schedules</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3>2.3 Usage Data</h3>
|
||||||
|
<ul>
|
||||||
|
<li>Login timestamps</li>
|
||||||
|
<li>Created and published posts</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>3. How We Use Your Data</h2>
|
||||||
|
<p>We use your data exclusively for:</p>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Authentication:</strong> To securely log you in</li>
|
||||||
|
<li><strong>Content Posting:</strong> To publish your approved posts to LinkedIn</li>
|
||||||
|
<li><strong>Content Management:</strong> To store your post drafts and schedules</li>
|
||||||
|
<li><strong>Service Provision:</strong> To provide our app's core functionality</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>4. Data Storage and Security</h2>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Encryption:</strong> LinkedIn access tokens are encrypted (AES-256)</li>
|
||||||
|
<li><strong>Secure Database:</strong> All data stored in secure Supabase database</li>
|
||||||
|
<li><strong>Access Control:</strong> Only you can access your own data</li>
|
||||||
|
<li><strong>HTTPS:</strong> All data transmissions encrypted via HTTPS</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>5. Data Sharing</h2>
|
||||||
|
<p>We do <strong>NOT</strong> share your data with third parties, except:</p>
|
||||||
|
<ul>
|
||||||
|
<li><strong>LinkedIn:</strong> When you schedule a post, we send content to LinkedIn's API to publish it (only at your explicit request)</li>
|
||||||
|
<li><strong>Infrastructure Providers:</strong> Supabase (database hosting) and other technical service providers</li>
|
||||||
|
<li><strong>Legal Obligations:</strong> Only when legally required</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>6. LinkedIn Data Usage</h2>
|
||||||
|
<p>
|
||||||
|
Our app uses the LinkedIn API to post on your behalf.
|
||||||
|
We strictly comply with
|
||||||
|
<a href="https://legal.linkedin.com/api-terms-of-use" target="_blank">LinkedIn API Terms of Use</a>.
|
||||||
|
</p>
|
||||||
|
<ul>
|
||||||
|
<li>Posts are ONLY published when you explicitly approve them</li>
|
||||||
|
<li>We do NOT post automatic or spam content</li>
|
||||||
|
<li>You maintain full control over all published content</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>7. Your Rights</h2>
|
||||||
|
<p>You have the following rights regarding your data:</p>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Access:</strong> View all your stored data at any time</li>
|
||||||
|
<li><strong>Edit:</strong> Modify or delete your data at any time</li>
|
||||||
|
<li><strong>Deletion:</strong> Completely delete your account and all associated data</li>
|
||||||
|
<li><strong>Revoke:</strong> Disconnect LinkedIn connection in settings at any time</li>
|
||||||
|
<li><strong>Export:</strong> Request data export</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>8. Disconnect LinkedIn</h2>
|
||||||
|
<p>You can disconnect LinkedIn at any time via:</p>
|
||||||
|
<ul>
|
||||||
|
<li>Settings → LinkedIn Account → "Disconnect"</li>
|
||||||
|
<li>LinkedIn.com → Settings → Apps → Remove our app</li>
|
||||||
|
</ul>
|
||||||
|
<p>
|
||||||
|
After disconnection, we immediately delete your LinkedIn access tokens.
|
||||||
|
Your saved post drafts remain (but cannot be published).
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>9. Cookies</h2>
|
||||||
|
<p>We use the following cookies:</p>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Session Cookie:</strong> For authentication (required)</li>
|
||||||
|
<li><strong>OAuth State Cookie:</strong> For secure LinkedIn authentication (temporary)</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>10. Children</h2>
|
||||||
|
<p>
|
||||||
|
Our app is not directed at persons under 16 years old.
|
||||||
|
We do not knowingly collect data from children.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>11. Changes to Privacy Policy</h2>
|
||||||
|
<p>
|
||||||
|
We may update this Privacy Policy occasionally.
|
||||||
|
Changes will be posted on this page with an updated "Last Updated" date.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>12. Contact</h2>
|
||||||
|
<p>
|
||||||
|
For questions about this Privacy Policy or your data, please contact us:
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>Email:</strong> [YOUR-EMAIL-ADDRESS]<br>
|
||||||
|
</p>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -13,7 +13,7 @@
|
|||||||
<form id="researchForm" class="card-bg rounded-xl border p-6">
|
<form id="researchForm" class="card-bg rounded-xl border p-6">
|
||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
<label class="block text-sm font-medium text-gray-300 mb-2">Kunde auswählen</label>
|
<label class="block text-sm font-medium text-gray-300 mb-2">Kunde auswählen</label>
|
||||||
<select name="customer_id" id="customerSelect" required class="w-full input-bg border rounded-lg px-4 py-3 text-white">
|
<select name="user_id" id="customerSelect" required class="w-full input-bg border rounded-lg px-4 py-3 text-white">
|
||||||
<option value="">-- Kunde wählen --</option>
|
<option value="">-- Kunde wählen --</option>
|
||||||
{% for customer in customers %}
|
{% for customer in customers %}
|
||||||
<option value="{{ customer.id }}">{{ customer.name }} - {{ customer.company_name or 'Kein Unternehmen' }}</option>
|
<option value="{{ customer.id }}">{{ customer.name }} - {{ customer.company_name or 'Kein Unternehmen' }}</option>
|
||||||
@@ -96,7 +96,7 @@ customerSelect.addEventListener('change', async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/customers/${customerId}/post-types`);
|
const response = await fetch(`/api/users/${customerId}/post-types`);
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
if (data.post_types && data.post_types.length > 0) {
|
if (data.post_types && data.post_types.length > 0) {
|
||||||
@@ -150,7 +150,7 @@ form.addEventListener('submit', async (e) => {
|
|||||||
progressArea.classList.remove('hidden');
|
progressArea.classList.remove('hidden');
|
||||||
|
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('customer_id', customerId);
|
formData.append('user_id', customerId);
|
||||||
if (selectedPostTypeId.value) {
|
if (selectedPostTypeId.value) {
|
||||||
formData.append('post_type_id', selectedPostTypeId.value);
|
formData.append('post_type_id', selectedPostTypeId.value);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -210,7 +210,7 @@ customerSelect.addEventListener('change', async () => {
|
|||||||
async function loadCustomerData(customerId) {
|
async function loadCustomerData(customerId) {
|
||||||
// Load post types
|
// Load post types
|
||||||
try {
|
try {
|
||||||
const ptResponse = await fetch(`/api/customers/${customerId}/post-types`);
|
const ptResponse = await fetch(`/api/users/${customerId}/post-types`);
|
||||||
const ptData = await ptResponse.json();
|
const ptData = await ptResponse.json();
|
||||||
currentPostTypes = ptData.post_types || [];
|
currentPostTypes = ptData.post_types || [];
|
||||||
|
|
||||||
@@ -231,7 +231,7 @@ async function loadCustomerData(customerId) {
|
|||||||
|
|
||||||
// Load posts
|
// Load posts
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/customers/${customerId}/linkedin-posts`);
|
const response = await fetch(`/api/users/${customerId}/linkedin-posts`);
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
console.log('API Response:', data);
|
console.log('API Response:', data);
|
||||||
@@ -484,7 +484,7 @@ classifyAllBtn.addEventListener('click', async () => {
|
|||||||
progressArea.classList.remove('hidden');
|
progressArea.classList.remove('hidden');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/customers/${customerId}/classify-posts`, {
|
const response = await fetch(`/api/users/${customerId}/classify-posts`, {
|
||||||
method: 'POST'
|
method: 'POST'
|
||||||
});
|
});
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
@@ -512,7 +512,7 @@ analyzeTypesBtn.addEventListener('click', async () => {
|
|||||||
progressArea.classList.remove('hidden');
|
progressArea.classList.remove('hidden');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/customers/${customerId}/analyze-post-types`, {
|
const response = await fetch(`/api/users/${customerId}/analyze-post-types`, {
|
||||||
method: 'POST'
|
method: 'POST'
|
||||||
});
|
});
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|||||||
@@ -57,14 +57,20 @@
|
|||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<div class="w-10 h-10 rounded-full overflow-hidden bg-brand-highlight flex items-center justify-center">
|
<div class="w-10 h-10 rounded-full overflow-hidden bg-brand-highlight flex items-center justify-center">
|
||||||
{% if session.linkedin_picture %}
|
{% if session.linkedin_picture %}
|
||||||
<img src="{{ session.linkedin_picture }}" alt="{{ session.linkedin_name }}" class="w-full h-full object-cover" referrerpolicy="no-referrer">
|
<img src="{{ session.linkedin_picture }}" alt="{{ session.display_name or session.linkedin_name }}" class="w-full h-full object-cover" referrerpolicy="no-referrer">
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="text-brand-bg-dark font-bold">{{ session.customer_name[0] | upper }}</span>
|
<span class="text-brand-bg-dark font-bold">{{ (session.display_name or session.linkedin_name or session.customer_name)[0] | upper }}</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
<p class="text-white font-medium text-sm truncate">{{ session.linkedin_name or session.customer_name }}</p>
|
<p class="text-white font-medium text-sm truncate">{{ session.display_name or session.linkedin_name or 'Benutzer' }}</p>
|
||||||
<p class="text-gray-400 text-xs truncate">{{ session.customer_name }}</p>
|
{% if session.account_type == 'ghostwriter' and session.customer_name %}
|
||||||
|
<p class="text-gray-400 text-xs truncate">schreibt für: {{ session.customer_name }}</p>
|
||||||
|
{% elif session.account_type == 'employee' and session.company_name %}
|
||||||
|
<p class="text-gray-400 text-xs truncate">Mitarbeiter bei: {{ session.company_name }}</p>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-gray-400 text-xs truncate">{{ session.email or '' }}</p>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -87,13 +93,35 @@
|
|||||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"/></svg>
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"/></svg>
|
||||||
Meine Posts
|
Meine Posts
|
||||||
</a>
|
</a>
|
||||||
|
<a href="/post-types" class="nav-link flex items-center gap-3 px-4 py-3 rounded-lg text-gray-300 hover:bg-brand-bg-light transition-colors {% if page == 'post_types' %}active{% endif %}">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z"/></svg>
|
||||||
|
Post-Typen
|
||||||
|
</a>
|
||||||
<a href="/status" class="nav-link flex items-center gap-3 px-4 py-3 rounded-lg text-gray-300 hover:bg-brand-bg-light transition-colors {% if page == 'status' %}active{% endif %}">
|
<a href="/status" class="nav-link flex items-center gap-3 px-4 py-3 rounded-lg text-gray-300 hover:bg-brand-bg-light transition-colors {% if page == 'status' %}active{% endif %}">
|
||||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"/></svg>
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"/></svg>
|
||||||
Status
|
Status
|
||||||
</a>
|
</a>
|
||||||
|
{% if session and session.account_type == 'company' %}
|
||||||
|
<a href="/company/accounts" class="nav-link flex items-center gap-3 px-4 py-3 rounded-lg text-gray-300 hover:bg-brand-bg-light transition-colors {% if page == 'accounts' %}active{% endif %}">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"/></svg>
|
||||||
|
Konten
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
{% if session and session.account_type == 'employee' %}
|
||||||
|
<a href="/employee/strategy" class="nav-link flex items-center gap-3 px-4 py-3 rounded-lg text-gray-300 hover:bg-brand-bg-light transition-colors {% if page == 'strategy' %}active{% endif %}">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-3 7h3m-3 4h3m-6-4h.01M9 16h.01"/>
|
||||||
|
</svg>
|
||||||
|
Unternehmensstrategie
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div class="p-4 border-t border-gray-600">
|
<div class="p-4 border-t border-gray-600 space-y-2">
|
||||||
|
<a href="/settings" class="flex items-center gap-2 text-gray-400 hover:text-gray-200 text-sm transition-colors {% if page == 'settings' %}text-brand-highlight{% endif %}">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/></svg>
|
||||||
|
Einstellungen
|
||||||
|
</a>
|
||||||
<a href="/logout" class="flex items-center gap-2 text-gray-400 hover:text-gray-200 text-sm transition-colors">
|
<a href="/logout" class="flex items-center gap-2 text-gray-400 hover:text-gray-200 text-sm transition-colors">
|
||||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"/></svg>
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"/></svg>
|
||||||
Logout
|
Logout
|
||||||
@@ -108,6 +136,122 @@
|
|||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
<!-- Toast Container -->
|
||||||
|
<div id="toast-container" class="fixed bottom-4 right-4 z-50 space-y-2"></div>
|
||||||
|
|
||||||
|
<!-- Background Jobs Script -->
|
||||||
|
<script>
|
||||||
|
(function() {
|
||||||
|
let eventSource = null;
|
||||||
|
|
||||||
|
function connectToJobUpdates() {
|
||||||
|
if (eventSource) {
|
||||||
|
eventSource.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
eventSource = new EventSource('/api/job-updates');
|
||||||
|
|
||||||
|
eventSource.onmessage = function(event) {
|
||||||
|
const job = JSON.parse(event.data);
|
||||||
|
showJobToast(job);
|
||||||
|
};
|
||||||
|
|
||||||
|
eventSource.onerror = function() {
|
||||||
|
// Reconnect after 5 seconds
|
||||||
|
setTimeout(connectToJobUpdates, 5000);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function showJobToast(job) {
|
||||||
|
const container = document.getElementById('toast-container');
|
||||||
|
let toast = document.getElementById('toast-' + job.id);
|
||||||
|
|
||||||
|
if (!toast) {
|
||||||
|
toast = document.createElement('div');
|
||||||
|
toast.id = 'toast-' + job.id;
|
||||||
|
toast.className = 'bg-brand-bg-dark border border-gray-600 rounded-lg shadow-lg p-4 min-w-80 transform transition-all duration-300';
|
||||||
|
container.appendChild(toast);
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusColors = {
|
||||||
|
'pending': 'text-gray-400',
|
||||||
|
'running': 'text-brand-highlight',
|
||||||
|
'completed': 'text-green-400',
|
||||||
|
'failed': 'text-red-400'
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusIcons = {
|
||||||
|
'pending': '<svg class="w-5 h-5 animate-pulse" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>',
|
||||||
|
'running': '<svg class="w-5 h-5 animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/></svg>',
|
||||||
|
'completed': '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>',
|
||||||
|
'failed': '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>'
|
||||||
|
};
|
||||||
|
|
||||||
|
const jobTypeNames = {
|
||||||
|
'profile_analysis': 'Profil-Analyse',
|
||||||
|
'post_categorization': 'Kategorisierung',
|
||||||
|
'post_type_analysis': 'Post-Typen-Analyse'
|
||||||
|
};
|
||||||
|
|
||||||
|
toast.innerHTML = `
|
||||||
|
<div class="flex items-start gap-3">
|
||||||
|
<span class="${statusColors[job.status]}">${statusIcons[job.status]}</span>
|
||||||
|
<div class="flex-1">
|
||||||
|
<p class="font-medium text-white text-sm">${jobTypeNames[job.job_type] || job.job_type}</p>
|
||||||
|
<p class="text-gray-400 text-xs mt-1">${job.message || ''}</p>
|
||||||
|
${job.status === 'running' ? `
|
||||||
|
<div class="mt-2 bg-gray-700 rounded-full h-1.5 overflow-hidden">
|
||||||
|
<div class="bg-brand-highlight h-full transition-all duration-300" style="width: ${job.progress}%"></div>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
${job.error ? `<p class="text-red-400 text-xs mt-1">${job.error}</p>` : ''}
|
||||||
|
</div>
|
||||||
|
${job.status === 'completed' || job.status === 'failed' ? `
|
||||||
|
<button onclick="this.parentElement.parentElement.remove()" class="text-gray-500 hover:text-gray-300">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>
|
||||||
|
</button>
|
||||||
|
` : ''}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Auto-remove completed toasts after 10 seconds
|
||||||
|
if (job.status === 'completed') {
|
||||||
|
setTimeout(() => {
|
||||||
|
if (toast.parentElement) {
|
||||||
|
toast.style.opacity = '0';
|
||||||
|
setTimeout(() => toast.remove(), 300);
|
||||||
|
}
|
||||||
|
}, 10000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connect when page loads
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', connectToJobUpdates);
|
||||||
|
} else {
|
||||||
|
connectToJobUpdates();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expose for manual toasts
|
||||||
|
window.showToast = function(message, type = 'info') {
|
||||||
|
const container = document.getElementById('toast-container');
|
||||||
|
const toast = document.createElement('div');
|
||||||
|
const colors = {
|
||||||
|
'info': 'bg-brand-highlight text-brand-bg-dark',
|
||||||
|
'success': 'bg-green-500 text-white',
|
||||||
|
'error': 'bg-red-500 text-white'
|
||||||
|
};
|
||||||
|
toast.className = `${colors[type]} px-6 py-3 rounded-lg shadow-lg`;
|
||||||
|
toast.textContent = message;
|
||||||
|
container.appendChild(toast);
|
||||||
|
setTimeout(() => {
|
||||||
|
toast.style.opacity = '0';
|
||||||
|
setTimeout(() => toast.remove(), 300);
|
||||||
|
}, 5000);
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
|
||||||
{% block scripts %}{% endblock %}
|
{% block scripts %}{% endblock %}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
185
src/web/templates/user/company/accounts.html
Normal file
185
src/web/templates/user/company/accounts.html
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Mitarbeiter verwalten{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="mb-8">
|
||||||
|
<h1 class="text-2xl font-bold text-white mb-2">Mitarbeiter verwalten</h1>
|
||||||
|
<p class="text-gray-400">Verwalte die Konten deiner Teammitglieder.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if error %}
|
||||||
|
<div class="bg-red-900/50 border border-red-500 text-red-200 px-4 py-3 rounded-lg mb-6">
|
||||||
|
{{ error }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if success %}
|
||||||
|
<div class="bg-green-900/50 border border-green-500 text-green-200 px-4 py-3 rounded-lg mb-6">
|
||||||
|
{{ success }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Invite New Employee -->
|
||||||
|
<div class="card-bg rounded-xl border p-6 mb-8">
|
||||||
|
<h2 class="text-lg font-medium text-white mb-4">Neuen Mitarbeiter einladen</h2>
|
||||||
|
|
||||||
|
<form id="invite-form" class="flex gap-4">
|
||||||
|
<input type="email" id="invite-email"
|
||||||
|
class="flex-1 input-bg border rounded-lg px-4 py-2 text-white"
|
||||||
|
placeholder="email@beispiel.de" required>
|
||||||
|
<button type="submit" class="btn-primary py-2 px-6 rounded-lg transition-colors">
|
||||||
|
Einladung senden
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pending Invitations -->
|
||||||
|
{% if pending_invitations and pending_invitations|length > 0 %}
|
||||||
|
<div class="card-bg rounded-xl border p-6 mb-8">
|
||||||
|
<h2 class="text-lg font-medium text-white mb-4">Offene Einladungen</h2>
|
||||||
|
|
||||||
|
<div class="space-y-3">
|
||||||
|
{% for invitation in pending_invitations %}
|
||||||
|
<div class="flex items-center justify-between p-3 bg-brand-bg border border-gray-600 rounded-lg">
|
||||||
|
<div>
|
||||||
|
<p class="text-white">{{ invitation.email }}</p>
|
||||||
|
<p class="text-xs text-gray-500">Eingeladen am {{ invitation.created_at.strftime('%d.%m.%Y') }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="text-xs px-2 py-1 rounded-full bg-yellow-900/50 text-yellow-300">Ausstehend</span>
|
||||||
|
<button onclick="cancelInvitation('{{ invitation.id }}')"
|
||||||
|
class="text-gray-500 hover:text-red-400 p-1"
|
||||||
|
title="Einladung zurückziehen">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Current Employees -->
|
||||||
|
<div class="card-bg rounded-xl border p-6">
|
||||||
|
<h2 class="text-lg font-medium text-white mb-4">Aktive Mitarbeiter ({{ employees|length }})</h2>
|
||||||
|
|
||||||
|
{% if employees and employees|length > 0 %}
|
||||||
|
<div class="space-y-3">
|
||||||
|
{% for employee in employees %}
|
||||||
|
<div class="flex items-center justify-between p-4 bg-brand-bg border border-gray-600 rounded-lg">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="w-10 h-10 rounded-full overflow-hidden bg-brand-highlight flex items-center justify-center">
|
||||||
|
{% if employee.linkedin_picture %}
|
||||||
|
<img src="{{ employee.linkedin_picture }}" alt="{{ employee.linkedin_name }}" class="w-full h-full object-cover" referrerpolicy="no-referrer">
|
||||||
|
{% else %}
|
||||||
|
<span class="text-brand-bg-dark font-bold">{{ (employee.linkedin_name or employee.email)[0] | upper }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-white font-medium">{{ employee.linkedin_name or employee.email }}</p>
|
||||||
|
<p class="text-xs text-gray-500">{{ employee.email }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<span class="text-xs px-2 py-1 rounded-full
|
||||||
|
{% if employee.onboarding_status == 'completed' or employee.onboarding_status.value == 'completed' %}
|
||||||
|
bg-green-900/50 text-green-300
|
||||||
|
{% else %}
|
||||||
|
bg-yellow-900/50 text-yellow-300
|
||||||
|
{% endif %}">
|
||||||
|
{% if employee.onboarding_status == 'completed' or employee.onboarding_status.value == 'completed' %}Aktiv{% else %}Onboarding{% endif %}
|
||||||
|
</span>
|
||||||
|
<button onclick="removeEmployee('{{ employee.id }}')"
|
||||||
|
class="text-gray-500 hover:text-red-400 p-1"
|
||||||
|
title="Mitarbeiter entfernen">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="text-center py-8">
|
||||||
|
<div class="w-16 h-16 bg-gray-600/30 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||||
|
<svg class="w-8 h-8 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<p class="text-gray-400">Noch keine Mitarbeiter</p>
|
||||||
|
<p class="text-sm text-gray-500">Lade dein erstes Teammitglied ein.</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script>
|
||||||
|
// Send invitation
|
||||||
|
document.getElementById('invite-form').addEventListener('submit', async function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
const email = document.getElementById('invite-email').value;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/company/invite', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({email})
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
location.reload();
|
||||||
|
} else {
|
||||||
|
alert('Fehler: ' + (data.error || 'Unbekannter Fehler'));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
alert('Fehler beim Senden der Einladung');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Cancel invitation
|
||||||
|
async function cancelInvitation(invitationId) {
|
||||||
|
if (!confirm('Einladung wirklich zurückziehen?')) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/company/invitations/' + invitationId, {
|
||||||
|
method: 'DELETE'
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
location.reload();
|
||||||
|
} else {
|
||||||
|
alert('Fehler: ' + (data.error || 'Unbekannter Fehler'));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
alert('Fehler beim Zurückziehen der Einladung');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove employee
|
||||||
|
async function removeEmployee(userId) {
|
||||||
|
if (!confirm('Mitarbeiter wirklich entfernen? Der Zugriff wird sofort entzogen.')) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/company/employees/' + userId, {
|
||||||
|
method: 'DELETE'
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
location.reload();
|
||||||
|
} else {
|
||||||
|
alert('Fehler: ' + (data.error || 'Unbekannter Fehler'));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
alert('Fehler beim Entfernen des Mitarbeiters');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
214
src/web/templates/user/company_accounts.html
Normal file
214
src/web/templates/user/company_accounts.html
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
{% extends "company_base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Mitarbeiter - {{ session.company_name }}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="max-w-4xl mx-auto">
|
||||||
|
<div class="mb-8">
|
||||||
|
<h1 class="text-2xl font-bold text-white mb-2">Mitarbeiter verwalten</h1>
|
||||||
|
<p class="text-gray-400">Verwalte die Konten deiner Teammitglieder.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if error %}
|
||||||
|
<div class="bg-red-900/50 border border-red-500 text-red-200 px-4 py-3 rounded-lg mb-6">
|
||||||
|
{{ error }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if success %}
|
||||||
|
<div class="bg-green-900/50 border border-green-500 text-green-200 px-4 py-3 rounded-lg mb-6">
|
||||||
|
{{ success }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Invite New Employee -->
|
||||||
|
<div class="card-bg rounded-xl border p-6 mb-8">
|
||||||
|
<h2 class="text-lg font-medium text-white mb-4">Neuen Mitarbeiter einladen</h2>
|
||||||
|
|
||||||
|
<form id="invite-form" class="flex gap-4">
|
||||||
|
<input type="email" id="invite-email"
|
||||||
|
class="flex-1 input-bg border rounded-lg px-4 py-2 text-white"
|
||||||
|
placeholder="email@beispiel.de" required>
|
||||||
|
<button type="submit" id="invite-submit-btn"
|
||||||
|
class="btn-primary py-2 px-6 rounded-lg transition-colors flex items-center gap-2">
|
||||||
|
<svg id="invite-spinner" class="hidden animate-spin h-5 w-5 text-brand-bg-dark" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||||
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||||
|
</svg>
|
||||||
|
<span id="invite-btn-text">Einladung senden</span>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pending Invitations -->
|
||||||
|
{% if pending_invitations and pending_invitations|length > 0 %}
|
||||||
|
<div class="card-bg rounded-xl border p-6 mb-8">
|
||||||
|
<h2 class="text-lg font-medium text-white mb-4">Offene Einladungen</h2>
|
||||||
|
|
||||||
|
<div class="space-y-3">
|
||||||
|
{% for invitation in pending_invitations %}
|
||||||
|
<div class="flex items-center justify-between p-3 bg-brand-bg border border-gray-600 rounded-lg">
|
||||||
|
<div>
|
||||||
|
<p class="text-white">{{ invitation.email }}</p>
|
||||||
|
<p class="text-xs text-gray-500">Eingeladen am {{ invitation.created_at.strftime('%d.%m.%Y') }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="text-xs px-2 py-1 rounded-full bg-yellow-900/50 text-yellow-300">Ausstehend</span>
|
||||||
|
<button onclick="cancelInvitation('{{ invitation.id }}')"
|
||||||
|
class="text-gray-500 hover:text-red-400 p-1"
|
||||||
|
title="Einladung zurückziehen">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Current Employees -->
|
||||||
|
<div class="card-bg rounded-xl border p-6">
|
||||||
|
<h2 class="text-lg font-medium text-white mb-4">Aktive Mitarbeiter ({{ employees|length }})</h2>
|
||||||
|
|
||||||
|
{% if employees and employees|length > 0 %}
|
||||||
|
<div class="space-y-3">
|
||||||
|
{% for employee in employees %}
|
||||||
|
<div class="flex items-center justify-between p-4 bg-brand-bg border border-gray-600 rounded-lg">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="w-10 h-10 rounded-full overflow-hidden bg-brand-highlight flex items-center justify-center">
|
||||||
|
{% if employee.linkedin_picture %}
|
||||||
|
<img src="{{ employee.linkedin_picture }}" alt="{{ employee.linkedin_name }}" class="w-full h-full object-cover" referrerpolicy="no-referrer">
|
||||||
|
{% else %}
|
||||||
|
<span class="text-brand-bg-dark font-bold">{{ (employee.display_name or employee.linkedin_name or employee.email)[0] | upper }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-white font-medium">{{ employee.display_name or employee.linkedin_name or employee.email }}</p>
|
||||||
|
<p class="text-xs text-gray-500">{{ employee.email }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
{% set status = employee.onboarding_status.value if employee.onboarding_status.value is defined else employee.onboarding_status %}
|
||||||
|
<span class="text-xs px-2 py-1 rounded-full {% if status == 'completed' %}bg-green-900/50 text-green-300{% else %}bg-yellow-900/50 text-yellow-300{% endif %}">
|
||||||
|
{% if status == 'completed' %}Aktiv{% else %}Onboarding{% endif %}
|
||||||
|
</span>
|
||||||
|
<button onclick="removeEmployee('{{ employee.id }}')"
|
||||||
|
class="text-gray-500 hover:text-red-400 p-1"
|
||||||
|
title="Mitarbeiter entfernen">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="text-center py-8">
|
||||||
|
<div class="w-16 h-16 bg-gray-600/30 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||||
|
<svg class="w-8 h-8 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<p class="text-gray-400">Noch keine Mitarbeiter</p>
|
||||||
|
<p class="text-sm text-gray-500">Lade dein erstes Teammitglied ein.</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script>
|
||||||
|
// Send invitation
|
||||||
|
document.getElementById('invite-form').addEventListener('submit', async function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
const email = document.getElementById('invite-email').value;
|
||||||
|
const submitBtn = document.getElementById('invite-submit-btn');
|
||||||
|
const btnText = document.getElementById('invite-btn-text');
|
||||||
|
const spinner = document.getElementById('invite-spinner');
|
||||||
|
const emailInput = document.getElementById('invite-email');
|
||||||
|
|
||||||
|
// Show loading state
|
||||||
|
submitBtn.disabled = true;
|
||||||
|
emailInput.disabled = true;
|
||||||
|
spinner.classList.remove('hidden');
|
||||||
|
btnText.textContent = 'Sende...';
|
||||||
|
submitBtn.classList.add('opacity-75', 'cursor-not-allowed');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/company/invite', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({email})
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
btnText.textContent = 'Erfolgreich!';
|
||||||
|
setTimeout(() => location.reload(), 500);
|
||||||
|
} else {
|
||||||
|
// Reset button state
|
||||||
|
submitBtn.disabled = false;
|
||||||
|
emailInput.disabled = false;
|
||||||
|
spinner.classList.add('hidden');
|
||||||
|
btnText.textContent = 'Einladung senden';
|
||||||
|
submitBtn.classList.remove('opacity-75', 'cursor-not-allowed');
|
||||||
|
|
||||||
|
alert('Fehler: ' + (data.error || 'Unbekannter Fehler'));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Reset button state
|
||||||
|
submitBtn.disabled = false;
|
||||||
|
emailInput.disabled = false;
|
||||||
|
spinner.classList.add('hidden');
|
||||||
|
btnText.textContent = 'Einladung senden';
|
||||||
|
submitBtn.classList.remove('opacity-75', 'cursor-not-allowed');
|
||||||
|
|
||||||
|
alert('Fehler beim Senden der Einladung');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Cancel invitation
|
||||||
|
async function cancelInvitation(invitationId) {
|
||||||
|
if (!confirm('Einladung wirklich zurückziehen?')) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/company/invitations/' + invitationId, {
|
||||||
|
method: 'DELETE'
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
location.reload();
|
||||||
|
} else {
|
||||||
|
alert('Fehler: ' + (data.error || 'Unbekannter Fehler'));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
alert('Fehler beim Zurückziehen der Einladung');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove employee
|
||||||
|
async function removeEmployee(userId) {
|
||||||
|
if (!confirm('Mitarbeiter wirklich entfernen? Der Zugriff wird sofort entzogen.')) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/company/employees/' + userId, {
|
||||||
|
method: 'DELETE'
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
location.reload();
|
||||||
|
} else {
|
||||||
|
alert('Fehler: ' + (data.error || 'Unbekannter Fehler'));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
alert('Fehler beim Entfernen des Mitarbeiters');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
114
src/web/templates/user/company_base.html
Normal file
114
src/web/templates/user/company_base.html
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>{% block title %}{{ session.company_name or 'Unternehmen' }} - LinkedIn Posts{% endblock %}</title>
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
<script>
|
||||||
|
tailwind.config = {
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
'brand': {
|
||||||
|
'bg': '#3d4848',
|
||||||
|
'bg-light': '#4a5858',
|
||||||
|
'bg-dark': '#2d3838',
|
||||||
|
'highlight': '#ffc700',
|
||||||
|
'highlight-dark': '#e6b300',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<style>
|
||||||
|
body { background-color: #3d4848; }
|
||||||
|
.nav-link.active { background-color: #ffc700; color: #2d3838; }
|
||||||
|
.nav-link.active svg { stroke: #2d3838; }
|
||||||
|
.btn-primary { background-color: #ffc700; color: #2d3838; }
|
||||||
|
.btn-primary:hover { background-color: #e6b300; }
|
||||||
|
.sidebar-bg { background-color: #2d3838; }
|
||||||
|
.card-bg { background-color: #4a5858; border-color: #5a6868; }
|
||||||
|
.input-bg { background-color: #3d4848; border-color: #5a6868; }
|
||||||
|
.input-bg:focus { border-color: #ffc700; outline: none; }
|
||||||
|
::-webkit-scrollbar { width: 8px; height: 8px; }
|
||||||
|
::-webkit-scrollbar-track { background: #3d4848; }
|
||||||
|
::-webkit-scrollbar-thumb { background: #5a6868; border-radius: 4px; }
|
||||||
|
::-webkit-scrollbar-thumb:hover { background: #6a7878; }
|
||||||
|
</style>
|
||||||
|
{% block head %}{% endblock %}
|
||||||
|
</head>
|
||||||
|
<body class="text-gray-100 min-h-screen flex">
|
||||||
|
<!-- Sidebar -->
|
||||||
|
<aside class="w-64 sidebar-bg border-r border-gray-600 flex flex-col fixed h-full">
|
||||||
|
<div class="p-4 border-b border-gray-600">
|
||||||
|
<div class="flex items-center justify-center gap-3">
|
||||||
|
<div>
|
||||||
|
<img src="/static/logo.png" alt="Logo" class="h-15 w-auto">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Company Profile -->
|
||||||
|
{% if session %}
|
||||||
|
<div class="p-4 border-b border-gray-600">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="w-10 h-10 rounded-lg overflow-hidden bg-brand-highlight flex items-center justify-center">
|
||||||
|
<svg class="w-6 h-6 text-brand-bg-dark" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<p class="text-white font-medium text-sm truncate">{{ session.company_name or 'Unternehmen' }}</p>
|
||||||
|
<p class="text-gray-400 text-xs truncate">{{ session.email or '' }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<nav class="flex-1 p-4 space-y-2">
|
||||||
|
<a href="/" class="nav-link flex items-center gap-3 px-4 py-3 rounded-lg text-gray-300 hover:bg-brand-bg-light transition-colors {% if page == 'home' %}active{% endif %}">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"/></svg>
|
||||||
|
Dashboard
|
||||||
|
</a>
|
||||||
|
<a href="/company/accounts" class="nav-link flex items-center gap-3 px-4 py-3 rounded-lg text-gray-300 hover:bg-brand-bg-light transition-colors {% if page == 'accounts' %}active{% endif %}">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"/></svg>
|
||||||
|
Mitarbeiter
|
||||||
|
</a>
|
||||||
|
<a href="/company/strategy" class="nav-link flex items-center gap-3 px-4 py-3 rounded-lg text-gray-300 hover:bg-brand-bg-light transition-colors {% if page == 'strategy' %}active{% endif %}">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/></svg>
|
||||||
|
Strategie
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<div class="pt-4 mt-4 border-t border-gray-600">
|
||||||
|
<p class="px-4 text-xs text-gray-500 uppercase tracking-wider mb-2">Mitarbeiter-Aktionen</p>
|
||||||
|
<a href="/company/manage" class="nav-link flex items-center gap-3 px-4 py-3 rounded-lg text-gray-300 hover:bg-brand-bg-light transition-colors {% if page == 'manage' %}active{% endif %}">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/></svg>
|
||||||
|
Inhalte verwalten
|
||||||
|
</a>
|
||||||
|
<a href="/company/calendar" class="nav-link flex items-center gap-3 px-4 py-3 rounded-lg text-gray-300 hover:bg-brand-bg-light transition-colors {% if page == 'calendar' %}active{% endif %}">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/></svg>
|
||||||
|
Posting-Kalender
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="p-4 border-t border-gray-600 space-y-2">
|
||||||
|
<a href="/logout" class="flex items-center gap-2 text-gray-400 hover:text-gray-200 text-sm transition-colors">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"/></svg>
|
||||||
|
Logout
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<!-- Main Content -->
|
||||||
|
<main class="flex-1 ml-64">
|
||||||
|
<div class="p-8">
|
||||||
|
{% block content %}{% endblock %}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{% block scripts %}{% endblock %}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
668
src/web/templates/user/company_calendar.html
Normal file
668
src/web/templates/user/company_calendar.html
Normal file
@@ -0,0 +1,668 @@
|
|||||||
|
{% extends "company_base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Posting-Kalender - {{ session.company_name }}{% endblock %}
|
||||||
|
|
||||||
|
{% block head %}
|
||||||
|
<style>
|
||||||
|
.calendar-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(7, 1fr);
|
||||||
|
gap: 1px;
|
||||||
|
background: #5a6868;
|
||||||
|
}
|
||||||
|
.calendar-cell {
|
||||||
|
background: #4a5858;
|
||||||
|
min-height: 120px;
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
.calendar-cell.other-month {
|
||||||
|
background: #3d4848;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
.calendar-cell.today {
|
||||||
|
border: 2px solid #ffc700;
|
||||||
|
}
|
||||||
|
.post-chip {
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.1s;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.post-chip:hover {
|
||||||
|
transform: scale(1.02);
|
||||||
|
}
|
||||||
|
.post-chip.scheduled {
|
||||||
|
background: #ffc700;
|
||||||
|
color: #2d3838;
|
||||||
|
}
|
||||||
|
.post-chip.published {
|
||||||
|
background: #6b7280;
|
||||||
|
color: #9ca3af;
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.post-chip.approved {
|
||||||
|
background: #3b82f6;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
.employee-color-1 { border-left: 3px solid #f472b6; }
|
||||||
|
.employee-color-2 { border-left: 3px solid #60a5fa; }
|
||||||
|
.employee-color-3 { border-left: 3px solid #34d399; }
|
||||||
|
.employee-color-4 { border-left: 3px solid #fbbf24; }
|
||||||
|
.employee-color-5 { border-left: 3px solid #a78bfa; }
|
||||||
|
.time-slot {
|
||||||
|
font-size: 10px;
|
||||||
|
color: #9ca3af;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
.view-toggle .active {
|
||||||
|
background: #ffc700;
|
||||||
|
color: #2d3838;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Timeline Week View */
|
||||||
|
.timeline-header {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 50px repeat(7, 1fr);
|
||||||
|
background: #3d4848;
|
||||||
|
}
|
||||||
|
.timeline-header-cell {
|
||||||
|
padding: 8px 4px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #9ca3af;
|
||||||
|
border-left: 1px solid #5a6868;
|
||||||
|
}
|
||||||
|
.timeline-header-cell.today {
|
||||||
|
color: #ffc700;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
.timeline-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 50px repeat(7, 1fr);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.timeline-hour-label {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #6b7280;
|
||||||
|
text-align: right;
|
||||||
|
padding-right: 8px;
|
||||||
|
height: 60px;
|
||||||
|
line-height: 1;
|
||||||
|
padding-top: 0;
|
||||||
|
transform: translateY(-6px);
|
||||||
|
}
|
||||||
|
.timeline-day-col {
|
||||||
|
position: relative;
|
||||||
|
height: 60px;
|
||||||
|
border-top: 1px solid #5a6868;
|
||||||
|
border-left: 1px solid #5a6868;
|
||||||
|
}
|
||||||
|
.timeline-day-col:hover {
|
||||||
|
background: rgba(255, 199, 0, 0.03);
|
||||||
|
}
|
||||||
|
.timeline-day-col.past-slot {
|
||||||
|
cursor: default !important;
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
|
.timeline-day-col.past-slot:hover {
|
||||||
|
background: none;
|
||||||
|
}
|
||||||
|
.calendar-cell .schedule-btn.past-slot {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
.timeline-post {
|
||||||
|
position: absolute;
|
||||||
|
left: 2px;
|
||||||
|
right: 2px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 2px 6px;
|
||||||
|
font-size: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
z-index: 5;
|
||||||
|
transition: transform 0.1s, z-index 0s;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
.timeline-post:hover {
|
||||||
|
transform: scale(1.03);
|
||||||
|
z-index: 20;
|
||||||
|
}
|
||||||
|
.timeline-post.scheduled {
|
||||||
|
background: #ffc700;
|
||||||
|
color: #2d3838;
|
||||||
|
}
|
||||||
|
.timeline-post.approved {
|
||||||
|
background: #3b82f6;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
.timeline-post.published {
|
||||||
|
background: #374151;
|
||||||
|
color: #9ca3af;
|
||||||
|
border: 1px solid #6b7280;
|
||||||
|
}
|
||||||
|
.timeline-post .timeline-post-time {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
.timeline-post .timeline-post-title {
|
||||||
|
font-size: 10px;
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="max-w-7xl mx-auto">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex items-center justify-between mb-6">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-bold text-white">Posting-Kalender</h1>
|
||||||
|
<p class="text-gray-400 mt-1">Plane und verwalte Posts aller Mitarbeiter</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<!-- View Toggle -->
|
||||||
|
<div class="view-toggle flex rounded-lg overflow-hidden border border-gray-600">
|
||||||
|
<a href="/company/calendar?month={{ month }}&year={{ year }}&view=month"
|
||||||
|
class="px-3 py-1.5 text-sm transition-colors {% if view == 'month' %}active{% else %}text-gray-300 hover:bg-brand-bg-dark{% endif %}">
|
||||||
|
Monat
|
||||||
|
</a>
|
||||||
|
<a href="/company/calendar?view=week&month={{ month }}&year={{ year }}"
|
||||||
|
class="px-3 py-1.5 text-sm transition-colors {% if view == 'week' %}active{% else %}text-gray-300 hover:bg-brand-bg-dark{% endif %}">
|
||||||
|
Woche
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Navigation -->
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
{% if view == 'week' %}
|
||||||
|
<a href="/company/calendar?view=week&week_start={{ prev_week_start }}&month={{ month }}&year={{ year }}" class="p-2 rounded-lg bg-brand-bg-dark hover:bg-brand-bg-light transition-colors">
|
||||||
|
<svg class="w-5 h-5 text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/>
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
<span class="text-white font-medium min-w-[200px] text-center">{{ week_label }}</span>
|
||||||
|
<a href="/company/calendar?view=week&week_start={{ next_week_start }}&month={{ month }}&year={{ year }}" class="p-2 rounded-lg bg-brand-bg-dark hover:bg-brand-bg-light transition-colors">
|
||||||
|
<svg class="w-5 h-5 text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
{% else %}
|
||||||
|
<a href="/company/calendar?month={{ prev_month }}&year={{ prev_year }}&view=month" class="p-2 rounded-lg bg-brand-bg-dark hover:bg-brand-bg-light transition-colors">
|
||||||
|
<svg class="w-5 h-5 text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/>
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
<span class="text-white font-medium min-w-[150px] text-center">{{ month_name }} {{ year }}</span>
|
||||||
|
<a href="/company/calendar?month={{ next_month }}&year={{ next_year }}&view=month" class="p-2 rounded-lg bg-brand-bg-dark hover:bg-brand-bg-light transition-colors">
|
||||||
|
<svg class="w-5 h-5 text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% if view == 'week' %}
|
||||||
|
<a href="/company/calendar?view=week" class="px-4 py-2 text-sm bg-brand-bg-dark hover:bg-brand-bg-light rounded-lg text-gray-300 transition-colors">
|
||||||
|
Heute
|
||||||
|
</a>
|
||||||
|
{% else %}
|
||||||
|
<a href="/company/calendar?month={{ current_month }}&year={{ current_year }}" class="px-4 py-2 text-sm bg-brand-bg-dark hover:bg-brand-bg-light rounded-lg text-gray-300 transition-colors">
|
||||||
|
Heute
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Employee Legend -->
|
||||||
|
{% if employees %}
|
||||||
|
<div class="card-bg border rounded-xl p-4 mb-6">
|
||||||
|
<p class="text-sm text-gray-400 mb-3">Mitarbeiter</p>
|
||||||
|
<div class="flex flex-wrap gap-3">
|
||||||
|
{% for emp in employees %}
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<div class="w-3 h-3 rounded employee-color-{{ loop.index % 5 + 1 }}" style="background: currentColor;"></div>
|
||||||
|
<span class="text-sm text-white">{{ emp.name }}</span>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Calendar -->
|
||||||
|
{% if view == 'week' %}
|
||||||
|
<!-- Week Timeline View -->
|
||||||
|
<div class="card-bg border rounded-xl overflow-hidden">
|
||||||
|
<!-- Day Headers -->
|
||||||
|
<div class="timeline-header">
|
||||||
|
<div class="timeline-header-cell" style="border-left: none;"></div>
|
||||||
|
{% for day in calendar_weeks[0] %}
|
||||||
|
<div class="timeline-header-cell {% if day.is_today %}today{% endif %}">
|
||||||
|
{{ day.full_date }}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Timeline Grid -->
|
||||||
|
<div class="timeline-grid" id="timelineGrid">
|
||||||
|
{% for hour in range(0, 24) %}
|
||||||
|
<!-- Hour row -->
|
||||||
|
<div class="timeline-hour-label">{{ '%02d'|format(hour) }}:00</div>
|
||||||
|
{% for day in calendar_weeks[0] %}
|
||||||
|
<div class="timeline-day-col"
|
||||||
|
data-date="{{ day.date }}" data-hour="{{ hour }}"
|
||||||
|
onclick="openScheduleModal('{{ day.date }}', '{{ '%02d'|format(hour) }}:00')"
|
||||||
|
style="cursor: pointer;">
|
||||||
|
{% for post in day.posts %}
|
||||||
|
{% set post_hour = post.time[:2]|int %}
|
||||||
|
{% if post_hour == hour %}
|
||||||
|
<div class="timeline-post {{ post.status }} employee-color-{{ post.employee_index % 5 + 1 }}"
|
||||||
|
onclick="event.stopPropagation(); openPostModal('{{ post.id }}')"
|
||||||
|
title="{{ post.employee_name }}: {{ post.topic_title }}"
|
||||||
|
data-day="{{ day.date }}"
|
||||||
|
data-abs-min="{{ post.time[:2]|int * 60 + post.time[3:]|int }}"
|
||||||
|
style="top: {% if post.status == 'published' %}{{ post.time[3:]|int - 36 }}{% else %}{{ post.time[3:]|int }}{% endif %}px;">
|
||||||
|
<div class="timeline-post-time">{{ post.time }}</div>
|
||||||
|
<div class="timeline-post-title">{{ post.topic_title[:15] }}{% if post.topic_title|length > 15 %}...{% endif %}</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<!-- Month Grid View -->
|
||||||
|
<div class="card-bg border rounded-xl overflow-hidden">
|
||||||
|
<!-- Weekday Headers -->
|
||||||
|
<div class="grid grid-cols-7 bg-brand-bg-dark">
|
||||||
|
<div class="p-3 text-center text-gray-400 text-sm font-medium">Mo</div>
|
||||||
|
<div class="p-3 text-center text-gray-400 text-sm font-medium">Di</div>
|
||||||
|
<div class="p-3 text-center text-gray-400 text-sm font-medium">Mi</div>
|
||||||
|
<div class="p-3 text-center text-gray-400 text-sm font-medium">Do</div>
|
||||||
|
<div class="p-3 text-center text-gray-400 text-sm font-medium">Fr</div>
|
||||||
|
<div class="p-3 text-center text-gray-400 text-sm font-medium">Sa</div>
|
||||||
|
<div class="p-3 text-center text-gray-400 text-sm font-medium">So</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Calendar Grid -->
|
||||||
|
<div class="calendar-grid">
|
||||||
|
{% for week in calendar_weeks %}
|
||||||
|
{% for day in week %}
|
||||||
|
<div class="calendar-cell {% if day.other_month %}other-month{% endif %} {% if day.is_today %}today{% endif %}">
|
||||||
|
<div class="text-sm {% if day.is_today %}text-brand-highlight font-bold{% else %}text-gray-400{% endif %} mb-2">
|
||||||
|
{{ day.day }}
|
||||||
|
</div>
|
||||||
|
<!-- Posts for this day -->
|
||||||
|
{% for post in day.posts %}
|
||||||
|
<div class="post-chip {{ post.status }} employee-color-{{ post.employee_index % 5 + 1 }}"
|
||||||
|
onclick="openPostModal('{{ post.id }}')"
|
||||||
|
title="{{ post.employee_name }}: {{ post.topic_title }}">
|
||||||
|
<span class="time-slot">{{ post.time }}</span>
|
||||||
|
{{ post.topic_title[:20] }}{% if post.topic_title|length > 20 %}...{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
<!-- Add post button for empty slots -->
|
||||||
|
{% if not day.other_month %}
|
||||||
|
<button onclick="openScheduleModal('{{ day.date }}')"
|
||||||
|
data-date="{{ day.date }}"
|
||||||
|
class="schedule-btn w-full mt-1 p-1 text-xs text-gray-500 hover:text-brand-highlight hover:bg-brand-bg-dark rounded transition-colors opacity-0 hover:opacity-100">
|
||||||
|
+ Post planen
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Unscheduled Posts -->
|
||||||
|
{% if unscheduled_posts %}
|
||||||
|
<div class="card-bg border rounded-xl p-6 mt-6">
|
||||||
|
<h2 class="text-lg font-semibold text-white mb-4">Nicht geplante Posts (bereit zum Einplanen)</h2>
|
||||||
|
<div class="grid md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{% for post in unscheduled_posts %}
|
||||||
|
<div class="bg-brand-bg-dark rounded-lg p-4" draggable="true" data-post-id="{{ post.id }}">
|
||||||
|
<div class="flex items-start justify-between mb-2">
|
||||||
|
<span class="text-xs px-2 py-1 rounded bg-blue-500/20 text-blue-400">{{ post.employee_name }}</span>
|
||||||
|
<span class="text-xs text-gray-500">{{ post.created_at.strftime('%d.%m.') }}</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-white text-sm font-medium mb-1">{{ post.topic_title }}</p>
|
||||||
|
<p class="text-gray-400 text-xs line-clamp-2">{{ post.post_content[:100] }}...</p>
|
||||||
|
<button onclick="openScheduleModalForPost('{{ post.id }}', '{{ post.topic_title|e }}')"
|
||||||
|
class="mt-3 w-full py-2 text-xs bg-brand-highlight text-brand-bg-dark rounded hover:bg-brand-highlight-dark transition-colors">
|
||||||
|
Einplanen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Schedule Modal -->
|
||||||
|
<div id="scheduleModal" class="fixed inset-0 bg-black/50 hidden items-center justify-center z-50">
|
||||||
|
<div class="bg-brand-bg-light rounded-xl p-6 w-full max-w-md mx-4">
|
||||||
|
<h3 class="text-lg font-semibold text-white mb-4">Post einplanen</h3>
|
||||||
|
<form id="scheduleForm" onsubmit="submitSchedule(event)">
|
||||||
|
<input type="hidden" id="schedulePostId" name="post_id">
|
||||||
|
|
||||||
|
<!-- Select Post (if no post pre-selected) -->
|
||||||
|
<div id="postSelectContainer" class="mb-4">
|
||||||
|
<label class="block text-sm text-gray-300 mb-2">Post auswahlen</label>
|
||||||
|
<select id="schedulePostSelect" name="post_id_select" class="w-full input-bg border rounded-lg px-4 py-2 text-white">
|
||||||
|
<option value="">-- Post auswahlen --</option>
|
||||||
|
{% for post in unscheduled_posts %}
|
||||||
|
<option value="{{ post.id }}">{{ post.employee_name }}: {{ post.topic_title[:40] }}...</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Date -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="block text-sm text-gray-300 mb-2">Datum</label>
|
||||||
|
<input type="date" id="scheduleDate" name="date" required
|
||||||
|
class="w-full input-bg border rounded-lg px-4 py-2 text-white">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Time -->
|
||||||
|
<div class="mb-6">
|
||||||
|
<label class="block text-sm text-gray-300 mb-2">Uhrzeit</label>
|
||||||
|
<input type="time" id="scheduleTime" name="time" value="09:00" required
|
||||||
|
class="w-full input-bg border rounded-lg px-4 py-2 text-white">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-3">
|
||||||
|
<button type="button" onclick="closeScheduleModal()" class="flex-1 py-2 bg-gray-600 text-white rounded-lg hover:bg-gray-500 transition-colors">
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
<button type="submit" class="flex-1 py-2 bg-brand-highlight text-brand-bg-dark rounded-lg hover:bg-brand-highlight-dark transition-colors">
|
||||||
|
Einplanen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Post Detail Modal -->
|
||||||
|
<div id="postModal" class="fixed inset-0 bg-black/50 hidden items-center justify-center z-50">
|
||||||
|
<div class="bg-brand-bg-light rounded-xl p-6 w-full max-w-lg mx-4">
|
||||||
|
<div class="flex items-start justify-between mb-4">
|
||||||
|
<h3 id="postModalTitle" class="text-lg font-semibold text-white">Post Details</h3>
|
||||||
|
<button onclick="closePostModal()" class="text-gray-400 hover:text-white">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div id="postModalContent" class="text-gray-300">
|
||||||
|
<!-- Filled via JS -->
|
||||||
|
</div>
|
||||||
|
<div id="postModalActions" class="mt-6 flex gap-3">
|
||||||
|
<!-- Filled via JS -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script>
|
||||||
|
function isInPast(date, time) {
|
||||||
|
const now = new Date();
|
||||||
|
const check = time ? new Date(date + 'T' + time) : new Date(date + 'T23:59:59');
|
||||||
|
return check < now;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openScheduleModal(date, time) {
|
||||||
|
if (isInPast(date, time)) return;
|
||||||
|
document.getElementById('schedulePostId').value = '';
|
||||||
|
document.getElementById('postSelectContainer').style.display = 'block';
|
||||||
|
document.getElementById('scheduleDate').value = date;
|
||||||
|
if (time) {
|
||||||
|
document.getElementById('scheduleTime').value = time;
|
||||||
|
}
|
||||||
|
document.getElementById('scheduleModal').classList.remove('hidden');
|
||||||
|
document.getElementById('scheduleModal').classList.add('flex');
|
||||||
|
}
|
||||||
|
|
||||||
|
function openScheduleModalForPost(postId, title) {
|
||||||
|
document.getElementById('schedulePostId').value = postId;
|
||||||
|
document.getElementById('postSelectContainer').style.display = 'none';
|
||||||
|
// Set default date to today
|
||||||
|
const today = new Date().toISOString().split('T')[0];
|
||||||
|
document.getElementById('scheduleDate').value = today;
|
||||||
|
document.getElementById('scheduleModal').classList.remove('hidden');
|
||||||
|
document.getElementById('scheduleModal').classList.add('flex');
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeScheduleModal() {
|
||||||
|
document.getElementById('scheduleModal').classList.add('hidden');
|
||||||
|
document.getElementById('scheduleModal').classList.remove('flex');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitSchedule(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
const postId = document.getElementById('schedulePostId').value || document.getElementById('schedulePostSelect').value;
|
||||||
|
const date = document.getElementById('scheduleDate').value;
|
||||||
|
const time = document.getElementById('scheduleTime').value;
|
||||||
|
|
||||||
|
if (!postId) {
|
||||||
|
alert('Bitte wahlen Sie einen Post aus');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isInPast(date, time)) {
|
||||||
|
alert('Der gewählte Zeitpunkt liegt in der Vergangenheit.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Convert local time to UTC for backend
|
||||||
|
const localDateTime = new Date(`${date}T${time}:00`);
|
||||||
|
const utcDateTime = localDateTime.toISOString();
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('scheduled_at', utcDateTime);
|
||||||
|
|
||||||
|
const response = await fetch(`/api/posts/${postId}/schedule`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
credentials: 'same-origin'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json();
|
||||||
|
throw new Error(error.detail || 'Fehler beim Einplanen');
|
||||||
|
}
|
||||||
|
|
||||||
|
window.location.reload();
|
||||||
|
} catch (error) {
|
||||||
|
alert('Fehler: ' + error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openPostModal(postId) {
|
||||||
|
document.getElementById('postModal').classList.remove('hidden');
|
||||||
|
document.getElementById('postModal').classList.add('flex');
|
||||||
|
document.getElementById('postModalContent').innerHTML = '<p class="text-gray-400">Laden...</p>';
|
||||||
|
document.getElementById('postModalActions').innerHTML = '';
|
||||||
|
|
||||||
|
// Fetch post details
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/posts/${postId}`, {
|
||||||
|
credentials: 'same-origin'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json().catch(() => ({}));
|
||||||
|
throw new Error(errorData.detail || `HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const post = await response.json();
|
||||||
|
|
||||||
|
document.getElementById('postModalTitle').textContent = post.topic_title || 'Post Details';
|
||||||
|
|
||||||
|
const scheduledAt = post.scheduled_at ? new Date(post.scheduled_at).toLocaleString('de-DE') : 'Nicht geplant';
|
||||||
|
const statusLabel = {
|
||||||
|
'scheduled': 'Geplant',
|
||||||
|
'published': 'Veroffentlicht',
|
||||||
|
'approved': 'Bereit',
|
||||||
|
'draft': 'Entwurf'
|
||||||
|
}[post.status] || post.status;
|
||||||
|
|
||||||
|
document.getElementById('postModalContent').innerHTML = `
|
||||||
|
<div class="mb-4">
|
||||||
|
<span class="text-xs px-2 py-1 rounded ${post.status === 'scheduled' ? 'bg-yellow-500/20 text-yellow-400' : post.status === 'published' ? 'bg-green-500/20 text-green-400' : 'bg-blue-500/20 text-blue-400'}">
|
||||||
|
${statusLabel}
|
||||||
|
</span>
|
||||||
|
${post.status === 'scheduled' ? `<span class="text-xs text-gray-400 ml-2">${scheduledAt}</span>` : ''}
|
||||||
|
</div>
|
||||||
|
<div class="bg-brand-bg-dark rounded-lg p-4 max-h-64 overflow-y-auto">
|
||||||
|
<p class="text-sm whitespace-pre-wrap">${post.post_content}</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
let actionsHtml = `
|
||||||
|
<a href="/company/manage/post/${postId}" class="flex-1 py-2 text-center bg-brand-bg-dark text-white rounded-lg hover:bg-gray-600 transition-colors">
|
||||||
|
Details ansehen
|
||||||
|
</a>
|
||||||
|
`;
|
||||||
|
|
||||||
|
if (post.status === 'scheduled') {
|
||||||
|
actionsHtml += `
|
||||||
|
<button onclick="unschedulePost('${postId}')" class="flex-1 py-2 bg-red-500/20 text-red-400 rounded-lg hover:bg-red-500/30 transition-colors">
|
||||||
|
Planung aufheben
|
||||||
|
</button>
|
||||||
|
`;
|
||||||
|
} else if (post.status === 'approved') {
|
||||||
|
actionsHtml += `
|
||||||
|
<button onclick="closePostModal(); openScheduleModalForPost('${postId}', '')" class="flex-1 py-2 bg-brand-highlight text-brand-bg-dark rounded-lg hover:bg-brand-highlight-dark transition-colors">
|
||||||
|
Einplanen
|
||||||
|
</button>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('postModalActions').innerHTML = actionsHtml;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading post:', error);
|
||||||
|
document.getElementById('postModalContent').innerHTML = `<p class="text-red-400">Fehler beim Laden: ${error.message}</p>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closePostModal() {
|
||||||
|
document.getElementById('postModal').classList.add('hidden');
|
||||||
|
document.getElementById('postModal').classList.remove('flex');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function unschedulePost(postId) {
|
||||||
|
if (!confirm('Mochten Sie die Planung dieses Posts wirklich aufheben?')) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/posts/${postId}/unschedule`, {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'same-origin'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json();
|
||||||
|
throw new Error(error.detail || 'Fehler beim Aufheben der Planung');
|
||||||
|
}
|
||||||
|
|
||||||
|
window.location.reload();
|
||||||
|
} catch (error) {
|
||||||
|
alert('Fehler: ' + error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close modals on outside click
|
||||||
|
document.getElementById('scheduleModal').addEventListener('click', function(e) {
|
||||||
|
if (e.target === this) closeScheduleModal();
|
||||||
|
});
|
||||||
|
document.getElementById('postModal').addEventListener('click', function(e) {
|
||||||
|
if (e.target === this) closePostModal();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Layout overlapping timeline posts side by side
|
||||||
|
(function() {
|
||||||
|
const grid = document.getElementById('timelineGrid');
|
||||||
|
if (!grid) return;
|
||||||
|
|
||||||
|
const POST_HEIGHT = 36;
|
||||||
|
const dayPosts = {};
|
||||||
|
|
||||||
|
grid.querySelectorAll('.timeline-post').forEach(el => {
|
||||||
|
const day = el.dataset.day;
|
||||||
|
const absMin = parseInt(el.dataset.absMin);
|
||||||
|
const isPublished = el.classList.contains('published');
|
||||||
|
const top = isPublished ? absMin - POST_HEIGHT : absMin;
|
||||||
|
if (!dayPosts[day]) dayPosts[day] = [];
|
||||||
|
dayPosts[day].push({ el, top, bottom: top + POST_HEIGHT });
|
||||||
|
});
|
||||||
|
|
||||||
|
Object.values(dayPosts).forEach(posts => {
|
||||||
|
if (posts.length <= 1) return;
|
||||||
|
posts.sort((a, b) => a.top - b.top);
|
||||||
|
|
||||||
|
// Group overlapping posts
|
||||||
|
const groups = [];
|
||||||
|
let group = [posts[0]];
|
||||||
|
|
||||||
|
for (let i = 1; i < posts.length; i++) {
|
||||||
|
const overlaps = group.some(p => posts[i].top < p.bottom && posts[i].bottom > p.top);
|
||||||
|
if (overlaps) {
|
||||||
|
group.push(posts[i]);
|
||||||
|
} else {
|
||||||
|
groups.push(group);
|
||||||
|
group = [posts[i]];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
groups.push(group);
|
||||||
|
|
||||||
|
groups.forEach(g => {
|
||||||
|
if (g.length <= 1) return;
|
||||||
|
const w = 100 / g.length;
|
||||||
|
g.forEach((p, idx) => {
|
||||||
|
p.el.style.left = (idx * w) + '%';
|
||||||
|
p.el.style.width = w + '%';
|
||||||
|
p.el.style.right = 'auto';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
|
||||||
|
// Dim past slots and hide past schedule buttons
|
||||||
|
(function() {
|
||||||
|
const now = new Date();
|
||||||
|
const todayStr = now.toISOString().split('T')[0];
|
||||||
|
const currentHour = now.getHours();
|
||||||
|
|
||||||
|
// Week view: dim past hour cells
|
||||||
|
document.querySelectorAll('.timeline-day-col').forEach(cell => {
|
||||||
|
const date = cell.dataset.date;
|
||||||
|
const hour = parseInt(cell.dataset.hour);
|
||||||
|
if (date < todayStr || (date === todayStr && hour <= currentHour)) {
|
||||||
|
cell.classList.add('past-slot');
|
||||||
|
cell.removeAttribute('onclick');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Month view: hide "+ Post planen" for past dates
|
||||||
|
document.querySelectorAll('.schedule-btn').forEach(btn => {
|
||||||
|
const date = btn.dataset.date;
|
||||||
|
if (date < todayStr) {
|
||||||
|
btn.classList.add('past-slot');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
180
src/web/templates/user/company_dashboard.html
Normal file
180
src/web/templates/user/company_dashboard.html
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
{% extends "company_base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Dashboard - {{ session.company_name }}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="max-w-4xl mx-auto">
|
||||||
|
<h1 class="text-2xl font-bold text-white mb-6">Dashboard</h1>
|
||||||
|
|
||||||
|
<!-- Stats Cards -->
|
||||||
|
<div class="grid md:grid-cols-3 gap-6 mb-8">
|
||||||
|
<!-- Employees Card -->
|
||||||
|
<div class="card-bg border rounded-xl p-6">
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<div class="w-12 h-12 bg-brand-highlight/20 rounded-lg flex items-center justify-center">
|
||||||
|
<svg class="w-6 h-6 text-brand-highlight" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{% if license_key %}
|
||||||
|
<p class="text-3xl font-bold text-white">{{ total_employees }}<span class="text-xl text-gray-500">/{{ license_key.max_employees }}</span></p>
|
||||||
|
<p class="text-gray-400 text-sm">Mitarbeiter</p>
|
||||||
|
<p class="text-xs text-gray-500 mt-1">
|
||||||
|
{% set remaining = license_key.max_employees - total_employees %}
|
||||||
|
{% if remaining > 0 %}
|
||||||
|
Noch {{ remaining }} verfügbar
|
||||||
|
{% else %}
|
||||||
|
<span class="text-yellow-400">Limit erreicht</span>
|
||||||
|
{% endif %}
|
||||||
|
</p>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-3xl font-bold text-white">{{ total_employees }}</p>
|
||||||
|
<p class="text-gray-400 text-sm">Mitarbeiter</p>
|
||||||
|
<p class="text-xs text-green-400 mt-1">Unbegrenzt</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pending Invitations Card -->
|
||||||
|
<div class="card-bg border rounded-xl p-6">
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<div class="w-12 h-12 bg-blue-500/20 rounded-lg flex items-center justify-center">
|
||||||
|
<svg class="w-6 h-6 text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-3xl font-bold text-white">{{ pending_invitations | length }}</p>
|
||||||
|
<p class="text-gray-400 text-sm">Offene Einladungen</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Strategy Status Card -->
|
||||||
|
<div class="card-bg border rounded-xl p-6">
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<div class="w-12 h-12 {% if company.company_strategy %}bg-green-500/20{% else %}bg-yellow-500/20{% endif %} rounded-lg flex items-center justify-center">
|
||||||
|
{% if company.company_strategy and company.company_strategy.mission %}
|
||||||
|
<svg class="w-6 h-6 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||||
|
</svg>
|
||||||
|
{% else %}
|
||||||
|
<svg class="w-6 h-6 text-yellow-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/>
|
||||||
|
</svg>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{% if company.company_strategy and company.company_strategy.mission %}
|
||||||
|
<p class="text-lg font-bold text-green-400">Aktiv</p>
|
||||||
|
<p class="text-gray-400 text-sm">Strategie definiert</p>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-lg font-bold text-yellow-400">Ausstehend</p>
|
||||||
|
<p class="text-gray-400 text-sm">Strategie fehlt</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Daily Quota Section -->
|
||||||
|
{% if quota and license_key %}
|
||||||
|
<div class="card-bg border rounded-xl p-6 mb-8">
|
||||||
|
<h2 class="text-lg font-semibold text-white mb-4">Tägliche Limits</h2>
|
||||||
|
|
||||||
|
<div class="grid md:grid-cols-2 gap-4">
|
||||||
|
<!-- Posts Quota -->
|
||||||
|
<div class="bg-brand-bg/30 rounded-lg p-4">
|
||||||
|
<div class="flex items-center justify-between mb-2">
|
||||||
|
<span class="text-sm text-gray-400">Posts heute</span>
|
||||||
|
<span class="text-xs text-gray-500">{{ quota.posts_created | default(0) }}/{{ license_key.max_posts_per_day }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="w-full bg-brand-bg rounded-full h-2">
|
||||||
|
{% set posts_pct = ((quota.posts_created / license_key.max_posts_per_day * 100) if license_key.max_posts_per_day > 0 else 0) | round %}
|
||||||
|
<div class="bg-brand-highlight h-2 rounded-full transition-all"
|
||||||
|
style="width: {{ posts_pct }}%"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Researches Quota -->
|
||||||
|
<div class="bg-brand-bg/30 rounded-lg p-4">
|
||||||
|
<div class="flex items-center justify-between mb-2">
|
||||||
|
<span class="text-sm text-gray-400">Researches heute</span>
|
||||||
|
<span class="text-xs text-gray-500">{{ quota.researches_created | default(0) }}/{{ license_key.max_researches_per_day }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="w-full bg-brand-bg rounded-full h-2">
|
||||||
|
{% set researches_pct = ((quota.researches_created / license_key.max_researches_per_day * 100) if license_key.max_researches_per_day > 0 else 0) | round %}
|
||||||
|
<div class="bg-blue-500 h-2 rounded-full transition-all"
|
||||||
|
style="width: {{ researches_pct }}%"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-gray-500 mt-3">
|
||||||
|
Limits werden täglich um Mitternacht zurückgesetzt. (Lizenz: {{ license_key.key }})
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{% elif quota %}
|
||||||
|
<!-- No license key - show info message -->
|
||||||
|
<div class="bg-blue-900/50 border border-blue-500 text-blue-200 px-4 py-3 rounded-lg mb-6">
|
||||||
|
<strong>Info:</strong> Dieser Account hat keinen Lizenzschlüssel und ist daher unbegrenzt.
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Quick Actions -->
|
||||||
|
<div class="card-bg border rounded-xl p-6 mb-8">
|
||||||
|
<h2 class="text-lg font-semibold text-white mb-4">Schnellzugriff</h2>
|
||||||
|
<div class="grid md:grid-cols-2 gap-4">
|
||||||
|
<a href="/company/accounts" class="flex items-center gap-3 p-4 bg-brand-bg rounded-lg hover:bg-brand-bg-light transition-colors">
|
||||||
|
<div class="w-10 h-10 bg-brand-highlight/20 rounded-lg flex items-center justify-center">
|
||||||
|
<svg class="w-5 h-5 text-brand-highlight" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18 9v3m0 0v3m0-3h3m-3 0h-3m-2-5a4 4 0 11-8 0 4 4 0 018 0zM3 20a6 6 0 0112 0v1H3v-1z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-white font-medium">Mitarbeiter einladen</p>
|
||||||
|
<p class="text-gray-400 text-sm">Neue Teammitglieder hinzufügen</p>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
<a href="/company/strategy" class="flex items-center gap-3 p-4 bg-brand-bg rounded-lg hover:bg-brand-bg-light transition-colors">
|
||||||
|
<div class="w-10 h-10 bg-brand-highlight/20 rounded-lg flex items-center justify-center">
|
||||||
|
<svg class="w-5 h-5 text-brand-highlight" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-white font-medium">Strategie bearbeiten</p>
|
||||||
|
<p class="text-gray-400 text-sm">Brand Voice und Richtlinien anpassen</p>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Recent Employees -->
|
||||||
|
{% if employees %}
|
||||||
|
<div class="card-bg border rounded-xl p-6">
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<h2 class="text-lg font-semibold text-white">Mitarbeiter</h2>
|
||||||
|
<a href="/company/accounts" class="text-brand-highlight text-sm hover:underline">Alle anzeigen</a>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-3">
|
||||||
|
{% for emp in employees[:5] %}
|
||||||
|
<div class="flex items-center gap-3 p-3 bg-brand-bg rounded-lg">
|
||||||
|
<div class="w-8 h-8 rounded-full bg-brand-highlight/20 flex items-center justify-center">
|
||||||
|
<span class="text-brand-highlight text-sm font-medium">{{ (emp.display_name or emp.email)[0] | upper }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1">
|
||||||
|
<p class="text-white text-sm">{{ emp.display_name or emp.email }}</p>
|
||||||
|
<p class="text-gray-500 text-xs">{{ emp.email }}</p>
|
||||||
|
</div>
|
||||||
|
<span class="text-xs px-2 py-1 rounded {% if emp.onboarding_status == 'completed' %}bg-green-500/20 text-green-400{% else %}bg-yellow-500/20 text-yellow-400{% endif %}">
|
||||||
|
{% if emp.onboarding_status == 'completed' %}Aktiv{% else %}Onboarding{% endif %}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
195
src/web/templates/user/company_manage.html
Normal file
195
src/web/templates/user/company_manage.html
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
{% extends "company_base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Inhalte verwalten - {{ session.company_name }}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="max-w-6xl mx-auto">
|
||||||
|
<h1 class="text-2xl font-bold text-white mb-6">Inhalte verwalten</h1>
|
||||||
|
|
||||||
|
<!-- Employee Selector -->
|
||||||
|
<div class="card-bg border rounded-xl p-6 mb-8">
|
||||||
|
<label class="block text-sm font-medium text-gray-300 mb-3">Mitarbeiter auswählen</label>
|
||||||
|
{% if active_employees %}
|
||||||
|
<div class="flex flex-wrap gap-3">
|
||||||
|
{% for emp in active_employees %}
|
||||||
|
<a href="/company/manage?employee_id={{ emp.id }}"
|
||||||
|
class="flex items-center gap-3 px-4 py-3 rounded-lg transition-colors {% if selected_employee and selected_employee.id == emp.id %}bg-brand-highlight text-brand-bg-dark{% else %}bg-brand-bg hover:bg-brand-bg-light text-white{% endif %}">
|
||||||
|
<div class="w-8 h-8 rounded-full {% if selected_employee and selected_employee.id == emp.id %}bg-brand-bg-dark/20{% else %}bg-brand-highlight/20{% endif %} flex items-center justify-center">
|
||||||
|
{% if emp.linkedin_picture %}
|
||||||
|
<img src="{{ emp.linkedin_picture }}" alt="" class="w-8 h-8 rounded-full">
|
||||||
|
{% else %}
|
||||||
|
<span class="{% if selected_employee and selected_employee.id == emp.id %}text-brand-bg-dark{% else %}text-brand-highlight{% endif %} text-sm font-medium">{{ (emp.display_name or emp.email)[0] | upper }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<span class="font-medium">{{ emp.display_name or emp.email }}</span>
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="text-center py-8">
|
||||||
|
<div class="w-16 h-16 bg-gray-600/30 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||||
|
<svg class="w-8 h-8 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<p class="text-gray-400 mb-4">Keine aktiven Mitarbeiter vorhanden</p>
|
||||||
|
<p class="text-gray-500 text-sm">Mitarbeiter erscheinen hier, sobald sie ihr Onboarding abgeschlossen haben.</p>
|
||||||
|
<a href="/company/accounts" class="inline-block mt-4 text-brand-highlight hover:underline">Mitarbeiter einladen</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if selected_employee %}
|
||||||
|
<!-- Selected Employee Info -->
|
||||||
|
<div class="card-bg border rounded-xl p-6 mb-8">
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<div class="w-16 h-16 rounded-full bg-brand-highlight/20 flex items-center justify-center overflow-hidden">
|
||||||
|
{% if selected_employee.linkedin_picture %}
|
||||||
|
<img src="{{ selected_employee.linkedin_picture }}" alt="" class="w-16 h-16 rounded-full">
|
||||||
|
{% else %}
|
||||||
|
<span class="text-brand-highlight text-2xl font-medium">{{ (selected_employee.display_name or selected_employee.email)[0] | upper }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 class="text-xl font-bold text-white">{{ selected_employee.display_name or selected_employee.email }}</h2>
|
||||||
|
<p class="text-gray-400">{{ selected_employee.email }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Stats -->
|
||||||
|
<div class="grid md:grid-cols-3 gap-6 mb-8">
|
||||||
|
<div class="card-bg border rounded-xl p-6">
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<div class="w-12 h-12 bg-brand-highlight/20 rounded-lg flex items-center justify-center">
|
||||||
|
<svg class="w-6 h-6 text-brand-highlight" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-3xl font-bold text-white">{{ employee_posts | length }}</p>
|
||||||
|
<p class="text-gray-400 text-sm">Posts gesamt</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-bg border rounded-xl p-6">
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<div class="w-12 h-12 bg-yellow-500/20 rounded-lg flex items-center justify-center">
|
||||||
|
<svg class="w-6 h-6 text-yellow-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-3xl font-bold text-white">{{ pending_posts }}</p>
|
||||||
|
<p class="text-gray-400 text-sm">Ausstehend</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-bg border rounded-xl p-6">
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<div class="w-12 h-12 bg-green-500/20 rounded-lg flex items-center justify-center">
|
||||||
|
<svg class="w-6 h-6 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-3xl font-bold text-white">{{ approved_posts }}</p>
|
||||||
|
<p class="text-gray-400 text-sm">Genehmigt</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Quick Actions -->
|
||||||
|
<div class="card-bg border rounded-xl p-6 mb-8">
|
||||||
|
<h3 class="text-lg font-semibold text-white mb-4">Aktionen</h3>
|
||||||
|
<div class="grid md:grid-cols-3 gap-4">
|
||||||
|
<a href="/company/manage/research?employee_id={{ selected_employee.id }}" class="flex items-center gap-3 p-4 bg-brand-bg rounded-lg hover:bg-brand-bg-light transition-colors">
|
||||||
|
<div class="w-10 h-10 bg-brand-highlight/20 rounded-lg flex items-center justify-center">
|
||||||
|
<svg class="w-5 h-5 text-brand-highlight" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-white font-medium">Research Topics</p>
|
||||||
|
<p class="text-gray-400 text-sm">Themen recherchieren</p>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
<a href="/company/manage/create?employee_id={{ selected_employee.id }}" class="flex items-center gap-3 p-4 bg-brand-bg rounded-lg hover:bg-brand-bg-light transition-colors">
|
||||||
|
<div class="w-10 h-10 bg-brand-highlight/20 rounded-lg flex items-center justify-center">
|
||||||
|
<svg class="w-5 h-5 text-brand-highlight" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-white font-medium">Neuer Post</p>
|
||||||
|
<p class="text-gray-400 text-sm">KI-generiert erstellen</p>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
<a href="/company/manage/posts?employee_id={{ selected_employee.id }}" class="flex items-center gap-3 p-4 bg-brand-bg rounded-lg hover:bg-brand-bg-light transition-colors">
|
||||||
|
<div class="w-10 h-10 bg-brand-highlight/20 rounded-lg flex items-center justify-center">
|
||||||
|
<svg class="w-5 h-5 text-brand-highlight" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-white font-medium">Alle Posts</p>
|
||||||
|
<p class="text-gray-400 text-sm">Posts anzeigen</p>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Recent Posts -->
|
||||||
|
{% if employee_posts %}
|
||||||
|
<div class="card-bg border rounded-xl p-6">
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<h3 class="text-lg font-semibold text-white">Letzte Posts</h3>
|
||||||
|
<a href="/company/manage/posts?employee_id={{ selected_employee.id }}" class="text-brand-highlight text-sm hover:underline">Alle anzeigen</a>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-3">
|
||||||
|
{% for post in employee_posts[:5] %}
|
||||||
|
<a href="/company/manage/post/{{ post.id }}?employee_id={{ selected_employee.id }}" class="block p-4 bg-brand-bg rounded-lg hover:bg-brand-bg-light transition-colors">
|
||||||
|
<div class="flex items-start gap-3">
|
||||||
|
<div class="flex-1">
|
||||||
|
<p class="text-white text-sm line-clamp-2">{{ post.post_content[:150] }}{% if post.post_content|length > 150 %}...{% endif %}</p>
|
||||||
|
<p class="text-gray-500 text-xs mt-2">{{ post.created_at.strftime('%d.%m.%Y %H:%M') }}</p>
|
||||||
|
</div>
|
||||||
|
<span class="text-xs px-2 py-1 rounded flex-shrink-0 {% if post.status == 'approved' %}bg-green-500/20 text-green-400{% elif post.status == 'rejected' %}bg-red-500/20 text-red-400{% else %}bg-yellow-500/20 text-yellow-400{% endif %}">
|
||||||
|
{% if post.status == 'approved' %}Genehmigt{% elif post.status == 'rejected' %}Abgelehnt{% else %}Ausstehend{% endif %}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="card-bg border rounded-xl p-6 text-center">
|
||||||
|
<div class="w-16 h-16 bg-gray-600/30 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||||
|
<svg class="w-8 h-8 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<p class="text-gray-400">Noch keine Posts vorhanden</p>
|
||||||
|
<a href="/company/manage/create?employee_id={{ selected_employee.id }}" class="inline-block mt-4 text-brand-highlight hover:underline">Ersten Post erstellen</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% else %}
|
||||||
|
<!-- No Employee Selected -->
|
||||||
|
{% if active_employees %}
|
||||||
|
<div class="card-bg border rounded-xl p-6 text-center">
|
||||||
|
<div class="w-16 h-16 bg-brand-highlight/20 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||||
|
<svg class="w-8 h-8 text-brand-highlight" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 15l-2 5L9 9l11 4-5 2zm0 0l5 5M7.188 2.239l.777 2.897M5.136 7.965l-2.898-.777M13.95 4.05l-2.122 2.122m-5.657 5.656l-2.12 2.122"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<p class="text-white text-lg font-medium mb-2">Mitarbeiter auswählen</p>
|
||||||
|
<p class="text-gray-400">Wähle oben einen Mitarbeiter aus, um dessen Inhalte zu verwalten.</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
979
src/web/templates/user/company_manage_create.html
Normal file
979
src/web/templates/user/company_manage_create.html
Normal file
@@ -0,0 +1,979 @@
|
|||||||
|
{% extends "company_base.html" %}
|
||||||
|
{% block title %}Post erstellen - {{ employee_name }} - {{ session.company_name }}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<!-- Breadcrumb -->
|
||||||
|
<div class="mb-6">
|
||||||
|
<nav class="flex items-center gap-2 text-sm">
|
||||||
|
<a href="/company/manage" class="text-gray-400 hover:text-white transition-colors">Inhalte verwalten</a>
|
||||||
|
<svg class="w-4 h-4 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/></svg>
|
||||||
|
<a href="/company/manage?employee_id={{ employee_id }}" class="text-gray-400 hover:text-white transition-colors">{{ employee_name }}</a>
|
||||||
|
<svg class="w-4 h-4 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/></svg>
|
||||||
|
<span class="text-white">Post erstellen</span>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Limit Warning -->
|
||||||
|
{% if limit_reached %}
|
||||||
|
<div class="max-w-2xl mx-auto mb-8">
|
||||||
|
<div class="bg-red-900/50 border border-red-500 text-red-200 px-6 py-4 rounded-xl">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<svg class="w-6 h-6 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/>
|
||||||
|
</svg>
|
||||||
|
<div>
|
||||||
|
<strong class="font-semibold">Limit erreicht</strong>
|
||||||
|
<p class="text-sm mt-1">{{ limit_message }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Wizard Container (hidden during generation) -->
|
||||||
|
<div id="wizardContainer" {% if limit_reached %}style="pointer-events: none; opacity: 0.5;"{% endif %}>
|
||||||
|
<div class="max-w-2xl mx-auto">
|
||||||
|
<div class="mb-8 text-center">
|
||||||
|
<h1 class="text-3xl font-bold text-white mb-2">Post erstellen</h1>
|
||||||
|
<p class="text-gray-400">Generiere einen neuen LinkedIn Post für {{ employee_name }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Wizard Steps Indicator -->
|
||||||
|
<div class="mb-8">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div id="stepIndicator1" class="w-10 h-10 rounded-full bg-brand-highlight text-brand-bg-dark flex items-center justify-center font-bold">1</div>
|
||||||
|
<span class="ml-2 text-white font-medium hidden sm:inline">Post-Typ</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 h-1 mx-4 bg-brand-bg-light rounded">
|
||||||
|
<div id="progressLine1" class="h-full bg-brand-highlight rounded transition-all duration-300" style="width: 0%"></div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div id="stepIndicator2" class="w-10 h-10 rounded-full bg-brand-bg-light text-gray-400 flex items-center justify-center font-bold">2</div>
|
||||||
|
<span class="ml-2 text-gray-400 font-medium hidden sm:inline">Thema</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 h-1 mx-4 bg-brand-bg-light rounded">
|
||||||
|
<div id="progressLine2" class="h-full bg-brand-highlight rounded transition-all duration-300" style="width: 0%"></div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div id="stepIndicator3" class="w-10 h-10 rounded-full bg-brand-bg-light text-gray-400 flex items-center justify-center font-bold">3</div>
|
||||||
|
<span class="ml-2 text-gray-400 font-medium hidden sm:inline">Gedanken</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 h-1 mx-4 bg-brand-bg-light rounded">
|
||||||
|
<div id="progressLine3" class="h-full bg-brand-highlight rounded transition-all duration-300" style="width: 0%"></div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div id="stepIndicator4" class="w-10 h-10 rounded-full bg-brand-bg-light text-gray-400 flex items-center justify-center font-bold">4</div>
|
||||||
|
<span class="ml-2 text-gray-400 font-medium hidden sm:inline">Hook</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Step 1: Post-Typ -->
|
||||||
|
<div id="step1" class="wizard-step card-bg rounded-xl border p-8">
|
||||||
|
<h2 class="text-xl font-semibold text-white mb-2">Post-Typ auswählen</h2>
|
||||||
|
<p class="text-gray-400 mb-6">Wähle einen Post-Typ, um die Topics zu filtern und den Stil anzupassen. <span class="text-gray-500">(optional)</span></p>
|
||||||
|
|
||||||
|
<div id="postTypeCards" class="flex flex-wrap gap-3 mb-8">
|
||||||
|
<p class="text-gray-500">Lade Post-Typen...</p>
|
||||||
|
</div>
|
||||||
|
<input type="hidden" id="selectedPostTypeId" value="">
|
||||||
|
|
||||||
|
<div class="flex justify-center">
|
||||||
|
<button onclick="goToStep(2)" class="px-8 py-3 rounded-lg font-medium bg-brand-bg hover:bg-brand-bg-light text-white transition-colors">
|
||||||
|
Überspringen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Step 2: Thema -->
|
||||||
|
<div id="step2" class="wizard-step card-bg rounded-xl border p-8 hidden">
|
||||||
|
<h2 class="text-xl font-semibold text-white mb-2">Thema auswählen</h2>
|
||||||
|
<p class="text-gray-400 mb-6">Wähle ein recherchiertes Topic oder gib ein eigenes ein.</p>
|
||||||
|
|
||||||
|
<div id="topicsList" class="space-y-2 max-h-72 overflow-y-auto mb-6">
|
||||||
|
<p class="text-gray-500">Lade Topics...</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="border-t border-brand-bg-light pt-6 mt-6">
|
||||||
|
<label class="block text-sm font-medium text-gray-300 mb-3">Oder eigenes Topic eingeben</label>
|
||||||
|
<div class="space-y-3">
|
||||||
|
<input type="text" id="customTopicTitle" placeholder="Topic Titel" class="w-full input-bg border rounded-lg px-4 py-3 text-white">
|
||||||
|
<textarea id="customTopicFact" rows="3" placeholder="Fakt / Kernaussage zum Topic..." class="w-full input-bg border rounded-lg px-4 py-3 text-white"></textarea>
|
||||||
|
<input type="text" id="customTopicSource" placeholder="Quelle (optional)" class="w-full input-bg border rounded-lg px-4 py-3 text-white">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-between gap-3 mt-8">
|
||||||
|
<button onclick="goToStep(1)" class="px-6 py-3 rounded-lg font-medium bg-brand-bg hover:bg-brand-bg-light text-white transition-colors">
|
||||||
|
Zurück
|
||||||
|
</button>
|
||||||
|
<button onclick="validateAndGoToStep3()" class="btn-primary px-8 py-3 rounded-lg font-medium">
|
||||||
|
Weiter
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Step 3: Gedanken -->
|
||||||
|
<div id="step3" class="wizard-step card-bg rounded-xl border p-8 hidden">
|
||||||
|
<h2 class="text-xl font-semibold text-white mb-2">Gedanken zum Thema</h2>
|
||||||
|
<p class="text-gray-400 mb-6">Was soll {{ employee_name }} zu diesem Thema sagen? Persönliche Meinung, Erfahrungen oder Einsichten.</p>
|
||||||
|
|
||||||
|
<div class="relative">
|
||||||
|
<textarea id="userThoughts" rows="8" placeholder="Schreibe die Gedanken ein...
|
||||||
|
|
||||||
|
z.B. 'Ich habe letzte Woche selbst erfahren, dass...'
|
||||||
|
oder 'Meine Meinung dazu ist...'"
|
||||||
|
class="w-full input-bg border rounded-lg px-4 py-4 text-white pr-16 text-lg"></textarea>
|
||||||
|
|
||||||
|
<!-- Speech-to-Text Button -->
|
||||||
|
<button id="speechBtn" type="button"
|
||||||
|
class="absolute right-4 top-4 p-3 rounded-lg bg-brand-bg hover:bg-brand-highlight hover:text-brand-bg-dark transition-colors group"
|
||||||
|
title="Spracheingabe starten">
|
||||||
|
<svg id="micIcon" class="w-6 h-6 text-gray-400 group-hover:text-brand-bg-dark" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v4m0 0H8m4 0h4m-4-8a3 3 0 01-3-3V5a3 3 0 116 0v6a3 3 0 01-3 3z"/>
|
||||||
|
</svg>
|
||||||
|
<svg id="micIconRecording" class="w-6 h-6 text-red-500 hidden animate-pulse" fill="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path d="M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3zm5.91-3c-.49 0-.9.36-.98.85C16.52 14.2 14.47 16 12 16s-4.52-1.8-4.93-4.15c-.08-.49-.49-.85-.98-.85-.61 0-1.09.54-1 1.14.49 3 2.89 5.35 5.91 5.78V20c0 .55.45 1 1 1s1-.45 1-1v-2.08c3.02-.43 5.42-2.78 5.91-5.78.1-.6-.39-1.14-1-1.14z"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between mt-2">
|
||||||
|
<p id="speechSupport" class="text-xs text-gray-500"></p>
|
||||||
|
<p id="speechStatus" class="text-sm text-brand-highlight hidden"></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-between gap-3 mt-8">
|
||||||
|
<button onclick="goToStep(2)" class="px-6 py-3 rounded-lg font-medium bg-brand-bg hover:bg-brand-bg-light text-white transition-colors">
|
||||||
|
Zurück
|
||||||
|
</button>
|
||||||
|
<button onclick="goToStep(4)" class="btn-primary px-8 py-3 rounded-lg font-medium">
|
||||||
|
Weiter
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Step 4: Hook-Auswahl -->
|
||||||
|
<div id="step4" class="wizard-step card-bg rounded-xl border p-8 hidden">
|
||||||
|
<h2 class="text-xl font-semibold text-white mb-2">Hook auswählen</h2>
|
||||||
|
<p class="text-gray-400 mb-6">Der Hook ist der erste Satz des Posts. Wähle einen KI-generierten oder schreibe einen eigenen.</p>
|
||||||
|
|
||||||
|
<div id="hookLoading" class="text-center py-12">
|
||||||
|
<svg class="w-10 h-10 animate-spin mx-auto text-brand-highlight" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||||
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||||
|
</svg>
|
||||||
|
<p class="text-gray-400 mt-4">Generiere Hooks...</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="hookOptions" class="space-y-3 hidden">
|
||||||
|
<!-- Hook options will be inserted here -->
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="hookError" class="text-red-400 text-center py-6 hidden"></div>
|
||||||
|
|
||||||
|
<div class="border-t border-brand-bg-light pt-6 mt-6">
|
||||||
|
<label class="block text-sm font-medium text-gray-300 mb-3">Oder eigenen Hook eingeben</label>
|
||||||
|
<textarea id="customHook" rows="2" placeholder="Eigener Hook-Text..."
|
||||||
|
class="w-full input-bg border rounded-lg px-4 py-3 text-white"></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-between gap-3 mt-8">
|
||||||
|
<button onclick="goToStep(3)" class="px-6 py-3 rounded-lg font-medium bg-brand-bg hover:bg-brand-bg-light text-white transition-colors">
|
||||||
|
Zurück
|
||||||
|
</button>
|
||||||
|
<button onclick="startPostCreation()" id="createPostBtn" class="btn-primary px-8 py-3 rounded-lg font-medium flex items-center gap-2">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/>
|
||||||
|
</svg>
|
||||||
|
Post erstellen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Generation & Result Container (shown after clicking "Post erstellen") -->
|
||||||
|
<div id="generationContainer" class="hidden">
|
||||||
|
<div class="max-w-4xl mx-auto">
|
||||||
|
<div class="mb-8 text-center">
|
||||||
|
<h1 class="text-3xl font-bold text-white mb-2">Post wird generiert</h1>
|
||||||
|
<p class="text-gray-400" id="topicTitle"></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Progress Section -->
|
||||||
|
<div id="generationProgress" class="card-bg rounded-xl border p-8 mb-8">
|
||||||
|
<div class="max-w-xl mx-auto">
|
||||||
|
<div class="flex items-center justify-between mb-3">
|
||||||
|
<span id="progressMessage" class="text-gray-300">Starte Post-Erstellung...</span>
|
||||||
|
<span id="progressPercent" class="text-gray-400 font-medium">0%</span>
|
||||||
|
</div>
|
||||||
|
<div class="w-full bg-brand-bg-dark rounded-full h-3">
|
||||||
|
<div id="progressBar" class="bg-brand-highlight h-3 rounded-full transition-all duration-300" style="width: 0%"></div>
|
||||||
|
</div>
|
||||||
|
<div id="iterationInfo" class="mt-3 text-sm text-gray-400 text-center"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Live Versions Display -->
|
||||||
|
<div id="liveVersions" class="hidden mb-8">
|
||||||
|
<div class="card-bg rounded-xl border p-8">
|
||||||
|
<div class="flex items-center justify-between mb-6">
|
||||||
|
<h3 class="text-lg font-semibold text-white">Live-Vorschau</h3>
|
||||||
|
<div id="versionTabs" class="flex gap-2"></div>
|
||||||
|
</div>
|
||||||
|
<div id="versionsContainer"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Final Result -->
|
||||||
|
<div id="finalResult" class="hidden">
|
||||||
|
<div class="card-bg rounded-xl border p-8">
|
||||||
|
<div id="resultHeader" class="flex items-center justify-between mb-6"></div>
|
||||||
|
<div id="postResult" class="bg-brand-bg/50 rounded-lg p-6 mb-6"></div>
|
||||||
|
<!-- Image Upload Area -->
|
||||||
|
<div id="resultImageArea" class="mb-6 hidden">
|
||||||
|
<div id="resultImageUploadZone" class="border-2 border-dashed border-brand-bg-light rounded-xl p-6 text-center cursor-pointer hover:border-brand-highlight transition-colors">
|
||||||
|
<input type="file" id="resultImageInput" accept="image/jpeg,image/png,image/gif,image/webp" class="hidden">
|
||||||
|
<svg class="w-8 h-8 mx-auto mb-2 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"/></svg>
|
||||||
|
<p class="text-sm text-gray-400">Bild hierher ziehen oder <span class="text-brand-highlight">durchsuchen</span></p>
|
||||||
|
<p class="text-xs text-gray-500 mt-1">JPEG, PNG, GIF, WebP - max. 5 MB</p>
|
||||||
|
</div>
|
||||||
|
<div id="resultImageProgress" class="hidden mt-2">
|
||||||
|
<div style="height:4px;background:rgba(61,72,72,0.8);border-radius:2px;overflow:hidden;">
|
||||||
|
<div id="resultImageProgressBar" style="height:100%;background:#ffc700;border-radius:2px;transition:width 0.3s;width:0%"></div>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-gray-400 mt-1 text-center">Wird hochgeladen...</p>
|
||||||
|
</div>
|
||||||
|
<div id="resultImagePreview" class="hidden mt-3">
|
||||||
|
<img id="resultImageImg" src="" alt="Post-Bild" class="rounded-lg w-full max-h-64 object-cover mb-2">
|
||||||
|
<div class="flex gap-2 justify-center">
|
||||||
|
<button onclick="document.getElementById('resultImageReplaceInput').click()" class="px-3 py-1.5 bg-brand-bg hover:bg-brand-bg-light text-gray-300 rounded-lg text-sm transition-colors">Ersetzen</button>
|
||||||
|
<button onclick="removeResultImage()" id="removeResultImageBtn" class="px-3 py-1.5 bg-red-600/20 hover:bg-red-600/30 text-red-400 rounded-lg text-sm transition-colors">Entfernen</button>
|
||||||
|
</div>
|
||||||
|
<input type="file" id="resultImageReplaceInput" accept="image/jpeg,image/png,image/gif,image/webp" class="hidden">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="resultActions" class="flex gap-3 flex-wrap justify-center"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script>
|
||||||
|
// ==================== CONFIG ====================
|
||||||
|
const USER_ID = '{{ user_id }}';
|
||||||
|
const EMPLOYEE_ID = '{{ employee_id }}';
|
||||||
|
const EMPLOYEE_NAME = '{{ employee_name }}';
|
||||||
|
|
||||||
|
// ==================== STATE ====================
|
||||||
|
let currentStep = 1;
|
||||||
|
let selectedTopic = null;
|
||||||
|
let currentPostTypes = [];
|
||||||
|
let currentTopics = [];
|
||||||
|
let selectedHook = null;
|
||||||
|
let generatedHooks = [];
|
||||||
|
let isRecording = false;
|
||||||
|
let currentVersionIndex = 0;
|
||||||
|
|
||||||
|
// ==================== ELEMENTS ====================
|
||||||
|
const steps = [1, 2, 3, 4];
|
||||||
|
const wizardContainer = document.getElementById('wizardContainer');
|
||||||
|
const generationContainer = document.getElementById('generationContainer');
|
||||||
|
const selectedPostTypeIdInput = document.getElementById('selectedPostTypeId');
|
||||||
|
const postTypeCards = document.getElementById('postTypeCards');
|
||||||
|
const topicsList = document.getElementById('topicsList');
|
||||||
|
const userThoughtsInput = document.getElementById('userThoughts');
|
||||||
|
const speechBtn = document.getElementById('speechBtn');
|
||||||
|
const micIcon = document.getElementById('micIcon');
|
||||||
|
const micIconRecording = document.getElementById('micIconRecording');
|
||||||
|
const speechStatus = document.getElementById('speechStatus');
|
||||||
|
const speechSupport = document.getElementById('speechSupport');
|
||||||
|
const hookLoading = document.getElementById('hookLoading');
|
||||||
|
const hookOptions = document.getElementById('hookOptions');
|
||||||
|
const hookError = document.getElementById('hookError');
|
||||||
|
const customHook = document.getElementById('customHook');
|
||||||
|
const generationProgress = document.getElementById('generationProgress');
|
||||||
|
const progressBar = document.getElementById('progressBar');
|
||||||
|
const progressMessage = document.getElementById('progressMessage');
|
||||||
|
const progressPercent = document.getElementById('progressPercent');
|
||||||
|
const iterationInfo = document.getElementById('iterationInfo');
|
||||||
|
const liveVersions = document.getElementById('liveVersions');
|
||||||
|
const versionTabs = document.getElementById('versionTabs');
|
||||||
|
const versionsContainer = document.getElementById('versionsContainer');
|
||||||
|
const finalResult = document.getElementById('finalResult');
|
||||||
|
const resultHeader = document.getElementById('resultHeader');
|
||||||
|
const postResult = document.getElementById('postResult');
|
||||||
|
const resultActions = document.getElementById('resultActions');
|
||||||
|
const topicTitleEl = document.getElementById('topicTitle');
|
||||||
|
|
||||||
|
// ==================== WIZARD NAVIGATION ====================
|
||||||
|
function goToStep(step) {
|
||||||
|
document.querySelectorAll('.wizard-step').forEach(el => el.classList.add('hidden'));
|
||||||
|
document.getElementById(`step${step}`).classList.remove('hidden');
|
||||||
|
|
||||||
|
steps.forEach(s => {
|
||||||
|
const indicator = document.getElementById(`stepIndicator${s}`);
|
||||||
|
const progressLine = document.getElementById(`progressLine${s}`);
|
||||||
|
|
||||||
|
if (s < step) {
|
||||||
|
indicator.className = 'w-10 h-10 rounded-full bg-brand-highlight text-brand-bg-dark flex items-center justify-center font-bold';
|
||||||
|
if (progressLine) progressLine.style.width = '100%';
|
||||||
|
} else if (s === step) {
|
||||||
|
indicator.className = 'w-10 h-10 rounded-full bg-brand-highlight text-brand-bg-dark flex items-center justify-center font-bold';
|
||||||
|
if (progressLine) progressLine.style.width = '0%';
|
||||||
|
} else {
|
||||||
|
indicator.className = 'w-10 h-10 rounded-full bg-brand-bg-light text-gray-400 flex items-center justify-center font-bold';
|
||||||
|
if (progressLine) progressLine.style.width = '0%';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
currentStep = step;
|
||||||
|
|
||||||
|
if (step === 4) loadHooks();
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateAndGoToStep3() {
|
||||||
|
const customTitle = document.getElementById('customTopicTitle').value.trim();
|
||||||
|
const customFact = document.getElementById('customTopicFact').value.trim();
|
||||||
|
|
||||||
|
if (!selectedTopic && (!customTitle || !customFact)) {
|
||||||
|
alert('Bitte wähle ein Topic aus oder gib ein eigenes ein (Titel und Fakt sind erforderlich).');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (customTitle && customFact) {
|
||||||
|
selectedTopic = {
|
||||||
|
title: customTitle,
|
||||||
|
fact: customFact,
|
||||||
|
source: document.getElementById('customTopicSource').value.trim() || null,
|
||||||
|
category: 'Custom'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
goToStep(3);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== LOAD DATA ====================
|
||||||
|
async function loadData() {
|
||||||
|
try {
|
||||||
|
const ptResponse = await fetch(`/api/post-types?user_id=${USER_ID}`);
|
||||||
|
const ptData = await ptResponse.json();
|
||||||
|
|
||||||
|
if (ptData.post_types && ptData.post_types.length > 0) {
|
||||||
|
currentPostTypes = ptData.post_types;
|
||||||
|
postTypeCards.innerHTML = `
|
||||||
|
<button type="button" onclick="selectPostType('')" id="ptc_all"
|
||||||
|
class="px-5 py-3 rounded-lg border text-sm transition-colors bg-brand-highlight/20 border-brand-highlight text-white font-medium">
|
||||||
|
Alle Typen
|
||||||
|
</button>
|
||||||
|
` + ptData.post_types.map(pt => `
|
||||||
|
<button type="button" onclick="selectPostType('${pt.id}')" id="ptc_${pt.id}"
|
||||||
|
class="px-5 py-3 rounded-lg border text-sm transition-colors bg-brand-bg border-brand-bg-light hover:border-brand-highlight/50 text-white">
|
||||||
|
${pt.name}
|
||||||
|
${pt.has_analysis ? '<span class="ml-1 text-green-400 text-xs">*</span>' : ''}
|
||||||
|
</button>
|
||||||
|
`).join('');
|
||||||
|
} else {
|
||||||
|
postTypeCards.innerHTML = '<p class="text-gray-500">Keine Post-Typen verfügbar. Du kannst diesen Schritt überspringen.</p>';
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load post types:', error);
|
||||||
|
postTypeCards.innerHTML = '<p class="text-gray-500">Keine Post-Typen verfügbar. Du kannst diesen Schritt überspringen.</p>';
|
||||||
|
}
|
||||||
|
|
||||||
|
loadTopics();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadTopics(postTypeId = null) {
|
||||||
|
topicsList.innerHTML = '<p class="text-gray-500">Lade Topics...</p>';
|
||||||
|
|
||||||
|
try {
|
||||||
|
let url = `/api/topics?user_id=${USER_ID}`;
|
||||||
|
if (postTypeId) url += `&post_type_id=${postTypeId}`;
|
||||||
|
|
||||||
|
const response = await fetch(url);
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.topics && data.topics.length > 0) {
|
||||||
|
renderTopicsList(data);
|
||||||
|
} else {
|
||||||
|
let message = '';
|
||||||
|
if (data.used_count > 0) {
|
||||||
|
message = `<div class="text-center py-6">
|
||||||
|
<p class="text-gray-400 mb-2">Alle ${data.used_count} Topics wurden bereits verwendet.</p>
|
||||||
|
<a href="/company/manage/research?employee_id=${EMPLOYEE_ID}" class="text-brand-highlight hover:underline">Neue Topics recherchieren</a>
|
||||||
|
</div>`;
|
||||||
|
} else {
|
||||||
|
message = `<div class="text-center py-6">
|
||||||
|
<p class="text-gray-400 mb-2">Keine Topics gefunden${postTypeId ? ' für diesen Post-Typ' : ''}.</p>
|
||||||
|
<a href="/company/manage/research?employee_id=${EMPLOYEE_ID}" class="text-brand-highlight hover:underline">Recherche starten</a>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
topicsList.innerHTML = message;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
topicsList.innerHTML = `<p class="text-red-400">Fehler beim Laden: ${error.message}</p>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectPostType(typeId) {
|
||||||
|
selectedPostTypeIdInput.value = typeId;
|
||||||
|
|
||||||
|
document.querySelectorAll('[id^="ptc_"]').forEach(card => {
|
||||||
|
if (card.id === `ptc_${typeId}` || (typeId === '' && card.id === 'ptc_all')) {
|
||||||
|
card.className = 'px-5 py-3 rounded-lg border text-sm transition-colors bg-brand-highlight/20 border-brand-highlight text-white font-medium';
|
||||||
|
} else {
|
||||||
|
card.className = 'px-5 py-3 rounded-lg border text-sm transition-colors bg-brand-bg border-brand-bg-light hover:border-brand-highlight/50 text-white';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
loadTopics(typeId);
|
||||||
|
goToStep(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTopicsList(data) {
|
||||||
|
currentTopics = data.topics;
|
||||||
|
selectedTopic = null;
|
||||||
|
|
||||||
|
let statsHtml = '';
|
||||||
|
if (data.used_count > 0) {
|
||||||
|
statsHtml = `<p class="text-xs text-gray-500 mb-3">${data.available_count} verfügbar · ${data.used_count} bereits verwendet</p>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
topicsList.innerHTML = statsHtml + data.topics.map((topic, i) => `
|
||||||
|
<label class="flex items-start gap-3 p-4 bg-brand-bg/50 rounded-lg cursor-pointer hover:bg-brand-bg transition-colors border border-transparent hover:border-brand-highlight/30">
|
||||||
|
<input type="radio" name="topic" value="${i}" class="mt-1 text-brand-highlight" data-topic-index="${i}">
|
||||||
|
<div class="flex-1">
|
||||||
|
<div class="flex items-center gap-2 mb-1 flex-wrap">
|
||||||
|
<span class="inline-block px-2 py-0.5 text-xs font-medium bg-brand-highlight/20 text-brand-highlight rounded">${escapeHtml(topic.category || 'Topic')}</span>
|
||||||
|
</div>
|
||||||
|
<p class="font-medium text-white">${escapeHtml(topic.title)}</p>
|
||||||
|
${topic.angle ? `<p class="text-xs text-brand-highlight/80 mt-1">→ ${escapeHtml(topic.angle)}</p>` : ''}
|
||||||
|
${topic.fact ? `<p class="text-sm text-gray-400 mt-1">${escapeHtml(topic.fact.substring(0, 120))}${topic.fact.length > 120 ? '...' : ''}</p>` : ''}
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
`).join('');
|
||||||
|
|
||||||
|
document.querySelectorAll('input[name="topic"]').forEach(radio => {
|
||||||
|
radio.addEventListener('change', () => {
|
||||||
|
const index = parseInt(radio.dataset.topicIndex, 10);
|
||||||
|
selectedTopic = currentTopics[index];
|
||||||
|
document.getElementById('customTopicTitle').value = '';
|
||||||
|
document.getElementById('customTopicFact').value = '';
|
||||||
|
document.getElementById('customTopicSource').value = '';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
['customTopicTitle', 'customTopicFact', 'customTopicSource'].forEach(id => {
|
||||||
|
document.getElementById(id).addEventListener('input', () => {
|
||||||
|
selectedTopic = null;
|
||||||
|
document.querySelectorAll('input[name="topic"]').forEach(radio => radio.checked = false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ==================== SPEECH TO TEXT ====================
|
||||||
|
let mediaRecorder = null;
|
||||||
|
let audioChunks = [];
|
||||||
|
|
||||||
|
function initSpeechRecognition() {
|
||||||
|
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
|
||||||
|
speechSupport.textContent = 'Mikrofon-Zugriff wird von diesem Browser nicht unterstützt.';
|
||||||
|
speechBtn.classList.add('opacity-50', 'cursor-not-allowed');
|
||||||
|
speechBtn.disabled = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
speechSupport.textContent = 'Klicke zum Aufnehmen, nochmal klicken zum Stoppen.';
|
||||||
|
|
||||||
|
speechBtn.onclick = async () => {
|
||||||
|
if (isRecording) {
|
||||||
|
stopRecording();
|
||||||
|
} else {
|
||||||
|
startRecording();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startRecording() {
|
||||||
|
try {
|
||||||
|
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||||
|
audioChunks = [];
|
||||||
|
|
||||||
|
const mimeType = MediaRecorder.isTypeSupported('audio/webm;codecs=opus') ? 'audio/webm;codecs=opus' : 'audio/webm';
|
||||||
|
mediaRecorder = new MediaRecorder(stream, { mimeType });
|
||||||
|
|
||||||
|
mediaRecorder.ondataavailable = (event) => {
|
||||||
|
if (event.data.size > 0) audioChunks.push(event.data);
|
||||||
|
};
|
||||||
|
|
||||||
|
mediaRecorder.onstop = async () => {
|
||||||
|
stream.getTracks().forEach(track => track.stop());
|
||||||
|
const audioBlob = new Blob(audioChunks, { type: 'audio/webm' });
|
||||||
|
speechStatus.textContent = 'Transkribiere...';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const text = await transcribeAudio(audioBlob);
|
||||||
|
if (text && text.trim()) {
|
||||||
|
if (userThoughtsInput.value && !userThoughtsInput.value.endsWith(' ')) {
|
||||||
|
userThoughtsInput.value += ' ';
|
||||||
|
}
|
||||||
|
userThoughtsInput.value += text.trim();
|
||||||
|
}
|
||||||
|
speechStatus.classList.add('hidden');
|
||||||
|
} catch (error) {
|
||||||
|
speechStatus.textContent = `Fehler: ${error.message}`;
|
||||||
|
setTimeout(() => speechStatus.classList.add('hidden'), 3000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
mediaRecorder.start();
|
||||||
|
isRecording = true;
|
||||||
|
|
||||||
|
micIcon.classList.add('hidden');
|
||||||
|
micIconRecording.classList.remove('hidden');
|
||||||
|
speechStatus.innerHTML = '<span class="inline-block w-2 h-2 bg-red-500 rounded-full animate-pulse mr-2"></span>Aufnahme läuft...';
|
||||||
|
speechStatus.classList.remove('hidden');
|
||||||
|
speechBtn.classList.add('bg-red-500/20');
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to start recording:', error);
|
||||||
|
speechStatus.textContent = 'Mikrofon-Zugriff verweigert.';
|
||||||
|
speechStatus.classList.remove('hidden');
|
||||||
|
setTimeout(() => speechStatus.classList.add('hidden'), 3000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopRecording() {
|
||||||
|
if (mediaRecorder && mediaRecorder.state === 'recording') mediaRecorder.stop();
|
||||||
|
isRecording = false;
|
||||||
|
micIcon.classList.remove('hidden');
|
||||||
|
micIconRecording.classList.add('hidden');
|
||||||
|
speechBtn.classList.remove('bg-red-500/20');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function transcribeAudio(audioBlob) {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('audio', audioBlob, 'recording.webm');
|
||||||
|
|
||||||
|
const response = await fetch('/api/transcribe', { method: 'POST', body: formData });
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json();
|
||||||
|
throw new Error(error.detail || 'Transkription fehlgeschlagen');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
return data.text;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== HOOK GENERATION ====================
|
||||||
|
async function loadHooks() {
|
||||||
|
hookLoading.classList.remove('hidden');
|
||||||
|
hookOptions.classList.add('hidden');
|
||||||
|
hookError.classList.add('hidden');
|
||||||
|
generatedHooks = [];
|
||||||
|
selectedHook = null;
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('topic_json', JSON.stringify(selectedTopic));
|
||||||
|
formData.append('user_thoughts', userThoughtsInput.value.trim());
|
||||||
|
formData.append('user_id', USER_ID);
|
||||||
|
if (selectedPostTypeIdInput.value) {
|
||||||
|
formData.append('post_type_id', selectedPostTypeIdInput.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/hooks', { method: 'POST', body: formData });
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error('Hook-Generierung fehlgeschlagen');
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
generatedHooks = data.hooks || [];
|
||||||
|
|
||||||
|
if (generatedHooks.length === 0) throw new Error('Keine Hooks generiert');
|
||||||
|
|
||||||
|
renderHookOptions();
|
||||||
|
hookLoading.classList.add('hidden');
|
||||||
|
hookOptions.classList.remove('hidden');
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Hook generation failed:', error);
|
||||||
|
hookLoading.classList.add('hidden');
|
||||||
|
hookError.textContent = `Fehler: ${error.message}. Du kannst trotzdem einen eigenen Hook eingeben.`;
|
||||||
|
hookError.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderHookOptions() {
|
||||||
|
hookOptions.innerHTML = generatedHooks.map((hook, i) => `
|
||||||
|
<label class="flex items-start gap-3 p-4 bg-brand-bg/50 rounded-lg cursor-pointer hover:bg-brand-bg transition-colors border-2 ${selectedHook === hook.hook ? 'border-brand-highlight bg-brand-highlight/10' : 'border-transparent hover:border-brand-highlight/30'}">
|
||||||
|
<input type="radio" name="hook" value="${i}" class="mt-1 text-brand-highlight" ${selectedHook === hook.hook ? 'checked' : ''}>
|
||||||
|
<div class="flex-1">
|
||||||
|
<span class="inline-block px-2 py-0.5 text-xs font-medium bg-brand-bg-light text-gray-300 rounded mb-2">${escapeHtml(hook.style)}</span>
|
||||||
|
<p class="text-white">${escapeHtml(hook.hook)}</p>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
`).join('');
|
||||||
|
|
||||||
|
document.querySelectorAll('input[name="hook"]').forEach(radio => {
|
||||||
|
radio.addEventListener('change', () => {
|
||||||
|
const index = parseInt(radio.value, 10);
|
||||||
|
selectedHook = generatedHooks[index].hook;
|
||||||
|
customHook.value = '';
|
||||||
|
renderHookOptions();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
customHook.addEventListener('input', () => {
|
||||||
|
if (customHook.value.trim()) {
|
||||||
|
selectedHook = null;
|
||||||
|
document.querySelectorAll('input[name="hook"]').forEach(radio => radio.checked = false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ==================== POST CREATION ====================
|
||||||
|
async function startPostCreation() {
|
||||||
|
const finalHook = customHook.value.trim() || selectedHook || '';
|
||||||
|
|
||||||
|
wizardContainer.classList.add('hidden');
|
||||||
|
generationContainer.classList.remove('hidden');
|
||||||
|
topicTitleEl.textContent = selectedTopic?.title || 'Benutzerdefiniertes Topic';
|
||||||
|
|
||||||
|
generationProgress.classList.remove('hidden');
|
||||||
|
liveVersions.classList.add('hidden');
|
||||||
|
finalResult.classList.add('hidden');
|
||||||
|
currentVersionIndex = 0;
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('topic_json', JSON.stringify(selectedTopic));
|
||||||
|
formData.append('user_thoughts', userThoughtsInput.value.trim());
|
||||||
|
formData.append('selected_hook', finalHook);
|
||||||
|
formData.append('user_id', USER_ID);
|
||||||
|
if (selectedPostTypeIdInput.value) {
|
||||||
|
formData.append('post_type_id', selectedPostTypeIdInput.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/posts', { method: 'POST', body: formData });
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
const taskId = data.task_id;
|
||||||
|
window.lastProgressData = null;
|
||||||
|
|
||||||
|
const pollInterval = setInterval(async () => {
|
||||||
|
const statusResponse = await fetch(`/api/tasks/${taskId}`);
|
||||||
|
const status = await statusResponse.json();
|
||||||
|
|
||||||
|
progressBar.style.width = `${status.progress}%`;
|
||||||
|
progressPercent.textContent = `${status.progress}%`;
|
||||||
|
progressMessage.textContent = status.message;
|
||||||
|
|
||||||
|
if (status.iteration !== undefined) {
|
||||||
|
iterationInfo.textContent = `Iteration ${status.iteration} von ${status.max_iterations}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status.versions && status.versions.length > 0) {
|
||||||
|
window.lastProgressData = status;
|
||||||
|
if (status.versions.length > currentVersionIndex + 1) {
|
||||||
|
currentVersionIndex = status.versions.length - 1;
|
||||||
|
}
|
||||||
|
renderVersions(status.versions, status.feedback_list || []);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status.status === 'completed') {
|
||||||
|
clearInterval(pollInterval);
|
||||||
|
showFinalResult(status.result);
|
||||||
|
} else if (status.status === 'error') {
|
||||||
|
clearInterval(pollInterval);
|
||||||
|
showError(status.message);
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
} catch (error) {
|
||||||
|
showError(error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showFinalResult(result) {
|
||||||
|
generationProgress.classList.add('hidden');
|
||||||
|
liveVersions.classList.add('hidden');
|
||||||
|
finalResult.classList.remove('hidden');
|
||||||
|
|
||||||
|
resultHeader.innerHTML = `
|
||||||
|
<div class="flex items-center gap-3 flex-wrap">
|
||||||
|
<span class="px-3 py-1.5 rounded-lg text-sm font-medium ${result.approved ? 'bg-green-600/30 text-green-300' : 'bg-yellow-600/30 text-yellow-300'}">
|
||||||
|
${result.approved ? 'Approved' : 'Review needed'}
|
||||||
|
</span>
|
||||||
|
<span class="text-gray-400">Score: <span class="text-white font-medium">${result.final_score}/100</span></span>
|
||||||
|
<span class="text-gray-400">Iterationen: <span class="text-white">${result.iterations}</span></span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
postResult.innerHTML = `<pre class="whitespace-pre-wrap text-gray-200 font-sans text-lg leading-relaxed">${result.final_post}</pre>`;
|
||||||
|
|
||||||
|
// Store post ID for image upload and show image area
|
||||||
|
window.currentResultPostId = result.post_id;
|
||||||
|
const imageArea = document.getElementById('resultImageArea');
|
||||||
|
imageArea.classList.remove('hidden');
|
||||||
|
document.getElementById('resultImageUploadZone').classList.remove('hidden');
|
||||||
|
document.getElementById('resultImagePreview').classList.add('hidden');
|
||||||
|
document.getElementById('resultImageProgress').classList.add('hidden');
|
||||||
|
initResultImageUpload();
|
||||||
|
|
||||||
|
resultActions.innerHTML = `
|
||||||
|
<button onclick="copyPost()" class="px-6 py-3 bg-brand-bg hover:bg-brand-bg-light rounded-lg text-white transition-colors flex items-center gap-2">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/></svg>
|
||||||
|
Kopieren
|
||||||
|
</button>
|
||||||
|
<a href="/company/manage/post/${result.post_id}?employee_id=${EMPLOYEE_ID}" class="px-6 py-3 bg-brand-highlight hover:bg-brand-highlight/90 rounded-lg text-brand-bg-dark font-medium transition-colors flex items-center gap-2">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/></svg>
|
||||||
|
Post öffnen
|
||||||
|
</a>
|
||||||
|
<button onclick="resetWizard()" class="px-6 py-3 bg-brand-bg hover:bg-brand-bg-light rounded-lg text-white transition-colors flex items-center gap-2">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/></svg>
|
||||||
|
Neuen Post erstellen
|
||||||
|
</button>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function showError(message) {
|
||||||
|
generationProgress.classList.add('hidden');
|
||||||
|
finalResult.classList.remove('hidden');
|
||||||
|
|
||||||
|
resultHeader.innerHTML = `<span class="text-red-400 font-medium">Fehler bei der Generierung</span>`;
|
||||||
|
postResult.innerHTML = `<p class="text-red-400">${message}</p>`;
|
||||||
|
resultActions.innerHTML = `
|
||||||
|
<button onclick="resetWizard()" class="px-6 py-3 bg-brand-bg hover:bg-brand-bg-light rounded-lg text-white transition-colors">
|
||||||
|
Zurück zum Wizard
|
||||||
|
</button>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetWizard() {
|
||||||
|
currentStep = 1;
|
||||||
|
selectedTopic = null;
|
||||||
|
selectedHook = null;
|
||||||
|
generatedHooks = [];
|
||||||
|
currentVersionIndex = 0;
|
||||||
|
|
||||||
|
document.getElementById('customTopicTitle').value = '';
|
||||||
|
document.getElementById('customTopicFact').value = '';
|
||||||
|
document.getElementById('customTopicSource').value = '';
|
||||||
|
userThoughtsInput.value = '';
|
||||||
|
customHook.value = '';
|
||||||
|
document.querySelectorAll('input[name="topic"]').forEach(radio => radio.checked = false);
|
||||||
|
document.querySelectorAll('input[name="hook"]').forEach(radio => radio.checked = false);
|
||||||
|
|
||||||
|
generationContainer.classList.add('hidden');
|
||||||
|
wizardContainer.classList.remove('hidden');
|
||||||
|
|
||||||
|
goToStep(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== VERSION DISPLAY ====================
|
||||||
|
function renderVersions(versions, feedbackList) {
|
||||||
|
if (!versions || versions.length === 0) {
|
||||||
|
liveVersions.classList.add('hidden');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
liveVersions.classList.remove('hidden');
|
||||||
|
|
||||||
|
versionTabs.innerHTML = versions.map((_, i) => `
|
||||||
|
<button onclick="showVersion(${i})" id="versionTab${i}"
|
||||||
|
class="px-4 py-2 rounded-lg text-sm font-medium transition-colors
|
||||||
|
${i === currentVersionIndex ? 'bg-brand-highlight text-brand-bg-dark' : 'bg-brand-bg text-gray-300 hover:bg-brand-bg-light'}">
|
||||||
|
V${i + 1}
|
||||||
|
${feedbackList[i] ? `<span class="ml-1 opacity-75">(${feedbackList[i].overall_score || '?'})</span>` : ''}
|
||||||
|
</button>
|
||||||
|
`).join('');
|
||||||
|
|
||||||
|
const currentVersion = versions[currentVersionIndex];
|
||||||
|
const currentFeedback = feedbackList[currentVersionIndex];
|
||||||
|
|
||||||
|
versionsContainer.innerHTML = `
|
||||||
|
<div class="grid grid-cols-1 ${currentFeedback ? 'lg:grid-cols-2' : ''} gap-6">
|
||||||
|
<div class="bg-brand-bg/50 rounded-lg p-5">
|
||||||
|
<div class="flex items-center justify-between mb-3">
|
||||||
|
<span class="text-sm font-medium text-gray-300">Version ${currentVersionIndex + 1}</span>
|
||||||
|
${currentFeedback ? `
|
||||||
|
<span class="px-2 py-0.5 text-xs rounded ${currentFeedback.approved ? 'bg-green-600/30 text-green-300' : 'bg-yellow-600/30 text-yellow-300'}">
|
||||||
|
${currentFeedback.approved ? 'Approved' : `Score: ${currentFeedback.overall_score}/100`}
|
||||||
|
</span>
|
||||||
|
` : '<span class="text-xs text-gray-500">Wird bewertet...</span>'}
|
||||||
|
</div>
|
||||||
|
<pre class="whitespace-pre-wrap text-gray-200 font-sans text-sm max-h-80 overflow-y-auto">${currentVersion}</pre>
|
||||||
|
</div>
|
||||||
|
${currentFeedback ? `
|
||||||
|
<div class="bg-brand-bg/30 rounded-lg p-5 border border-brand-bg-light">
|
||||||
|
<span class="text-sm font-medium text-gray-300 block mb-3">Kritik</span>
|
||||||
|
<p class="text-sm text-gray-400 mb-4">${currentFeedback.feedback || 'Keine Kritik'}</p>
|
||||||
|
${currentFeedback.improvements && currentFeedback.improvements.length > 0 ? `
|
||||||
|
<div>
|
||||||
|
<span class="text-xs font-medium text-gray-400">Verbesserungen:</span>
|
||||||
|
<ul class="mt-2 space-y-1">
|
||||||
|
${currentFeedback.improvements.map(imp => `
|
||||||
|
<li class="text-xs text-gray-500 flex items-start gap-2">
|
||||||
|
<span class="text-yellow-500 mt-0.5">•</span> ${imp}
|
||||||
|
</li>
|
||||||
|
`).join('')}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function showVersion(index) {
|
||||||
|
currentVersionIndex = index;
|
||||||
|
const cachedData = window.lastProgressData;
|
||||||
|
if (cachedData) renderVersions(cachedData.versions, cachedData.feedback_list);
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyPost() {
|
||||||
|
const postText = document.querySelector('#postResult pre').textContent;
|
||||||
|
navigator.clipboard.writeText(postText).then(() => {
|
||||||
|
const btn = document.querySelector('[onclick="copyPost()"]');
|
||||||
|
const originalText = btn.innerHTML;
|
||||||
|
btn.innerHTML = '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg> Kopiert!';
|
||||||
|
setTimeout(() => { btn.innerHTML = originalText; }, 2000);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== HELPERS ====================
|
||||||
|
function escapeHtml(text) {
|
||||||
|
if (!text) return '';
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = text;
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
function showToast(message, type = 'info') {
|
||||||
|
const toast = document.createElement('div');
|
||||||
|
toast.className = `fixed bottom-4 right-4 px-4 py-3 rounded-lg shadow-lg z-50 transition-all transform translate-y-0 opacity-100 ${
|
||||||
|
type === 'success' ? 'bg-green-600 text-white' :
|
||||||
|
type === 'error' ? 'bg-red-600 text-white' :
|
||||||
|
'bg-brand-bg-light text-white'
|
||||||
|
}`;
|
||||||
|
toast.textContent = message;
|
||||||
|
document.body.appendChild(toast);
|
||||||
|
setTimeout(() => {
|
||||||
|
toast.classList.add('opacity-0', 'translate-y-2');
|
||||||
|
setTimeout(() => toast.remove(), 300);
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== RESULT IMAGE UPLOAD ====================
|
||||||
|
let resultImageInitialized = false;
|
||||||
|
|
||||||
|
async function handleResultImageUpload(file) {
|
||||||
|
if (!file || !window.currentResultPostId) return;
|
||||||
|
|
||||||
|
const uploadZone = document.getElementById('resultImageUploadZone');
|
||||||
|
const progressEl = document.getElementById('resultImageProgress');
|
||||||
|
const progressBar = document.getElementById('resultImageProgressBar');
|
||||||
|
const previewEl = document.getElementById('resultImagePreview');
|
||||||
|
|
||||||
|
uploadZone.classList.add('hidden');
|
||||||
|
progressEl.classList.remove('hidden');
|
||||||
|
progressBar.style.width = '30%';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('image', file);
|
||||||
|
progressBar.style.width = '60%';
|
||||||
|
|
||||||
|
const response = await fetch(`/api/posts/${window.currentResultPostId}/image`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
|
||||||
|
progressBar.style.width = '90%';
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const err = await response.json();
|
||||||
|
throw new Error(err.detail || 'Upload fehlgeschlagen');
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
progressBar.style.width = '100%';
|
||||||
|
|
||||||
|
document.getElementById('resultImageImg').src = result.image_url;
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
progressEl.classList.add('hidden');
|
||||||
|
previewEl.classList.remove('hidden');
|
||||||
|
}, 300);
|
||||||
|
|
||||||
|
showToast('Bild erfolgreich hochgeladen!', 'success');
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Image upload error:', error);
|
||||||
|
showToast('Fehler: ' + error.message, 'error');
|
||||||
|
progressEl.classList.add('hidden');
|
||||||
|
uploadZone.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeResultImage() {
|
||||||
|
if (!window.currentResultPostId) return;
|
||||||
|
const btn = document.getElementById('removeResultImageBtn');
|
||||||
|
const originalHTML = btn.innerHTML;
|
||||||
|
btn.innerHTML = 'Wird entfernt...';
|
||||||
|
btn.disabled = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/posts/${window.currentResultPostId}/image`, { method: 'DELETE' });
|
||||||
|
if (!response.ok) throw new Error('Löschen fehlgeschlagen');
|
||||||
|
|
||||||
|
document.getElementById('resultImagePreview').classList.add('hidden');
|
||||||
|
document.getElementById('resultImageUploadZone').classList.remove('hidden');
|
||||||
|
showToast('Bild entfernt.', 'success');
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Image delete error:', error);
|
||||||
|
showToast('Fehler: ' + error.message, 'error');
|
||||||
|
} finally {
|
||||||
|
btn.innerHTML = originalHTML;
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function initResultImageUpload() {
|
||||||
|
if (resultImageInitialized) return;
|
||||||
|
resultImageInitialized = true;
|
||||||
|
|
||||||
|
const uploadZone = document.getElementById('resultImageUploadZone');
|
||||||
|
const fileInput = document.getElementById('resultImageInput');
|
||||||
|
const replaceInput = document.getElementById('resultImageReplaceInput');
|
||||||
|
|
||||||
|
if (!uploadZone) return;
|
||||||
|
|
||||||
|
uploadZone.addEventListener('click', () => fileInput.click());
|
||||||
|
fileInput.addEventListener('change', (e) => { if (e.target.files[0]) handleResultImageUpload(e.target.files[0]); });
|
||||||
|
if (replaceInput) {
|
||||||
|
replaceInput.addEventListener('change', (e) => { if (e.target.files[0]) handleResultImageUpload(e.target.files[0]); });
|
||||||
|
}
|
||||||
|
uploadZone.addEventListener('dragover', (e) => { e.preventDefault(); uploadZone.classList.add('border-brand-highlight', 'bg-brand-highlight/5'); });
|
||||||
|
uploadZone.addEventListener('dragleave', () => { uploadZone.classList.remove('border-brand-highlight', 'bg-brand-highlight/5'); });
|
||||||
|
uploadZone.addEventListener('drop', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
uploadZone.classList.remove('border-brand-highlight', 'bg-brand-highlight/5');
|
||||||
|
if (e.dataTransfer.files[0]) handleResultImageUpload(e.dataTransfer.files[0]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== INIT ====================
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
loadData();
|
||||||
|
initSpeechRecognition();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
393
src/web/templates/user/company_manage_post_detail.html
Normal file
393
src/web/templates/user/company_manage_post_detail.html
Normal file
@@ -0,0 +1,393 @@
|
|||||||
|
{% extends "company_base.html" %}
|
||||||
|
{% block title %}{{ post.topic_title }} - {{ employee_name }} - {{ session.company_name }}{% endblock %}
|
||||||
|
|
||||||
|
{% block head %}
|
||||||
|
<style>
|
||||||
|
.section-card {
|
||||||
|
background: rgba(61, 72, 72, 0.3);
|
||||||
|
border: 1px solid rgba(61, 72, 72, 0.6);
|
||||||
|
}
|
||||||
|
.linkedin-preview {
|
||||||
|
background: #ffffff;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
|
||||||
|
color: rgba(0, 0, 0, 0.9);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.linkedin-header {
|
||||||
|
padding: 12px 16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.linkedin-avatar {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: linear-gradient(135deg, #0a66c2 0%, #004182 100%);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: white;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 18px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.linkedin-user-info { flex: 1; min-width: 0; }
|
||||||
|
.linkedin-name { font-weight: 600; font-size: 14px; color: rgba(0, 0, 0, 0.9); }
|
||||||
|
.linkedin-headline { font-size: 12px; color: rgba(0, 0, 0, 0.6); margin-top: 2px; }
|
||||||
|
.linkedin-timestamp { font-size: 12px; color: rgba(0, 0, 0, 0.6); display: flex; align-items: center; gap: 4px; margin-top: 2px; }
|
||||||
|
.linkedin-content { padding: 0 16px 12px; font-size: 14px; line-height: 1.5; color: rgba(0, 0, 0, 0.9); white-space: pre-wrap; }
|
||||||
|
.linkedin-engagement { padding: 8px 16px; border-top: 1px solid rgba(0, 0, 0, 0.08); display: flex; align-items: center; gap: 4px; font-size: 12px; color: rgba(0, 0, 0, 0.6); }
|
||||||
|
.linkedin-actions { display: flex; border-top: 1px solid rgba(0, 0, 0, 0.08); }
|
||||||
|
.linkedin-action-btn { flex: 1; display: flex; align-items: center; justify-content: center; gap: 6px; padding: 12px 8px; font-size: 14px; font-weight: 600; color: rgba(0, 0, 0, 0.6); }
|
||||||
|
.linkedin-action-btn svg { width: 20px; height: 20px; }
|
||||||
|
.linkedin-post-image { width: 100%; max-height: 400px; object-fit: cover; }
|
||||||
|
.image-upload-zone { border: 2px dashed rgba(61, 72, 72, 0.8); border-radius: 0.75rem; padding: 1.5rem; text-align: center; cursor: pointer; transition: all 0.2s; }
|
||||||
|
.image-upload-zone:hover, .image-upload-zone.dragover { border-color: #ffc700; background: rgba(255, 199, 0, 0.05); }
|
||||||
|
.image-upload-zone input[type="file"] { display: none; }
|
||||||
|
.image-preview-container { position: relative; border-radius: 0.75rem; overflow: hidden; }
|
||||||
|
.image-preview-container img { width: 100%; border-radius: 0.75rem; }
|
||||||
|
.image-upload-progress { height: 4px; background: rgba(61, 72, 72, 0.8); border-radius: 2px; overflow: hidden; margin-top: 0.5rem; }
|
||||||
|
.image-upload-progress-bar { height: 100%; background: #ffc700; border-radius: 2px; transition: width 0.3s; }
|
||||||
|
.loading-spinner { border: 2px solid rgba(255, 199, 0, 0.2); border-top-color: #ffc700; border-radius: 50%; width: 20px; height: 20px; animation: spin 0.8s linear infinite; }
|
||||||
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<!-- Breadcrumb -->
|
||||||
|
<div class="mb-6">
|
||||||
|
<nav class="flex items-center gap-2 text-sm">
|
||||||
|
<a href="/company/manage" class="text-gray-400 hover:text-white transition-colors">Inhalte verwalten</a>
|
||||||
|
<svg class="w-4 h-4 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/></svg>
|
||||||
|
<a href="/company/manage?employee_id={{ employee_id }}" class="text-gray-400 hover:text-white transition-colors">{{ employee_name }}</a>
|
||||||
|
<svg class="w-4 h-4 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/></svg>
|
||||||
|
<a href="/company/manage/posts?employee_id={{ employee_id }}" class="text-gray-400 hover:text-white transition-colors">Posts</a>
|
||||||
|
<svg class="w-4 h-4 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/></svg>
|
||||||
|
<span class="text-white truncate max-w-xs">{{ post.topic_title or 'Post' }}</span>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="mb-6">
|
||||||
|
<div class="flex items-start justify-between gap-4">
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<h1 class="text-2xl font-bold text-white mb-2">{{ post.topic_title or 'Untitled Post' }}</h1>
|
||||||
|
<div class="flex items-center gap-3 text-sm text-gray-400 flex-wrap">
|
||||||
|
<span>{{ post.created_at.strftime('%d.%m.%Y um %H:%M Uhr') if post.created_at else 'N/A' }}</span>
|
||||||
|
<span class="text-gray-600">|</span>
|
||||||
|
<span>{{ post.iterations }} Iteration{{ 's' if post.iterations != 1 else '' }}</span>
|
||||||
|
<span class="text-gray-600">|</span>
|
||||||
|
<span>{{ employee_name }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-3 flex-shrink-0">
|
||||||
|
<span class="px-3 py-1.5 rounded-lg text-sm font-medium {{ 'bg-green-600/30 text-green-300 border border-green-600/50' if post.status == 'approved' else 'bg-yellow-600/30 text-yellow-300 border border-yellow-600/50' if post.status == 'draft' else 'bg-blue-600/30 text-blue-300 border border-blue-600/50' }}">
|
||||||
|
{% if post.status == 'draft' %}Vorschlag{% elif post.status == 'approved' %}Bearbeitet{% elif post.status == 'published' %}Veröffentlicht{% else %}{{ post.status | capitalize }}{% endif %}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 xl:grid-cols-3 gap-6">
|
||||||
|
<!-- Post Content -->
|
||||||
|
<div class="xl:col-span-2">
|
||||||
|
<div class="section-card rounded-xl p-6">
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<h2 class="text-lg font-semibold text-white flex items-center gap-2">
|
||||||
|
<svg class="w-5 h-5 text-brand-highlight" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
|
||||||
|
LinkedIn Post
|
||||||
|
</h2>
|
||||||
|
<button onclick="copyToClipboard()" class="px-3 py-1.5 bg-brand-bg hover:bg-brand-bg-light rounded-lg text-sm text-gray-300 transition-colors flex items-center gap-2">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/></svg>
|
||||||
|
Kopieren
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- LinkedIn Preview -->
|
||||||
|
<div class="linkedin-preview shadow-lg">
|
||||||
|
<div class="linkedin-header">
|
||||||
|
<div class="linkedin-avatar">{{ employee_name[:2] | upper if employee_name else 'UN' }}</div>
|
||||||
|
<div class="linkedin-user-info">
|
||||||
|
<div class="linkedin-name">{{ employee_name }}</div>
|
||||||
|
<div class="linkedin-headline">{{ session.company_name }}</div>
|
||||||
|
<div class="linkedin-timestamp">
|
||||||
|
<span>{{ post.created_at.strftime('%d.%m.%Y') if post.created_at else 'Jetzt' }}</span>
|
||||||
|
<span>•</span>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
||||||
|
<path d="M8 1a7 7 0 107 7 7 7 0 00-7-7zM3 8a5 5 0 011-3l.55.55A1.5 1.5 0 015 6.62v1.07a.75.75 0 00.22.53l.56.56a.75.75 0 00.53.22H7v.69a.75.75 0 00.22.53l.56.56a.75.75 0 01.22.53V13a5 5 0 01-5-5zm6.24 4.83l2-2.46a.75.75 0 00.09-.8l-.58-1.16A.76.76 0 0010 8H7v-.19a.51.51 0 01.28-.45l.38-.19a.74.74 0 00.3-1L7.4 5.19a.75.75 0 00-.67-.41H5.67a.75.75 0 01-.44-.14l-.34-.26a5 5 0 017.35 8.44z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="linkedin-content">{{ post.post_content }}</div>
|
||||||
|
{% if post.image_url %}
|
||||||
|
<img id="linkedinPostImage" src="{{ post.image_url }}" alt="Post image" class="linkedin-post-image">
|
||||||
|
{% else %}
|
||||||
|
<img id="linkedinPostImage" src="" alt="Post image" class="linkedin-post-image" style="display: none;">
|
||||||
|
{% endif %}
|
||||||
|
<div class="linkedin-engagement">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="#0a66c2">
|
||||||
|
<path d="M19.46 11l-3.91-3.91a7 7 0 01-1.69-2.74l-.49-1.47A2.76 2.76 0 0010.76 1 2.75 2.75 0 008 3.74v1.12a9.19 9.19 0 00.46 2.85L8.89 9H4.12A2.12 2.12 0 002 11.12a2.16 2.16 0 00.92 1.76A2.11 2.11 0 002 14.62a2.14 2.14 0 001.28 2 2 2 0 00-.28 1 2.12 2.12 0 002 2.12v.14A2.12 2.12 0 007.12 22h7.49a8.08 8.08 0 003.58-.84l.31-.16H21V11z"/>
|
||||||
|
</svg>
|
||||||
|
<span style="margin-left: 4px;">42</span>
|
||||||
|
<span style="margin-left: auto;">12 Kommentare • 3 Reposts</span>
|
||||||
|
</div>
|
||||||
|
<div class="linkedin-actions">
|
||||||
|
<button class="linkedin-action-btn">
|
||||||
|
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M19.46 11l-3.91-3.91a7 7 0 01-1.69-2.74l-.49-1.47A2.76 2.76 0 0010.76 1 2.75 2.75 0 008 3.74v1.12a9.19 9.19 0 00.46 2.85L8.89 9H4.12A2.12 2.12 0 002 11.12a2.16 2.16 0 00.92 1.76A2.11 2.11 0 002 14.62a2.14 2.14 0 001.28 2 2 2 0 00-.28 1 2.12 2.12 0 002 2.12v.14A2.12 2.12 0 007.12 22h7.49a8.08 8.08 0 003.58-.84l.31-.16H21V11z"/></svg>
|
||||||
|
Gefällt mir
|
||||||
|
</button>
|
||||||
|
<button class="linkedin-action-btn">
|
||||||
|
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M7 9h10v1H7zm0 4h7v-1H7zm16-2a6.78 6.78 0 01-2.84 5.61L12 22v-4H8A7 7 0 018 4h8a7 7 0 017 7z"/></svg>
|
||||||
|
Kommentieren
|
||||||
|
</button>
|
||||||
|
<button class="linkedin-action-btn">
|
||||||
|
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M13.96 5H6c-.55 0-1 .45-1 1v11H3V6c0-1.66 1.34-3 3-3h7.96L12 0l1.96 5zM17 7h-7c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h7c1.1 0 2-.9 2-2V9c0-1.1-.9-2-2-2z"/></svg>
|
||||||
|
Reposten
|
||||||
|
</button>
|
||||||
|
<button class="linkedin-action-btn">
|
||||||
|
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M21 3L0 10l7.66 4.26L16 8l-6.26 8.34L14 24l7-21z"/></svg>
|
||||||
|
Senden
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sidebar -->
|
||||||
|
<div class="space-y-6">
|
||||||
|
<!-- Actions -->
|
||||||
|
<div class="section-card rounded-xl p-6">
|
||||||
|
<h3 class="font-semibold text-white mb-4">Aktionen</h3>
|
||||||
|
<div class="space-y-3">
|
||||||
|
<button onclick="updateStatus('approved')" class="w-full px-4 py-3 bg-blue-600/20 hover:bg-blue-600/30 text-blue-300 rounded-lg transition-colors flex items-center justify-center gap-2 {% if post.status == 'approved' %}ring-2 ring-blue-500{% endif %}">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>
|
||||||
|
Als bearbeitet markieren
|
||||||
|
</button>
|
||||||
|
<button onclick="updateStatus('published')" class="w-full px-4 py-3 bg-green-600/20 hover:bg-green-600/30 text-green-300 rounded-lg transition-colors flex items-center justify-center gap-2 {% if post.status == 'published' %}ring-2 ring-green-500{% endif %}">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
|
||||||
|
Als veröffentlicht markieren
|
||||||
|
</button>
|
||||||
|
<button onclick="updateStatus('draft')" class="w-full px-4 py-3 bg-yellow-600/20 hover:bg-yellow-600/30 text-yellow-300 rounded-lg transition-colors flex items-center justify-center gap-2 {% if post.status == 'draft' %}ring-2 ring-yellow-500{% endif %}">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"/></svg>
|
||||||
|
Zurück zu Vorschlag
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Image Upload -->
|
||||||
|
<div class="section-card rounded-xl p-6">
|
||||||
|
<h3 class="font-semibold text-white mb-4 flex items-center gap-2">
|
||||||
|
<svg class="w-5 h-5 text-brand-highlight" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"/></svg>
|
||||||
|
Bild
|
||||||
|
</h3>
|
||||||
|
<div id="imageUploadZone" class="image-upload-zone {% if post.image_url %}hidden{% endif %}">
|
||||||
|
<input type="file" id="imageFileInput" accept="image/jpeg,image/png,image/gif,image/webp">
|
||||||
|
<svg class="w-8 h-8 mx-auto mb-2 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"/></svg>
|
||||||
|
<p class="text-sm text-gray-400">Bild hierher ziehen oder <span class="text-brand-highlight cursor-pointer">durchsuchen</span></p>
|
||||||
|
<p class="text-xs text-gray-500 mt-1">JPEG, PNG, GIF, WebP - max. 5 MB</p>
|
||||||
|
</div>
|
||||||
|
<div id="imageUploadProgress" class="hidden">
|
||||||
|
<div class="image-upload-progress">
|
||||||
|
<div id="imageProgressBar" class="image-upload-progress-bar" style="width: 0%"></div>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-gray-400 mt-1 text-center">Wird hochgeladen...</p>
|
||||||
|
</div>
|
||||||
|
<div id="imagePreviewSection" class="{% if not post.image_url %}hidden{% endif %}">
|
||||||
|
<div class="image-preview-container mb-3">
|
||||||
|
<img id="sidebarImagePreview" src="{{ post.image_url or '' }}" alt="Post-Bild">
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button onclick="document.getElementById('imageReplaceInput').click()" class="flex-1 px-3 py-2 bg-brand-bg hover:bg-brand-bg-light text-gray-300 rounded-lg transition-colors text-sm flex items-center justify-center gap-2">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/></svg>
|
||||||
|
Ersetzen
|
||||||
|
</button>
|
||||||
|
<button onclick="removeImage()" id="removeImageBtn" class="flex-1 px-3 py-2 bg-red-600/20 hover:bg-red-600/30 text-red-400 rounded-lg transition-colors text-sm flex items-center justify-center gap-2">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
|
||||||
|
Entfernen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<input type="file" id="imageReplaceInput" accept="image/jpeg,image/png,image/gif,image/webp" class="hidden">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Post Info -->
|
||||||
|
<div class="section-card rounded-xl p-6">
|
||||||
|
<h3 class="font-semibold text-white mb-4">Details</h3>
|
||||||
|
<div class="space-y-3 text-sm">
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<span class="text-gray-400">Erstellt</span>
|
||||||
|
<span class="text-white">{{ post.created_at.strftime('%d.%m.%Y') if post.created_at else 'N/A' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<span class="text-gray-400">Iterationen</span>
|
||||||
|
<span class="text-white">{{ post.iterations }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<span class="text-gray-400">Zeichen</span>
|
||||||
|
<span class="text-white">{{ post.post_content | length }}</span>
|
||||||
|
</div>
|
||||||
|
{% if post.topic_title %}
|
||||||
|
<div class="pt-3 border-t border-brand-bg-light">
|
||||||
|
<span class="text-gray-400 block mb-1">Topic</span>
|
||||||
|
<span class="text-white">{{ post.topic_title }}</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script>
|
||||||
|
const POST_ID = '{{ post.id }}';
|
||||||
|
const EMPLOYEE_ID = '{{ employee_id }}';
|
||||||
|
|
||||||
|
function copyToClipboard() {
|
||||||
|
const content = document.querySelector('.linkedin-content').textContent;
|
||||||
|
navigator.clipboard.writeText(content).then(() => {
|
||||||
|
const btn = document.querySelector('[onclick="copyToClipboard()"]');
|
||||||
|
const original = btn.innerHTML;
|
||||||
|
btn.innerHTML = '<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg> Kopiert!';
|
||||||
|
setTimeout(() => { btn.innerHTML = original; }, 2000);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateStatus(newStatus) {
|
||||||
|
try {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('status', newStatus);
|
||||||
|
|
||||||
|
const response = await fetch(`/api/posts/${POST_ID}/status`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
location.reload();
|
||||||
|
} else {
|
||||||
|
alert('Fehler beim Aktualisieren des Status');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error:', error);
|
||||||
|
alert('Fehler beim Aktualisieren des Status');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== IMAGE UPLOAD ====================
|
||||||
|
|
||||||
|
function showToast(message, type = 'info') {
|
||||||
|
const toast = document.createElement('div');
|
||||||
|
toast.className = `fixed bottom-4 right-4 px-4 py-3 rounded-lg shadow-lg z-50 transition-all transform translate-y-0 opacity-100 ${
|
||||||
|
type === 'success' ? 'bg-green-600 text-white' :
|
||||||
|
type === 'error' ? 'bg-red-600 text-white' :
|
||||||
|
'bg-brand-bg-light text-white'
|
||||||
|
}`;
|
||||||
|
toast.textContent = message;
|
||||||
|
document.body.appendChild(toast);
|
||||||
|
setTimeout(() => {
|
||||||
|
toast.classList.add('opacity-0', 'translate-y-2');
|
||||||
|
setTimeout(() => toast.remove(), 300);
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleImageUpload(file) {
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
const uploadZone = document.getElementById('imageUploadZone');
|
||||||
|
const progressEl = document.getElementById('imageUploadProgress');
|
||||||
|
const progressBar = document.getElementById('imageProgressBar');
|
||||||
|
const previewSection = document.getElementById('imagePreviewSection');
|
||||||
|
|
||||||
|
uploadZone.classList.add('hidden');
|
||||||
|
progressEl.classList.remove('hidden');
|
||||||
|
progressBar.style.width = '30%';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('image', file);
|
||||||
|
progressBar.style.width = '60%';
|
||||||
|
|
||||||
|
const response = await fetch(`/api/posts/${POST_ID}/image`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
|
||||||
|
progressBar.style.width = '90%';
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const err = await response.json();
|
||||||
|
throw new Error(err.detail || 'Upload fehlgeschlagen');
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
progressBar.style.width = '100%';
|
||||||
|
|
||||||
|
document.getElementById('linkedinPostImage').src = result.image_url;
|
||||||
|
document.getElementById('linkedinPostImage').style.display = 'block';
|
||||||
|
document.getElementById('sidebarImagePreview').src = result.image_url;
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
progressEl.classList.add('hidden');
|
||||||
|
previewSection.classList.remove('hidden');
|
||||||
|
}, 300);
|
||||||
|
|
||||||
|
showToast('Bild erfolgreich hochgeladen!', 'success');
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Image upload error:', error);
|
||||||
|
showToast('Fehler: ' + error.message, 'error');
|
||||||
|
progressEl.classList.add('hidden');
|
||||||
|
uploadZone.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeImage() {
|
||||||
|
const btn = document.getElementById('removeImageBtn');
|
||||||
|
const originalHTML = btn.innerHTML;
|
||||||
|
btn.innerHTML = '<div class="loading-spinner" style="width:16px;height:16px;"></div>';
|
||||||
|
btn.disabled = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/posts/${POST_ID}/image`, { method: 'DELETE' });
|
||||||
|
if (!response.ok) throw new Error('Löschen fehlgeschlagen');
|
||||||
|
|
||||||
|
document.getElementById('linkedinPostImage').style.display = 'none';
|
||||||
|
document.getElementById('imagePreviewSection').classList.add('hidden');
|
||||||
|
document.getElementById('imageUploadZone').classList.remove('hidden');
|
||||||
|
showToast('Bild entfernt.', 'success');
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Image delete error:', error);
|
||||||
|
showToast('Fehler: ' + error.message, 'error');
|
||||||
|
} finally {
|
||||||
|
btn.innerHTML = originalHTML;
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
const uploadZone = document.getElementById('imageUploadZone');
|
||||||
|
const fileInput = document.getElementById('imageFileInput');
|
||||||
|
const replaceInput = document.getElementById('imageReplaceInput');
|
||||||
|
|
||||||
|
if (uploadZone) {
|
||||||
|
uploadZone.addEventListener('click', () => fileInput.click());
|
||||||
|
fileInput.addEventListener('change', (e) => { if (e.target.files[0]) handleImageUpload(e.target.files[0]); });
|
||||||
|
if (replaceInput) {
|
||||||
|
replaceInput.addEventListener('change', (e) => { if (e.target.files[0]) handleImageUpload(e.target.files[0]); });
|
||||||
|
}
|
||||||
|
uploadZone.addEventListener('dragover', (e) => { e.preventDefault(); uploadZone.classList.add('dragover'); });
|
||||||
|
uploadZone.addEventListener('dragleave', () => { uploadZone.classList.remove('dragover'); });
|
||||||
|
uploadZone.addEventListener('drop', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
uploadZone.classList.remove('dragover');
|
||||||
|
if (e.dataTransfer.files[0]) handleImageUpload(e.dataTransfer.files[0]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
286
src/web/templates/user/company_manage_posts.html
Normal file
286
src/web/templates/user/company_manage_posts.html
Normal file
@@ -0,0 +1,286 @@
|
|||||||
|
{% extends "company_base.html" %}
|
||||||
|
{% block title %}Posts - {{ employee_name }} - {{ session.company_name }}{% endblock %}
|
||||||
|
|
||||||
|
{% macro render_post_card(post) %}
|
||||||
|
<div class="post-card"
|
||||||
|
draggable="true"
|
||||||
|
data-post-id="{{ post.id }}"
|
||||||
|
ondragstart="handleDragStart(event)"
|
||||||
|
ondragend="handleDragEnd(event)"
|
||||||
|
onclick="window.location.href='/company/manage/post/{{ post.id }}?employee_id={{ employee_id }}'">
|
||||||
|
<div class="flex items-start justify-between gap-2 mb-2">
|
||||||
|
<h4 class="post-card-title">{{ post.topic_title or 'Untitled' }}</h4>
|
||||||
|
{% if post.critic_feedback and post.critic_feedback | length > 0 %}
|
||||||
|
{% set score = post.critic_feedback[-1].overall_score %}
|
||||||
|
<span class="score-badge flex-shrink-0 {{ 'score-high' if score >= 85 else 'score-medium' if score >= 70 else 'score-low' }}">
|
||||||
|
{{ score }}
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="post-card-meta">
|
||||||
|
<span class="flex items-center gap-1">
|
||||||
|
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/></svg>
|
||||||
|
{{ post.created_at.strftime('%d.%m.%Y') if post.created_at else 'N/A' }}
|
||||||
|
</span>
|
||||||
|
<span class="flex items-center gap-1">
|
||||||
|
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/></svg>
|
||||||
|
{{ post.iterations }}x
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{% if post.post_content %}
|
||||||
|
<p class="post-card-preview">{{ post.post_content[:150] }}{% if post.post_content | length > 150 %}...{% endif %}</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endmacro %}
|
||||||
|
|
||||||
|
{% block head %}
|
||||||
|
<style>
|
||||||
|
.kanban-board {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 1.5rem;
|
||||||
|
min-height: calc(100vh - 300px);
|
||||||
|
}
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
.kanban-board { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
|
.kanban-column {
|
||||||
|
background: rgba(45, 56, 56, 0.3);
|
||||||
|
border: 1px solid rgba(61, 72, 72, 0.6);
|
||||||
|
border-radius: 1rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 400px;
|
||||||
|
}
|
||||||
|
.kanban-header {
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
border-bottom: 1px solid rgba(61, 72, 72, 0.6);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
.kanban-header h3 { font-weight: 600; display: flex; align-items: center; gap: 0.5rem; }
|
||||||
|
.kanban-count { background: rgba(61, 72, 72, 0.8); padding: 0.125rem 0.5rem; border-radius: 9999px; font-size: 0.75rem; font-weight: 500; }
|
||||||
|
.kanban-body { flex: 1; padding: 1rem; overflow-y: auto; min-height: 100px; }
|
||||||
|
.kanban-body.drag-over { background: rgba(255, 199, 0, 0.05); border: 2px dashed rgba(255, 199, 0, 0.3); border-radius: 0.5rem; margin: 0.5rem; }
|
||||||
|
.post-card {
|
||||||
|
background: linear-gradient(135deg, rgba(61, 72, 72, 0.5) 0%, rgba(45, 56, 56, 0.6) 100%);
|
||||||
|
border: 1px solid rgba(61, 72, 72, 0.8);
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
padding: 1rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
cursor: grab;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
.post-card:hover { border-color: rgba(255, 199, 0, 0.4); transform: translateY(-2px); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2); }
|
||||||
|
.post-card.dragging { opacity: 0.5; cursor: grabbing; }
|
||||||
|
.post-card-title { font-weight: 500; color: white; margin-bottom: 0.5rem; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
|
||||||
|
.post-card-meta { display: flex; align-items: center; gap: 0.75rem; font-size: 0.75rem; color: #9ca3af; }
|
||||||
|
.post-card-preview { font-size: 0.8rem; color: #9ca3af; margin-top: 0.75rem; padding-top: 0.75rem; border-top: 1px solid rgba(61, 72, 72, 0.6); display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; line-height: 1.4; }
|
||||||
|
.score-badge { display: inline-flex; align-items: center; gap: 0.25rem; padding: 0.125rem 0.5rem; border-radius: 9999px; font-size: 0.7rem; font-weight: 600; }
|
||||||
|
.score-high { background: rgba(34, 197, 94, 0.2); color: #86efac; }
|
||||||
|
.score-medium { background: rgba(234, 179, 8, 0.2); color: #fde047; }
|
||||||
|
.score-low { background: rgba(239, 68, 68, 0.2); color: #fca5a5; }
|
||||||
|
.column-draft .kanban-header { border-left: 3px solid #f59e0b; }
|
||||||
|
.column-approved .kanban-header { border-left: 3px solid #3b82f6; }
|
||||||
|
.column-ready .kanban-header { border-left: 3px solid #22c55e; }
|
||||||
|
.empty-column { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 2rem; color: #6b7280; text-align: center; }
|
||||||
|
.empty-column svg { width: 3rem; height: 3rem; margin-bottom: 0.75rem; opacity: 0.5; }
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<!-- Breadcrumb -->
|
||||||
|
<div class="mb-6">
|
||||||
|
<nav class="flex items-center gap-2 text-sm">
|
||||||
|
<a href="/company/manage" class="text-gray-400 hover:text-white transition-colors">Inhalte verwalten</a>
|
||||||
|
<svg class="w-4 h-4 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/></svg>
|
||||||
|
<a href="/company/manage?employee_id={{ employee_id }}" class="text-gray-400 hover:text-white transition-colors">{{ employee_name }}</a>
|
||||||
|
<svg class="w-4 h-4 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/></svg>
|
||||||
|
<span class="text-white">Posts</span>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-6 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-bold text-white mb-1">Posts von {{ employee_name }}</h1>
|
||||||
|
<p class="text-gray-400 text-sm">Ziehe Posts zwischen den Spalten um den Status zu ändern</p>
|
||||||
|
</div>
|
||||||
|
<a href="/company/manage/create?employee_id={{ employee_id }}" class="px-4 py-2.5 btn-primary rounded-lg font-medium flex items-center gap-2">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"/></svg>
|
||||||
|
Neuer Post
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="kanban-board">
|
||||||
|
<!-- Column: Vorschlag (draft) -->
|
||||||
|
<div class="kanban-column column-draft">
|
||||||
|
<div class="kanban-header">
|
||||||
|
<h3 class="text-yellow-400">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"/></svg>
|
||||||
|
Vorschlag
|
||||||
|
</h3>
|
||||||
|
<span class="kanban-count" id="count-draft">{{ posts | selectattr('status', 'equalto', 'draft') | list | length }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="kanban-body" data-status="draft" ondragover="handleDragOver(event)" ondrop="handleDrop(event)" ondragleave="handleDragLeave(event)">
|
||||||
|
{% for post in posts if post.status == 'draft' %}
|
||||||
|
{{ render_post_card(post) }}
|
||||||
|
{% else %}
|
||||||
|
<div class="empty-column" id="empty-draft">
|
||||||
|
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"/></svg>
|
||||||
|
<p>Keine Vorschläge</p>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Column: Bearbeitet (approved) - waiting for customer approval -->
|
||||||
|
<div class="kanban-column column-approved">
|
||||||
|
<div class="kanban-header">
|
||||||
|
<h3 class="text-blue-400">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>
|
||||||
|
Bearbeitet
|
||||||
|
</h3>
|
||||||
|
<span class="kanban-count" id="count-approved">{{ posts | selectattr('status', 'equalto', 'approved') | list | length }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="kanban-body" data-status="approved" ondragover="handleDragOver(event)" ondrop="handleDrop(event)" ondragleave="handleDragLeave(event)">
|
||||||
|
{% for post in posts if post.status == 'approved' %}
|
||||||
|
{{ render_post_card(post) }}
|
||||||
|
{% else %}
|
||||||
|
<div class="empty-column" id="empty-approved">
|
||||||
|
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>
|
||||||
|
<p>Keine bearbeiteten Posts</p>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Column: Freigegeben (ready) - approved by customer, ready for calendar scheduling -->
|
||||||
|
<div class="kanban-column column-ready">
|
||||||
|
<div class="kanban-header">
|
||||||
|
<h3 class="text-green-400">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
|
||||||
|
Freigegeben
|
||||||
|
</h3>
|
||||||
|
<span class="kanban-count" id="count-ready">{{ posts | selectattr('status', 'equalto', 'ready') | list | length }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="kanban-body" data-status="ready" ondragover="handleDragOver(event)" ondrop="handleDrop(event)" ondragleave="handleDragLeave(event)">
|
||||||
|
{% for post in posts if post.status == 'ready' %}
|
||||||
|
{{ render_post_card(post) }}
|
||||||
|
{% else %}
|
||||||
|
<div class="empty-column" id="empty-ready">
|
||||||
|
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
|
||||||
|
<p>Keine freigegebenen Posts</p>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if not posts %}
|
||||||
|
<div class="card-bg rounded-xl border p-12 text-center mt-6">
|
||||||
|
<div class="w-20 h-20 bg-brand-bg rounded-2xl flex items-center justify-center mx-auto mb-6">
|
||||||
|
<svg class="w-10 h-10 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/></svg>
|
||||||
|
</div>
|
||||||
|
<h3 class="text-xl font-semibold text-white mb-2">Noch keine Posts</h3>
|
||||||
|
<p class="text-gray-400 mb-6 max-w-md mx-auto">Erstelle den ersten LinkedIn Post für {{ employee_name }}.</p>
|
||||||
|
<a href="/company/manage/create?employee_id={{ employee_id }}" class="inline-flex items-center gap-2 px-6 py-3 btn-primary font-medium rounded-lg transition-colors">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"/></svg>
|
||||||
|
Post erstellen
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script>
|
||||||
|
let draggedElement = null;
|
||||||
|
let sourceStatus = null;
|
||||||
|
|
||||||
|
function handleDragStart(e) {
|
||||||
|
draggedElement = e.target;
|
||||||
|
sourceStatus = e.target.closest('.kanban-body').dataset.status;
|
||||||
|
e.target.classList.add('dragging');
|
||||||
|
e.dataTransfer.effectAllowed = 'move';
|
||||||
|
e.dataTransfer.setData('text/plain', e.target.dataset.postId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDragEnd(e) {
|
||||||
|
e.target.classList.remove('dragging');
|
||||||
|
document.querySelectorAll('.kanban-body').forEach(body => body.classList.remove('drag-over'));
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDragOver(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.dataTransfer.dropEffect = 'move';
|
||||||
|
const kanbanBody = e.target.closest('.kanban-body');
|
||||||
|
if (kanbanBody) kanbanBody.classList.add('drag-over');
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDragLeave(e) {
|
||||||
|
const kanbanBody = e.target.closest('.kanban-body');
|
||||||
|
if (kanbanBody && !kanbanBody.contains(e.relatedTarget)) kanbanBody.classList.remove('drag-over');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDrop(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
const kanbanBody = e.target.closest('.kanban-body');
|
||||||
|
if (!kanbanBody || !draggedElement) return;
|
||||||
|
|
||||||
|
kanbanBody.classList.remove('drag-over');
|
||||||
|
const newStatus = kanbanBody.dataset.status;
|
||||||
|
const postId = draggedElement.dataset.postId;
|
||||||
|
|
||||||
|
if (sourceStatus === newStatus) return;
|
||||||
|
|
||||||
|
const emptyPlaceholder = kanbanBody.querySelector('.empty-column');
|
||||||
|
if (emptyPlaceholder) emptyPlaceholder.remove();
|
||||||
|
|
||||||
|
kanbanBody.appendChild(draggedElement);
|
||||||
|
|
||||||
|
const sourceBody = document.querySelector(`.kanban-body[data-status="${sourceStatus}"]`);
|
||||||
|
if (sourceBody && sourceBody.querySelectorAll('.post-card').length === 0) {
|
||||||
|
addEmptyPlaceholder(sourceBody, sourceStatus);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateCounts();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('status', newStatus);
|
||||||
|
|
||||||
|
const response = await fetch(`/api/posts/${postId}/status`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error('Failed to update status');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating status:', error);
|
||||||
|
location.reload();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addEmptyPlaceholder(container, status) {
|
||||||
|
const icons = {
|
||||||
|
'draft': '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"/>',
|
||||||
|
'approved': '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/>',
|
||||||
|
'ready': '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>'
|
||||||
|
};
|
||||||
|
const labels = { 'draft': 'Keine Vorschläge', 'approved': 'Keine bearbeiteten Posts', 'ready': 'Keine freigegebenen Posts' };
|
||||||
|
|
||||||
|
const placeholder = document.createElement('div');
|
||||||
|
placeholder.className = 'empty-column';
|
||||||
|
placeholder.id = `empty-${status}`;
|
||||||
|
placeholder.innerHTML = `<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">${icons[status]}</svg><p>${labels[status]}</p>`;
|
||||||
|
container.appendChild(placeholder);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateCounts() {
|
||||||
|
['draft', 'approved', 'ready'].forEach(status => {
|
||||||
|
const count = document.querySelectorAll(`.kanban-body[data-status="${status}"] .post-card`).length;
|
||||||
|
document.getElementById(`count-${status}`).textContent = count;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -1,31 +1,47 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "company_base.html" %}
|
||||||
{% block title %}Research Topics - LinkedIn Posts{% endblock %}
|
{% block title %}Research - {{ employee_name }} - {{ session.company_name }}{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
<!-- Breadcrumb -->
|
||||||
|
<div class="mb-6">
|
||||||
|
<nav class="flex items-center gap-2 text-sm">
|
||||||
|
<a href="/company/manage" class="text-gray-400 hover:text-white transition-colors">Inhalte verwalten</a>
|
||||||
|
<svg class="w-4 h-4 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/></svg>
|
||||||
|
<a href="/company/manage?employee_id={{ employee_id }}" class="text-gray-400 hover:text-white transition-colors">{{ employee_name }}</a>
|
||||||
|
<svg class="w-4 h-4 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/></svg>
|
||||||
|
<span class="text-white">Research</span>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="mb-8">
|
<div class="mb-8">
|
||||||
<h1 class="text-3xl font-bold text-white mb-2">Research Topics</h1>
|
<h1 class="text-3xl font-bold text-white mb-2">Research Topics</h1>
|
||||||
<p class="text-gray-400">Recherchiere neue Content-Themen mit Perplexity AI</p>
|
<p class="text-gray-400">Recherchiere neue Content-Themen für {{ employee_name }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Limit Warning -->
|
||||||
|
{% if limit_reached %}
|
||||||
|
<div class="bg-red-900/50 border border-red-500 text-red-200 px-6 py-4 rounded-xl mb-8">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<svg class="w-6 h-6 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/>
|
||||||
|
</svg>
|
||||||
|
<div>
|
||||||
|
<strong class="font-semibold">Limit erreicht</strong>
|
||||||
|
<p class="text-sm mt-1">{{ limit_message }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||||
<!-- Left: Form -->
|
<!-- Left: Form -->
|
||||||
<div>
|
<div>
|
||||||
<form id="researchForm" class="card-bg rounded-xl border p-6">
|
<form id="researchForm" class="card-bg rounded-xl border p-6">
|
||||||
<div class="mb-6">
|
|
||||||
<label class="block text-sm font-medium text-gray-300 mb-2">Kunde auswählen</label>
|
|
||||||
<select name="customer_id" id="customerSelect" required class="w-full input-bg border rounded-lg px-4 py-3 text-white">
|
|
||||||
<option value="">-- Kunde wählen --</option>
|
|
||||||
{% for customer in customers %}
|
|
||||||
<option value="{{ customer.id }}">{{ customer.name }} - {{ customer.company_name or 'Kein Unternehmen' }}</option>
|
|
||||||
{% endfor %}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Post Type Selection -->
|
<!-- Post Type Selection -->
|
||||||
<div id="postTypeArea" class="mb-6 hidden">
|
<div id="postTypeArea" class="mb-6">
|
||||||
<label class="block text-sm font-medium text-gray-300 mb-2">Post-Typ (optional)</label>
|
<label class="block text-sm font-medium text-gray-300 mb-2">Post-Typ (optional)</label>
|
||||||
<div id="postTypeCards" class="grid grid-cols-2 gap-2 mb-2">
|
<div id="postTypeCards" class="grid grid-cols-2 gap-2 mb-2">
|
||||||
<!-- Post type cards will be loaded here -->
|
<div class="text-gray-500 text-sm">Lade Post-Typen...</div>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-xs text-gray-500">Wähle einen Post-Typ für gezielte Recherche oder lasse leer für allgemeine Recherche.</p>
|
<p class="text-xs text-gray-500">Wähle einen Post-Typ für gezielte Recherche oder lasse leer für allgemeine Recherche.</p>
|
||||||
<input type="hidden" name="post_type_id" id="selectedPostTypeId" value="">
|
<input type="hidden" name="post_type_id" id="selectedPostTypeId" value="">
|
||||||
@@ -44,17 +60,12 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button type="submit" id="submitBtn" class="w-full btn-primary font-medium py-3 rounded-lg transition-colors flex items-center justify-center gap-2">
|
<button type="submit" id="submitBtn" {% if limit_reached %}disabled{% endif %}
|
||||||
|
class="w-full btn-primary font-medium py-3 rounded-lg transition-colors flex items-center justify-center gap-2 {% if limit_reached %}opacity-50 cursor-not-allowed{% endif %}">
|
||||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg>
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg>
|
||||||
Research starten
|
{% if limit_reached %}Limit erreicht{% else %}Research starten{% endif %}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
{% if not customers %}
|
|
||||||
<div class="mt-4 bg-yellow-900/30 border border-yellow-600 rounded-lg p-4">
|
|
||||||
<p class="text-yellow-300">Noch keine Kunden vorhanden. <a href="/admin/customers/new" class="underline">Erstelle zuerst einen Kunden</a>.</p>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Right: Results -->
|
<!-- Right: Results -->
|
||||||
@@ -71,6 +82,9 @@
|
|||||||
|
|
||||||
{% block scripts %}
|
{% block scripts %}
|
||||||
<script>
|
<script>
|
||||||
|
const USER_ID = '{{ user_id }}';
|
||||||
|
const EMPLOYEE_ID = '{{ employee_id }}';
|
||||||
|
|
||||||
const form = document.getElementById('researchForm');
|
const form = document.getElementById('researchForm');
|
||||||
const submitBtn = document.getElementById('submitBtn');
|
const submitBtn = document.getElementById('submitBtn');
|
||||||
const progressArea = document.getElementById('progressArea');
|
const progressArea = document.getElementById('progressArea');
|
||||||
@@ -78,30 +92,19 @@ const progressBar = document.getElementById('progressBar');
|
|||||||
const progressMessage = document.getElementById('progressMessage');
|
const progressMessage = document.getElementById('progressMessage');
|
||||||
const progressPercent = document.getElementById('progressPercent');
|
const progressPercent = document.getElementById('progressPercent');
|
||||||
const topicsList = document.getElementById('topicsList');
|
const topicsList = document.getElementById('topicsList');
|
||||||
const customerSelect = document.getElementById('customerSelect');
|
|
||||||
const postTypeArea = document.getElementById('postTypeArea');
|
|
||||||
const postTypeCards = document.getElementById('postTypeCards');
|
const postTypeCards = document.getElementById('postTypeCards');
|
||||||
const selectedPostTypeId = document.getElementById('selectedPostTypeId');
|
const selectedPostTypeId = document.getElementById('selectedPostTypeId');
|
||||||
|
|
||||||
let currentPostTypes = [];
|
let currentPostTypes = [];
|
||||||
|
|
||||||
// Load post types when customer is selected
|
// Load post types on page load
|
||||||
customerSelect.addEventListener('change', async () => {
|
async function loadPostTypes() {
|
||||||
const customerId = customerSelect.value;
|
|
||||||
selectedPostTypeId.value = '';
|
|
||||||
|
|
||||||
if (!customerId) {
|
|
||||||
postTypeArea.classList.add('hidden');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/admin/api/customers/${customerId}/post-types`);
|
const response = await fetch(`/api/post-types?user_id=${USER_ID}`);
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
if (data.post_types && data.post_types.length > 0) {
|
if (data.post_types && data.post_types.length > 0) {
|
||||||
currentPostTypes = data.post_types;
|
currentPostTypes = data.post_types;
|
||||||
postTypeArea.classList.remove('hidden');
|
|
||||||
|
|
||||||
postTypeCards.innerHTML = `
|
postTypeCards.innerHTML = `
|
||||||
<button type="button" onclick="selectPostType('')" id="pt_all"
|
<button type="button" onclick="selectPostType('')" id="pt_all"
|
||||||
@@ -118,18 +121,17 @@ customerSelect.addEventListener('change', async () => {
|
|||||||
</button>
|
</button>
|
||||||
`).join('');
|
`).join('');
|
||||||
} else {
|
} else {
|
||||||
postTypeArea.classList.add('hidden');
|
postTypeCards.innerHTML = '<p class="text-gray-500 text-sm col-span-2">Keine Post-Typen konfiguriert.</p>';
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load post types:', error);
|
console.error('Failed to load post types:', error);
|
||||||
postTypeArea.classList.add('hidden');
|
postTypeCards.innerHTML = '<p class="text-red-400 text-sm col-span-2">Fehler beim Laden.</p>';
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
|
||||||
function selectPostType(typeId) {
|
function selectPostType(typeId) {
|
||||||
selectedPostTypeId.value = typeId;
|
selectedPostTypeId.value = typeId;
|
||||||
|
|
||||||
// Update card styles
|
|
||||||
document.querySelectorAll('[id^="pt_"]').forEach(card => {
|
document.querySelectorAll('[id^="pt_"]').forEach(card => {
|
||||||
if (card.id === `pt_${typeId}` || (typeId === '' && card.id === 'pt_all')) {
|
if (card.id === `pt_${typeId}` || (typeId === '' && card.id === 'pt_all')) {
|
||||||
card.className = 'p-3 rounded-lg border text-left transition-colors bg-brand-highlight/20 border-brand-highlight text-white';
|
card.className = 'p-3 rounded-lg border text-left transition-colors bg-brand-highlight/20 border-brand-highlight text-white';
|
||||||
@@ -142,21 +144,18 @@ function selectPostType(typeId) {
|
|||||||
form.addEventListener('submit', async (e) => {
|
form.addEventListener('submit', async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
const customerId = customerSelect.value;
|
|
||||||
if (!customerId) return;
|
|
||||||
|
|
||||||
submitBtn.disabled = true;
|
submitBtn.disabled = true;
|
||||||
submitBtn.innerHTML = '<svg class="w-5 h-5 animate-spin" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path></svg> Recherchiert...';
|
submitBtn.innerHTML = '<svg class="w-5 h-5 animate-spin" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path></svg> Recherchiert...';
|
||||||
progressArea.classList.remove('hidden');
|
progressArea.classList.remove('hidden');
|
||||||
|
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('customer_id', customerId);
|
formData.append('user_id', USER_ID);
|
||||||
if (selectedPostTypeId.value) {
|
if (selectedPostTypeId.value) {
|
||||||
formData.append('post_type_id', selectedPostTypeId.value);
|
formData.append('post_type_id', selectedPostTypeId.value);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/admin/api/research', {
|
const response = await fetch('/api/research', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: formData
|
body: formData
|
||||||
});
|
});
|
||||||
@@ -164,7 +163,7 @@ form.addEventListener('submit', async (e) => {
|
|||||||
|
|
||||||
const taskId = data.task_id;
|
const taskId = data.task_id;
|
||||||
const pollInterval = setInterval(async () => {
|
const pollInterval = setInterval(async () => {
|
||||||
const statusResponse = await fetch(`/admin/api/tasks/${taskId}`);
|
const statusResponse = await fetch(`/api/tasks/${taskId}`);
|
||||||
const status = await statusResponse.json();
|
const status = await statusResponse.json();
|
||||||
|
|
||||||
progressBar.style.width = `${status.progress}%`;
|
progressBar.style.width = `${status.progress}%`;
|
||||||
@@ -177,7 +176,6 @@ form.addEventListener('submit', async (e) => {
|
|||||||
submitBtn.disabled = false;
|
submitBtn.disabled = false;
|
||||||
submitBtn.innerHTML = '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg> Research starten';
|
submitBtn.innerHTML = '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg> Research starten';
|
||||||
|
|
||||||
// Display topics
|
|
||||||
if (status.topics && status.topics.length > 0) {
|
if (status.topics && status.topics.length > 0) {
|
||||||
topicsList.innerHTML = status.topics.map((topic, i) => `
|
topicsList.innerHTML = status.topics.map((topic, i) => `
|
||||||
<div class="bg-brand-bg rounded-lg p-4 border border-brand-bg-light">
|
<div class="bg-brand-bg rounded-lg p-4 border border-brand-bg-light">
|
||||||
@@ -211,5 +209,8 @@ form.addEventListener('submit', async (e) => {
|
|||||||
topicsList.innerHTML = `<p class="text-red-400">Fehler: ${error.message}</p>`;
|
topicsList.innerHTML = `<p class="text-red-400">Fehler: ${error.message}</p>`;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Load post types on page load
|
||||||
|
loadPostTypes();
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
260
src/web/templates/user/company_strategy.html
Normal file
260
src/web/templates/user/company_strategy.html
Normal file
@@ -0,0 +1,260 @@
|
|||||||
|
{% extends "company_base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Strategie - {{ session.company_name }}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="max-w-3xl mx-auto">
|
||||||
|
<h1 class="text-2xl font-bold text-white mb-2">Unternehmensstrategie</h1>
|
||||||
|
<p class="text-gray-400 mb-8">Diese Strategie wird bei der Erstellung aller LinkedIn-Posts deiner Mitarbeiter berücksichtigt.</p>
|
||||||
|
|
||||||
|
{% if success %}
|
||||||
|
<div class="bg-green-900/50 border border-green-500 text-green-200 px-4 py-3 rounded-lg mb-6">
|
||||||
|
Strategie erfolgreich gespeichert!
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if error %}
|
||||||
|
<div class="bg-red-900/50 border border-red-500 text-red-200 px-4 py-3 rounded-lg mb-6">
|
||||||
|
{{ error }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<form method="POST" action="/company/strategy" class="space-y-6">
|
||||||
|
<!-- Mission & Vision -->
|
||||||
|
<div class="card-bg border rounded-xl p-6">
|
||||||
|
<h2 class="text-lg font-semibold text-white mb-4">Mission & Vision</h2>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label for="mission" class="block text-sm font-medium text-gray-300 mb-1">Mission</label>
|
||||||
|
<textarea id="mission" name="mission" rows="2"
|
||||||
|
class="w-full input-bg border rounded-lg px-4 py-3 text-white"
|
||||||
|
placeholder="Was ist der Zweck deines Unternehmens?">{{ strategy.mission or '' }}</textarea>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="vision" class="block text-sm font-medium text-gray-300 mb-1">Vision</label>
|
||||||
|
<textarea id="vision" name="vision" rows="2"
|
||||||
|
class="w-full input-bg border rounded-lg px-4 py-3 text-white"
|
||||||
|
placeholder="Wo soll dein Unternehmen in Zukunft stehen?">{{ strategy.vision or '' }}</textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Brand Voice -->
|
||||||
|
<div class="card-bg border rounded-xl p-6">
|
||||||
|
<h2 class="text-lg font-semibold text-white mb-4">Brand Voice</h2>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label for="brand_voice" class="block text-sm font-medium text-gray-300 mb-1">Markenstimme</label>
|
||||||
|
<textarea id="brand_voice" name="brand_voice" rows="2"
|
||||||
|
class="w-full input-bg border rounded-lg px-4 py-3 text-white"
|
||||||
|
placeholder="Wie soll deine Marke klingen? (z.B. professionell, freundlich, innovativ)">{{ strategy.brand_voice or '' }}</textarea>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="tone_guidelines" class="block text-sm font-medium text-gray-300 mb-1">Tonalität-Richtlinien</label>
|
||||||
|
<textarea id="tone_guidelines" name="tone_guidelines" rows="3"
|
||||||
|
class="w-full input-bg border rounded-lg px-4 py-3 text-white"
|
||||||
|
placeholder="Spezifische Anweisungen zur Tonalität (z.B. Duzen/Siezen, Fachsprache, etc.)">{{ strategy.tone_guidelines or '' }}</textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Target Audience -->
|
||||||
|
<div class="card-bg border rounded-xl p-6">
|
||||||
|
<h2 class="text-lg font-semibold text-white mb-4">Zielgruppe</h2>
|
||||||
|
<div>
|
||||||
|
<label for="target_audience" class="block text-sm font-medium text-gray-300 mb-1">Beschreibung der Zielgruppe</label>
|
||||||
|
<textarea id="target_audience" name="target_audience" rows="3"
|
||||||
|
class="w-full input-bg border rounded-lg px-4 py-3 text-white"
|
||||||
|
placeholder="Wer ist deine Zielgruppe auf LinkedIn?">{{ strategy.target_audience or '' }}</textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Content Pillars -->
|
||||||
|
<div class="card-bg border rounded-xl p-6">
|
||||||
|
<h2 class="text-lg font-semibold text-white mb-4">Content-Säulen</h2>
|
||||||
|
<p class="text-gray-400 text-sm mb-4">Die Hauptthemen, über die dein Unternehmen postet.</p>
|
||||||
|
<div id="content-pillars" class="space-y-2">
|
||||||
|
{% for pillar in strategy.content_pillars or [] %}
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<input type="text" name="content_pillar" value="{{ pillar }}"
|
||||||
|
class="flex-1 input-bg border rounded-lg px-4 py-2 text-white"
|
||||||
|
placeholder="z.B. Innovation, Nachhaltigkeit, Teamkultur">
|
||||||
|
<button type="button" onclick="this.parentElement.remove()"
|
||||||
|
class="px-3 py-2 text-red-400 hover:text-red-300">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% if not strategy.content_pillars %}
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<input type="text" name="content_pillar"
|
||||||
|
class="flex-1 input-bg border rounded-lg px-4 py-2 text-white"
|
||||||
|
placeholder="z.B. Innovation, Nachhaltigkeit, Teamkultur">
|
||||||
|
<button type="button" onclick="this.parentElement.remove()"
|
||||||
|
class="px-3 py-2 text-red-400 hover:text-red-300">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<button type="button" onclick="addPillar()"
|
||||||
|
class="mt-3 text-brand-highlight text-sm hover:underline flex items-center gap-1">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/>
|
||||||
|
</svg>
|
||||||
|
Weitere Säule hinzufügen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Do's and Don'ts -->
|
||||||
|
<div class="grid md:grid-cols-2 gap-6">
|
||||||
|
<!-- Do's -->
|
||||||
|
<div class="card-bg border rounded-xl p-6">
|
||||||
|
<h2 class="text-lg font-semibold text-green-400 mb-4">Do's</h2>
|
||||||
|
<div id="dos-list" class="space-y-2">
|
||||||
|
{% for do in strategy.dos or [] %}
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<input type="text" name="do_item" value="{{ do }}"
|
||||||
|
class="flex-1 input-bg border rounded-lg px-4 py-2 text-white text-sm"
|
||||||
|
placeholder="z.B. Erfolge feiern">
|
||||||
|
<button type="button" onclick="this.parentElement.remove()"
|
||||||
|
class="px-2 py-2 text-red-400 hover:text-red-300">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% if not strategy.dos %}
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<input type="text" name="do_item"
|
||||||
|
class="flex-1 input-bg border rounded-lg px-4 py-2 text-white text-sm"
|
||||||
|
placeholder="z.B. Erfolge feiern">
|
||||||
|
<button type="button" onclick="this.parentElement.remove()"
|
||||||
|
class="px-2 py-2 text-red-400 hover:text-red-300">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<button type="button" onclick="addDo()"
|
||||||
|
class="mt-3 text-green-400 text-sm hover:underline flex items-center gap-1">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/>
|
||||||
|
</svg>
|
||||||
|
Hinzufügen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Don'ts -->
|
||||||
|
<div class="card-bg border rounded-xl p-6">
|
||||||
|
<h2 class="text-lg font-semibold text-red-400 mb-4">Don'ts</h2>
|
||||||
|
<div id="donts-list" class="space-y-2">
|
||||||
|
{% for dont in strategy.donts or [] %}
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<input type="text" name="dont_item" value="{{ dont }}"
|
||||||
|
class="flex-1 input-bg border rounded-lg px-4 py-2 text-white text-sm"
|
||||||
|
placeholder="z.B. Konkurrenz kritisieren">
|
||||||
|
<button type="button" onclick="this.parentElement.remove()"
|
||||||
|
class="px-2 py-2 text-red-400 hover:text-red-300">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% if not strategy.donts %}
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<input type="text" name="dont_item"
|
||||||
|
class="flex-1 input-bg border rounded-lg px-4 py-2 text-white text-sm"
|
||||||
|
placeholder="z.B. Konkurrenz kritisieren">
|
||||||
|
<button type="button" onclick="this.parentElement.remove()"
|
||||||
|
class="px-2 py-2 text-red-400 hover:text-red-300">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<button type="button" onclick="addDont()"
|
||||||
|
class="mt-3 text-red-400 text-sm hover:underline flex items-center gap-1">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/>
|
||||||
|
</svg>
|
||||||
|
Hinzufügen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Submit Button -->
|
||||||
|
<div class="flex justify-end">
|
||||||
|
<button type="submit" class="btn-primary font-medium py-3 px-8 rounded-lg transition-colors">
|
||||||
|
Strategie speichern
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function addPillar() {
|
||||||
|
const container = document.getElementById('content-pillars');
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.className = 'flex gap-2';
|
||||||
|
div.innerHTML = `
|
||||||
|
<input type="text" name="content_pillar"
|
||||||
|
class="flex-1 input-bg border rounded-lg px-4 py-2 text-white"
|
||||||
|
placeholder="z.B. Innovation, Nachhaltigkeit, Teamkultur">
|
||||||
|
<button type="button" onclick="this.parentElement.remove()"
|
||||||
|
class="px-3 py-2 text-red-400 hover:text-red-300">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
`;
|
||||||
|
container.appendChild(div);
|
||||||
|
}
|
||||||
|
|
||||||
|
function addDo() {
|
||||||
|
const container = document.getElementById('dos-list');
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.className = 'flex gap-2';
|
||||||
|
div.innerHTML = `
|
||||||
|
<input type="text" name="do_item"
|
||||||
|
class="flex-1 input-bg border rounded-lg px-4 py-2 text-white text-sm"
|
||||||
|
placeholder="z.B. Erfolge feiern">
|
||||||
|
<button type="button" onclick="this.parentElement.remove()"
|
||||||
|
class="px-2 py-2 text-red-400 hover:text-red-300">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
`;
|
||||||
|
container.appendChild(div);
|
||||||
|
}
|
||||||
|
|
||||||
|
function addDont() {
|
||||||
|
const container = document.getElementById('donts-list');
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.className = 'flex gap-2';
|
||||||
|
div.innerHTML = `
|
||||||
|
<input type="text" name="dont_item"
|
||||||
|
class="flex-1 input-bg border rounded-lg px-4 py-2 text-white text-sm"
|
||||||
|
placeholder="z.B. Konkurrenz kritisieren">
|
||||||
|
<button type="button" onclick="this.parentElement.remove()"
|
||||||
|
class="px-2 py-2 text-red-400 hover:text-red-300">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
`;
|
||||||
|
container.appendChild(div);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
File diff suppressed because it is too large
Load Diff
171
src/web/templates/user/email_action_result.html
Normal file
171
src/web/templates/user/email_action_result.html
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>{{ title }}</title>
|
||||||
|
<style>
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
background: linear-gradient(135deg, #1a2424 0%, #2d3838 100%);
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
max-width: 500px;
|
||||||
|
width: 100%;
|
||||||
|
background: white;
|
||||||
|
border-radius: 16px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
padding: 32px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header.success {
|
||||||
|
background: linear-gradient(135deg, #22c55e 0%, #16a34a 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header.error {
|
||||||
|
background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
width: 64px;
|
||||||
|
height: 64px;
|
||||||
|
margin: 0 auto 16px;
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon svg {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header h1 {
|
||||||
|
color: white;
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
padding: 32px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message {
|
||||||
|
font-size: 16px;
|
||||||
|
color: #4b5563;
|
||||||
|
line-height: 1.6;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 8px 20px;
|
||||||
|
border-radius: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 14px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-badge.approved {
|
||||||
|
background: #dcfce7;
|
||||||
|
color: #166534;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-badge.rejected {
|
||||||
|
background: #fef3c7;
|
||||||
|
color: #92400e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-box {
|
||||||
|
background: #f8fafc;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 20px;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-box p {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
padding: 20px 32px;
|
||||||
|
background: #f8fafc;
|
||||||
|
text-align: center;
|
||||||
|
border-top: 1px solid #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer p {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="header {% if success %}success{% else %}error{% endif %}">
|
||||||
|
<div class="icon">
|
||||||
|
{% if success %}
|
||||||
|
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
|
||||||
|
</svg>
|
||||||
|
{% else %}
|
||||||
|
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||||
|
</svg>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<h1>{{ title }}</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="content">
|
||||||
|
{% if success and action %}
|
||||||
|
<div class="action-badge {% if action == 'approve' %}approved{% else %}rejected{% endif %}">
|
||||||
|
{% if action == 'approve' %}
|
||||||
|
Freigegeben
|
||||||
|
{% else %}
|
||||||
|
Zur Überarbeitung
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<p class="message">{{ message }}</p>
|
||||||
|
|
||||||
|
{% if success %}
|
||||||
|
<div class="info-box">
|
||||||
|
{% if action == 'approve' %}
|
||||||
|
<p>Der Post wurde in die Spalte "Veröffentlicht" verschoben und kann nun auf LinkedIn gepostet werden.</p>
|
||||||
|
{% else %}
|
||||||
|
<p>Der Post wurde zurück in die Spalte "Vorschläge" verschoben. Der Creator wurde benachrichtigt und wird die gewünschten Änderungen vornehmen.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="footer">
|
||||||
|
<p>Du kannst dieses Fenster jetzt schließen.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
137
src/web/templates/user/employee_base.html
Normal file
137
src/web/templates/user/employee_base.html
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>{% block title %}Dashboard{% endblock %} - LinkedIn Post Generator</title>
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
<script>
|
||||||
|
tailwind.config = {
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
'brand-bg': '#1a1a2e',
|
||||||
|
'brand-bg-dark': '#0f0f1a',
|
||||||
|
'brand-bg-light': '#252540',
|
||||||
|
'brand-highlight': '#e94560',
|
||||||
|
'brand-accent': '#0f3460',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
background-color: #0f0f1a;
|
||||||
|
}
|
||||||
|
.sidebar-link {
|
||||||
|
@apply flex items-center gap-3 px-4 py-3 text-gray-300 hover:bg-brand-bg-light rounded-lg transition-colors;
|
||||||
|
}
|
||||||
|
.sidebar-link.active {
|
||||||
|
@apply bg-brand-bg-light text-white;
|
||||||
|
}
|
||||||
|
.card-bg {
|
||||||
|
background-color: #1a1a2e;
|
||||||
|
border-color: #2a2a4a;
|
||||||
|
}
|
||||||
|
.input-bg {
|
||||||
|
background-color: #252540;
|
||||||
|
border-color: #3a3a5a;
|
||||||
|
}
|
||||||
|
.input-bg:focus {
|
||||||
|
border-color: #e94560;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
.btn-primary {
|
||||||
|
background-color: #e94560;
|
||||||
|
}
|
||||||
|
.btn-primary:hover {
|
||||||
|
background-color: #d13a52;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% block head %}{% endblock %}
|
||||||
|
</head>
|
||||||
|
<body class="min-h-screen">
|
||||||
|
<div class="flex min-h-screen">
|
||||||
|
<!-- Sidebar -->
|
||||||
|
<aside class="w-64 bg-brand-bg border-r border-gray-700 flex flex-col">
|
||||||
|
<!-- Logo/Brand -->
|
||||||
|
<div class="p-6 border-b border-gray-700">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="w-10 h-10 bg-brand-highlight rounded-lg flex items-center justify-center">
|
||||||
|
<svg class="w-6 h-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 class="text-white font-bold">{{ session.linkedin_name or session.customer_name or 'Mitarbeiter' }}</h1>
|
||||||
|
<p class="text-gray-500 text-xs">Mitarbeiter</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Company Info -->
|
||||||
|
{% if session.company_name %}
|
||||||
|
<div class="px-6 py-4 border-b border-gray-700">
|
||||||
|
<p class="text-xs text-gray-500 uppercase tracking-wide mb-1">Unternehmen</p>
|
||||||
|
<p class="text-brand-highlight font-medium">{{ session.company_name }}</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Navigation -->
|
||||||
|
<nav class="flex-1 p-4 space-y-1">
|
||||||
|
<a href="/" class="sidebar-link {% if request.url.path == '/' %}active{% endif %}">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 5a1 1 0 011-1h14a1 1 0 011 1v2a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM4 13a1 1 0 011-1h6a1 1 0 011 1v6a1 1 0 01-1 1H5a1 1 0 01-1-1v-6zM16 13a1 1 0 011-1h2a1 1 0 011 1v6a1 1 0 01-1 1h-2a1 1 0 01-1-1v-6z"/>
|
||||||
|
</svg>
|
||||||
|
Dashboard
|
||||||
|
</a>
|
||||||
|
<a href="/posts" class="sidebar-link {% if '/posts' in request.url.path %}active{% endif %}">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
|
||||||
|
</svg>
|
||||||
|
Meine Posts
|
||||||
|
</a>
|
||||||
|
<a href="/create-post" class="sidebar-link {% if '/create-post' in request.url.path %}active{% endif %}">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/>
|
||||||
|
</svg>
|
||||||
|
Neuer Post
|
||||||
|
</a>
|
||||||
|
<a href="/employee/strategy" class="sidebar-link {% if '/employee/strategy' in request.url.path %}active{% endif %}">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
|
||||||
|
</svg>
|
||||||
|
Unternehmensstrategie
|
||||||
|
</a>
|
||||||
|
<a href="/settings" class="sidebar-link {% if '/settings' in request.url.path %}active{% endif %}">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/>
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
|
||||||
|
</svg>
|
||||||
|
Einstellungen
|
||||||
|
</a>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- Logout -->
|
||||||
|
<div class="p-4 border-t border-gray-700">
|
||||||
|
<a href="/logout" class="sidebar-link text-red-400 hover:text-red-300 hover:bg-red-900/20">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"/>
|
||||||
|
</svg>
|
||||||
|
Abmelden
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<!-- Main Content -->
|
||||||
|
<main class="flex-1 overflow-auto">
|
||||||
|
<div class="p-8">
|
||||||
|
{% block content %}{% endblock %}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% block scripts %}{% endblock %}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
147
src/web/templates/user/employee_dashboard.html
Normal file
147
src/web/templates/user/employee_dashboard.html
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Dashboard{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="max-w-4xl mx-auto">
|
||||||
|
<h1 class="text-2xl font-bold text-white mb-6">Willkommen, {{ session.linkedin_name or session.customer_name or 'Mitarbeiter' }}!</h1>
|
||||||
|
|
||||||
|
<!-- Company Info Banner -->
|
||||||
|
{% if session.company_name %}
|
||||||
|
<div class="bg-brand-highlight/10 border border-brand-highlight/30 rounded-xl p-6 mb-8">
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<div class="w-12 h-12 bg-brand-highlight rounded-lg flex items-center justify-center">
|
||||||
|
<svg class="w-6 h-6 text-brand-bg-dark" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-gray-400 text-sm">Du bist Mitarbeiter von</p>
|
||||||
|
<p class="text-xl font-bold text-brand-highlight">{{ session.company_name }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Stats Cards -->
|
||||||
|
<div class="grid md:grid-cols-3 gap-6 mb-8">
|
||||||
|
<!-- Posts Card -->
|
||||||
|
<div class="card-bg border rounded-xl p-6">
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<div class="w-12 h-12 bg-brand-highlight/20 rounded-lg flex items-center justify-center">
|
||||||
|
<svg class="w-6 h-6 text-brand-highlight" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-3xl font-bold text-white">{{ posts_count or 0 }}</p>
|
||||||
|
<p class="text-gray-400 text-sm">Erstellte Posts</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pending Posts Card -->
|
||||||
|
<div class="card-bg border rounded-xl p-6">
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<div class="w-12 h-12 bg-yellow-500/20 rounded-lg flex items-center justify-center">
|
||||||
|
<svg class="w-6 h-6 text-yellow-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-3xl font-bold text-white">{{ pending_posts or 0 }}</p>
|
||||||
|
<p class="text-gray-400 text-sm">Ausstehend</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Approved Posts Card -->
|
||||||
|
<div class="card-bg border rounded-xl p-6">
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<div class="w-12 h-12 bg-green-500/20 rounded-lg flex items-center justify-center">
|
||||||
|
<svg class="w-6 h-6 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-3xl font-bold text-white">{{ approved_posts or 0 }}</p>
|
||||||
|
<p class="text-gray-400 text-sm">Genehmigt</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Quick Actions -->
|
||||||
|
<div class="card-bg border rounded-xl p-6 mb-8">
|
||||||
|
<h2 class="text-lg font-semibold text-white mb-4">Schnellzugriff</h2>
|
||||||
|
<div class="grid md:grid-cols-3 gap-4">
|
||||||
|
<a href="/research" class="flex items-center gap-3 p-4 bg-brand-bg-dark rounded-lg hover:bg-brand-bg-light transition-colors">
|
||||||
|
<div class="w-10 h-10 bg-brand-highlight/20 rounded-lg flex items-center justify-center">
|
||||||
|
<svg class="w-5 h-5 text-brand-highlight" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-white font-medium">Research Topics</p>
|
||||||
|
<p class="text-gray-400 text-sm">Themen recherchieren</p>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
<a href="/create" class="flex items-center gap-3 p-4 bg-brand-bg-dark rounded-lg hover:bg-brand-bg-light transition-colors">
|
||||||
|
<div class="w-10 h-10 bg-brand-highlight/20 rounded-lg flex items-center justify-center">
|
||||||
|
<svg class="w-5 h-5 text-brand-highlight" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-white font-medium">Neuer Post</p>
|
||||||
|
<p class="text-gray-400 text-sm">KI-generiert</p>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
<a href="/employee/strategy" class="flex items-center gap-3 p-4 bg-brand-bg-dark rounded-lg hover:bg-brand-bg-light transition-colors">
|
||||||
|
<div class="w-10 h-10 bg-brand-highlight/20 rounded-lg flex items-center justify-center">
|
||||||
|
<svg class="w-5 h-5 text-brand-highlight" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-3 7h3m-3 4h3m-6-4h.01M9 16h.01"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-white font-medium">Strategie</p>
|
||||||
|
<p class="text-gray-400 text-sm">Richtlinien</p>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Recent Posts -->
|
||||||
|
{% if recent_posts and recent_posts|length > 0 %}
|
||||||
|
<div class="card-bg border rounded-xl p-6">
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<h2 class="text-lg font-semibold text-white">Letzte Posts</h2>
|
||||||
|
<a href="/posts" class="text-brand-highlight text-sm hover:underline">Alle anzeigen</a>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-3">
|
||||||
|
{% for post in recent_posts[:5] %}
|
||||||
|
<div class="flex items-start gap-3 p-3 bg-brand-bg-dark rounded-lg">
|
||||||
|
<div class="flex-1">
|
||||||
|
<p class="text-white text-sm line-clamp-2">{{ post.post_content[:150] }}{% if post.post_content|length > 150 %}...{% endif %}</p>
|
||||||
|
<p class="text-gray-500 text-xs mt-1">{{ post.created_at.strftime('%d.%m.%Y') }}</p>
|
||||||
|
</div>
|
||||||
|
<span class="text-xs px-2 py-1 rounded {% if post.status == 'approved' %}bg-green-500/20 text-green-400{% elif post.status == 'rejected' %}bg-red-500/20 text-red-400{% else %}bg-yellow-500/20 text-yellow-400{% endif %}">
|
||||||
|
{% if post.status == 'approved' %}Genehmigt{% elif post.status == 'rejected' %}Abgelehnt{% else %}Ausstehend{% endif %}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="card-bg border rounded-xl p-6 text-center">
|
||||||
|
<div class="w-16 h-16 bg-gray-600/30 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||||
|
<svg class="w-8 h-8 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<p class="text-gray-400">Noch keine Posts erstellt</p>
|
||||||
|
<a href="/create" class="inline-block mt-4 text-brand-highlight hover:underline">Ersten Post erstellen</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
139
src/web/templates/user/employee_strategy.html
Normal file
139
src/web/templates/user/employee_strategy.html
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Unternehmensstrategie - {{ session.company_name }}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="max-w-3xl mx-auto">
|
||||||
|
<div class="mb-8">
|
||||||
|
<h1 class="text-2xl font-bold text-white mb-2">Unternehmensstrategie</h1>
|
||||||
|
<p class="text-gray-400">Diese Strategie wird bei der Erstellung deiner LinkedIn-Posts berücksichtigt.</p>
|
||||||
|
{% if session.company_name %}
|
||||||
|
<p class="text-brand-highlight text-sm mt-2">Richtlinien von {{ session.company_name }}</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if not strategy or not strategy.mission %}
|
||||||
|
<div class="card-bg border rounded-xl p-8 text-center">
|
||||||
|
<div class="w-16 h-16 bg-yellow-500/20 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||||
|
<svg class="w-8 h-8 text-yellow-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<p class="text-gray-400">Dein Unternehmen hat noch keine Strategie definiert.</p>
|
||||||
|
<p class="text-gray-500 text-sm mt-2">Wende dich an deinen Administrator, um die Unternehmensstrategie einzurichten.</p>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="space-y-6">
|
||||||
|
<!-- Mission & Vision -->
|
||||||
|
<div class="card-bg border rounded-xl p-6">
|
||||||
|
<h2 class="text-lg font-semibold text-white mb-4">Mission & Vision</h2>
|
||||||
|
<div class="space-y-4">
|
||||||
|
{% if strategy.mission %}
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium text-gray-400 mb-1">Mission</p>
|
||||||
|
<p class="text-white bg-brand-bg-dark rounded-lg p-3">{{ strategy.mission }}</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if strategy.vision %}
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium text-gray-400 mb-1">Vision</p>
|
||||||
|
<p class="text-white bg-brand-bg-dark rounded-lg p-3">{{ strategy.vision }}</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Brand Voice -->
|
||||||
|
{% if strategy.brand_voice or strategy.tone_guidelines %}
|
||||||
|
<div class="card-bg border rounded-xl p-6">
|
||||||
|
<h2 class="text-lg font-semibold text-white mb-4">Brand Voice</h2>
|
||||||
|
<div class="space-y-4">
|
||||||
|
{% if strategy.brand_voice %}
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium text-gray-400 mb-1">Markenstimme</p>
|
||||||
|
<p class="text-white bg-brand-bg-dark rounded-lg p-3">{{ strategy.brand_voice }}</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if strategy.tone_guidelines %}
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium text-gray-400 mb-1">Tonalität-Richtlinien</p>
|
||||||
|
<p class="text-white bg-brand-bg-dark rounded-lg p-3">{{ strategy.tone_guidelines }}</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Target Audience -->
|
||||||
|
{% if strategy.target_audience %}
|
||||||
|
<div class="card-bg border rounded-xl p-6">
|
||||||
|
<h2 class="text-lg font-semibold text-white mb-4">Zielgruppe</h2>
|
||||||
|
<p class="text-white bg-brand-bg-dark rounded-lg p-3">{{ strategy.target_audience }}</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Content Pillars -->
|
||||||
|
{% if strategy.content_pillars and strategy.content_pillars|length > 0 %}
|
||||||
|
<div class="card-bg border rounded-xl p-6">
|
||||||
|
<h2 class="text-lg font-semibold text-white mb-4">Content-Säulen</h2>
|
||||||
|
<p class="text-gray-400 text-sm mb-4">Die Hauptthemen, über die das Unternehmen postet.</p>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
{% for pillar in strategy.content_pillars %}
|
||||||
|
<span class="px-3 py-2 bg-brand-highlight/20 border border-brand-highlight/30 rounded-lg text-brand-highlight text-sm">{{ pillar }}</span>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Do's and Don'ts -->
|
||||||
|
{% if (strategy.dos and strategy.dos|length > 0) or (strategy.donts and strategy.donts|length > 0) %}
|
||||||
|
<div class="grid md:grid-cols-2 gap-6">
|
||||||
|
<!-- Do's -->
|
||||||
|
{% if strategy.dos and strategy.dos|length > 0 %}
|
||||||
|
<div class="card-bg border rounded-xl p-6">
|
||||||
|
<h2 class="text-lg font-semibold text-green-400 mb-4 flex items-center gap-2">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
|
||||||
|
</svg>
|
||||||
|
Do's
|
||||||
|
</h2>
|
||||||
|
<ul class="space-y-2">
|
||||||
|
{% for do in strategy.dos %}
|
||||||
|
<li class="flex items-start gap-2 text-white text-sm">
|
||||||
|
<svg class="w-4 h-4 text-green-400 mt-0.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
|
||||||
|
</svg>
|
||||||
|
{{ do }}
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Don'ts -->
|
||||||
|
{% if strategy.donts and strategy.donts|length > 0 %}
|
||||||
|
<div class="card-bg border rounded-xl p-6">
|
||||||
|
<h2 class="text-lg font-semibold text-red-400 mb-4 flex items-center gap-2">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||||
|
</svg>
|
||||||
|
Don'ts
|
||||||
|
</h2>
|
||||||
|
<ul class="space-y-2">
|
||||||
|
{% for dont in strategy.donts %}
|
||||||
|
<li class="flex items-start gap-2 text-white text-sm">
|
||||||
|
<svg class="w-4 h-4 text-red-400 mt-0.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||||
|
</svg>
|
||||||
|
{{ dont }}
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
151
src/web/templates/user/invite_accept.html
Normal file
151
src/web/templates/user/invite_accept.html
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Einladung annehmen - LinkedIn Posts</title>
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
<script>
|
||||||
|
tailwind.config = {
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
'brand': {
|
||||||
|
'bg': '#3d4848',
|
||||||
|
'bg-light': '#4a5858',
|
||||||
|
'bg-dark': '#2d3838',
|
||||||
|
'highlight': '#ffc700',
|
||||||
|
'highlight-dark': '#e6b300',
|
||||||
|
},
|
||||||
|
'linkedin': '#0A66C2'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<style>
|
||||||
|
body { background-color: #3d4848; }
|
||||||
|
.btn-linkedin { background-color: #0A66C2; }
|
||||||
|
.btn-linkedin:hover { background-color: #004182; }
|
||||||
|
.card-bg { background-color: #4a5858; border-color: #5a6868; }
|
||||||
|
.input-bg { background-color: #3d4848; border-color: #5a6868; }
|
||||||
|
.input-bg:focus { border-color: #ffc700; outline: none; }
|
||||||
|
.btn-primary { background-color: #ffc700; color: #2d3838; }
|
||||||
|
.btn-primary:hover { background-color: #e6b300; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body class="text-gray-100 min-h-screen flex items-center justify-center">
|
||||||
|
<div class="w-full max-w-md px-4">
|
||||||
|
<div class="card-bg rounded-xl border p-8">
|
||||||
|
<div class="text-center mb-8">
|
||||||
|
<img src="/static/logo.png" alt="Logo" class="h-16 w-auto mx-auto mb-4">
|
||||||
|
<h1 class="text-2xl font-bold text-white mb-2">Einladung von {{ company_name }}</h1>
|
||||||
|
<p class="text-gray-400">Du wurdest von {{ inviter_name }} eingeladen, dem Team beizutreten.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if error %}
|
||||||
|
<div class="bg-red-900/50 border border-red-500 text-red-200 px-4 py-3 rounded-lg mb-6">
|
||||||
|
{{ error }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if expired %}
|
||||||
|
<div class="bg-yellow-900/50 border border-yellow-500 text-yellow-200 px-4 py-3 rounded-lg mb-6">
|
||||||
|
<p class="font-medium">Diese Einladung ist abgelaufen</p>
|
||||||
|
<p class="text-sm">Bitte frage nach einer neuen Einladung.</p>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
|
||||||
|
<!-- Invitation Details -->
|
||||||
|
<div class="bg-brand-bg border border-gray-600 rounded-lg p-4 mb-6">
|
||||||
|
<div class="flex items-center gap-3 mb-3">
|
||||||
|
<div class="w-10 h-10 bg-brand-highlight/20 rounded-lg flex items-center justify-center">
|
||||||
|
<svg class="w-5 h-5 text-brand-highlight" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-white font-medium">{{ company_name }}</p>
|
||||||
|
<p class="text-sm text-gray-400">Eingeladen am {{ invitation.created_at.strftime('%d.%m.%Y') }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-gray-400">
|
||||||
|
Als Teammitglied kannst du LinkedIn-Posts erstellen, die der Unternehmensstrategie entsprechen.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- LinkedIn OAuth -->
|
||||||
|
<div class="mb-6">
|
||||||
|
<a href="/auth/linkedin?invite_token={{ invitation.token }}" class="w-full btn-linkedin text-white font-medium py-3 px-4 rounded-lg transition-colors flex items-center justify-center gap-3">
|
||||||
|
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433c-1.144 0-2.063-.926-2.063-2.065 0-1.138.92-2.063 2.063-2.063 1.14 0 2.064.925 2.064 2.063 0 1.139-.925 2.065-2.064 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"/>
|
||||||
|
</svg>
|
||||||
|
Mit LinkedIn beitreten
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="relative my-6">
|
||||||
|
<div class="absolute inset-0 flex items-center">
|
||||||
|
<div class="w-full border-t border-gray-600"></div>
|
||||||
|
</div>
|
||||||
|
<div class="relative flex justify-center text-sm">
|
||||||
|
<span class="px-4 bg-brand-bg-light text-gray-400">oder mit E-Mail</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Email/Password Form -->
|
||||||
|
<form method="POST" action="/invite/{{ invitation.token }}/accept" class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label for="email" class="block text-sm font-medium text-gray-300 mb-1">E-Mail</label>
|
||||||
|
<input type="email" id="email" name="email" required
|
||||||
|
class="w-full input-bg border rounded-lg px-4 py-2 text-white"
|
||||||
|
value="{{ invitation.email }}" readonly>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="password" class="block text-sm font-medium text-gray-300 mb-1">Passwort erstellen</label>
|
||||||
|
<input type="password" id="password" name="password" required minlength="8"
|
||||||
|
class="w-full input-bg border rounded-lg px-4 py-2 text-white"
|
||||||
|
placeholder="Mindestens 8 Zeichen">
|
||||||
|
<p class="text-xs text-gray-500 mt-1">Mind. 8 Zeichen, 1 Großbuchstabe, 1 Zahl</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="password_confirm" class="block text-sm font-medium text-gray-300 mb-1">Passwort bestätigen</label>
|
||||||
|
<input type="password" id="password_confirm" name="password_confirm" required
|
||||||
|
class="w-full input-bg border rounded-lg px-4 py-2 text-white"
|
||||||
|
placeholder="Passwort wiederholen">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="w-full btn-primary font-medium py-3 px-4 rounded-lg transition-colors">
|
||||||
|
Einladung annehmen
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="text-center pt-6 mt-6 border-t border-gray-600">
|
||||||
|
<p class="text-gray-400 text-sm">
|
||||||
|
Du hast bereits ein Konto?
|
||||||
|
<a href="/login" class="text-brand-highlight hover:underline">Anmelden</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Password confirmation validation
|
||||||
|
const form = document.querySelector('form');
|
||||||
|
if (form) {
|
||||||
|
const password = document.getElementById('password');
|
||||||
|
const passwordConfirm = document.getElementById('password_confirm');
|
||||||
|
|
||||||
|
form.addEventListener('submit', function(e) {
|
||||||
|
if (password.value !== passwordConfirm.value) {
|
||||||
|
e.preventDefault();
|
||||||
|
alert('Passwörter stimmen nicht überein');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -59,8 +59,35 @@
|
|||||||
Mit LinkedIn anmelden
|
Mit LinkedIn anmelden
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
|
<div class="relative my-4">
|
||||||
|
<div class="absolute inset-0 flex items-center">
|
||||||
|
<div class="w-full border-t border-gray-600"></div>
|
||||||
|
</div>
|
||||||
|
<div class="relative flex justify-center text-sm">
|
||||||
|
<span class="px-4 bg-brand-bg-light text-gray-400">oder</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Email/Password Login Form -->
|
||||||
|
<form method="POST" action="/auth/login" class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<input type="email" name="email" required
|
||||||
|
class="w-full bg-brand-bg border border-gray-600 rounded-lg px-4 py-2 text-white focus:border-brand-highlight focus:outline-none"
|
||||||
|
placeholder="E-Mail">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<input type="password" name="password" required
|
||||||
|
class="w-full bg-brand-bg border border-gray-600 rounded-lg px-4 py-2 text-white focus:border-brand-highlight focus:outline-none"
|
||||||
|
placeholder="Passwort">
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="w-full bg-brand-highlight hover:bg-brand-highlight-dark text-brand-bg-dark font-medium py-3 px-4 rounded-lg transition-colors">
|
||||||
|
Anmelden
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
<p class="text-center text-gray-500 text-sm">
|
<p class="text-center text-gray-500 text-sm">
|
||||||
Melde dich mit deinem LinkedIn-Konto an, um auf das Dashboard zuzugreifen.
|
Noch kein Konto?
|
||||||
|
<a href="/register" class="text-brand-highlight hover:underline">Jetzt registrieren</a>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
85
src/web/templates/user/onboarding/base.html
Normal file
85
src/web/templates/user/onboarding/base.html
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>{% block title %}Onboarding{% endblock %} - LinkedIn Posts</title>
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
<script>
|
||||||
|
tailwind.config = {
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
'brand': {
|
||||||
|
'bg': '#3d4848',
|
||||||
|
'bg-light': '#4a5858',
|
||||||
|
'bg-dark': '#2d3838',
|
||||||
|
'highlight': '#ffc700',
|
||||||
|
'highlight-dark': '#e6b300',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<style>
|
||||||
|
body { background-color: #3d4848; }
|
||||||
|
.card-bg { background-color: #4a5858; border-color: #5a6868; }
|
||||||
|
.input-bg { background-color: #3d4848; border-color: #5a6868; }
|
||||||
|
.input-bg:focus { border-color: #ffc700; outline: none; }
|
||||||
|
.btn-primary { background-color: #ffc700; color: #2d3838; }
|
||||||
|
.btn-primary:hover { background-color: #e6b300; }
|
||||||
|
.btn-secondary { background-color: #5a6868; color: #fff; }
|
||||||
|
.btn-secondary:hover { background-color: #6a7878; }
|
||||||
|
.step-active { background-color: #ffc700; color: #2d3838; }
|
||||||
|
.step-done { background-color: #22c55e; color: white; }
|
||||||
|
.step-pending { background-color: #5a6868; color: #999; }
|
||||||
|
</style>
|
||||||
|
{% block head %}{% endblock %}
|
||||||
|
</head>
|
||||||
|
<body class="text-gray-100 min-h-screen">
|
||||||
|
<div class="max-w-4xl mx-auto px-4 py-8">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="text-center mb-8">
|
||||||
|
<img src="/static/logo.png" alt="Logo" class="h-12 w-auto mx-auto mb-4">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Progress Steps -->
|
||||||
|
{% if steps %}
|
||||||
|
<div class="mb-8">
|
||||||
|
<div class="flex items-center justify-center">
|
||||||
|
{% for step in steps %}
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div class="flex items-center justify-center w-8 h-8 rounded-full text-sm font-medium
|
||||||
|
{% if step.status == 'done' %}step-done
|
||||||
|
{% elif step.status == 'active' %}step-active
|
||||||
|
{% else %}step-pending{% endif %}">
|
||||||
|
{% if step.status == 'done' %}
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
|
||||||
|
</svg>
|
||||||
|
{% else %}
|
||||||
|
{{ loop.index }}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<span class="ml-2 text-sm {% if step.status == 'active' %}text-white{% else %}text-gray-500{% endif %}">
|
||||||
|
{{ step.name }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{% if not loop.last %}
|
||||||
|
<div class="w-12 h-0.5 mx-2 {% if step.status == 'done' %}bg-green-500{% else %}bg-gray-600{% endif %}"></div>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Content Card -->
|
||||||
|
<div class="card-bg rounded-xl border p-8">
|
||||||
|
{% block content %}{% endblock %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% block scripts %}{% endblock %}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
193
src/web/templates/user/onboarding/categorize.html
Normal file
193
src/web/templates/user/onboarding/categorize.html
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
{% extends "onboarding/base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Posts kategorisieren{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="text-center mb-8">
|
||||||
|
<h1 class="text-2xl font-bold text-white mb-2">Posts kategorisieren</h1>
|
||||||
|
<p class="text-gray-400">Wir ordnen deine Posts automatisch den Post-Typen zu.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if error %}
|
||||||
|
<div class="bg-red-900/50 border border-red-500 text-red-200 px-4 py-3 rounded-lg mb-6">
|
||||||
|
{{ error }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Classification Progress -->
|
||||||
|
<div id="classification-section" class="mb-8">
|
||||||
|
<div class="card-bg border border-gray-600 rounded-lg p-6">
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<h3 class="text-lg font-medium text-white">Kategorisierung</h3>
|
||||||
|
<div id="classification-status" class="text-sm">
|
||||||
|
{% if classification_complete %}
|
||||||
|
<span class="text-green-400">Abgeschlossen</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-yellow-400">Ausstehend</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Progress Bar -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<div class="h-2 bg-gray-600 rounded-full overflow-hidden">
|
||||||
|
<div id="progress-bar" class="h-full bg-brand-highlight transition-all duration-300"
|
||||||
|
style="width: {{ progress }}%"></div>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between text-xs text-gray-400 mt-1">
|
||||||
|
<span id="progress-text">{{ classified_count }}/{{ total_posts }} Posts</span>
|
||||||
|
<span id="progress-percent">{{ progress }}%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if not classification_complete %}
|
||||||
|
<button id="start-classification-btn"
|
||||||
|
class="btn-primary py-2 px-4 rounded-lg transition-colors flex items-center gap-2">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"/>
|
||||||
|
</svg>
|
||||||
|
Automatisch kategorisieren
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Post Type Distribution -->
|
||||||
|
<div class="mb-8">
|
||||||
|
<h3 class="text-lg font-medium text-white mb-4">Verteilung nach Post-Typ</h3>
|
||||||
|
|
||||||
|
<div class="space-y-3" id="type-distribution">
|
||||||
|
{% for post_type in post_types %}
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<div class="flex-1">
|
||||||
|
<div class="flex justify-between text-sm mb-1">
|
||||||
|
<span class="text-white">{{ post_type.name }}</span>
|
||||||
|
<span class="text-gray-400">{{ post_type.count }} Posts</span>
|
||||||
|
</div>
|
||||||
|
<div class="h-2 bg-gray-600 rounded-full overflow-hidden">
|
||||||
|
<div class="h-full bg-brand-highlight"
|
||||||
|
style="width: {{ (post_type.count / total_posts * 100) if total_posts > 0 else 0 }}%"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
{% if uncategorized_count > 0 %}
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<div class="flex-1">
|
||||||
|
<div class="flex justify-between text-sm mb-1">
|
||||||
|
<span class="text-gray-400">Nicht kategorisiert</span>
|
||||||
|
<span class="text-gray-400">{{ uncategorized_count }} Posts</span>
|
||||||
|
</div>
|
||||||
|
<div class="h-2 bg-gray-600 rounded-full overflow-hidden">
|
||||||
|
<div class="h-full bg-gray-500"
|
||||||
|
style="width: {{ (uncategorized_count / total_posts * 100) if total_posts > 0 else 0 }}%"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Manual Review Section -->
|
||||||
|
{% if uncategorized_posts and uncategorized_posts|length > 0 %}
|
||||||
|
<div class="mb-8">
|
||||||
|
<h3 class="text-lg font-medium text-white mb-4">Manuelle Nachkategorisierung</h3>
|
||||||
|
<p class="text-sm text-gray-400 mb-4">Diese Posts konnten nicht automatisch kategorisiert werden:</p>
|
||||||
|
|
||||||
|
<div class="space-y-4" id="manual-review-list">
|
||||||
|
{% for post in uncategorized_posts[:5] %}
|
||||||
|
<div class="bg-brand-bg border border-gray-600 rounded-lg p-4">
|
||||||
|
<p class="text-sm text-gray-300 mb-3 line-clamp-3">{{ post.post_text[:300] }}{% if post.post_text|length > 300 %}...{% endif %}</p>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
{% for pt in post_types %}
|
||||||
|
<button type="button"
|
||||||
|
class="text-xs px-3 py-1 rounded-full border border-gray-500 text-gray-300 hover:border-brand-highlight hover:text-brand-highlight transition-colors"
|
||||||
|
onclick="categorizePost('{{ post.id }}', '{{ pt.id }}')">
|
||||||
|
{{ pt.name }}
|
||||||
|
</button>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Navigation -->
|
||||||
|
<form method="POST" action="/onboarding/categorize">
|
||||||
|
<div class="flex justify-between pt-6 border-t border-gray-600">
|
||||||
|
<a href="/onboarding/post-types" class="btn-secondary font-medium py-3 px-6 rounded-lg transition-colors">
|
||||||
|
Zurück
|
||||||
|
</a>
|
||||||
|
<button type="submit" class="btn-primary font-medium py-3 px-8 rounded-lg transition-colors"
|
||||||
|
{% if not classification_complete and classified_count < 3 %}disabled{% endif %}>
|
||||||
|
Weiter
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script>
|
||||||
|
const userId = "{{ user_id }}";
|
||||||
|
|
||||||
|
{% if not classification_complete %}
|
||||||
|
document.getElementById('start-classification-btn').addEventListener('click', async function() {
|
||||||
|
const btn = this;
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.innerHTML = `
|
||||||
|
<svg class="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||||
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||||
|
</svg>
|
||||||
|
Kategorisiere...
|
||||||
|
`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/onboarding/classify-posts', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({user_id: userId})
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
location.reload();
|
||||||
|
} else {
|
||||||
|
alert('Fehler: ' + (data.error || 'Unbekannter Fehler'));
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.innerHTML = `
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"/>
|
||||||
|
</svg>
|
||||||
|
Automatisch kategorisieren
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
alert('Fehler bei der Kategorisierung');
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
async function categorizePost(postId, postTypeId) {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/onboarding/categorize-post', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({post_id: postId, post_type_id: postTypeId})
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
location.reload();
|
||||||
|
} else {
|
||||||
|
alert('Fehler: ' + (data.error || 'Unbekannter Fehler'));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
alert('Fehler bei der Kategorisierung');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
77
src/web/templates/user/onboarding/company.html
Normal file
77
src/web/templates/user/onboarding/company.html
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
{% extends "onboarding/base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Unternehmensdaten{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="text-center mb-8">
|
||||||
|
<h1 class="text-2xl font-bold text-white mb-2">Unternehmensdaten</h1>
|
||||||
|
<p class="text-gray-400">Erzähl uns mehr über dein Unternehmen.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if error %}
|
||||||
|
<div class="bg-red-900/50 border border-red-500 text-red-200 px-4 py-3 rounded-lg mb-6">
|
||||||
|
{{ error }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<form method="POST" action="/onboarding/company" class="space-y-6">
|
||||||
|
<!-- Company Name -->
|
||||||
|
<div>
|
||||||
|
<label for="name" class="block text-sm font-medium text-gray-300 mb-1">
|
||||||
|
Unternehmensname *
|
||||||
|
</label>
|
||||||
|
<input type="text" id="name" name="name" required
|
||||||
|
class="w-full input-bg border rounded-lg px-4 py-3 text-white"
|
||||||
|
placeholder="Dein Unternehmen GmbH"
|
||||||
|
value="{{ company.name if company else '' }}">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Description -->
|
||||||
|
<div>
|
||||||
|
<label for="description" class="block text-sm font-medium text-gray-300 mb-1">
|
||||||
|
Beschreibung
|
||||||
|
</label>
|
||||||
|
<textarea id="description" name="description" rows="3"
|
||||||
|
class="w-full input-bg border rounded-lg px-4 py-3 text-white"
|
||||||
|
placeholder="Was macht dein Unternehmen?">{{ company.description if company else '' }}</textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Website & Industry -->
|
||||||
|
<div class="grid md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label for="website" class="block text-sm font-medium text-gray-300 mb-1">
|
||||||
|
Website
|
||||||
|
</label>
|
||||||
|
<input type="url" id="website" name="website"
|
||||||
|
class="w-full input-bg border rounded-lg px-4 py-3 text-white"
|
||||||
|
placeholder="https://dein-unternehmen.de"
|
||||||
|
value="{{ company.website if company else '' }}">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="industry" class="block text-sm font-medium text-gray-300 mb-1">
|
||||||
|
Branche
|
||||||
|
</label>
|
||||||
|
<select id="industry" name="industry"
|
||||||
|
class="w-full input-bg border rounded-lg px-4 py-3 text-white">
|
||||||
|
<option value="">Bitte wählen...</option>
|
||||||
|
<option value="technology" {% if company and company.industry == 'technology' %}selected{% endif %}>Technologie</option>
|
||||||
|
<option value="finance" {% if company and company.industry == 'finance' %}selected{% endif %}>Finanzen</option>
|
||||||
|
<option value="healthcare" {% if company and company.industry == 'healthcare' %}selected{% endif %}>Gesundheitswesen</option>
|
||||||
|
<option value="education" {% if company and company.industry == 'education' %}selected{% endif %}>Bildung</option>
|
||||||
|
<option value="retail" {% if company and company.industry == 'retail' %}selected{% endif %}>Einzelhandel</option>
|
||||||
|
<option value="manufacturing" {% if company and company.industry == 'manufacturing' %}selected{% endif %}>Produktion</option>
|
||||||
|
<option value="consulting" {% if company and company.industry == 'consulting' %}selected{% endif %}>Beratung</option>
|
||||||
|
<option value="marketing" {% if company and company.industry == 'marketing' %}selected{% endif %}>Marketing & Werbung</option>
|
||||||
|
<option value="real_estate" {% if company and company.industry == 'real_estate' %}selected{% endif %}>Immobilien</option>
|
||||||
|
<option value="other" {% if company and company.industry == 'other' %}selected{% endif %}>Sonstiges</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-end pt-4">
|
||||||
|
<button type="submit" class="btn-primary font-medium py-3 px-8 rounded-lg transition-colors">
|
||||||
|
Weiter zur Strategie
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{% endblock %}
|
||||||
104
src/web/templates/user/onboarding/complete.html
Normal file
104
src/web/templates/user/onboarding/complete.html
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
{% extends "onboarding/base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Setup abgeschlossen{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="text-center">
|
||||||
|
<!-- Success Icon -->
|
||||||
|
<div class="w-20 h-20 bg-green-500/20 rounded-full flex items-center justify-center mx-auto mb-6">
|
||||||
|
<svg class="w-10 h-10 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 class="text-2xl font-bold text-white mb-2">Setup abgeschlossen!</h1>
|
||||||
|
<p class="text-gray-400 mb-8">Dein Profil wurde erfolgreich eingerichtet. Du kannst jetzt mit dem Erstellen von Posts beginnen.</p>
|
||||||
|
|
||||||
|
<!-- Summary -->
|
||||||
|
<div class="bg-brand-bg border border-gray-600 rounded-lg p-6 mb-8 text-left">
|
||||||
|
<h3 class="text-lg font-medium text-white mb-4">Zusammenfassung</h3>
|
||||||
|
|
||||||
|
<div class="space-y-3 text-sm">
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<span class="text-gray-400">LinkedIn-Profil</span>
|
||||||
|
<span class="text-white">{{ profile.linkedin_url }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<span class="text-gray-400">Posts analysiert</span>
|
||||||
|
<span class="text-white">{{ posts_count }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<span class="text-gray-400">Post-Typen definiert</span>
|
||||||
|
<span class="text-white">{{ post_types_count }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<span class="text-gray-400">Profil-Analyse</span>
|
||||||
|
{% if profile_analysis %}
|
||||||
|
<span class="text-green-400">Abgeschlossen</span>
|
||||||
|
{% elif analysis_started %}
|
||||||
|
<span class="text-brand-highlight animate-pulse">Läuft im Hintergrund...</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-gray-500">Ausstehend</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if analysis_started %}
|
||||||
|
<!-- Analysis Running Info -->
|
||||||
|
<div class="bg-brand-highlight/10 border border-brand-highlight/30 rounded-lg p-4 mb-8 text-left">
|
||||||
|
<div class="flex items-start gap-3">
|
||||||
|
<svg class="w-5 h-5 text-brand-highlight mt-0.5 animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
|
||||||
|
</svg>
|
||||||
|
<div>
|
||||||
|
<h4 class="font-medium text-brand-highlight">Analyse läuft im Hintergrund</h4>
|
||||||
|
<p class="text-sm text-gray-400 mt-1">
|
||||||
|
Wir analysieren jetzt dein Profil, kategorisieren deine Posts und analysieren deine Post-Typen.
|
||||||
|
Du kannst das Dashboard bereits nutzen - die Fortschrittsanzeige siehst du unten rechts.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Next Steps -->
|
||||||
|
<div class="grid md:grid-cols-3 gap-4 mb-8">
|
||||||
|
<div class="bg-brand-bg border border-gray-600 rounded-lg p-4">
|
||||||
|
<div class="w-10 h-10 bg-brand-highlight/20 rounded-lg flex items-center justify-center mb-3 mx-auto">
|
||||||
|
<svg class="w-5 h-5 text-brand-highlight" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h4 class="font-medium text-white mb-1">Topics recherchieren</h4>
|
||||||
|
<p class="text-xs text-gray-400">Finde relevante Themen für deine nächsten Posts</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-brand-bg border border-gray-600 rounded-lg p-4">
|
||||||
|
<div class="w-10 h-10 bg-brand-highlight/20 rounded-lg flex items-center justify-center mb-3 mx-auto">
|
||||||
|
<svg class="w-5 h-5 text-brand-highlight" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h4 class="font-medium text-white mb-1">Post erstellen</h4>
|
||||||
|
<p class="text-xs text-gray-400">Schreibe deinen ersten KI-unterstützten Post</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-brand-bg border border-gray-600 rounded-lg p-4">
|
||||||
|
<div class="w-10 h-10 bg-brand-highlight/20 rounded-lg flex items-center justify-center mb-3 mx-auto">
|
||||||
|
<svg class="w-5 h-5 text-brand-highlight" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/>
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h4 class="font-medium text-white mb-1">Einstellungen</h4>
|
||||||
|
<p class="text-xs text-gray-400">Passe dein Profil weiter an</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- CTA -->
|
||||||
|
<a href="/" class="inline-block btn-primary font-medium py-3 px-8 rounded-lg transition-colors">
|
||||||
|
Zum Dashboard
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
229
src/web/templates/user/onboarding/post_types.html
Normal file
229
src/web/templates/user/onboarding/post_types.html
Normal file
@@ -0,0 +1,229 @@
|
|||||||
|
{% extends "onboarding/base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Post-Typen{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="text-center mb-8">
|
||||||
|
<h1 class="text-2xl font-bold text-white mb-2">Post-Typen definieren</h1>
|
||||||
|
<p class="text-gray-400">Definiere die verschiedenen Arten von Posts, die du schreibst.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if error %}
|
||||||
|
<div class="bg-red-900/50 border border-red-500 text-red-200 px-4 py-3 rounded-lg mb-6">
|
||||||
|
{{ error }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Predefined Post Types -->
|
||||||
|
<div class="mb-8">
|
||||||
|
<h3 class="text-lg font-medium text-white mb-4">Vordefinierte Post-Typen</h3>
|
||||||
|
<p class="text-sm text-gray-400 mb-4">Wähle die Post-Typen aus, die zu deinem Content passen:</p>
|
||||||
|
|
||||||
|
<div class="grid md:grid-cols-2 gap-4" id="predefined-types">
|
||||||
|
<label class="flex items-start gap-3 p-4 border border-gray-600 rounded-lg cursor-pointer hover:border-brand-highlight transition-colors">
|
||||||
|
<input type="checkbox" name="predefined_type" value="thought_leadership" class="mt-1">
|
||||||
|
<div>
|
||||||
|
<span class="text-white font-medium">Thought Leadership</span>
|
||||||
|
<p class="text-sm text-gray-400">Brancheninsights, Meinungen, Trends</p>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="flex items-start gap-3 p-4 border border-gray-600 rounded-lg cursor-pointer hover:border-brand-highlight transition-colors">
|
||||||
|
<input type="checkbox" name="predefined_type" value="personal_story" class="mt-1">
|
||||||
|
<div>
|
||||||
|
<span class="text-white font-medium">Personal Story</span>
|
||||||
|
<p class="text-sm text-gray-400">Persönliche Erfahrungen, Learnings</p>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="flex items-start gap-3 p-4 border border-gray-600 rounded-lg cursor-pointer hover:border-brand-highlight transition-colors">
|
||||||
|
<input type="checkbox" name="predefined_type" value="how_to" class="mt-1">
|
||||||
|
<div>
|
||||||
|
<span class="text-white font-medium">How-To / Tutorial</span>
|
||||||
|
<p class="text-sm text-gray-400">Anleitungen, Tipps, Best Practices</p>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="flex items-start gap-3 p-4 border border-gray-600 rounded-lg cursor-pointer hover:border-brand-highlight transition-colors">
|
||||||
|
<input type="checkbox" name="predefined_type" value="news_commentary" class="mt-1">
|
||||||
|
<div>
|
||||||
|
<span class="text-white font-medium">News & Kommentar</span>
|
||||||
|
<p class="text-sm text-gray-400">Aktuelle Nachrichten mit Einordnung</p>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="flex items-start gap-3 p-4 border border-gray-600 rounded-lg cursor-pointer hover:border-brand-highlight transition-colors">
|
||||||
|
<input type="checkbox" name="predefined_type" value="case_study" class="mt-1">
|
||||||
|
<div>
|
||||||
|
<span class="text-white font-medium">Case Study</span>
|
||||||
|
<p class="text-sm text-gray-400">Erfolgsgeschichten, Projektberichte</p>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="flex items-start gap-3 p-4 border border-gray-600 rounded-lg cursor-pointer hover:border-brand-highlight transition-colors">
|
||||||
|
<input type="checkbox" name="predefined_type" value="engagement" class="mt-1">
|
||||||
|
<div>
|
||||||
|
<span class="text-white font-medium">Engagement Post</span>
|
||||||
|
<p class="text-sm text-gray-400">Fragen, Umfragen, Diskussionen</p>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Custom Post Types -->
|
||||||
|
<div class="mb-8">
|
||||||
|
<h3 class="text-lg font-medium text-white mb-4">Eigene Post-Typen</h3>
|
||||||
|
|
||||||
|
<div id="custom-types-list" class="space-y-3 mb-4">
|
||||||
|
<!-- Custom types will be added here -->
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="button" id="add-custom-type-btn"
|
||||||
|
class="btn-secondary py-2 px-4 rounded-lg transition-colors flex items-center gap-2">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/>
|
||||||
|
</svg>
|
||||||
|
Eigenen Typ hinzufügen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Navigation -->
|
||||||
|
<form method="POST" action="/onboarding/post-types" id="post-types-form">
|
||||||
|
<input type="hidden" name="post_types_json" id="post-types-json">
|
||||||
|
|
||||||
|
<div class="flex justify-between pt-6 border-t border-gray-600">
|
||||||
|
<a href="/onboarding/posts" class="btn-secondary font-medium py-3 px-6 rounded-lg transition-colors">
|
||||||
|
Zurück
|
||||||
|
</a>
|
||||||
|
<button type="submit" class="btn-primary font-medium py-3 px-8 rounded-lg transition-colors">
|
||||||
|
Weiter
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<!-- Custom Type Modal -->
|
||||||
|
<div id="custom-type-modal" class="hidden fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||||
|
<div class="card-bg rounded-xl p-6 w-full max-w-md mx-4">
|
||||||
|
<h3 class="text-lg font-medium text-white mb-4">Eigenen Post-Typ erstellen</h3>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-300 mb-1">Name *</label>
|
||||||
|
<input type="text" id="custom-type-name"
|
||||||
|
class="w-full input-bg border rounded-lg px-4 py-2 text-white"
|
||||||
|
placeholder="z.B. Produktvorstellung">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-300 mb-1">Beschreibung</label>
|
||||||
|
<textarea id="custom-type-description" rows="2"
|
||||||
|
class="w-full input-bg border rounded-lg px-4 py-2 text-white"
|
||||||
|
placeholder="Kurze Beschreibung..."></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-300 mb-1">Keywords (kommagetrennt)</label>
|
||||||
|
<input type="text" id="custom-type-keywords"
|
||||||
|
class="w-full input-bg border rounded-lg px-4 py-2 text-white"
|
||||||
|
placeholder="produkt, launch, neu">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-end gap-3 mt-6">
|
||||||
|
<button type="button" onclick="closeCustomTypeModal()"
|
||||||
|
class="btn-secondary py-2 px-4 rounded-lg">Abbrechen</button>
|
||||||
|
<button type="button" onclick="addCustomType()"
|
||||||
|
class="btn-primary py-2 px-4 rounded-lg">Hinzufügen</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script>
|
||||||
|
const customTypes = [];
|
||||||
|
|
||||||
|
// Predefined types mapping
|
||||||
|
const predefinedTypes = {
|
||||||
|
'thought_leadership': {name: 'Thought Leadership', description: 'Brancheninsights, Meinungen, Trends', keywords: ['insight', 'trend', 'meinung']},
|
||||||
|
'personal_story': {name: 'Personal Story', description: 'Persönliche Erfahrungen, Learnings', keywords: ['story', 'erfahrung', 'learning']},
|
||||||
|
'how_to': {name: 'How-To / Tutorial', description: 'Anleitungen, Tipps, Best Practices', keywords: ['howto', 'tipps', 'anleitung']},
|
||||||
|
'news_commentary': {name: 'News & Kommentar', description: 'Aktuelle Nachrichten mit Einordnung', keywords: ['news', 'aktuell', 'kommentar']},
|
||||||
|
'case_study': {name: 'Case Study', description: 'Erfolgsgeschichten, Projektberichte', keywords: ['case', 'projekt', 'erfolg']},
|
||||||
|
'engagement': {name: 'Engagement Post', description: 'Fragen, Umfragen, Diskussionen', keywords: ['frage', 'umfrage', 'diskussion']}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.getElementById('add-custom-type-btn').addEventListener('click', function() {
|
||||||
|
document.getElementById('custom-type-modal').classList.remove('hidden');
|
||||||
|
});
|
||||||
|
|
||||||
|
function closeCustomTypeModal() {
|
||||||
|
document.getElementById('custom-type-modal').classList.add('hidden');
|
||||||
|
document.getElementById('custom-type-name').value = '';
|
||||||
|
document.getElementById('custom-type-description').value = '';
|
||||||
|
document.getElementById('custom-type-keywords').value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function addCustomType() {
|
||||||
|
const name = document.getElementById('custom-type-name').value.trim();
|
||||||
|
const description = document.getElementById('custom-type-description').value.trim();
|
||||||
|
const keywords = document.getElementById('custom-type-keywords').value.split(',').map(k => k.trim()).filter(k => k);
|
||||||
|
|
||||||
|
if (!name) {
|
||||||
|
alert('Name ist erforderlich');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
customTypes.push({name, description, keywords, custom: true});
|
||||||
|
renderCustomTypes();
|
||||||
|
closeCustomTypeModal();
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeCustomType(index) {
|
||||||
|
customTypes.splice(index, 1);
|
||||||
|
renderCustomTypes();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCustomTypes() {
|
||||||
|
const container = document.getElementById('custom-types-list');
|
||||||
|
container.innerHTML = customTypes.map((type, index) => `
|
||||||
|
<div class="flex items-center justify-between p-3 bg-brand-bg border border-gray-600 rounded-lg">
|
||||||
|
<div>
|
||||||
|
<span class="text-white font-medium">${type.name}</span>
|
||||||
|
${type.description ? `<p class="text-sm text-gray-400">${type.description}</p>` : ''}
|
||||||
|
</div>
|
||||||
|
<button type="button" onclick="removeCustomType(${index})" class="text-gray-500 hover:text-red-400">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Form submission
|
||||||
|
document.getElementById('post-types-form').addEventListener('submit', function(e) {
|
||||||
|
// Collect selected predefined types
|
||||||
|
const selected = Array.from(document.querySelectorAll('input[name="predefined_type"]:checked'))
|
||||||
|
.map(cb => {
|
||||||
|
const def = predefinedTypes[cb.value];
|
||||||
|
return {
|
||||||
|
name: def.name,
|
||||||
|
description: def.description,
|
||||||
|
identifying_keywords: def.keywords,
|
||||||
|
custom: false
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add custom types
|
||||||
|
const allTypes = [...selected, ...customTypes.map(t => ({
|
||||||
|
name: t.name,
|
||||||
|
description: t.description,
|
||||||
|
identifying_keywords: t.keywords,
|
||||||
|
custom: true
|
||||||
|
}))];
|
||||||
|
|
||||||
|
document.getElementById('post-types-json').value = JSON.stringify(allTypes);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
226
src/web/templates/user/onboarding/posts.html
Normal file
226
src/web/templates/user/onboarding/posts.html
Normal file
@@ -0,0 +1,226 @@
|
|||||||
|
{% extends "onboarding/base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Posts analysieren{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="text-center mb-8">
|
||||||
|
<h1 class="text-2xl font-bold text-white mb-2">Posts analysieren</h1>
|
||||||
|
<p class="text-gray-400">Wir analysieren die bisherigen LinkedIn-Posts deines Kunden, um den Schreibstil zu lernen.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if error %}
|
||||||
|
<div class="bg-red-900/50 border border-red-500 text-red-200 px-4 py-3 rounded-lg mb-6">
|
||||||
|
{{ error }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Scraping Status -->
|
||||||
|
<div id="scraping-section" class="mb-8">
|
||||||
|
<div class="card-bg border border-gray-600 rounded-lg p-6">
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<div>
|
||||||
|
<h3 class="text-lg font-medium text-white">LinkedIn Posts von {{ profile.display_name }}</h3>
|
||||||
|
<p class="text-sm text-gray-400">Profil: {{ profile.linkedin_url }}</p>
|
||||||
|
</div>
|
||||||
|
<div id="post-count" class="text-2xl font-bold text-brand-highlight">
|
||||||
|
<span id="post-count-number">{{ scraped_posts_count }}</span> Posts
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Scraping in Progress Notice -->
|
||||||
|
<div id="scraping-progress" class="{% if not scraping_in_progress %}hidden{% endif %} bg-brand-highlight/10 border border-brand-highlight/30 rounded-lg p-4 mb-4">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<svg class="w-5 h-5 text-brand-highlight animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
|
||||||
|
</svg>
|
||||||
|
<div>
|
||||||
|
<p class="text-brand-highlight font-medium">Posts werden im Hintergrund geladen...</p>
|
||||||
|
<p class="text-sm text-gray-400">Du kannst fortfahren sobald genug Posts vorhanden sind.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if scraped_posts_count < 10 %}
|
||||||
|
<div id="low-posts-warning" class="bg-yellow-900/30 border border-yellow-600 rounded-lg p-4 mb-4">
|
||||||
|
<div class="flex items-start gap-3">
|
||||||
|
<svg class="w-5 h-5 text-yellow-500 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/>
|
||||||
|
</svg>
|
||||||
|
<div>
|
||||||
|
<p class="text-yellow-200 font-medium">Zu wenige Posts gefunden</p>
|
||||||
|
<p class="text-yellow-300/70 text-sm">Für eine gute Stilanalyse brauchen wir mindestens 10 Posts. Du kannst entweder manuell Beispiel-Posts hinzufügen oder warten bis das Scraping fertig ist.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<button id="rescrape-btn" class="btn-secondary py-2 px-4 rounded-lg transition-colors flex items-center gap-2">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
|
||||||
|
</svg>
|
||||||
|
<span>Posts neu laden</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Manual Posts Section -->
|
||||||
|
<div id="manual-posts-section" class="{% if scraped_posts_count >= 10 %}hidden{% endif %} mb-8">
|
||||||
|
<h3 class="text-lg font-medium text-white mb-4">Beispiel-Posts manuell hinzufügen</h3>
|
||||||
|
|
||||||
|
<form id="manual-post-form" class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<textarea id="manual-post-text" rows="6"
|
||||||
|
class="w-full input-bg border rounded-lg px-4 py-3 text-white"
|
||||||
|
placeholder="Füge hier einen LinkedIn-Post deines Kunden ein..."></textarea>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn-secondary py-2 px-4 rounded-lg transition-colors">
|
||||||
|
Post hinzufügen
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<!-- Added Manual Posts -->
|
||||||
|
<div id="manual-posts-list" class="mt-4 space-y-2">
|
||||||
|
{% for post in example_posts %}
|
||||||
|
<div class="bg-brand-bg border border-gray-600 rounded-lg p-3 flex justify-between items-start">
|
||||||
|
<p class="text-sm text-gray-300 line-clamp-2">{{ post.post_text[:200] }}{% if post.post_text|length > 200 %}...{% endif %}</p>
|
||||||
|
<button class="text-gray-500 hover:text-red-400 ml-2" onclick="removeManualPost('{{ post.id }}', this.parentElement)">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Navigation -->
|
||||||
|
<div class="flex justify-between pt-8 border-t border-gray-600">
|
||||||
|
<a href="/onboarding/profile" class="text-gray-400 hover:text-white transition-colors flex items-center gap-2">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 17l-5-5m0 0l5-5m-5 5h12"/>
|
||||||
|
</svg>
|
||||||
|
Zurück
|
||||||
|
</a>
|
||||||
|
<form method="POST" action="/onboarding/posts">
|
||||||
|
<button type="submit" id="continue-btn" class="btn-primary font-medium py-3 px-8 rounded-lg transition-colors flex items-center gap-2"
|
||||||
|
{% if scraped_posts_count < 5 %}disabled{% endif %}>
|
||||||
|
Weiter
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7l5 5m0 0l-5 5m5-5H6"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
async function removeManualPost(postId, element) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/onboarding/remove-manual-post/${postId}`, {method: 'DELETE'});
|
||||||
|
if (response.ok) {
|
||||||
|
if (element) {
|
||||||
|
element.remove();
|
||||||
|
} else {
|
||||||
|
// Find and remove the element by post ID
|
||||||
|
const btn = document.querySelector(`[onclick*="${postId}"]`);
|
||||||
|
if (btn) btn.closest('.bg-brand-bg').remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error removing manual post:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const postCountEl = document.getElementById('post-count-number');
|
||||||
|
const progressEl = document.getElementById('scraping-progress');
|
||||||
|
const warningEl = document.getElementById('low-posts-warning');
|
||||||
|
const manualSection = document.getElementById('manual-posts-section');
|
||||||
|
const continueBtn = document.getElementById('continue-btn');
|
||||||
|
const rescrapeBtn = document.getElementById('rescrape-btn');
|
||||||
|
|
||||||
|
// Poll for post count updates
|
||||||
|
let pollInterval = setInterval(async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/posts-count');
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
postCountEl.textContent = data.count;
|
||||||
|
|
||||||
|
// Update UI based on count
|
||||||
|
if (data.count >= 10) {
|
||||||
|
if (warningEl) warningEl.classList.add('hidden');
|
||||||
|
if (manualSection) manualSection.classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.count >= 5) {
|
||||||
|
continueBtn.disabled = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if scraping is still in progress
|
||||||
|
if (!data.scraping_active) {
|
||||||
|
progressEl.classList.add('hidden');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error polling post count:', e);
|
||||||
|
}
|
||||||
|
}, 3000);
|
||||||
|
|
||||||
|
// Manual post form
|
||||||
|
const form = document.getElementById('manual-post-form');
|
||||||
|
form.addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const textarea = document.getElementById('manual-post-text');
|
||||||
|
const text = textarea.value.trim();
|
||||||
|
|
||||||
|
if (!text) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/onboarding/add-manual-post', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({post_text: text})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
textarea.value = '';
|
||||||
|
|
||||||
|
// Add to list
|
||||||
|
const list = document.getElementById('manual-posts-list');
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.className = 'bg-brand-bg border border-gray-600 rounded-lg p-3 flex justify-between items-start';
|
||||||
|
div.innerHTML = `
|
||||||
|
<p class="text-sm text-gray-300 line-clamp-2">${text.substring(0, 200)}${text.length > 200 ? '...' : ''}</p>
|
||||||
|
<button class="text-gray-500 hover:text-red-400 ml-2" onclick="removeManualPost('${data.id}', this.parentElement)">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
`;
|
||||||
|
list.appendChild(div);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error adding manual post:', e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Rescrape button
|
||||||
|
rescrapeBtn.addEventListener('click', async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/onboarding/rescrape', {method: 'POST'});
|
||||||
|
if (response.ok) {
|
||||||
|
progressEl.classList.remove('hidden');
|
||||||
|
showToast('Scraping gestartet...');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error starting rescrape:', e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Cleanup on leave
|
||||||
|
window.addEventListener('beforeunload', () => {
|
||||||
|
clearInterval(pollInterval);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
130
src/web/templates/user/onboarding/profile.html
Normal file
130
src/web/templates/user/onboarding/profile.html
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
{% extends "onboarding/base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Profil einrichten{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="text-center mb-8">
|
||||||
|
<h1 class="text-2xl font-bold text-white mb-2">Profil einrichten</h1>
|
||||||
|
<p class="text-gray-400">Richte dein Ghostwriter-Profil ein und gib die Daten deines Kunden an.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if error %}
|
||||||
|
<div class="bg-red-900/50 border border-red-500 text-red-200 px-4 py-3 rounded-lg mb-6">
|
||||||
|
{{ error }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<form method="POST" action="/onboarding/profile" class="space-y-8">
|
||||||
|
<!-- Section: Ghostwriter (You) -->
|
||||||
|
<div class="bg-brand-bg border border-gray-600 rounded-xl p-6">
|
||||||
|
<h2 class="text-lg font-semibold text-white mb-4 flex items-center gap-2">
|
||||||
|
<svg class="w-5 h-5 text-brand-highlight" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"/>
|
||||||
|
</svg>
|
||||||
|
Deine Daten (Ghostwriter)
|
||||||
|
</h2>
|
||||||
|
<p class="text-gray-400 text-sm mb-4">Diese Informationen werden in deinem Dashboard angezeigt.</p>
|
||||||
|
|
||||||
|
<div class="grid md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label for="ghostwriter_name" class="block text-sm font-medium text-gray-300 mb-1">
|
||||||
|
Dein Name *
|
||||||
|
</label>
|
||||||
|
<input type="text" id="ghostwriter_name" name="ghostwriter_name" required
|
||||||
|
class="w-full input-bg border rounded-lg px-4 py-3 text-white"
|
||||||
|
placeholder="Max Mustermann"
|
||||||
|
value="{{ prefill.ghostwriter_name or session.linkedin_name or '' }}">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="creator_email" class="block text-sm font-medium text-gray-300 mb-1">
|
||||||
|
Deine E-Mail
|
||||||
|
</label>
|
||||||
|
<input type="email" id="creator_email" name="creator_email"
|
||||||
|
class="w-full input-bg border rounded-lg px-4 py-3 text-white"
|
||||||
|
placeholder="ghostwriter@email.de"
|
||||||
|
value="{{ prefill.creator_email or session.email or '' }}">
|
||||||
|
<p class="text-xs text-gray-500 mt-1">Du wirst über Entscheidungen benachrichtigt</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Section: Client/Customer -->
|
||||||
|
<div class="bg-brand-bg border border-gray-600 rounded-xl p-6">
|
||||||
|
<h2 class="text-lg font-semibold text-white mb-4 flex items-center gap-2">
|
||||||
|
<svg class="w-5 h-5 text-brand-highlight" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z"/>
|
||||||
|
</svg>
|
||||||
|
Kunden-Daten (für wen du schreibst)
|
||||||
|
</h2>
|
||||||
|
<p class="text-gray-400 text-sm mb-4">Die Person/Marke, für die du LinkedIn-Posts erstellst.</p>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="grid md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label for="customer_name" class="block text-sm font-medium text-gray-300 mb-1">
|
||||||
|
Kunden-Name *
|
||||||
|
</label>
|
||||||
|
<input type="text" id="customer_name" name="customer_name" required
|
||||||
|
class="w-full input-bg border rounded-lg px-4 py-3 text-white"
|
||||||
|
placeholder="Lisa Kundin"
|
||||||
|
value="{{ prefill.customer_name or '' }}">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="customer_email" class="block text-sm font-medium text-gray-300 mb-1">
|
||||||
|
Kunden E-Mail
|
||||||
|
</label>
|
||||||
|
<input type="email" id="customer_email" name="customer_email"
|
||||||
|
class="w-full input-bg border rounded-lg px-4 py-3 text-white"
|
||||||
|
placeholder="kunde@email.de"
|
||||||
|
value="{{ prefill.customer_email or '' }}">
|
||||||
|
<p class="text-xs text-gray-500 mt-1">Genehmigt Posts per E-Mail (optional)</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="linkedin_url" class="block text-sm font-medium text-gray-300 mb-1">
|
||||||
|
LinkedIn-Profil des Kunden *
|
||||||
|
</label>
|
||||||
|
<input type="url" id="linkedin_url" name="linkedin_url" required
|
||||||
|
class="w-full input-bg border rounded-lg px-4 py-3 text-white"
|
||||||
|
placeholder="https://linkedin.com/in/kunde-name"
|
||||||
|
value="{{ prefill.linkedin_url or '' }}">
|
||||||
|
<p class="text-xs text-gray-500 mt-1">Wir analysieren die bisherigen Posts, um den Schreibstil zu lernen</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="company_name" class="block text-sm font-medium text-gray-300 mb-1">
|
||||||
|
Firma des Kunden (optional)
|
||||||
|
</label>
|
||||||
|
<input type="text" id="company_name" name="company_name"
|
||||||
|
class="w-full input-bg border rounded-lg px-4 py-3 text-white"
|
||||||
|
placeholder="Kunden GmbH"
|
||||||
|
value="{{ prefill.company_name or '' }}">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Section: Writing Style Notes -->
|
||||||
|
<div class="bg-brand-bg border border-gray-600 rounded-xl p-6">
|
||||||
|
<h2 class="text-lg font-semibold text-white mb-4 flex items-center gap-2">
|
||||||
|
<svg class="w-5 h-5 text-brand-highlight" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/>
|
||||||
|
</svg>
|
||||||
|
Schreibstil-Notizen (optional)
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<textarea id="writing_style_notes" name="writing_style_notes" rows="4"
|
||||||
|
class="w-full input-bg border rounded-lg px-4 py-3 text-white"
|
||||||
|
placeholder="Besondere Hinweise zum Schreibstil des Kunden, z.B. 'Duzt immer', 'Nutzt oft Emojis', 'Formeller Ton', etc.">{{ prefill.writing_style_notes or '' }}</textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-end pt-4">
|
||||||
|
<button type="submit" class="btn-primary font-medium py-3 px-8 rounded-lg transition-colors flex items-center gap-2">
|
||||||
|
Weiter
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7l5 5m0 0l-5 5m5-5H6"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{% endblock %}
|
||||||
93
src/web/templates/user/onboarding/profile_employee.html
Normal file
93
src/web/templates/user/onboarding/profile_employee.html
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
{% extends "onboarding/base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Profil einrichten{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="text-center mb-8">
|
||||||
|
<h1 class="text-2xl font-bold text-white mb-2">Profil einrichten</h1>
|
||||||
|
<p class="text-gray-400">Richte dein LinkedIn-Profil ein, um personalisierte Posts zu erstellen.</p>
|
||||||
|
{% if session.company_name %}
|
||||||
|
<p class="text-brand-highlight text-sm mt-2">Du wirst als Mitarbeiter von {{ session.company_name }} registriert.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if error %}
|
||||||
|
<div class="bg-red-900/50 border border-red-500 text-red-200 px-4 py-3 rounded-lg mb-6">
|
||||||
|
{{ error }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<form method="POST" action="/onboarding/profile" class="space-y-8">
|
||||||
|
<!-- Hidden field to indicate employee flow -->
|
||||||
|
<input type="hidden" name="is_employee" value="true">
|
||||||
|
|
||||||
|
<!-- Section: Your Profile -->
|
||||||
|
<div class="bg-brand-bg border border-gray-600 rounded-xl p-6">
|
||||||
|
<h2 class="text-lg font-semibold text-white mb-4 flex items-center gap-2">
|
||||||
|
<svg class="w-5 h-5 text-brand-highlight" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"/>
|
||||||
|
</svg>
|
||||||
|
Deine Daten
|
||||||
|
</h2>
|
||||||
|
<p class="text-gray-400 text-sm mb-4">Diese Informationen werden in deinem Dashboard angezeigt.</p>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="grid md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label for="name" class="block text-sm font-medium text-gray-300 mb-1">
|
||||||
|
Dein Name *
|
||||||
|
</label>
|
||||||
|
<input type="text" id="name" name="name" required
|
||||||
|
class="w-full input-bg border rounded-lg px-4 py-3 text-white"
|
||||||
|
placeholder="Max Mustermann"
|
||||||
|
value="{{ prefill.name or session.linkedin_name or '' }}">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="email" class="block text-sm font-medium text-gray-300 mb-1">
|
||||||
|
Deine E-Mail
|
||||||
|
</label>
|
||||||
|
<input type="email" id="email" name="email"
|
||||||
|
class="w-full input-bg border rounded-lg px-4 py-3 text-white"
|
||||||
|
placeholder="deine@email.de"
|
||||||
|
value="{{ prefill.email or session.email or '' }}">
|
||||||
|
<p class="text-xs text-gray-500 mt-1">Du wirst benachrichtigt, wenn Posts bereit sind</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="linkedin_url" class="block text-sm font-medium text-gray-300 mb-1">
|
||||||
|
Dein LinkedIn-Profil *
|
||||||
|
</label>
|
||||||
|
<input type="url" id="linkedin_url" name="linkedin_url" required
|
||||||
|
class="w-full input-bg border rounded-lg px-4 py-3 text-white"
|
||||||
|
placeholder="https://linkedin.com/in/dein-name"
|
||||||
|
value="{{ prefill.linkedin_url or '' }}">
|
||||||
|
<p class="text-xs text-gray-500 mt-1">Wir analysieren deine bisherigen Posts, um deinen Schreibstil zu lernen</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Section: Writing Style Notes -->
|
||||||
|
<div class="bg-brand-bg border border-gray-600 rounded-xl p-6">
|
||||||
|
<h2 class="text-lg font-semibold text-white mb-4 flex items-center gap-2">
|
||||||
|
<svg class="w-5 h-5 text-brand-highlight" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/>
|
||||||
|
</svg>
|
||||||
|
Schreibstil-Notizen (optional)
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<textarea id="writing_style_notes" name="writing_style_notes" rows="4"
|
||||||
|
class="w-full input-bg border rounded-lg px-4 py-3 text-white"
|
||||||
|
placeholder="Besondere Hinweise zu deinem Schreibstil, z.B. 'Ich duze immer', 'Nutze oft Emojis', 'Formeller Ton', etc.">{{ prefill.writing_style_notes or '' }}</textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-end pt-4">
|
||||||
|
<button type="submit" class="btn-primary font-medium py-3 px-8 rounded-lg transition-colors flex items-center gap-2">
|
||||||
|
Weiter
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7l5 5m0 0l-5 5m5-5H6"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{% endblock %}
|
||||||
277
src/web/templates/user/onboarding/strategy.html
Normal file
277
src/web/templates/user/onboarding/strategy.html
Normal file
@@ -0,0 +1,277 @@
|
|||||||
|
{% extends "onboarding/base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Unternehmensstrategie{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="text-center mb-8">
|
||||||
|
<h1 class="text-2xl font-bold text-white mb-2">Unternehmensstrategie</h1>
|
||||||
|
<p class="text-gray-400">Definiere die Content-Strategie für dein Unternehmen. Diese Richtlinien werden bei jedem Post berücksichtigt.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if error %}
|
||||||
|
<div class="bg-red-900/50 border border-red-500 text-red-200 px-4 py-3 rounded-lg mb-6">
|
||||||
|
{{ error }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<form method="POST" action="/onboarding/strategy" class="space-y-8">
|
||||||
|
<!-- Mission & Vision -->
|
||||||
|
<div class="space-y-4">
|
||||||
|
<h3 class="text-lg font-medium text-white">Mission & Vision</h3>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="mission" class="block text-sm font-medium text-gray-300 mb-1">
|
||||||
|
Mission
|
||||||
|
</label>
|
||||||
|
<textarea id="mission" name="mission" rows="2"
|
||||||
|
class="w-full input-bg border rounded-lg px-4 py-3 text-white"
|
||||||
|
placeholder="Was ist der Zweck deines Unternehmens?">{{ strategy.mission if strategy else '' }}</textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="vision" class="block text-sm font-medium text-gray-300 mb-1">
|
||||||
|
Vision
|
||||||
|
</label>
|
||||||
|
<textarea id="vision" name="vision" rows="2"
|
||||||
|
class="w-full input-bg border rounded-lg px-4 py-3 text-white"
|
||||||
|
placeholder="Wo soll dein Unternehmen in 5-10 Jahren stehen?">{{ strategy.vision if strategy else '' }}</textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Brand Voice -->
|
||||||
|
<div class="space-y-4">
|
||||||
|
<h3 class="text-lg font-medium text-white">Brand Voice & Tonalität</h3>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="brand_voice" class="block text-sm font-medium text-gray-300 mb-1">
|
||||||
|
Brand Voice
|
||||||
|
</label>
|
||||||
|
<textarea id="brand_voice" name="brand_voice" rows="2"
|
||||||
|
class="w-full input-bg border rounded-lg px-4 py-3 text-white"
|
||||||
|
placeholder="Wie soll dein Unternehmen klingen? z.B. 'professionell aber nahbar', 'innovativ und zukunftsorientiert'">{{ strategy.brand_voice if strategy else '' }}</textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="tone_guidelines" class="block text-sm font-medium text-gray-300 mb-1">
|
||||||
|
Tonalitäts-Richtlinien
|
||||||
|
</label>
|
||||||
|
<textarea id="tone_guidelines" name="tone_guidelines" rows="3"
|
||||||
|
class="w-full input-bg border rounded-lg px-4 py-3 text-white"
|
||||||
|
placeholder="Spezifische Anweisungen zur Tonalität, z.B. 'Wir duzen unsere Zielgruppe', 'Wir nutzen keine Anglizismen'">{{ strategy.tone_guidelines if strategy else '' }}</textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Content Pillars -->
|
||||||
|
<div class="space-y-4">
|
||||||
|
<h3 class="text-lg font-medium text-white">Content Pillars</h3>
|
||||||
|
<p class="text-sm text-gray-400">Die Hauptthemen, über die dein Unternehmen kommuniziert (max. 5).</p>
|
||||||
|
|
||||||
|
<div id="content-pillars" class="space-y-2">
|
||||||
|
{% if strategy and strategy.content_pillars %}
|
||||||
|
{% for pillar in strategy.content_pillars %}
|
||||||
|
<div class="flex gap-2 pillar-row">
|
||||||
|
<input type="text" name="content_pillar" value="{{ pillar }}"
|
||||||
|
class="flex-1 input-bg border rounded-lg px-4 py-2 text-white"
|
||||||
|
placeholder="z.B. 'Digitale Transformation'">
|
||||||
|
<button type="button" onclick="removePillar(this)"
|
||||||
|
class="text-gray-500 hover:text-red-400 px-2">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% else %}
|
||||||
|
<div class="flex gap-2 pillar-row">
|
||||||
|
<input type="text" name="content_pillar"
|
||||||
|
class="flex-1 input-bg border rounded-lg px-4 py-2 text-white"
|
||||||
|
placeholder="z.B. 'Digitale Transformation'">
|
||||||
|
<button type="button" onclick="removePillar(this)"
|
||||||
|
class="text-gray-500 hover:text-red-400 px-2">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="button" onclick="addPillar()"
|
||||||
|
class="text-sm text-brand-highlight hover:underline flex items-center gap-1">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/>
|
||||||
|
</svg>
|
||||||
|
Weiteren Pillar hinzufügen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Target Audience -->
|
||||||
|
<div>
|
||||||
|
<label for="target_audience" class="block text-sm font-medium text-gray-300 mb-1">
|
||||||
|
Zielgruppe
|
||||||
|
</label>
|
||||||
|
<textarea id="target_audience" name="target_audience" rows="2"
|
||||||
|
class="w-full input-bg border rounded-lg px-4 py-3 text-white"
|
||||||
|
placeholder="Wer ist eure Zielgruppe auf LinkedIn? z.B. 'B2B Entscheider in der DACH-Region, CEOs und CTOs von mittelstaendischen Unternehmen'">{{ strategy.target_audience if strategy else '' }}</textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Do's and Don'ts -->
|
||||||
|
<div class="grid md:grid-cols-2 gap-6">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-300 mb-1">
|
||||||
|
Do's (empfohlen)
|
||||||
|
</label>
|
||||||
|
<div id="dos-list" class="space-y-2 mb-2">
|
||||||
|
{% if strategy and strategy.dos %}
|
||||||
|
{% for item in strategy.dos %}
|
||||||
|
<div class="flex gap-2 do-row">
|
||||||
|
<input type="text" name="do_item" value="{{ item }}"
|
||||||
|
class="flex-1 input-bg border rounded-lg px-3 py-2 text-white text-sm"
|
||||||
|
placeholder="z.B. 'Aktuelle Studien zitieren'">
|
||||||
|
<button type="button" onclick="removeItem(this)"
|
||||||
|
class="text-gray-500 hover:text-red-400">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% else %}
|
||||||
|
<div class="flex gap-2 do-row">
|
||||||
|
<input type="text" name="do_item"
|
||||||
|
class="flex-1 input-bg border rounded-lg px-3 py-2 text-white text-sm"
|
||||||
|
placeholder="z.B. 'Aktuelle Studien zitieren'">
|
||||||
|
<button type="button" onclick="removeItem(this)"
|
||||||
|
class="text-gray-500 hover:text-red-400">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<button type="button" onclick="addDo()"
|
||||||
|
class="text-xs text-brand-highlight hover:underline">+ Hinzufügen</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-300 mb-1">
|
||||||
|
Don'ts (vermeiden)
|
||||||
|
</label>
|
||||||
|
<div id="donts-list" class="space-y-2 mb-2">
|
||||||
|
{% if strategy and strategy.donts %}
|
||||||
|
{% for item in strategy.donts %}
|
||||||
|
<div class="flex gap-2 dont-row">
|
||||||
|
<input type="text" name="dont_item" value="{{ item }}"
|
||||||
|
class="flex-1 input-bg border rounded-lg px-3 py-2 text-white text-sm"
|
||||||
|
placeholder="z.B. 'Keine politischen Themen'">
|
||||||
|
<button type="button" onclick="removeItem(this)"
|
||||||
|
class="text-gray-500 hover:text-red-400">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% else %}
|
||||||
|
<div class="flex gap-2 dont-row">
|
||||||
|
<input type="text" name="dont_item"
|
||||||
|
class="flex-1 input-bg border rounded-lg px-3 py-2 text-white text-sm"
|
||||||
|
placeholder="z.B. 'Keine politischen Themen'">
|
||||||
|
<button type="button" onclick="removeItem(this)"
|
||||||
|
class="text-gray-500 hover:text-red-400">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<button type="button" onclick="addDont()"
|
||||||
|
class="text-xs text-brand-highlight hover:underline">+ Hinzufügen</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-between pt-6 border-t border-gray-600">
|
||||||
|
<a href="/onboarding/company" class="btn-secondary font-medium py-3 px-6 rounded-lg transition-colors">
|
||||||
|
Zurück
|
||||||
|
</a>
|
||||||
|
<button type="submit" class="btn-primary font-medium py-3 px-8 rounded-lg transition-colors">
|
||||||
|
Strategie speichern
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script>
|
||||||
|
function addPillar() {
|
||||||
|
const container = document.getElementById('content-pillars');
|
||||||
|
if (container.querySelectorAll('.pillar-row').length >= 5) {
|
||||||
|
alert('Maximal 5 Content Pillars erlaubt');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.className = 'flex gap-2 pillar-row';
|
||||||
|
row.innerHTML = `
|
||||||
|
<input type="text" name="content_pillar"
|
||||||
|
class="flex-1 input-bg border rounded-lg px-4 py-2 text-white"
|
||||||
|
placeholder="z.B. 'Digitale Transformation'">
|
||||||
|
<button type="button" onclick="removePillar(this)"
|
||||||
|
class="text-gray-500 hover:text-red-400 px-2">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
`;
|
||||||
|
container.appendChild(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
function removePillar(btn) {
|
||||||
|
const container = document.getElementById('content-pillars');
|
||||||
|
if (container.querySelectorAll('.pillar-row').length > 1) {
|
||||||
|
btn.closest('.pillar-row').remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addDo() {
|
||||||
|
const container = document.getElementById('dos-list');
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.className = 'flex gap-2 do-row';
|
||||||
|
row.innerHTML = `
|
||||||
|
<input type="text" name="do_item"
|
||||||
|
class="flex-1 input-bg border rounded-lg px-3 py-2 text-white text-sm"
|
||||||
|
placeholder="z.B. 'Aktuelle Studien zitieren'">
|
||||||
|
<button type="button" onclick="removeItem(this)"
|
||||||
|
class="text-gray-500 hover:text-red-400">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
`;
|
||||||
|
container.appendChild(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
function addDont() {
|
||||||
|
const container = document.getElementById('donts-list');
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.className = 'flex gap-2 dont-row';
|
||||||
|
row.innerHTML = `
|
||||||
|
<input type="text" name="dont_item"
|
||||||
|
class="flex-1 input-bg border rounded-lg px-3 py-2 text-white text-sm"
|
||||||
|
placeholder="z.B. 'Keine politischen Themen'">
|
||||||
|
<button type="button" onclick="removeItem(this)"
|
||||||
|
class="text-gray-500 hover:text-red-400">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
`;
|
||||||
|
container.appendChild(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeItem(btn) {
|
||||||
|
btn.closest('div').remove();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
File diff suppressed because it is too large
Load Diff
352
src/web/templates/user/post_types.html
Normal file
352
src/web/templates/user/post_types.html
Normal file
@@ -0,0 +1,352 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Post-Typen{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="max-w-6xl mx-auto">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex items-center justify-between mb-6">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-bold text-white">Post-Typen</h1>
|
||||||
|
<p class="text-gray-400 mt-1">Verwalte und kategorisiere deine LinkedIn-Posts</p>
|
||||||
|
</div>
|
||||||
|
<button onclick="runAnalysis()" id="analyze-btn" class="btn-primary px-5 py-2.5 rounded-lg font-medium flex items-center gap-2">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/></svg>
|
||||||
|
Analyse starten
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Filter & Search Bar -->
|
||||||
|
<div class="card-bg border rounded-xl p-4 mb-6">
|
||||||
|
<div class="flex flex-col md:flex-row gap-4">
|
||||||
|
<!-- Search -->
|
||||||
|
<div class="flex-1">
|
||||||
|
<div class="relative">
|
||||||
|
<svg class="w-5 h-5 text-gray-400 absolute left-3 top-1/2 -translate-y-1/2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
|
||||||
|
</svg>
|
||||||
|
<input type="text" id="search-input" placeholder="Posts durchsuchen..."
|
||||||
|
class="w-full input-bg border rounded-lg pl-10 pr-4 py-2.5 text-white"
|
||||||
|
oninput="filterPosts()">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Post Type Filter -->
|
||||||
|
<div class="md:w-64">
|
||||||
|
<select id="type-filter" class="w-full input-bg border rounded-lg px-4 py-2.5 text-white" onchange="filterPosts()">
|
||||||
|
<option value="all">Alle Post-Typen</option>
|
||||||
|
<option value="uncategorized">Nicht kategorisiert</option>
|
||||||
|
{% for pt in post_types %}
|
||||||
|
<option value="{{ pt.id }}">{{ pt.name }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Stats Row -->
|
||||||
|
<div class="flex flex-wrap gap-4 mt-4 pt-4 border-t border-gray-600">
|
||||||
|
<div class="text-sm">
|
||||||
|
<span class="text-gray-400">Gesamt:</span>
|
||||||
|
<span class="text-white font-medium ml-1">{{ (uncategorized_posts|length) + (categorized_posts|length) }} Posts</span>
|
||||||
|
</div>
|
||||||
|
<div class="text-sm">
|
||||||
|
<span class="text-gray-400">Kategorisiert:</span>
|
||||||
|
<span class="text-green-400 font-medium ml-1">{{ categorized_posts|length }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="text-sm">
|
||||||
|
<span class="text-gray-400">Nicht kategorisiert:</span>
|
||||||
|
<span class="text-yellow-400 font-medium ml-1">{{ uncategorized_posts|length }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Posts List -->
|
||||||
|
<div class="space-y-3" id="posts-list">
|
||||||
|
{% for post in uncategorized_posts %}
|
||||||
|
<div class="post-item card-bg border rounded-xl overflow-hidden"
|
||||||
|
data-post-id="{{ post.id }}"
|
||||||
|
data-type-id=""
|
||||||
|
data-text="{{ post.post_text|lower }}">
|
||||||
|
<div class="p-4">
|
||||||
|
<div class="flex items-start justify-between gap-4">
|
||||||
|
<!-- Post Content -->
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<div class="flex items-center gap-2 mb-2">
|
||||||
|
<span class="px-2 py-0.5 text-xs rounded-full bg-yellow-900/50 text-yellow-300">
|
||||||
|
Nicht kategorisiert
|
||||||
|
</span>
|
||||||
|
{% if post.post_date %}
|
||||||
|
<span class="text-xs text-gray-500">{{ post.post_date.strftime('%d.%m.%Y') if post.post_date else '' }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<p class="text-gray-300 text-sm post-text line-clamp-3">{{ post.post_text[:300] }}{% if post.post_text|length > 300 %}...{% endif %}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Post Type Selector -->
|
||||||
|
<div class="flex-shrink-0 w-48">
|
||||||
|
<select onchange="categorizePost('{{ post.id }}', this.value)"
|
||||||
|
class="w-full input-bg border rounded-lg px-3 py-2 text-sm text-white">
|
||||||
|
<option value="">Typ wählen...</option>
|
||||||
|
{% for pt in post_types %}
|
||||||
|
<option value="{{ pt.id }}">{{ pt.name }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Expand Button -->
|
||||||
|
<button onclick="toggleExpand(this)" class="text-xs text-gray-500 hover:text-gray-300 mt-2 flex items-center gap-1">
|
||||||
|
<svg class="w-4 h-4 expand-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
|
||||||
|
</svg>
|
||||||
|
<span class="expand-text">Mehr anzeigen</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Full Content (hidden by default) -->
|
||||||
|
<div class="full-content hidden border-t border-gray-600 p-4 bg-brand-bg/30">
|
||||||
|
<p class="text-gray-300 text-sm whitespace-pre-wrap">{{ post.post_text }}</p>
|
||||||
|
{% if post.likes or post.comments %}
|
||||||
|
<div class="flex gap-4 mt-3 text-xs text-gray-500">
|
||||||
|
{% if post.likes %}<span>{{ post.likes }} Likes</span>{% endif %}
|
||||||
|
{% if post.comments %}<span>{{ post.comments }} Kommentare</span>{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
{% for post in categorized_posts %}
|
||||||
|
<div class="post-item card-bg border rounded-xl overflow-hidden"
|
||||||
|
data-post-id="{{ post.id }}"
|
||||||
|
data-type-id="{{ post.post_type_id or '' }}"
|
||||||
|
data-text="{{ post.post_text|lower }}">
|
||||||
|
<div class="p-4">
|
||||||
|
<div class="flex items-start justify-between gap-4">
|
||||||
|
<!-- Post Content -->
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<div class="flex items-center gap-2 mb-2">
|
||||||
|
{% set type_name = "Unbekannt" %}
|
||||||
|
{% for pt in post_types %}
|
||||||
|
{% if pt.id == post.post_type_id %}
|
||||||
|
{% set type_name = pt.name %}
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
<span class="px-2 py-0.5 text-xs rounded-full bg-brand-highlight/20 text-brand-highlight">
|
||||||
|
{{ type_name }}
|
||||||
|
</span>
|
||||||
|
{% if post.post_date %}
|
||||||
|
<span class="text-xs text-gray-500">{{ post.post_date.strftime('%d.%m.%Y') if post.post_date else '' }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<p class="text-gray-300 text-sm post-text line-clamp-3">{{ post.post_text[:300] }}{% if post.post_text|length > 300 %}...{% endif %}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Post Type Selector -->
|
||||||
|
<div class="flex-shrink-0 w-48">
|
||||||
|
<select onchange="categorizePost('{{ post.id }}', this.value)"
|
||||||
|
class="w-full input-bg border rounded-lg px-3 py-2 text-sm text-white">
|
||||||
|
<option value="">Typ ändern...</option>
|
||||||
|
{% for pt in post_types %}
|
||||||
|
<option value="{{ pt.id }}" {% if pt.id == post.post_type_id %}selected{% endif %}>{{ pt.name }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Expand Button -->
|
||||||
|
<button onclick="toggleExpand(this)" class="text-xs text-gray-500 hover:text-gray-300 mt-2 flex items-center gap-1">
|
||||||
|
<svg class="w-4 h-4 expand-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
|
||||||
|
</svg>
|
||||||
|
<span class="expand-text">Mehr anzeigen</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Full Content (hidden by default) -->
|
||||||
|
<div class="full-content hidden border-t border-gray-600 p-4 bg-brand-bg/30">
|
||||||
|
<p class="text-gray-300 text-sm whitespace-pre-wrap">{{ post.post_text }}</p>
|
||||||
|
{% if post.likes or post.comments %}
|
||||||
|
<div class="flex gap-4 mt-3 text-xs text-gray-500">
|
||||||
|
{% if post.likes %}<span>{{ post.likes }} Likes</span>{% endif %}
|
||||||
|
{% if post.comments %}<span>{{ post.comments }} Kommentare</span>{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Empty State -->
|
||||||
|
{% if not uncategorized_posts and not categorized_posts %}
|
||||||
|
<div class="card-bg border rounded-xl p-12 text-center">
|
||||||
|
<svg class="w-16 h-16 text-gray-600 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
|
||||||
|
</svg>
|
||||||
|
<h3 class="text-xl font-semibold text-white mb-2">Keine Posts gefunden</h3>
|
||||||
|
<p class="text-gray-400">Es wurden noch keine LinkedIn-Posts geladen.</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- No Results State (hidden by default) -->
|
||||||
|
<div id="no-results" class="hidden card-bg border rounded-xl p-12 text-center">
|
||||||
|
<svg class="w-16 h-16 text-gray-600 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
|
||||||
|
</svg>
|
||||||
|
<h3 class="text-xl font-semibold text-white mb-2">Keine Ergebnisse</h3>
|
||||||
|
<p class="text-gray-400">Keine Posts gefunden, die deinen Filterkriterien entsprechen.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function toggleExpand(btn) {
|
||||||
|
const card = btn.closest('.post-item');
|
||||||
|
const fullContent = card.querySelector('.full-content');
|
||||||
|
const icon = btn.querySelector('.expand-icon');
|
||||||
|
const text = btn.querySelector('.expand-text');
|
||||||
|
|
||||||
|
if (fullContent.classList.contains('hidden')) {
|
||||||
|
fullContent.classList.remove('hidden');
|
||||||
|
icon.style.transform = 'rotate(180deg)';
|
||||||
|
text.textContent = 'Weniger anzeigen';
|
||||||
|
card.querySelector('.post-text').classList.remove('line-clamp-3');
|
||||||
|
} else {
|
||||||
|
fullContent.classList.add('hidden');
|
||||||
|
icon.style.transform = '';
|
||||||
|
text.textContent = 'Mehr anzeigen';
|
||||||
|
card.querySelector('.post-text').classList.add('line-clamp-3');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterPosts() {
|
||||||
|
const searchTerm = document.getElementById('search-input').value.toLowerCase();
|
||||||
|
const typeFilter = document.getElementById('type-filter').value;
|
||||||
|
const posts = document.querySelectorAll('.post-item');
|
||||||
|
let visibleCount = 0;
|
||||||
|
|
||||||
|
posts.forEach(post => {
|
||||||
|
const text = post.dataset.text;
|
||||||
|
const typeId = post.dataset.typeId;
|
||||||
|
|
||||||
|
const matchesSearch = !searchTerm || text.includes(searchTerm);
|
||||||
|
let matchesType = true;
|
||||||
|
|
||||||
|
if (typeFilter === 'uncategorized') {
|
||||||
|
matchesType = !typeId;
|
||||||
|
} else if (typeFilter !== 'all') {
|
||||||
|
matchesType = typeId === typeFilter;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matchesSearch && matchesType) {
|
||||||
|
post.classList.remove('hidden');
|
||||||
|
visibleCount++;
|
||||||
|
} else {
|
||||||
|
post.classList.add('hidden');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Show/hide no results message
|
||||||
|
const noResults = document.getElementById('no-results');
|
||||||
|
if (visibleCount === 0 && posts.length > 0) {
|
||||||
|
noResults.classList.remove('hidden');
|
||||||
|
} else {
|
||||||
|
noResults.classList.add('hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function categorizePost(postId, postTypeId) {
|
||||||
|
if (!postTypeId) return;
|
||||||
|
|
||||||
|
const postEl = document.querySelector(`[data-post-id="${postId}"]`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/categorize-post', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({post_id: postId, post_type_id: postTypeId})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
// Update the badge
|
||||||
|
const badge = postEl.querySelector('.px-2.py-0\\.5');
|
||||||
|
const select = postEl.querySelector('select');
|
||||||
|
const selectedOption = select.options[select.selectedIndex];
|
||||||
|
|
||||||
|
badge.textContent = selectedOption.text;
|
||||||
|
badge.className = 'px-2 py-0.5 text-xs rounded-full bg-brand-highlight/20 text-brand-highlight';
|
||||||
|
|
||||||
|
// Update data attribute
|
||||||
|
postEl.dataset.typeId = postTypeId;
|
||||||
|
|
||||||
|
// Show success toast
|
||||||
|
if (window.showToast) {
|
||||||
|
showToast('Post kategorisiert!', 'success');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error categorizing post:', error);
|
||||||
|
if (window.showToast) {
|
||||||
|
showToast('Fehler beim Kategorisieren', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runAnalysis() {
|
||||||
|
const btn = document.getElementById('analyze-btn');
|
||||||
|
const originalHTML = btn.innerHTML;
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.innerHTML = '<svg class="w-5 h-5 animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/></svg> Analysiere...';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/run-post-type-analysis', {
|
||||||
|
method: 'POST'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
if (window.showToast) {
|
||||||
|
showToast('Post-Typ-Analyse gestartet!');
|
||||||
|
}
|
||||||
|
// Listen for job completion via SSE, then reload
|
||||||
|
if (data.job_id) {
|
||||||
|
const evtSource = new EventSource('/api/job-updates');
|
||||||
|
evtSource.onmessage = function(event) {
|
||||||
|
try {
|
||||||
|
const jobData = JSON.parse(event.data);
|
||||||
|
if (jobData.id === data.job_id) {
|
||||||
|
if (jobData.status === 'completed' || jobData.status === 'failed') {
|
||||||
|
evtSource.close();
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.innerHTML = originalHTML;
|
||||||
|
if (jobData.status === 'completed') {
|
||||||
|
window.location.reload();
|
||||||
|
} else {
|
||||||
|
if (window.showToast) showToast('Analyse fehlgeschlagen', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error parsing SSE data:', e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
evtSource.onerror = function() {
|
||||||
|
evtSource.close();
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.innerHTML = originalHTML;
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.innerHTML = originalHTML;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.innerHTML = originalHTML;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error starting analysis:', error);
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.innerHTML = originalHTML;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -1,39 +1,175 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% block title %}Meine Posts - LinkedIn Posts{% endblock %}
|
{% block title %}Meine Posts - LinkedIn Posts{% endblock %}
|
||||||
|
|
||||||
|
{% macro render_post_card(post) %}
|
||||||
|
<div class="post-card"
|
||||||
|
draggable="true"
|
||||||
|
data-post-id="{{ post.id }}"
|
||||||
|
ondragstart="handleDragStart(event)"
|
||||||
|
ondragend="handleDragEnd(event)"
|
||||||
|
onclick="window.location.href='/posts/{{ post.id }}'">
|
||||||
|
<div class="flex items-start justify-between gap-2 mb-2">
|
||||||
|
<h4 class="post-card-title">{{ post.topic_title or 'Untitled' }}</h4>
|
||||||
|
{% if post.critic_feedback and post.critic_feedback | length > 0 %}
|
||||||
|
{% set score = post.critic_feedback[-1].overall_score %}
|
||||||
|
<span class="score-badge flex-shrink-0 {{ 'score-high' if score >= 85 else 'score-medium' if score >= 70 else 'score-low' }}">
|
||||||
|
{{ score }}
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="post-card-meta">
|
||||||
|
<span class="flex items-center gap-1">
|
||||||
|
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/></svg>
|
||||||
|
{{ post.created_at.strftime('%d.%m.%Y') if post.created_at else 'N/A' }}
|
||||||
|
</span>
|
||||||
|
<span class="flex items-center gap-1">
|
||||||
|
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/></svg>
|
||||||
|
{{ post.iterations }}x
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{% if post.post_content %}
|
||||||
|
<p class="post-card-preview">{{ post.post_content[:150] }}{% if post.post_content | length > 150 %}...{% endif %}</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endmacro %}
|
||||||
|
|
||||||
{% block head %}
|
{% block head %}
|
||||||
<style>
|
<style>
|
||||||
.post-card {
|
.kanban-board {
|
||||||
background: linear-gradient(135deg, rgba(61, 72, 72, 0.3) 0%, rgba(45, 56, 56, 0.4) 100%);
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 1.5rem;
|
||||||
|
min-height: calc(100vh - 250px);
|
||||||
|
}
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
.kanban-board {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.kanban-column {
|
||||||
|
background: rgba(45, 56, 56, 0.3);
|
||||||
border: 1px solid rgba(61, 72, 72, 0.6);
|
border: 1px solid rgba(61, 72, 72, 0.6);
|
||||||
|
border-radius: 1rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 400px;
|
||||||
|
}
|
||||||
|
.kanban-header {
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
border-bottom: 1px solid rgba(61, 72, 72, 0.6);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
.kanban-header h3 {
|
||||||
|
font-weight: 600;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
.kanban-count {
|
||||||
|
background: rgba(61, 72, 72, 0.8);
|
||||||
|
padding: 0.125rem 0.5rem;
|
||||||
|
border-radius: 9999px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.kanban-body {
|
||||||
|
flex: 1;
|
||||||
|
padding: 1rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
min-height: 100px;
|
||||||
|
}
|
||||||
|
.kanban-body.drag-over {
|
||||||
|
background: rgba(255, 199, 0, 0.05);
|
||||||
|
border: 2px dashed rgba(255, 199, 0, 0.3);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
margin: 0.5rem;
|
||||||
|
}
|
||||||
|
.post-card {
|
||||||
|
background: linear-gradient(135deg, rgba(61, 72, 72, 0.5) 0%, rgba(45, 56, 56, 0.6) 100%);
|
||||||
|
border: 1px solid rgba(61, 72, 72, 0.8);
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
padding: 1rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
cursor: grab;
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
}
|
}
|
||||||
.post-card:hover {
|
.post-card:hover {
|
||||||
border-color: rgba(255, 199, 0, 0.3);
|
border-color: rgba(255, 199, 0, 0.4);
|
||||||
transform: translateY(-1px);
|
transform: translateY(-2px);
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
|
||||||
}
|
}
|
||||||
.score-ring {
|
.post-card.dragging {
|
||||||
width: 44px;
|
opacity: 0.5;
|
||||||
height: 44px;
|
cursor: grabbing;
|
||||||
border-radius: 50%;
|
}
|
||||||
|
.post-card-title {
|
||||||
|
font-weight: 500;
|
||||||
|
color: white;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.post-card-meta {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
gap: 0.75rem;
|
||||||
font-weight: 700;
|
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
.post-card-preview {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #9ca3af;
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
padding-top: 0.75rem;
|
||||||
|
border-top: 1px solid rgba(61, 72, 72, 0.6);
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 3;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
.score-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
padding: 0.125rem 0.5rem;
|
||||||
|
border-radius: 9999px;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.score-high { background: rgba(34, 197, 94, 0.2); color: #86efac; }
|
||||||
|
.score-medium { background: rgba(234, 179, 8, 0.2); color: #fde047; }
|
||||||
|
.score-low { background: rgba(239, 68, 68, 0.2); color: #fca5a5; }
|
||||||
|
.column-draft .kanban-header { border-left: 3px solid #f59e0b; }
|
||||||
|
.column-approved .kanban-header { border-left: 3px solid #3b82f6; }
|
||||||
|
.column-ready .kanban-header { border-left: 3px solid #22c55e; }
|
||||||
|
.empty-column {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 2rem;
|
||||||
|
color: #6b7280;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.empty-column svg {
|
||||||
|
width: 3rem;
|
||||||
|
height: 3rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
opacity: 0.5;
|
||||||
}
|
}
|
||||||
.score-high { background: rgba(34, 197, 94, 0.2); border: 2px solid rgba(34, 197, 94, 0.5); color: #86efac; }
|
|
||||||
.score-medium { background: rgba(234, 179, 8, 0.2); border: 2px solid rgba(234, 179, 8, 0.5); color: #fde047; }
|
|
||||||
.score-low { background: rgba(239, 68, 68, 0.2); border: 2px solid rgba(239, 68, 68, 0.5); color: #fca5a5; }
|
|
||||||
</style>
|
</style>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="mb-8 flex items-center justify-between">
|
<div class="mb-6 flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-3xl font-bold text-white mb-2">Meine Posts</h1>
|
<h1 class="text-2xl font-bold text-white mb-1">Meine Posts</h1>
|
||||||
<p class="text-gray-400">{{ total_posts }} generierte Posts</p>
|
<p class="text-gray-400 text-sm">Ziehe Posts zwischen den Spalten um den Status zu ändern</p>
|
||||||
</div>
|
</div>
|
||||||
<a href="/create" class="px-4 py-2.5 btn-primary rounded-lg font-medium flex items-center gap-2">
|
<a href="/create" class="px-4 py-2.5 btn-primary rounded-lg font-medium flex items-center gap-2">
|
||||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"/></svg>
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"/></svg>
|
||||||
@@ -47,59 +183,73 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if posts %}
|
<div class="kanban-board">
|
||||||
<div class="card-bg rounded-xl border overflow-hidden">
|
<!-- Column: Vorschlag (draft) -->
|
||||||
<div class="p-4">
|
<div class="kanban-column column-draft">
|
||||||
<div class="grid gap-3">
|
<div class="kanban-header">
|
||||||
{% for post in posts %}
|
<h3 class="text-yellow-400">
|
||||||
<a href="/posts/{{ post.id }}" class="post-card rounded-xl p-4 block group">
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"/></svg>
|
||||||
<div class="flex items-center gap-4">
|
Vorschlag
|
||||||
<!-- Score Circle -->
|
</h3>
|
||||||
{% if post.critic_feedback and post.critic_feedback | length > 0 %}
|
<span class="kanban-count" id="count-draft">{{ posts | selectattr('status', 'equalto', 'draft') | list | length }}</span>
|
||||||
{% set score = post.critic_feedback[-1].overall_score %}
|
</div>
|
||||||
<div class="score-ring flex-shrink-0 {{ 'score-high' if score >= 85 else 'score-medium' if score >= 70 else 'score-low' }}">
|
<div class="kanban-body" data-status="draft" ondragover="handleDragOver(event)" ondrop="handleDrop(event)" ondragleave="handleDragLeave(event)">
|
||||||
{{ score }}
|
{% for post in posts if post.status == 'draft' %}
|
||||||
</div>
|
{{ render_post_card(post) }}
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="score-ring flex-shrink-0 bg-brand-bg-dark border-2 border-brand-bg-light text-gray-500">
|
<div class="empty-column" id="empty-draft">
|
||||||
—
|
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"/></svg>
|
||||||
</div>
|
<p>Keine Vorschläge</p>
|
||||||
{% endif %}
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Content -->
|
<!-- Column: Bearbeitet (approved) - waiting for customer approval -->
|
||||||
<div class="flex-1 min-w-0">
|
<div class="kanban-column column-approved">
|
||||||
<div class="flex items-start justify-between gap-3">
|
<div class="kanban-header">
|
||||||
<h4 class="font-medium text-white group-hover:text-brand-highlight transition-colors truncate">
|
<h3 class="text-blue-400">
|
||||||
{{ post.topic_title or 'Untitled' }}
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>
|
||||||
</h4>
|
Bearbeitet
|
||||||
<span class="flex-shrink-0 px-2 py-0.5 text-xs rounded font-medium {{ 'bg-green-600/20 text-green-400 border border-green-600/30' if post.status == 'approved' else 'bg-yellow-600/20 text-yellow-400 border border-yellow-600/30' }}">
|
</h3>
|
||||||
{{ post.status | capitalize }}
|
<span class="kanban-count" id="count-approved">{{ posts | selectattr('status', 'equalto', 'approved') | list | length }}</span>
|
||||||
</span>
|
</div>
|
||||||
</div>
|
<div class="kanban-body" data-status="approved" ondragover="handleDragOver(event)" ondrop="handleDrop(event)" ondragleave="handleDragLeave(event)">
|
||||||
<div class="flex items-center gap-4 mt-1.5 text-sm text-gray-500">
|
{% for post in posts if post.status == 'approved' %}
|
||||||
<span class="flex items-center gap-1.5">
|
{{ render_post_card(post) }}
|
||||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/></svg>
|
{% else %}
|
||||||
{{ post.created_at.strftime('%d.%m.%Y') if post.created_at else 'N/A' }}
|
<div class="empty-column" id="empty-approved">
|
||||||
</span>
|
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>
|
||||||
<span class="flex items-center gap-1.5">
|
<p>Keine bearbeiteten Posts</p>
|
||||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/></svg>
|
</div>
|
||||||
{{ post.iterations }} Iteration{{ 's' if post.iterations != 1 else '' }}
|
{% endfor %}
|
||||||
</span>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Arrow -->
|
<!-- Column: Freigegeben (ready) - approved by customer, ready for calendar scheduling -->
|
||||||
<svg class="w-5 h-5 text-gray-600 group-hover:text-brand-highlight transition-colors flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<div class="kanban-column column-ready">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
|
<div class="kanban-header">
|
||||||
</svg>
|
<h3 class="text-green-400">
|
||||||
</div>
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
|
||||||
</a>
|
Freigegeben
|
||||||
|
</h3>
|
||||||
|
<span class="kanban-count" id="count-ready">{{ posts | selectattr('status', 'equalto', 'ready') | list | length }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="kanban-body" data-status="ready" ondragover="handleDragOver(event)" ondrop="handleDrop(event)" ondragleave="handleDragLeave(event)">
|
||||||
|
{% for post in posts if post.status == 'ready' %}
|
||||||
|
{{ render_post_card(post) }}
|
||||||
|
{% else %}
|
||||||
|
<div class="empty-column" id="empty-ready">
|
||||||
|
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
|
||||||
|
<p>Keine freigegebenen Posts</p>
|
||||||
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
|
||||||
<div class="card-bg rounded-xl border p-12 text-center">
|
{% if not posts %}
|
||||||
|
<div class="card-bg rounded-xl border p-12 text-center mt-6">
|
||||||
<div class="w-20 h-20 bg-brand-bg rounded-2xl flex items-center justify-center mx-auto mb-6">
|
<div class="w-20 h-20 bg-brand-bg rounded-2xl flex items-center justify-center mx-auto mb-6">
|
||||||
<svg class="w-10 h-10 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/></svg>
|
<svg class="w-10 h-10 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/></svg>
|
||||||
</div>
|
</div>
|
||||||
@@ -111,4 +261,149 @@
|
|||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script>
|
||||||
|
let draggedElement = null;
|
||||||
|
let sourceStatus = null;
|
||||||
|
|
||||||
|
function handleDragStart(e) {
|
||||||
|
draggedElement = e.target;
|
||||||
|
sourceStatus = e.target.closest('.kanban-body').dataset.status;
|
||||||
|
e.target.classList.add('dragging');
|
||||||
|
e.dataTransfer.effectAllowed = 'move';
|
||||||
|
e.dataTransfer.setData('text/plain', e.target.dataset.postId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDragEnd(e) {
|
||||||
|
e.target.classList.remove('dragging');
|
||||||
|
document.querySelectorAll('.kanban-body').forEach(body => {
|
||||||
|
body.classList.remove('drag-over');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDragOver(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.dataTransfer.dropEffect = 'move';
|
||||||
|
const kanbanBody = e.target.closest('.kanban-body');
|
||||||
|
if (kanbanBody) {
|
||||||
|
kanbanBody.classList.add('drag-over');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDragLeave(e) {
|
||||||
|
const kanbanBody = e.target.closest('.kanban-body');
|
||||||
|
if (kanbanBody && !kanbanBody.contains(e.relatedTarget)) {
|
||||||
|
kanbanBody.classList.remove('drag-over');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDrop(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
const kanbanBody = e.target.closest('.kanban-body');
|
||||||
|
if (!kanbanBody || !draggedElement) return;
|
||||||
|
|
||||||
|
kanbanBody.classList.remove('drag-over');
|
||||||
|
|
||||||
|
const newStatus = kanbanBody.dataset.status;
|
||||||
|
const postId = draggedElement.dataset.postId;
|
||||||
|
|
||||||
|
// Don't do anything if dropped in same column
|
||||||
|
if (sourceStatus === newStatus) return;
|
||||||
|
|
||||||
|
// Remove empty placeholder if exists
|
||||||
|
const emptyPlaceholder = kanbanBody.querySelector('.empty-column');
|
||||||
|
if (emptyPlaceholder) {
|
||||||
|
emptyPlaceholder.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move card to new column
|
||||||
|
kanbanBody.appendChild(draggedElement);
|
||||||
|
|
||||||
|
// Check if source column is now empty
|
||||||
|
const sourceBody = document.querySelector(`.kanban-body[data-status="${sourceStatus}"]`);
|
||||||
|
if (sourceBody && sourceBody.querySelectorAll('.post-card').length === 0) {
|
||||||
|
addEmptyPlaceholder(sourceBody, sourceStatus);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update counts
|
||||||
|
updateCounts();
|
||||||
|
|
||||||
|
// Update status in backend
|
||||||
|
try {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('status', newStatus);
|
||||||
|
|
||||||
|
const response = await fetch(`/api/posts/${postId}/status`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to update status');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating status:', error);
|
||||||
|
showToast('Fehler beim Aktualisieren des Status', 'error');
|
||||||
|
// Revert the move
|
||||||
|
location.reload();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addEmptyPlaceholder(container, status) {
|
||||||
|
const icons = {
|
||||||
|
'draft': '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"/>',
|
||||||
|
'approved': '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/>',
|
||||||
|
'ready': '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>'
|
||||||
|
};
|
||||||
|
const labels = {
|
||||||
|
'draft': 'Keine Vorschläge',
|
||||||
|
'approved': 'Keine bearbeiteten Posts',
|
||||||
|
'ready': 'Keine freigegebenen Posts'
|
||||||
|
};
|
||||||
|
|
||||||
|
const placeholder = document.createElement('div');
|
||||||
|
placeholder.className = 'empty-column';
|
||||||
|
placeholder.id = `empty-${status}`;
|
||||||
|
placeholder.innerHTML = `
|
||||||
|
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">${icons[status]}</svg>
|
||||||
|
<p>${labels[status]}</p>
|
||||||
|
`;
|
||||||
|
container.appendChild(placeholder);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateCounts() {
|
||||||
|
['draft', 'approved', 'ready'].forEach(status => {
|
||||||
|
const count = document.querySelectorAll(`.kanban-body[data-status="${status}"] .post-card`).length;
|
||||||
|
document.getElementById(`count-${status}`).textContent = count;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStatusLabel(status) {
|
||||||
|
const labels = {
|
||||||
|
'draft': 'Vorschlag',
|
||||||
|
'approved': 'Bearbeitet',
|
||||||
|
'ready': 'Freigegeben'
|
||||||
|
};
|
||||||
|
return labels[status] || status;
|
||||||
|
}
|
||||||
|
|
||||||
|
function showToast(message, type = 'info') {
|
||||||
|
const toast = document.createElement('div');
|
||||||
|
toast.className = `fixed bottom-4 right-4 px-4 py-3 rounded-lg shadow-lg z-50 transition-all transform ${
|
||||||
|
type === 'success' ? 'bg-green-600 text-white' :
|
||||||
|
type === 'error' ? 'bg-red-600 text-white' :
|
||||||
|
'bg-brand-bg-light text-white'
|
||||||
|
}`;
|
||||||
|
toast.textContent = message;
|
||||||
|
document.body.appendChild(toast);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
toast.classList.add('opacity-0');
|
||||||
|
setTimeout(() => toast.remove(), 300);
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
97
src/web/templates/user/register.html
Normal file
97
src/web/templates/user/register.html
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Registrierung - LinkedIn Posts</title>
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
<script>
|
||||||
|
tailwind.config = {
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
'brand': {
|
||||||
|
'bg': '#3d4848',
|
||||||
|
'bg-light': '#4a5858',
|
||||||
|
'bg-dark': '#2d3838',
|
||||||
|
'highlight': '#ffc700',
|
||||||
|
'highlight-dark': '#e6b300',
|
||||||
|
},
|
||||||
|
'linkedin': '#0A66C2'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<style>
|
||||||
|
body { background-color: #3d4848; }
|
||||||
|
.card-bg { background-color: #4a5858; border-color: #5a6868; }
|
||||||
|
.btn-primary { background-color: #ffc700; color: #2d3838; }
|
||||||
|
.btn-primary:hover { background-color: #e6b300; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body class="text-gray-100 min-h-screen flex items-center justify-center">
|
||||||
|
<div class="w-full max-w-2xl px-4">
|
||||||
|
<div class="card-bg rounded-xl border p-8">
|
||||||
|
<div class="text-center mb-8">
|
||||||
|
<img src="/static/logo.png" alt="Logo" class="h-16 w-auto mx-auto mb-4">
|
||||||
|
<h1 class="text-2xl font-bold text-white mb-2">Unternehmen registrieren</h1>
|
||||||
|
<p class="text-gray-400">Erstelle ein Konto für dein Unternehmen</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- GHOSTWRITER FEATURE DISABLED -->
|
||||||
|
<!-- To re-enable: Uncomment ghostwriter option below and change grid to md:grid-cols-2 -->
|
||||||
|
<div class="mb-8">
|
||||||
|
<!-- Ghostwriter Option (DISABLED) -->
|
||||||
|
<!--
|
||||||
|
<a href="/register/ghostwriter" class="block p-6 rounded-xl border border-gray-600 hover:border-brand-highlight transition-colors group">
|
||||||
|
<div class="w-12 h-12 bg-brand-highlight/20 rounded-lg flex items-center justify-center mb-4 group-hover:bg-brand-highlight/30">
|
||||||
|
<svg class="w-6 h-6 text-brand-highlight" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h3 class="text-lg font-semibold text-white mb-2">Ghostwriter</h3>
|
||||||
|
<p class="text-gray-400 text-sm">
|
||||||
|
Erstelle LinkedIn-Posts fur dich selbst oder fur einen einzelnen Kunden.
|
||||||
|
Ideal fur Freelancer und Content-Creator.
|
||||||
|
</p>
|
||||||
|
<div class="mt-4 text-brand-highlight text-sm font-medium group-hover:underline">
|
||||||
|
Als Ghostwriter starten →
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
-->
|
||||||
|
|
||||||
|
<!-- Company Option -->
|
||||||
|
<a href="/register/company" class="block p-6 rounded-xl border border-brand-highlight bg-brand-highlight/5 transition-colors group max-w-lg mx-auto">
|
||||||
|
<div class="w-12 h-12 bg-brand-highlight/20 rounded-lg flex items-center justify-center mb-4 group-hover:bg-brand-highlight/30">
|
||||||
|
<svg class="w-6 h-6 text-brand-highlight" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h3 class="text-lg font-semibold text-white mb-2">Unternehmen</h3>
|
||||||
|
<p class="text-gray-400 text-sm">
|
||||||
|
Verwalte mehrere Mitarbeiter-Konten mit einer einheitlichen Unternehmensstrategie.
|
||||||
|
Ideal fur Teams und Agenturen.
|
||||||
|
</p>
|
||||||
|
<div class="mt-4 text-brand-highlight text-sm font-medium group-hover:underline">
|
||||||
|
Jetzt registrieren →
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text-center pt-6 border-t border-gray-600">
|
||||||
|
<p class="text-gray-400 text-sm">
|
||||||
|
Du hast bereits ein Konto?
|
||||||
|
<a href="/login" class="text-brand-highlight hover:underline">Anmelden</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-6 text-center">
|
||||||
|
<a href="/admin/login" class="text-gray-500 hover:text-gray-300 text-sm">
|
||||||
|
Admin-Login
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
160
src/web/templates/user/register_company.html
Normal file
160
src/web/templates/user/register_company.html
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Unternehmens Registrierung - LinkedIn Posts</title>
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
<script>
|
||||||
|
tailwind.config = {
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
'brand': {
|
||||||
|
'bg': '#3d4848',
|
||||||
|
'bg-light': '#4a5858',
|
||||||
|
'bg-dark': '#2d3838',
|
||||||
|
'highlight': '#ffc700',
|
||||||
|
'highlight-dark': '#e6b300',
|
||||||
|
},
|
||||||
|
'linkedin': '#0A66C2'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<style>
|
||||||
|
body { background-color: #3d4848; }
|
||||||
|
.btn-linkedin { background-color: #0A66C2; }
|
||||||
|
.btn-linkedin:hover { background-color: #004182; }
|
||||||
|
.card-bg { background-color: #4a5858; border-color: #5a6868; }
|
||||||
|
.input-bg { background-color: #3d4848; border-color: #5a6868; }
|
||||||
|
.input-bg:focus { border-color: #ffc700; outline: none; }
|
||||||
|
.btn-primary { background-color: #ffc700; color: #2d3838; }
|
||||||
|
.btn-primary:hover { background-color: #e6b300; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body class="text-gray-100 min-h-screen flex items-center justify-center">
|
||||||
|
<div class="w-full max-w-md px-4">
|
||||||
|
<div class="card-bg rounded-xl border p-8">
|
||||||
|
<div class="text-center mb-8">
|
||||||
|
<img src="/static/logo.png" alt="Logo" class="h-16 w-auto mx-auto mb-4">
|
||||||
|
<h1 class="text-2xl font-bold text-white mb-2">Unternehmens-Konto</h1>
|
||||||
|
<p class="text-gray-400">Registriere dein Unternehmen</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if error %}
|
||||||
|
<div class="bg-red-900/50 border border-red-500 text-red-200 px-4 py-3 rounded-lg mb-6">
|
||||||
|
{{ error }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- LinkedIn OAuth -->
|
||||||
|
<div class="mb-6">
|
||||||
|
<a href="/auth/linkedin?account_type=company" class="w-full btn-linkedin text-white font-medium py-3 px-4 rounded-lg transition-colors flex items-center justify-center gap-3">
|
||||||
|
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433c-1.144 0-2.063-.926-2.063-2.065 0-1.138.92-2.063 2.063-2.063 1.14 0 2.064.925 2.064 2.063 0 1.139-.925 2.065-2.064 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"/>
|
||||||
|
</svg>
|
||||||
|
Mit LinkedIn registrieren (empfohlen)
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="relative my-6">
|
||||||
|
<div class="absolute inset-0 flex items-center">
|
||||||
|
<div class="w-full border-t border-gray-600"></div>
|
||||||
|
</div>
|
||||||
|
<div class="relative flex justify-center text-sm">
|
||||||
|
<span class="px-4 bg-brand-bg-light text-gray-400">oder mit E-Mail</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Email/Password Form -->
|
||||||
|
<form method="POST" action="/register/company" class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label for="license_key" class="block text-sm font-medium text-gray-300 mb-1">
|
||||||
|
Lizenzschlüssel *
|
||||||
|
<span class="text-xs text-gray-500 font-normal ml-2">
|
||||||
|
(Format: XXXX-XXXX-XXXX-XXXX)
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<input type="text"
|
||||||
|
id="license_key"
|
||||||
|
name="license_key"
|
||||||
|
placeholder="ABCD-EFGH-IJKL-MNOP"
|
||||||
|
pattern="[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}"
|
||||||
|
required
|
||||||
|
class="w-full input-bg border rounded-lg px-4 py-2 text-white placeholder-gray-500"
|
||||||
|
maxlength="19"
|
||||||
|
style="text-transform: uppercase;">
|
||||||
|
<p class="text-xs text-gray-500 mt-1">
|
||||||
|
Bitte gib deinen Lizenzschlüssel ein, um dich zu registrieren.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="company_name" class="block text-sm font-medium text-gray-300 mb-1">Unternehmensname</label>
|
||||||
|
<input type="text" id="company_name" name="company_name" required
|
||||||
|
class="w-full input-bg border rounded-lg px-4 py-2 text-white"
|
||||||
|
placeholder="Dein Unternehmen GmbH">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="email" class="block text-sm font-medium text-gray-300 mb-1">E-Mail</label>
|
||||||
|
<input type="email" id="email" name="email" required
|
||||||
|
class="w-full input-bg border rounded-lg px-4 py-2 text-white"
|
||||||
|
placeholder="admin@unternehmen.de">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="password" class="block text-sm font-medium text-gray-300 mb-1">Passwort</label>
|
||||||
|
<input type="password" id="password" name="password" required minlength="8"
|
||||||
|
class="w-full input-bg border rounded-lg px-4 py-2 text-white"
|
||||||
|
placeholder="Mindestens 8 Zeichen">
|
||||||
|
<p class="text-xs text-gray-500 mt-1">Mind. 8 Zeichen, 1 Großbuchstabe, 1 Zahl</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="password_confirm" class="block text-sm font-medium text-gray-300 mb-1">Passwort bestätigen</label>
|
||||||
|
<input type="password" id="password_confirm" name="password_confirm" required
|
||||||
|
class="w-full input-bg border rounded-lg px-4 py-2 text-white"
|
||||||
|
placeholder="Passwort wiederholen">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="w-full btn-primary font-medium py-3 px-4 rounded-lg transition-colors">
|
||||||
|
Unternehmen registrieren
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="text-center pt-6 mt-6 border-t border-gray-600">
|
||||||
|
<p class="text-gray-400 text-sm">
|
||||||
|
Du hast bereits ein Konto?
|
||||||
|
<a href="/login" class="text-brand-highlight hover:underline">Anmelden</a>
|
||||||
|
</p>
|
||||||
|
<p class="text-gray-500 text-sm mt-2">
|
||||||
|
<a href="/register" class="hover:text-gray-300">← Kontotyp ändern</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// License key uppercase formatting
|
||||||
|
const licenseKeyInput = document.getElementById('license_key');
|
||||||
|
licenseKeyInput.addEventListener('input', (e) => {
|
||||||
|
e.target.value = e.target.value.toUpperCase();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Password confirmation validation
|
||||||
|
const form = document.querySelector('form');
|
||||||
|
const password = document.getElementById('password');
|
||||||
|
const passwordConfirm = document.getElementById('password_confirm');
|
||||||
|
|
||||||
|
form.addEventListener('submit', function(e) {
|
||||||
|
if (password.value !== passwordConfirm.value) {
|
||||||
|
e.preventDefault();
|
||||||
|
alert('Passwörter stimmen nicht überein');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
126
src/web/templates/user/register_ghostwriter.html
Normal file
126
src/web/templates/user/register_ghostwriter.html
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Ghostwriter Registrierung - LinkedIn Posts</title>
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
<script>
|
||||||
|
tailwind.config = {
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
'brand': {
|
||||||
|
'bg': '#3d4848',
|
||||||
|
'bg-light': '#4a5858',
|
||||||
|
'bg-dark': '#2d3838',
|
||||||
|
'highlight': '#ffc700',
|
||||||
|
'highlight-dark': '#e6b300',
|
||||||
|
},
|
||||||
|
'linkedin': '#0A66C2'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<style>
|
||||||
|
body { background-color: #3d4848; }
|
||||||
|
.btn-linkedin { background-color: #0A66C2; }
|
||||||
|
.btn-linkedin:hover { background-color: #004182; }
|
||||||
|
.card-bg { background-color: #4a5858; border-color: #5a6868; }
|
||||||
|
.input-bg { background-color: #3d4848; border-color: #5a6868; }
|
||||||
|
.input-bg:focus { border-color: #ffc700; outline: none; }
|
||||||
|
.btn-primary { background-color: #ffc700; color: #2d3838; }
|
||||||
|
.btn-primary:hover { background-color: #e6b300; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body class="text-gray-100 min-h-screen flex items-center justify-center">
|
||||||
|
<div class="w-full max-w-md px-4">
|
||||||
|
<div class="card-bg rounded-xl border p-8">
|
||||||
|
<div class="text-center mb-8">
|
||||||
|
<img src="/static/logo.png" alt="Logo" class="h-16 w-auto mx-auto mb-4">
|
||||||
|
<h1 class="text-2xl font-bold text-white mb-2">Ghostwriter Konto</h1>
|
||||||
|
<p class="text-gray-400">Erstelle dein persönliches Konto</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if error %}
|
||||||
|
<div class="bg-red-900/50 border border-red-500 text-red-200 px-4 py-3 rounded-lg mb-6">
|
||||||
|
{{ error }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- LinkedIn OAuth -->
|
||||||
|
<div class="mb-6">
|
||||||
|
<a href="/auth/linkedin?account_type=ghostwriter" class="w-full btn-linkedin text-white font-medium py-3 px-4 rounded-lg transition-colors flex items-center justify-center gap-3">
|
||||||
|
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433c-1.144 0-2.063-.926-2.063-2.065 0-1.138.92-2.063 2.063-2.063 1.14 0 2.064.925 2.064 2.063 0 1.139-.925 2.065-2.064 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"/>
|
||||||
|
</svg>
|
||||||
|
Mit LinkedIn registrieren (empfohlen)
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="relative my-6">
|
||||||
|
<div class="absolute inset-0 flex items-center">
|
||||||
|
<div class="w-full border-t border-gray-600"></div>
|
||||||
|
</div>
|
||||||
|
<div class="relative flex justify-center text-sm">
|
||||||
|
<span class="px-4 bg-brand-bg-light text-gray-400">oder mit E-Mail</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Email/Password Form -->
|
||||||
|
<form method="POST" action="/register/ghostwriter" class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label for="email" class="block text-sm font-medium text-gray-300 mb-1">E-Mail</label>
|
||||||
|
<input type="email" id="email" name="email" required
|
||||||
|
class="w-full input-bg border rounded-lg px-4 py-2 text-white"
|
||||||
|
placeholder="deine@email.de">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="password" class="block text-sm font-medium text-gray-300 mb-1">Passwort</label>
|
||||||
|
<input type="password" id="password" name="password" required minlength="8"
|
||||||
|
class="w-full input-bg border rounded-lg px-4 py-2 text-white"
|
||||||
|
placeholder="Mindestens 8 Zeichen">
|
||||||
|
<p class="text-xs text-gray-500 mt-1">Mind. 8 Zeichen, 1 Großbuchstabe, 1 Zahl</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="password_confirm" class="block text-sm font-medium text-gray-300 mb-1">Passwort bestätigen</label>
|
||||||
|
<input type="password" id="password_confirm" name="password_confirm" required
|
||||||
|
class="w-full input-bg border rounded-lg px-4 py-2 text-white"
|
||||||
|
placeholder="Passwort wiederholen">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="w-full btn-primary font-medium py-3 px-4 rounded-lg transition-colors">
|
||||||
|
Konto erstellen
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="text-center pt-6 mt-6 border-t border-gray-600">
|
||||||
|
<p class="text-gray-400 text-sm">
|
||||||
|
Du hast bereits ein Konto?
|
||||||
|
<a href="/login" class="text-brand-highlight hover:underline">Anmelden</a>
|
||||||
|
</p>
|
||||||
|
<p class="text-gray-500 text-sm mt-2">
|
||||||
|
<a href="/register" class="hover:text-gray-300">← Kontotyp ändern</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Password confirmation validation
|
||||||
|
const form = document.querySelector('form');
|
||||||
|
const password = document.getElementById('password');
|
||||||
|
const passwordConfirm = document.getElementById('password_confirm');
|
||||||
|
|
||||||
|
form.addEventListener('submit', function(e) {
|
||||||
|
if (password.value !== passwordConfirm.value) {
|
||||||
|
e.preventDefault();
|
||||||
|
alert('Passwörter stimmen nicht überein');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -7,6 +7,21 @@
|
|||||||
<p class="text-gray-400">Recherchiere neue Content-Themen mit Perplexity AI</p>
|
<p class="text-gray-400">Recherchiere neue Content-Themen mit Perplexity AI</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Limit Warning -->
|
||||||
|
{% if limit_reached %}
|
||||||
|
<div class="bg-red-900/50 border border-red-500 text-red-200 px-6 py-4 rounded-xl mb-8">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<svg class="w-6 h-6 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/>
|
||||||
|
</svg>
|
||||||
|
<div>
|
||||||
|
<strong class="font-semibold">Limit erreicht</strong>
|
||||||
|
<p class="text-sm mt-1">{{ limit_message }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||||
<!-- Left: Form -->
|
<!-- Left: Form -->
|
||||||
<div>
|
<div>
|
||||||
@@ -34,9 +49,10 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button type="submit" id="submitBtn" class="w-full btn-primary font-medium py-3 rounded-lg transition-colors flex items-center justify-center gap-2">
|
<button type="submit" id="submitBtn" {% if limit_reached %}disabled{% endif %}
|
||||||
|
class="w-full btn-primary font-medium py-3 rounded-lg transition-colors flex items-center justify-center gap-2 {% if limit_reached %}opacity-50 cursor-not-allowed{% endif %}">
|
||||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg>
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg>
|
||||||
Research starten
|
{% if limit_reached %}Limit erreicht{% else %}Research starten{% endif %}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
316
src/web/templates/user/settings.html
Normal file
316
src/web/templates/user/settings.html
Normal file
@@ -0,0 +1,316 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Einstellungen{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="max-w-2xl mx-auto">
|
||||||
|
<div class="mb-8">
|
||||||
|
<h1 class="text-2xl font-bold text-white mb-2">Einstellungen</h1>
|
||||||
|
<p class="text-gray-400">Verwalte deine Profil- und Email-Einstellungen</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if error %}
|
||||||
|
<div class="bg-red-900/50 border border-red-500 text-red-200 px-4 py-3 rounded-lg mb-6">
|
||||||
|
<strong>Error:</strong> {{ error }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Email Settings -->
|
||||||
|
<div class="card-bg rounded-xl border p-6 mb-6">
|
||||||
|
<h2 class="text-lg font-semibold text-white mb-4 flex items-center gap-2">
|
||||||
|
<svg class="w-5 h-5 text-brand-highlight" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/>
|
||||||
|
</svg>
|
||||||
|
Email-Benachrichtigungen
|
||||||
|
</h2>
|
||||||
|
<p class="text-sm text-gray-400 mb-6">
|
||||||
|
Konfiguriere die Email-Adressen für den Freigabe-Workflow. Wenn ein Post in "Bearbeitet" verschoben wird,
|
||||||
|
erhält die Kunden-Email einen Link zur Freigabe. Nach der Entscheidung wird die Creator-Email benachrichtigt.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form id="emailSettingsForm" class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-300 mb-2">
|
||||||
|
Creator-Email
|
||||||
|
<span class="text-gray-500 font-normal">(erhält Benachrichtigungen über Entscheidungen)</span>
|
||||||
|
</label>
|
||||||
|
<input type="email"
|
||||||
|
name="creator_email"
|
||||||
|
id="creatorEmail"
|
||||||
|
value="{{ profile.creator_email or '' }}"
|
||||||
|
placeholder="creator@example.com"
|
||||||
|
class="w-full px-4 py-3 bg-brand-bg border border-brand-bg-light rounded-lg text-white placeholder-gray-500 focus:outline-none focus:border-brand-highlight">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-300 mb-2">
|
||||||
|
Kunden-Email
|
||||||
|
<span class="text-gray-500 font-normal">(erhält Posts zur Freigabe)</span>
|
||||||
|
</label>
|
||||||
|
<input type="email"
|
||||||
|
name="customer_email"
|
||||||
|
id="customerEmail"
|
||||||
|
value="{{ profile.customer_email or '' }}"
|
||||||
|
placeholder="kunde@example.com"
|
||||||
|
class="w-full px-4 py-3 bg-brand-bg border border-brand-bg-light rounded-lg text-white placeholder-gray-500 focus:outline-none focus:border-brand-highlight">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pt-4">
|
||||||
|
<button type="submit"
|
||||||
|
id="saveEmailsBtn"
|
||||||
|
class="px-6 py-3 bg-brand-highlight hover:bg-brand-highlight/90 text-brand-bg-dark font-medium rounded-lg transition-colors flex items-center gap-2">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
|
||||||
|
</svg>
|
||||||
|
Speichern
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- LinkedIn Account Connection -->
|
||||||
|
<div class="card-bg rounded-xl border border-gray-700 p-6 mb-6">
|
||||||
|
<h2 class="text-xl font-semibold text-white mb-4 flex items-center gap-2">
|
||||||
|
<svg class="w-5 h-5 text-[#0A66C2]" fill="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433c-1.144 0-2.063-.926-2.063-2.065 0-1.138.92-2.063 2.063-2.063 1.14 0 2.064.925 2.064 2.063 0 1.139-.925 2.065-2.064 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"/>
|
||||||
|
</svg>
|
||||||
|
LinkedIn-Konto verbinden
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{% if linkedin_account %}
|
||||||
|
<!-- Connected State -->
|
||||||
|
<div class="bg-green-900/20 border border-green-600 rounded-lg p-4 mb-4">
|
||||||
|
<div class="flex items-start gap-4">
|
||||||
|
{% if linkedin_account.linkedin_picture %}
|
||||||
|
<img src="{{ linkedin_account.linkedin_picture }}"
|
||||||
|
alt="{{ linkedin_account.linkedin_name }}"
|
||||||
|
class="w-12 h-12 rounded-full border-2 border-green-500">
|
||||||
|
{% endif %}
|
||||||
|
<div class="flex-1">
|
||||||
|
<p class="text-white font-medium flex items-center gap-2">
|
||||||
|
<svg class="w-4 h-4 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
|
||||||
|
</svg>
|
||||||
|
{{ linkedin_account.linkedin_name }}
|
||||||
|
</p>
|
||||||
|
<p class="text-gray-400 text-sm mt-1">
|
||||||
|
Verbunden seit {{ linkedin_account.created_at.strftime('%d.%m.%Y um %H:%M') }} Uhr
|
||||||
|
</p>
|
||||||
|
{% if linkedin_account.last_used_at %}
|
||||||
|
<p class="text-gray-500 text-xs mt-1">
|
||||||
|
Zuletzt verwendet: {{ linkedin_account.last_used_at.strftime('%d.%m.%Y um %H:%M') }} Uhr
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if linkedin_account.last_error %}
|
||||||
|
<div class="bg-yellow-900/20 border border-yellow-600 rounded-lg p-4 mb-4">
|
||||||
|
<div class="flex items-start gap-3">
|
||||||
|
<svg class="w-5 h-5 text-yellow-400 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/>
|
||||||
|
</svg>
|
||||||
|
<div>
|
||||||
|
<p class="text-yellow-200 font-medium">Verbindungsproblem</p>
|
||||||
|
<p class="text-yellow-200/80 text-sm mt-1">
|
||||||
|
Deine LinkedIn-Verbindung funktioniert möglicherweise nicht mehr. Bitte verbinde dein Konto erneut.
|
||||||
|
</p>
|
||||||
|
<p class="text-yellow-600 text-xs mt-2 font-mono">{{ linkedin_account.last_error }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="bg-blue-900/20 border border-blue-600 rounded-lg p-4 mb-4">
|
||||||
|
<p class="text-blue-200 text-sm">
|
||||||
|
<strong>✨ Automatisches Posten aktiviert!</strong><br>
|
||||||
|
Geplante Posts werden automatisch auf deinem LinkedIn-Profil veröffentlicht.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<button onclick="disconnectLinkedIn()"
|
||||||
|
class="px-6 py-3 bg-red-600 hover:bg-red-700 text-white rounded-lg transition-colors flex items-center gap-2">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||||
|
</svg>
|
||||||
|
Verbindung trennen
|
||||||
|
</button>
|
||||||
|
{% else %}
|
||||||
|
<!-- Not Connected State -->
|
||||||
|
<p class="text-gray-400 mb-4">
|
||||||
|
Verbinde dein LinkedIn-Konto, um Posts automatisch zu veröffentlichen.
|
||||||
|
Wenn dein Konto verbunden ist, werden geplante Posts direkt auf dein LinkedIn-Profil gepostet.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="bg-brand-bg-light rounded-lg p-4 mb-4 border border-brand-bg-light">
|
||||||
|
<div class="flex items-start gap-3">
|
||||||
|
<svg class="w-5 h-5 text-brand-highlight flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||||
|
</svg>
|
||||||
|
<div class="text-sm text-gray-400">
|
||||||
|
<p class="font-medium text-white mb-2">Vorteile:</p>
|
||||||
|
<ul class="space-y-1">
|
||||||
|
<li>• Automatische Veröffentlichung zur geplanten Zeit</li>
|
||||||
|
<li>• Keine manuelle Arbeit mehr nötig</li>
|
||||||
|
<li>• Posts mit Bildern werden unterstützt</li>
|
||||||
|
<li>• Du bleibst im Workflow informiert</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a href="/settings/linkedin/connect"
|
||||||
|
class="inline-flex items-center gap-2 px-6 py-3 bg-[#0A66C2] hover:bg-[#004182] text-white rounded-lg transition-colors">
|
||||||
|
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433c-1.144 0-2.063-.926-2.063-2.065 0-1.138.92-2.063 2.063-2.063 1.14 0 2.064.925 2.064 2.063 0 1.139-.925 2.065-2.064 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"/>
|
||||||
|
</svg>
|
||||||
|
Mit LinkedIn verbinden
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Workflow Info -->
|
||||||
|
<div class="card-bg rounded-xl border p-6">
|
||||||
|
<h2 class="text-lg font-semibold text-white mb-4 flex items-center gap-2">
|
||||||
|
<svg class="w-5 h-5 text-brand-highlight" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||||
|
</svg>
|
||||||
|
So funktioniert der Workflow
|
||||||
|
</h2>
|
||||||
|
<div class="space-y-4 text-sm text-gray-400">
|
||||||
|
<div class="flex gap-3">
|
||||||
|
<div class="w-8 h-8 rounded-full bg-yellow-600/20 text-yellow-400 flex items-center justify-center flex-shrink-0 font-semibold">1</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-white font-medium">Vorschlag</p>
|
||||||
|
<p>Neue Posts werden hier erstellt und können bearbeitet werden.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-3">
|
||||||
|
<div class="w-8 h-8 rounded-full bg-blue-600/20 text-blue-400 flex items-center justify-center flex-shrink-0 font-semibold">2</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-white font-medium">Bearbeitet</p>
|
||||||
|
<p>Wenn ein Post hierher verschoben wird, erhält die Kunden-Email eine Freigabe-Anfrage.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-3">
|
||||||
|
<div class="w-8 h-8 rounded-full bg-green-600/20 text-green-400 flex items-center justify-center flex-shrink-0 font-semibold">3</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-white font-medium">Veröffentlicht</p>
|
||||||
|
<p>Nach Freigabe durch den Kunden landet der Post hier und ist bereit für LinkedIn.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script>
|
||||||
|
document.getElementById('emailSettingsForm').addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const btn = document.getElementById('saveEmailsBtn');
|
||||||
|
const originalHTML = btn.innerHTML;
|
||||||
|
btn.innerHTML = '<div class="w-4 h-4 border-2 border-brand-bg-dark border-t-transparent rounded-full animate-spin"></div> Speichern...';
|
||||||
|
btn.disabled = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('creator_email', document.getElementById('creatorEmail').value);
|
||||||
|
formData.append('customer_email', document.getElementById('customerEmail').value);
|
||||||
|
|
||||||
|
const response = await fetch('/api/settings/emails', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Fehler beim Speichern');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show success
|
||||||
|
btn.innerHTML = '<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg> Gespeichert!';
|
||||||
|
btn.classList.remove('bg-brand-highlight');
|
||||||
|
btn.classList.add('bg-green-600');
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
btn.innerHTML = originalHTML;
|
||||||
|
btn.classList.remove('bg-green-600');
|
||||||
|
btn.classList.add('bg-brand-highlight');
|
||||||
|
btn.disabled = false;
|
||||||
|
}, 2000);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error saving settings:', error);
|
||||||
|
btn.innerHTML = 'Fehler!';
|
||||||
|
btn.classList.remove('bg-brand-highlight');
|
||||||
|
btn.classList.add('bg-red-600');
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
btn.innerHTML = originalHTML;
|
||||||
|
btn.classList.remove('bg-red-600');
|
||||||
|
btn.classList.add('bg-brand-highlight');
|
||||||
|
btn.disabled = false;
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// LinkedIn disconnect
|
||||||
|
async function disconnectLinkedIn() {
|
||||||
|
if (!confirm('LinkedIn-Verbindung wirklich trennen?\n\nPosts werden dann nicht mehr automatisch veröffentlicht und du erhältst wieder Email-Benachrichtigungen.')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/settings/linkedin/disconnect', {
|
||||||
|
method: 'POST'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
window.location.reload();
|
||||||
|
} else {
|
||||||
|
alert('Fehler beim Trennen der Verbindung. Bitte versuche es erneut.');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error disconnecting LinkedIn:', error);
|
||||||
|
alert('Fehler: ' + error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show success/error messages from URL params
|
||||||
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
|
if (urlParams.has('success')) {
|
||||||
|
const successMsg = urlParams.get('success');
|
||||||
|
if (successMsg === 'linkedin_connected') {
|
||||||
|
// Show temporary success message
|
||||||
|
const successDiv = document.createElement('div');
|
||||||
|
successDiv.className = 'fixed top-4 right-4 bg-green-600 text-white px-6 py-3 rounded-lg shadow-lg z-50';
|
||||||
|
successDiv.textContent = '✓ LinkedIn-Konto erfolgreich verbunden!';
|
||||||
|
document.body.appendChild(successDiv);
|
||||||
|
setTimeout(() => successDiv.remove(), 3000);
|
||||||
|
|
||||||
|
// Clean URL
|
||||||
|
window.history.replaceState({}, '', window.location.pathname);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (urlParams.has('error')) {
|
||||||
|
const errorMsg = urlParams.get('error');
|
||||||
|
const errorMessages = {
|
||||||
|
'linkedin_auth_failed': 'LinkedIn-Authentifizierung fehlgeschlagen',
|
||||||
|
'invalid_state': 'Sicherheitsprüfung fehlgeschlagen. Bitte versuche es erneut.',
|
||||||
|
'token_exchange_failed': 'Token-Austausch fehlgeschlagen',
|
||||||
|
'userinfo_failed': 'Konnte LinkedIn-Profil nicht abrufen',
|
||||||
|
'connection_failed': 'Verbindung fehlgeschlagen. Bitte versuche es erneut.'
|
||||||
|
};
|
||||||
|
|
||||||
|
const errorDiv = document.createElement('div');
|
||||||
|
errorDiv.className = 'fixed top-4 right-4 bg-red-600 text-white px-6 py-3 rounded-lg shadow-lg z-50';
|
||||||
|
errorDiv.textContent = '✗ ' + (errorMessages[errorMsg] || 'Ein Fehler ist aufgetreten');
|
||||||
|
document.body.appendChild(errorDiv);
|
||||||
|
setTimeout(() => errorDiv.remove(), 5000);
|
||||||
|
|
||||||
|
// Clean URL
|
||||||
|
window.history.replaceState({}, '', window.location.pathname);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -21,14 +21,20 @@
|
|||||||
<div class="flex items-center gap-4">
|
<div class="flex items-center gap-4">
|
||||||
<div class="w-12 h-12 rounded-full flex items-center justify-center overflow-hidden {{ 'bg-brand-highlight' if not profile_picture else '' }}">
|
<div class="w-12 h-12 rounded-full flex items-center justify-center overflow-hidden {{ 'bg-brand-highlight' if not profile_picture else '' }}">
|
||||||
{% if profile_picture %}
|
{% if profile_picture %}
|
||||||
<img src="{{ profile_picture }}" alt="{{ customer.name }}" class="w-full h-full object-cover" loading="lazy" referrerpolicy="no-referrer">
|
<img src="{{ profile_picture }}" alt="{{ profile.display_name }}" class="w-full h-full object-cover" loading="lazy" referrerpolicy="no-referrer">
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="text-brand-bg-dark font-bold text-lg">{{ customer.name[0] | upper }}</span>
|
<span class="text-brand-bg-dark font-bold text-lg">{{ (profile.display_name or 'U')[0] | upper }}</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h3 class="font-semibold text-white text-lg">{{ customer.name }}</h3>
|
<h3 class="font-semibold text-white text-lg">{{ profile.display_name or session.linkedin_name or 'Unbekannt' }}</h3>
|
||||||
<p class="text-sm text-gray-400">{{ customer.company_name or 'Kein Unternehmen' }}</p>
|
<p class="text-sm text-gray-400">
|
||||||
|
{% if session.account_type == 'ghostwriter' %}
|
||||||
|
Ghostwriter
|
||||||
|
{% else %}
|
||||||
|
{{ session.company_name or 'Kein Unternehmen' }}
|
||||||
|
{% endif %}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
|
|||||||
@@ -1,123 +1,35 @@
|
|||||||
"""User authentication with Supabase LinkedIn OAuth."""
|
"""User authentication with Supabase Auth.
|
||||||
import re
|
|
||||||
|
Uses Supabase's built-in authentication for:
|
||||||
|
- Email/password signup and login
|
||||||
|
- LinkedIn OAuth
|
||||||
|
- Session management via JWT tokens
|
||||||
|
"""
|
||||||
import secrets
|
import secrets
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from fastapi import Request, Response
|
from fastapi import Request, Response
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
from supabase import create_client
|
||||||
|
|
||||||
from src.config import settings
|
from src.config import settings
|
||||||
from src.database import db
|
from src.database import db
|
||||||
|
|
||||||
# Session management
|
# Session management - using Supabase JWT tokens stored in cookies
|
||||||
USER_SESSION_COOKIE = "linkedin_user_session"
|
USER_SESSION_COOKIE = "sb_session" # Supabase session cookie
|
||||||
|
REFRESH_TOKEN_COOKIE = "sb_refresh_token"
|
||||||
SESSION_SECRET = settings.session_secret or secrets.token_hex(32)
|
SESSION_SECRET = settings.session_secret or secrets.token_hex(32)
|
||||||
|
|
||||||
|
# Supabase client for auth operations
|
||||||
|
_supabase_client = None
|
||||||
|
|
||||||
def normalize_linkedin_url(url: str) -> str:
|
def get_supabase():
|
||||||
"""Normalize LinkedIn URL for comparison.
|
"""Get or create Supabase client."""
|
||||||
|
global _supabase_client
|
||||||
Extracts the username/vanityName from various LinkedIn URL formats.
|
if _supabase_client is None:
|
||||||
"""
|
_supabase_client = create_client(settings.supabase_url, settings.supabase_key)
|
||||||
if not url:
|
return _supabase_client
|
||||||
return ""
|
|
||||||
# Match linkedin.com/in/username with optional trailing slash or query params
|
|
||||||
match = re.search(r'linkedin\.com/in/([^/?]+)', url.lower())
|
|
||||||
if match:
|
|
||||||
return match.group(1).rstrip('/')
|
|
||||||
return url.lower().strip()
|
|
||||||
|
|
||||||
|
|
||||||
async def get_customer_by_vanity_name(vanity_name: str) -> Optional[dict]:
|
|
||||||
"""Find customer by LinkedIn vanityName.
|
|
||||||
|
|
||||||
Constructs the LinkedIn URL from vanityName and matches against
|
|
||||||
Customer.linkedin_url (normalized).
|
|
||||||
"""
|
|
||||||
if not vanity_name:
|
|
||||||
return None
|
|
||||||
|
|
||||||
normalized_vanity = normalize_linkedin_url(f"https://www.linkedin.com/in/{vanity_name}/")
|
|
||||||
|
|
||||||
# Get all customers and match
|
|
||||||
customers = await db.list_customers()
|
|
||||||
for customer in customers:
|
|
||||||
customer_vanity = normalize_linkedin_url(customer.linkedin_url)
|
|
||||||
if customer_vanity == normalized_vanity:
|
|
||||||
return {
|
|
||||||
"id": str(customer.id),
|
|
||||||
"name": customer.name,
|
|
||||||
"linkedin_url": customer.linkedin_url,
|
|
||||||
"company_name": customer.company_name,
|
|
||||||
"email": customer.email
|
|
||||||
}
|
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
async def get_customer_by_email(email: str) -> Optional[dict]:
|
|
||||||
"""Find customer by email address.
|
|
||||||
|
|
||||||
Fallback matching when LinkedIn vanityName is not available.
|
|
||||||
"""
|
|
||||||
if not email:
|
|
||||||
return None
|
|
||||||
|
|
||||||
email_lower = email.lower().strip()
|
|
||||||
|
|
||||||
# Get all customers and match by email
|
|
||||||
customers = await db.list_customers()
|
|
||||||
for customer in customers:
|
|
||||||
if customer.email and customer.email.lower().strip() == email_lower:
|
|
||||||
return {
|
|
||||||
"id": str(customer.id),
|
|
||||||
"name": customer.name,
|
|
||||||
"linkedin_url": customer.linkedin_url,
|
|
||||||
"company_name": customer.company_name,
|
|
||||||
"email": customer.email
|
|
||||||
}
|
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
async def get_customer_by_name(name: str) -> Optional[dict]:
|
|
||||||
"""Find customer by name.
|
|
||||||
|
|
||||||
Fallback matching when email is not available.
|
|
||||||
Tries exact match first, then case-insensitive.
|
|
||||||
"""
|
|
||||||
if not name:
|
|
||||||
return None
|
|
||||||
|
|
||||||
name_lower = name.lower().strip()
|
|
||||||
|
|
||||||
# Get all customers and match by name
|
|
||||||
customers = await db.list_customers()
|
|
||||||
|
|
||||||
# First try exact match
|
|
||||||
for customer in customers:
|
|
||||||
if customer.name == name:
|
|
||||||
return {
|
|
||||||
"id": str(customer.id),
|
|
||||||
"name": customer.name,
|
|
||||||
"linkedin_url": customer.linkedin_url,
|
|
||||||
"company_name": customer.company_name,
|
|
||||||
"email": customer.email
|
|
||||||
}
|
|
||||||
|
|
||||||
# Then try case-insensitive
|
|
||||||
for customer in customers:
|
|
||||||
if customer.name.lower().strip() == name_lower:
|
|
||||||
return {
|
|
||||||
"id": str(customer.id),
|
|
||||||
"name": customer.name,
|
|
||||||
"linkedin_url": customer.linkedin_url,
|
|
||||||
"company_name": customer.company_name,
|
|
||||||
"email": customer.email
|
|
||||||
}
|
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
class UserSession:
|
class UserSession:
|
||||||
@@ -125,19 +37,47 @@ class UserSession:
|
|||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
customer_id: str,
|
user_id: Optional[str] = None,
|
||||||
customer_name: str,
|
linkedin_vanity_name: str = "",
|
||||||
linkedin_vanity_name: str,
|
|
||||||
linkedin_name: Optional[str] = None,
|
linkedin_name: Optional[str] = None,
|
||||||
linkedin_picture: Optional[str] = None,
|
linkedin_picture: Optional[str] = None,
|
||||||
email: Optional[str] = None
|
email: Optional[str] = None,
|
||||||
|
account_type: str = "ghostwriter",
|
||||||
|
company_id: Optional[str] = None,
|
||||||
|
onboarding_status: str = "completed",
|
||||||
|
company_name: Optional[str] = None,
|
||||||
|
display_name: Optional[str] = None
|
||||||
):
|
):
|
||||||
self.customer_id = customer_id
|
self.user_id = user_id
|
||||||
self.customer_name = customer_name
|
|
||||||
self.linkedin_vanity_name = linkedin_vanity_name
|
self.linkedin_vanity_name = linkedin_vanity_name
|
||||||
self.linkedin_name = linkedin_name
|
self.linkedin_name = linkedin_name
|
||||||
self.linkedin_picture = linkedin_picture
|
self.linkedin_picture = linkedin_picture
|
||||||
self.email = email
|
self.email = email
|
||||||
|
self.account_type = account_type
|
||||||
|
self.company_id = company_id
|
||||||
|
self.onboarding_status = onboarding_status
|
||||||
|
self.company_name = company_name
|
||||||
|
self.display_name = display_name or linkedin_name
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_onboarding_complete(self) -> bool:
|
||||||
|
"""Check if user has completed onboarding."""
|
||||||
|
return self.onboarding_status == "completed"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_company_owner(self) -> bool:
|
||||||
|
"""Check if user is a company owner."""
|
||||||
|
return self.account_type == "company"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_employee(self) -> bool:
|
||||||
|
"""Check if user is an employee."""
|
||||||
|
return self.account_type == "employee"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_ghostwriter(self) -> bool:
|
||||||
|
"""Check if user is a ghostwriter."""
|
||||||
|
return self.account_type == "ghostwriter"
|
||||||
|
|
||||||
def to_cookie_value(self) -> str:
|
def to_cookie_value(self) -> str:
|
||||||
"""Serialize session to cookie value."""
|
"""Serialize session to cookie value."""
|
||||||
@@ -145,12 +85,16 @@ class UserSession:
|
|||||||
import hashlib
|
import hashlib
|
||||||
|
|
||||||
data = {
|
data = {
|
||||||
"customer_id": self.customer_id,
|
"user_id": self.user_id,
|
||||||
"customer_name": self.customer_name,
|
|
||||||
"linkedin_vanity_name": self.linkedin_vanity_name,
|
"linkedin_vanity_name": self.linkedin_vanity_name,
|
||||||
"linkedin_name": self.linkedin_name,
|
"linkedin_name": self.linkedin_name,
|
||||||
"linkedin_picture": self.linkedin_picture,
|
"linkedin_picture": self.linkedin_picture,
|
||||||
"email": self.email
|
"email": self.email,
|
||||||
|
"account_type": self.account_type,
|
||||||
|
"company_id": self.company_id,
|
||||||
|
"onboarding_status": self.onboarding_status,
|
||||||
|
"company_name": self.company_name,
|
||||||
|
"display_name": self.display_name
|
||||||
}
|
}
|
||||||
|
|
||||||
# Create signed cookie value
|
# Create signed cookie value
|
||||||
@@ -184,12 +128,16 @@ class UserSession:
|
|||||||
|
|
||||||
data = json.loads(json_data)
|
data = json.loads(json_data)
|
||||||
return cls(
|
return cls(
|
||||||
customer_id=data["customer_id"],
|
user_id=data.get("user_id"),
|
||||||
customer_name=data["customer_name"],
|
linkedin_vanity_name=data.get("linkedin_vanity_name", ""),
|
||||||
linkedin_vanity_name=data["linkedin_vanity_name"],
|
|
||||||
linkedin_name=data.get("linkedin_name"),
|
linkedin_name=data.get("linkedin_name"),
|
||||||
linkedin_picture=data.get("linkedin_picture"),
|
linkedin_picture=data.get("linkedin_picture"),
|
||||||
email=data.get("email")
|
email=data.get("email"),
|
||||||
|
account_type=data.get("account_type", "ghostwriter"),
|
||||||
|
company_id=data.get("company_id"),
|
||||||
|
onboarding_status=data.get("onboarding_status", "completed"),
|
||||||
|
company_name=data.get("company_name"),
|
||||||
|
display_name=data.get("display_name")
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to parse session cookie: {e}")
|
logger.error(f"Failed to parse session cookie: {e}")
|
||||||
@@ -198,146 +146,354 @@ class UserSession:
|
|||||||
|
|
||||||
def get_user_session(request: Request) -> Optional[UserSession]:
|
def get_user_session(request: Request) -> Optional[UserSession]:
|
||||||
"""Get user session from request cookies."""
|
"""Get user session from request cookies."""
|
||||||
cookie = request.cookies.get(USER_SESSION_COOKIE)
|
# Try legacy cookie first (contains full session data including profile info)
|
||||||
if not cookie:
|
legacy_cookie = request.cookies.get("linkedin_user_session")
|
||||||
return None
|
if legacy_cookie:
|
||||||
return UserSession.from_cookie_value(cookie)
|
session = UserSession.from_cookie_value(legacy_cookie)
|
||||||
|
if session:
|
||||||
|
return session
|
||||||
|
|
||||||
|
# Fallback to Supabase session validation
|
||||||
|
access_token = request.cookies.get(USER_SESSION_COOKIE)
|
||||||
|
if access_token:
|
||||||
|
try:
|
||||||
|
supabase = get_supabase()
|
||||||
|
user_response = supabase.auth.get_user(access_token)
|
||||||
|
if user_response and user_response.user:
|
||||||
|
return _create_session_from_supabase_user(user_response.user)
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"Could not validate Supabase session: {e}")
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def set_user_session(response: Response, session: UserSession) -> None:
|
async def get_user_session_async(request: Request) -> Optional[UserSession]:
|
||||||
"""Set user session cookie."""
|
"""Async version of get_user_session with profile lookup."""
|
||||||
|
session = get_user_session(request)
|
||||||
|
if session and session.user_id:
|
||||||
|
# Fetch additional profile data if needed
|
||||||
|
try:
|
||||||
|
user = await db.get_user(UUID(session.user_id))
|
||||||
|
if user:
|
||||||
|
session.onboarding_status = user.onboarding_status.value if hasattr(user.onboarding_status, 'value') else user.onboarding_status
|
||||||
|
session.account_type = user.account_type.value if hasattr(user.account_type, 'value') else user.account_type
|
||||||
|
session.company_id = str(user.company_id) if user.company_id else None
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Could not fetch profile data: {e}")
|
||||||
|
return session
|
||||||
|
|
||||||
|
|
||||||
|
def _create_session_from_supabase_user(user) -> UserSession:
|
||||||
|
"""Create UserSession from Supabase user object."""
|
||||||
|
user_metadata = user.user_metadata or {}
|
||||||
|
|
||||||
|
return UserSession(
|
||||||
|
user_id=str(user.id),
|
||||||
|
linkedin_vanity_name=user_metadata.get("vanityName", ""),
|
||||||
|
linkedin_name=user_metadata.get("name"),
|
||||||
|
linkedin_picture=user_metadata.get("picture"),
|
||||||
|
email=user.email,
|
||||||
|
account_type=user_metadata.get("account_type", "ghostwriter"),
|
||||||
|
company_id=None, # Will be fetched from profile
|
||||||
|
onboarding_status="pending", # Will be fetched from profile
|
||||||
|
company_name=None,
|
||||||
|
display_name=user_metadata.get("display_name") or user_metadata.get("name")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def set_user_session(response: Response, session: UserSession, access_token: str = None, refresh_token: str = None) -> None:
|
||||||
|
"""Set user session cookies."""
|
||||||
|
if access_token:
|
||||||
|
response.set_cookie(
|
||||||
|
key=USER_SESSION_COOKIE,
|
||||||
|
value=access_token,
|
||||||
|
httponly=True,
|
||||||
|
max_age=60 * 60,
|
||||||
|
samesite="lax",
|
||||||
|
secure=True
|
||||||
|
)
|
||||||
|
|
||||||
|
if refresh_token:
|
||||||
|
response.set_cookie(
|
||||||
|
key=REFRESH_TOKEN_COOKIE,
|
||||||
|
value=refresh_token,
|
||||||
|
httponly=True,
|
||||||
|
max_age=60 * 60 * 24 * 7,
|
||||||
|
samesite="lax",
|
||||||
|
secure=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# Also set legacy cookie for backwards compatibility
|
||||||
response.set_cookie(
|
response.set_cookie(
|
||||||
key=USER_SESSION_COOKIE,
|
key="linkedin_user_session",
|
||||||
value=session.to_cookie_value(),
|
value=session.to_cookie_value(),
|
||||||
httponly=True,
|
httponly=True,
|
||||||
max_age=60 * 60 * 24 * 7, # 7 days
|
max_age=60 * 60 * 24 * 7,
|
||||||
samesite="lax"
|
samesite="lax"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def clear_user_session(response: Response) -> None:
|
def clear_user_session(response: Response) -> None:
|
||||||
"""Clear user session cookie."""
|
"""Clear all session cookies."""
|
||||||
response.delete_cookie(USER_SESSION_COOKIE)
|
response.delete_cookie(USER_SESSION_COOKIE)
|
||||||
|
response.delete_cookie(REFRESH_TOKEN_COOKIE)
|
||||||
|
response.delete_cookie("linkedin_user_session")
|
||||||
|
|
||||||
|
|
||||||
async def handle_oauth_callback(
|
async def handle_oauth_callback(
|
||||||
access_token: str,
|
access_token: str,
|
||||||
refresh_token: Optional[str] = None
|
refresh_token: Optional[str] = None,
|
||||||
) -> Optional[UserSession]:
|
allow_registration: bool = True,
|
||||||
"""Handle OAuth callback from Supabase.
|
account_type: str = "ghostwriter"
|
||||||
|
) -> tuple[Optional[UserSession], Optional[str], Optional[str]]:
|
||||||
|
"""Handle OAuth callback from Supabase Auth.
|
||||||
|
|
||||||
1. Get user info from Supabase using access token
|
Supabase Auth handles the user creation in auth.users automatically.
|
||||||
2. Extract LinkedIn vanityName from user metadata
|
Our trigger creates the profile in the profiles table.
|
||||||
3. Match with Customer record
|
|
||||||
4. Create session if match found
|
|
||||||
|
|
||||||
Returns UserSession if authorized, None if not.
|
Returns:
|
||||||
|
Tuple of (UserSession, access_token, refresh_token) if authorized,
|
||||||
|
(None, None, None) if not.
|
||||||
"""
|
"""
|
||||||
from supabase import create_client
|
from src.database.models import Profile, AccountType, OnboardingStatus
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Create a new client with the user's access token
|
supabase = get_supabase()
|
||||||
supabase = create_client(settings.supabase_url, settings.supabase_key)
|
|
||||||
|
|
||||||
# Get user info using the access token
|
# Get user info using the access token
|
||||||
user_response = supabase.auth.get_user(access_token)
|
user_response = supabase.auth.get_user(access_token)
|
||||||
|
|
||||||
if not user_response or not user_response.user:
|
if not user_response or not user_response.user:
|
||||||
logger.error("Failed to get user from Supabase")
|
logger.error("Failed to get user from Supabase")
|
||||||
return None
|
return None, None, None
|
||||||
|
|
||||||
user = user_response.user
|
user = user_response.user
|
||||||
user_metadata = user.user_metadata or {}
|
user_metadata = user.user_metadata or {}
|
||||||
|
|
||||||
# Debug: Log full response
|
|
||||||
import json
|
import json
|
||||||
logger.info(f"=== FULL OAUTH RESPONSE ===")
|
logger.info(f"=== OAUTH CALLBACK ===")
|
||||||
logger.info(f"user.id: {user.id}")
|
logger.info(f"user.id: {user.id}")
|
||||||
logger.info(f"user.email: {user.email}")
|
logger.info(f"user.email: {user.email}")
|
||||||
logger.info(f"user.phone: {user.phone}")
|
|
||||||
logger.info(f"user.app_metadata: {json.dumps(user.app_metadata, indent=2)}")
|
|
||||||
logger.info(f"user.user_metadata: {json.dumps(user.user_metadata, indent=2)}")
|
logger.info(f"user.user_metadata: {json.dumps(user.user_metadata, indent=2)}")
|
||||||
logger.info(f"--- Einzelne Felder ---")
|
|
||||||
logger.info(f"given_name: {user_metadata.get('given_name')}")
|
|
||||||
logger.info(f"family_name: {user_metadata.get('family_name')}")
|
|
||||||
logger.info(f"name: {user_metadata.get('name')}")
|
|
||||||
logger.info(f"email (metadata): {user_metadata.get('email')}")
|
|
||||||
logger.info(f"picture: {user_metadata.get('picture')}")
|
|
||||||
logger.info(f"sub: {user_metadata.get('sub')}")
|
|
||||||
logger.info(f"provider_id: {user_metadata.get('provider_id')}")
|
|
||||||
logger.info(f"=== END OAUTH RESPONSE ===")
|
|
||||||
|
|
||||||
# LinkedIn OIDC provides these fields
|
vanity_name = user_metadata.get("vanityName")
|
||||||
vanity_name = user_metadata.get("vanityName") # LinkedIn username (often not provided)
|
|
||||||
name = user_metadata.get("name")
|
name = user_metadata.get("name")
|
||||||
picture = user_metadata.get("picture")
|
picture = user_metadata.get("picture")
|
||||||
email = user.email
|
email = user.email
|
||||||
|
|
||||||
logger.info(f"OAuth callback for user: {name} (vanityName={vanity_name}, email={email})")
|
logger.info(f"OAuth callback for user: {name} (vanityName={vanity_name}, email={email})")
|
||||||
|
|
||||||
# Try to match with customer
|
# Check if profile exists (should be created by trigger)
|
||||||
customer = None
|
profile = await db.get_profile(UUID(str(user.id)))
|
||||||
|
|
||||||
# First try vanityName if available
|
if not profile:
|
||||||
if vanity_name:
|
logger.info(f"Profile not found for user {user.id}, creating...")
|
||||||
customer = await get_customer_by_vanity_name(vanity_name)
|
profile = Profile(
|
||||||
if customer:
|
account_type=AccountType(account_type),
|
||||||
logger.info(f"Matched by vanityName: {vanity_name}")
|
onboarding_status=OnboardingStatus.PENDING,
|
||||||
|
display_name=name
|
||||||
|
)
|
||||||
|
profile = await db.create_profile(UUID(str(user.id)), profile)
|
||||||
|
|
||||||
# Fallback to email matching
|
# Get company name if applicable
|
||||||
if not customer and email:
|
company_name = None
|
||||||
customer = await get_customer_by_email(email)
|
if profile.company_id:
|
||||||
if customer:
|
company = await db.get_company(profile.company_id)
|
||||||
logger.info(f"Matched by email: {email}")
|
if company:
|
||||||
|
company_name = company.name
|
||||||
|
|
||||||
# Fallback to name matching
|
session = UserSession(
|
||||||
if not customer and name:
|
user_id=str(user.id),
|
||||||
customer = await get_customer_by_name(name)
|
linkedin_vanity_name=vanity_name or "",
|
||||||
if customer:
|
|
||||||
logger.info(f"Matched by name: {name}")
|
|
||||||
|
|
||||||
if not customer:
|
|
||||||
# Debug: List all customers to help diagnose
|
|
||||||
all_customers = await db.list_customers()
|
|
||||||
logger.warning(f"No customer found for LinkedIn user: {name} (email={email}, vanityName={vanity_name})")
|
|
||||||
logger.warning(f"Available customers:")
|
|
||||||
for c in all_customers:
|
|
||||||
logger.warning(f" - {c.name}: email={c.email}, linkedin={c.linkedin_url}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
logger.info(f"User {name} matched with customer {customer['name']}")
|
|
||||||
|
|
||||||
# Use vanityName from OAuth or extract from customer's linkedin_url
|
|
||||||
effective_vanity_name = vanity_name
|
|
||||||
if not effective_vanity_name and customer.get("linkedin_url"):
|
|
||||||
effective_vanity_name = normalize_linkedin_url(customer["linkedin_url"])
|
|
||||||
|
|
||||||
return UserSession(
|
|
||||||
customer_id=customer["id"],
|
|
||||||
customer_name=customer["name"],
|
|
||||||
linkedin_vanity_name=effective_vanity_name or "",
|
|
||||||
linkedin_name=name,
|
linkedin_name=name,
|
||||||
linkedin_picture=picture,
|
linkedin_picture=picture,
|
||||||
email=email
|
email=email,
|
||||||
|
account_type=profile.account_type.value if hasattr(profile.account_type, 'value') else profile.account_type,
|
||||||
|
company_id=str(profile.company_id) if profile.company_id else None,
|
||||||
|
onboarding_status=profile.onboarding_status.value if hasattr(profile.onboarding_status, 'value') else profile.onboarding_status,
|
||||||
|
company_name=company_name,
|
||||||
|
display_name=profile.display_name or name
|
||||||
)
|
)
|
||||||
|
|
||||||
|
return session, access_token, refresh_token
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception(f"OAuth callback error: {e}")
|
logger.exception(f"OAuth callback error: {e}")
|
||||||
return None
|
return None, None, None
|
||||||
|
|
||||||
|
|
||||||
|
async def handle_email_password_login(email: str, password: str) -> tuple[Optional[UserSession], Optional[str], Optional[str]]:
|
||||||
|
"""Handle email/password login via Supabase Auth."""
|
||||||
|
try:
|
||||||
|
supabase = get_supabase()
|
||||||
|
|
||||||
|
auth_response = supabase.auth.sign_in_with_password({
|
||||||
|
"email": email.lower(),
|
||||||
|
"password": password
|
||||||
|
})
|
||||||
|
|
||||||
|
if not auth_response or not auth_response.user:
|
||||||
|
logger.warning(f"Failed login attempt for: {email}")
|
||||||
|
return None, None, None
|
||||||
|
|
||||||
|
user = auth_response.user
|
||||||
|
session = auth_response.session
|
||||||
|
|
||||||
|
logger.info(f"Successful email/password login for: {email}")
|
||||||
|
|
||||||
|
# Get profile data
|
||||||
|
profile = await db.get_profile(UUID(str(user.id)))
|
||||||
|
|
||||||
|
if not profile:
|
||||||
|
logger.error(f"No profile found for user {user.id}")
|
||||||
|
return None, None, None
|
||||||
|
|
||||||
|
# Get company name if applicable
|
||||||
|
company_name = None
|
||||||
|
if profile.company_id:
|
||||||
|
company = await db.get_company(profile.company_id)
|
||||||
|
if company:
|
||||||
|
company_name = company.name
|
||||||
|
|
||||||
|
user_session = UserSession(
|
||||||
|
user_id=str(user.id),
|
||||||
|
linkedin_vanity_name="",
|
||||||
|
linkedin_name=profile.display_name,
|
||||||
|
linkedin_picture=None,
|
||||||
|
email=user.email,
|
||||||
|
account_type=profile.account_type.value if hasattr(profile.account_type, 'value') else profile.account_type,
|
||||||
|
company_id=str(profile.company_id) if profile.company_id else None,
|
||||||
|
onboarding_status=profile.onboarding_status.value if hasattr(profile.onboarding_status, 'value') else profile.onboarding_status,
|
||||||
|
company_name=company_name,
|
||||||
|
display_name=profile.display_name
|
||||||
|
)
|
||||||
|
|
||||||
|
return user_session, session.access_token, session.refresh_token
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Email/password login error: {e}")
|
||||||
|
return None, None, None
|
||||||
|
|
||||||
|
|
||||||
|
async def create_email_password_user(
|
||||||
|
email: str,
|
||||||
|
password: str,
|
||||||
|
account_type: str = "ghostwriter",
|
||||||
|
company_id: Optional[str] = None,
|
||||||
|
display_name: Optional[str] = None
|
||||||
|
) -> tuple[Optional[UserSession], Optional[str], Optional[str], Optional[str]]:
|
||||||
|
"""Create a new user with email/password authentication via Supabase Auth."""
|
||||||
|
from src.database.models import Profile, AccountType, OnboardingStatus
|
||||||
|
from src.web.user.password_auth import validate_password_strength
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Validate password
|
||||||
|
is_valid, error_msg = validate_password_strength(password)
|
||||||
|
if not is_valid:
|
||||||
|
logger.warning(f"Weak password for registration: {error_msg}")
|
||||||
|
return None, None, None, error_msg
|
||||||
|
|
||||||
|
supabase = get_supabase()
|
||||||
|
|
||||||
|
# Sign up via Supabase Auth
|
||||||
|
auth_response = supabase.auth.sign_up({
|
||||||
|
"email": email.lower(),
|
||||||
|
"password": password,
|
||||||
|
"options": {
|
||||||
|
"data": {
|
||||||
|
"account_type": account_type,
|
||||||
|
"display_name": display_name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if not auth_response or not auth_response.user:
|
||||||
|
logger.warning(f"Failed to create user: {email}")
|
||||||
|
return None, None, None, "Registrierung fehlgeschlagen"
|
||||||
|
|
||||||
|
user = auth_response.user
|
||||||
|
session = auth_response.session
|
||||||
|
|
||||||
|
logger.info(f"Created new user via Supabase Auth: {user.id}")
|
||||||
|
|
||||||
|
# Wait a moment for the trigger to create the profile
|
||||||
|
import asyncio
|
||||||
|
await asyncio.sleep(0.5)
|
||||||
|
|
||||||
|
# Update profile with additional data
|
||||||
|
profile = await db.get_profile(UUID(str(user.id)))
|
||||||
|
|
||||||
|
if not profile:
|
||||||
|
logger.warning(f"Profile not created by trigger, creating manually")
|
||||||
|
acc_type = AccountType(account_type)
|
||||||
|
profile = Profile(
|
||||||
|
account_type=acc_type,
|
||||||
|
display_name=display_name,
|
||||||
|
onboarding_status=OnboardingStatus.PENDING
|
||||||
|
)
|
||||||
|
if company_id:
|
||||||
|
profile.company_id = UUID(company_id)
|
||||||
|
profile = await db.create_profile(UUID(str(user.id)), profile)
|
||||||
|
elif company_id or display_name:
|
||||||
|
updates = {}
|
||||||
|
if company_id:
|
||||||
|
updates["company_id"] = company_id
|
||||||
|
if display_name:
|
||||||
|
updates["display_name"] = display_name
|
||||||
|
if updates:
|
||||||
|
profile = await db.update_profile(UUID(str(user.id)), updates)
|
||||||
|
|
||||||
|
# Get company name if applicable
|
||||||
|
company_name = None
|
||||||
|
if profile.company_id:
|
||||||
|
company = await db.get_company(profile.company_id)
|
||||||
|
if company:
|
||||||
|
company_name = company.name
|
||||||
|
|
||||||
|
user_session = UserSession(
|
||||||
|
user_id=str(user.id),
|
||||||
|
linkedin_vanity_name="",
|
||||||
|
linkedin_name=display_name,
|
||||||
|
linkedin_picture=None,
|
||||||
|
email=user.email,
|
||||||
|
account_type=account_type,
|
||||||
|
company_id=company_id,
|
||||||
|
onboarding_status="pending",
|
||||||
|
company_name=company_name,
|
||||||
|
display_name=display_name
|
||||||
|
)
|
||||||
|
|
||||||
|
access_token = session.access_token if session else None
|
||||||
|
refresh_token = session.refresh_token if session else None
|
||||||
|
|
||||||
|
return user_session, access_token, refresh_token, None
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
error_str = str(e)
|
||||||
|
logger.exception(f"Error creating email/password user: {e}")
|
||||||
|
|
||||||
|
if "already registered" in error_str.lower() or "already exists" in error_str.lower():
|
||||||
|
return None, None, None, "Diese E-Mail-Adresse ist bereits registriert"
|
||||||
|
|
||||||
|
return None, None, None, "Registrierung fehlgeschlagen"
|
||||||
|
|
||||||
|
|
||||||
|
async def sign_out(access_token: Optional[str] = None) -> bool:
|
||||||
|
"""Sign out user from Supabase Auth."""
|
||||||
|
try:
|
||||||
|
if access_token:
|
||||||
|
supabase = get_supabase()
|
||||||
|
supabase.auth.sign_out()
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Error signing out: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def get_supabase_login_url(redirect_to: str) -> str:
|
def get_supabase_login_url(redirect_to: str) -> str:
|
||||||
"""Generate Supabase OAuth login URL for LinkedIn.
|
"""Generate Supabase OAuth login URL for LinkedIn."""
|
||||||
|
|
||||||
Args:
|
|
||||||
redirect_to: The URL to redirect to after OAuth (the callback endpoint)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
The Supabase OAuth URL to redirect the user to
|
|
||||||
"""
|
|
||||||
from urllib.parse import urlencode
|
from urllib.parse import urlencode
|
||||||
|
|
||||||
# Supabase OAuth endpoint
|
|
||||||
base_url = f"{settings.supabase_url}/auth/v1/authorize"
|
base_url = f"{settings.supabase_url}/auth/v1/authorize"
|
||||||
|
|
||||||
params = {
|
params = {
|
||||||
@@ -346,3 +502,18 @@ def get_supabase_login_url(redirect_to: str) -> str:
|
|||||||
}
|
}
|
||||||
|
|
||||||
return f"{base_url}?{urlencode(params)}"
|
return f"{base_url}?{urlencode(params)}"
|
||||||
|
|
||||||
|
|
||||||
|
async def refresh_session(refresh_token: str) -> tuple[Optional[str], Optional[str]]:
|
||||||
|
"""Refresh the user's session using a refresh token."""
|
||||||
|
try:
|
||||||
|
supabase = get_supabase()
|
||||||
|
response = supabase.auth.refresh_session(refresh_token)
|
||||||
|
|
||||||
|
if response and response.session:
|
||||||
|
return response.session.access_token, response.session.refresh_token
|
||||||
|
|
||||||
|
return None, None
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Error refreshing session: {e}")
|
||||||
|
return None, None
|
||||||
|
|||||||
141
src/web/user/password_auth.py
Normal file
141
src/web/user/password_auth.py
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
"""Password authentication utilities."""
|
||||||
|
import secrets
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from typing import Optional, Tuple
|
||||||
|
|
||||||
|
import bcrypt
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
|
||||||
|
def hash_password(password: str) -> str:
|
||||||
|
"""Hash a password using bcrypt.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
password: Plain text password
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Hashed password string
|
||||||
|
"""
|
||||||
|
# bcrypt automatically handles salting
|
||||||
|
salt = bcrypt.gensalt(rounds=12)
|
||||||
|
hashed = bcrypt.hashpw(password.encode('utf-8'), salt)
|
||||||
|
return hashed.decode('utf-8')
|
||||||
|
|
||||||
|
|
||||||
|
def verify_password(password: str, password_hash: str) -> bool:
|
||||||
|
"""Verify a password against its hash.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
password: Plain text password to verify
|
||||||
|
password_hash: Stored hash to compare against
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if password matches, False otherwise
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return bcrypt.checkpw(password.encode('utf-8'), password_hash.encode('utf-8'))
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Password verification error: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def generate_verification_token() -> str:
|
||||||
|
"""Generate a secure email verification token.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
URL-safe token string (64 characters)
|
||||||
|
"""
|
||||||
|
return secrets.token_urlsafe(48)
|
||||||
|
|
||||||
|
|
||||||
|
def generate_invitation_token() -> str:
|
||||||
|
"""Generate a secure invitation token.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
URL-safe token string (32 characters)
|
||||||
|
"""
|
||||||
|
return secrets.token_urlsafe(24)
|
||||||
|
|
||||||
|
|
||||||
|
def generate_password_reset_token() -> str:
|
||||||
|
"""Generate a secure password reset token.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
URL-safe token string (64 characters)
|
||||||
|
"""
|
||||||
|
return secrets.token_urlsafe(48)
|
||||||
|
|
||||||
|
|
||||||
|
def get_verification_expiry(hours: int = 24) -> datetime:
|
||||||
|
"""Get expiry datetime for email verification token.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
hours: Number of hours until expiry (default 24)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Datetime when token expires (timezone-aware UTC)
|
||||||
|
"""
|
||||||
|
from datetime import timezone
|
||||||
|
return datetime.now(timezone.utc) + timedelta(hours=hours)
|
||||||
|
|
||||||
|
|
||||||
|
def get_invitation_expiry(days: int = 7) -> datetime:
|
||||||
|
"""Get expiry datetime for invitation token.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
days: Number of days until expiry (default 7)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Datetime when token expires (timezone-aware UTC)
|
||||||
|
"""
|
||||||
|
from datetime import timezone
|
||||||
|
return datetime.now(timezone.utc) + timedelta(days=days)
|
||||||
|
|
||||||
|
|
||||||
|
def is_token_expired(expires_at: datetime) -> bool:
|
||||||
|
"""Check if a token has expired.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
expires_at: Expiry datetime of the token
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if expired, False otherwise
|
||||||
|
"""
|
||||||
|
from datetime import timezone
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
# Handle both timezone-aware and naive datetimes
|
||||||
|
if expires_at.tzinfo is None:
|
||||||
|
expires_at = expires_at.replace(tzinfo=timezone.utc)
|
||||||
|
return now > expires_at
|
||||||
|
|
||||||
|
|
||||||
|
def validate_password_strength(password: str) -> Tuple[bool, Optional[str]]:
|
||||||
|
"""Validate password meets minimum requirements.
|
||||||
|
|
||||||
|
Requirements:
|
||||||
|
- Minimum 8 characters
|
||||||
|
- At least one uppercase letter
|
||||||
|
- At least one lowercase letter
|
||||||
|
- At least one digit
|
||||||
|
|
||||||
|
Args:
|
||||||
|
password: Password to validate
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (is_valid, error_message)
|
||||||
|
"""
|
||||||
|
if len(password) < 8:
|
||||||
|
return False, "Passwort muss mindestens 8 Zeichen lang sein"
|
||||||
|
|
||||||
|
if not any(c.isupper() for c in password):
|
||||||
|
return False, "Passwort muss mindestens einen Großbuchstaben enthalten"
|
||||||
|
|
||||||
|
if not any(c.islower() for c in password):
|
||||||
|
return False, "Passwort muss mindestens einen Kleinbuchstaben enthalten"
|
||||||
|
|
||||||
|
if not any(c.isdigit() for c in password):
|
||||||
|
return False, "Passwort muss mindestens eine Zahl enthalten"
|
||||||
|
|
||||||
|
return True, None
|
||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user