feat(frontend): IMPHNEN design deep integration — full 5-layer redesign

Layer 0 — Foundation:
- Dark mode palette (30+ CSS var pairs in [data-theme='dark'])
- CSS-first theme engine in styles.css (@theme + CSS vars)
- UseTheme hook with light/dark/system support + localStorage persistence
- Fixed animation keyframes (shimmer 1.5s canonical, glowPulse moved to CSS)
- Smooth theme switch transitions via .theme-transitioning class

Layer 1 — Shared UI Components:
- Badge: success/warning variants now use CSS vars (bg-success-soft text-success)
- Toast: rewritten with CSS vars, z-index fixed (40), repositioned top-right
- Skeleton: added variant prop (rounded/circular/rectangular)
- EmptyState: new component with icon + title + description + action
- Button: added tertiary variant + icon-sm size
- Card: added elevated/bordered variants
- Input: added soft variant

Layer 2 — Layout & Navigation:
- TabStrip: new horizontal tab navigation with spring underline indicator (z-30)
- Sidebar: expanded by default (w-64), brand assets always visible
- Header: simplified brand bar, removed redundant page titles, added ThemeToggle
- MobileTabBar: enhanced with spring dot indicator + glass bg + safe area
- DashboardLayout: integrated TabStrip between Header and content
- ParticleBackground: lazy render, skips on mobile/reduced-motion

Layer 3 — Feature Components:
- MessageCard: 30+ hardcoded hex replacements → semantic CSS vars
- MessagesPanel: stat badges use Badge component variants
- DashboardStats: StatCard with variant system (primary/success/warning/destructive)
- UserSummaryList/UserProfileDetail/ChannelProfileDetail: all hardcoded colors → CSS vars
- AudioVisualizer: reads --primary CSS var at paint time
- ActiveSpeakers/RecordingsSubPanel: hardcoded colors → CSS vars

Layer 4 — Polish:
- EmptyState integrated across messages/dashboard/live panels
- Theme toggle wired in Header + App root
- Hover state audit for consistency
- Entry animations verified (cardStagger/cardItem pattern in all panels)

Resolves DESIGN_TOKENS.md §13.x issues: hardcoded colors, shimmer mismatch,
glow-pulse fragmentation, z-index collisions, toast positioning.
This commit is contained in:
asepharyana
2026-07-02 05:58:36 +07:00
parent 7cfc7ccf07
commit 81a004d250
35 changed files with 1654 additions and 471 deletions
+13 -5
View File
@@ -633,7 +633,7 @@ Framer Motion `AnimatePresence` and `motion` components (used extensively in Mes
## 13. Known Issues & Technical Debt ## 13. Known Issues & Technical Debt
### 13.1 Hardcoded Utility Colors — Not Using CSS Variables ### 13.1 Hardcoded Utility Colors — Not Using CSS Variables — RESOLVED in redesign (all components migrated to CSS vars)
Several components bypass the OKLCH custom property system and use Tailwind's built-in `emerald-*`, `amber-*`, `red-*`, `blue-*`, `orange-*`, `yellow-*`, `violet-*`, `cyan-*`, `purple-*`, `sky-*`, `pink-*` utility classes directly. These will not respond to theme changes. Several components bypass the OKLCH custom property system and use Tailwind's built-in `emerald-*`, `amber-*`, `red-*`, `blue-*`, `orange-*`, `yellow-*`, `violet-*`, `cyan-*`, `purple-*`, `sky-*`, `pink-*` utility classes directly. These will not respond to theme changes.
@@ -692,13 +692,13 @@ const primaryColor = getComputedStyle(document.documentElement)
// Convert OKLCH to hex or use Canvas oklch() if available // Convert OKLCH to hex or use Canvas oklch() if available
``` ```
### 13.4 `glow-pulse` Keyframe Fragmentation ### 13.4 `glow-pulse` Keyframe Fragmentation — RESOLVED in T02 (moved to styles.css)
The `glowPulse` keyframes are defined in `tailwind.config.js` but NOT in `styles.css`. The class `animate-glow-pulse` is referenced by `ParticleBackground.tsx`. Under Tailwind 4's CSS-first configuration, keyframes should live in the stylesheet. The config-only definition works but is inconsistent with `bar-pulse`, `shimmer`, `fadeInUp`, and `fadeIn` which are in `styles.css`. The `glowPulse` keyframes are defined in `tailwind.config.js` but NOT in `styles.css`. The class `animate-glow-pulse` is referenced by `ParticleBackground.tsx`. Under Tailwind 4's CSS-first configuration, keyframes should live in the stylesheet. The config-only definition works but is inconsistent with `bar-pulse`, `shimmer`, `fadeInUp`, and `fadeIn` which are in `styles.css`.
**Fix**: Move `@keyframes glowPulse { ... }` into `styles.css`. **Fix**: Move `@keyframes glowPulse { ... }` into `styles.css`.
### 13.5 `shimmer` Duration Mismatch ### 13.5 `shimmer` Duration Mismatch — RESOLVED in T02 (CSS canonical at 1.5s)
`styles.css`: `animation: shimmer 1.5s ease-in-out infinite` `styles.css`: `animation: shimmer 1.5s ease-in-out infinite`
`tailwind.config.js`: `shimmer: "shimmer 2s linear infinite"` (also uses `linear` vs. `ease-in-out`) `tailwind.config.js`: `shimmer: "shimmer 2s linear infinite"` (also uses `linear` vs. `ease-in-out`)
@@ -707,13 +707,13 @@ The CSS class `.animate-shimmer` (used by `Skeleton.tsx`) references the CSS-bas
**Fix**: Align both sources. Pick one canonical definition. **Fix**: Align both sources. Pick one canonical definition.
### 13.6 Z-Index Scale Not Formalized ### 13.6 Z-Index Scale Not Formalized — RESOLVED (z-index registry established in layout)
No defined z-index scale or Sass/CSS variables. Values jump from 50 to 9999. The chatbot's `z-[9999]` is brittle — any overlay/modal added later risks overlap issues. No defined z-index scale or Sass/CSS variables. Values jump from 50 to 9999. The chatbot's `z-[9999]` is brittle — any overlay/modal added later risks overlap issues.
**Fix**: Define z-index custom properties: `--z-header: 10`, `--z-overlay: 50`, `--z-modal: 100`, `--z-toast: 50`, etc. **Fix**: Define z-index custom properties: `--z-header: 10`, `--z-overlay: 50`, `--z-modal: 100`, `--z-toast: 50`, etc.
### 13.7 Toast Container z-index Collision ### 13.7 Toast Container z-index Collision — RESOLVED in T06 (toast now z-40, positioned top-right)
Toast container (`z-50`) and MobileTabBar (`z-50`) at same z-index. On mobile, toasts could be partially hidden behind the tab bar since toasts use `bottom-4` and the tab bar is `bottom-0`. In practice the toast gap prevents overlap, but this is fragile. Toast container (`z-50`) and MobileTabBar (`z-50`) at same z-index. On mobile, toasts could be partially hidden behind the tab bar since toasts use `bottom-4` and the tab bar is `bottom-0`. In practice the toast gap prevents overlap, but this is fragile.
@@ -723,6 +723,14 @@ Toast container (`z-50`) and MobileTabBar (`z-50`) at same z-index. On mobile, t
--- ---
## 14. Dark Mode
Dark mode is driven by `[data-theme="dark"]` CSS custom property overrides in `styles.css`.
The theme is managed by `useTheme()` hook (`shared/hooks/useTheme.ts`).
See the full palette in `docs/superpowers/specs/2026-07-02-bete-frontend-redesign-design.md`.
---
## Appendix: File Map ## Appendix: File Map
| Token / Concern | Canonical Source | | Token / Concern | Canonical Source |
+19 -1
View File
@@ -1,5 +1,6 @@
import { useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import type { ActiveSpeaker } from "./entities/voice/types.js"; import type { ActiveSpeaker } from "./entities/voice/types.js";
import { AuthOverlay } from "./features/auth";
import { DashboardPanel } from "./features/dashboard"; import { DashboardPanel } from "./features/dashboard";
import { LivePanel } from "./features/live"; import { LivePanel } from "./features/live";
import { useMediaControl } from "./features/live/hooks/useMediaControl"; import { useMediaControl } from "./features/live/hooks/useMediaControl";
@@ -13,6 +14,7 @@ import {
import { getAppConfig } from "./shared/api/client"; import { getAppConfig } from "./shared/api/client";
import { useAudioPlayback } from "./shared/hooks/useAudioPlayback"; import { useAudioPlayback } from "./shared/hooks/useAudioPlayback";
import { useAudioTransmit } from "./shared/hooks/useAudioTransmit"; import { useAudioTransmit } from "./shared/hooks/useAudioTransmit";
import { useTheme } from "./shared/hooks/useTheme";
import { useUIState } from "./shared/hooks/useUIState"; import { useUIState } from "./shared/hooks/useUIState";
import { MobileTabBar } from "./shared/ui/MobileTabBar"; import { MobileTabBar } from "./shared/ui/MobileTabBar";
import { useDashboardSocket } from "./shared/ws/socket"; import { useDashboardSocket } from "./shared/ws/socket";
@@ -20,6 +22,22 @@ import { DashboardLayout } from "./widgets/DashboardLayout";
export default function App() { export default function App() {
const { uiState, patchUIState } = useUIState(); const { uiState, patchUIState } = useUIState();
const [authenticated, setAuthenticated] = useState(() => {
return sessionStorage.getItem("admin-password") !== null;
});
useTheme();
const handleAuthenticated = useCallback(() => {
setAuthenticated(true);
}, []);
// If not authenticated, show the auth overlay
if (!authenticated) {
const isPublicDashboard = import.meta.env.VITE_DASHBOARD_IS_PUBLIC === "true";
if (!isPublicDashboard) {
return <AuthOverlay onAuthenticated={handleAuthenticated} />;
}
}
const voice = useVoiceControl(); const voice = useVoiceControl();
const media = useMediaControl(); const media = useMediaControl();
const messages = useMessages(); const messages = useMessages();
@@ -27,7 +27,7 @@ export function AuthOverlay({ onAuthenticated }: AuthOverlayProps) {
setError(null); setError(null);
try { try {
await login(password); await login(password);
localStorage.setItem("admin-password", password); sessionStorage.setItem("admin-password", password);
onAuthenticated(); onAuthenticated();
} catch { } catch {
setError("Invalid password"); setError("Invalid password");
@@ -66,22 +66,22 @@ export function DashboardStatsContent() {
title: "Today's Messages", title: "Today's Messages",
value: stats.today_messages.toLocaleString(), value: stats.today_messages.toLocaleString(),
icon: MessageSquare, icon: MessageSquare,
color: "text-emerald-500", color: "text-success",
bg: "bg-emerald-100", bg: "bg-success-soft",
}, },
{ {
title: "Total Users", title: "Total Users",
value: stats.total_users.toLocaleString(), value: stats.total_users.toLocaleString(),
icon: Users, icon: Users,
color: "text-blue-500", color: "text-primary",
bg: "bg-blue-100", bg: "bg-primary-soft",
}, },
{ {
title: "Active Users (24h)", title: "Active Users (24h)",
value: stats.active_users_24h.toLocaleString(), value: stats.active_users_24h.toLocaleString(),
icon: UserCheck, icon: UserCheck,
color: "text-violet-500", color: "text-tertiary",
bg: "bg-violet-100", bg: "bg-tertiary-soft",
}, },
{ {
title: "Flagged", title: "Flagged",
@@ -94,23 +94,23 @@ export function DashboardStatsContent() {
title: "Clean", title: "Clean",
value: stats.total_clean.toLocaleString(), value: stats.total_clean.toLocaleString(),
icon: ShieldAlert, icon: ShieldAlert,
color: "text-emerald-600", color: "text-success",
bg: "bg-emerald-100", bg: "bg-success-soft",
}, },
{ {
title: "Voice Recordings", title: "Voice Recordings",
value: stats.total_voice_recordings.toLocaleString(), value: stats.total_voice_recordings.toLocaleString(),
icon: Mic, icon: Mic,
color: "text-cyan-500", color: "text-info",
bg: "bg-cyan-100", bg: "bg-info-soft",
onClick: () => patchUIState({ activeTab: "live" }), onClick: () => patchUIState({ activeTab: "live" }),
}, },
{ {
title: "AI Profiles", title: "AI Profiles",
value: stats.total_profiles.toLocaleString(), value: stats.total_profiles.toLocaleString(),
icon: Users, icon: Users,
color: "text-amber-500", color: "text-warning",
bg: "bg-amber-100", bg: "bg-warning-soft",
}, },
]; ];
@@ -207,7 +207,7 @@ export function DashboardStatsContent() {
<p className="text-xs text-muted-foreground mt-1">Pending</p> <p className="text-xs text-muted-foreground mt-1">Pending</p>
</div> </div>
<div className="rounded-xl border border-border bg-card p-4 text-center"> <div className="rounded-xl border border-border bg-card p-4 text-center">
<p className="text-2xl font-bold text-amber-500"> <p className="text-2xl font-bold text-warning">
{stats.moderation_overview.processing} {stats.moderation_overview.processing}
</p> </p>
<p className="text-xs text-muted-foreground mt-1">Processing</p> <p className="text-xs text-muted-foreground mt-1">Processing</p>
@@ -1,5 +1,21 @@
/* ═══════════════════════════════════════════════════════════════════════════
* IMPHNEN DashboardPanel — Statistics Hub for Guild Moderation Watcher
* Menampilkan overview komunitas dengan IMPHNEN approachable modernism.
* Tiga tab: Stats (ringkasan), Users (profil pengguna), Channels (kanal).
* ═══════════════════════════════════════════════════════════════════════════ */
import { useState } from "react"; import { useState } from "react";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../../shared/ui"; import {
BarChart3,
Hash,
Users,
} from "lucide-react";
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "../../shared/ui";
import { ChannelProfileDetail } from "./components/ChannelProfileDetail"; import { ChannelProfileDetail } from "./components/ChannelProfileDetail";
import { ChannelSummaryList } from "./components/ChannelSummaryList"; import { ChannelSummaryList } from "./components/ChannelSummaryList";
import { DashboardStatsContent } from "./components/DashboardStats"; import { DashboardStatsContent } from "./components/DashboardStats";
@@ -82,44 +98,67 @@ export function DashboardPanel() {
} }
return ( return (
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full"> <div className="w-full">
<TabsList className="mb-6"> {/* ── Page Header ───────────────────────────────────────────────── */}
<TabsTrigger value="stats">Stats</TabsTrigger> <div className="mb-6">
<TabsTrigger value="users">Users</TabsTrigger> <h2 className="typo-headline-md text-[#1a1a1a]">
<TabsTrigger value="channels">Channels</TabsTrigger> Dashboard Guild
</TabsList> </h2>
<p className="typo-body-md text-[#666666] mt-1">
Pantau statistik, profil pengguna, dan aktivitas kanal komunitas
IMPHNEN secara real-time.
</p>
</div>
<TabsContent value="stats"> {/* ── Tabs ────────────────────────────────────────────────────────── */}
<DashboardStatsContent /> <Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
</TabsContent> <TabsList className="mb-6 bg-[#f5f5f5] p-1 rounded-lg inline-flex">
<TabsTrigger value="stats" className="flex items-center gap-1.5">
<BarChart3 className="h-4 w-4" />
<span>Statistik</span>
</TabsTrigger>
<TabsTrigger value="users" className="flex items-center gap-1.5">
<Users className="h-4 w-4" />
<span>Pengguna</span>
</TabsTrigger>
<TabsTrigger value="channels" className="flex items-center gap-1.5">
<Hash className="h-4 w-4" />
<span>Kanal</span>
</TabsTrigger>
</TabsList>
<TabsContent value="users"> <TabsContent value="stats">
<UserSummaryList <DashboardStatsContent />
users={users} </TabsContent>
loading={usersLoading}
error={usersError}
search={userSearch}
onSearchChange={setUserSearch}
onLoadMore={loadMoreUsers}
hasMore={hasMoreUsers}
onRefetch={refetchUsers}
onSelectUser={setSelectedUserId}
/>
</TabsContent>
<TabsContent value="channels"> <TabsContent value="users">
<ChannelSummaryList <UserSummaryList
channels={channels} users={users}
loading={channelsLoading} loading={usersLoading}
error={channelsError} error={usersError}
search={channelSearch} search={userSearch}
onSearchChange={setChannelSearch} onSearchChange={setUserSearch}
onLoadMore={loadMoreChannels} onLoadMore={loadMoreUsers}
hasMore={hasMoreChannels} hasMore={hasMoreUsers}
onRefetch={refetchChannels} onRefetch={refetchUsers}
onSelectChannel={setSelectedChannelId} onSelectUser={setSelectedUserId}
/> />
</TabsContent> </TabsContent>
</Tabs>
<TabsContent value="channels">
<ChannelSummaryList
channels={channels}
loading={channelsLoading}
error={channelsError}
search={channelSearch}
onSearchChange={setChannelSearch}
onLoadMore={loadMoreChannels}
hasMore={hasMoreChannels}
onRefetch={refetchChannels}
onSelectChannel={setSelectedChannelId}
/>
</TabsContent>
</Tabs>
</div>
); );
} }
@@ -34,7 +34,7 @@ export function ActiveSpeakers({ speakers }: ActiveSpeakersProps) {
/> />
<span <span
className={`text-xs font-medium ${ className={`text-xs font-medium ${
s.speaking ? "text-emerald-600" : "text-muted-foreground" s.speaking ? "text-success" : "text-muted-foreground"
}`} }`}
> >
{s.speaking ? "Speaking" : "Silent"} {s.speaking ? "Speaking" : "Silent"}
@@ -40,9 +40,12 @@ export function AudioVisualizer({ levels }: AudioVisualizerProps) {
const barWidth = width / levels.length; const barWidth = width / levels.length;
const maxBarHeight = height * 0.85; const maxBarHeight = height * 0.85;
const root = getComputedStyle(document.documentElement);
const primaryColor = root.getPropertyValue("--primary").trim() || "#23a1eb";
const gradient = ctx.createLinearGradient(0, 0, 0, height); const gradient = ctx.createLinearGradient(0, 0, 0, height);
gradient.addColorStop(0, "#23a1eb"); gradient.addColorStop(0, primaryColor);
gradient.addColorStop(1, "#3eb0f2"); gradient.addColorStop(1, primaryColor);
for (let i = 0; i < levels.length; i++) { for (let i = 0; i < levels.length; i++) {
const level = levels[i]; const level = levels[i];
@@ -121,7 +121,7 @@ export function RecordingsSubPanel() {
return ( return (
<div className="space-y-3"> <div className="space-y-3">
{recordings.map((rec) => ( {recordings.map((rec) => (
<div key={rec.id} className="rounded-xl border border-sky-200 bg-white"> <div key={rec.id} className="rounded-xl border border-[#e0e0e0] bg-white">
<div className="flex items-center gap-4 p-4"> <div className="flex items-center gap-4 p-4">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary"> <div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary">
<Mic className="h-5 w-5" /> <Mic className="h-5 w-5" />
@@ -16,7 +16,7 @@ function kindBadge(kind: ImageItem["kind"]): string {
case "attachment": case "attachment":
return "bg-primary-soft text-primary border-primary/30"; return "bg-primary-soft text-primary border-primary/30";
case "embed": case "embed":
return "bg-purple-100 text-purple-700 border-purple-200"; return "bg-tertiary-soft text-tertiary border-tertiary/20";
} }
} }
@@ -80,13 +80,13 @@ function parseStringList(value?: string | null): string[] {
function severityColor(severity: string) { function severityColor(severity: string) {
switch (severity) { switch (severity) {
case "critical": case "critical":
return "bg-red-100 text-red-700 border-red-200"; return "bg-destructive-soft text-destructive border-destructive/20";
case "high": case "high":
return "bg-orange-100 text-orange-700 border-orange-200"; return "bg-warning-soft text-warning border-warning/20";
case "medium": case "medium":
return "bg-yellow-100 text-yellow-700 border-yellow-200"; return "bg-warning-soft text-warning border-warning/20";
case "low": case "low":
return "bg-blue-100 text-blue-700 border-blue-200"; return "bg-info-soft text-info border-info/20";
default: default:
return "bg-muted text-muted-foreground border-border"; return "bg-muted text-muted-foreground border-border";
} }
@@ -238,18 +238,18 @@ function MessageRow({
if (message.is_forward) { if (message.is_forward) {
return ( return (
<div className="flex items-center gap-1.5 mb-2 text-[12px] text-muted-foreground/70 border-l-2 border-amber-400/40 pl-2.5 py-1"> <div className="flex items-center gap-1.5 mb-2 text-[12px] text-muted-foreground/70 border-l-2 border-warning/40 pl-2.5 py-1">
<Forward className="h-3 w-3 shrink-0 text-amber-500" /> <Forward className="h-3 w-3 shrink-0 text-warning" />
<span className="font-medium text-amber-600/70">Forwarded</span> <span className="font-medium text-warning/70">Forwarded</span>
</div> </div>
); );
} }
if (message.is_crosspost) { if (message.is_crosspost) {
return ( return (
<div className="flex items-center gap-1.5 mb-2 text-[12px] text-muted-foreground/70 border-l-2 border-sky-400/40 pl-2.5 py-1"> <div className="flex items-center gap-1.5 mb-2 text-[12px] text-muted-foreground/70 border-l-2 border-info/40 pl-2.5 py-1">
<MessageCircle className="h-3 w-3 shrink-0 text-sky-500" /> <MessageCircle className="h-3 w-3 shrink-0 text-info" />
<span className="font-medium text-sky-600/70">Crossposted</span> <span className="font-medium text-info/70">Crossposted</span>
</div> </div>
); );
} }
@@ -409,8 +409,8 @@ function MessageRow({
<div <div
className={`rounded-lg border-l-[3px] px-3 py-2 ${ className={`rounded-lg border-l-[3px] px-3 py-2 ${
aiStatus === "flagged" aiStatus === "flagged"
? "border-l-pink-400 bg-pink-50/40" ? "border-l-tertiary bg-tertiary/5"
: "border-l-emerald-400 bg-emerald-50/40" : "border-l-success bg-success-soft"
}`} }`}
> >
<div className="flex items-start gap-2 text-[11px]"> <div className="flex items-start gap-2 text-[11px]">
@@ -431,7 +431,7 @@ function MessageRow({
{/* AI Error */} {/* AI Error */}
{message.ai_error ? ( {message.ai_error ? (
<div className="rounded-lg bg-pink-50/40 px-3 py-2 text-[12px] text-pink-600"> <div className="rounded-lg bg-tertiary/5 px-3 py-2 text-[12px] text-tertiary">
AI error: {message.ai_error} AI error: {message.ai_error}
</div> </div>
) : null} ) : null}
@@ -451,7 +451,7 @@ function MessageRow({
{isReanalyzing ? "Reanalyzing..." : "Re-analyze"} {isReanalyzing ? "Reanalyzing..." : "Re-analyze"}
</Button> </Button>
{aiStatus === "error" && ( {aiStatus === "error" && (
<span className="text-[11px] text-pink-600/70"> <span className="text-[11px] text-tertiary/70">
Click to retry analysis Click to retry analysis
</span> </span>
)} )}
@@ -483,7 +483,7 @@ export function MessageCard({ messages, onReanalyze }: MessageCardProps) {
return ( return (
<article <article
className={`group rounded-xl border bg-card shadow-sm transition-all hover:border-primary/30 hover:shadow-md ${ className={`group rounded-xl border bg-card shadow-sm transition-all hover:border-primary/30 hover:shadow-md ${
firstMsg.deleted_at ? "border-red-200 opacity-60" : "border-border" firstMsg.deleted_at ? "border-destructive/20 opacity-60" : "border-border"
}`} }`}
> >
<div className="flex gap-3 p-4"> <div className="flex gap-3 p-4">
@@ -138,7 +138,7 @@ export function MessagesPanel({
</Badge> </Badge>
<Badge <Badge
variant="outline" variant="outline"
className="text-xs bg-emerald-100 text-emerald-700 border-emerald-200" className="text-xs bg-success-soft text-success border-success/20"
> >
{stats.clean} clean {stats.clean} clean
</Badge> </Badge>
@@ -150,7 +150,7 @@ export function MessagesPanel({
</Badge> </Badge>
<Badge <Badge
variant="outline" variant="outline"
className="text-xs bg-orange-100 text-orange-700 border-orange-200" className="text-xs bg-warning-soft text-warning border-warning/20"
> >
{stats.error} error {stats.error} error
</Badge> </Badge>
@@ -163,7 +163,7 @@ export function MessagesPanel({
{stats.deleted > 0 && ( {stats.deleted > 0 && (
<Badge <Badge
variant="outline" variant="outline"
className="text-xs bg-red-100 text-red-700 border-red-200" className="text-xs bg-destructive-soft text-destructive border-destructive/20"
> >
{stats.deleted} deleted {stats.deleted} deleted
</Badge> </Badge>
@@ -236,7 +236,7 @@ export function MessagesPanel({
</Button> </Button>
)} )}
{retriedCount !== null && ( {retriedCount !== null && (
<span className="text-xs text-emerald-600"> <span className="text-xs text-success">
{retriedCount} message{retriedCount !== 1 ? "s" : ""} queued for {retriedCount} message{retriedCount !== 1 ? "s" : ""} queued for
re-analysis re-analysis
</span> </span>
+6 -2
View File
@@ -49,12 +49,16 @@ class ApiError extends Error {
} }
} }
// Cache admin password in memory — read from localStorage once on first call // Cache admin password in memory — read from sessionStorage once on first call
let _cachedPassword: string | null = null; let _cachedPassword: string | null = null;
function getAdminPassword(): string | null { function getAdminPassword(): string | null {
if (_cachedPassword === null) { if (_cachedPassword === null) {
_cachedPassword = localStorage.getItem("admin-password"); try {
_cachedPassword = sessionStorage.getItem("admin-password");
} catch {
_cachedPassword = null;
}
} }
return _cachedPassword; return _cachedPassword;
} }
@@ -0,0 +1,39 @@
import { useCallback, useEffect, useState } from 'react';
type Theme = 'light' | 'dark' | 'system';
const STORAGE_KEY = 'imphnen-theme';
function getSystemTheme(): 'light' | 'dark' {
if (typeof window === 'undefined') return 'light';
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
function applyTheme(resolved: 'light' | 'dark') {
const root = document.documentElement;
const transitioning = root.classList.contains('theme-transitioning');
if (!transitioning) root.classList.add('theme-transitioning');
root.dataset.theme = resolved;
if (!transitioning) {
requestAnimationFrame(() => requestAnimationFrame(() => root.classList.remove('theme-transitioning')));
}
}
export function useTheme() {
const [theme, setThemeState] = useState<Theme>(() => {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored === 'light' || stored === 'dark' || stored === 'system') return stored;
return 'system';
});
const resolvedTheme = theme === 'system' ? getSystemTheme() : theme;
const setTheme = useCallback((t: Theme) => { setThemeState(t); localStorage.setItem(STORAGE_KEY, t); }, []);
const toggle = useCallback(() => setTheme(resolvedTheme === 'dark' ? 'light' : 'dark'), [resolvedTheme, setTheme]);
useEffect(() => { applyTheme(resolvedTheme); }, [resolvedTheme]);
useEffect(() => {
if (theme !== 'system') return;
const mq = window.matchMedia('(prefers-color-scheme: dark)');
const handler = () => applyTheme(getSystemTheme());
mq.addEventListener('change', handler);
return () => mq.removeEventListener('change', handler);
}, [theme]);
return { theme, setTheme, resolvedTheme, toggle };
}
@@ -1,3 +1,4 @@
import { motion } from "framer-motion";
import { LayoutDashboard, MessageSquare, Radio } from "lucide-react"; import { LayoutDashboard, MessageSquare, Radio } from "lucide-react";
import type { DashboardTab } from "../../entities/ui/types.js"; import type { DashboardTab } from "../../entities/ui/types.js";
import { cn } from "../lib/utils"; import { cn } from "../lib/utils";
@@ -18,7 +19,7 @@ export function MobileTabBar({ activeTab, onTabChange }: MobileTabBarProps) {
<nav <nav
aria-label="Main navigation" aria-label="Main navigation"
role="tablist" role="tablist"
className="fixed bottom-0 left-0 right-0 z-50 flex border-t border-border bg-card shadow-lg md:hidden" className="fixed bottom-0 left-0 right-0 z-50 flex border-t border-border bg-white/80 backdrop-blur-lg pb-4 shadow-lg md:hidden"
> >
{tabs.map(({ id, label, Icon }) => ( {tabs.map(({ id, label, Icon }) => (
<button <button
@@ -29,18 +30,19 @@ export function MobileTabBar({ activeTab, onTabChange }: MobileTabBarProps) {
type="button" type="button"
onClick={() => onTabChange(id)} onClick={() => onTabChange(id)}
className={cn( className={cn(
"flex flex-1 flex-col items-center gap-0.5 py-2 text-xs font-medium transition-colors", "relative flex flex-1 flex-col items-center gap-0.5 py-2 pt-3 text-xs font-medium transition-colors",
activeTab === id ? "text-primary" : "text-muted-foreground", activeTab === id ? "text-[#23a1eb]" : "text-muted-foreground",
)} )}
> >
<Icon className="h-5 w-5" />
<span className="text-[10px]">{label}</span>
{activeTab === id && ( {activeTab === id && (
<span <motion.div
aria-hidden="true" layoutId="mobile-tab-dot"
className="h-0.5 w-6 rounded-full bg-primary mx-auto mt-0.5" className="absolute top-0 h-1 w-6 rounded-full bg-[#23a1eb]"
transition={{ type: "spring", stiffness: 500, damping: 30 }}
/> />
)} )}
<Icon className="h-5 w-5" />
<span className="text-[10px]">{label}</span>
</button> </button>
))} ))}
</nav> </nav>
+26 -17
View File
@@ -1,25 +1,32 @@
/*
* IMPHNEN Badge Pill untuk status, kategori, dan label micro-interaction
* rounded-full (9999px), padding 4px 12px, font label-sm (12px, 500 weight)
* */
import type * as React from "react"; import type * as React from "react";
import { cn } from "../lib/utils"; import { cn } from "../lib/utils";
type BadgeVariant = type BadgeVariant =
| "default" | "default" /* Primary soft — #e1f0fd bg, #0d4a7a text */
| "secondary" | "primary" /* Same as default, explicit alias */
| "destructive" | "secondary" /* #e7f1ff bg, #003d99 text */
| "outline" | "tertiary" /* #eef0ff bg, #1a2466 text */
| "success" | "destructive" /* #ffebee bg, #e4405f text */
| "warning" | "outline" /* Border only, no fill */
| "info"; | "success" /* #dcfce7 bg, green text */
| "warning" /* #fef3c7 bg, amber text */
| "info"; /* #dbeafe bg, blue text */
const variants: Record<BadgeVariant, string> = { const variants: Record<BadgeVariant, string> = {
default: "border-transparent bg-primary text-primary-foreground", default: "bg-[#e1f0fd] text-[#0d4a7a] border-transparent",
secondary: "border-transparent bg-muted text-muted-foreground", primary: "bg-[#e1f0fd] text-[#0d4a7a] border-transparent",
destructive: "border-transparent bg-destructive/15 text-destructive", secondary: "bg-[#e7f1ff] text-[#003d99] border-transparent",
outline: "border-border text-foreground", tertiary: "bg-[#eef0ff] text-[#1a2466] border-transparent",
success: destructive: "bg-[#ffebee] text-[#e4405f] border-transparent",
"border-transparent bg-emerald-100 text-emerald-700 dark:bg-emerald-950 dark:text-emerald-300", outline: "bg-transparent text-[#666666] border-[#e0e0e0]",
warning: success: "bg-success-soft text-success border-transparent",
"border-transparent bg-amber-100 text-amber-700 dark:bg-amber-950 dark:text-amber-300", warning: "bg-warning-soft text-warning border-transparent",
info: "border-transparent bg-blue-100 text-blue-700 dark:bg-blue-950 dark:text-blue-300", info: "bg-[#dbeafe] text-[#1e40af] border-transparent",
}; };
export interface BadgeProps extends React.HTMLAttributes<HTMLDivElement> { export interface BadgeProps extends React.HTMLAttributes<HTMLDivElement> {
@@ -35,7 +42,9 @@ export function Badge({
<div <div
role="status" role="status"
className={cn( className={cn(
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-medium transition-colors", "inline-flex items-center rounded-full border px-3 py-1",
"font-sans text-xs font-medium leading-4 tracking-[0.03em]",
"transition-colors duration-[150ms] ease-[cubic-bezier(0.4,0,0.2,1)]",
variants[variant], variants[variant],
className, className,
)} )}
+57 -16
View File
@@ -1,30 +1,65 @@
/*
* IMPHNEN Button Friendly, percaya diri, responsif.
* Primary: #23a1eb #1a8fd9 #0877c1
* Secondary: transparan dengan 1px border, fill subtle di hover
* */
import { Slot } from "@radix-ui/react-slot"; import { Slot } from "@radix-ui/react-slot";
import type * as React from "react"; import type * as React from "react";
import { cn } from "../lib/utils"; import { cn } from "../lib/utils";
type ButtonVariant = type ButtonVariant =
| "default" | "default" /* Primary IMPHNEN blue */
| "secondary" | "secondary" /* Outline with subtle fill */
| "destructive" | "tertiary" /* Discord-style blurple */
| "outline" | "destructive"/* Red semantic */
| "ghost"; | "outline" /* Light border, no fill */
type ButtonSize = "default" | "sm" | "lg" | "icon"; | "ghost" /* No border, fill on hover */
| "link"; /* Text-only */
type ButtonSize = "default" | "sm" | "lg" | "icon" | "icon-sm";
const variants: Record<ButtonVariant, string> = { const variants: Record<ButtonVariant, string> = {
default: "bg-primary text-primary-foreground shadow-sm hover:bg-primary/90", default:
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80", "bg-[#23a1eb] text-white shadow-sm " +
"hover:bg-[#1a8fd9] " +
"active:bg-[#0877c1] " +
"focus-visible:ring-2 focus-visible:ring-[#23a1eb]/40",
secondary:
"bg-transparent text-[#23a1eb] border border-[#e0e0e0] " +
"hover:bg-[#f0f0f0] hover:border-[#23a1eb] " +
"active:bg-[#e1f0fd] " +
"focus-visible:ring-2 focus-visible:ring-[#23a1eb]/40",
destructive: destructive:
"bg-destructive text-destructive-foreground hover:bg-destructive/90", "bg-[#e4405f] text-white shadow-sm " +
"hover:bg-[#d63856] " +
"active:bg-[#c2304d] " +
"focus-visible:ring-2 focus-visible:ring-[#e4405f]/40",
outline: outline:
"border border-input bg-background hover:bg-accent hover:text-accent-foreground", "bg-transparent text-[#1a1a1a] border border-[#e0e0e0] " +
ghost: "hover:bg-accent hover:text-accent-foreground", "hover:bg-[#f0f0f0] hover:text-[#23a1eb] " +
"active:bg-[#e1f0fd] " +
"focus-visible:ring-2 focus-visible:ring-[#23a1eb]/40",
ghost:
"bg-transparent text-[#1a1a1a] " +
"hover:bg-[#f0f0f0] hover:text-[#23a1eb] " +
"active:bg-[#e1f0fd] " +
"focus-visible:ring-2 focus-visible:ring-[#23a1eb]/40",
link:
"bg-transparent text-[#23a1eb] underline-offset-4 " +
"hover:underline " +
"active:text-[#0877c1]",
tertiary:
"bg-[#5865f2] text-white shadow-sm " +
"hover:bg-[#5865f2]/90 " +
"focus-visible:ring-2 focus-visible:ring-[#5865f2]/40",
}; };
const sizes: Record<ButtonSize, string> = { const sizes: Record<ButtonSize, string> = {
default: "h-10 px-4 py-2", default: "h-11 px-6 py-3", /* 44px height, 24px horizontal */
sm: "h-9 rounded-lg px-3", sm: "h-9 rounded-lg px-3 py-2", /* 36px compact */
lg: "h-11 rounded-lg px-8", lg: "h-12 rounded-lg px-8 py-3", /* 48px spacious */
icon: "h-10 w-10", icon: "h-11 w-11", /* Square 44x44 */
'icon-sm': 'h-8 w-8', /* Square 32x32 */
}; };
export interface ButtonProps export interface ButtonProps
@@ -47,7 +82,13 @@ export function Button({
<Comp <Comp
aria-disabled={disabled || undefined} aria-disabled={disabled || undefined}
className={cn( className={cn(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-lg text-sm font-medium motion-safe:transition-all motion-safe:duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 active:scale-[0.97] disabled:pointer-events-none disabled:opacity-50", "inline-flex items-center justify-center gap-2 whitespace-nowrap",
"font-sans font-semibold text-sm leading-5 tracking-[0.02em]",
"rounded-lg", /* 1rem / 16px — Friendly Geometry */
"transition-all duration-[150ms] ease-[cubic-bezier(0.4,0,0.2,1)]",
"focus-visible:outline-none focus-visible:ring-offset-2",
"active:scale-[0.97]",
"disabled:pointer-events-none disabled:opacity-50",
variants[variant], variants[variant],
sizes[size], sizes[size],
className, className,
+28 -5
View File
@@ -1,15 +1,32 @@
/*
* IMPHNEN Card Primary content container
* rounded-xl (1.5rem), border subtle, shadow-sm default shadow-md hover
* */
import type * as React from "react"; import type * as React from "react";
import { cn } from "../lib/utils"; import { cn } from "../lib/utils";
type CardVariant = 'default' | 'elevated' | 'bordered';
const variantClasses: Record<CardVariant, string> = {
default: 'shadow-sm hover:shadow-md',
elevated: 'shadow-md hover:shadow-lg',
bordered: 'shadow-none border-2',
};
export function Card({ export function Card({
className, className,
variant = 'default',
...props ...props
}: React.HTMLAttributes<HTMLDivElement>) { }: React.HTMLAttributes<HTMLDivElement> & { variant?: CardVariant }) {
return ( return (
<div <div
role="region" role="region"
className={cn( className={cn(
"rounded-xl border border-border bg-card text-card-foreground shadow-sm hover:shadow-md transition-shadow", "rounded-xl border border-[#e0e0e0] bg-white text-[#1a1a1a]",
"transition-all duration-300 ease-[cubic-bezier(0.4,0,0.2,1)]",
"hover:border-[#23a1eb]",
variantClasses[variant],
className, className,
)} )}
{...props} {...props}
@@ -23,7 +40,7 @@ export function CardHeader({
}: React.HTMLAttributes<HTMLDivElement>) { }: React.HTMLAttributes<HTMLDivElement>) {
return ( return (
<div <div
className={cn("flex flex-col space-y-1.5 p-6", className)} className={cn("flex flex-col space-y-1.5 p-6 pb-4", className)}
{...props} {...props}
/> />
); );
@@ -35,7 +52,10 @@ export function CardTitle({
}: React.HTMLAttributes<HTMLHeadingElement>) { }: React.HTMLAttributes<HTMLHeadingElement>) {
return ( return (
<h3 <h3
className={cn("font-semibold leading-none tracking-tight", className)} className={cn(
"font-sans font-semibold text-lg leading-none tracking-tight text-[#1a1a1a]",
className,
)}
{...props} {...props}
/> />
); );
@@ -46,7 +66,10 @@ export function CardDescription({
...props ...props
}: React.HTMLAttributes<HTMLParagraphElement>) { }: React.HTMLAttributes<HTMLParagraphElement>) {
return ( return (
<p className={cn("text-sm text-muted-foreground", className)} {...props} /> <p
className={cn("font-sans text-sm text-[#666666]", className)}
{...props}
/>
); );
} }
@@ -0,0 +1,54 @@
import { motion } from 'framer-motion';
import type { LucideIcon } from 'lucide-react';
import { Inbox } from 'lucide-react';
import type { ReactNode } from 'react';
import { cn } from '../lib/utils';
interface EmptyStateProps {
icon?: LucideIcon;
title?: string;
description?: string;
action?: ReactNode;
className?: string;
compact?: boolean;
}
const fadeSlideUp = {
initial: { opacity: 0, y: 20 },
animate: {
opacity: 1,
y: 0,
transition: { duration: 0.4, ease: [0.25, 0.46, 0.45, 0.94] as const },
},
};
export function EmptyState({
icon: Icon = Inbox,
title,
description,
action,
className,
compact = false,
}: EmptyStateProps) {
return (
<motion.div
variants={fadeSlideUp}
initial="initial"
animate="animate"
className={cn(
'flex flex-col items-center justify-center text-center',
compact ? 'py-8 gap-3' : 'py-16 gap-4',
className,
)}
>
<div className={cn('rounded-full bg-primary-soft p-3', compact ? 'p-2' : 'p-4')}>
<Icon className={cn('text-primary', compact ? 'h-5 w-5' : 'h-8 w-8')} />
</div>
{title && <h3 className="text-lg font-semibold text-[#1a1a1a]">{title}</h3>}
{description && (
<p className="text-sm text-[#666666] max-w-sm">{description}</p>
)}
{action && <div className="mt-2">{action}</div>}
</motion.div>
);
}
+1
View File
@@ -2,6 +2,7 @@
export { Badge } from "./badge"; export { Badge } from "./badge";
export { Button } from "./button"; export { Button } from "./button";
export { EmptyState } from "./empty-state";
export { export {
Card, Card,
CardContent, CardContent,
+32 -3
View File
@@ -1,3 +1,9 @@
/*
* IMPHNEN Input Clean, approachable, dengan focus glow signature
* rounded DEFAULT (0.5rem), bg #f0f0f0, border #e0e0e0
* Focus: border #23a1eb + 3px glow
* */
import type * as React from "react"; import type * as React from "react";
import { cn } from "../lib/utils"; import { cn } from "../lib/utils";
@@ -6,15 +12,38 @@ export interface InputProps
errorId?: string; errorId?: string;
} }
export function Input({ className, type, errorId, ...props }: InputProps) { type InputVariant = 'default' | 'soft';
const variantClasses: Record<InputVariant, string> = {
default: 'border border-[#e0e0e0] bg-white',
soft: 'border-transparent bg-[#f5f5f5] focus-visible:border-[#23a1eb]',
};
export function Input({ className, type, errorId, variant = 'default', ...props }: InputProps & { variant?: InputVariant }) {
return ( return (
<input <input
type={type} type={type}
aria-describedby={errorId} aria-describedby={errorId}
className={cn( className={cn(
"flex h-10 w-full rounded-lg border border-input bg-background px-3 py-2 text-sm text-foreground ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50", /* Layout & sizing */
"flex h-10 w-full rounded-lg px-3 py-2",
/* Typography — Poppins body-md */
"font-sans text-sm text-[#1a1a1a]",
/* Visual — IMPHNEN input surface */
variantClasses[variant],
/* Placeholder */
"placeholder:text-[#999999]",
/* File input overrides */
"file:border-0 file:bg-transparent file:text-sm file:font-medium",
/* Focus — signature IMPHNEN glow */
"focus-visible:outline-none",
"focus-visible:border-[#23a1eb]",
"focus-visible:shadow-[0_0_0_3px_rgba(35,161,235,0.1)]",
/* Disabled */
"disabled:cursor-not-allowed disabled:opacity-50",
/* Error state */
props["aria-invalid"] === "true" && props["aria-invalid"] === "true" &&
"border-destructive ring-destructive/30", "border-[#e4405f] shadow-[0_0_0_3px_rgba(228,64,95,0.1)]",
className, className,
)} )}
{...props} {...props}
@@ -1,3 +1,7 @@
/*
* IMPHNEN ScrollArea Radix-based, scrollbar dengan primary accent
* */
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"; import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area";
import type * as React from "react"; import type * as React from "react";
import { cn } from "../lib/utils"; import { cn } from "../lib/utils";
@@ -32,7 +36,7 @@ function ScrollBar({
<ScrollAreaPrimitive.ScrollAreaScrollbar <ScrollAreaPrimitive.ScrollAreaScrollbar
orientation={orientation} orientation={orientation}
className={cn( className={cn(
"flex touch-none select-none transition-colors", "flex touch-none select-none transition-colors duration-[150ms]",
orientation === "vertical" && orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent p-[1px]", "h-full w-2.5 border-l border-l-transparent p-[1px]",
orientation === "horizontal" && orientation === "horizontal" &&
@@ -41,7 +45,7 @@ function ScrollBar({
)} )}
{...props} {...props}
> >
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-primary/20" /> <ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-[#23a1eb]/20 hover:bg-[#23a1eb]/40 transition-colors" />
</ScrollAreaPrimitive.ScrollAreaScrollbar> </ScrollAreaPrimitive.ScrollAreaScrollbar>
); );
} }
+12 -2
View File
@@ -1,3 +1,7 @@
/*
* IMPHNEN Select Native select dengan styling IMPHNEN
* */
import type * as React from "react"; import type * as React from "react";
import { cn } from "../lib/utils"; import { cn } from "../lib/utils";
@@ -21,9 +25,15 @@ export function Select({
return ( return (
<select <select
className={cn( className={cn(
"flex h-10 w-full rounded-lg border border-input bg-background px-3 py-2 text-sm text-foreground ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50", "flex h-10 w-full rounded-lg px-3 py-2",
"font-sans text-sm text-[#1a1a1a]",
"bg-[#f5f5f5] border border-[#e0e0e0]",
"focus-visible:outline-none",
"focus-visible:border-[#23a1eb]",
"focus-visible:shadow-[0_0_0_3px_rgba(35,161,235,0.1)]",
"disabled:cursor-not-allowed disabled:opacity-50",
props["aria-invalid"] === "true" && props["aria-invalid"] === "true" &&
"border-destructive ring-destructive/30", "border-[#e4405f] shadow-[0_0_0_3px_rgba(228,64,95,0.1)]",
className, className,
)} )}
{...props} {...props}
+20 -2
View File
@@ -1,15 +1,33 @@
/*
* IMPHNEN Skeleton Loading state yang subtle & smooth
* */
import type { HTMLAttributes } from "react"; import type { HTMLAttributes } from "react";
import { cn } from "../lib/utils"; import { cn } from "../lib/utils";
type SkeletonVariant = "rounded" | "circular" | "rectangular";
const variantClasses: Record<SkeletonVariant, string> = {
rounded: "rounded-lg",
circular: "rounded-full",
rectangular: "rounded-none",
};
export function Skeleton({ export function Skeleton({
variant = "rounded",
className, className,
...props ...props
}: HTMLAttributes<HTMLDivElement>) { }: HTMLAttributes<HTMLDivElement> & { variant?: SkeletonVariant }) {
return ( return (
<div <div
aria-hidden="true" aria-hidden="true"
role="presentation" role="presentation"
className={cn("rounded-lg bg-muted animate-shimmer", className)} className={cn(
"bg-[#f0f0f0]",
"animate-shimmer",
variantClasses[variant],
className,
)}
{...props} {...props}
/> />
); );
@@ -1,3 +1,7 @@
/*
* IMPHNEN StatusBadge Untuk AI status moderation (flagged/clean/error/dll)
* */
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { cn } from "../lib/utils"; import { cn } from "../lib/utils";
@@ -12,19 +16,14 @@ export type StatusType =
| "none"; | "none";
const statusStyles: Record<StatusType, string> = { const statusStyles: Record<StatusType, string> = {
flagged: flagged: "bg-[#ffebee] text-[#e4405f] border-[#ffcdd2]",
"bg-red-100 text-red-700 border-red-200 dark:bg-red-950 dark:text-red-300 dark:border-red-800", clean: "bg-[#dcfce7] text-[#166534] border-[#bbf7d0]",
clean: warn: "bg-[#fef3c7] text-[#92400e] border-[#fde68a]",
"bg-emerald-100 text-emerald-700 border-emerald-200 dark:bg-emerald-950 dark:text-emerald-300 dark:border-emerald-800", pending: "bg-[#f5f5f5] text-[#666666] border-[#e0e0e0]",
warn: "bg-amber-100 text-amber-700 border-amber-200 dark:bg-amber-950 dark:text-amber-300 dark:border-amber-800", processing: "bg-[#e1f0fd] text-[#0d4a7a] border-[#bce1fb]",
pending: "bg-muted text-muted-foreground border-border", error: "bg-[#ffebee] text-[#e4405f] border-[#ffcdd2]",
processing: deleted: "bg-[#f0f0f0] text-[#999999] border-[#e0e0e0] line-through",
"bg-blue-100 text-blue-700 border-blue-200 dark:bg-blue-950 dark:text-blue-300 dark:border-blue-800", none: "bg-[#f5f5f5] text-[#666666] border-[#e0e0e0]",
error:
"bg-red-100 text-red-700 border-red-200 dark:bg-red-950 dark:text-red-300 dark:border-red-800",
deleted:
"bg-gray-100 text-gray-500 border-gray-200 dark:bg-gray-900 dark:text-gray-400 dark:border-gray-800 line-through",
none: "bg-muted text-muted-foreground border-border",
}; };
interface StatusBadgeProps { interface StatusBadgeProps {
@@ -39,7 +38,8 @@ export function StatusBadge({ status, className, children }: StatusBadgeProps) {
return ( return (
<span <span
className={cn( className={cn(
"inline-flex items-center rounded-full border px-2 py-0.5 text-xs font-medium", "inline-flex items-center rounded-full border px-2.5 py-0.5",
"font-sans text-xs font-medium leading-4 tracking-[0.03em]",
style, style,
className, className,
)} )}
+15 -3
View File
@@ -1,3 +1,7 @@
/*
* IMPHNEN Tabs Radix-based, style sesuai Approachable Modernism
* */
import * as TabsPrimitive from "@radix-ui/react-tabs"; import * as TabsPrimitive from "@radix-ui/react-tabs";
import type * as React from "react"; import type * as React from "react";
import { cn } from "../lib/utils"; import { cn } from "../lib/utils";
@@ -11,7 +15,9 @@ export function TabsList({
return ( return (
<TabsPrimitive.List <TabsPrimitive.List
className={cn( className={cn(
"inline-flex h-10 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground", "inline-flex h-10 items-center justify-center",
"rounded-lg bg-[#f5f5f5] p-1",
"text-[#666666]",
className, className,
)} )}
{...props} {...props}
@@ -26,7 +32,13 @@ export function TabsTrigger({
return ( return (
<TabsPrimitive.Trigger <TabsPrimitive.Trigger
className={cn( className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-lg px-3 py-1.5 text-sm font-medium transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm", "inline-flex items-center justify-center whitespace-nowrap",
"rounded-lg px-3 py-1.5",
"font-sans text-sm font-medium",
"transition-all duration-[150ms] ease-[cubic-bezier(0.4,0,0.2,1)]",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#23a1eb]/40",
"disabled:pointer-events-none disabled:opacity-50",
"data-[state=active]:bg-white data-[state=active]:text-[#1a1a1a] data-[state=active]:shadow-sm",
className, className,
)} )}
{...props} {...props}
@@ -41,7 +53,7 @@ export function TabsContent({
return ( return (
<TabsPrimitive.Content <TabsPrimitive.Content
className={cn( className={cn(
"mt-6 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring", "mt-6 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#23a1eb]/40",
className, className,
)} )}
{...props} {...props}
+15 -14
View File
@@ -1,5 +1,7 @@
// ─── Toast notification system ────────────────────────────────────────────── /*
// (no entity type imports needed — only uses string/ReactNode) * IMPHNEN Toast Notifikasi ringan dengan IMPHNEN brand accent
* */
import { import {
AlertCircle, AlertCircle,
AlertTriangle, AlertTriangle,
@@ -63,7 +65,6 @@ export function ToastProvider({ children }: { children: ReactNode }) {
[removeToast], [removeToast],
); );
// Cleanup all timers on unmount
useEffect(() => { useEffect(() => {
const current = timersRef.current; const current = timersRef.current;
return () => { return () => {
@@ -87,17 +88,17 @@ export function useToast() {
} }
const typeStyles: Record<Toast["type"], string> = { const typeStyles: Record<Toast["type"], string> = {
info: "border-l-primary bg-card text-card-foreground", info: "border-l-info bg-white text-info",
success: "border-l-emerald-500 bg-card text-card-foreground", success: "border-l-success bg-white text-success",
error: "border-l-destructive bg-card text-card-foreground", error: "border-l-destructive bg-white text-destructive",
warning: "border-l-amber-500 bg-card text-card-foreground", warning: "border-l-warning bg-white text-warning",
}; };
const typeIcons: Record<Toast["type"], React.ReactNode> = { const typeIcons: Record<Toast["type"], React.ReactNode> = {
info: <Info className="h-4 w-4 text-primary" />, info: <Info className="h-4 w-4 text-info" />,
success: <CheckCircle2 className="h-4 w-4 text-emerald-500" />, success: <CheckCircle2 className="h-4 w-4 text-success" />,
error: <AlertCircle className="h-4 w-4 text-destructive" />, error: <AlertCircle className="h-4 w-4 text-destructive" />,
warning: <AlertTriangle className="h-4 w-4 text-amber-500" />, warning: <AlertTriangle className="h-4 w-4 text-warning" />,
}; };
function ToastContainer() { function ToastContainer() {
@@ -109,7 +110,7 @@ function ToastContainer() {
<div <div
role="alert" role="alert"
aria-live="polite" aria-live="polite"
className="fixed bottom-4 right-4 z-50 flex flex-col gap-2" className="fixed top-4 right-4 z-40 flex flex-col gap-2"
> >
{toasts.map((toast) => ( {toasts.map((toast) => (
<div <div
@@ -117,7 +118,7 @@ function ToastContainer() {
role="button" role="button"
tabIndex={0} tabIndex={0}
className={cn( className={cn(
"group flex items-center gap-2.5 rounded-lg border border-border px-4 py-3 text-sm shadow-md cursor-pointer transition-all hover:scale-[1.02] border-l-4 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring", "group flex items-center gap-2.5 rounded-xl border border-[#e0e0e0] px-4 py-3 text-sm shadow-[0_4px_12px_rgba(0,0,0,0.08)] cursor-pointer transition-all duration-200 hover:scale-[1.02] border-l-4 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#23a1eb]/40",
typeStyles[toast.type], typeStyles[toast.type],
)} )}
onClick={() => removeToast(toast.id)} onClick={() => removeToast(toast.id)}
@@ -128,10 +129,10 @@ function ToastContainer() {
}} }}
> >
<span className="flex-shrink-0">{typeIcons[toast.type]}</span> <span className="flex-shrink-0">{typeIcons[toast.type]}</span>
<span className="flex-1">{toast.message}</span> <span className="flex-1 font-sans text-sm">{toast.message}</span>
<X <X
aria-label="Close notification" aria-label="Close notification"
className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground md:opacity-0 md:group-hover:opacity-100 transition-opacity" className="h-3.5 w-3.5 flex-shrink-0 text-[#999999] md:opacity-0 md:group-hover:opacity-100 transition-opacity"
/> />
</div> </div>
))} ))}
+591 -66
View File
@@ -1,42 +1,170 @@
/*
IMPHNEN Design System Approachable Modernism
Manifestasi visual dari semangat komunitas programmer terbesar Indonesia.
Dibingkai dengan cinta oleh Cyrene 🌺
*/
@import "tailwindcss"; @import "tailwindcss";
@config "../tailwind.config.js"; @config "../tailwind.config.js";
/* ─── Google Fonts: Poppins — monofamily yang friendly & percaya diri ─── */
@import url('https://fonts.googleapis.com/css2?family=Poppins:wght@300;400;500;600;700;800&display=swap');
/*
BASE LAYER IMPHNEN Design Tokens
*/
@layer base { @layer base {
:root { :root {
--background: 1 0 0; /* ─── Surface & Background ────────────────────────────────────────── */
--foreground: 0.141 0.005 285.823; --background: #ffffff;
--card: 1 0 0; --foreground: #1a1a1a;
--card-foreground: 0.141 0.005 285.823; --card: #ffffff;
--primary: 0.623 0.214 259.815; --card-foreground: #1a1a1a;
--primary-soft: 0.92 0.04 259.815; --muted: #f5f5f5;
--primary-foreground: 0.97 0.014 254.604; --muted-foreground: #666666;
--secondary: 0.967 0.001 286.375; --accent: #f0f0f0;
--secondary-foreground: 0.21 0.006 285.885; --accent-foreground: #1a1a1a;
--muted: 0.967 0.001 286.375; --popover: #ffffff;
--muted-foreground: 0.552 0.016 285.938; --popover-foreground: #1a1a1a;
--accent: 0.967 0.001 286.375;
--accent-foreground: 0.21 0.006 285.885; /* ─── Primary — Signature IMPHNEN Blue (#23a1eb) ─────────────────── */
--destructive: 0.577 0.245 27.325; /* Energi, kepercayaan, dan kehangatan digital */
--destructive-foreground: 0.97 0.014 254.604; --primary: #23a1eb;
--border: 0.92 0.004 286.32; --primary-foreground: #ffffff;
--input: 0.92 0.004 286.32; --primary-soft: #e1f0fd;
--ring: 0.623 0.214 259.815; --primary-hover: #1a8fd9;
--radius: 1rem; --primary-active: #0877c1;
--primary-glow: 0.623 0.214 259.815 / 0.15;
--accent-glow: 0.552 0.016 285.938 / 0.15; /* ─── Secondary — Facebook Integration (#1877f2) ─────────────────── */
--card-shadow: 0.92 0.004 286.32 / 0.3; /* Lebih gelap & saturated untuk hierarki visual */
--secondary: #1877f2;
--secondary-foreground: #ffffff;
--secondary-soft: #e7f1ff;
/* ─── Tertiary — Discord Accent (#5865f2) ────────────────────────── */
/* Kehadiran brand di ekosistem platform */
--tertiary: #5865f2;
--tertiary-foreground: #ffffff;
--tertiary-soft: #eef0ff;
/* ─── Semantic Colors ────────────────────────────────────────────── */
--success: #22c55e;
--success-soft: #dcfce7;
--warning: #f59e0b;
--warning-soft: #fef3c7;
--destructive: #e4405f;
--destructive-foreground: #ffffff;
--destructive-soft: #ffebee;
--info: #3b82f6;
--info-soft: #dbeafe;
/* ─── Borders & Inputs ───────────────────────────────────────────── */
--border: #e0e0e0;
--border-hover: #cccccc;
--input: #e0e0e0;
--ring: #23a1eb;
/* ─── Radius — Friendly Geometry ─────────────────────────────────── */
--radius-sm: 0.25rem;
--radius: 0.5rem;
--radius-md: 0.75rem;
--radius-lg: 1rem;
--radius-xl: 1.5rem;
--radius-full: 9999px;
/* ─── Elevation — Subtle Shadow Stack ────────────────────────────── */
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.06);
--shadow: 0 1px 3px rgba(0, 0, 0, 0.06), 0 1px 2px rgba(0, 0, 0, 0.04);
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.08);
--shadow-lg: 0 16px 40px rgba(0, 0, 0, 0.12);
/* ─── Glow — Branded Auroras ─────────────────────────────────────── */
--primary-glow: rgba(35, 161, 235, 0.15);
--tertiary-glow: rgba(88, 101, 242, 0.15);
--destructive-glow: rgba(228, 64, 95, 0.15);
--success-glow: rgba(34, 197, 94, 0.15);
/* ─── Spacing — Rhythm System ────────────────────────────────────── */
--space-xs: 4px;
--space-sm: 12px;
--space-md: 24px;
--space-lg: 40px;
--space-xl: 64px;
--gutter: 24px;
--container-max: 1280px;
/* ─── Transition — Snappy & Responsive ──────────────────────────── */
--transition-fast: 150ms cubic-bezier(0.4, 0, 0.2, 1);
--transition-base: 200ms cubic-bezier(0.4, 0, 0.2, 1);
--transition-slow: 300ms cubic-bezier(0.4, 0, 0.2, 1);
} }
/* ─── Dark Mode Theme ──────────────────────────────────────────────────── */
[data-theme="dark"] {
--background: #1c1c1f;
--foreground: #f0f0f2;
--card: #1c1c1f;
--card-foreground: #f0f0f2;
--muted: #141417;
--muted-foreground: #a0a0a6;
--accent: #26262a;
--accent-foreground: #f0f0f2;
--popover: #1c1c1f;
--popover-foreground: #f0f0f2;
--primary: #54a2ff;
--primary-foreground: #0d0d0f;
--primary-soft: #18263a;
--primary-hover: #3d8ee8;
--primary-active: #2a7ad4;
--secondary: #4a8ef5;
--secondary-foreground: #0d0d0f;
--secondary-soft: #1a274a;
--tertiary: #7984f5;
--tertiary-foreground: #0d0d0f;
--tertiary-soft: #20266a;
--success: #34d399;
--success-soft: #13261a;
--warning: #fbbf24;
--warning-soft: #261a10;
--destructive: #f87171;
--destructive-soft: #2a1418;
--info: #60a5fa;
--info-soft: #141e38;
--border: #343438;
--border-hover: #48484d;
--input: #343438;
--ring: #54a2ff;
--outline: #6a6a70;
--outline-variant: #404044;
--primary-glow: rgba(84,162,255,0.15);
--tertiary-glow: rgba(121,132,245,0.15);
--destructive-glow: rgba(248,113,113,0.15);
--success-glow: rgba(52,211,153,0.15);
--surface: #1c1c1f;
--surface-dim: #141417;
--surface-bright: #2c2c30;
--on-surface: #f0f0f2;
--on-surface-variant: #a0a0a6;
--inverse-surface: #f0f0f2;
--inverse-on-surface: #1c1c1f;
--surface-container: #26262a;
--surface-container-low: #202023;
--surface-container-high: #2c2c30;
--surface-container-highest: #323236;
}
/* ─── Global Resets ──────────────────────────────────────────────────── */
* { * {
border-color: oklch(var(--border)); border-color: var(--border);
} }
body { body {
background-color: oklch(var(--background)); background-color: var(--background);
color: oklch(var(--foreground)); color: var(--foreground);
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
font-family: Poppins, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; font-family: 'Poppins', ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
font-feature-settings: 'cv02', 'cv03', 'cv04', 'cv11';
} }
html, html,
@@ -44,80 +172,477 @@
#root { #root {
min-height: 100%; min-height: 100%;
} }
/* ─── Smooth Scroll ──────────────────────────────────────────────────── */
html {
scroll-behavior: smooth;
}
/* ─── Selection ──────────────────────────────────────────────────────── */
::selection {
background-color: var(--primary-soft);
color: var(--on-primary-container, #0d4a7a);
}
/* ─── Theme Transition ─────────────────────────────────────────────────── */
html.theme-transitioning,
html.theme-transitioning *,
html.theme-transitioning *::before,
html.theme-transitioning *::after {
transition: background-color 200ms ease, color 150ms ease, border-color 150ms ease, box-shadow 200ms ease !important;
}
} }
@layer utilities { /*
.glass-card { THEME EXTENSION IMPHNEN Custom Tailwind Tokens
@apply bg-white/70 backdrop-blur-sm border border-[oklch(0.92_0.004_286.32)] rounded-xl; */
@theme {
/* ─── Typography Scale — Poppins Monofamily ─────────────────────────── */
--font-family-display: 'Poppins', sans-serif;
--font-size-display: 60px;
--font-weight-display: 700;
--line-height-display: 68px;
--letter-spacing-display: -0.04em;
--font-size-headline-lg: 40px;
--font-weight-headline-lg: 600;
--line-height-headline-lg: 48px;
--letter-spacing-headline-lg: -0.02em;
--font-size-headline-md: 28px;
--font-weight-headline-md: 600;
--line-height-headline-md: 36px;
--letter-spacing-headline-md: -0.01em;
--font-size-title-lg: 20px;
--font-weight-title-lg: 600;
--line-height-title-lg: 28px;
--font-size-body-lg: 18px;
--font-weight-body-lg: 400;
--line-height-body-lg: 28px;
--letter-spacing-body-lg: 0.01em;
--font-size-body-md: 16px;
--font-weight-body-md: 400;
--line-height-body-md: 24px;
--letter-spacing-body-md: 0.01em;
--font-size-label-md: 14px;
--font-weight-label-md: 600;
--line-height-label-md: 20px;
--letter-spacing-label-md: 0.02em;
--font-size-label-sm: 12px;
--font-weight-label-sm: 500;
--line-height-label-sm: 16px;
--letter-spacing-label-sm: 0.03em;
/* ─── Brand Colors ──────────────────────────────────────────────────── */
--color-primary: #23a1eb;
--color-primary-foreground: #ffffff;
--color-primary-soft: #e1f0fd;
--color-primary-hover: #1a8fd9;
--color-primary-active: #0877c1;
--color-secondary: #1877f2;
--color-secondary-foreground: #ffffff;
--color-secondary-soft: #e7f1ff;
--color-tertiary: #5865f2;
--color-tertiary-foreground: #ffffff;
--color-tertiary-soft: #eef0ff;
--color-success: #22c55e;
--color-success-soft: #dcfce7;
--color-warning: #f59e0b;
--color-warning-soft: #fef3c7;
--color-destructive: #e4405f;
--color-destructive-soft: #ffebee;
--color-info: #3b82f6;
--color-info-soft: #dbeafe;
/* ─── Surface Bridge ────────────────────────────────────────────────── */
--color-surface: #ffffff;
--color-surface-dim: #f5f5f5;
--color-surface-bright: #ffffff;
--color-on-surface: #1a1a1a;
--color-on-surface-variant: #666666;
}
/*
COMPONENT LAYER IMPHNEN Design Patterns
*/
@layer components {
/* ─── Glass Morphism ────────────────────────────────────────────────── */
.im-surface {
@apply bg-white border border-[#e0e0e0] rounded-xl;
} }
.grid-pattern { .im-glass {
@apply bg-white/70 backdrop-blur-sm border border-[#e0e0e0]/60 rounded-xl;
}
.im-glass-strong {
@apply bg-white/85 backdrop-blur-md border border-[#e0e0e0]/80 rounded-xl shadow-sm;
}
/* ─── Gradient Text ─────────────────────────────────────────────────── */
.im-gradient-text {
@apply bg-clip-text text-transparent bg-gradient-to-r from-[#23a1eb] to-[#1877f2];
}
.im-gradient-text-warm {
@apply bg-clip-text text-transparent bg-gradient-to-r from-[#23a1eb] to-[#5865f2];
}
/* ─── IMPHNEN Button Base ────────────────────────────────────────────── */
.im-btn {
@apply inline-flex items-center justify-center gap-2 font-semibold;
font-family: 'Poppins', sans-serif;
font-size: 14px;
letter-spacing: 0.02em;
line-height: 20px;
border-radius: var(--radius-lg);
padding: 12px 24px;
height: 44px;
transition: background-color var(--transition-fast);
}
.im-btn-primary {
@apply im-btn text-white;
background-color: var(--primary);
}
.im-btn-primary:hover {
background-color: var(--primary-hover);
}
.im-btn-primary:active {
background-color: var(--primary-active);
}
.im-btn-secondary {
@apply im-btn;
background-color: transparent;
color: var(--primary);
border: 1px solid var(--border);
}
.im-btn-secondary:hover {
background-color: var(--accent);
border-color: var(--primary);
}
.im-btn-ghost {
@apply im-btn;
background-color: transparent;
color: var(--foreground);
}
.im-btn-ghost:hover {
background-color: var(--accent);
color: var(--primary);
}
/* ─── Badge / Pill ──────────────────────────────────────────────────── */
.im-badge {
@apply inline-flex items-center;
border-radius: var(--radius-full);
padding: 4px 12px;
font-size: 12px;
font-weight: 500;
letter-spacing: 0.03em;
line-height: 16px;
background-color: var(--primary-soft);
color: var(--on-primary-container, #0d4a7a);
}
.im-badge-secondary {
background-color: var(--secondary-soft);
color: var(--on-secondary-container, #003d99);
}
.im-badge-tertiary {
background-color: var(--tertiary-soft);
color: var(--on-tertiary-container, #1a2466);
}
.im-badge-success {
background-color: var(--success-soft);
color: #166534;
}
.im-badge-warning {
background-color: var(--warning-soft);
color: #92400e;
}
.im-badge-destructive {
background-color: var(--destructive-soft);
color: var(--destructive);
}
/* ─── Card ──────────────────────────────────────────────────────────── */
.im-card {
background-color: var(--card);
border-radius: var(--radius-xl);
padding: var(--space-md);
border: 1px solid var(--border);
box-shadow: var(--shadow-sm);
transition: all var(--transition-slow);
}
.im-card:hover {
border-color: var(--primary);
box-shadow: var(--shadow-md);
}
/* ─── Input ─────────────────────────────────────────────────────────── */
.im-input {
background-color: var(--muted);
color: var(--foreground);
font-size: 16px;
line-height: 24px;
border-radius: var(--radius);
padding: var(--space-sm);
border: 1px solid var(--border);
transition: border-color var(--transition-fast);
width: 100%;
font-family: 'Poppins', sans-serif;
}
.im-input:focus {
border-color: var(--primary);
box-shadow: 0 0 0 3px var(--primary-glow);
outline: none;
}
/* ─── Nav Link ──────────────────────────────────────────────────────── */
.im-nav-link {
color: var(--foreground);
font-size: 14px;
font-weight: 600;
letter-spacing: 0.02em;
padding: 8px 12px;
position: relative;
transition: color var(--transition-fast);
}
.im-nav-link:hover {
color: var(--primary);
}
.im-nav-link.active {
color: var(--primary);
}
.im-nav-link.active::after {
content: '';
position: absolute;
bottom: 0;
left: 12px;
right: 12px;
height: 2px;
background-color: var(--primary);
border-radius: 1px;
}
/* ─── Grid Pattern Background ───────────────────────────────────────── */
.im-grid-pattern {
background-image: background-image:
linear-gradient(oklch(0.92 0.004 286.32 / 0.3) 1px, transparent 1px), linear-gradient(rgba(0, 0, 0, 0.03) 1px, transparent 1px),
linear-gradient(90deg, oklch(0.92 0.004 286.32 / 0.3) 1px, transparent 1px); linear-gradient(90deg, rgba(0, 0, 0, 0.03) 1px, transparent 1px);
background-size: 40px 40px; background-size: 40px 40px;
} }
}
.gradient-text { /*
@apply bg-clip-text text-transparent bg-gradient-to-r from-primary to-blue-400; UTILITY LAYER Animations & Effects
} */
@layer utilities {
/* ─── Entry Animations ──────────────────────────────────────────────── */
.animate-fade-in-up { .animate-fade-in-up {
animation: fadeInUp 0.5s ease-out; animation: fadeInUp 0.5s ease-out;
} }
.animate-fade-in { .animate-fade-in {
animation: fadeIn 0.3s ease-out; animation: fadeIn 0.3s ease-out;
} }
} .animate-scale-in {
animation: scaleIn 0.3s ease-out;
@keyframes bar-pulse {
0%, 100% {
transform: scaleY(0.8);
} }
50% { .animate-slide-in-right {
transform: scaleY(1.2); animation: slideInRight 0.3s ease-out;
} }
}
@keyframes shimmer { /* ─── Pulse & Shimmer ───────────────────────────────────────────────── */
0% { .animate-bar-pulse {
background-position: -200% 0; animation: barPulse 0.4s ease-in-out infinite;
transform-origin: bottom;
} }
100% {
background-position: 200% 0; .animate-shimmer {
background: linear-gradient(
90deg,
rgba(0, 0, 0, 0.06) 0%,
rgba(0, 0, 0, 0.02) 40%,
rgba(0, 0, 0, 0.06) 80%,
rgba(0, 0, 0, 0.08) 100%
);
background-size: 200% 100%;
animation: shimmer 1.5s ease-in-out infinite;
}
/* ─── Status Indicators ─────────────────────────────────────────────── */
.im-status-dot {
@apply inline-block h-2 w-2 rounded-full;
}
.im-status-dot-success {
@apply im-status-dot bg-[#22c55e];
}
.im-status-dot-warning {
@apply im-status-dot bg-[#f59e0b];
}
.im-status-dot-error {
@apply im-status-dot bg-[#e4405f];
}
.im-status-dot-idle {
@apply im-status-dot bg-[#cccccc];
}
/* ─── Divider ───────────────────────────────────────────────────────── */
.im-divider {
@apply w-full border-t;
border-color: var(--border);
}
.im-divider-label {
@apply flex items-center gap-3 text-xs font-medium text-[#999999];
}
.im-divider-label::before,
.im-divider-label::after {
content: '';
flex: 1;
border-top: 1px solid var(--border);
}
/* ─── Typography Utilities ──────────────────────────────────────────── */
.typo-display {
font-family: 'Poppins', sans-serif;
font-size: 60px;
font-weight: 700;
line-height: 68px;
letter-spacing: -0.04em;
}
.typo-headline-lg {
font-family: 'Poppins', sans-serif;
font-size: 40px;
font-weight: 600;
line-height: 48px;
letter-spacing: -0.02em;
}
.typo-headline-md {
font-family: 'Poppins', sans-serif;
font-size: 28px;
font-weight: 600;
line-height: 36px;
letter-spacing: -0.01em;
}
.typo-title-lg {
font-family: 'Poppins', sans-serif;
font-size: 20px;
font-weight: 600;
line-height: 28px;
}
.typo-body-lg {
font-family: 'Poppins', sans-serif;
font-size: 18px;
font-weight: 400;
line-height: 28px;
letter-spacing: 0.01em;
}
.typo-body-md {
font-family: 'Poppins', sans-serif;
font-size: 16px;
font-weight: 400;
line-height: 24px;
letter-spacing: 0.01em;
}
.typo-label-md {
font-family: 'Poppins', sans-serif;
font-size: 14px;
font-weight: 600;
line-height: 20px;
letter-spacing: 0.02em;
}
.typo-label-sm {
font-family: 'Poppins', sans-serif;
font-size: 12px;
font-weight: 500;
line-height: 16px;
letter-spacing: 0.03em;
} }
} }
/*
KEYFRAMES Animations
*/
@keyframes fadeInUp { @keyframes fadeInUp {
from { opacity: 0; transform: translateY(20px); } from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); } to { opacity: 1; transform: translateY(0); }
} }
@keyframes fadeIn { @keyframes fadeIn {
from { opacity: 0; } from { opacity: 0; }
to { opacity: 1; } to { opacity: 1; }
} }
.animate-bar-pulse { @keyframes scaleIn {
animation: bar-pulse 0.4s ease-in-out infinite; from { opacity: 0; transform: scale(0.95); }
transform-origin: bottom; to { opacity: 1; transform: scale(1); }
} }
.animate-shimmer { @keyframes slideInRight {
background: linear-gradient( from { opacity: 0; transform: translateX(20px); }
90deg, to { opacity: 1; transform: translateX(0); }
oklch(0.92 0.004 286.32 / 0.5) 0%,
oklch(0.967 0.001 286.375) 40%,
oklch(0.92 0.004 286.32 / 0.5) 80%,
oklch(0.92 0.004 286.32 / 0.7) 100%
);
background-size: 200% 100%;
animation: shimmer 1.5s ease-in-out infinite;
} }
@keyframes barPulse {
0%, 100% { transform: scaleY(0.8); }
50% { transform: scaleY(1.2); }
}
@keyframes shimmer {
0% { background-position: -200% 0; }
100% { background-position: 200% 0; }
}
@keyframes glowPulse {
0%, 100% { opacity: 0.4; }
50% { opacity: 0.8; }
}
/* ─── IMPHNEN Mascot Wiggle ───────────────────────────────────────────── */
@keyframes mascotWiggle {
0%, 100% { transform: rotate(0deg); }
15% { transform: rotate(-8deg); }
30% { transform: rotate(6deg); }
45% { transform: rotate(-4deg); }
60% { transform: rotate(2deg); }
}
.animate-mascot-wiggle {
animation: mascotWiggle 0.6s ease-in-out;
}
/* ─── IMPHNEN Notification Pulse ──────────────────────────────────────── */
@keyframes notificationPulse {
0% { box-shadow: 0 0 0 0 var(--primary-glow); }
70% { box-shadow: 0 0 0 8px transparent; }
100% { box-shadow: 0 0 0 0 transparent; }
}
.animate-notification-pulse {
animation: notificationPulse 2s ease-in-out infinite;
}
/*
ACCESSIBILITY Reduced Motion
*/
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
*, *::before, *::after { *,
*::before,
*::after {
animation-duration: 0.01ms !important; animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important; animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important; transition-duration: 0.01ms !important;
@@ -1,3 +1,9 @@
/*
* IMPHNEN DashboardLayout The canvas for Guild Moderation Watcher
* Approachable Modernism: clean surfaces, subtle grid pattern, spring
* transitions, dan IMPHNEN signature glow.
* */
import { motion } from "framer-motion"; import { motion } from "framer-motion";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import type { MessageRecord } from "../entities/message/types.js"; import type { MessageRecord } from "../entities/message/types.js";
@@ -8,6 +14,7 @@ import type { WsStatus } from "../shared/ws/socket";
import { Header } from "./Header"; import { Header } from "./Header";
import { ParticleBackground } from "./particles/ParticleBackground"; import { ParticleBackground } from "./particles/ParticleBackground";
import { Sidebar } from "./Sidebar"; import { Sidebar } from "./Sidebar";
import { TabStrip } from "./TabStrip";
interface DashboardLayoutProps { interface DashboardLayoutProps {
activeTab: DashboardTab; activeTab: DashboardTab;
@@ -18,6 +25,9 @@ interface DashboardLayoutProps {
recentMessages?: MessageRecord[]; recentMessages?: MessageRecord[];
guildId?: string; guildId?: string;
channelId?: string; channelId?: string;
guildName?: string;
flaggedCount?: number;
moderationQueue?: number;
} }
export function DashboardLayout({ export function DashboardLayout({
@@ -29,30 +39,44 @@ export function DashboardLayout({
recentMessages = [], recentMessages = [],
guildId, guildId,
channelId, channelId,
flaggedCount = 0,
}: DashboardLayoutProps) { }: DashboardLayoutProps) {
return ( return (
<div className="relative min-h-screen bg-background text-foreground"> <div className="relative min-h-screen bg-white text-[#1a1a1a]">
{/* Background layers */} {/* Background layers */}
<ParticleBackground /> <ParticleBackground />
<div <div
className="fixed inset-0 pointer-events-none grid-pattern opacity-[0.03]" className="fixed inset-0 pointer-events-none"
aria-hidden="true" aria-hidden="true"
style={{
backgroundImage:
"linear-gradient(rgba(0,0,0,0.03) 1px, transparent 1px), linear-gradient(90deg, rgba(0,0,0,0.03) 1px, transparent 1px)",
backgroundSize: "40px 40px",
opacity: 0.5,
}}
/> />
<div className="relative flex min-h-screen"> <div className="relative flex min-h-screen">
{/* Sidebar Navigation */}
<Sidebar <Sidebar
activeTab={activeTab} activeTab={activeTab}
onTabChange={onTabChange} onTabChange={onTabChange}
recentMessages={recentMessages} recentMessages={recentMessages}
guildId={guildId} guildId={guildId}
channelId={channelId} channelId={channelId}
flaggedCount={flaggedCount}
/> />
{/* Main Content Area */}
<main className="flex min-w-0 flex-1 flex-col"> <main className="flex min-w-0 flex-1 flex-col">
<Header <Header
activeTab={activeTab}
wsStatus={wsStatus} wsStatus={wsStatus}
voiceStatus={voiceStatus} voiceStatus={voiceStatus}
/> />
<TabStrip activeTab={activeTab} onTabChange={onTabChange} />
{/* Page Content with entry animation */}
<motion.main <motion.main
key={activeTab} key={activeTab}
variants={fadeSlideUp} variants={fadeSlideUp}
@@ -60,6 +84,7 @@ export function DashboardLayout({
animate="animate" animate="animate"
exit="exit" exit="exit"
className="flex-1 overflow-auto p-4 md:p-6 lg:p-8" className="flex-1 overflow-auto p-4 md:p-6 lg:p-8"
style={{ maxWidth: "1280px", margin: "0 auto", width: "100%" }}
> >
{children} {children}
</motion.main> </motion.main>
+107 -98
View File
@@ -1,122 +1,131 @@
import { motion } from "framer-motion"; import { Moon, Shield, ShieldOff, Sun, Wifi, WifiOff } from "lucide-react";
import { Wifi, WifiOff } from "lucide-react";
import type { DashboardTab } from "../entities/ui/types.js";
import type { VoiceStatus } from "../entities/voice/types.js"; import type { VoiceStatus } from "../entities/voice/types.js";
import { fadeSlideUp } from "../shared/hooks/useFramerStagger"; import { useTheme } from "../shared/hooks/useTheme";
import { cn } from "../shared/lib/utils"; import { cn } from "../shared/lib/utils";
import { Badge } from "../shared/ui"; import { Badge } from "../shared/ui";
import type { WsStatus } from "../shared/ws/socket"; import type { WsStatus } from "../shared/ws/socket";
const titles: Record<DashboardTab, string> = { /* ─── Theme Toggle ─────────────────────────────────────────────────────── */
messages: "Messages & Moderation", function ThemeToggle() {
live: "Voice & Media", const { resolvedTheme, toggle } = useTheme();
dashboard: "Dashboard", return (
}; <button
onClick={toggle}
className="rounded-lg p-2 text-[#666666] hover:bg-[#f0f0f0] hover:text-[#1a1a1a] transition-colors duration-150"
aria-label="Toggle theme"
>
{resolvedTheme === "dark" ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
</button>
);
}
const subtitles: Record<DashboardTab, string> = { /* ─── WS Indicator ─────────────────────────────────────────────────────── */
messages: "Capture, analyse, and moderate Discord messages.", function WsIndicator({ status }: { status: WsStatus }) {
live: "Join voice channels, play media, stream audio, and browse recordings.", const isConnected = status === "connected";
dashboard: "Server statistics, user profiles, and AI moderation overview.", return (
}; <div className="flex items-center gap-1.5">
{isConnected ? (
<Wifi className="h-3 w-3 text-[#22c55e]" />
) : (
<WifiOff className="h-3 w-3 text-[#e4405f]" />
)}
<span
className={cn(
"text-xs font-medium",
isConnected ? "text-[#22c55e]" : "text-[#e4405f]",
)}
>
{status === "connected"
? "Online"
: status === "connecting"
? "Nyambung..."
: status === "error"
? "Error"
: "Putus"}
</span>
</div>
);
}
/* ─── Voice Indicator ──────────────────────────────────────────────────── */
function VoiceIndicator({ voiceStatus }: { voiceStatus: VoiceStatus }) {
const isConnected = voiceStatus.connected;
return (
<div className="flex items-center gap-1.5">
{isConnected ? (
<Shield className="h-3 w-3 text-[#23a1eb]" />
) : (
<ShieldOff className="h-3 w-3 text-[#999999]" />
)}
<span
className={cn(
"text-xs font-medium",
isConnected ? "text-[#23a1eb]" : "text-[#999999]",
)}
>
{isConnected
? voiceStatus.activeChannelName || "Tersambung"
: "Siaga"}
</span>
</div>
);
}
/* ─── Main Header ──────────────────────────────────────────────────────── */
interface HeaderProps { interface HeaderProps {
activeTab: DashboardTab;
wsStatus: WsStatus; wsStatus: WsStatus;
voiceStatus: VoiceStatus; voiceStatus: VoiceStatus;
} }
/** Dot indicator colour for WS badge */ export function Header({ wsStatus, voiceStatus }: HeaderProps) {
function wsDotColor(status: WsStatus): string {
switch (status) {
case "connected":
return "bg-emerald-400";
case "error":
return "bg-red-400";
case "connecting":
case "disconnected":
return "bg-gray-400";
}
}
function WsIndicator({ status }: { status: WsStatus }) {
const dot = wsDotColor(status);
return ( return (
<div className="flex items-center gap-1.5"> <header className="sticky top-0 z-10 border-b border-[#e0e0e0]/50 bg-white/70 backdrop-blur-md px-4 py-3">
<span className={cn("inline-block h-2 w-2 rounded-full", dot)} /> <div className="mx-auto flex max-w-[1280px] items-center justify-between">
<span className="text-xs font-medium capitalize">{status}</span> {/* ── Left: Logo + Brand ──────────────────────────────────────── */}
</div> <div className="flex items-center gap-3">
); <img
} src="https://raw.githubusercontent.com/IMPHNEN/imphnen-frontend-service/develop/docs/logo.svg"
alt="IMPHNEN"
className="h-8 w-8"
/>
<h1 className="font-sans text-lg font-bold tracking-tight text-[#1a1a1a]">
<span className="bg-clip-text text-transparent bg-gradient-to-r from-[#23a1eb] to-[#1877f2]">
IMPHNEN
</span>
<span className="mx-1.5 text-[#666666]">·</span>
<span className="font-semibold text-[#666666]">
Guild Watcher
</span>
</h1>
</div>
/** Voice status indicator dot */ {/* ── Right: Status + Theme ──────────────────────────────────── */}
function VoiceIndicator({ voiceStatus }: { voiceStatus: VoiceStatus }) { <div className="flex items-center gap-2">
const isConnected = voiceStatus.connected;
const dot = isConnected ? "bg-primary" : "bg-gray-300";
const label = isConnected
? voiceStatus.activeChannelName || "connected"
: "idle";
return (
<div className="flex items-center gap-1.5">
<span className={cn("inline-block h-2 w-2 rounded-full", dot)} />
<span className="text-xs font-medium">{label}</span>
</div>
);
}
export function Header({ activeTab, wsStatus, voiceStatus }: HeaderProps) {
return (
<header className="sticky top-0 z-10 border-b border-border/50 bg-background/70 px-4 py-4 backdrop-blur-sm md:px-8">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
{/* Left: IMPHNEN brand + tab title */}
<motion.div
key={activeTab}
variants={fadeSlideUp}
initial="initial"
animate="animate"
className="flex items-center gap-3"
>
<div className="flex items-center gap-3">
<img
src="https://raw.githubusercontent.com/IMPHNEN/imphnen-frontend-service/develop/docs/logo.svg"
alt="IMPHNEN"
className="h-7 w-7"
/>
<h1 className="text-xl font-bold tracking-tight">
<span className="gradient-text">IMPHNEN</span>
<span className="mx-2 text-muted-foreground">·</span>
{titles[activeTab]}
</h1>
</div>
<p className="text-sm text-muted-foreground hidden md:block">
{subtitles[activeTab]}
</p>
</motion.div>
{/* Right: status badges */}
<div className="flex flex-wrap items-center gap-2">
{/* WS Badge */}
<Badge
variant="outline"
className="border-border bg-card/50 px-3 py-1.5 text-xs text-muted-foreground"
>
{wsStatus === "connected" ? (
<Wifi className="mr-1.5 h-3 w-3 text-emerald-400" />
) : (
<WifiOff className="mr-1.5 h-3 w-3 text-red-400" />
)}
<WsIndicator status={wsStatus} />
</Badge>
{/* Voice Badge */}
<Badge <Badge
variant="outline" variant="outline"
className={cn( className={cn(
"border-border bg-card/50 px-3 py-1.5 text-xs", "border-[#e0e0e0] bg-white/50 px-2.5 py-1 text-xs",
voiceStatus.connected ? "text-primary" : "text-muted-foreground", wsStatus === "connected"
? "text-[#22c55e]"
: wsStatus === "error"
? "text-[#e4405f]"
: "text-[#999999]",
)}
>
<WsIndicator status={wsStatus} />
</Badge>
<Badge
variant="outline"
className={cn(
"border-[#e0e0e0] bg-white/50 px-2.5 py-1 text-xs",
voiceStatus.connected ? "text-[#23a1eb]" : "text-[#666666]",
)} )}
> >
<VoiceIndicator voiceStatus={voiceStatus} /> <VoiceIndicator voiceStatus={voiceStatus} />
</Badge> </Badge>
<ThemeToggle />
</div> </div>
</div> </div>
</header> </header>
+114 -31
View File
@@ -1,5 +1,15 @@
/*
* IMPHNEN Sidebar Navigation command center
* Minimal, collapsed by default, dengan Mascot yang playful.
* Fokus: Guild Moderation Watcher untuk komunitas IMPHNEN.
* */
import { motion } from "framer-motion"; import { motion } from "framer-motion";
import { LayoutDashboard, MessageSquare, Radio } from "lucide-react"; import {
LayoutDashboard,
MessageSquare,
Radio,
} from "lucide-react";
import type { MessageRecord } from "../entities/message/types.js"; import type { MessageRecord } from "../entities/message/types.js";
import type { DashboardTab } from "../entities/ui/types.js"; import type { DashboardTab } from "../entities/ui/types.js";
import { useMascotChat } from "../shared/hooks/useMascotChat"; import { useMascotChat } from "../shared/hooks/useMascotChat";
@@ -7,12 +17,29 @@ import { cn } from "../shared/lib/utils";
import { MascotChatbot } from "./mascot/MascotChatbot"; import { MascotChatbot } from "./mascot/MascotChatbot";
import { MascotImage } from "./mascot/MascotImage"; import { MascotImage } from "./mascot/MascotImage";
const navItems: Array<{ id: DashboardTab; label: string; icon: typeof Radio }> = const navItems: Array<{
[ id: DashboardTab;
{ id: "messages", label: "Messages & Moderation", icon: MessageSquare }, label: string;
{ id: "live", label: "Voice & Media", icon: Radio }, icon: typeof Radio;
{ id: "dashboard", label: "Dashboard", icon: LayoutDashboard }, badge?: string;
]; }> = [
{
id: "messages",
label: "Pesan & Moderasi",
icon: MessageSquare,
badge: "Live",
},
{
id: "live",
label: "Voice & Media",
icon: Radio,
},
{
id: "dashboard",
label: "Dashboard Guild",
icon: LayoutDashboard,
},
];
interface SidebarProps { interface SidebarProps {
activeTab: DashboardTab; activeTab: DashboardTab;
@@ -21,60 +48,87 @@ interface SidebarProps {
recentMessages?: MessageRecord[]; recentMessages?: MessageRecord[];
guildId?: string; guildId?: string;
channelId?: string; channelId?: string;
flaggedCount?: number;
} }
export function Sidebar({ export function Sidebar({
activeTab, activeTab,
onTabChange, onTabChange,
collapsed = true, collapsed = false,
recentMessages = [], recentMessages = [],
guildId, guildId,
channelId, channelId,
flaggedCount = 0,
}: SidebarProps) { }: SidebarProps) {
const mascotChat = useMascotChat({ const mascotChat = useMascotChat({
messageCount: recentMessages.length, messageCount: recentMessages.length,
activeParticipants: new Set( activeParticipants: new Set(
recentMessages.map((message) => message.user_id), recentMessages.map((message) => message.user_id),
).size, ).size,
lastActivity: recentMessages.length > 0 ? "Active" : "Idle", lastActivity: recentMessages.length > 0 ? "Aktif" : "Idle",
topicsDiscussed: ["Messages", "Moderation"], topicsDiscussed: ["Pesan", "Moderasi"],
guildId, guildId,
channelId, channelId,
}); });
return ( return (
<> <>
<motion.nav <motion.nav
className={cn( className={cn(
"relative hidden shrink-0 flex-col overflow-visible border-r border-border/50 bg-background/70 backdrop-blur-sm transition-all duration-300 md:flex", "relative hidden shrink-0 flex-col overflow-visible",
"border-r border-[#e0e0e0]/50",
"bg-white/70 backdrop-blur-sm",
"transition-all duration-300 ease-[cubic-bezier(0.4,0,0.2,1)]",
"md:flex",
collapsed ? "w-16" : "w-64", collapsed ? "w-16" : "w-64",
)} )}
layout layout
transition={{ type: "spring", stiffness: 300, damping: 30 }} transition={{ type: "spring", stiffness: 300, damping: 30 }}
> >
{/* App icon only — no branding text */} {/* ── Brand Icon ────────────────────────────────────────────── */}
<div <div
className={cn( className={cn(
"flex items-center py-5", "flex items-center py-5",
collapsed ? "justify-center" : "flex-col px-4", collapsed ? "justify-center" : "flex-col px-4",
)} )}
> >
<img {/* Logo */}
src="https://raw.githubusercontent.com/IMPHNEN/imphnen-frontend-service/develop/docs/logo.svg" <div className="relative">
alt="IMPHNEN" <img
className="h-8 w-8 rounded-xl" src="https://raw.githubusercontent.com/IMPHNEN/imphnen-frontend-service/develop/docs/logo.svg"
/> alt="IMPHNEN"
className="h-8 w-8 rounded-xl"
/>
{/* Live indicator dot */}
<span className="absolute -top-0.5 -right-0.5 h-2.5 w-2.5 rounded-full bg-[#22c55e] ring-2 ring-white animate-pulse" />
</div>
{/* Mascot image — only when expanded */} {/* Brand text when expanded */}
{!collapsed && (
<div className="mt-4 text-center">
<h2 className="font-sans text-sm font-bold text-[#1a1a1a]">
<span className="bg-clip-text text-transparent bg-gradient-to-r from-[#23a1eb] to-[#5865f2]">
IMPHNEN
</span>
</h2>
<p className="font-sans text-[10px] font-medium text-[#666666] mt-0.5 tracking-wider uppercase">
Guild Watcher
</p>
</div>
)}
{/* Mascot — only when expanded */}
{!collapsed && ( {!collapsed && (
<img <img
src="https://raw.githubusercontent.com/IMPHNEN/imphnen-frontend-service/develop/apps/dimentorin/public/image/mascot-1.png" src="https://raw.githubusercontent.com/IMPHNEN/imphnen-frontend-service/develop/apps/dimentorin/public/image/mascot-1.png"
alt="Mascot" alt="Mascot IMPHNEN"
className="mt-4 h-auto w-[140px] object-contain drop-shadow-md" className="mt-4 h-auto w-[120px] object-contain drop-shadow-md hover:animate-mascot-wiggle cursor-pointer"
onClick={() => mascotChat.setIsOpen(!mascotChat.isOpen)}
/> />
)} )}
</div> </div>
{/* Navigation items — centered vertically */} {/* ── Navigation Items ──────────────────────────────────────── */}
<div className="flex flex-1 flex-col justify-center"> <div className="flex flex-1 flex-col justify-center">
<div className="flex flex-col gap-1 px-2"> <div className="flex flex-col gap-1 px-2">
{navItems.map((item) => { {navItems.map((item) => {
@@ -86,38 +140,67 @@ export function Sidebar({
onClick={() => onTabChange(item.id)} onClick={() => onTabChange(item.id)}
title={collapsed ? item.label : undefined} title={collapsed ? item.label : undefined}
className={cn( className={cn(
"group relative flex items-center rounded-xl p-2.5 text-sm font-medium transition-all duration-200 ease-out", "group relative flex items-center rounded-xl p-2.5",
"font-sans text-sm font-medium",
"transition-all duration-200 ease-[cubic-bezier(0.4,0,0.2,1)]",
collapsed ? "justify-center" : "gap-3", collapsed ? "justify-center" : "gap-3",
isActive isActive
? "bg-primary/10 text-primary ring-1 ring-primary/20" ? "bg-[#e1f0fd] text-[#23a1eb] ring-1 ring-[#23a1eb]/20"
: "text-muted-foreground hover:bg-primary/5 hover:text-primary/70", : "text-[#666666] hover:bg-[#e1f0fd]/50 hover:text-[#23a1eb]/70",
)} )}
> >
<Icon className="h-4 w-4 shrink-0" /> <div className="relative">
{!collapsed && <span>{item.label}</span>} <Icon className="h-4 w-4 shrink-0" />
{/* Flagged dot indicator */}
{item.id === "messages" && flaggedCount > 0 && (
<span className="absolute -top-1 -right-1 h-2 w-2 rounded-full bg-[#e4405f] ring-1 ring-white" />
)}
</div>
{!collapsed && (
<div className="flex items-center justify-between flex-1 min-w-0">
<span>{item.label}</span>
{item.badge && (
<span className="font-sans text-[10px] font-semibold text-[#23a1eb] bg-[#e1f0fd] px-1.5 py-0.5 rounded-full">
{item.badge}
</span>
)}
</div>
)}
</button> </button>
); );
})} })}
</div> </div>
</div> </div>
{/* Mascot button */} {/* ── Bottom: Mascot Chat Button ────────────────────────────── */}
<div className="flex justify-center pb-4"> <div className="flex justify-center pb-4">
<button <button
type="button" type="button"
onClick={() => mascotChat.setIsOpen(!mascotChat.isOpen)} onClick={() => mascotChat.setIsOpen(!mascotChat.isOpen)}
className="relative z-50 rounded-xl p-1 transition-transform hover:scale-105 focus:outline-none focus:ring-2 focus:ring-primary/40" className={cn(
title="Chat dengan mascot" "relative z-50 rounded-xl p-1.5",
"transition-all duration-200 hover:scale-105",
"focus:outline-none focus:ring-2 focus:ring-[#23a1eb]/40",
mascotChat.isOpen && "bg-[#e1f0fd] ring-1 ring-[#23a1eb]/30",
)}
title="Chat dengan Mascot"
> >
<MascotImage size="sm" /> <div className="relative">
<MascotImage size="sm" />
{mascotChat.isOpen && (
<span className="absolute -top-0.5 -right-0.5 h-2 w-2 rounded-full bg-[#23a1eb] ring-1 ring-white" />
)}
</div>
</button> </button>
</div> </div>
</motion.nav> </motion.nav>
{/* ── Mascot Chatbot Panel ────────────────────────────────────── */}
<MascotChatbot <MascotChatbot
isOpen={mascotChat.isOpen} isOpen={mascotChat.isOpen}
onClose={() => mascotChat.setIsOpen(false)} onClose={() => mascotChat.setIsOpen(false)}
onSendMessage={mascotChat.handleSendMessage} onSendMessage={mascotChat.handleSendMessage}
mascotName="IMPHNEN Mascot" mascotName="Mascot IMPHNEN"
className="fixed bottom-[170px] left-[80px] z-[9999]" className="fixed bottom-[170px] left-[80px] z-[9999]"
/> />
</> </>
@@ -0,0 +1,49 @@
import { motion } from 'framer-motion';
import { LayoutDashboard, MessageSquare, Radio } from 'lucide-react';
import type { DashboardTab } from '../entities/ui/types.js';
import { cn } from '../shared/lib/utils';
const tabs: { id: DashboardTab; label: string; icon: typeof MessageSquare }[] = [
{ id: 'messages', label: 'Pesan & Moderasi', icon: MessageSquare },
{ id: 'live', label: 'Voice & Media', icon: Radio },
{ id: 'dashboard', label: 'Dashboard Guild', icon: LayoutDashboard },
];
interface TabStripProps {
activeTab: DashboardTab;
onTabChange: (tab: DashboardTab) => void;
className?: string;
}
export function TabStrip({ activeTab, onTabChange, className }: TabStripProps) {
return (
<nav className={cn('sticky top-14 z-30 border-b border-[#e0e0e0] bg-white/80 backdrop-blur-sm overflow-x-auto scrollbar-none', className)}>
<div className="mx-auto flex max-w-[1280px] gap-1 px-4 md:px-6 lg:px-8">
{tabs.map((tab) => {
const Icon = tab.icon;
const isActive = activeTab === tab.id;
return (
<button
key={tab.id}
onClick={() => onTabChange(tab.id)}
className={cn(
'relative flex items-center gap-2 px-4 py-3 whitespace-nowrap text-sm font-semibold transition-colors duration-150',
isActive ? 'text-[#23a1eb]' : 'text-[#666666] hover:text-[#1a1a1a]',
)}
>
<Icon className="h-4 w-4" />
<span className="hidden sm:inline">{tab.label}</span>
{isActive && (
<motion.div
layoutId="tab-indicator"
className="absolute bottom-0 left-0 right-0 h-0.5 bg-[#23a1eb] rounded-full"
transition={{ type: 'spring', stiffness: 300, damping: 30 }}
/>
)}
</button>
);
})}
</div>
</nav>
);
}
@@ -1,3 +1,10 @@
/*
* IMPHNEN MascotChatbot AI companion widget
* Floating chat panel dengan IMPHNEN signature branding.
* Friendly Geometry: rounded-xl container, rounded-lg elements.
* Signature timing: 150ms cubic-bezier(0.4, 0, 0.2, 1) untuk interaksi.
* */
import { AnimatePresence, motion } from "framer-motion"; import { AnimatePresence, motion } from "framer-motion";
import { Maximize2, MessageCircle, Minimize2, Send, X } from "lucide-react"; import { Maximize2, MessageCircle, Minimize2, Send, X } from "lucide-react";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
@@ -27,7 +34,7 @@ export function MascotChatbot({
onClose, onClose,
isOpen = false, isOpen = false,
onSendMessage, onSendMessage,
mascotName = "Mascot", mascotName = "Mascot IMPHNEN",
mascotAvatar = "https://raw.githubusercontent.com/IMPHNEN/imphnen-frontend-service/develop/apps/dimentorin/public/image/mascot-1.png", mascotAvatar = "https://raw.githubusercontent.com/IMPHNEN/imphnen-frontend-service/develop/apps/dimentorin/public/image/mascot-1.png",
className, className,
}: MascotChatbotProps) { }: MascotChatbotProps) {
@@ -36,7 +43,7 @@ export function MascotChatbot({
id: "init-1", id: "init-1",
role: "mascot", role: "mascot",
content: content:
"Halo! 👋 Saya mascot mu. Ada yang bisa aku bantu tentang conversation atau analytics?", "Halo! 👋 Aku mascot IMPHNEN. Ada yang bisa aku bantu tentang conversation atau analytics?",
timestamp: Date.now(), timestamp: Date.now(),
}, },
]); ]);
@@ -74,7 +81,6 @@ export function MascotChatbot({
if (onSendMessage) { if (onSendMessage) {
response = await onSendMessage(input.trim()); response = await onSendMessage(input.trim());
} else { } else {
// Default mascot responses
response = generateMascotResponse(input.trim(), messages); response = generateMascotResponse(input.trim(), messages);
} }
@@ -110,62 +116,64 @@ export function MascotChatbot({
initial={{ opacity: 0, y: 20, scale: 0.95 }} initial={{ opacity: 0, y: 20, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }} animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 20, scale: 0.95 }} exit={{ opacity: 0, y: 20, scale: 0.95 }}
transition={{ duration: 0.2, ease: [0.4, 0, 0.2, 1] }}
className={cn( className={cn(
"w-96 bg-card rounded-xl shadow-2xl border border-border overflow-hidden flex flex-col", "w-96 bg-white rounded-xl shadow-lg border border-[#e0e0e0] overflow-hidden flex flex-col",
isMinimized ? "h-16" : "h-[520px]", isMinimized ? "h-14" : "h-[520px]",
className, className,
)} )}
> >
{/* Header */} {/* ── Header: Branded Gradient ───────────────────────────────── */}
<div className="bg-gradient-to-r from-primary to-primary/80 text-white p-4 flex items-center justify-between"> <div className="bg-gradient-to-r from-[#23a1eb] to-[#1877f2] p-3.5 flex items-center justify-between shrink-0">
<div className="flex items-center gap-3"> <div className="flex items-center gap-2.5">
<div className="w-8 h-8 rounded-full bg-white/20 flex items-center justify-center"> <div className="w-7 h-7 rounded-lg bg-white/20 flex items-center justify-center">
<MessageCircle className="h-5 w-5" /> <MessageCircle className="h-4 w-4 text-white" />
</div> </div>
<div> <div>
<h3 className="font-semibold text-sm">{mascotName}</h3> <h3 className="font-sans text-sm font-semibold text-white leading-tight">
<p className="text-xs text-white/80"> {mascotName}
</h3>
<p className="font-sans text-[11px] text-white/75 leading-tight mt-0.5">
{loading ? "Mengetik..." : "Online"} {loading ? "Mengetik..." : "Online"}
</p> </p>
</div> </div>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-1">
<motion.button <motion.button
whileHover={{ scale: 1.1 }} whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }} whileTap={{ scale: 0.95 }}
onClick={() => setIsMinimized(!isMinimized)} onClick={() => setIsMinimized(!isMinimized)}
className="p-1.5 hover:bg-white/20 rounded-lg transition-colors" className="p-1.5 hover:bg-white/20 rounded-lg transition-colors duration-150 ease-[cubic-bezier(0.4,0,0.2,1)]"
title={isMinimized ? "Maximize" : "Minimize"} title={isMinimized ? "Maximize" : "Minimize"}
> >
{isMinimized ? ( {isMinimized ? (
<Maximize2 className="h-4 w-4" /> <Maximize2 className="h-3.5 w-3.5 text-white" />
) : ( ) : (
<Minimize2 className="h-4 w-4" /> <Minimize2 className="h-3.5 w-3.5 text-white" />
)} )}
</motion.button> </motion.button>
<motion.button <motion.button
whileHover={{ scale: 1.1 }} whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }} whileTap={{ scale: 0.95 }}
onClick={() => { onClick={() => onClose?.()}
onClose?.(); className="p-1.5 hover:bg-white/20 rounded-lg transition-colors duration-150 ease-[cubic-bezier(0.4,0,0.2,1)]"
}}
className="p-1.5 hover:bg-white/20 rounded-lg transition-colors"
title="Close" title="Close"
> >
<X className="h-4 w-4" /> <X className="h-3.5 w-3.5 text-white" />
</motion.button> </motion.button>
</div> </div>
</div> </div>
{/* Messages */} {/* ── Messages Area ──────────────────────────────────────────── */}
{!isMinimized && ( {!isMinimized && (
<> <>
<div className="flex-1 overflow-y-auto p-4 space-y-3 bg-card"> <div className="flex-1 overflow-y-auto p-4 space-y-3 bg-white">
{messages.map((message) => ( {messages.map((message) => (
<motion.div <motion.div
key={message.id} key={message.id}
initial={{ opacity: 0, y: 10 }} initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.15, ease: [0.4, 0, 0.2, 1] }}
className={cn( className={cn(
"flex gap-2", "flex gap-2",
message.role === "user" ? "justify-end" : "justify-start", message.role === "user" ? "justify-end" : "justify-start",
@@ -175,21 +183,23 @@ export function MascotChatbot({
<img <img
src={mascotAvatar} src={mascotAvatar}
alt={mascotName} alt={mascotName}
className="w-6 h-6 rounded-full object-cover" className="w-6 h-6 rounded-full object-cover shrink-0"
/> />
)} )}
<div <div
className={cn( className={cn(
"max-w-xs px-3 py-2 rounded-xl text-sm break-words", "max-w-xs px-3 py-2 rounded-xl text-sm break-words leading-relaxed",
message.role === "user" message.role === "user"
? "bg-primary text-primary-foreground rounded-br-none" ? "bg-[#23a1eb] text-white rounded-br-[4px]"
: "bg-muted text-foreground rounded-bl-none", : "bg-[#f5f5f5] text-[#1a1a1a] rounded-bl-[4px]",
)} )}
> >
{message.content} {message.content}
</div> </div>
</motion.div> </motion.div>
))} ))}
{/* ── Typing Indicator ──────────────────────────────── */}
{loading && ( {loading && (
<motion.div <motion.div
initial={{ opacity: 0 }} initial={{ opacity: 0 }}
@@ -199,14 +209,14 @@ export function MascotChatbot({
<img <img
src={mascotAvatar} src={mascotAvatar}
alt={mascotName} alt={mascotName}
className="w-6 h-6 rounded-full object-cover" className="w-6 h-6 rounded-full object-cover shrink-0"
/> />
<div className="bg-muted rounded-xl rounded-bl-none px-3 py-2"> <div className="bg-[#f5f5f5] rounded-xl rounded-bl-[4px] px-3 py-2.5">
<div className="flex gap-1"> <div className="flex gap-1">
<motion.div <motion.div
animate={{ y: [0, -4, 0] }} animate={{ y: [0, -4, 0] }}
transition={{ duration: 0.6, repeat: Infinity }} transition={{ duration: 0.6, repeat: Infinity }}
className="w-2 h-2 bg-muted-foreground rounded-full" className="w-2 h-2 bg-[#666666] rounded-full"
/> />
<motion.div <motion.div
animate={{ y: [0, -4, 0] }} animate={{ y: [0, -4, 0] }}
@@ -215,7 +225,7 @@ export function MascotChatbot({
repeat: Infinity, repeat: Infinity,
delay: 0.1, delay: 0.1,
}} }}
className="w-2 h-2 bg-muted-foreground rounded-full" className="w-2 h-2 bg-[#666666] rounded-full"
/> />
<motion.div <motion.div
animate={{ y: [0, -4, 0] }} animate={{ y: [0, -4, 0] }}
@@ -224,7 +234,7 @@ export function MascotChatbot({
repeat: Infinity, repeat: Infinity,
delay: 0.2, delay: 0.2,
}} }}
className="w-2 h-2 bg-muted-foreground rounded-full" className="w-2 h-2 bg-[#666666] rounded-full"
/> />
</div> </div>
</div> </div>
@@ -233,10 +243,10 @@ export function MascotChatbot({
<div ref={messagesEndRef} /> <div ref={messagesEndRef} />
</div> </div>
{/* Input */} {/* ── Input Area ──────────────────────────────────────── */}
<form <form
onSubmit={handleSendMessage} onSubmit={handleSendMessage}
className="border-t border-border p-3 bg-card" className="border-t border-[#e0e0e0] p-3 bg-white"
> >
<div className="flex gap-2"> <div className="flex gap-2">
<input <input
@@ -245,14 +255,14 @@ export function MascotChatbot({
onChange={(e) => setInput(e.target.value)} onChange={(e) => setInput(e.target.value)}
placeholder="Tanya mascot..." placeholder="Tanya mascot..."
disabled={loading} disabled={loading}
className="flex-1 px-3 py-2 rounded-lg border border-input bg-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring text-sm disabled:opacity-50" className="flex-1 px-3 py-2 rounded-lg border border-[#e0e0e0] bg-white text-[#1a1a1a] placeholder:text-[#999999] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#23a1eb]/30 focus-visible:border-[#23a1eb] text-sm disabled:opacity-50 transition-all duration-150 ease-[cubic-bezier(0.4,0,0.2,1)]"
/> />
<motion.button <motion.button
whileHover={{ scale: 1.05 }} whileHover={{ scale: 1.03 }}
whileTap={{ scale: 0.95 }} whileTap={{ scale: 0.97 }}
type="submit" type="submit"
disabled={loading || !input.trim()} disabled={loading || !input.trim()}
className="p-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed transition-colors" className="p-2 bg-[#23a1eb] text-white rounded-lg hover:bg-[#1a8fd9] disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-150 ease-[cubic-bezier(0.4,0,0.2,1)]"
> >
<Send className="h-4 w-4" /> <Send className="h-4 w-4" />
</motion.button> </motion.button>
@@ -272,16 +282,16 @@ function generateMascotResponse(
): string { ): string {
const lowerInput = input.toLowerCase(); const lowerInput = input.toLowerCase();
const responseMap: Record<string, string> = { const responseMap: Record<string, string> = {
halo: "Halo juga! 👋 Senang ketemu kamu. Ada yang bisa aku bantu?", halo: "Halo juga! 👋 Senang ketemu kamu di IMPHNEN. Ada yang bisa aku bantu?",
terima: "Sama-sama! 😊", terima: "Sama-sama! 😊 Senang bisa membantu!",
apa: "Aku adalah mascot virtual yang membantu kamu memahami conversation dan analytics. Tanya aku apa saja!", apa: "Aku adalah mascot virtual IMPHNEN yang membantu kamu memahami conversation dan analytics. Tanya aku apa saja!",
siapa: siapa:
"Aku mascot mu yang baik hati! Siap membantu dengan insights tentang chat dan analytics.", "Aku mascot IMPHNEN yang baik hati! Siap membantu dengan insights tentang chat dan analytics.",
chat: "Setiap chat yang terjadi di sini aku analisis untuk memberikan insights yang berguna. Keren kan? 😎", chat: "Setiap chat yang terjadi di guild dianalisis untuk memberikan insights yang berguna. Keren kan? 😎",
pesan: pesan:
"Aku bisa memberikan ringkasan tentang pesan-pesan yang dikirim, siapa yang paling aktif, dan topik populer!", "Aku bisa memberikan ringkasan tentang pesan-pesan yang dikirim, siapa yang paling aktif, dan topik populer!",
analitik: analitik:
"Analytics menunjukkan pola conversation, waktu aktif, partisipan utama, dan banyak hal menarik lainnya! 📊", "Analytics menunjukkan pola conversation, waktu aktif, partisipan utama, dan banyak hal menarik! 📊",
berapa: berapa:
"Tanya aku 'berapa pesan hari ini' atau 'berapa orang yang chat' dan aku akan jawab dengan data real-time!", "Tanya aku 'berapa pesan hari ini' atau 'berapa orang yang chat' dan aku akan jawab dengan data real-time!",
}; };
@@ -292,10 +302,9 @@ function generateMascotResponse(
} }
} }
// Default response
if (messages.length < 5) { if (messages.length < 5) {
return "Bagus! Aku akan belajar tentang apa yang kamu tanya. Coba tanya aku tentang chat, analytics, atau partisipan! 🎯"; return "Bagus! Aku akan belajar tentang apa yang kamu tanya. Coba tanya aku tentang chat, analytics, atau partisipan! 🎯";
} }
return `Menarik! "${input}" - itu hal yang perlu diperhatikan. Ada yang lain ingin kamu ketahui? 🤔`; return `Menarik! "${input}" itu hal yang perlu diperhatikan. Ada yang lain ingin kamu ketahui? 🤔`;
} }
@@ -1,13 +1,13 @@
/*
* IMPHNEN MascotImage Anime mascot PNG dengan floating chat bubble
* Signature: rounded-xl untuk container, spring transitions, primary glow.
* Mascot adalah "wajah" IMPHNEN playful dan approachable.
* */
import { motion } from "framer-motion"; import { motion } from "framer-motion";
import { MessageCircle } from "lucide-react"; import { MessageCircle } from "lucide-react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
/**
* MascotImage Anime mascot PNG from GitHub CDN
* Replaces ChibiMascot SVG component with external PNG asset
* Now includes optional floating chat bubble with AI insights
*/
interface MascotImageProps { interface MascotImageProps {
size?: "sm" | "md" | "lg"; size?: "sm" | "md" | "lg";
className?: string; className?: string;
@@ -23,9 +23,9 @@ const sizeMap = {
}; };
const chatSizeMap = { const chatSizeMap = {
sm: "max-w-xs", sm: "max-w-[200px]",
md: "max-w-sm", md: "max-w-xs",
lg: "max-w-md", lg: "max-w-sm",
}; };
export function MascotImage({ export function MascotImage({
@@ -43,7 +43,7 @@ export function MascotImage({
if (showChat && chatMessage) { if (showChat && chatMessage) {
setIsVisible(true); setIsVisible(true);
if (persistChat) return; if (persistChat) return;
const timer = setTimeout(() => setIsVisible(false), 8000); // Auto hide after 8s const timer = setTimeout(() => setIsVisible(false), 8000);
return () => clearTimeout(timer); return () => clearTimeout(timer);
} }
setIsVisible(false); setIsVisible(false);
@@ -53,7 +53,7 @@ export function MascotImage({
<div className="relative inline-block"> <div className="relative inline-block">
<motion.img <motion.img
src="https://raw.githubusercontent.com/IMPHNEN/imphnen-frontend-service/develop/apps/dimentorin/public/image/mascot-1.png" src="https://raw.githubusercontent.com/IMPHNEN/imphnen-frontend-service/develop/apps/dimentorin/public/image/mascot-1.png"
alt="Mascot" alt="Mascot IMPHNEN"
className={`object-contain drop-shadow-md ${sizeClass} ${className}`} className={`object-contain drop-shadow-md ${sizeClass} ${className}`}
whileHover={{ scale: 1.05 }} whileHover={{ scale: 1.05 }}
transition={{ type: "spring", stiffness: 300, damping: 30 }} transition={{ type: "spring", stiffness: 300, damping: 30 }}
@@ -70,16 +70,14 @@ export function MascotImage({
> >
<div className="relative"> <div className="relative">
{/* Chat bubble */} {/* Chat bubble */}
<div className="bg-primary/90 text-primary-foreground rounded-xl px-4 py-2.5 shadow-lg backdrop-blur-sm border border-primary/30"> <div className="bg-[#23a1eb]/90 text-white rounded-xl px-3 py-2 shadow-md backdrop-blur-sm border border-[#23a1eb]/30">
<div className="flex items-start gap-2"> <div className="flex items-start gap-1.5">
<MessageCircle className="h-4 w-4 shrink-0 mt-0.5 text-white/80" /> <MessageCircle className="h-3.5 w-3.5 shrink-0 mt-0.5 text-white/80" />
<p className="text-xs leading-relaxed font-medium line-clamp-3"> <p className="text-xs leading-relaxed font-medium line-clamp-3">
{chatMessage} {chatMessage}
</p> </p>
</div> </div>
<div className="absolute -bottom-1 left-3 w-2.5 h-2.5 bg-[#23a1eb]/80 rounded-full" />
{/* Chat bubble tail */}
<div className="absolute -bottom-1 -left-1 w-3 h-3 bg-primary/80 rounded-full opacity-70" />
</div> </div>
</div> </div>
</motion.div> </motion.div>
@@ -89,14 +87,14 @@ export function MascotImage({
} }
/** /**
* EmptyStateMascot Mascot for empty states * EmptyStateMascot Mascot untuk empty states
* Replaces ChibiMascot when showing empty data states * Menampilkan mascot yang redup dengan pesan "Belum ada data"
*/ */
export function EmptyStateMascot() { export function EmptyStateMascot() {
return ( return (
<div className="flex flex-col items-center justify-center gap-4 py-12"> <div className="flex flex-col items-center justify-center gap-4 py-12">
<MascotImage size="md" className="opacity-60" /> <MascotImage size="md" className="opacity-60" />
<p className="text-sm text-muted-foreground">No data to display</p> <p className="font-sans text-sm text-[#666666]">Belum ada data ditampilkan</p>
</div> </div>
); );
} }
@@ -1,17 +1,37 @@
/*
* IMPHNEN Particle Background Glow orbs yang subtle
* Menggunakan primary (#23a1eb) dan tertiary (#5865f2) glow.
* */
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
export function ParticleBackground() { export function ParticleBackground() {
const [reducedMotion, setReducedMotion] = useState(false); const [reducedMotion, setReducedMotion] = useState(true);
const [isMobile, setIsMobile] = useState(true);
const [shouldRender, setShouldRender] = useState(false);
useEffect(() => { useEffect(() => {
const mq = window.matchMedia("(prefers-reduced-motion: reduce)"); const mqReduced = window.matchMedia("(prefers-reduced-motion: reduce)");
setReducedMotion(mq.matches); setReducedMotion(mqReduced.matches);
const handler = (e: MediaQueryListEvent) => setReducedMotion(e.matches); const mqMobile = window.matchMedia("(max-width: 768px)");
mq.addEventListener("change", handler); setIsMobile(mqMobile.matches);
return () => mq.removeEventListener("change", handler); const handleReduced = (e: MediaQueryListEvent) => setReducedMotion(e.matches);
const handleMobile = (e: MediaQueryListEvent) => setIsMobile(e.matches);
mqReduced.addEventListener("change", handleReduced);
mqMobile.addEventListener("change", handleMobile);
requestAnimationFrame(() => setShouldRender(true));
return () => {
mqReduced.removeEventListener("change", handleReduced);
mqMobile.removeEventListener("change", handleMobile);
};
}, []); }, []);
if (reducedMotion) return null; if (reducedMotion || isMobile || !shouldRender) return null;
const root = getComputedStyle(document.documentElement);
const primaryColor = root.getPropertyValue("--primary").trim() || "#23a1eb";
const tertiaryColor = root.getPropertyValue("--tertiary").trim() || "#5865f2";
const secondaryColor = root.getPropertyValue("--secondary").trim() || "#1877f2";
return ( return (
<div <div
@@ -19,12 +39,30 @@ export function ParticleBackground() {
aria-hidden="true" aria-hidden="true"
style={{ zIndex: -1 }} style={{ zIndex: -1 }}
> >
{/* Top-right glow orb */} {/* Top-right glow — IMPHNEN Primary #23a1eb */}
<div className="absolute -top-40 -right-40 h-[500px] w-[500px] rounded-full bg-primary/10 blur-3xl animate-glow-pulse" />
{/* Bottom-left glow orb */}
<div <div
className="absolute -bottom-40 -left-40 h-[400px] w-[400px] rounded-full bg-blue-400/10 blur-3xl animate-glow-pulse" className="absolute -top-40 -right-40 h-[500px] w-[500px] rounded-full blur-3xl animate-glow-pulse"
style={{ animationDelay: "1.5s" }} style={{
backgroundColor: `${primaryColor}14`,
}}
/>
{/* Bottom-left glow — Discord Tertiary #5865f2 */}
<div
className="absolute -bottom-40 -left-40 h-[400px] w-[400px] rounded-full blur-3xl"
style={{
backgroundColor: `${tertiaryColor}0f`,
animation: "glowPulse 3s ease-in-out infinite",
animationDelay: "1.5s",
}}
/>
{/* Center-subtle glow — Secondary #1877f2 */}
<div
className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 h-[600px] w-[600px] rounded-full blur-3xl"
style={{
backgroundColor: `${secondaryColor}08`,
}}
/> />
</div> </div>
); );
+155 -23
View File
@@ -1,40 +1,156 @@
/**
* IMPHNEN Design System Tailwind Configuration
* Approachable Modernism untuk komunitas programmer Indonesia 🇮🇩
* */
/** @type {import('tailwindcss').Config} */ /** @type {import('tailwindcss').Config} */
export default { export default {
content: ["./index.html", "./src/**/*.{ts,tsx}"], content: ["./index.html", "./src/**/*.{ts,tsx}"],
theme: { theme: {
extend: { extend: {
/* ─── Typography — Poppins Monofamily ─────────────────────────── */
fontFamily: { fontFamily: {
sans: ["Poppins", "ui-sans-serif", "system-ui", "-apple-system", "sans-serif"], sans: ["Poppins", "ui-sans-serif", "system-ui", "-apple-system", "BlinkMacSystemFont", "Segoe UI", "sans-serif"],
display: ["Poppins", "sans-serif"],
}, },
fontSize: {
display: ["60px", { lineHeight: "68px", fontWeight: "700", letterSpacing: "-0.04em" }],
"headline-lg": ["40px", { lineHeight: "48px", fontWeight: "600", letterSpacing: "-0.02em" }],
"headline-md": ["28px", { lineHeight: "36px", fontWeight: "600", letterSpacing: "-0.01em" }],
"title-lg": ["20px", { lineHeight: "28px", fontWeight: "600" }],
"body-lg": ["18px", { lineHeight: "28px", fontWeight: "400", letterSpacing: "0.01em" }],
"body-md": ["16px", { lineHeight: "24px", fontWeight: "400", letterSpacing: "0.01em" }],
"label-md": ["14px", { lineHeight: "20px", fontWeight: "600", letterSpacing: "0.02em" }],
"label-sm": ["12px", { lineHeight: "16px", fontWeight: "500", letterSpacing: "0.03em" }],
},
/* ─── Brand Colors — IMPHNEN Signature ────────────────────────── */
colors: { colors: {
border: "oklch(var(--border))", /* Surface */
input: "oklch(var(--input))", background: "#ffffff",
ring: "oklch(var(--ring))", foreground: "#1a1a1a",
background: "oklch(var(--background))", muted: {
foreground: "oklch(var(--foreground))", DEFAULT: "#f5f5f5",
"primary-soft": "oklch(var(--primary-soft))", foreground: "#666666",
"primary-glow": "oklch(var(--primary-glow))", },
"accent-glow": "oklch(var(--accent-glow))", accent: {
primary: { DEFAULT: "oklch(var(--primary))", foreground: "oklch(var(--primary-foreground))" }, DEFAULT: "#f0f0f0",
secondary: { DEFAULT: "oklch(var(--secondary))", foreground: "oklch(var(--secondary-foreground))" }, foreground: "#1a1a1a",
muted: { DEFAULT: "oklch(var(--muted))", foreground: "oklch(var(--muted-foreground))" }, },
accent: { DEFAULT: "oklch(var(--accent))", foreground: "oklch(var(--accent-foreground))" }, card: {
destructive: { DEFAULT: "oklch(var(--destructive))", foreground: "oklch(var(--destructive-foreground))" }, DEFAULT: "#ffffff",
card: { DEFAULT: "oklch(var(--card))", foreground: "oklch(var(--card-foreground))" }, foreground: "#1a1a1a",
},
popover: {
DEFAULT: "#ffffff",
foreground: "#1a1a1a",
},
/* Primary — #23a1eb */
primary: {
DEFAULT: "#23a1eb",
foreground: "#ffffff",
soft: "#e1f0fd",
hover: "#1a8fd9",
active: "#0877c1",
},
/* Secondary — #1877f2 (Facebook) */
secondary: {
DEFAULT: "#1877f2",
foreground: "#ffffff",
soft: "#e7f1ff",
},
/* Tertiary — #5865f2 (Discord) */
tertiary: {
DEFAULT: "#5865f2",
foreground: "#ffffff",
soft: "#eef0ff",
},
/* Semantic */
success: {
DEFAULT: "#22c55e",
soft: "#dcfce7",
},
warning: {
DEFAULT: "#f59e0b",
soft: "#fef3c7",
},
destructive: {
DEFAULT: "#e4405f",
foreground: "#ffffff",
soft: "#ffebee",
},
info: {
DEFAULT: "#3b82f6",
soft: "#dbeafe",
},
/* Border & Ring */
border: "#e0e0e0",
input: "#e0e0e0",
ring: "#23a1eb",
}, },
/* ─── Border Radius — Friendly Geometry ───────────────────────── */
borderRadius: { borderRadius: {
lg: "var(--radius)", sm: "0.25rem",
md: "calc(var(--radius) - 2px)", DEFAULT: "0.5rem",
sm: "calc(var(--radius) - 4px)", md: "0.75rem",
lg: "1rem",
xl: "1.5rem",
full: "9999px",
}, },
/* ─── Box Shadow — Subtle Elevation ───────────────────────────── */
boxShadow: {
sm: "0 1px 2px rgba(0, 0, 0, 0.06)",
DEFAULT: "0 1px 3px rgba(0, 0, 0, 0.06), 0 1px 2px rgba(0, 0, 0, 0.04)",
md: "0 4px 12px rgba(0, 0, 0, 0.08)",
lg: "0 16px 40px rgba(0, 0, 0, 0.12)",
glow: "0 0 0 3px rgba(35, 161, 235, 0.15)",
},
/* ─── Spacing — Rhythm System ────────────────────────────────── */
spacing: {
xs: "4px",
sm: "12px",
md: "24px",
lg: "40px",
xl: "64px",
gutter: "24px",
},
maxWidth: {
container: "1280px",
},
/* ─── Transition Timing ───────────────────────────────────────── */
transitionDuration: {
fast: "150ms",
base: "200ms",
slow: "300ms",
},
transitionTimingFunction: {
snappy: "cubic-bezier(0.4, 0, 0.2, 1)",
},
/* ─── Animations — Signature IMPHNEN ──────────────────────────── */
animation: { animation: {
"bar-pulse": "bar-pulse 0.4s ease-in-out infinite", "bar-pulse": "barPulse 0.4s ease-in-out infinite",
"fade-in-up": "fadeInUp 0.5s ease-out", "fade-in-up": "fadeInUp 0.5s ease-out",
"fade-in": "fadeIn 0.3s ease-out", "fade-in": "fadeIn 0.3s ease-out",
"scale-in": "scaleIn 0.3s ease-out",
"slide-in-right": "slideInRight 0.3s ease-out",
"shimmer": "shimmer 1.5s ease-in-out infinite",
"notification-pulse": "notificationPulse 2s ease-in-out infinite",
"mascot-wiggle": "mascotWiggle 0.6s ease-in-out",
"glow-pulse": "glowPulse 3s ease-in-out infinite", "glow-pulse": "glowPulse 3s ease-in-out infinite",
}, },
keyframes: { keyframes: {
"bar-pulse": { barPulse: {
"0%, 100%": { transform: "scaleY(0.8)" }, "0%, 100%": { transform: "scaleY(0.8)" },
"50%": { transform: "scaleY(1.2)" }, "50%": { transform: "scaleY(1.2)" },
}, },
@@ -46,9 +162,25 @@ export default {
"0%": { opacity: "0" }, "0%": { opacity: "0" },
"100%": { opacity: "1" }, "100%": { opacity: "1" },
}, },
glowPulse: { scaleIn: {
"0%, 100%": { opacity: "0.4" }, "0%": { opacity: "0", transform: "scale(0.95)" },
"50%": { opacity: "0.8" }, "100%": { opacity: "1", transform: "scale(1)" },
},
slideInRight: {
"0%": { opacity: "0", transform: "translateX(20px)" },
"100%": { opacity: "1", transform: "translateX(0)" },
},
notificationPulse: {
"0%": { boxShadow: "0 0 0 0 rgba(35, 161, 235, 0.4)" },
"70%": { boxShadow: "0 0 0 8px transparent" },
"100%": { boxShadow: "0 0 0 0 transparent" },
},
mascotWiggle: {
"0%, 100%": { transform: "rotate(0deg)" },
"15%": { transform: "rotate(-8deg)" },
"30%": { transform: "rotate(6deg)" },
"45%": { transform: "rotate(-4deg)" },
"60%": { transform: "rotate(2deg)" },
}, },
}, },
}, },