08: Migrate & redesign Blog, Ads, and Dashboard pages
- Blog listing page with hero, responsive grid, tag filters, load more - Blog post page with markdown rendering, related posts, social share - Ads landing page with conversion copy, pricing, FAQ, testimonials - Dashboard shell with sidebar, topbar, stat cards, activity feed - Dashboard components: Sidebar, TopBar, StatCard, ActivityFeed, QuickActions - Comprehensive test suite covering all pages and components
This commit is contained in:
80
web/src/components/dashboard/ActivityFeed.tsx
Normal file
80
web/src/components/dashboard/ActivityFeed.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
import { For, Show } from "solid-js";
|
||||
import { cn } from "~/lib/utils";
|
||||
import { Badge } from "~/components/ui";
|
||||
|
||||
interface Activity {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
timestamp: string;
|
||||
type: "alert" | "success" | "info" | "warning";
|
||||
}
|
||||
|
||||
interface ActivityFeedProps {
|
||||
activities: Activity[];
|
||||
class?: string;
|
||||
}
|
||||
|
||||
const typeVariants: Record<Activity["type"], "error" | "success" | "info" | "warning"> = {
|
||||
alert: "error",
|
||||
success: "success",
|
||||
info: "info",
|
||||
warning: "warning",
|
||||
};
|
||||
|
||||
function ActivityIcon(props: { type: Activity["type"] }) {
|
||||
return (
|
||||
<div class={cn(
|
||||
"w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0",
|
||||
props.type === "alert" && "bg-[var(--color-error-bg)] text-[var(--color-error)]",
|
||||
props.type === "success" && "bg-[var(--color-success-bg)] text-[var(--color-success)]",
|
||||
props.type === "info" && "bg-[var(--color-info-bg)] text-[var(--color-info)]",
|
||||
props.type === "warning" && "bg-[var(--color-warning-bg)] text-[var(--color-warning)]",
|
||||
)}>
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<Show when={props.type === "alert"}>
|
||||
<circle cx="8" cy="8" r="6" stroke="currentColor" stroke-width="1.5"/>
|
||||
<path d="M8 5v3M8 10.5v.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
||||
</Show>
|
||||
<Show when={props.type === "success"}>
|
||||
<path d="M4 8l3 3 5-5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</Show>
|
||||
<Show when={props.type === "info"}>
|
||||
<circle cx="8" cy="8" r="6" stroke="currentColor" stroke-width="1.5"/>
|
||||
<path d="M8 7v4M8 5v.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
||||
</Show>
|
||||
<Show when={props.type === "warning"}>
|
||||
<path d="M8 2l6 11H2L8 2z" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/>
|
||||
<path d="M8 7v3M8 12v.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
||||
</Show>
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ActivityFeed(props: ActivityFeedProps) {
|
||||
return (
|
||||
<div class={cn("gradient-card border border-[var(--color-border)]/50 rounded-xl", props.class)}>
|
||||
<div class="px-5 py-4 border-b border-[var(--color-border)]/50">
|
||||
<h3 class="text-sm font-semibold text-[var(--color-text-primary)]">Recent Activity</h3>
|
||||
</div>
|
||||
<div class="divide-y divide-[var(--color-border)]/50">
|
||||
<For each={props.activities}>
|
||||
{(activity) => (
|
||||
<div class="px-5 py-3.5 flex items-start gap-3">
|
||||
<ActivityIcon type={activity.type} />
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2 mb-0.5">
|
||||
<span class="text-sm font-medium text-[var(--color-text-primary)]">{activity.title}</span>
|
||||
<Badge variant={typeVariants[activity.type]}>{activity.type}</Badge>
|
||||
</div>
|
||||
<p class="text-xs text-[var(--color-text-secondary)]">{activity.description}</p>
|
||||
</div>
|
||||
<span class="text-xs text-[var(--color-text-tertiary)] whitespace-nowrap">{activity.timestamp}</span>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
40
web/src/components/dashboard/QuickActions.tsx
Normal file
40
web/src/components/dashboard/QuickActions.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import { For, type JSX } from "solid-js";
|
||||
import { A } from "@solidjs/router";
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
interface QuickAction {
|
||||
label: string;
|
||||
href: string;
|
||||
icon: () => JSX.Element;
|
||||
}
|
||||
|
||||
interface QuickActionsProps {
|
||||
actions: QuickAction[];
|
||||
class?: string;
|
||||
}
|
||||
|
||||
export default function QuickActions(props: QuickActionsProps) {
|
||||
return (
|
||||
<div class={cn("gradient-card border border-[var(--color-border)]/50 rounded-xl", props.class)}>
|
||||
<div class="px-5 py-4 border-b border-[var(--color-border)]/50">
|
||||
<h3 class="text-sm font-semibold text-[var(--color-text-primary)]">Quick Actions</h3>
|
||||
</div>
|
||||
<div class="p-5 grid grid-cols-2 gap-3">
|
||||
<For each={props.actions}>
|
||||
{(action) => {
|
||||
const Icon = action.icon;
|
||||
return (
|
||||
<A
|
||||
href={action.href}
|
||||
class="flex flex-col items-center gap-2 p-3 rounded-xl bg-[var(--color-bg-secondary)] hover:bg-[var(--color-brand-primary)]/10 text-[var(--color-text-secondary)] hover:text-[var(--color-brand-primary)] transition-colors text-center"
|
||||
>
|
||||
<Icon />
|
||||
<span class="text-xs font-medium">{action.label}</span>
|
||||
</A>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
146
web/src/components/dashboard/Sidebar.tsx
Normal file
146
web/src/components/dashboard/Sidebar.tsx
Normal file
@@ -0,0 +1,146 @@
|
||||
import { createSignal, Show, type JSX } from "solid-js";
|
||||
import { A, useLocation } from "@solidjs/router";
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
interface SidebarLink {
|
||||
label: string;
|
||||
href: string;
|
||||
icon: () => JSX.Element;
|
||||
}
|
||||
|
||||
function OverviewIcon() {
|
||||
return (
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="2" y="2" width="7" height="7" rx="1" fill="currentColor"/>
|
||||
<rect x="11" y="2" width="7" height="7" rx="1" fill="currentColor" opacity="0.5"/>
|
||||
<rect x="2" y="11" width="7" height="7" rx="1" fill="currentColor" opacity="0.5"/>
|
||||
<rect x="11" y="11" width="7" height="7" rx="1" fill="currentColor" opacity="0.3"/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function DarkWatchIcon() {
|
||||
return (
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M10 2L2 6v6c0 4.4 3.6 8.4 8 9 4.4-.6 8-4.6 8-9V6l-8-4zm0 1.7L16 7.5v4.5c0 3.7-2.6 7-6 8-3.4-1-6-4.3-6-8V7.5l6-3.8z" fill="currentColor"/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function VoicePrintIcon() {
|
||||
return (
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M9 2h2v3H9V2zM6 6h8v1.5H6V6zm-1 3h10v1.5H5V9zm0 3h10v1.5H5V12z" fill="currentColor"/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function SpamShieldIcon() {
|
||||
return (
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M10 2l7 3.5v5c0 5.2-3.5 9.5-7 10.5-3.5-1-7-5.3-7-10.5v-5L10 2zm0 2.1L5 7.2v3.3c0 4.2 2.8 7.8 5 8.6 2.2-.8 5-4.4 5-8.6V7.2l-5-3.1z" fill="currentColor"/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function HomeTitleIcon() {
|
||||
return (
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M10 3L3 8v9h5v-5h4v5h5V8l-7-5zm0 2.5L14 9H6l4-3.5z" fill="currentColor"/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function RemoveBrokersIcon() {
|
||||
return (
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M7 3h6v2H7V3zm-1 4h8l1 1v1.5H5V8l1-1zm-1 4h10v7H5v-7zm2 2v3h6v-3H7z" fill="currentColor"/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function SettingsIcon() {
|
||||
return (
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="10" cy="10" r="2.5" fill="currentColor"/>
|
||||
<path d="M10 1.5l2.5 1v2.5L10 5l-2.5-1v-2.5L10 1.5z" fill="currentColor" opacity="0.3"/>
|
||||
<path d="M18.5 10l-1 2.5H15l-1-2.5 1-2.5h2.5l1 2.5z" fill="currentColor" opacity="0.3"/>
|
||||
<path d="M10 18.5l-2.5-1V15L10 14l2.5 1v2.5l-2.5 1z" fill="currentColor" opacity="0.3"/>
|
||||
<path d="M1.5 10l1-2.5H5l1 2.5-1 2.5H2.5l-1-2.5z" fill="currentColor" opacity="0.3"/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
const sidebarLinks: SidebarLink[] = [
|
||||
{ label: "Overview", href: "/dashboard", icon: OverviewIcon },
|
||||
{ label: "DarkWatch", href: "/dashboard/darkwatch", icon: DarkWatchIcon },
|
||||
{ label: "VoicePrint", href: "/dashboard/voiceprint", icon: VoicePrintIcon },
|
||||
{ label: "SpamShield", href: "/dashboard/spamshield", icon: SpamShieldIcon },
|
||||
{ label: "HomeTitle", href: "/dashboard/hometitle", icon: HomeTitleIcon },
|
||||
{ label: "RemoveBrokers", href: "/dashboard/removebrokers", icon: RemoveBrokersIcon },
|
||||
{ label: "Settings", href: "/dashboard/settings", icon: SettingsIcon },
|
||||
];
|
||||
|
||||
interface SidebarProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function Sidebar(props: SidebarProps) {
|
||||
const location = useLocation();
|
||||
|
||||
return (
|
||||
<>
|
||||
<aside
|
||||
class={cn(
|
||||
"fixed lg:static inset-y-0 left-0 z-40 w-64 bg-[var(--color-bg)] border-r border-[var(--color-border)] transform transition-transform duration-300 ease-in-out lg:transform-none overflow-y-auto",
|
||||
props.open ? "translate-x-0" : "-translate-x-full lg:translate-x-0",
|
||||
)}
|
||||
>
|
||||
<div class="p-6 border-b border-[var(--color-border)]">
|
||||
<A href="/dashboard" class="flex items-center gap-2">
|
||||
<svg width="24" height="28" viewBox="0 0 28 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M14 0L26 6V16C26 24 14 32 14 32S2 24 2 16V6L14 0Z" fill="url(#sidebar-shield-grad)"/>
|
||||
<path d="M10 16L13 19L19 13" stroke="white" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<defs>
|
||||
<linearGradient id="sidebar-shield-grad" x1="0" y1="0" x2="28" y2="32" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="var(--color-brand-primary)"/>
|
||||
<stop offset="1" stop-color="var(--color-brand-accent)"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
<span class="text-lg font-bold text-[var(--color-text-primary)]">ShieldAI</span>
|
||||
</A>
|
||||
</div>
|
||||
<nav class="p-4 space-y-1">
|
||||
{sidebarLinks.map((link) => {
|
||||
const Icon = link.icon;
|
||||
const isActive = location.pathname === link.href ||
|
||||
(link.href !== "/dashboard" && location.pathname.startsWith(link.href));
|
||||
return (
|
||||
<A
|
||||
href={link.href}
|
||||
class={cn(
|
||||
"flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-colors",
|
||||
isActive
|
||||
? "bg-[var(--color-brand-primary)]/10 text-[var(--color-brand-primary)]"
|
||||
: "text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-bg-secondary)]",
|
||||
)}
|
||||
onClick={() => props.onClose()}
|
||||
>
|
||||
<Icon />
|
||||
{link.label}
|
||||
</A>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</aside>
|
||||
<Show when={props.open}>
|
||||
<div
|
||||
class="fixed inset-0 z-30 bg-black/50 lg:hidden"
|
||||
onClick={() => props.onClose()}
|
||||
/>
|
||||
</Show>
|
||||
</>
|
||||
);
|
||||
}
|
||||
54
web/src/components/dashboard/StatCard.tsx
Normal file
54
web/src/components/dashboard/StatCard.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import { Show, type JSX } from "solid-js";
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
interface StatCardProps {
|
||||
label: string;
|
||||
value: string;
|
||||
trend?: "up" | "down";
|
||||
trendLabel?: string;
|
||||
icon: () => JSX.Element;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
export default function StatCard(props: StatCardProps) {
|
||||
const Icon = props.icon;
|
||||
return (
|
||||
<div class={cn("gradient-card border border-[var(--color-border)]/50 rounded-xl p-5", props.class)}>
|
||||
<div class="flex items-start justify-between mb-3">
|
||||
<span class="text-sm font-medium text-[var(--color-text-secondary)]">{props.label}</span>
|
||||
<div class="p-2 rounded-lg bg-[var(--color-brand-primary)]/10 text-[var(--color-brand-primary)]">
|
||||
<Icon />
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-2xl font-bold text-[var(--color-text-primary)] mb-1">{props.value}</div>
|
||||
<Show when={props.trend}>
|
||||
<div class="flex items-center gap-1 text-xs">
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 12 12"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class={cn(
|
||||
props.trend === "up" ? "text-[var(--color-success)]" : "text-[var(--color-error)]",
|
||||
)}
|
||||
>
|
||||
<path
|
||||
d={props.trend === "up" ? "M6 9V3M6 3l-3 3M6 3l3 3" : "M6 3v6M6 9l-3-3M6 9l3-3"}
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
<span class={cn(
|
||||
"font-medium",
|
||||
props.trend === "up" ? "text-[var(--color-success)]" : "text-[var(--color-error)]",
|
||||
)}>
|
||||
{props.trendLabel}
|
||||
</span>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
91
web/src/components/dashboard/TopBar.tsx
Normal file
91
web/src/components/dashboard/TopBar.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
import { createSignal, Show } from "solid-js";
|
||||
import { A } from "@solidjs/router";
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
interface TopBarProps {
|
||||
onMenuToggle: () => void;
|
||||
}
|
||||
|
||||
export default function TopBar(props: TopBarProps) {
|
||||
const [showDropdown, setShowDropdown] = createSignal(false);
|
||||
|
||||
return (
|
||||
<header class="h-16 border-b border-[var(--color-border)] bg-[var(--color-bg)] flex items-center justify-between px-4 lg:px-6">
|
||||
<div class="flex items-center gap-4">
|
||||
<button
|
||||
type="button"
|
||||
class="lg:hidden p-2 rounded-lg text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-bg-secondary)]"
|
||||
onClick={() => props.onMenuToggle()}
|
||||
aria-label="Toggle menu"
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M3 5h14M3 10h14M3 15h14" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
<div class="relative hidden sm:block">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" class="absolute left-3 top-1/2 -translate-y-1/2 text-[var(--color-text-tertiary)]">
|
||||
<circle cx="7" cy="7" r="3" stroke="currentColor" stroke-width="1.5"/>
|
||||
<path d="M11 11l3 3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
||||
</svg>
|
||||
<input
|
||||
type="search"
|
||||
placeholder="Search..."
|
||||
class="pl-9 pr-4 py-2 text-sm rounded-lg bg-[var(--color-bg-secondary)] border border-[var(--color-border)] text-[var(--color-text-primary)] placeholder:text-[var(--color-text-tertiary)] focus:outline-none focus:ring-2 focus:ring-[var(--color-brand-primary)]/30 w-64"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
class="relative p-2 rounded-lg text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-bg-secondary)]"
|
||||
aria-label="Notifications"
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M10 2.5a5.5 5.5 0 00-5.5 5.5v3l-1.5 2v1h14v-1l-1.5-2V8a5.5 5.5 0 00-5.5-5.5z" stroke="currentColor" stroke-width="1.5"/>
|
||||
<path d="M8 15.5a2 2 0 004 0" stroke="currentColor" stroke-width="1.5"/>
|
||||
</svg>
|
||||
<span class="absolute top-1.5 right-1.5 w-2 h-2 rounded-full bg-[var(--color-error)]" />
|
||||
</button>
|
||||
|
||||
<div class="relative">
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center gap-2 p-1.5 rounded-lg hover:bg-[var(--color-bg-secondary)] transition-colors"
|
||||
onClick={() => setShowDropdown(v => !v)}
|
||||
aria-label="User menu"
|
||||
>
|
||||
<div class="w-8 h-8 rounded-full bg-[var(--color-brand-primary)] flex items-center justify-center text-white text-sm font-medium">
|
||||
JD
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<Show when={showDropdown()}>
|
||||
<>
|
||||
<div
|
||||
class="fixed inset-0 z-40"
|
||||
onClick={() => setShowDropdown(false)}
|
||||
/>
|
||||
<div class="absolute right-0 top-full mt-2 z-50 w-48 rounded-xl bg-[var(--color-bg)] border border-[var(--color-border)] shadow-lg py-1">
|
||||
<div class="px-4 py-2 border-b border-[var(--color-border)]">
|
||||
<p class="text-sm font-medium text-[var(--color-text-primary)]">John Doe</p>
|
||||
<p class="text-xs text-[var(--color-text-tertiary)]">john@shieldai.app</p>
|
||||
</div>
|
||||
<A href="/dashboard/settings" class="block px-4 py-2 text-sm text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-bg-secondary)]" onClick={() => setShowDropdown(false)}>
|
||||
Settings
|
||||
</A>
|
||||
<button
|
||||
type="button"
|
||||
class="w-full text-left px-4 py-2 text-sm text-[var(--color-error)] hover:bg-[var(--color-bg-secondary)]"
|
||||
onClick={() => setShowDropdown(false)}
|
||||
>
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
5
web/src/components/dashboard/index.ts
Normal file
5
web/src/components/dashboard/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export { default as Sidebar } from "./Sidebar";
|
||||
export { default as TopBar } from "./TopBar";
|
||||
export { default as StatCard } from "./StatCard";
|
||||
export { default as ActivityFeed } from "./ActivityFeed";
|
||||
export { default as QuickActions } from "./QuickActions";
|
||||
Reference in New Issue
Block a user