Phase 7: Apply design system to all admin pages + fix test stubs
Frontend — consistent HSL token usage across remaining pages: - Users: shared Card, Badge with success/error tokens, h2 typography, animate-fadeIn - Audit: shared Card, muted-foreground text, animate-fadeIn - Clients: shared Card, Badge active/inactive, hsl(--primary) icon color - Storage: shared Card, StatusBadge for status pills, hsl warning/primary bars replacing hardcoded amber/blue, all gray text → muted-foreground - Login: hsl(--surface) bg, hsl(--primary) submit button, brand mark icon, animate-scaleIn card entry, hsl(--warning) dev notice Backend tests — convert print-only stubs to real assertions: - test_pptx_creator: mkdir, deterministic save path, assert file exists + slide count - test_gemini_schema_support: direct google.genai client, skipif guard on GOOGLE_API_KEY, JSON parse + Pydantic model validation assertions - test_openai_schema_support: clean skip (OpenAI removed in Phase 6) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
a74f533043
commit
5def8f9e84
8 changed files with 171 additions and 194 deletions
|
|
@ -1,28 +1,8 @@
|
|||
import json
|
||||
import os
|
||||
import pytest
|
||||
from typing import Optional
|
||||
from pydantic import BaseModel, Field
|
||||
from google.genai.types import GenerateContentResponse, GenerateContentConfig
|
||||
|
||||
|
||||
from utils.llm_provider import get_google_llm_client, get_large_model
|
||||
|
||||
|
||||
class HeadingDescription(BaseModel):
|
||||
heading: str = Field(
|
||||
description="Heading of the slide", min_length=10, max_length=20
|
||||
)
|
||||
description: str = Field(
|
||||
description="Description of the slide", min_length=40, max_length=200
|
||||
)
|
||||
|
||||
|
||||
class SlideContentTest(BaseModel):
|
||||
title: str = Field(description="Title of the slide", min_length=10, max_length=20)
|
||||
first_content: HeadingDescription = Field(description="First content of the slide")
|
||||
second_content: HeadingDescription = Field(
|
||||
description="Second content of the slide"
|
||||
)
|
||||
third_content: HeadingDescription = Field(description="Third content of the slide")
|
||||
|
||||
|
||||
class ColumnContentModel(BaseModel):
|
||||
|
|
@ -37,34 +17,55 @@ class TwoColumnSlideModel(BaseModel):
|
|||
description="Title of the slide",
|
||||
)
|
||||
subtitle: Optional[str] = Field(
|
||||
default=None,
|
||||
min_length=3,
|
||||
max_length=150,
|
||||
description="Optional subtitle or description",
|
||||
)
|
||||
leftColumn: ColumnContentModel = Field(
|
||||
description="Left column content",
|
||||
)
|
||||
rightColumn: ColumnContentModel = Field(
|
||||
description="Right column content",
|
||||
)
|
||||
leftColumn: ColumnContentModel = Field(description="Left column content")
|
||||
rightColumn: ColumnContentModel = Field(description="Right column content")
|
||||
backgroundImage: Optional[str] = Field(
|
||||
description="URL to background image for the slide"
|
||||
default=None,
|
||||
description="URL to background image for the slide",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
not os.getenv("GOOGLE_API_KEY"),
|
||||
reason="GOOGLE_API_KEY not set — skipping Gemini API test",
|
||||
)
|
||||
def test_gemini_schema_support():
|
||||
response: GenerateContentResponse = get_google_llm_client().models.generate_content(
|
||||
model=get_large_model(),
|
||||
import google.genai as genai
|
||||
from google.genai.types import GenerateContentConfig
|
||||
|
||||
client = genai.Client(api_key=os.environ["GOOGLE_API_KEY"])
|
||||
model = os.getenv("GOOGLE_MODEL", "gemini-2.0-flash")
|
||||
|
||||
response = client.models.generate_content(
|
||||
model=model,
|
||||
contents=[
|
||||
"Generate a slide for a presentation",
|
||||
"The slide should have a title and two contents",
|
||||
"The title should be a short title for the slide",
|
||||
"The contents should be a heading and a description",
|
||||
"The heading should be a short heading for the slide",
|
||||
"Generate a slide for a presentation about renewable energy.",
|
||||
"The slide should have a title, subtitle, and two columns (left and right).",
|
||||
],
|
||||
config=GenerateContentConfig(
|
||||
response_mime_type="application/json",
|
||||
response_schema=TwoColumnSlideModel.model_json_schema(),
|
||||
),
|
||||
)
|
||||
print(response.text)
|
||||
|
||||
assert response is not None, "Response from Gemini was None"
|
||||
assert response.text, "Response text is empty"
|
||||
|
||||
data = json.loads(response.text)
|
||||
assert "title" in data, "Response missing 'title' field"
|
||||
assert "leftColumn" in data, "Response missing 'leftColumn' field"
|
||||
assert "rightColumn" in data, "Response missing 'rightColumn' field"
|
||||
assert isinstance(data["leftColumn"], dict), "'leftColumn' should be an object"
|
||||
assert "title" in data["leftColumn"], "'leftColumn' missing 'title'"
|
||||
assert "content" in data["leftColumn"], "'leftColumn' missing 'content'"
|
||||
|
||||
# Validate full model parse
|
||||
slide = TwoColumnSlideModel.model_validate(data)
|
||||
assert len(slide.title) >= 3, "Title too short"
|
||||
assert len(slide.leftColumn.content) >= 10, "Left column content too short"
|
||||
assert len(slide.rightColumn.content) >= 10, "Right column content too short"
|
||||
|
|
|
|||
|
|
@ -1,66 +1,10 @@
|
|||
import asyncio
|
||||
import json
|
||||
from typing import Optional
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
from utils.llm_provider import get_llm_client, get_large_model
|
||||
|
||||
|
||||
class HeadingDescription(BaseModel):
|
||||
heading: str = Field(
|
||||
description="Heading of the slide", min_length=10, max_length=20
|
||||
)
|
||||
description: str = Field(
|
||||
description="Description of the slide", min_length=40, max_length=200
|
||||
)
|
||||
|
||||
|
||||
class SlideContentTest(BaseModel):
|
||||
title: str = Field(description="Title of the slide", min_length=10, max_length=20)
|
||||
first_content: HeadingDescription = Field(description="First content of the slide")
|
||||
second_content: HeadingDescription = Field(
|
||||
description="Second content of the slide"
|
||||
)
|
||||
third_content: HeadingDescription = Field(description="Third content of the slide")
|
||||
|
||||
|
||||
class ColumnContentModel(BaseModel):
|
||||
title: str = Field(min_length=3, max_length=100, description="Column title")
|
||||
content: str = Field(min_length=10, max_length=800, description="Column content")
|
||||
|
||||
|
||||
class TwoColumnSlideModel(BaseModel):
|
||||
title: str = Field(
|
||||
min_length=3,
|
||||
max_length=100,
|
||||
description="Title of the slide",
|
||||
)
|
||||
subtitle: Optional[str] = Field(
|
||||
min_length=3,
|
||||
max_length=150,
|
||||
description="Optional subtitle or description",
|
||||
)
|
||||
leftColumn: ColumnContentModel = Field(
|
||||
description="Left column content",
|
||||
)
|
||||
rightColumn: ColumnContentModel = Field(
|
||||
description="Right column content",
|
||||
)
|
||||
backgroundImage: Optional[str] = Field(
|
||||
description="URL to background image for the slide"
|
||||
)
|
||||
import pytest
|
||||
|
||||
# This project uses a Gemini-only LLM stack (Phase 6 migration).
|
||||
# OpenAI structured output is no longer part of the stack.
|
||||
# See test_gemini_schema_support.py for the equivalent test.
|
||||
|
||||
|
||||
@pytest.mark.skip(reason="OpenAI removed in Phase 6 Gemini-only migration — see test_gemini_schema_support.py")
|
||||
def test_openai_schema_support():
|
||||
response = asyncio.run(
|
||||
get_llm_client().beta.chat.completions.parse(
|
||||
model=get_large_model(),
|
||||
messages=[
|
||||
{"role": "system", "content": "You are a helpful assistant."},
|
||||
{"role": "user", "content": "Generate a slide for a presentation"},
|
||||
],
|
||||
response_format=TwoColumnSlideModel,
|
||||
)
|
||||
)
|
||||
print(response.choices[0].message.parsed)
|
||||
pass
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import asyncio
|
||||
import os
|
||||
from models.pptx_models import (
|
||||
PptxAutoShapeBoxModel,
|
||||
PptxFillModel,
|
||||
|
|
@ -8,6 +9,7 @@ from models.pptx_models import (
|
|||
)
|
||||
from services.pptx_presentation_creator import PptxPresentationCreator
|
||||
from pptx.enum.shapes import MSO_AUTO_SHAPE_TYPE
|
||||
from pptx import Presentation
|
||||
|
||||
|
||||
pptx_model = PptxPresentationModel(
|
||||
|
|
@ -34,7 +36,16 @@ pptx_model = PptxPresentationModel(
|
|||
|
||||
|
||||
def test_pptx_creator():
|
||||
temp_dir = "/tmp/deckforge"
|
||||
temp_dir = "/tmp/deckforge_test"
|
||||
os.makedirs(temp_dir, exist_ok=True)
|
||||
save_path = "/tmp/deckforge_test/test_output.pptx"
|
||||
|
||||
pptx_creator = PptxPresentationCreator(pptx_model, temp_dir)
|
||||
asyncio.run(pptx_creator.create_ppt())
|
||||
pptx_creator.save("debug/test.pptx")
|
||||
pptx_creator.save(save_path)
|
||||
|
||||
assert os.path.exists(save_path), f"PPTX file was not created at {save_path}"
|
||||
assert os.path.getsize(save_path) > 0, "PPTX file is empty"
|
||||
|
||||
prs = Presentation(save_path)
|
||||
assert len(prs.slides) == 1, f"Expected 1 slide, got {len(prs.slides)}"
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import {
|
|||
} from '@/components/ui/table';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card } from '@/components/shared/Card';
|
||||
import DataExportButton from '../components/DataExportButton';
|
||||
import { Search } from 'lucide-react';
|
||||
|
||||
|
|
@ -40,9 +41,9 @@ export default function AuditLogPage() {
|
|||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-4 animate-fadeIn">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-semibold">Audit Log</h1>
|
||||
<h1 className="h2">Audit Log</h1>
|
||||
<DataExportButton endpoint="/api/v1/admin/audit-log/export" />
|
||||
</div>
|
||||
|
||||
|
|
@ -58,11 +59,11 @@ export default function AuditLogPage() {
|
|||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg border">
|
||||
<Card className="p-0 overflow-hidden">
|
||||
{loading ? (
|
||||
<div className="space-y-3 p-4">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<div key={i} className="h-10 bg-gray-200 rounded animate-pulse" />
|
||||
<div key={i} className="h-10 bg-[hsl(var(--surface-hover))] rounded animate-pulse" />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
|
|
@ -79,18 +80,18 @@ export default function AuditLogPage() {
|
|||
<TableBody>
|
||||
{auditLogs.map((log) => (
|
||||
<TableRow key={log.id}>
|
||||
<TableCell className="text-sm text-gray-500">
|
||||
<TableCell className="text-sm text-muted-foreground">
|
||||
{log.created_at ? new Date(log.created_at).toLocaleString() : '—'}
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-sm">{log.action}</TableCell>
|
||||
<TableCell>{log.resource_type}</TableCell>
|
||||
<TableCell className="text-sm">{log.user_id || 'System'}</TableCell>
|
||||
<TableCell className="text-sm text-gray-500">{log.ip_address || '—'}</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">{log.ip_address || '—'}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{auditLogs.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="text-center text-gray-500 py-8">
|
||||
<TableCell colSpan={5} className="text-center text-muted-foreground py-8">
|
||||
No audit log entries found.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
|
@ -98,7 +99,7 @@ export default function AuditLogPage() {
|
|||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,8 @@ import { useDispatch, useSelector } from 'react-redux';
|
|||
import { AppDispatch, RootState } from '@/store/store';
|
||||
import { fetchClients } from '@/store/slices/adminSlice';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/shared/Card';
|
||||
import { Plus, Building2 } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import CreateClientDialog from '../components/CreateClientDialog';
|
||||
|
|
@ -22,11 +23,11 @@ export default function ClientsPage() {
|
|||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h1 className="text-2xl font-semibold">Clients</h1>
|
||||
<div className="space-y-4 animate-fadeIn">
|
||||
<h1 className="h2">Clients</h1>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{[...Array(3)].map((_, i) => (
|
||||
<div key={i} className="h-36 bg-gray-200 rounded-lg animate-pulse" />
|
||||
<div key={i} className="h-36 bg-[hsl(var(--surface-hover))] rounded-lg animate-pulse" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -34,9 +35,9 @@ export default function ClientsPage() {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-4 animate-fadeIn">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-semibold">Clients</h1>
|
||||
<h1 className="h2">Clients</h1>
|
||||
{user?.role === 'super_admin' && (
|
||||
<Button onClick={() => setCreateOpen(true)}>
|
||||
<Plus className="w-4 h-4 mr-1" />
|
||||
|
|
@ -46,8 +47,8 @@ export default function ClientsPage() {
|
|||
</div>
|
||||
|
||||
{clients.length === 0 ? (
|
||||
<div className="text-center py-12 text-gray-500">
|
||||
<Building2 className="w-12 h-12 mx-auto mb-3 text-gray-300" />
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
<Building2 className="w-12 h-12 mx-auto mb-3 opacity-30" />
|
||||
<p>No clients yet.</p>
|
||||
{user?.role === 'super_admin' && (
|
||||
<p className="text-sm mt-1">Create your first client to get started.</p>
|
||||
|
|
@ -57,21 +58,28 @@ export default function ClientsPage() {
|
|||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{clients.map((client) => (
|
||||
<Link key={client.id} href={`/admin/clients/${client.id}`}>
|
||||
<Card className="hover:shadow-md transition-shadow cursor-pointer">
|
||||
<CardHeader className="pb-2">
|
||||
<Card hover className="p-5">
|
||||
<CardHeader className="mb-2 pb-0">
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<Building2 className="w-5 h-5 text-[#5146E5]" />
|
||||
<Building2 className="w-5 h-5 text-[hsl(var(--primary))]" />
|
||||
{client.name}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-between text-sm text-gray-500">
|
||||
<div className="flex items-center justify-between text-sm text-muted-foreground">
|
||||
<span>/{client.slug}</span>
|
||||
<span className={client.is_active ? 'text-green-600' : 'text-red-600'}>
|
||||
<Badge
|
||||
className={
|
||||
client.is_active
|
||||
? 'bg-[hsl(var(--success)/0.1)] text-[hsl(var(--success))] border-[hsl(var(--success)/0.2)]'
|
||||
: 'bg-[hsl(var(--error)/0.1)] text-[hsl(var(--error))] border-[hsl(var(--error)/0.2)]'
|
||||
}
|
||||
variant="outline"
|
||||
>
|
||||
{client.is_active ? 'Active' : 'Inactive'}
|
||||
</span>
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-xs text-gray-400 mt-2">
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
Policy: {client.review_policy}
|
||||
</p>
|
||||
</CardContent>
|
||||
|
|
|
|||
|
|
@ -16,7 +16,8 @@ import {
|
|||
AlertTriangle,
|
||||
RefreshCw,
|
||||
} from 'lucide-react';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Card } from '@/components/shared/Card';
|
||||
import { StatusBadge } from '@/components/shared/StatusBadge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
|
|
@ -68,11 +69,6 @@ function formatBytes(bytes: number): string {
|
|||
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`;
|
||||
}
|
||||
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
draft: 'bg-yellow-100 text-yellow-700',
|
||||
in_review: 'bg-blue-100 text-blue-700',
|
||||
approved: 'bg-green-100 text-green-700',
|
||||
};
|
||||
|
||||
export default function StoragePage() {
|
||||
const user = useSelector((state: RootState) => state.auth.user);
|
||||
|
|
@ -219,7 +215,7 @@ export default function StoragePage() {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-6 animate-fadeIn">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-semibold">Storage</h1>
|
||||
<div className="flex items-center gap-3">
|
||||
|
|
@ -251,14 +247,14 @@ export default function StoragePage() {
|
|||
|
||||
{/* Summary Cards */}
|
||||
{summary && (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 animate-fadeIn">
|
||||
<Card className="p-5">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Presentations</p>
|
||||
<p className="text-sm text-muted-foreground">Presentations</p>
|
||||
<p className="text-2xl font-bold mt-1">{summary.total_presentations}</p>
|
||||
</div>
|
||||
<div className="p-2 rounded-lg bg-gray-50 text-[#5146E5]">
|
||||
<div className="p-2 rounded-lg bg-[hsl(var(--primary-light))] text-[hsl(var(--primary))]">
|
||||
<FileText className="w-5 h-5" />
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -266,10 +262,10 @@ export default function StoragePage() {
|
|||
<Card className="p-5">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Export Files</p>
|
||||
<p className="text-sm text-muted-foreground">Export Files</p>
|
||||
<p className="text-2xl font-bold mt-1">{summary.total_files}</p>
|
||||
</div>
|
||||
<div className="p-2 rounded-lg bg-gray-50 text-blue-600">
|
||||
<div className="p-2 rounded-lg bg-[hsl(var(--primary-light))] text-[hsl(var(--primary))]">
|
||||
<FolderOpen className="w-5 h-5" />
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -277,15 +273,15 @@ export default function StoragePage() {
|
|||
<Card className="p-5">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Master Decks</p>
|
||||
<p className="text-sm text-muted-foreground">Master Decks</p>
|
||||
<p className="text-2xl font-bold mt-1">
|
||||
{summary.total_master_decks}
|
||||
<span className="text-sm font-normal text-gray-400 ml-1">
|
||||
<span className="text-sm font-normal text-muted-foreground ml-1">
|
||||
({summary.master_deck_files} files)
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-2 rounded-lg bg-gray-50 text-purple-600">
|
||||
<div className="p-2 rounded-lg bg-[hsl(var(--primary-light))] text-[hsl(var(--primary))]">
|
||||
<Layers className="w-5 h-5" />
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -293,12 +289,12 @@ export default function StoragePage() {
|
|||
<Card className="p-5">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Total Size</p>
|
||||
<p className="text-sm text-muted-foreground">Total Size</p>
|
||||
<p className="text-2xl font-bold mt-1">
|
||||
{formatBytes(summary.total_size_bytes + (summary.master_deck_size_bytes || 0))}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-2 rounded-lg bg-gray-50 text-green-600">
|
||||
<div className="p-2 rounded-lg bg-[hsl(var(--success)/0.1)] text-[hsl(var(--success))]">
|
||||
<HardDrive className="w-5 h-5" />
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -308,8 +304,8 @@ export default function StoragePage() {
|
|||
|
||||
{/* Soft-deleted notice + purge */}
|
||||
{summary && summary.total_deleted > 0 && isSuperAdmin && (
|
||||
<div className="flex items-center justify-between p-3 bg-amber-50 border border-amber-200 rounded-lg">
|
||||
<div className="flex items-center gap-2 text-sm text-amber-700">
|
||||
<div className="flex items-center justify-between p-3 bg-[hsl(var(--warning)/0.08)] border border-[hsl(var(--warning)/0.3)] rounded-lg">
|
||||
<div className="flex items-center gap-2 text-sm text-[hsl(var(--warning))]">
|
||||
<AlertTriangle className="w-4 h-4" />
|
||||
<span>{summary.total_deleted} soft-deleted presentation(s) with files still on disk.</span>
|
||||
</div>
|
||||
|
|
@ -318,7 +314,7 @@ export default function StoragePage() {
|
|||
size="sm"
|
||||
onClick={handlePurge}
|
||||
disabled={purging}
|
||||
className="text-amber-700 border-amber-300 hover:bg-amber-100"
|
||||
className="text-[hsl(var(--warning))] border-[hsl(var(--warning)/0.3)] hover:bg-[hsl(var(--warning)/0.08)]"
|
||||
>
|
||||
{purging ? <Loader2 className="w-3.5 h-3.5 mr-1 animate-spin" /> : <Trash2 className="w-3.5 h-3.5 mr-1" />}
|
||||
Purge Files
|
||||
|
|
@ -328,8 +324,8 @@ export default function StoragePage() {
|
|||
|
||||
{/* Bulk actions toolbar */}
|
||||
{selectedIds.size > 0 && (
|
||||
<div className="flex items-center gap-3 p-3 bg-blue-50 border border-blue-200 rounded-lg">
|
||||
<span className="text-sm text-blue-700 font-medium">
|
||||
<div className="flex items-center gap-3 p-3 bg-[hsl(var(--primary)/0.06)] border border-[hsl(var(--primary)/0.2)] rounded-lg">
|
||||
<span className="text-sm text-[hsl(var(--primary))] font-medium">
|
||||
{selectedIds.size} selected
|
||||
</span>
|
||||
<Button
|
||||
|
|
@ -355,9 +351,9 @@ export default function StoragePage() {
|
|||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b bg-gray-50">
|
||||
<tr className="border-b bg-[hsl(var(--surface))]">
|
||||
<th className="p-3 w-10">
|
||||
<button onClick={toggleSelectAll} className="text-gray-400 hover:text-gray-600">
|
||||
<button onClick={toggleSelectAll} className="text-muted-foreground hover:text-foreground">
|
||||
{selectedIds.size === presentations.length && presentations.length > 0 ? (
|
||||
<CheckSquare className="w-4 h-4" />
|
||||
) : (
|
||||
|
|
@ -365,18 +361,18 @@ export default function StoragePage() {
|
|||
)}
|
||||
</button>
|
||||
</th>
|
||||
<th className="text-left p-3 font-medium text-gray-600">Title</th>
|
||||
<th className="text-left p-3 font-medium text-gray-600">Status</th>
|
||||
<th className="text-left p-3 font-medium text-gray-600">Created</th>
|
||||
<th className="text-right p-3 font-medium text-gray-600">Files</th>
|
||||
<th className="text-right p-3 font-medium text-gray-600">Size</th>
|
||||
<th className="text-right p-3 font-medium text-gray-600">Actions</th>
|
||||
<th className="text-left p-3 font-medium text-muted-foreground">Title</th>
|
||||
<th className="text-left p-3 font-medium text-muted-foreground">Status</th>
|
||||
<th className="text-left p-3 font-medium text-muted-foreground">Created</th>
|
||||
<th className="text-right p-3 font-medium text-muted-foreground">Files</th>
|
||||
<th className="text-right p-3 font-medium text-muted-foreground">Size</th>
|
||||
<th className="text-right p-3 font-medium text-muted-foreground">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{presentations.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={7} className="p-8 text-center text-gray-400">
|
||||
<td colSpan={7} className="p-8 text-center text-muted-foreground">
|
||||
No presentations found.
|
||||
</td>
|
||||
</tr>
|
||||
|
|
@ -384,12 +380,12 @@ export default function StoragePage() {
|
|||
presentations.map((p) => (
|
||||
<tr
|
||||
key={p.id}
|
||||
className={`border-b last:border-0 hover:bg-gray-50 ${selectedIds.has(p.id) ? 'bg-blue-50' : ''}`}
|
||||
className={`border-b last:border-0 hover:bg-[hsl(var(--surface))] ${selectedIds.has(p.id) ? 'bg-[hsl(var(--primary)/0.05)]' : ''}`}
|
||||
>
|
||||
<td className="p-3">
|
||||
<button onClick={() => toggleSelect(p.id)} className="text-gray-400 hover:text-gray-600">
|
||||
<button onClick={() => toggleSelect(p.id)} className="text-muted-foreground hover:text-foreground">
|
||||
{selectedIds.has(p.id) ? (
|
||||
<CheckSquare className="w-4 h-4 text-blue-600" />
|
||||
<CheckSquare className="w-4 h-4 text-[hsl(var(--primary))]" />
|
||||
) : (
|
||||
<Square className="w-4 h-4" />
|
||||
)}
|
||||
|
|
@ -399,15 +395,13 @@ export default function StoragePage() {
|
|||
<span className="font-medium">{p.title || 'Untitled'}</span>
|
||||
</td>
|
||||
<td className="p-3">
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full ${STATUS_COLORS[p.status] || 'bg-gray-100 text-gray-600'}`}>
|
||||
{p.status}
|
||||
</span>
|
||||
<StatusBadge status={p.status as 'draft' | 'in_review' | 'approved'} />
|
||||
</td>
|
||||
<td className="p-3 text-gray-500">
|
||||
<td className="p-3 text-muted-foreground">
|
||||
{p.created_at ? new Date(p.created_at).toLocaleDateString() : '—'}
|
||||
</td>
|
||||
<td className="p-3 text-right text-gray-500">{p.file_count}</td>
|
||||
<td className="p-3 text-right text-gray-500">
|
||||
<td className="p-3 text-right text-muted-foreground">{p.file_count}</td>
|
||||
<td className="p-3 text-right text-muted-foreground">
|
||||
{p.total_size_bytes > 0 ? formatBytes(p.total_size_bytes) : '—'}
|
||||
</td>
|
||||
<td className="p-3 text-right">
|
||||
|
|
@ -428,7 +422,7 @@ export default function StoragePage() {
|
|||
onClick={() => setDeleteTarget(p)}
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 className="w-4 h-4 text-red-500" />
|
||||
<Trash2 className="w-4 h-4 text-[hsl(var(--error))]" />
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
|
|
@ -446,7 +440,7 @@ export default function StoragePage() {
|
|||
<DialogHeader>
|
||||
<DialogTitle>Delete Presentation</DialogTitle>
|
||||
</DialogHeader>
|
||||
<p className="text-sm text-gray-500">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Are you sure you want to delete "{deleteTarget?.title || 'Untitled'}"?
|
||||
Associated files will be cleaned up by the retention service.
|
||||
</p>
|
||||
|
|
@ -467,7 +461,7 @@ export default function StoragePage() {
|
|||
<DialogHeader>
|
||||
<DialogTitle>Delete {selectedIds.size} Presentations</DialogTitle>
|
||||
</DialogHeader>
|
||||
<p className="text-sm text-gray-500">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Are you sure you want to delete {selectedIds.size} selected presentations?
|
||||
This action can be undone by an admin.
|
||||
</p>
|
||||
|
|
|
|||
|
|
@ -21,6 +21,8 @@ import {
|
|||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Card } from '@/components/shared/Card';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
export default function UsersPage() {
|
||||
|
|
@ -53,11 +55,11 @@ export default function UsersPage() {
|
|||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h1 className="text-2xl font-semibold">Users</h1>
|
||||
<div className="space-y-4 animate-fadeIn">
|
||||
<h1 className="h2">Users</h1>
|
||||
<div className="space-y-3">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<div key={i} className="h-12 bg-gray-200 rounded animate-pulse" />
|
||||
<div key={i} className="h-12 bg-[hsl(var(--surface-hover))] rounded animate-pulse" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -65,9 +67,9 @@ export default function UsersPage() {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h1 className="text-2xl font-semibold">Users</h1>
|
||||
<div className="bg-white rounded-lg border">
|
||||
<div className="space-y-4 animate-fadeIn">
|
||||
<h1 className="h2">Users</h1>
|
||||
<Card className="p-0 overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
|
|
@ -86,11 +88,18 @@ export default function UsersPage() {
|
|||
<TableCell>{user.email}</TableCell>
|
||||
<TableCell><RoleBadge role={user.role} /></TableCell>
|
||||
<TableCell>
|
||||
<span className={`text-xs ${user.is_active ? 'text-green-600' : 'text-red-600'}`}>
|
||||
<Badge
|
||||
className={
|
||||
user.is_active
|
||||
? 'bg-[hsl(var(--success)/0.1)] text-[hsl(var(--success))] border-[hsl(var(--success)/0.2)]'
|
||||
: 'bg-[hsl(var(--error)/0.1)] text-[hsl(var(--error))] border-[hsl(var(--error)/0.2)]'
|
||||
}
|
||||
variant="outline"
|
||||
>
|
||||
{user.is_active ? 'Active' : 'Inactive'}
|
||||
</span>
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-gray-500">
|
||||
<TableCell className="text-sm text-muted-foreground">
|
||||
{user.last_login_at
|
||||
? new Date(user.last_login_at).toLocaleDateString()
|
||||
: 'Never'}
|
||||
|
|
@ -115,7 +124,7 @@ export default function UsersPage() {
|
|||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-red-600 hover:text-red-700"
|
||||
className="text-[hsl(var(--error))] hover:text-[hsl(var(--error))] hover:border-[hsl(var(--error)/0.4)]"
|
||||
onClick={() => handleDeactivate(user.id)}
|
||||
>
|
||||
Deactivate
|
||||
|
|
@ -128,14 +137,14 @@ export default function UsersPage() {
|
|||
))}
|
||||
{users.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="text-center text-gray-500 py-8">
|
||||
<TableCell colSpan={6} className="text-center text-muted-foreground py-8">
|
||||
No users found.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -52,14 +52,23 @@ export default function LoginPage() {
|
|||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-slate-100 flex items-center justify-center p-4">
|
||||
<div className="max-w-md w-full">
|
||||
<div className="bg-white rounded-2xl shadow-xl border border-gray-100 p-8">
|
||||
<div className="min-h-screen bg-[hsl(var(--surface))] flex items-center justify-center p-4">
|
||||
<div className="max-w-md w-full animate-scaleIn">
|
||||
<div className="bg-white rounded-2xl shadow-xl border border-border p-8">
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-2xl font-bold text-gray-900 font-inter">
|
||||
{/* Brand mark */}
|
||||
<div className="inline-flex items-center justify-center w-12 h-12 rounded-xl bg-[hsl(var(--primary-light))] mb-4">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
|
||||
<rect x="3" y="3" width="8" height="8" rx="1.5" fill="hsl(var(--primary))" />
|
||||
<rect x="13" y="3" width="8" height="8" rx="1.5" fill="hsl(var(--primary))" opacity="0.6" />
|
||||
<rect x="3" y="13" width="8" height="8" rx="1.5" fill="hsl(var(--primary))" opacity="0.4" />
|
||||
<rect x="13" y="13" width="8" height="8" rx="1.5" fill="hsl(var(--primary))" opacity="0.8" />
|
||||
</svg>
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-foreground font-inter">
|
||||
OLIVER DeckForge
|
||||
</h1>
|
||||
<p className="text-sm text-gray-500 mt-2">
|
||||
<p className="text-sm text-muted-foreground mt-2">
|
||||
AI-powered presentation generation for enterprise teams
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -81,8 +90,8 @@ export default function LoginPage() {
|
|||
|
||||
{isDevMode && (
|
||||
<form onSubmit={handleDevLogin} className="space-y-4">
|
||||
<div className="bg-amber-50 border border-amber-200 rounded-lg p-3 mb-4">
|
||||
<p className="text-xs text-amber-700 font-medium">
|
||||
<div className="bg-[hsl(var(--warning)/0.08)] border border-[hsl(var(--warning)/0.3)] rounded-lg p-3 mb-4">
|
||||
<p className="text-xs text-[hsl(var(--warning))] font-medium">
|
||||
Development Mode — Azure AD not configured
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -90,7 +99,7 @@ export default function LoginPage() {
|
|||
<div>
|
||||
<label
|
||||
htmlFor="email"
|
||||
className="block text-sm font-medium text-gray-700 mb-1"
|
||||
className="block text-sm font-medium text-foreground mb-1"
|
||||
>
|
||||
Email
|
||||
</label>
|
||||
|
|
@ -100,7 +109,7 @@ export default function LoginPage() {
|
|||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
className="w-full rounded-lg border border-border px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[hsl(var(--primary))] focus:border-transparent"
|
||||
placeholder="admin@deckforge.dev"
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -108,7 +117,7 @@ export default function LoginPage() {
|
|||
<div>
|
||||
<label
|
||||
htmlFor="password"
|
||||
className="block text-sm font-medium text-gray-700 mb-1"
|
||||
className="block text-sm font-medium text-foreground mb-1"
|
||||
>
|
||||
Password
|
||||
</label>
|
||||
|
|
@ -118,19 +127,19 @@ export default function LoginPage() {
|
|||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
placeholder="devpass123"
|
||||
className="w-full rounded-lg border border-border px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[hsl(var(--primary))] focus:border-transparent"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="text-sm text-red-600">{error}</p>
|
||||
<p className="text-sm text-[hsl(var(--error))]">{error}</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full bg-blue-600 text-white rounded-lg px-4 py-3 font-medium hover:bg-blue-700 transition-colors disabled:opacity-50"
|
||||
className="w-full bg-[hsl(var(--primary))] text-white rounded-lg px-4 py-3 font-medium hover:bg-[hsl(var(--primary-hover))] transition-colors disabled:opacity-50"
|
||||
>
|
||||
{loading ? 'Signing in...' : 'Sign in (Development)'}
|
||||
</button>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue