Rebrand site for podcast, add admin CMS, social metadata, and platform links
This commit is contained in:
@@ -0,0 +1,260 @@
|
||||
import { useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import type { SiteContent, CustomLink, CustomBlock } from './App'
|
||||
import { DEFAULTS } from './App'
|
||||
|
||||
interface Props {
|
||||
content: SiteContent
|
||||
onSave: (c: SiteContent) => void
|
||||
}
|
||||
|
||||
type StringField = Exclude<keyof SiteContent, 'customLinks' | 'customBlocks'>
|
||||
|
||||
const FIELDS: { key: StringField; label: string; multiline?: boolean }[] = [
|
||||
{ key: 'eyebrow', label: 'Hero Eyebrow Text' },
|
||||
{ key: 'heroTagline', label: 'Hero Tagline' },
|
||||
{ key: 'aboutShowHeading', label: 'About Show — Heading' },
|
||||
{ key: 'aboutShowP1', label: 'About Show — Paragraph 1', multiline: true },
|
||||
{ key: 'aboutShowP2', label: 'About Show — Paragraph 2', multiline: true },
|
||||
{ key: 'aboutNate', label: 'About Nate', multiline: true },
|
||||
{ key: 'seriesLabel', label: 'Series Label (e.g. "Now Playing")' },
|
||||
{ key: 'seriesTitle', label: 'Series Title' },
|
||||
{ key: 'seriesDescription', label: 'Series Description', multiline: true },
|
||||
{ key: 'studyGuideTitle', label: 'Study Guide Title' },
|
||||
{ key: 'studyGuideDescription', label: 'Study Guide Description', multiline: true },
|
||||
{ key: 'studyGuideUrl', label: 'Study Guide URL (Amazon link)' },
|
||||
{ key: 'shareHeading', label: 'Share Section — Heading' },
|
||||
{ key: 'shareP', label: 'Share Section — Paragraph', multiline: true },
|
||||
]
|
||||
|
||||
export default function AdminPage({ content, onSave }: Props) {
|
||||
const [form, setForm] = useState<SiteContent>(content)
|
||||
const [status, setStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle')
|
||||
const [errorMsg, setErrorMsg] = useState('')
|
||||
|
||||
function handleChange(key: StringField, value: string) {
|
||||
setForm(f => ({ ...f, [key]: value }))
|
||||
}
|
||||
|
||||
function addLink() {
|
||||
setForm(f => ({
|
||||
...f,
|
||||
customLinks: [
|
||||
...(f.customLinks ?? []),
|
||||
{ id: Date.now().toString(36), label: '', url: '', placement: 'platforms' as const },
|
||||
],
|
||||
}))
|
||||
}
|
||||
|
||||
function updateLink(id: string, field: keyof CustomLink, value: string) {
|
||||
setForm(f => ({
|
||||
...f,
|
||||
customLinks: (f.customLinks ?? []).map(l => l.id === id ? { ...l, [field]: value } : l),
|
||||
}))
|
||||
}
|
||||
|
||||
function removeLink(id: string) {
|
||||
setForm(f => ({ ...f, customLinks: (f.customLinks ?? []).filter(l => l.id !== id) }))
|
||||
}
|
||||
|
||||
function addBlock() {
|
||||
setForm(f => ({
|
||||
...f,
|
||||
customBlocks: [
|
||||
...(f.customBlocks ?? []),
|
||||
{ id: Date.now().toString(36), heading: '', body: '' },
|
||||
],
|
||||
}))
|
||||
}
|
||||
|
||||
function updateBlock(id: string, field: keyof CustomBlock, value: string) {
|
||||
setForm(f => ({
|
||||
...f,
|
||||
customBlocks: (f.customBlocks ?? []).map(b => b.id === id ? { ...b, [field]: value } : b),
|
||||
}))
|
||||
}
|
||||
|
||||
function removeBlock(id: string) {
|
||||
setForm(f => ({ ...f, customBlocks: (f.customBlocks ?? []).filter(b => b.id !== id) }))
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
setStatus('saving')
|
||||
setErrorMsg('')
|
||||
try {
|
||||
const res = await fetch('/api/admin-content', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ siteContent: form }),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}))
|
||||
throw new Error((data as { message?: string }).message ?? 'Save failed')
|
||||
}
|
||||
onSave(form)
|
||||
setStatus('saved')
|
||||
setTimeout(() => setStatus('idle'), 3500)
|
||||
} catch (err) {
|
||||
setErrorMsg(err instanceof Error ? err.message : 'Unknown error')
|
||||
setStatus('error')
|
||||
}
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
if (confirm('Reset all fields to defaults?')) {
|
||||
setForm(DEFAULTS)
|
||||
setStatus('idle')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="admin-page">
|
||||
<div className="admin-header">
|
||||
<span className="admin-ornament">✦ ✦ ✦</span>
|
||||
<h1>Site Admin</h1>
|
||||
<p className="admin-sub">Verse by Verse with Nate</p>
|
||||
<Link to="/" className="admin-back">← Back to site</Link>
|
||||
</div>
|
||||
|
||||
<div className="admin-form-wrap">
|
||||
<form
|
||||
className="admin-form"
|
||||
onSubmit={e => { e.preventDefault(); handleSave() }}
|
||||
>
|
||||
{FIELDS.map(({ key, label, multiline }) => (
|
||||
<div className="admin-field" key={key}>
|
||||
<label htmlFor={`field-${key}`}>{label}</label>
|
||||
{multiline ? (
|
||||
<textarea
|
||||
id={`field-${key}`}
|
||||
value={form[key] as string}
|
||||
onChange={e => handleChange(key, e.target.value)}
|
||||
rows={4}
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
id={`field-${key}`}
|
||||
type="text"
|
||||
value={form[key] as string}
|
||||
onChange={e => handleChange(key, e.target.value)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* ── Custom Links ── */}
|
||||
<div className="admin-section-header">
|
||||
<h3>Custom Links</h3>
|
||||
<p>Add links to show in the platform buttons row, footer, or a dedicated "More Resources" section.</p>
|
||||
</div>
|
||||
{(form.customLinks ?? []).map(link => (
|
||||
<div key={link.id} className="admin-array-row">
|
||||
<div className="admin-array-fields">
|
||||
<div className="admin-field">
|
||||
<label htmlFor={`link-label-${link.id}`}>Label</label>
|
||||
<input
|
||||
id={`link-label-${link.id}`}
|
||||
type="text"
|
||||
value={link.label}
|
||||
placeholder="e.g. iHeart Radio"
|
||||
onChange={e => updateLink(link.id, 'label', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="admin-field">
|
||||
<label htmlFor={`link-url-${link.id}`}>URL</label>
|
||||
<input
|
||||
id={`link-url-${link.id}`}
|
||||
type="url"
|
||||
value={link.url}
|
||||
placeholder="https://..."
|
||||
onChange={e => updateLink(link.id, 'url', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="admin-field">
|
||||
<label htmlFor={`link-placement-${link.id}`}>Show in</label>
|
||||
<select
|
||||
id={`link-placement-${link.id}`}
|
||||
value={link.placement}
|
||||
onChange={e => updateLink(link.id, 'placement', e.target.value)}
|
||||
>
|
||||
<option value="platforms">Platform Buttons (Listen section)</option>
|
||||
<option value="footer">Footer Nav</option>
|
||||
<option value="resources">More Resources Section</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" className="btn-admin-remove" onClick={() => removeLink(link.id)}>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<button type="button" className="btn-admin-add" onClick={addLink}>
|
||||
+ Add Link
|
||||
</button>
|
||||
|
||||
{/* ── Custom Blocks ── */}
|
||||
<div className="admin-section-header">
|
||||
<h3>Custom Content Blocks</h3>
|
||||
<p>Add extra text sections. They appear below the share/QR section on the site.</p>
|
||||
</div>
|
||||
{(form.customBlocks ?? []).map(block => (
|
||||
<div key={block.id} className="admin-array-row">
|
||||
<div className="admin-array-fields">
|
||||
<div className="admin-field">
|
||||
<label htmlFor={`block-heading-${block.id}`}>Heading</label>
|
||||
<input
|
||||
id={`block-heading-${block.id}`}
|
||||
type="text"
|
||||
value={block.heading}
|
||||
placeholder="Section heading"
|
||||
onChange={e => updateBlock(block.id, 'heading', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="admin-field">
|
||||
<label htmlFor={`block-body-${block.id}`}>Body Text</label>
|
||||
<textarea
|
||||
id={`block-body-${block.id}`}
|
||||
value={block.body}
|
||||
rows={3}
|
||||
placeholder="Write your content here…"
|
||||
onChange={e => updateBlock(block.id, 'body', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" className="btn-admin-remove" onClick={() => removeBlock(block.id)}>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<button type="button" className="btn-admin-add" onClick={addBlock}>
|
||||
+ Add Content Block
|
||||
</button>
|
||||
|
||||
<div className="admin-actions">
|
||||
<button
|
||||
type="submit"
|
||||
className="btn-admin-save"
|
||||
disabled={status === 'saving'}
|
||||
>
|
||||
{status === 'saving' ? 'Saving…' : 'Save Changes'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-admin-reset"
|
||||
onClick={handleReset}
|
||||
>
|
||||
Reset to Defaults
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{status === 'saved' && (
|
||||
<p className="admin-status admin-status--ok">✓ Changes saved.</p>
|
||||
)}
|
||||
{status === 'error' && (
|
||||
<p className="admin-status admin-status--err">✗ {errorMsg}</p>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+1011
-1050
File diff suppressed because it is too large
Load Diff
+363
-2603
File diff suppressed because it is too large
Load Diff
+16
-230
@@ -29,243 +29,29 @@ export type Project = {
|
||||
|
||||
export const projects: Project[] = [
|
||||
{
|
||||
slug: 'weather',
|
||||
title: 'NeCloud Weather',
|
||||
domain: 'weather.necloud.us',
|
||||
url: 'https://weather.necloud.us',
|
||||
readmeUrl: 'https://raw.githubusercontent.com/nmemmert/weather/main/README.md',
|
||||
category: 'Weather',
|
||||
slug: 'verse-by-verse',
|
||||
title: 'Verse by Verse with Nate',
|
||||
domain: 'spotify.com',
|
||||
url: 'https://creators.spotify.com/pod/profile/nmemmert/',
|
||||
category: 'Podcast',
|
||||
access: 'Public',
|
||||
ownership: 'built',
|
||||
ownership: 'hosted',
|
||||
status: 'Live',
|
||||
summary: 'Weather dashboard with hourly forecasts, radar, and practical daily details.',
|
||||
summary: 'A verse-by-verse podcast from Nate Emmert focused on Scripture, story, and practical faith application.',
|
||||
details:
|
||||
'The homepage highlights Open-Meteo and RainViewer data, current conditions, high/low, daylight windows, and quick bring-along suggestions.',
|
||||
features: ['Hourly forecast', 'Radar view', 'Wind and atmosphere panel'],
|
||||
scanSource: 'scanned',
|
||||
featured: {
|
||||
headline: 'Fast weather checks with useful details at a glance.',
|
||||
problem:
|
||||
'Most weather tools either feel too basic or too noisy when you only want quick, actionable updates.',
|
||||
solution:
|
||||
'NeCloud Weather combines clean current conditions, practical recommendation blocks, and rapid forecast scanning in one view.',
|
||||
stack: ['React', 'TypeScript', 'Open-Meteo', 'RainViewer'],
|
||||
highlights: ['Location search', 'Hero weather card', '24-hour timeline', 'Radar shortcut'],
|
||||
nextSteps: ['Add severe weather alerts', 'Save favorite locations', 'Add sunrise trend charts'],
|
||||
updateNote: 'Edit this content in src/data/projects.ts under the weather project.',
|
||||
},
|
||||
},
|
||||
{
|
||||
slug: 'capsule',
|
||||
title: 'Capsule',
|
||||
domain: 'capsule.necloud.us',
|
||||
url: 'https://capsule.necloud.us',
|
||||
readmeUrl: 'https://raw.githubusercontent.com/nmemmert/capsule/main/README.md',
|
||||
category: 'Finance',
|
||||
access: 'Mixed',
|
||||
iconUrl: 'https://capsule.necloud.us/images/capsule-logo.svg',
|
||||
ownership: 'built',
|
||||
status: 'Live',
|
||||
summary: 'Smart envelope budgeting app focused on planning and income allocation.',
|
||||
details:
|
||||
'The site presents guided setup, envelope-based spending controls, and paycheck auto-allocation to keep budgets structured.',
|
||||
features: ['Envelope budgeting', 'Income auto-allocation', 'Offline-ready data'],
|
||||
scanSource: 'scanned',
|
||||
featured: {
|
||||
headline: 'A practical budgeting workflow built around envelope control.',
|
||||
problem:
|
||||
'Traditional budget tools can feel rigid or overwhelming for everyday spending decisions.',
|
||||
solution:
|
||||
'Capsule uses guided setup and smart allocation so users can organize spending with clear envelope categories.',
|
||||
stack: ['React', 'TypeScript', 'Local-first storage'],
|
||||
highlights: ['Guided setup wizard', 'Envelope categories', 'Auto-allocation rules'],
|
||||
nextSteps: ['Add recurring bill templates', 'Import transaction feeds', 'Monthly budget snapshots'],
|
||||
updateNote: 'Edit this content in src/data/projects.ts under the capsule project.',
|
||||
},
|
||||
},
|
||||
{
|
||||
slug: 'nexusbible',
|
||||
title: 'Nexus Bible',
|
||||
domain: 'nexusbible.necloud.us',
|
||||
url: 'https://nexusbible.necloud.us',
|
||||
readmeUrl: 'https://raw.githubusercontent.com/nmemmert/nexusbible/main/README.md',
|
||||
category: 'Study',
|
||||
access: 'Mixed',
|
||||
ownership: 'built',
|
||||
status: 'Live',
|
||||
summary: 'Bible reader for reading, comparison, and study using Free Use Bible API data.',
|
||||
details:
|
||||
'The app exposes navigation, reader controls, bookmarks, and daily-focus tooling to support regular scripture study.',
|
||||
features: ['Reader modes', 'Bookmarks', 'Daily focus workflow'],
|
||||
scanSource: 'scanned',
|
||||
featured: {
|
||||
headline: 'Scripture reading designed for depth, rhythm, and clarity.',
|
||||
problem:
|
||||
'Switching between apps for reading, comparison, and planning breaks study flow.',
|
||||
solution:
|
||||
'Nexus Bible unifies reader tools, bookmarks, and daily focus controls for a single focused study experience.',
|
||||
stack: ['React', 'TypeScript', 'Free Use Bible API'],
|
||||
highlights: ['Reader mode', 'Passage navigation', 'Bookmarks', 'Daily focus entry points'],
|
||||
nextSteps: ['Add multi-translation compare view', 'Personal note collections', 'Reading streak tracking'],
|
||||
updateNote: 'Edit this content in src/data/projects.ts under the nexusbible project.',
|
||||
},
|
||||
},
|
||||
{
|
||||
slug: 'prayer',
|
||||
title: 'Haver',
|
||||
domain: 'prayer.necloud.us',
|
||||
url: 'https://prayer.necloud.us',
|
||||
readmeUrl: 'https://raw.githubusercontent.com/nmemmert/haver/main/README.md',
|
||||
category: 'Faith',
|
||||
access: 'Mixed',
|
||||
ownership: 'built',
|
||||
status: 'Live',
|
||||
summary: 'Prayer journal app for daily entries, requests, and answered-prayer tracking.',
|
||||
details:
|
||||
'Landing content emphasizes a minimalist prayer workflow with dashboards, calendar history, and Google Calendar reminder sync.',
|
||||
features: ['Prayer journal', 'Prayer dashboard', 'Calendar integration'],
|
||||
scanSource: 'scanned',
|
||||
featured: {
|
||||
headline: 'A calm daily prayer workflow with meaningful tracking.',
|
||||
problem:
|
||||
'Prayer journaling often lives across scattered notes and reminders, making consistency difficult.',
|
||||
solution:
|
||||
'Haver centralizes requests, daily entries, answered prayers, and reminder sync into one simple interface.',
|
||||
stack: ['React', 'Firebase', 'Google Calendar integration'],
|
||||
highlights: ['Prayer composer', 'Answered prayer tracking', 'Calendar view', 'Reminder sync'],
|
||||
nextSteps: ['Add shared prayer groups', 'Voice prayer entry', 'Weekly summary digest'],
|
||||
updateNote: 'Edit this content in src/data/projects.ts under the prayer project.',
|
||||
},
|
||||
},
|
||||
{
|
||||
slug: 'skywatch',
|
||||
title: 'Skywatch',
|
||||
domain: 'skywatch.necloud.us',
|
||||
url: 'https://skywatch.necloud.us',
|
||||
category: 'Sky and Space',
|
||||
access: 'Public',
|
||||
ownership: 'built',
|
||||
status: 'Live',
|
||||
summary: 'Skywatch project in your NeCloud stack.',
|
||||
details:
|
||||
'Live page content could not be scanned automatically because the endpoint responded with 401; this entry is currently a manual placeholder.',
|
||||
features: ['Manual metadata fallback', 'Live endpoint available via domain'],
|
||||
'Weekly episodes explore Scripture one verse at a time, mixing theological insight, devotional reflection, and real-life application.',
|
||||
features: ['Verse-by-verse teaching', 'Weekly podcast episodes', 'Practical reflection'],
|
||||
scanSource: 'manual',
|
||||
featured: {
|
||||
headline: 'Skywatch project overview placeholder.',
|
||||
problem: 'Automated scanning could not access this project because the endpoint returned 401.',
|
||||
solution:
|
||||
'This featured page is intentionally editable so you can replace placeholder copy with your exact app details.',
|
||||
stack: ['Update manually'],
|
||||
highlights: ['Placeholder content', 'Manual metadata fallback'],
|
||||
nextSteps: ['Add full description', 'Add stack details', 'Add feature highlights'],
|
||||
updateNote: 'Edit this content in src/data/projects.ts under the skywatch project.',
|
||||
},
|
||||
},
|
||||
{
|
||||
slug: 'spellinghub',
|
||||
title: 'Spelling Hub',
|
||||
domain: 'spellinghub.necloud.us',
|
||||
url: 'https://spellinghub.necloud.us',
|
||||
readmeUrl: 'https://raw.githubusercontent.com/nmemmert/spellinghub/main/README.md',
|
||||
category: 'Education',
|
||||
access: 'Mixed',
|
||||
ownership: 'built',
|
||||
status: 'Live',
|
||||
summary: 'Modern spelling practice application with account-based learning flows.',
|
||||
details:
|
||||
'Homepage messaging presents a clean practice platform with login and registration for user progress.',
|
||||
features: ['Practice workflows', 'User auth', 'Simple home dashboard'],
|
||||
scanSource: 'scanned',
|
||||
featured: {
|
||||
headline: 'Focused spelling practice with lightweight account flow.',
|
||||
headline: 'Podcast teaching shaped around verse-by-verse discovery.',
|
||||
problem:
|
||||
'Many spelling tools are cluttered and do not support structured, repeatable daily practice.',
|
||||
'Bible study and podcast listening often feel disconnected from each other and from everyday life.',
|
||||
solution:
|
||||
'Spelling Hub keeps onboarding simple and centers the product on practical, repeatable learning loops.',
|
||||
stack: ['React', 'TypeScript', 'Auth-backed workflow'],
|
||||
highlights: ['Login/register flow', 'Practice-first design', 'Simple navigation'],
|
||||
nextSteps: ['Add custom word lists', 'Teacher/student modes', 'Progress analytics dashboard'],
|
||||
updateNote: 'Edit this content in src/data/projects.ts under the spellinghub project.',
|
||||
},
|
||||
},
|
||||
{
|
||||
slug: 'mealie',
|
||||
title: 'Mealie',
|
||||
domain: 'mealie.necloud.us',
|
||||
url: 'https://mealie.necloud.us',
|
||||
category: 'Self-hosted Tools',
|
||||
access: 'Mixed',
|
||||
ownership: 'hosted',
|
||||
status: 'Live',
|
||||
summary: 'Self-hosted recipe manager with categories, tags, and kitchen planning tools.',
|
||||
details:
|
||||
'Scanned content confirms a full Mealie interface with home feed, recipe browsing, and account login flow.',
|
||||
features: ['Recipe library', 'Tags and categories', 'User login'],
|
||||
scanSource: 'scanned',
|
||||
featured: {
|
||||
headline: 'Recipe organization and planning through your self-hosted stack.',
|
||||
problem:
|
||||
'Recipe collections get scattered across browser bookmarks and notes without centralized organization.',
|
||||
solution:
|
||||
'Your Mealie instance provides structured categories, tags, and searchable recipes for day-to-day meal planning.',
|
||||
stack: ['Mealie', 'Self-hosted deployment'],
|
||||
highlights: ['Recipe catalog', 'Category and tag views', 'Account login'],
|
||||
nextSteps: ['Add meal plan snapshots', 'Import bookmark recipes', 'Shopping list workflows'],
|
||||
updateNote: 'Edit this content in src/data/projects.ts under the mealie project.',
|
||||
},
|
||||
},
|
||||
{
|
||||
slug: 'music',
|
||||
title: 'Swing Music',
|
||||
domain: 'music.necloud.us',
|
||||
url: 'https://music.necloud.us',
|
||||
category: 'Self-hosted Tools',
|
||||
access: 'Mixed',
|
||||
ownership: 'hosted',
|
||||
status: 'Live',
|
||||
summary: 'Self-hosted music streaming interface with folders, playlists, and favorites.',
|
||||
details:
|
||||
'Homepage scan identifies Swing Music with library navigation, playlists, favorites, and guest/standard access options.',
|
||||
features: ['Library browsing', 'Playlists', 'Guest mode'],
|
||||
scanSource: 'scanned',
|
||||
featured: {
|
||||
headline: 'Swing Music streaming from your own infrastructure.',
|
||||
problem:
|
||||
'Centralized music services can limit control over personal libraries and hosting preferences.',
|
||||
solution:
|
||||
'Your Swing Music instance provides library browsing, playlists, and favorites with local hosting control.',
|
||||
stack: ['Swing Music', 'Self-hosted deployment'],
|
||||
highlights: ['Library navigation', 'Playlist support', 'Favorites', 'Guest mode'],
|
||||
nextSteps: ['Improve mobile now-playing UI', 'Add listening history', 'Playlist sharing links'],
|
||||
updateNote: 'Edit this content in src/data/projects.ts under the music project.',
|
||||
},
|
||||
},
|
||||
{
|
||||
slug: 'seafile',
|
||||
title: 'Seafile Instance',
|
||||
domain: 'seafile.necloud.us',
|
||||
url: 'https://seafile.necloud.us',
|
||||
category: 'Self-hosted Tools',
|
||||
access: 'Login required',
|
||||
iconUrl: 'https://seafile.necloud.us/media/custom/mylogo.png',
|
||||
ownership: 'hosted',
|
||||
status: 'Live',
|
||||
summary: 'Your self-hosted Seafile instance for secure sync, sharing, and account-based access.',
|
||||
details:
|
||||
'Scanned landing view exposes the Seafile login portal with account signup and password reset features.',
|
||||
features: ['File sync platform', 'Account login', 'Password recovery'],
|
||||
scanSource: 'scanned',
|
||||
featured: {
|
||||
headline: 'A private file sync and sharing system you host yourself.',
|
||||
problem:
|
||||
'Cloud storage options often reduce control over data location and account governance.',
|
||||
solution:
|
||||
'Your Seafile instance gives you direct ownership of file sync, sharing access, and user account management.',
|
||||
stack: ['Seafile', 'Self-hosted deployment'],
|
||||
highlights: ['Secure login', 'Account management', 'Password reset flow'],
|
||||
nextSteps: ['Add team spaces', 'Automated backup checks', 'Storage usage dashboard'],
|
||||
updateNote: 'Edit this content in src/data/projects.ts under the seafile project.',
|
||||
'Verse by Verse with Nate brings Scripture study and podcast conversation together in a format that is easy to follow and apply.',
|
||||
stack: ['Podcast hosting', 'Spotify', 'Verse-by-verse teaching'],
|
||||
highlights: ['Episode guides', 'Scripture focus', 'Practical application'],
|
||||
nextSteps: ['Add episode notes', 'Add guest interviews', 'Link transcripts and study resources'],
|
||||
updateNote: 'Edit this content in src/data/projects.ts under the Verse by Verse podcast project.',
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
+21
-206
@@ -1,218 +1,33 @@
|
||||
:root {
|
||||
--bg: #f6f0e8;
|
||||
--ink: #1f1d1a;
|
||||
--ink-muted: #5d584f;
|
||||
--line: #d8cdbf;
|
||||
--accent: #b04a1f;
|
||||
--hero-glow: #f7d8a6;
|
||||
--bg-radial: #fff5de;
|
||||
--bg-bottom: #efe4d6;
|
||||
--surface: #fff;
|
||||
--surface-soft: #fffcf7;
|
||||
--chip-bg: #f6f3ef;
|
||||
--pill-bg: #f1e7da;
|
||||
--pill-ink: #694627;
|
||||
--input-bg: #fffdf9;
|
||||
--display: 'Rockwell', 'Bookman Old Style', 'Georgia', serif;
|
||||
--body: 'Trebuchet MS', 'Segoe UI', Tahoma, sans-serif;
|
||||
--radius-card: 18px;
|
||||
--radius-tile: 14px;
|
||||
--radius-panel: 24px;
|
||||
--radius-note: 12px;
|
||||
--radius-input: 10px;
|
||||
--radius-chip: 8px;
|
||||
--shadow: 0 1px 4px rgba(0, 0, 0, 0.06), 0 2px 8px rgba(0, 0, 0, 0.04);
|
||||
--shadow-elevated: 0 4px 16px rgba(0, 0, 0, 0.10), 0 8px 24px rgba(0, 0, 0, 0.06);
|
||||
*, *::before, *::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
font-family: var(--body);
|
||||
line-height: 1.45;
|
||||
color: var(--ink);
|
||||
:root {
|
||||
font-family: 'Barlow Condensed', 'Trebuchet MS', sans-serif;
|
||||
line-height: 1.5;
|
||||
color: #f0e6d0;
|
||||
background: #0a0a0a;
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
:root[data-theme='ocean'] {
|
||||
--bg: #eaf4f5;
|
||||
--ink: #122227;
|
||||
--ink-muted: #3d5b64;
|
||||
--line: #b7d4d9;
|
||||
--accent: #0f7f8c;
|
||||
--hero-glow: #8ed6df;
|
||||
--bg-radial: #d7eef2;
|
||||
--bg-bottom: #d8e9ec;
|
||||
--surface: #ffffff;
|
||||
--surface-soft: #f4fbfc;
|
||||
--chip-bg: #ecf5f7;
|
||||
--pill-bg: #dceef1;
|
||||
--pill-ink: #1f5a63;
|
||||
--input-bg: #f8fcfd;
|
||||
}
|
||||
|
||||
:root[data-theme='midnight'] {
|
||||
--bg: #0f1722;
|
||||
--ink: #ecf0f6;
|
||||
--ink-muted: #a6b4c6;
|
||||
--line: #2a3a4d;
|
||||
--accent: #e06c2f;
|
||||
--hero-glow: #2f4663;
|
||||
--bg-radial: #1b2939;
|
||||
--bg-bottom: #111d2c;
|
||||
--surface: #172332;
|
||||
--surface-soft: #1a2737;
|
||||
--chip-bg: #26384c;
|
||||
--pill-bg: #31445b;
|
||||
--pill-ink: #d9e5f3;
|
||||
--input-bg: #121d2b;
|
||||
--shadow: 0 2px 12px rgba(0, 0, 0, 0.4);
|
||||
--shadow-elevated: 0 6px 24px rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
|
||||
:root[data-theme='forest'] {
|
||||
--bg: #edf4ee;
|
||||
--ink: #1a2a20;
|
||||
--ink-muted: #446051;
|
||||
--line: #bfd6c4;
|
||||
--accent: #2f7a4e;
|
||||
--hero-glow: #9ad3ac;
|
||||
--bg-radial: #dceedd;
|
||||
--bg-bottom: #d9eadc;
|
||||
--surface: #ffffff;
|
||||
--surface-soft: #f6fbf7;
|
||||
--chip-bg: #edf6ef;
|
||||
--pill-bg: #dff0e3;
|
||||
--pill-ink: #24593a;
|
||||
--input-bg: #f8fcf8;
|
||||
}
|
||||
|
||||
:root[data-theme='sunset'] {
|
||||
--bg: #fff1e8;
|
||||
--ink: #3d1f1a;
|
||||
--ink-muted: #7d4a3d;
|
||||
--line: #f0c8b5;
|
||||
--accent: #d85b34;
|
||||
--hero-glow: #ffc08f;
|
||||
--bg-radial: #ffe1cc;
|
||||
--bg-bottom: #ffd8bf;
|
||||
--surface: #fffaf7;
|
||||
--surface-soft: #fff5ef;
|
||||
--chip-bg: #ffece0;
|
||||
--pill-bg: #ffe2d1;
|
||||
--pill-ink: #8a3f2a;
|
||||
--input-bg: #fff9f4;
|
||||
}
|
||||
|
||||
:root[data-theme='rose'] {
|
||||
--bg: #fcf0f4;
|
||||
--ink: #2d1825;
|
||||
--ink-muted: #7d4b63;
|
||||
--line: #f0c4d8;
|
||||
--accent: #b8325a;
|
||||
--hero-glow: #f4aec8;
|
||||
--bg-radial: #fde3ec;
|
||||
--bg-bottom: #f8dde8;
|
||||
--surface: #ffffff;
|
||||
--surface-soft: #fff7fa;
|
||||
--chip-bg: #fdeef3;
|
||||
--pill-bg: #fbd6e5;
|
||||
--pill-ink: #8a2444;
|
||||
--input-bg: #fffcfd;
|
||||
--radius-card: 22px;
|
||||
--radius-tile: 18px;
|
||||
--radius-panel: 32px;
|
||||
--radius-note: 16px;
|
||||
--radius-input: 12px;
|
||||
--radius-chip: 10px;
|
||||
--shadow: 0 2px 10px rgba(184, 50, 90, 0.08), 0 1px 4px rgba(0, 0, 0, 0.04);
|
||||
--shadow-elevated: 0 6px 28px rgba(184, 50, 90, 0.14), 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
--display: 'Georgia', 'Book Antiqua', 'Palatino', serif;
|
||||
}
|
||||
|
||||
:root[data-theme='slate'] {
|
||||
--bg: #f0f2f6;
|
||||
--ink: #181d28;
|
||||
--ink-muted: #49556e;
|
||||
--line: #cdd3e0;
|
||||
--accent: #3455e0;
|
||||
--hero-glow: #b5c1f5;
|
||||
--bg-radial: #e2e7f4;
|
||||
--bg-bottom: #e4e8f0;
|
||||
--surface: #ffffff;
|
||||
--surface-soft: #f5f7fb;
|
||||
--chip-bg: #edf0f8;
|
||||
--pill-bg: #dce2f8;
|
||||
--pill-ink: #1e38be;
|
||||
--input-bg: #f9fafc;
|
||||
--radius-card: 8px;
|
||||
--radius-tile: 6px;
|
||||
--radius-panel: 12px;
|
||||
--radius-note: 8px;
|
||||
--radius-input: 6px;
|
||||
--radius-chip: 4px;
|
||||
--shadow: 0 1px 6px rgba(0, 0, 0, 0.08), 0 3px 10px rgba(52, 85, 224, 0.06);
|
||||
--shadow-elevated: 0 4px 20px rgba(0, 0, 0, 0.12), 0 8px 20px rgba(52, 85, 224, 0.08);
|
||||
--display: 'Trebuchet MS', 'Segoe UI', Tahoma, sans-serif;
|
||||
}
|
||||
|
||||
:root[data-theme='aurora'] {
|
||||
--bg: #0c1220;
|
||||
--ink: #e5edf8;
|
||||
--ink-muted: #8096bb;
|
||||
--line: #263050;
|
||||
--accent: #00c9a7;
|
||||
--hero-glow: #0a4040;
|
||||
--bg-radial: #121d38;
|
||||
--bg-bottom: #090f1c;
|
||||
--surface: #131e34;
|
||||
--surface-soft: #192438;
|
||||
--chip-bg: #1e2e50;
|
||||
--pill-bg: #0e3040;
|
||||
--pill-ink: #7ef0d8;
|
||||
--input-bg: #0c1625;
|
||||
--shadow: 0 2px 12px rgba(0, 0, 0, 0.45), 0 0 20px rgba(0, 201, 167, 0.06);
|
||||
--shadow-elevated: 0 6px 28px rgba(0, 0, 0, 0.6), 0 0 40px rgba(0, 201, 167, 0.12);
|
||||
--display: 'Courier New', Courier, monospace;
|
||||
}
|
||||
|
||||
:root[data-theme='amethyst'] {
|
||||
--bg: #f2eff9;
|
||||
--ink: #1c1435;
|
||||
--ink-muted: #5e4e90;
|
||||
--line: #d4c8f0;
|
||||
--accent: #7c3aed;
|
||||
--hero-glow: #c4b0e8;
|
||||
--bg-radial: #e8e1f8;
|
||||
--bg-bottom: #e2d9f5;
|
||||
--surface: #ffffff;
|
||||
--surface-soft: #f8f6fc;
|
||||
--chip-bg: #f0ecfa;
|
||||
--pill-bg: #e6dbf8;
|
||||
--pill-ink: #5a28b5;
|
||||
--input-bg: #fbfafe;
|
||||
--radius-card: 24px;
|
||||
--radius-tile: 18px;
|
||||
--radius-panel: 32px;
|
||||
--radius-note: 18px;
|
||||
--radius-input: 14px;
|
||||
--radius-chip: 10px;
|
||||
--shadow: 0 2px 10px rgba(124, 58, 237, 0.10), 0 1px 4px rgba(0, 0, 0, 0.04);
|
||||
--shadow-elevated: 0 6px 28px rgba(124, 58, 237, 0.16), 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
--display: 'Georgia', 'Book Antiqua', 'Palatino', serif;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background:
|
||||
radial-gradient(circle at 20% 0%, var(--bg-radial) 0%, rgba(255, 245, 222, 0) 38%),
|
||||
linear-gradient(180deg, var(--bg) 0%, var(--bg-bottom) 100%);
|
||||
min-height: 100vh;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
#root {
|
||||
min-height: 100vh;
|
||||
img {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
p, h1, h2, h3, h4, h5, h6 {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user