create-handover 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Binary file
@@ -0,0 +1,118 @@
1
+ @import "tailwindcss";
2
+
3
+ :root {
4
+ --bg-900: #0f1724;
5
+ --bg-800: #17283b;
6
+ --surface-100: #f8fafc;
7
+ --surface-200: #eef2f6;
8
+ --surface-300: #dce3ec;
9
+ --text-900: #0f172a;
10
+ --text-700: #334155;
11
+ --text-500: #64748b;
12
+ --brand-500: #0d9488;
13
+ --brand-600: #0f766e;
14
+ --danger-500: #dc2626;
15
+ --danger-600: #b91c1c;
16
+ --success-600: #15803d;
17
+ --warning-600: #b45309;
18
+ --surface-card-bg: #ffffff;
19
+ --radius-lg: 0.75rem;
20
+ --radius-xl: 1rem;
21
+ --shadow-soft: 0 10px 30px rgba(15, 23, 42, 0.06);
22
+ --background: var(--surface-100);
23
+ --foreground: var(--text-900);
24
+
25
+ /* Spacing Scale (4px base unit) */
26
+ --spacing-1: 0.25rem; /* 4px */
27
+ --spacing-2: 0.5rem; /* 8px */
28
+ --spacing-3: 0.75rem; /* 12px */
29
+ --spacing-4: 1rem; /* 16px */
30
+ --spacing-5: 1.25rem; /* 20px */
31
+ --spacing-6: 1.5rem; /* 24px */
32
+ --spacing-8: 2rem; /* 32px */
33
+ --spacing-10: 2.5rem; /* 40px */
34
+ --spacing-12: 3rem; /* 48px */
35
+ --spacing-16: 4rem; /* 64px */
36
+ --spacing-20: 5rem; /* 80px */
37
+ --spacing-24: 6rem; /* 96px */
38
+
39
+ /* Semantic Spacing */
40
+ --spacing-inline: var(--spacing-2); /* 8px - Between inline elements */
41
+ --spacing-stack: var(--spacing-4); /* 16px - Between stacked elements */
42
+ --spacing-card: var(--spacing-6); /* 24px - Card/component padding */
43
+ --spacing-card-compact: var(--spacing-4); /* 16px - Compact card padding */
44
+ --spacing-section: var(--spacing-8); /* 32px - Between major sections */
45
+ --spacing-page-x: var(--spacing-4); /* 16px - Page horizontal padding */
46
+ --spacing-page-y: var(--spacing-8); /* 32px - Page vertical padding */
47
+ }
48
+
49
+ @theme inline {
50
+ --color-background: var(--background);
51
+ --color-foreground: var(--foreground);
52
+ --font-sans: var(--font-geist-sans);
53
+ --font-mono: var(--font-geist-mono);
54
+ }
55
+
56
+ body {
57
+ background: var(--background);
58
+ color: var(--foreground);
59
+ font-family: var(--font-geist-sans), "Segoe UI", sans-serif;
60
+ }
61
+
62
+ /* Global focus-visible styles for accessibility */
63
+ button:focus-visible,
64
+ a:focus-visible,
65
+ input:focus-visible,
66
+ select:focus-visible,
67
+ textarea:focus-visible,
68
+ [role="tab"]:focus-visible,
69
+ [role="button"]:focus-visible,
70
+ [tabindex]:not([tabindex="-1"]):focus-visible {
71
+ outline: none;
72
+ box-shadow: 0 0 0 3px rgba(13, 148, 136, 0.25);
73
+ }
74
+
75
+ /* Ensure focus-visible is only shown on keyboard navigation */
76
+ *:focus:not(:focus-visible) {
77
+ outline: none;
78
+ box-shadow: none;
79
+ }
80
+
81
+ .surface-card {
82
+ background: var(--surface-card-bg);
83
+ border: 1px solid var(--surface-300);
84
+ border-radius: var(--radius-xl);
85
+ box-shadow: var(--shadow-soft);
86
+ }
87
+
88
+ .admin-shell {
89
+ background:
90
+ radial-gradient(1200px 400px at 10% -10%, rgba(13, 148, 136, 0.14), transparent 60%),
91
+ radial-gradient(900px 300px at 90% 0%, rgba(23, 40, 59, 0.16), transparent 55%),
92
+ var(--surface-100);
93
+ }
94
+
95
+ .section-label {
96
+ font-size: 0.72rem;
97
+ font-weight: 600;
98
+ letter-spacing: 0.08em;
99
+ text-transform: uppercase;
100
+ color: var(--text-500);
101
+ }
102
+
103
+ .focus-ring {
104
+ /* Legacy class - now handled by global focus-visible styles */
105
+ /* Kept for backwards compatibility */
106
+ }
107
+
108
+ .error-banner {
109
+ border: 1px solid rgba(220, 38, 38, 0.3);
110
+ background: rgba(220, 38, 38, 0.08);
111
+ color: #991b1b;
112
+ }
113
+
114
+ @media (max-width: 768px) {
115
+ .surface-card {
116
+ border-radius: var(--radius-lg);
117
+ }
118
+ }
@@ -0,0 +1,36 @@
1
+ import { Geist, Geist_Mono } from "next/font/google";
2
+ import "./globals.css";
3
+ import { HandoverProvider } from "@/components/HandoverProvider";
4
+
5
+ const geistSans = Geist({
6
+ variable: "--font-geist-sans",
7
+ subsets: ["latin"],
8
+ });
9
+
10
+ const geistMono = Geist_Mono({
11
+ variable: "--font-geist-mono",
12
+ subsets: ["latin"],
13
+ });
14
+
15
+ export const metadata = {
16
+ title: "Client Admin | Handover Template",
17
+ description: "Client-facing content admin powered by Handover.",
18
+ };
19
+
20
+ export default function RootLayout({
21
+ children,
22
+ }: Readonly<{
23
+ children: React.ReactNode;
24
+ }>) {
25
+ return (
26
+ <html lang="en">
27
+ <body
28
+ className={`${geistSans.variable} ${geistMono.variable} antialiased`}
29
+ >
30
+ <HandoverProvider>
31
+ {children}
32
+ </HandoverProvider>
33
+ </body>
34
+ </html>
35
+ );
36
+ }
@@ -0,0 +1,78 @@
1
+ import Link from "next/link";
2
+ import { branding } from "@/lib/branding";
3
+
4
+ /**
5
+ * Homepage - currently static, but when CMS content is integrated:
6
+ *
7
+ * IMPORTANT: Add ISR (Incremental Static Regeneration) to keep content fresh
8
+ * while maintaining performance benefits of static generation.
9
+ *
10
+ * Example implementation when fetching from Handover SDK:
11
+ * ```typescript
12
+ * export const revalidate = 60; // Revalidate every 60 seconds
13
+ *
14
+ * export default async function Home() {
15
+ * const sdk = createHandoverSDK();
16
+ * const content = await sdk.getPageContent('home');
17
+ * // ... render CMS content
18
+ * }
19
+ * ```
20
+ *
21
+ * The revalidate value balances freshness vs. build load:
22
+ * - High traffic sites: 300-3600 (5min-1hr)
23
+ * - Medium traffic: 60-300 (1-5min)
24
+ * - Low traffic or frequent updates: 30-60 (30sec-1min)
25
+ */
26
+ export default function Home() {
27
+ return (
28
+ <div className="min-h-screen admin-shell px-4 py-6 sm:px-8 sm:py-10">
29
+ <main className="mx-auto max-w-5xl space-y-8 sm:space-y-10">
30
+ <section className="surface-card p-6 sm:p-10">
31
+ <p className="section-label">{branding.siteName}</p>
32
+ <h1 className="mt-3 text-2xl sm:text-4xl font-semibold tracking-tight text-slate-900 max-w-2xl">
33
+ {branding.heroTagline}
34
+ </h1>
35
+ <p className="mt-4 text-sm sm:text-base text-slate-600 max-w-2xl">
36
+ This starter ships with a secure embedded admin that lets authorized clients edit text and media without exposing your platform internals.
37
+ </p>
38
+ <div className="mt-8 flex flex-col sm:flex-row gap-3">
39
+ <Link
40
+ className="focus-ring inline-flex h-11 items-center justify-center rounded-lg bg-teal-700 px-5 text-sm font-medium text-white hover:bg-teal-800"
41
+ href="/admin/login"
42
+ >
43
+ Open Admin
44
+ </Link>
45
+ <a
46
+ className="focus-ring inline-flex h-11 items-center justify-center rounded-lg border border-slate-300 bg-white px-5 text-sm font-medium text-slate-700 hover:bg-slate-50"
47
+ href={`mailto:${branding.supportEmail}`}
48
+ >
49
+ Contact Support
50
+ </a>
51
+ </div>
52
+ </section>
53
+
54
+ <section className="grid grid-cols-1 md:grid-cols-3 gap-4 sm:gap-5">
55
+ <article className="surface-card p-5">
56
+ <p className="section-label">Secure by design</p>
57
+ <p className="mt-2 text-sm text-slate-700">Upload limits and password checks are enforced on the platform API, not in browser-only logic.</p>
58
+ </article>
59
+ <article className="surface-card p-5">
60
+ <p className="section-label">Client ready</p>
61
+ <p className="mt-2 text-sm text-slate-700">Branding labels and support contact details can be changed via environment variables.</p>
62
+ </article>
63
+ <article className="surface-card p-5">
64
+ <p className="section-label">Operational control</p>
65
+ <p className="mt-2 text-sm text-slate-700">When the project is locked in your platform dashboard, this site displays the suspension state automatically.</p>
66
+ </article>
67
+ </section>
68
+
69
+ <section className="surface-card p-5 sm:p-6">
70
+ <p className="section-label">Next step</p>
71
+ <p className="mt-2 text-sm text-slate-700">
72
+ Configure <code>NEXT_PUBLIC_HANDOVER_API_URL</code> and <code>NEXT_PUBLIC_HANDOVER_API_KEY</code>, then share <code>/admin/login</code> with your client.
73
+ </p>
74
+ </section>
75
+ </main>
76
+ </div>
77
+ );
78
+ }
@@ -0,0 +1,128 @@
1
+ "use client";
2
+
3
+ import { createContext, useContext, useEffect, useState, ReactNode } from "react";
4
+ import { Handover, HandoverError } from "@/lib/handover";
5
+ import { ShieldAlert, CreditCard } from "lucide-react";
6
+
7
+ // Initialize SDK instance
8
+ const handover = new Handover({
9
+ apiKey: process.env.NEXT_PUBLIC_HANDOVER_API_KEY || "",
10
+ url: process.env.NEXT_PUBLIC_HANDOVER_API_URL || "",
11
+ });
12
+
13
+ interface HandoverContextType {
14
+ handover: Handover;
15
+ text: Record<string, unknown>;
16
+ images: Array<{ _id: string; url: string; altText?: string; mimeType: string }>;
17
+ isLoading: boolean;
18
+ loadError: string | null;
19
+ refresh: () => Promise<void>;
20
+ }
21
+
22
+ const HandoverContext = createContext<HandoverContextType | null>(null);
23
+
24
+ export function useHandover() {
25
+ const context = useContext(HandoverContext);
26
+ if (!context) {
27
+ throw new Error("useHandover must be used within a HandoverProvider");
28
+ }
29
+ return context;
30
+ }
31
+
32
+ export function HandoverProvider({ children }: { children: ReactNode }) {
33
+ const [text, setText] = useState<Record<string, unknown>>({});
34
+ const [images, setImages] = useState<Array<{ _id: string; url: string; altText?: string; mimeType: string }>>([]);
35
+ const [isLocked, setIsLocked] = useState(false);
36
+ const [isLoading, setIsLoading] = useState(true);
37
+ const [loadError, setLoadError] = useState<string | null>(null);
38
+
39
+ const fetchContent = async () => {
40
+ // If no API key is set, we can't do anything (or handled gracefully)
41
+ if (!process.env.NEXT_PUBLIC_HANDOVER_API_KEY) {
42
+ setLoadError("Missing NEXT_PUBLIC_HANDOVER_API_KEY");
43
+ setIsLoading(false);
44
+ return;
45
+ }
46
+
47
+ if (!process.env.NEXT_PUBLIC_HANDOVER_API_URL) {
48
+ setLoadError("Missing NEXT_PUBLIC_HANDOVER_API_URL");
49
+ setIsLoading(false);
50
+ return;
51
+ }
52
+
53
+ try {
54
+ const data = await handover.getContent();
55
+ // Data is now { text, images }
56
+ setText(data.text || {});
57
+ setImages(data.images || []);
58
+ setLoadError(null);
59
+ } catch (error: unknown) {
60
+ if (error instanceof HandoverError && error.code === "HANDOVER_LOCKED") {
61
+ setIsLocked(true);
62
+ } else if (error instanceof HandoverError && error.code === "INVALID_API_KEY") {
63
+ setLoadError("The configured API key is invalid for this project.");
64
+ } else {
65
+ setLoadError("Failed to load content from Handover. Check API URL and connectivity.");
66
+ }
67
+ } finally {
68
+ setIsLoading(false);
69
+ }
70
+ };
71
+
72
+ useEffect(() => {
73
+ fetchContent();
74
+ }, []);
75
+
76
+ if (loadError && !isLocked) {
77
+ return (
78
+ <div className="min-h-screen admin-shell flex items-center justify-center p-4">
79
+ <div className="surface-card w-full max-w-lg p-6 sm:p-8 space-y-4 text-left">
80
+ <div className="section-label">Configuration issue</div>
81
+ <h1 className="text-xl sm:text-2xl font-semibold text-slate-900">Template is not fully configured</h1>
82
+ <p className="text-sm sm:text-base text-slate-600 break-words">{loadError}</p>
83
+ <p className="text-xs sm:text-sm text-slate-500">
84
+ Add the required environment variables and reload this app:
85
+ </p>
86
+ <ul className="text-xs sm:text-sm text-slate-700 list-disc pl-5 space-y-1">
87
+ <li><code>NEXT_PUBLIC_HANDOVER_API_URL</code></li>
88
+ <li><code>NEXT_PUBLIC_HANDOVER_API_KEY</code></li>
89
+ </ul>
90
+ </div>
91
+ </div>
92
+ );
93
+ }
94
+
95
+ if (isLocked) {
96
+ return (
97
+ <div className="min-h-screen flex items-center justify-center bg-gray-50 text-gray-900 p-4">
98
+ <div className="max-w-md w-full bg-white shadow-xl rounded-xl p-8 text-center space-y-6 border border-gray-100">
99
+ <div className="bg-red-50 w-16 h-16 rounded-full flex items-center justify-center mx-auto mb-4">
100
+ <ShieldAlert className="w-8 h-8 text-red-500" />
101
+ </div>
102
+
103
+ <div className="space-y-2">
104
+ <h1 className="text-2xl font-bold tracking-tight text-gray-900">
105
+ Service Suspended
106
+ </h1>
107
+ <p className="text-gray-500">
108
+ This website is temporarily unavailable due to a billing issue or administrative lock.
109
+ </p>
110
+ </div>
111
+
112
+ <div className="pt-4 border-t border-gray-100">
113
+ <div className="flex items-center justify-center gap-2 text-sm text-gray-400">
114
+ <CreditCard className="w-4 h-4" />
115
+ <span>Payment Required (402)</span>
116
+ </div>
117
+ </div>
118
+ </div>
119
+ </div>
120
+ );
121
+ }
122
+
123
+ return (
124
+ <HandoverContext.Provider value={{ handover, text, images, isLoading, loadError, refresh: fetchContent }}>
125
+ {children}
126
+ </HandoverContext.Provider>
127
+ );
128
+ }
@@ -0,0 +1,75 @@
1
+ "use client";
2
+
3
+ import { X } from "lucide-react";
4
+ import { useEffect, useState } from "react";
5
+ import { createPortal } from "react-dom";
6
+
7
+ interface ModalProps {
8
+ isOpen: boolean;
9
+ onClose: () => void;
10
+ title: string;
11
+ children: React.ReactNode;
12
+ footer?: React.ReactNode;
13
+ }
14
+
15
+ export function Modal({ isOpen, onClose, title, children, footer }: ModalProps) {
16
+ const [isAnimating, setIsAnimating] = useState(false);
17
+ const [shouldRender, setShouldRender] = useState(false);
18
+
19
+ useEffect(() => {
20
+ if (isOpen) {
21
+ setShouldRender(true);
22
+ document.body.style.overflow = "hidden";
23
+ // Trigger animation after render
24
+ requestAnimationFrame(() => {
25
+ setIsAnimating(true);
26
+ });
27
+ } else {
28
+ setIsAnimating(false);
29
+ // Wait for exit animation to complete
30
+ const timeout = setTimeout(() => {
31
+ setShouldRender(false);
32
+ }, 200);
33
+ document.body.style.overflow = "unset";
34
+ return () => clearTimeout(timeout);
35
+ }
36
+ return () => {
37
+ document.body.style.overflow = "unset";
38
+ };
39
+ }, [isOpen]);
40
+
41
+ if (!shouldRender) return null;
42
+
43
+ return createPortal(
44
+ <div
45
+ className={`fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm transition-opacity duration-200 ${
46
+ isAnimating ? 'opacity-100' : 'opacity-0'
47
+ }`}
48
+ onClick={onClose}
49
+ >
50
+ <div
51
+ className={`bg-white rounded-xl shadow-xl w-full max-w-md overflow-hidden transition-all duration-200 ${
52
+ isAnimating ? 'opacity-100 scale-100 translate-y-0' : 'opacity-0 scale-95 translate-y-4'
53
+ }`}
54
+ onClick={(e) => e.stopPropagation()}
55
+ >
56
+ <div className="flex items-center justify-between p-4 border-b border-gray-100">
57
+ <h3 className="font-semibold text-gray-900">{title}</h3>
58
+ <button
59
+ onClick={onClose}
60
+ className="focus-ring text-gray-400 hover:text-gray-500 transition-colors rounded-md p-1"
61
+ >
62
+ <X className="w-5 h-5" />
63
+ </button>
64
+ </div>
65
+ <div className="p-4">{children}</div>
66
+ {footer && (
67
+ <div className="bg-gray-50 px-4 py-3 border-t border-gray-100 flex justify-end gap-2">
68
+ {footer}
69
+ </div>
70
+ )}
71
+ </div>
72
+ </div>,
73
+ document.body
74
+ );
75
+ }
@@ -0,0 +1,18 @@
1
+ import { defineConfig, globalIgnores } from "eslint/config";
2
+ import nextVitals from "eslint-config-next/core-web-vitals";
3
+ import nextTs from "eslint-config-next/typescript";
4
+
5
+ const eslintConfig = defineConfig([
6
+ ...nextVitals,
7
+ ...nextTs,
8
+ // Override default ignores of eslint-config-next.
9
+ globalIgnores([
10
+ // Default ignores of eslint-config-next:
11
+ ".next/**",
12
+ "out/**",
13
+ "build/**",
14
+ "next-env.d.ts",
15
+ ]),
16
+ ]);
17
+
18
+ export default eslintConfig;
@@ -0,0 +1,12 @@
1
+ export const branding = {
2
+ siteName: process.env.NEXT_PUBLIC_SITE_NAME || "Client Site",
3
+ adminTitle: process.env.NEXT_PUBLIC_ADMIN_TITLE || "Client Admin",
4
+ supportEmail: process.env.NEXT_PUBLIC_SUPPORT_EMAIL || "support@example.com",
5
+ heroTagline:
6
+ process.env.NEXT_PUBLIC_BRAND_TAGLINE ||
7
+ "Keep your website content fresh with a secure, lightweight admin experience.",
8
+ };
9
+
10
+ export function getBrandInitial(): string {
11
+ return branding.siteName.trim().charAt(0).toUpperCase() || "C";
12
+ }