Initial commit: story-teller.ink platform
- Complete Astro + Alpine.js implementation - Docker Compose setup with Caddy reverse proxy - Dual platform: Anonymous & Named Storytellers - Interactive features: voting, comments, filtering - Categories page with search functionality - Content collections for markdown stories - Responsive design with accessibility features - Environment variable configuration 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
commit
e6d335f5b5
76
.astro/collections/dignity.schema.json
Normal file
76
.astro/collections/dignity.schema.json
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
{
|
||||||
|
"$ref": "#/definitions/dignity",
|
||||||
|
"definitions": {
|
||||||
|
"dignity": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"title": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"excerpt": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"authorName": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"authorAge": {
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
"tags": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"default": []
|
||||||
|
},
|
||||||
|
"location": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"dateOfEvent": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"upvotes": {
|
||||||
|
"type": "number",
|
||||||
|
"default": 0
|
||||||
|
},
|
||||||
|
"isPromoted": {
|
||||||
|
"type": "boolean",
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"originalStoryId": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"publishedAt": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"format": "date-time"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"format": "date"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "integer",
|
||||||
|
"format": "unix-time"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"commentCount": {
|
||||||
|
"type": "number",
|
||||||
|
"default": 0
|
||||||
|
},
|
||||||
|
"$schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"title",
|
||||||
|
"authorName",
|
||||||
|
"publishedAt"
|
||||||
|
],
|
||||||
|
"additionalProperties": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"$schema": "http://json-schema.org/draft-07/schema#"
|
||||||
|
}
|
70
.astro/collections/nevertell.schema.json
Normal file
70
.astro/collections/nevertell.schema.json
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
{
|
||||||
|
"$ref": "#/definitions/nevertell",
|
||||||
|
"definitions": {
|
||||||
|
"nevertell": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"title": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"excerpt": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"tags": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"default": []
|
||||||
|
},
|
||||||
|
"location": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"dateOfEvent": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"upvotes": {
|
||||||
|
"type": "number",
|
||||||
|
"default": 0
|
||||||
|
},
|
||||||
|
"promotionVotes": {
|
||||||
|
"type": "number",
|
||||||
|
"default": 0
|
||||||
|
},
|
||||||
|
"isPromoted": {
|
||||||
|
"type": "boolean",
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"publishedAt": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"format": "date-time"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"format": "date"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "integer",
|
||||||
|
"format": "unix-time"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"commentCount": {
|
||||||
|
"type": "number",
|
||||||
|
"default": 0
|
||||||
|
},
|
||||||
|
"$schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"title",
|
||||||
|
"publishedAt"
|
||||||
|
],
|
||||||
|
"additionalProperties": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"$schema": "http://json-schema.org/draft-07/schema#"
|
||||||
|
}
|
1
.astro/content-assets.mjs
Normal file
1
.astro/content-assets.mjs
Normal file
@ -0,0 +1 @@
|
|||||||
|
export default new Map();
|
1
.astro/content-modules.mjs
Normal file
1
.astro/content-modules.mjs
Normal file
@ -0,0 +1 @@
|
|||||||
|
export default new Map();
|
219
.astro/content.d.ts
vendored
Normal file
219
.astro/content.d.ts
vendored
Normal file
@ -0,0 +1,219 @@
|
|||||||
|
declare module 'astro:content' {
|
||||||
|
export interface RenderResult {
|
||||||
|
Content: import('astro/runtime/server/index.js').AstroComponentFactory;
|
||||||
|
headings: import('astro').MarkdownHeading[];
|
||||||
|
remarkPluginFrontmatter: Record<string, any>;
|
||||||
|
}
|
||||||
|
interface Render {
|
||||||
|
'.md': Promise<RenderResult>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RenderedContent {
|
||||||
|
html: string;
|
||||||
|
metadata?: {
|
||||||
|
imagePaths: Array<string>;
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module 'astro:content' {
|
||||||
|
type Flatten<T> = T extends { [K: string]: infer U } ? U : never;
|
||||||
|
|
||||||
|
export type CollectionKey = keyof AnyEntryMap;
|
||||||
|
export type CollectionEntry<C extends CollectionKey> = Flatten<AnyEntryMap[C]>;
|
||||||
|
|
||||||
|
export type ContentCollectionKey = keyof ContentEntryMap;
|
||||||
|
export type DataCollectionKey = keyof DataEntryMap;
|
||||||
|
|
||||||
|
type AllValuesOf<T> = T extends any ? T[keyof T] : never;
|
||||||
|
type ValidContentEntrySlug<C extends keyof ContentEntryMap> = AllValuesOf<
|
||||||
|
ContentEntryMap[C]
|
||||||
|
>['slug'];
|
||||||
|
|
||||||
|
export type ReferenceDataEntry<
|
||||||
|
C extends CollectionKey,
|
||||||
|
E extends keyof DataEntryMap[C] = string,
|
||||||
|
> = {
|
||||||
|
collection: C;
|
||||||
|
id: E;
|
||||||
|
};
|
||||||
|
export type ReferenceContentEntry<
|
||||||
|
C extends keyof ContentEntryMap,
|
||||||
|
E extends ValidContentEntrySlug<C> | (string & {}) = string,
|
||||||
|
> = {
|
||||||
|
collection: C;
|
||||||
|
slug: E;
|
||||||
|
};
|
||||||
|
export type ReferenceLiveEntry<C extends keyof LiveContentConfig['collections']> = {
|
||||||
|
collection: C;
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** @deprecated Use `getEntry` instead. */
|
||||||
|
export function getEntryBySlug<
|
||||||
|
C extends keyof ContentEntryMap,
|
||||||
|
E extends ValidContentEntrySlug<C> | (string & {}),
|
||||||
|
>(
|
||||||
|
collection: C,
|
||||||
|
// Note that this has to accept a regular string too, for SSR
|
||||||
|
entrySlug: E,
|
||||||
|
): E extends ValidContentEntrySlug<C>
|
||||||
|
? Promise<CollectionEntry<C>>
|
||||||
|
: Promise<CollectionEntry<C> | undefined>;
|
||||||
|
|
||||||
|
/** @deprecated Use `getEntry` instead. */
|
||||||
|
export function getDataEntryById<C extends keyof DataEntryMap, E extends keyof DataEntryMap[C]>(
|
||||||
|
collection: C,
|
||||||
|
entryId: E,
|
||||||
|
): Promise<CollectionEntry<C>>;
|
||||||
|
|
||||||
|
export function getCollection<C extends keyof AnyEntryMap, E extends CollectionEntry<C>>(
|
||||||
|
collection: C,
|
||||||
|
filter?: (entry: CollectionEntry<C>) => entry is E,
|
||||||
|
): Promise<E[]>;
|
||||||
|
export function getCollection<C extends keyof AnyEntryMap>(
|
||||||
|
collection: C,
|
||||||
|
filter?: (entry: CollectionEntry<C>) => unknown,
|
||||||
|
): Promise<CollectionEntry<C>[]>;
|
||||||
|
|
||||||
|
export function getLiveCollection<C extends keyof LiveContentConfig['collections']>(
|
||||||
|
collection: C,
|
||||||
|
filter?: LiveLoaderCollectionFilterType<C>,
|
||||||
|
): Promise<
|
||||||
|
import('astro').LiveDataCollectionResult<LiveLoaderDataType<C>, LiveLoaderErrorType<C>>
|
||||||
|
>;
|
||||||
|
|
||||||
|
export function getEntry<
|
||||||
|
C extends keyof ContentEntryMap,
|
||||||
|
E extends ValidContentEntrySlug<C> | (string & {}),
|
||||||
|
>(
|
||||||
|
entry: ReferenceContentEntry<C, E>,
|
||||||
|
): E extends ValidContentEntrySlug<C>
|
||||||
|
? Promise<CollectionEntry<C>>
|
||||||
|
: Promise<CollectionEntry<C> | undefined>;
|
||||||
|
export function getEntry<
|
||||||
|
C extends keyof DataEntryMap,
|
||||||
|
E extends keyof DataEntryMap[C] | (string & {}),
|
||||||
|
>(
|
||||||
|
entry: ReferenceDataEntry<C, E>,
|
||||||
|
): E extends keyof DataEntryMap[C]
|
||||||
|
? Promise<DataEntryMap[C][E]>
|
||||||
|
: Promise<CollectionEntry<C> | undefined>;
|
||||||
|
export function getEntry<
|
||||||
|
C extends keyof ContentEntryMap,
|
||||||
|
E extends ValidContentEntrySlug<C> | (string & {}),
|
||||||
|
>(
|
||||||
|
collection: C,
|
||||||
|
slug: E,
|
||||||
|
): E extends ValidContentEntrySlug<C>
|
||||||
|
? Promise<CollectionEntry<C>>
|
||||||
|
: Promise<CollectionEntry<C> | undefined>;
|
||||||
|
export function getEntry<
|
||||||
|
C extends keyof DataEntryMap,
|
||||||
|
E extends keyof DataEntryMap[C] | (string & {}),
|
||||||
|
>(
|
||||||
|
collection: C,
|
||||||
|
id: E,
|
||||||
|
): E extends keyof DataEntryMap[C]
|
||||||
|
? string extends keyof DataEntryMap[C]
|
||||||
|
? Promise<DataEntryMap[C][E]> | undefined
|
||||||
|
: Promise<DataEntryMap[C][E]>
|
||||||
|
: Promise<CollectionEntry<C> | undefined>;
|
||||||
|
export function getLiveEntry<C extends keyof LiveContentConfig['collections']>(
|
||||||
|
collection: C,
|
||||||
|
filter: string | LiveLoaderEntryFilterType<C>,
|
||||||
|
): Promise<import('astro').LiveDataEntryResult<LiveLoaderDataType<C>, LiveLoaderErrorType<C>>>;
|
||||||
|
|
||||||
|
/** Resolve an array of entry references from the same collection */
|
||||||
|
export function getEntries<C extends keyof ContentEntryMap>(
|
||||||
|
entries: ReferenceContentEntry<C, ValidContentEntrySlug<C>>[],
|
||||||
|
): Promise<CollectionEntry<C>[]>;
|
||||||
|
export function getEntries<C extends keyof DataEntryMap>(
|
||||||
|
entries: ReferenceDataEntry<C, keyof DataEntryMap[C]>[],
|
||||||
|
): Promise<CollectionEntry<C>[]>;
|
||||||
|
|
||||||
|
export function render<C extends keyof AnyEntryMap>(
|
||||||
|
entry: AnyEntryMap[C][string],
|
||||||
|
): Promise<RenderResult>;
|
||||||
|
|
||||||
|
export function reference<C extends keyof AnyEntryMap>(
|
||||||
|
collection: C,
|
||||||
|
): import('astro/zod').ZodEffects<
|
||||||
|
import('astro/zod').ZodString,
|
||||||
|
C extends keyof ContentEntryMap
|
||||||
|
? ReferenceContentEntry<C, ValidContentEntrySlug<C>>
|
||||||
|
: ReferenceDataEntry<C, keyof DataEntryMap[C]>
|
||||||
|
>;
|
||||||
|
// Allow generic `string` to avoid excessive type errors in the config
|
||||||
|
// if `dev` is not running to update as you edit.
|
||||||
|
// Invalid collection names will be caught at build time.
|
||||||
|
export function reference<C extends string>(
|
||||||
|
collection: C,
|
||||||
|
): import('astro/zod').ZodEffects<import('astro/zod').ZodString, never>;
|
||||||
|
|
||||||
|
type ReturnTypeOrOriginal<T> = T extends (...args: any[]) => infer R ? R : T;
|
||||||
|
type InferEntrySchema<C extends keyof AnyEntryMap> = import('astro/zod').infer<
|
||||||
|
ReturnTypeOrOriginal<Required<ContentConfig['collections'][C]>['schema']>
|
||||||
|
>;
|
||||||
|
|
||||||
|
type ContentEntryMap = {
|
||||||
|
|
||||||
|
};
|
||||||
|
|
||||||
|
type DataEntryMap = {
|
||||||
|
"dignity": Record<string, {
|
||||||
|
id: string;
|
||||||
|
render(): Render[".md"];
|
||||||
|
slug: string;
|
||||||
|
body: string;
|
||||||
|
collection: "dignity";
|
||||||
|
data: InferEntrySchema<"dignity">;
|
||||||
|
rendered?: RenderedContent;
|
||||||
|
filePath?: string;
|
||||||
|
}>;
|
||||||
|
"nevertell": Record<string, {
|
||||||
|
id: string;
|
||||||
|
render(): Render[".md"];
|
||||||
|
slug: string;
|
||||||
|
body: string;
|
||||||
|
collection: "nevertell";
|
||||||
|
data: InferEntrySchema<"nevertell">;
|
||||||
|
rendered?: RenderedContent;
|
||||||
|
filePath?: string;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
};
|
||||||
|
|
||||||
|
type AnyEntryMap = ContentEntryMap & DataEntryMap;
|
||||||
|
|
||||||
|
type ExtractLoaderTypes<T> = T extends import('astro/loaders').LiveLoader<
|
||||||
|
infer TData,
|
||||||
|
infer TEntryFilter,
|
||||||
|
infer TCollectionFilter,
|
||||||
|
infer TError
|
||||||
|
>
|
||||||
|
? { data: TData; entryFilter: TEntryFilter; collectionFilter: TCollectionFilter; error: TError }
|
||||||
|
: { data: never; entryFilter: never; collectionFilter: never; error: never };
|
||||||
|
type ExtractDataType<T> = ExtractLoaderTypes<T>['data'];
|
||||||
|
type ExtractEntryFilterType<T> = ExtractLoaderTypes<T>['entryFilter'];
|
||||||
|
type ExtractCollectionFilterType<T> = ExtractLoaderTypes<T>['collectionFilter'];
|
||||||
|
type ExtractErrorType<T> = ExtractLoaderTypes<T>['error'];
|
||||||
|
|
||||||
|
type LiveLoaderDataType<C extends keyof LiveContentConfig['collections']> =
|
||||||
|
LiveContentConfig['collections'][C]['schema'] extends undefined
|
||||||
|
? ExtractDataType<LiveContentConfig['collections'][C]['loader']>
|
||||||
|
: import('astro/zod').infer<
|
||||||
|
Exclude<LiveContentConfig['collections'][C]['schema'], undefined>
|
||||||
|
>;
|
||||||
|
type LiveLoaderEntryFilterType<C extends keyof LiveContentConfig['collections']> =
|
||||||
|
ExtractEntryFilterType<LiveContentConfig['collections'][C]['loader']>;
|
||||||
|
type LiveLoaderCollectionFilterType<C extends keyof LiveContentConfig['collections']> =
|
||||||
|
ExtractCollectionFilterType<LiveContentConfig['collections'][C]['loader']>;
|
||||||
|
type LiveLoaderErrorType<C extends keyof LiveContentConfig['collections']> = ExtractErrorType<
|
||||||
|
LiveContentConfig['collections'][C]['loader']
|
||||||
|
>;
|
||||||
|
|
||||||
|
export type ContentConfig = typeof import("../src/content/config.js");
|
||||||
|
export type LiveContentConfig = never;
|
||||||
|
}
|
1
.astro/data-store.json
Normal file
1
.astro/data-store.json
Normal file
File diff suppressed because one or more lines are too long
5
.astro/settings.json
Normal file
5
.astro/settings.json
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"_variables": {
|
||||||
|
"lastUpdateCheck": 1755489734681
|
||||||
|
}
|
||||||
|
}
|
2
.astro/types.d.ts
vendored
Normal file
2
.astro/types.d.ts
vendored
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
/// <reference types="astro/client" />
|
||||||
|
/// <reference path="content.d.ts" />
|
10
.dockerignore
Normal file
10
.dockerignore
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
node_modules
|
||||||
|
npm-debug.log
|
||||||
|
dist
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
README.md
|
||||||
|
.env.local
|
||||||
|
.env.development.local
|
||||||
|
.env.test.local
|
||||||
|
.env.production.local
|
21
.env
Normal file
21
.env
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
# Development Environment Variables
|
||||||
|
DOMAIN=st.l.supported.systems
|
||||||
|
|
||||||
|
# Site Configuration
|
||||||
|
PUBLIC_SITE_URL=https://st.l.supported.systems
|
||||||
|
PUBLIC_API_BASE_URL=https://st.l.supported.systems/api
|
||||||
|
|
||||||
|
# Platform Configuration
|
||||||
|
PUBLIC_ANONYMOUS_PLATFORM_NAME="Anonymous Storytellers"
|
||||||
|
PUBLIC_NAMED_PLATFORM_NAME="Named Storytellers"
|
||||||
|
|
||||||
|
# Analytics (optional)
|
||||||
|
# PUBLIC_ANALYTICS_ID=
|
||||||
|
|
||||||
|
# Contact Information
|
||||||
|
PUBLIC_CONTACT_EMAIL=stories@st.l.supported.systems
|
||||||
|
|
||||||
|
# Features
|
||||||
|
PUBLIC_ENABLE_COMMENTS=true
|
||||||
|
PUBLIC_ENABLE_VOTING=true
|
||||||
|
PUBLIC_ENABLE_PROMOTION=true
|
18
Dockerfile
Normal file
18
Dockerfile
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
FROM node:18-alpine
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy package files
|
||||||
|
COPY package*.json ./
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
RUN npm install
|
||||||
|
|
||||||
|
# Copy source code
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Expose port
|
||||||
|
EXPOSE 4321
|
||||||
|
|
||||||
|
# Start development server
|
||||||
|
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"]
|
287
README.md
Normal file
287
README.md
Normal file
@ -0,0 +1,287 @@
|
|||||||
|
# story-teller.ink - Astro + Alpine.js Version
|
||||||
|
|
||||||
|
## 🏔️🚀 Lightning Fast Storytelling Platform
|
||||||
|
|
||||||
|
This is the **Astro + Alpine.js** implementation of story-teller.ink - a dual platform connecting seniors through storytelling with **zero-JS-by-default** performance and **15kb total JavaScript**.
|
||||||
|
|
||||||
|
## 🎯 Why Astro + Alpine.js?
|
||||||
|
|
||||||
|
### **Performance for Seniors**
|
||||||
|
- **~20kb total** per story page (vs 300kb+ React)
|
||||||
|
- **Instant story loading** - static HTML with no hydration delay
|
||||||
|
- **15kb Alpine.js** only loads for interactive features
|
||||||
|
- **Works on ancient devices** - no modern JS features required
|
||||||
|
|
||||||
|
### **Accessibility First**
|
||||||
|
- **HTML-first** - stories load immediately, work with JS disabled
|
||||||
|
- **Progressive enhancement** - Alpine adds interactivity gracefully
|
||||||
|
- **Screen reader perfect** - semantic HTML from the start
|
||||||
|
- **High contrast & reduced motion** support built-in
|
||||||
|
|
||||||
|
### **Content Management**
|
||||||
|
- **Markdown stories** - easy to write and maintain
|
||||||
|
- **Content collections** - organized by platform (nevertell/dignity)
|
||||||
|
- **Static generation** - stories become permanent web pages
|
||||||
|
- **Type-safe frontmatter** with Zod validation
|
||||||
|
|
||||||
|
## 🏗️ Architecture Overview
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── content/
|
||||||
|
│ ├── nevertell/ # Anonymous stories (markdown)
|
||||||
|
│ │ ├── elvis-concert-1956.md
|
||||||
|
│ │ └── college-roommate-prank.md
|
||||||
|
│ └── dignity/ # Named stories (markdown)
|
||||||
|
│ ├── martha-navy-nurse.md
|
||||||
|
│ └── dorothy-civil-rights.md
|
||||||
|
├── pages/
|
||||||
|
│ ├── index.astro # Main landing page
|
||||||
|
│ ├── nevertell/
|
||||||
|
│ │ ├── index.astro # Anonymous story list + Alpine filtering
|
||||||
|
│ │ └── [slug].astro # Static story + Alpine comments
|
||||||
|
│ └── dignity/
|
||||||
|
│ ├── index.astro # Named story list
|
||||||
|
│ └── [slug].astro # Static story + Alpine interactions
|
||||||
|
├── layouts/
|
||||||
|
│ ├── BaseLayout.astro # Global Alpine.js setup
|
||||||
|
│ ├── NevertellLayout.astro
|
||||||
|
│ └── DignityLayout.astro
|
||||||
|
└── styles/
|
||||||
|
└── global.css # Tailwind + accessibility styles
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🌟 Key Features
|
||||||
|
|
||||||
|
### **Dual Platform Architecture**
|
||||||
|
- **Anonymous Storytellers** 🤫 - Anonymous stories with mischievous tone
|
||||||
|
- **Named Storytellers** 🏆 - Named stories with respectful tone
|
||||||
|
- **Cross-platform promotion** - community can encourage anonymous→named
|
||||||
|
|
||||||
|
### **Static + Interactive**
|
||||||
|
- **Static story pages** load instantly
|
||||||
|
- **Alpine.js islands** for voting, comments, filtering
|
||||||
|
- **Progressive enhancement** - works without JavaScript
|
||||||
|
- **Real-time interactions** where needed
|
||||||
|
|
||||||
|
### **Senior-Optimized**
|
||||||
|
- **Large fonts** (18px base, scalable to 20px+)
|
||||||
|
- **44px minimum touch targets** for mobile
|
||||||
|
- **High contrast mode** support
|
||||||
|
- **Keyboard navigation** optimized
|
||||||
|
- **Reduced motion** preferences honored
|
||||||
|
|
||||||
|
## 🚀 Getting Started
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
- Node.js 18+
|
||||||
|
- npm or yarn
|
||||||
|
|
||||||
|
### Installation
|
||||||
|
```bash
|
||||||
|
cd story-bridge-astro
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
### Development
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
# Visit http://localhost:4321
|
||||||
|
```
|
||||||
|
|
||||||
|
### Build for Production
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
npm run preview
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📖 Content Management
|
||||||
|
|
||||||
|
### Adding New Stories
|
||||||
|
|
||||||
|
#### Anonymous Story
|
||||||
|
```markdown
|
||||||
|
---
|
||||||
|
# src/content/nevertell/new-story.md
|
||||||
|
title: "The Time I..."
|
||||||
|
excerpt: "Brief description..."
|
||||||
|
tags: ["1960s", "college", "pranks"]
|
||||||
|
location: "Senior Center, City State"
|
||||||
|
dateOfEvent: "Summer 1965"
|
||||||
|
upvotes: 0
|
||||||
|
promotionVotes: 0
|
||||||
|
publishedAt: 2024-01-20T00:00:00.000Z
|
||||||
|
commentCount: 0
|
||||||
|
---
|
||||||
|
|
||||||
|
Your amazing story content here in beautiful markdown...
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Named Story
|
||||||
|
```markdown
|
||||||
|
---
|
||||||
|
# src/content/dignity/new-story.md
|
||||||
|
title: "Hero's Journey"
|
||||||
|
excerpt: "Brief description..."
|
||||||
|
authorName: "John Smith"
|
||||||
|
authorAge: 87
|
||||||
|
tags: ["WWII", "service", "heroism"]
|
||||||
|
location: "Veterans Home, City State"
|
||||||
|
dateOfEvent: "1943-1945"
|
||||||
|
upvotes: 0
|
||||||
|
publishedAt: 2024-01-20T00:00:00.000Z
|
||||||
|
commentCount: 0
|
||||||
|
---
|
||||||
|
|
||||||
|
Their incredible story of service and sacrifice...
|
||||||
|
```
|
||||||
|
|
||||||
|
### Content Validation
|
||||||
|
All story frontmatter is validated with Zod schemas in `src/content/config.ts`.
|
||||||
|
|
||||||
|
## ⚡ Performance Metrics
|
||||||
|
|
||||||
|
### Page Load Sizes
|
||||||
|
- **Main page**: ~25kb (HTML + CSS + 15kb Alpine)
|
||||||
|
- **Story pages**: ~20kb (mostly static HTML content)
|
||||||
|
- **Story list**: ~30kb (static HTML + Alpine filtering)
|
||||||
|
|
||||||
|
### Performance Benefits
|
||||||
|
- **0ms hydration** - stories load as static HTML
|
||||||
|
- **Instant navigation** - no client-side routing delays
|
||||||
|
- **Offline reading** - cached HTML works without connection
|
||||||
|
- **SEO perfect** - fully rendered HTML for search engines
|
||||||
|
|
||||||
|
## 🎨 Styling & Themes
|
||||||
|
|
||||||
|
### Platform-Specific Themes
|
||||||
|
```css
|
||||||
|
/* Anonymous Storytellers - playful, mischievous */
|
||||||
|
nevertell: {
|
||||||
|
primary: '#6366f1', // indigo
|
||||||
|
secondary: '#ec4899', // pink
|
||||||
|
accent: '#f59e0b', // amber
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Named Storytellers - warm, respectful */
|
||||||
|
dignity: {
|
||||||
|
primary: '#059669', // emerald
|
||||||
|
secondary: '#dc2626', // red
|
||||||
|
accent: '#d97706', // amber
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Accessibility Features
|
||||||
|
- **Large fonts** with scalable sizing
|
||||||
|
- **High contrast** mode support
|
||||||
|
- **Reduced motion** preferences
|
||||||
|
- **Focus indicators** for keyboard navigation
|
||||||
|
- **Screen reader** optimized markup
|
||||||
|
|
||||||
|
## 🌐 Deployment
|
||||||
|
|
||||||
|
### Vercel (Recommended)
|
||||||
|
```bash
|
||||||
|
npm install -g vercel
|
||||||
|
vercel --prod
|
||||||
|
```
|
||||||
|
|
||||||
|
### Netlify
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
# Upload dist/ folder to Netlify
|
||||||
|
```
|
||||||
|
|
||||||
|
### Static Hosting
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
# Serve dist/ folder from any static host
|
||||||
|
```
|
||||||
|
|
||||||
|
### Domain Configuration
|
||||||
|
Configure `story-teller.ink` for hosting. The platform handles both anonymous and named storytellers on the same domain with different routes.
|
||||||
|
|
||||||
|
## 🔧 Alpine.js Features
|
||||||
|
|
||||||
|
### Global Stores
|
||||||
|
```javascript
|
||||||
|
// Utility functions available everywhere
|
||||||
|
Alpine.store('utils', {
|
||||||
|
formatDate(dateString),
|
||||||
|
formatRelativeTime(dateString),
|
||||||
|
truncateText(text, maxLength)
|
||||||
|
});
|
||||||
|
|
||||||
|
// API functions for voting and comments
|
||||||
|
Alpine.store('api', {
|
||||||
|
vote(storyId, type),
|
||||||
|
submitComment(storyId, content, authorName),
|
||||||
|
loadComments(storyId)
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Interactive Components
|
||||||
|
- **Story filtering** with real-time search and tag selection
|
||||||
|
- **Voting systems** for upvotes and promotions
|
||||||
|
- **Comment systems** with threaded discussions
|
||||||
|
- **Form validation** and submission handling
|
||||||
|
|
||||||
|
## 🎯 Mission Statement
|
||||||
|
|
||||||
|
We're building bridges between the generation that invented rock & roll and the generation that invented the internet.
|
||||||
|
|
||||||
|
### The Story Bridge Effect
|
||||||
|
1. **Stories flow out** (resident → internet)
|
||||||
|
2. **Connections flow back** (internet → resident)
|
||||||
|
3. **Social energy flows within** (resident ↔ other residents)
|
||||||
|
|
||||||
|
## 🤝 Contributing
|
||||||
|
|
||||||
|
### Adding Features
|
||||||
|
1. Static content goes in `src/pages/` and `src/content/`
|
||||||
|
2. Interactive features use Alpine.js data functions
|
||||||
|
3. Maintain accessibility standards
|
||||||
|
4. Test on mobile and with keyboard navigation
|
||||||
|
|
||||||
|
### Content Guidelines
|
||||||
|
- **Anonymous stories** should be fun, engaging, authentic
|
||||||
|
- **Named stories** should be respectful, honoring, dignified
|
||||||
|
- **All stories** must have proper frontmatter and tags
|
||||||
|
- **Excerpts** should be compelling and draw readers in
|
||||||
|
|
||||||
|
## 📊 Analytics & Monitoring
|
||||||
|
|
||||||
|
### Metrics to Track
|
||||||
|
- **Story reading time** (engagement)
|
||||||
|
- **Comment engagement** (community building)
|
||||||
|
- **Cross-platform promotions** (success indicator)
|
||||||
|
- **Mobile vs desktop** usage (senior preferences)
|
||||||
|
- **Accessibility feature** usage
|
||||||
|
|
||||||
|
### Success Indicators
|
||||||
|
- Stories being shared outside the platform
|
||||||
|
- Comments creating real connections
|
||||||
|
- Anonymous stories being promoted to named storytellers
|
||||||
|
- Seniors discussing stories in their facilities
|
||||||
|
|
||||||
|
## 🔮 Future Enhancements
|
||||||
|
|
||||||
|
### Planned Features
|
||||||
|
- **RSS feeds** for story subscriptions
|
||||||
|
- **Story search** with full-text indexing
|
||||||
|
- **Related stories** based on tags and themes
|
||||||
|
- **Email notifications** for story responses
|
||||||
|
- **Print-friendly** story formats
|
||||||
|
- **Audio narration** for accessibility
|
||||||
|
|
||||||
|
### Technical Improvements
|
||||||
|
- **Progressive Web App** features
|
||||||
|
- **Offline story caching**
|
||||||
|
- **Image optimization** for story photos
|
||||||
|
- **CDN integration** for global performance
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Built with ❤️ for the generation that has the best stories to tell.
|
||||||
|
|
||||||
|
**Performance**: Astro ⚡ **Interactivity**: Alpine.js 🏔️ **Accessibility**: Senior-first ♿
|
31
astro.config.mjs
Normal file
31
astro.config.mjs
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
import { defineConfig } from 'astro/config';
|
||||||
|
import tailwind from '@astrojs/tailwind';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
integrations: [tailwind()],
|
||||||
|
site: process.env.PUBLIC_SITE_URL || 'https://st.l.supported.systems',
|
||||||
|
output: 'static',
|
||||||
|
|
||||||
|
server: {
|
||||||
|
host: '0.0.0.0',
|
||||||
|
port: 4321
|
||||||
|
},
|
||||||
|
|
||||||
|
// Handle domain routing for both .ink domains
|
||||||
|
vite: {
|
||||||
|
define: {
|
||||||
|
global: 'globalThis',
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
host: '0.0.0.0',
|
||||||
|
allowedHosts: [
|
||||||
|
process.env.DOMAIN || 'st.l.supported.systems',
|
||||||
|
'localhost',
|
||||||
|
'127.0.0.1'
|
||||||
|
],
|
||||||
|
fs: {
|
||||||
|
allow: ['..']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
32
docker-compose.yml
Normal file
32
docker-compose.yml
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
story-teller:
|
||||||
|
build: .
|
||||||
|
container_name: story-teller-app
|
||||||
|
environment:
|
||||||
|
- NODE_ENV=development
|
||||||
|
- DOMAIN=${DOMAIN}
|
||||||
|
- PUBLIC_SITE_URL=${PUBLIC_SITE_URL}
|
||||||
|
- PUBLIC_API_BASE_URL=${PUBLIC_API_BASE_URL}
|
||||||
|
- PUBLIC_ANONYMOUS_PLATFORM_NAME=${PUBLIC_ANONYMOUS_PLATFORM_NAME}
|
||||||
|
- PUBLIC_NAMED_PLATFORM_NAME=${PUBLIC_NAMED_PLATFORM_NAME}
|
||||||
|
- PUBLIC_CONTACT_EMAIL=${PUBLIC_CONTACT_EMAIL}
|
||||||
|
- PUBLIC_ENABLE_COMMENTS=${PUBLIC_ENABLE_COMMENTS}
|
||||||
|
- PUBLIC_ENABLE_VOTING=${PUBLIC_ENABLE_VOTING}
|
||||||
|
- PUBLIC_ENABLE_PROMOTION=${PUBLIC_ENABLE_PROMOTION}
|
||||||
|
labels:
|
||||||
|
caddy: ${DOMAIN}
|
||||||
|
caddy.reverse_proxy: "{{upstreams 4321}}"
|
||||||
|
networks:
|
||||||
|
- caddy
|
||||||
|
volumes:
|
||||||
|
- .:/app
|
||||||
|
- /app/node_modules
|
||||||
|
expose:
|
||||||
|
- "4321"
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
networks:
|
||||||
|
caddy:
|
||||||
|
external: true
|
23
package.json
Normal file
23
package.json
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"name": "story-bridge-astro",
|
||||||
|
"type": "module",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Astro + Alpine.js version of The Story Bridge Project",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "astro dev",
|
||||||
|
"start": "astro dev",
|
||||||
|
"build": "astro build",
|
||||||
|
"preview": "astro preview",
|
||||||
|
"astro": "astro"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"astro": "^5.13.2",
|
||||||
|
"@astrojs/tailwind": "^5.1.3",
|
||||||
|
"tailwindcss": "^3.4.0",
|
||||||
|
"alpinejs": "^3.14.3"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^20.12.0",
|
||||||
|
"typescript": "^5.4.0"
|
||||||
|
}
|
||||||
|
}
|
8
setup.sh
Executable file
8
setup.sh
Executable file
@ -0,0 +1,8 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Create caddy network if it doesn't exist
|
||||||
|
docker network ls | grep caddy > /dev/null || docker network create caddy
|
||||||
|
|
||||||
|
echo "Caddy network ready!"
|
||||||
|
echo "Now you can run: docker compose up -d"
|
||||||
|
echo "Your app will be available at: https://${DOMAIN:-st.l.supported.systems}"
|
40
src/content/config.ts
Normal file
40
src/content/config.ts
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
import { defineCollection, z } from 'astro:content';
|
||||||
|
|
||||||
|
const nevertellStories = defineCollection({
|
||||||
|
type: 'content',
|
||||||
|
schema: z.object({
|
||||||
|
title: z.string(),
|
||||||
|
excerpt: z.string().optional(),
|
||||||
|
tags: z.array(z.string()).default([]),
|
||||||
|
location: z.string().optional(),
|
||||||
|
dateOfEvent: z.string().optional(),
|
||||||
|
upvotes: z.number().default(0),
|
||||||
|
promotionVotes: z.number().default(0),
|
||||||
|
isPromoted: z.boolean().default(false),
|
||||||
|
publishedAt: z.date(),
|
||||||
|
commentCount: z.number().default(0),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const dignityStories = defineCollection({
|
||||||
|
type: 'content',
|
||||||
|
schema: z.object({
|
||||||
|
title: z.string(),
|
||||||
|
excerpt: z.string().optional(),
|
||||||
|
authorName: z.string(),
|
||||||
|
authorAge: z.number().optional(),
|
||||||
|
tags: z.array(z.string()).default([]),
|
||||||
|
location: z.string().optional(),
|
||||||
|
dateOfEvent: z.string().optional(),
|
||||||
|
upvotes: z.number().default(0),
|
||||||
|
isPromoted: z.boolean().default(false),
|
||||||
|
originalStoryId: z.string().optional(),
|
||||||
|
publishedAt: z.date(),
|
||||||
|
commentCount: z.number().default(0),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const collections = {
|
||||||
|
'nevertell': nevertellStories,
|
||||||
|
'dignity': dignityStories,
|
||||||
|
};
|
52
src/content/dignity/dorothy-civil-rights.md
Normal file
52
src/content/dignity/dorothy-civil-rights.md
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
---
|
||||||
|
title: "From Anonymous to Proud: My Journey Fighting for Civil Rights"
|
||||||
|
excerpt: "Originally shared anonymously on nevertell.ink, Dorothy Mae Johnson decided to tell her full story..."
|
||||||
|
authorName: "Dorothy Mae Johnson"
|
||||||
|
authorAge: 83
|
||||||
|
tags: ["civil rights", "1960s", "activism", "courage"]
|
||||||
|
location: "Golden Years Community, Atlanta GA"
|
||||||
|
dateOfEvent: "1963-1968"
|
||||||
|
upvotes: 312
|
||||||
|
publishedAt: 2024-01-08T00:00:00.000Z
|
||||||
|
commentCount: 143
|
||||||
|
isPromoted: true
|
||||||
|
originalStoryId: "dorothy-bus-boycott"
|
||||||
|
---
|
||||||
|
|
||||||
|
*Editor's note: This story was originally shared anonymously on nevertell.ink, where it received over 150 votes for promotion. After much encouragement from the community, Dorothy Mae Johnson decided to share her full story and put her name to this important piece of history.*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
For sixty years, I kept quiet about my role in the civil rights movement. Not because I was ashamed, but because I was taught that good work doesn't need recognition. My grandmother used to say, "Do what's right because it's right, not because folks are watching." But at 83, I've decided some stories need to be told with names attached, especially when young people today are still fighting for justice.
|
||||||
|
|
||||||
|
In 1963, I was a 22-year-old teacher at a segregated elementary school in Montgomery, Alabama. I was making $32 a week teaching 47 children in a classroom meant for 25, using textbooks that were ten years out of date and had already been discarded by the white schools across town.
|
||||||
|
|
||||||
|
When Dr. King and the others organized the Montgomery Bus Boycott, I knew I had to be part of it, even though it meant walking four miles to work every morning and four miles home every evening. My principal warned all of us teachers that we could lose our jobs if we were caught participating in "agitation activities." But how could I teach my students about dignity and self-respect if I wasn't willing to fight for my own?
|
||||||
|
|
||||||
|
The hardest part wasn't the walking - though Lord knows my feet were screaming by December. The hardest part was the fear. Every morning when I left my apartment, I didn't know if I'd make it to school safely. There were cars full of angry white men who would drive slowly beside us as we walked, shouting things I won't repeat, sometimes throwing things.
|
||||||
|
|
||||||
|
But we weren't alone. The solidarity among the Black community during those 381 days was something I'll never forget. People who owned cars organized carpools. Folks who lived along the walking routes would set up water stations and first aid stops. My neighbor, Mrs. Washington, would make an extra sandwich every morning and press it into my hand as I walked past her house.
|
||||||
|
|
||||||
|
One morning in February, it was particularly cold and rainy. I was walking with a group of other teachers and domestic workers when a police car pulled up beside us. My heart started racing - we all knew stories of people being arrested for "loitering" or "disturbing the peace" for simply walking down the street.
|
||||||
|
|
||||||
|
But instead of arresting us, the officer rolled down his window and said, "You folks need a ride?" We looked at each other, confused. Then he said, quietly, "My daughter goes to your school, Miss Johnson. She talks about you all the time. Says you're the best teacher she's ever had."
|
||||||
|
|
||||||
|
It turned out Officer Williams was one of the few Black police officers in Montgomery, and he'd been secretly helping with the boycott by giving rides when he could do so safely. That day, he drove all six of us teachers to school, and we arrived dry and warm for the first time in months.
|
||||||
|
|
||||||
|
After the boycott succeeded, I thought the hardest part was over. I was wrong again. The real work was just beginning. I spent the next five years helping to integrate schools, registering voters, and organizing community meetings. I was arrested three times - once for "trespassing" when I tried to register to vote at the white courthouse, once for "disturbing the peace" during a peaceful protest, and once for "conspiracy" when we organized a voter registration drive.
|
||||||
|
|
||||||
|
Each arrest was terrifying, but they were also clarifying. Every time they put me in that cell, I became more certain that what we were doing was not just right, but necessary. My students needed to grow up in a world where their worth wasn't determined by the color of their skin.
|
||||||
|
|
||||||
|
The most meaningful moment came in 1968, when the first integrated class graduated from Montgomery High School. I was teaching at the newly integrated elementary school by then, and several of my former students were in that graduating class. As I watched them walk across that stage - Black and white students together - I thought about all the miles we'd walked, all the risks we'd taken, all the small acts of courage that had led to this moment.
|
||||||
|
|
||||||
|
After the ceremony, one of my former students, a young man named Marcus, came up to me with his family. He introduced me to his parents as "the teacher who taught me that education and courage go hand in hand." His mother hugged me and whispered, "Thank you for being brave so my son could have choices."
|
||||||
|
|
||||||
|
That's when I understood what my grandmother meant about doing what's right. It's not about the recognition - though this old lady appreciates the kind words from the community here on dignity.ink. It's about planting seeds for a harvest you might never see.
|
||||||
|
|
||||||
|
I taught for thirty-eight more years, watching integration slowly become normal, watching my students grow up to become doctors and lawyers and teachers themselves. Some of them had children who ended up in my classroom too. That's the real victory - raising a generation that can't imagine a world where people are separated by skin color.
|
||||||
|
|
||||||
|
To the young activists today who are still fighting for justice: the work is hard, the progress is slow, and some days it feels impossible. But every small act of courage matters. Every time you stand up for what's right, you're walking in the footsteps of everyone who came before you, and you're paving the way for everyone who comes after.
|
||||||
|
|
||||||
|
Keep walking. Keep fighting. Keep believing that change is possible, because it is. I've seen it happen, one step at a time.
|
||||||
|
|
||||||
|
*Dorothy Mae Johnson taught in Alabama public schools for 40 years and continues to speak at schools and community organizations about the civil rights movement. She says her greatest achievement is the thousands of students who learned in her classroom that education and equality go hand in hand.*
|
41
src/content/dignity/martha-navy-nurse.md
Normal file
41
src/content/dignity/martha-navy-nurse.md
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
---
|
||||||
|
title: "Serving in the Pacific: A Nurse's Story from WWII"
|
||||||
|
excerpt: "Martha Henderson, 98, shares her experiences as a Navy nurse during the Battle of Guadalcanal..."
|
||||||
|
authorName: "Martha Henderson"
|
||||||
|
authorAge: 98
|
||||||
|
tags: ["WWII", "nursing", "service", "Pacific Theater"]
|
||||||
|
location: "Sunset Manor, Portland OR"
|
||||||
|
dateOfEvent: "1943-1945"
|
||||||
|
upvotes: 234
|
||||||
|
publishedAt: 2024-01-10T00:00:00.000Z
|
||||||
|
commentCount: 89
|
||||||
|
isPromoted: false
|
||||||
|
---
|
||||||
|
|
||||||
|
I was twenty-two years old when I enlisted as a Navy nurse in 1943. Fresh out of nursing school, I thought I knew what I was getting into. I was wrong about almost everything, but right about the one thing that mattered most: I knew I needed to help.
|
||||||
|
|
||||||
|
They shipped us out to the Pacific Theater, and my first assignment was a hospital ship near Guadalcanal. If you've never been on a hospital ship during wartime, let me tell you - it's organized chaos. We'd get word that casualties were coming in, and suddenly our peaceful floating hospital would transform into the most important place in the world for dozens of young men who just wanted to go home.
|
||||||
|
|
||||||
|
The hardest part wasn't the blood or the wounds - nursing school had prepared me for that. The hardest part was how young they all were. Boys, really, barely old enough to shave, calling out for their mothers in the middle of the night. I was only a few years older than most of them, but I had to be strong for them when they couldn't be strong for themselves.
|
||||||
|
|
||||||
|
There was one boy - I'll call him Tommy, though that wasn't his real name - who came in with shrapnel wounds to his legs. He was conscious and kept apologizing for bleeding on my uniform. Can you imagine? This brave young man, wounded serving his country, apologizing to me for doing my job.
|
||||||
|
|
||||||
|
Tommy was from a small farm in Iowa, and he told me all about his family's corn fields while I cleaned his wounds. He had a sweetheart back home named Betty, and he showed me her picture so many times I felt like I knew her personally. He was planning to propose when he got back, had already bought the ring and everything.
|
||||||
|
|
||||||
|
We worked on Tommy for hours. The doctors said his legs could be saved, but it would be a long recovery. He'd probably walk with a limp for the rest of his life, but he'd walk. When we told him, he cried with relief. "Betty won't mind," he said. "She loves me for who I am, not how I walk."
|
||||||
|
|
||||||
|
That was my job for two years - being strong for the Tommys of the world, holding their hands when they were scared, celebrating with them when they got good news, and sometimes... sometimes saying goodbye when the good news didn't come.
|
||||||
|
|
||||||
|
I learned more about courage on that hospital ship than in all my years before or since. It wasn't the dramatic kind of courage you see in movies. It was quiet courage - the courage to keep going when everything hurts, to smile when you want to cry, to believe in tomorrow when today seems impossible.
|
||||||
|
|
||||||
|
When the war ended, I came home to Oregon and worked at Portland General Hospital for thirty-seven years. I married a wonderful man named Robert, and we had three children and seven grandchildren. I lived a full, blessed life.
|
||||||
|
|
||||||
|
But I never forgot my Navy family, especially Tommy. About ten years ago, I got a letter from his granddaughter. She'd found my name in some old letters he'd kept. Tommy had passed away in 2018 at the age of ninety-three, surrounded by his family. He'd married his Betty right after the war, just like he planned. They'd had sixty-eight years together.
|
||||||
|
|
||||||
|
In the letter, his granddaughter told me that Tommy used to talk about "his angel nurse" who helped him through the darkest time of his life. He never knew my name - military protocol and the chaos of wartime meant we often didn't exchange personal information - but he remembered my kindness.
|
||||||
|
|
||||||
|
That letter sits framed on my nightstand now. Some people might think it's sad that we never reconnected, but I don't see it that way. We were both exactly where we needed to be when we needed to be there. That's what service means - showing up for each other when it matters most, whether you ever see each other again or not.
|
||||||
|
|
||||||
|
The young nurses starting their careers today ask me for advice sometimes. I tell them what I learned on that hospital ship: Your hands can heal, but your heart heals even more. Every patient is someone's Tommy, someone's most precious person. Treat them that way, and you'll never go wrong.
|
||||||
|
|
||||||
|
*At 98, Martha still volunteers at the local veterans' hospital every Tuesday, reading to patients and sharing stories from her service.*
|
39
src/content/nevertell/college-roommate-prank.md
Normal file
39
src/content/nevertell/college-roommate-prank.md
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
---
|
||||||
|
title: "College Roommate Shenanigans That Almost Got Us Expelled"
|
||||||
|
excerpt: "It involved a dean's car, three chickens, and a very confused security guard..."
|
||||||
|
tags: ["college", "1960s", "pranks"]
|
||||||
|
location: "Golden Years Community, Boston MA"
|
||||||
|
dateOfEvent: "Spring 1963"
|
||||||
|
upvotes: 89
|
||||||
|
promotionVotes: 12
|
||||||
|
publishedAt: 2024-01-12T00:00:00.000Z
|
||||||
|
commentCount: 32
|
||||||
|
---
|
||||||
|
|
||||||
|
Spring of 1963, my roommate Jimmy and I were facing our final exams, and we were stressed out of our minds. We'd been cooped up in the library for weeks, and I think we both went a little stir-crazy.
|
||||||
|
|
||||||
|
It started as a joke when we saw Dean Morrison's brand new Cadillac parked in his reserved spot. Jimmy said, "Wouldn't it be funny if..." and I should have stopped him right there, but I didn't.
|
||||||
|
|
||||||
|
The plan was ridiculous: we were going to "redecorate" his car. Nothing permanent, just something that would make him scratch his head. We bought three live chickens from a farmer outside town (don't ask me how we got them back to campus), and a bunch of balloons.
|
||||||
|
|
||||||
|
At 2 AM, we snuck out to the parking lot. The security guard was making his rounds, so we had to time it perfectly. We tied the balloons to the car's antenna and bumpers, then - and this is where it gets really stupid - we put the chickens inside the car.
|
||||||
|
|
||||||
|
How did we get into a locked car? Jimmy's dad was a locksmith, and he'd taught Jimmy a thing or two. We figured the chickens would just sit there looking confused, we'd get a laugh, and then we'd let them out before morning.
|
||||||
|
|
||||||
|
What we didn't account for was that chickens don't just sit quietly. By morning, those birds had completely destroyed the interior of Dean Morrison's brand new Cadillac. Feathers everywhere, you-know-what on every surface, and somehow one of them had figured out how to honk the horn repeatedly.
|
||||||
|
|
||||||
|
The security guard found the car at 6 AM with balloons bobbing in the breeze and chickens raising absolute hell inside. Half the campus came out to see what the commotion was about.
|
||||||
|
|
||||||
|
Dean Morrison was... not amused. Jimmy and I were called into his office that afternoon, where he sat behind his desk with feathers still stuck in his hair from trying to retrieve his car keys from the backseat.
|
||||||
|
|
||||||
|
"Gentlemen," he said, "I have two questions. First: why chickens? Second: how did you plan to explain this to my wife?"
|
||||||
|
|
||||||
|
We told him the truth - that we were stressed about exams and thought it would be funny. He stared at us for what felt like an hour, then started laughing so hard he couldn't breathe.
|
||||||
|
|
||||||
|
Turns out, he and his fraternity brothers had done something similar to the previous dean twenty years earlier, except with a cow (don't ask me how they got a cow up to the third floor of the administration building).
|
||||||
|
|
||||||
|
Our punishment? We had to wash and detail his car every week for the rest of the semester, and we had to take the chickens back to the farmer and explain to him why we were returning slightly traumatized poultry.
|
||||||
|
|
||||||
|
Jimmy and I graduated that spring, and Dean Morrison gave us each a small rubber chicken with our diplomas. I kept mine for sixty years until my granddaughter finally convinced me to tell her this story. Now it sits on her desk at college, and I suspect she's planning her own shenanigans.
|
||||||
|
|
||||||
|
Some traditions never die, they just get passed down to the next generation of troublemakers.
|
29
src/content/nevertell/elvis-concert-1956.md
Normal file
29
src/content/nevertell/elvis-concert-1956.md
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
---
|
||||||
|
title: "The Time I Snuck Out to See Elvis"
|
||||||
|
excerpt: "My parents thought I was at Bible study, but I was actually..."
|
||||||
|
tags: ["1950s", "music", "rebellion"]
|
||||||
|
location: "Sunset Manor, Memphis TN"
|
||||||
|
dateOfEvent: "Summer 1956"
|
||||||
|
upvotes: 127
|
||||||
|
promotionVotes: 23
|
||||||
|
publishedAt: 2024-01-15T00:00:00.000Z
|
||||||
|
commentCount: 45
|
||||||
|
---
|
||||||
|
|
||||||
|
My parents thought I was at Bible study, but I was actually waiting outside the back door of the community center where Elvis was performing his first paid concert in our little town.
|
||||||
|
|
||||||
|
I was seventeen and had never disobeyed my parents before, but something about that music just called to me. When I heard he was coming to town, I knew I had to see him, even if it meant lying to my family.
|
||||||
|
|
||||||
|
The plan was simple: tell my parents I was going to Wednesday night Bible study (which I never missed), but instead sneak around to the back of the community center where the colored folks had to enter. I figured if I stood there, I might be able to hear the music.
|
||||||
|
|
||||||
|
What I didn't expect was for Elvis himself to come out that back door during his break! He was just a young man then, probably only a few years older than me, and when he saw me standing there in my church dress, he smiled and said, "Well hello there, darlin'. You waitin' for someone?"
|
||||||
|
|
||||||
|
I was so nervous I could barely speak, but I managed to tell him I just wanted to hear the music. He laughed - not mean, but kind - and said, "Well shoot, come on in then. Music's for everyone."
|
||||||
|
|
||||||
|
And that's how I ended up dancing to Elvis Presley before anyone knew who he was, in a room full of people my parents would have been scandalized to know I was mixing with. But you know what? It was the most alive I'd ever felt.
|
||||||
|
|
||||||
|
When I got home three hours later, I told my parents Bible study ran long because we were discussing "joyful noise unto the Lord." It wasn't even a lie, really.
|
||||||
|
|
||||||
|
I never told them the truth, even after Elvis became famous. But every time I heard "That's All Right" on the radio, I'd smile and remember the night I learned that sometimes the most important lessons come from the most unexpected places.
|
||||||
|
|
||||||
|
*[This story received 23 votes for promotion to dignity.ink, but the author chose to keep it anonymous]*
|
181
src/layouts/BaseLayout.astro
Normal file
181
src/layouts/BaseLayout.astro
Normal file
@ -0,0 +1,181 @@
|
|||||||
|
---
|
||||||
|
export interface Props {
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
platform?: 'nevertell' | 'dignity' | 'main';
|
||||||
|
}
|
||||||
|
|
||||||
|
const { title, description = "The Story Bridge Project - Connecting generations through storytelling", platform = 'main' } = Astro.props;
|
||||||
|
|
||||||
|
// Platform-specific styling
|
||||||
|
const platformStyles = {
|
||||||
|
nevertell: {
|
||||||
|
bgClass: 'bg-nevertell-background',
|
||||||
|
primaryColor: 'nevertell-primary',
|
||||||
|
textColor: 'nevertell-text'
|
||||||
|
},
|
||||||
|
dignity: {
|
||||||
|
bgClass: 'bg-dignity-background',
|
||||||
|
primaryColor: 'dignity-primary',
|
||||||
|
textColor: 'dignity-text'
|
||||||
|
},
|
||||||
|
main: {
|
||||||
|
bgClass: 'bg-gradient-to-br from-indigo-50 to-emerald-50',
|
||||||
|
primaryColor: 'gray-800',
|
||||||
|
textColor: 'gray-900'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = platformStyles[platform];
|
||||||
|
---
|
||||||
|
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="description" content={description} />
|
||||||
|
<title>{title}</title>
|
||||||
|
|
||||||
|
<!-- Preload fonts for better performance -->
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||||
|
|
||||||
|
<!-- Alpine.js - Only 15kb! -->
|
||||||
|
<script defer src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"></script>
|
||||||
|
|
||||||
|
<!-- Accessibility improvements -->
|
||||||
|
<style>
|
||||||
|
/* Enhanced focus styles for keyboard navigation */
|
||||||
|
*:focus {
|
||||||
|
outline: 3px solid #3b82f6;
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* High contrast mode support */
|
||||||
|
@media (prefers-contrast: high) {
|
||||||
|
.btn-primary, .btn-nevertell, .btn-dignity {
|
||||||
|
background: black !important;
|
||||||
|
color: white !important;
|
||||||
|
border: 2px solid black !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Reduced motion support */
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
* {
|
||||||
|
animation-duration: 0.01ms !important;
|
||||||
|
animation-iteration-count: 1 !important;
|
||||||
|
transition-duration: 0.01ms !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Larger text for senior accessibility */
|
||||||
|
body {
|
||||||
|
font-size: 18px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.story-content {
|
||||||
|
font-size: 1.125rem;
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Minimum touch targets for mobile */
|
||||||
|
button, a, input, textarea, select {
|
||||||
|
min-height: 44px;
|
||||||
|
min-width: 44px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body class={`min-h-screen ${styles.bgClass} font-sans`}>
|
||||||
|
<!-- Skip link for screen readers -->
|
||||||
|
<a
|
||||||
|
href="#main-content"
|
||||||
|
class="sr-only focus:not-sr-only focus:absolute focus:top-2 focus:left-2 bg-black text-white px-4 py-2 rounded z-50"
|
||||||
|
>
|
||||||
|
Skip to main content
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<div id="main-content">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Global Alpine.js data and functions -->
|
||||||
|
<script>
|
||||||
|
document.addEventListener('alpine:init', () => {
|
||||||
|
// Global utility functions
|
||||||
|
Alpine.store('utils', {
|
||||||
|
formatDate(dateString) {
|
||||||
|
return new Date(dateString).toLocaleDateString('en-US', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric'
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
formatRelativeTime(dateString) {
|
||||||
|
const date = new Date(dateString);
|
||||||
|
const now = new Date();
|
||||||
|
const diffTime = Math.abs(now - date);
|
||||||
|
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
|
||||||
|
|
||||||
|
if (diffDays === 1) return 'Yesterday';
|
||||||
|
if (diffDays < 7) return `${diffDays} days ago`;
|
||||||
|
if (diffDays < 30) return `${Math.ceil(diffDays / 7)} weeks ago`;
|
||||||
|
if (diffDays < 365) return `${Math.ceil(diffDays / 30)} months ago`;
|
||||||
|
return `${Math.ceil(diffDays / 365)} years ago`;
|
||||||
|
},
|
||||||
|
|
||||||
|
truncateText(text, maxLength = 150) {
|
||||||
|
if (text.length <= maxLength) return text;
|
||||||
|
return text.substring(0, maxLength).trim() + '...';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Global API functions
|
||||||
|
Alpine.store('api', {
|
||||||
|
async vote(storyId, type) {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/vote', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ storyId, type })
|
||||||
|
});
|
||||||
|
return await response.json();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Vote failed:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async submitComment(storyId, content, authorName = null) {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/comments', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ storyId, content, authorName })
|
||||||
|
});
|
||||||
|
return await response.json();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Comment submission failed:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async loadComments(storyId) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/comments?storyId=${storyId}`);
|
||||||
|
return await response.json();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load comments:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
100
src/layouts/DignityLayout.astro
Normal file
100
src/layouts/DignityLayout.astro
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
---
|
||||||
|
import BaseLayout from './BaseLayout.astro';
|
||||||
|
|
||||||
|
export interface Props {
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { title, description } = Astro.props;
|
||||||
|
---
|
||||||
|
|
||||||
|
<BaseLayout title={title} description={description} platform="dignity">
|
||||||
|
<header class="bg-white shadow-sm border-b-2 border-dignity-primary">
|
||||||
|
<nav class="container mx-auto px-4 py-4">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<a href="/dignity" class="flex items-center space-x-2 group">
|
||||||
|
<span class="text-2xl group-hover:scale-110 transition-transform">🏆</span>
|
||||||
|
<span class="text-2xl font-bold text-dignity-primary">dignity.ink</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<div class="hidden md:flex items-center space-x-6">
|
||||||
|
<a href="/dignity" class="text-gray-700 hover:text-dignity-primary transition-colors">
|
||||||
|
Stories
|
||||||
|
</a>
|
||||||
|
<a href="/dignity/heroes" class="text-gray-700 hover:text-dignity-primary transition-colors">
|
||||||
|
Heroes
|
||||||
|
</a>
|
||||||
|
<a href="/dignity/legacy" class="text-gray-700 hover:text-dignity-primary transition-colors">
|
||||||
|
Legacy
|
||||||
|
</a>
|
||||||
|
<a href="/dignity/submit" class="px-6 py-2 bg-dignity-primary text-white rounded-lg hover:bg-emerald-700 transition-colors focus:outline-none focus:ring-2 focus:ring-dignity-primary focus:ring-offset-2">
|
||||||
|
Share a Story
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Mobile menu button -->
|
||||||
|
<button
|
||||||
|
x-data="{ open: false }"
|
||||||
|
@click="open = !open"
|
||||||
|
class="md:hidden p-2 rounded-lg hover:bg-gray-100"
|
||||||
|
aria-label="Toggle menu"
|
||||||
|
>
|
||||||
|
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Mobile menu -->
|
||||||
|
<div
|
||||||
|
x-data="{ open: false }"
|
||||||
|
x-show="open"
|
||||||
|
x-transition
|
||||||
|
class="md:hidden mt-4 space-y-2"
|
||||||
|
>
|
||||||
|
<a href="/dignity" class="block px-4 py-2 text-gray-700 hover:bg-dignity-primary/10 rounded">
|
||||||
|
Stories
|
||||||
|
</a>
|
||||||
|
<a href="/dignity/heroes" class="block px-4 py-2 text-gray-700 hover:bg-dignity-primary/10 rounded">
|
||||||
|
Heroes
|
||||||
|
</a>
|
||||||
|
<a href="/dignity/legacy" class="block px-4 py-2 text-gray-700 hover:bg-dignity-primary/10 rounded">
|
||||||
|
Legacy
|
||||||
|
</a>
|
||||||
|
<a href="/dignity/submit" class="block px-4 py-2 bg-dignity-primary text-white rounded">
|
||||||
|
Share a Story
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="min-h-screen">
|
||||||
|
<slot />
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer class="bg-gray-800 text-white py-8 mt-16">
|
||||||
|
<div class="container mx-auto px-4 text-center">
|
||||||
|
<p class="mb-4 text-lg">
|
||||||
|
"Pull up a chair..." - Honoring the stories that shaped our world
|
||||||
|
</p>
|
||||||
|
<div class="flex flex-col sm:flex-row justify-center items-center space-y-2 sm:space-y-0 sm:space-x-4 text-sm">
|
||||||
|
<a href="/nevertell" class="text-nevertell-primary hover:underline transition-colors">
|
||||||
|
Visit nevertell.ink
|
||||||
|
</a>
|
||||||
|
<span class="hidden sm:inline">•</span>
|
||||||
|
<a href="/about" class="hover:underline transition-colors">
|
||||||
|
About the Project
|
||||||
|
</a>
|
||||||
|
<span class="hidden sm:inline">•</span>
|
||||||
|
<a href="/privacy" class="hover:underline transition-colors">
|
||||||
|
Privacy Policy
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="mt-4 text-xs text-gray-400">
|
||||||
|
<p>Stories are shared with honor and become part of our permanent archive.</p>
|
||||||
|
<p>© 2024 The Story Bridge Project</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</BaseLayout>
|
94
src/layouts/NevertellLayout.astro
Normal file
94
src/layouts/NevertellLayout.astro
Normal file
@ -0,0 +1,94 @@
|
|||||||
|
---
|
||||||
|
import BaseLayout from './BaseLayout.astro';
|
||||||
|
|
||||||
|
export interface Props {
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { title, description } = Astro.props;
|
||||||
|
---
|
||||||
|
|
||||||
|
<BaseLayout title={title} description={description} platform="nevertell">
|
||||||
|
<header class="bg-white shadow-sm border-b-2 border-nevertell-primary">
|
||||||
|
<nav class="container mx-auto px-4 py-4">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<a href="/nevertell" class="flex items-center space-x-2 group">
|
||||||
|
<span class="text-2xl group-hover:scale-110 transition-transform">🤫</span>
|
||||||
|
<span class="text-2xl font-bold text-nevertell-primary">nevertell.ink</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<div class="hidden md:flex items-center space-x-6">
|
||||||
|
<a href="/nevertell" class="text-gray-700 hover:text-nevertell-primary transition-colors">
|
||||||
|
Stories
|
||||||
|
</a>
|
||||||
|
<a href="/nevertell/categories" class="text-gray-700 hover:text-nevertell-primary transition-colors">
|
||||||
|
Categories
|
||||||
|
</a>
|
||||||
|
<a href="/nevertell/submit" class="px-6 py-2 bg-nevertell-primary text-white rounded-lg hover:bg-indigo-700 transition-colors focus:outline-none focus:ring-2 focus:ring-nevertell-primary focus:ring-offset-2">
|
||||||
|
Share a Story
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Mobile menu button -->
|
||||||
|
<button
|
||||||
|
x-data="{ open: false }"
|
||||||
|
@click="open = !open"
|
||||||
|
class="md:hidden p-2 rounded-lg hover:bg-gray-100"
|
||||||
|
aria-label="Toggle menu"
|
||||||
|
>
|
||||||
|
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Mobile menu -->
|
||||||
|
<div
|
||||||
|
x-data="{ open: false }"
|
||||||
|
x-show="open"
|
||||||
|
x-transition
|
||||||
|
class="md:hidden mt-4 space-y-2"
|
||||||
|
>
|
||||||
|
<a href="/nevertell" class="block px-4 py-2 text-gray-700 hover:bg-nevertell-primary/10 rounded">
|
||||||
|
Stories
|
||||||
|
</a>
|
||||||
|
<a href="/nevertell/categories" class="block px-4 py-2 text-gray-700 hover:bg-nevertell-primary/10 rounded">
|
||||||
|
Categories
|
||||||
|
</a>
|
||||||
|
<a href="/nevertell/submit" class="block px-4 py-2 bg-nevertell-primary text-white rounded">
|
||||||
|
Share a Story
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="min-h-screen">
|
||||||
|
<slot />
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer class="bg-gray-800 text-white py-8 mt-16">
|
||||||
|
<div class="container mx-auto px-4 text-center">
|
||||||
|
<p class="mb-4 text-lg">
|
||||||
|
"Between you and me..." - Anonymous stories from amazing lives
|
||||||
|
</p>
|
||||||
|
<div class="flex flex-col sm:flex-row justify-center items-center space-y-2 sm:space-y-0 sm:space-x-4 text-sm">
|
||||||
|
<a href="/dignity" class="text-dignity-primary hover:underline transition-colors">
|
||||||
|
Visit dignity.ink
|
||||||
|
</a>
|
||||||
|
<span class="hidden sm:inline">•</span>
|
||||||
|
<a href="/about" class="hover:underline transition-colors">
|
||||||
|
About the Project
|
||||||
|
</a>
|
||||||
|
<span class="hidden sm:inline">•</span>
|
||||||
|
<a href="/privacy" class="hover:underline transition-colors">
|
||||||
|
Privacy Policy
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="mt-4 text-xs text-gray-400">
|
||||||
|
<p>Stories are shared anonymously and treated with respect.</p>
|
||||||
|
<p>© 2024 The Story Bridge Project</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</BaseLayout>
|
285
src/pages/dignity/[slug].astro
Normal file
285
src/pages/dignity/[slug].astro
Normal file
@ -0,0 +1,285 @@
|
|||||||
|
---
|
||||||
|
import { getCollection } from 'astro:content';
|
||||||
|
import DignityLayout from '../../layouts/DignityLayout.astro';
|
||||||
|
|
||||||
|
export async function getStaticPaths() {
|
||||||
|
const stories = await getCollection('dignity');
|
||||||
|
return stories.map((story) => ({
|
||||||
|
params: { slug: story.slug },
|
||||||
|
props: story,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
const story = Astro.props;
|
||||||
|
const { Content } = await story.render();
|
||||||
|
---
|
||||||
|
|
||||||
|
<DignityLayout title={story.data.title}>
|
||||||
|
<div class="container mx-auto px-4 py-8">
|
||||||
|
<div class="max-w-4xl mx-auto">
|
||||||
|
{/* Story Header */}
|
||||||
|
<div class="mb-8">
|
||||||
|
<a
|
||||||
|
href="/dignity"
|
||||||
|
class="text-dignity-primary hover:underline mb-4 inline-block transition-colors"
|
||||||
|
>
|
||||||
|
← Back to Stories
|
||||||
|
</a>
|
||||||
|
|
||||||
|
{story.data.isPromoted && (
|
||||||
|
<div class="mb-4 p-3 bg-nevertell-primary/10 rounded-lg border-l-4 border-nevertell-primary">
|
||||||
|
<p class="text-sm text-nevertell-primary font-medium">
|
||||||
|
✨ This story was promoted from nevertell.ink after community encouragement!
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<h1 class="text-story-title font-serif text-dignity-text mb-4">
|
||||||
|
{story.data.title}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap items-center text-gray-600 mb-4 gap-4">
|
||||||
|
<span class="font-medium text-dignity-primary text-lg">
|
||||||
|
{story.data.authorName}
|
||||||
|
</span>
|
||||||
|
{story.data.authorAge && (
|
||||||
|
<span>Age {story.data.authorAge}</span>
|
||||||
|
)}
|
||||||
|
{story.data.location && (
|
||||||
|
<span>📍 {story.data.location}</span>
|
||||||
|
)}
|
||||||
|
{story.data.dateOfEvent && (
|
||||||
|
<span>📅 Period: {story.data.dateOfEvent}</span>
|
||||||
|
)}
|
||||||
|
<span>📝 {story.data.publishedAt.toLocaleDateString()}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{story.data.tags.length > 0 && (
|
||||||
|
<div class="flex flex-wrap gap-2 mb-6">
|
||||||
|
{story.data.tags.map(tag => (
|
||||||
|
<a
|
||||||
|
href={`/dignity?tag=${tag}`}
|
||||||
|
class="px-3 py-1 bg-dignity-primary/10 text-dignity-primary rounded-full text-sm hover:bg-dignity-primary hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
#{tag}
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Story Content */}
|
||||||
|
<div class="bg-white rounded-lg shadow-lg p-8 mb-8">
|
||||||
|
<div class="max-w-none story-content font-serif text-gray-800">
|
||||||
|
<Content />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Interactive Honor Section with Alpine.js */}
|
||||||
|
<div
|
||||||
|
class="bg-white rounded-lg shadow-lg p-6 mb-8"
|
||||||
|
x-data="storyHonor({
|
||||||
|
storyId: '{story.slug}',
|
||||||
|
initialUpvotes: {story.data.upvotes}
|
||||||
|
})"
|
||||||
|
>
|
||||||
|
<div class="flex flex-col lg:flex-row items-start lg:items-center justify-between gap-6">
|
||||||
|
<div class="flex items-center space-x-6">
|
||||||
|
<button
|
||||||
|
@click="honor()"
|
||||||
|
:disabled="honoring"
|
||||||
|
class="flex items-center space-x-2 text-gray-600 hover:text-dignity-primary transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<span class="text-xl">🙏</span>
|
||||||
|
<span x-text="upvotes + ' honors'"></span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="flex items-center space-x-2 text-gray-600">
|
||||||
|
<span class="text-xl">💬</span>
|
||||||
|
<span>{story.data.commentCount} reflections</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center space-x-4">
|
||||||
|
<a
|
||||||
|
href="/anonymous"
|
||||||
|
class="text-nevertell-primary hover:underline transition-colors"
|
||||||
|
>
|
||||||
|
Browse Anonymous Stories →
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="/dignity/submit"
|
||||||
|
class="px-4 py-2 bg-dignity-primary text-white rounded-lg hover:bg-emerald-700 transition-colors"
|
||||||
|
>
|
||||||
|
Share Another Story
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div x-show="message" x-transition class="mt-4 p-3 bg-green-100 text-green-700 rounded-lg">
|
||||||
|
<p x-text="message"></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Comments Section with Alpine.js */}
|
||||||
|
<div
|
||||||
|
class="bg-white rounded-lg shadow-lg p-8"
|
||||||
|
x-data="dignityCommentSystem('{story.slug}')"
|
||||||
|
x-init="loadComments()"
|
||||||
|
>
|
||||||
|
<h3 class="text-xl font-semibold text-dignity-text mb-6">
|
||||||
|
Community Reflections (<span x-text="comments.length"></span>)
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{/* Comment Form */}
|
||||||
|
<form @submit.prevent="submitComment()" class="mb-8">
|
||||||
|
<div class="grid md:grid-cols-3 gap-4 mb-4">
|
||||||
|
<div class="md:col-span-1">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
x-model="authorName"
|
||||||
|
placeholder="Your name (optional)"
|
||||||
|
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-dignity-primary focus:border-transparent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="md:col-span-2">
|
||||||
|
<textarea
|
||||||
|
x-model="newComment"
|
||||||
|
placeholder="Share your reflection on this story... What does it mean to you? How does it inspire you?"
|
||||||
|
rows="4"
|
||||||
|
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-dignity-primary focus:border-transparent resize-vertical"
|
||||||
|
required
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<p class="text-sm text-gray-500">
|
||||||
|
Share how this story touches you or reminds you of someone special in your life.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
:disabled="submitting || !newComment.trim()"
|
||||||
|
class="px-6 py-2 bg-dignity-primary text-white rounded-lg hover:bg-emerald-700 transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<span x-show="!submitting">Post Reflection</span>
|
||||||
|
<span x-show="submitting">Posting...</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{/* Comments List */}
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div x-show="comments.length === 0 && !loadingComments" class="text-center py-8">
|
||||||
|
<div class="text-gray-400 text-4xl mb-4">🙏</div>
|
||||||
|
<p class="text-gray-500">Be the first to honor this story with your reflection.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div x-show="loadingComments" class="text-center py-8">
|
||||||
|
<div class="text-gray-400 text-2xl mb-4">⏳</div>
|
||||||
|
<p class="text-gray-500">Loading reflections...</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template x-for="comment in comments" :key="comment.id">
|
||||||
|
<div class="border-b border-gray-200 pb-4 last:border-b-0">
|
||||||
|
<div class="flex items-center justify-between mb-2">
|
||||||
|
<span class="text-sm font-medium text-dignity-primary" x-text="comment.authorName || 'Anonymous'"></span>
|
||||||
|
<span class="text-sm text-gray-500" x-text="$store.utils.formatRelativeTime(comment.createdAt)"></span>
|
||||||
|
</div>
|
||||||
|
<p class="text-gray-700 leading-relaxed" x-text="comment.content"></p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Author Recognition */}
|
||||||
|
<div class="mt-12 bg-dignity-primary/5 rounded-lg p-8 text-center">
|
||||||
|
<h3 class="text-xl font-semibold text-dignity-text mb-4">
|
||||||
|
Thank You, {story.data.authorName}
|
||||||
|
</h3>
|
||||||
|
<p class="text-gray-600 mb-4">
|
||||||
|
Your story has been preserved as part of our permanent honor roll.
|
||||||
|
It will inspire future generations and ensure your experiences are never forgotten.
|
||||||
|
</p>
|
||||||
|
<div class="flex justify-center space-x-4">
|
||||||
|
<a
|
||||||
|
href="/dignity"
|
||||||
|
class="px-4 py-2 bg-dignity-primary text-white rounded-lg hover:bg-emerald-700 transition-colors"
|
||||||
|
>
|
||||||
|
Read More Honor Stories
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="/dignity/submit"
|
||||||
|
class="px-4 py-2 border border-dignity-primary text-dignity-primary hover:bg-dignity-primary hover:text-white rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
Share Another Story
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.addEventListener('alpine:init', () => {
|
||||||
|
Alpine.data('storyHonor', (config) => ({
|
||||||
|
storyId: config.storyId,
|
||||||
|
upvotes: config.initialUpvotes,
|
||||||
|
honoring: false,
|
||||||
|
message: '',
|
||||||
|
|
||||||
|
async honor() {
|
||||||
|
this.honoring = true;
|
||||||
|
try {
|
||||||
|
const data = await Alpine.store('api').vote(this.storyId, 'upvote');
|
||||||
|
this.upvotes = data.upvotes;
|
||||||
|
this.message = 'Thank you for honoring this story! 🙏';
|
||||||
|
setTimeout(() => this.message = '', 3000);
|
||||||
|
} catch (error) {
|
||||||
|
this.message = 'Sorry, honoring failed. Please try again.';
|
||||||
|
setTimeout(() => this.message = '', 3000);
|
||||||
|
}
|
||||||
|
this.honoring = false;
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
Alpine.data('dignityCommentSystem', (storyId) => ({
|
||||||
|
storyId,
|
||||||
|
comments: [],
|
||||||
|
newComment: '',
|
||||||
|
authorName: '',
|
||||||
|
submitting: false,
|
||||||
|
loadingComments: false,
|
||||||
|
|
||||||
|
async loadComments() {
|
||||||
|
this.loadingComments = true;
|
||||||
|
try {
|
||||||
|
this.comments = await Alpine.store('api').loadComments(this.storyId);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load comments:', error);
|
||||||
|
}
|
||||||
|
this.loadingComments = false;
|
||||||
|
},
|
||||||
|
|
||||||
|
async submitComment() {
|
||||||
|
if (!this.newComment.trim()) return;
|
||||||
|
|
||||||
|
this.submitting = true;
|
||||||
|
try {
|
||||||
|
const result = await Alpine.store('api').submitComment(
|
||||||
|
this.storyId,
|
||||||
|
this.newComment,
|
||||||
|
this.authorName.trim() || null
|
||||||
|
);
|
||||||
|
if (result.success) {
|
||||||
|
this.newComment = '';
|
||||||
|
this.authorName = '';
|
||||||
|
this.loadComments(); // Refresh comments
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Comment submission failed:', error);
|
||||||
|
}
|
||||||
|
this.submitting = false;
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</DignityLayout>
|
207
src/pages/dignity/index.astro
Normal file
207
src/pages/dignity/index.astro
Normal file
@ -0,0 +1,207 @@
|
|||||||
|
---
|
||||||
|
import { getCollection } from 'astro:content';
|
||||||
|
import DignityLayout from '../../layouts/DignityLayout.astro';
|
||||||
|
|
||||||
|
const stories = await getCollection('dignity');
|
||||||
|
const sortedStories = stories.sort((a, b) => b.data.publishedAt.getTime() - a.data.publishedAt.getTime());
|
||||||
|
---
|
||||||
|
|
||||||
|
<DignityLayout title="dignity.ink - Stories of Honor and Legacy">
|
||||||
|
<div class="container mx-auto px-4 py-8">
|
||||||
|
{/* Hero Section */}
|
||||||
|
<div class="text-center mb-12">
|
||||||
|
<h1 class="text-4xl font-bold text-dignity-text mb-4">
|
||||||
|
Stories They Wish the World Would Remember
|
||||||
|
</h1>
|
||||||
|
<p class="text-lg text-gray-600 max-w-2xl mx-auto mb-6">
|
||||||
|
Named accounts of heroism, service, love, and the moments that defined a generation.
|
||||||
|
These are the stories of ordinary people who did extraordinary things.
|
||||||
|
</p>
|
||||||
|
<p class="text-dignity-primary font-medium italic">
|
||||||
|
Warm reverence, pull up a chair...
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Featured Stories */}
|
||||||
|
<div class="max-w-6xl mx-auto">
|
||||||
|
<div class="flex justify-between items-center mb-8">
|
||||||
|
<h2 class="text-2xl font-semibold text-dignity-text">Featured Stories</h2>
|
||||||
|
<a
|
||||||
|
href="/dignity/submit"
|
||||||
|
class="px-6 py-2 bg-dignity-primary text-white rounded-lg hover:bg-emerald-700 transition-colors"
|
||||||
|
>
|
||||||
|
Share Your Story
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-8">
|
||||||
|
{sortedStories.map(async (story) => {
|
||||||
|
const { Content } = await story.render();
|
||||||
|
return (
|
||||||
|
<article class="bg-white rounded-lg shadow-lg p-8 border border-gray-200">
|
||||||
|
{story.data.isPromoted && (
|
||||||
|
<div class="mb-4 p-3 bg-nevertell-primary/10 rounded-lg border-l-4 border-nevertell-primary">
|
||||||
|
<p class="text-sm text-nevertell-primary font-medium">
|
||||||
|
✨ This story was promoted from nevertell.ink after community encouragement!
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div class="flex justify-between items-start mb-4">
|
||||||
|
<h3 class="text-story-title font-serif text-dignity-text hover:text-dignity-primary">
|
||||||
|
<a href={`/dignity/${story.slug}`}>
|
||||||
|
{story.data.title}
|
||||||
|
</a>
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center mb-4 text-gray-600">
|
||||||
|
<span class="font-medium text-dignity-primary text-lg">{story.data.authorName}</span>
|
||||||
|
{story.data.authorAge && (
|
||||||
|
<>
|
||||||
|
<span class="mx-2">•</span>
|
||||||
|
<span>Age {story.data.authorAge}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{story.data.location && (
|
||||||
|
<>
|
||||||
|
<span class="mx-2">•</span>
|
||||||
|
<span className="text-sm">{story.data.location}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-gray-700 mb-6 text-story-body">
|
||||||
|
{story.data.excerpt || (await story.render()).remarkPluginFrontmatter?.excerpt}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap gap-2 mb-6">
|
||||||
|
{story.data.tags.map(tag => (
|
||||||
|
<span
|
||||||
|
class="px-3 py-1 bg-dignity-primary/10 text-dignity-primary rounded-full text-sm"
|
||||||
|
>
|
||||||
|
#{tag}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between border-t pt-4">
|
||||||
|
<div class="flex items-center space-x-6">
|
||||||
|
<span class="flex items-center space-x-2 text-gray-600">
|
||||||
|
<span>🙏</span>
|
||||||
|
<span>{story.data.upvotes} honors</span>
|
||||||
|
</span>
|
||||||
|
<a
|
||||||
|
href={`/dignity/${story.slug}#comments`}
|
||||||
|
class="flex items-center space-x-2 text-gray-600 hover:text-dignity-primary transition-colors"
|
||||||
|
>
|
||||||
|
<span>💬</span>
|
||||||
|
<span>{story.data.commentCount} reflections</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a
|
||||||
|
href={`/dignity/${story.slug}`}
|
||||||
|
class="text-dignity-primary hover:underline font-medium"
|
||||||
|
>
|
||||||
|
Read Full Story →
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Categories Section */}
|
||||||
|
<section class="mt-16 max-w-6xl mx-auto">
|
||||||
|
<h3 class="text-2xl font-semibold text-dignity-text mb-8 text-center">
|
||||||
|
Honor Stories by Theme
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div class="grid md:grid-cols-3 gap-8">
|
||||||
|
<div class="text-center p-6 bg-white rounded-lg shadow-md hover:shadow-lg transition-shadow">
|
||||||
|
<div class="text-4xl mb-4">🎖️</div>
|
||||||
|
<h4 class="text-xl font-semibold text-dignity-primary mb-3">Heroes & Service</h4>
|
||||||
|
<p class="text-gray-600 mb-4">
|
||||||
|
Stories of military service, community leadership, and everyday heroism
|
||||||
|
</p>
|
||||||
|
<a href="/dignity/heroes" class="text-dignity-primary hover:underline">
|
||||||
|
Explore Heroes →
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text-center p-6 bg-white rounded-lg shadow-md hover:shadow-lg transition-shadow">
|
||||||
|
<div class="text-4xl mb-4">❤️</div>
|
||||||
|
<h4 class="text-xl font-semibold text-dignity-primary mb-3">Love & Family</h4>
|
||||||
|
<p class="text-gray-600 mb-4">
|
||||||
|
Enduring love stories, family traditions, and generational wisdom
|
||||||
|
</p>
|
||||||
|
<a href="/dignity/love" class="text-dignity-primary hover:underline">
|
||||||
|
Explore Love Stories →
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text-center p-6 bg-white rounded-lg shadow-md hover:shadow-lg transition-shadow">
|
||||||
|
<div class="text-4xl mb-4">🏗️</div>
|
||||||
|
<h4 class="text-xl font-semibold text-dignity-primary mb-3">Builders & Pioneers</h4>
|
||||||
|
<p class="text-gray-600 mb-4">
|
||||||
|
Those who built communities, broke barriers, and paved the way
|
||||||
|
</p>
|
||||||
|
<a href="/dignity/builders" class="text-dignity-primary hover:underline">
|
||||||
|
Explore Pioneers →
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Recent Additions */}
|
||||||
|
<section class="mt-16 max-w-4xl mx-auto">
|
||||||
|
<h3 class="text-2xl font-semibold text-dignity-text mb-8 text-center">
|
||||||
|
Recent Additions to Our Honor Roll
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
{sortedStories.slice(0, 3).map(story => (
|
||||||
|
<div class="bg-white rounded-lg shadow p-6 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h4 class="font-semibold text-dignity-primary text-lg">{story.data.authorName}</h4>
|
||||||
|
<p class="text-gray-600">
|
||||||
|
{story.data.authorAge && `Age ${story.data.authorAge} • `}
|
||||||
|
{story.data.tags[0] && `${story.data.tags[0].charAt(0).toUpperCase() + story.data.tags[0].slice(1)}`}
|
||||||
|
</p>
|
||||||
|
<p class="text-sm text-gray-500">{story.data.location}</p>
|
||||||
|
</div>
|
||||||
|
<a
|
||||||
|
href={`/dignity/${story.slug}`}
|
||||||
|
class="text-dignity-primary hover:underline font-medium"
|
||||||
|
>
|
||||||
|
Read Their Story →
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Call to Action */}
|
||||||
|
<section class="mt-16 bg-dignity-primary/5 rounded-2xl p-8 text-center max-w-4xl mx-auto">
|
||||||
|
<h3 class="text-2xl font-semibold text-dignity-text mb-4">
|
||||||
|
Share a Story That Deserves Recognition
|
||||||
|
</h3>
|
||||||
|
<p class="text-gray-600 mb-6">
|
||||||
|
Know someone whose story should be honored? Help them share their legacy with the world.
|
||||||
|
</p>
|
||||||
|
<div class="flex flex-col sm:flex-row gap-4 justify-center">
|
||||||
|
<a href="/dignity/submit" class="px-6 py-3 bg-dignity-primary text-white rounded-lg hover:bg-emerald-700 transition-colors">
|
||||||
|
Submit a Story
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="/nevertell"
|
||||||
|
class="px-6 py-3 border border-nevertell-primary text-nevertell-primary hover:bg-nevertell-primary hover:text-white rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
Browse Anonymous Stories
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</DignityLayout>
|
196
src/pages/index.astro
Normal file
196
src/pages/index.astro
Normal file
@ -0,0 +1,196 @@
|
|||||||
|
---
|
||||||
|
import BaseLayout from '../layouts/BaseLayout.astro';
|
||||||
|
---
|
||||||
|
|
||||||
|
<BaseLayout title="story-teller.ink - Where storytellers gather">
|
||||||
|
<div class="min-h-screen">
|
||||||
|
<div class="container mx-auto px-4 py-16">
|
||||||
|
<div class="text-center mb-16">
|
||||||
|
<h1 class="text-6xl font-bold text-gray-900 mb-6">
|
||||||
|
story-teller.ink
|
||||||
|
</h1>
|
||||||
|
<p class="text-xl text-gray-600 max-w-3xl mx-auto mb-8">
|
||||||
|
Where storytellers gather. Every senior is a storyteller with experiences worth sharing -
|
||||||
|
the whispered tales and the celebrated legacies.
|
||||||
|
</p>
|
||||||
|
<p class="text-lg text-gray-500 italic">
|
||||||
|
"Empowering the generation that lived history to share their stories with the world."
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid md:grid-cols-2 gap-12 max-w-6xl mx-auto">
|
||||||
|
<!-- Anonymous Storytellers -->
|
||||||
|
<div class="bg-white rounded-2xl shadow-xl p-8 border-2 border-indigo-100 hover:shadow-2xl transition-shadow">
|
||||||
|
<div class="text-center mb-6">
|
||||||
|
<div class="text-6xl mb-4">🎭</div>
|
||||||
|
<h2 class="text-3xl font-bold text-nevertell-primary mb-4">
|
||||||
|
Anonymous Storytellers
|
||||||
|
</h2>
|
||||||
|
<p class="text-lg text-gray-600 italic mb-4">
|
||||||
|
"Between you and me..."
|
||||||
|
</p>
|
||||||
|
<p class="text-gray-700">
|
||||||
|
For the storytellers who want to share their most adventurous tales
|
||||||
|
without revealing their identity. Wild adventures, secret rebellions,
|
||||||
|
and the stories that make you say "I can't believe I did that!"
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-3 mb-6">
|
||||||
|
<div class="flex items-center text-gray-600">
|
||||||
|
<span class="text-nevertell-primary mr-2">🎭</span>
|
||||||
|
Anonymous storytelling
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center text-gray-600">
|
||||||
|
<span class="text-nevertell-primary mr-2">✨</span>
|
||||||
|
Mischievous wink, playful tone
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center text-gray-600">
|
||||||
|
<span class="text-nevertell-primary mr-2">💬</span>
|
||||||
|
Community encouragement
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a
|
||||||
|
href="/nevertell"
|
||||||
|
class="block w-full px-6 py-3 bg-nevertell-primary text-white rounded-lg hover:bg-indigo-700 transition-colors text-center font-medium focus:outline-none focus:ring-2 focus:ring-nevertell-primary focus:ring-offset-2"
|
||||||
|
>
|
||||||
|
Share Anonymously
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Named Storytellers -->
|
||||||
|
<div class="bg-white rounded-2xl shadow-xl p-8 border-2 border-emerald-100 hover:shadow-2xl transition-shadow">
|
||||||
|
<div class="text-center mb-6">
|
||||||
|
<div class="text-6xl mb-4">🏆</div>
|
||||||
|
<h2 class="text-3xl font-bold text-dignity-primary mb-4">
|
||||||
|
Named Storytellers
|
||||||
|
</h2>
|
||||||
|
<p class="text-lg text-gray-600 italic mb-4">
|
||||||
|
"Pull up a chair..."
|
||||||
|
</p>
|
||||||
|
<p class="text-gray-700">
|
||||||
|
For the storytellers who want recognition for their experiences.
|
||||||
|
Stories of heroism, service, love, and the moments that shaped
|
||||||
|
a generation - with proper credit to the storyteller.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-3 mb-6">
|
||||||
|
<div class="flex items-center text-gray-600">
|
||||||
|
<span class="text-dignity-primary mr-2">🏆</span>
|
||||||
|
Named storytelling
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center text-gray-600">
|
||||||
|
<span class="text-dignity-primary mr-2">🎖️</span>
|
||||||
|
Warm reverence, respectful tone
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center text-gray-600">
|
||||||
|
<span class="text-dignity-primary mr-2">📚</span>
|
||||||
|
Legacy preservation
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a
|
||||||
|
href="/dignity"
|
||||||
|
class="block w-full px-6 py-3 bg-dignity-primary text-white rounded-lg hover:bg-emerald-700 transition-colors text-center font-medium focus:outline-none focus:ring-2 focus:ring-dignity-primary focus:ring-offset-2"
|
||||||
|
>
|
||||||
|
Share With Recognition
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-16 text-center">
|
||||||
|
<h3 class="text-2xl font-semibold text-gray-800 mb-4">
|
||||||
|
Empowering Storytellers
|
||||||
|
</h3>
|
||||||
|
<div class="grid md:grid-cols-3 gap-8 max-w-4xl mx-auto">
|
||||||
|
<div class="text-center">
|
||||||
|
<div class="text-4xl mb-3">🎙️</div>
|
||||||
|
<h4 class="font-semibold text-gray-800 mb-2">Every Senior is a Storyteller</h4>
|
||||||
|
<p class="text-gray-600">
|
||||||
|
We visit senior communities to help storytellers share their incredible experiences
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="text-center">
|
||||||
|
<div class="text-4xl mb-3">🌐</div>
|
||||||
|
<h4 class="font-semibold text-gray-800 mb-2">Connect Across Generations</h4>
|
||||||
|
<p class="text-gray-600">
|
||||||
|
Stories bridge the gap between those who lived history and those learning from it
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="text-center">
|
||||||
|
<div class="text-4xl mb-3">💝</div>
|
||||||
|
<h4 class="font-semibold text-gray-800 mb-2">Preserve & Honor</h4>
|
||||||
|
<p class="text-gray-600">
|
||||||
|
Each story becomes part of our permanent archive, honoring the storyteller's legacy
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Stats section with Alpine.js -->
|
||||||
|
<div
|
||||||
|
class="mt-16 bg-white rounded-2xl shadow-lg p-8"
|
||||||
|
x-data="statsCounter()"
|
||||||
|
x-init="animateStats()"
|
||||||
|
>
|
||||||
|
<h3 class="text-2xl font-semibold text-gray-800 mb-6 text-center">
|
||||||
|
Celebrating Our Storytellers
|
||||||
|
</h3>
|
||||||
|
<div class="grid md:grid-cols-4 gap-6 text-center">
|
||||||
|
<div>
|
||||||
|
<div class="text-3xl font-bold text-nevertell-primary" x-text="anonymousStories"></div>
|
||||||
|
<div class="text-gray-600">Anonymous Storytellers</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-3xl font-bold text-dignity-primary" x-text="honorStories"></div>
|
||||||
|
<div class="text-gray-600">Named Storytellers</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-3xl font-bold text-gray-800" x-text="connections"></div>
|
||||||
|
<div class="text-gray-600">Cross-Generational Connections</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-3xl font-bold text-gray-800" x-text="facilities"></div>
|
||||||
|
<div class="text-gray-600">Senior Communities</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.addEventListener('alpine:init', () => {
|
||||||
|
Alpine.data('statsCounter', () => ({
|
||||||
|
anonymousStories: 0,
|
||||||
|
honorStories: 0,
|
||||||
|
connections: 0,
|
||||||
|
facilities: 0,
|
||||||
|
|
||||||
|
animateStats() {
|
||||||
|
// Animate counters on page load
|
||||||
|
this.animateNumber('anonymousStories', 47, 2000);
|
||||||
|
this.animateNumber('honorStories', 23, 2000);
|
||||||
|
this.animateNumber('connections', 156, 2000);
|
||||||
|
this.animateNumber('facilities', 8, 2000);
|
||||||
|
},
|
||||||
|
|
||||||
|
animateNumber(property, target, duration) {
|
||||||
|
const start = this[property];
|
||||||
|
const increment = target / (duration / 16);
|
||||||
|
let current = start;
|
||||||
|
|
||||||
|
const timer = setInterval(() => {
|
||||||
|
current += increment;
|
||||||
|
if (current >= target) {
|
||||||
|
current = target;
|
||||||
|
clearInterval(timer);
|
||||||
|
}
|
||||||
|
this[property] = Math.floor(current);
|
||||||
|
}, 16);
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</BaseLayout>
|
277
src/pages/nevertell/[slug].astro
Normal file
277
src/pages/nevertell/[slug].astro
Normal file
@ -0,0 +1,277 @@
|
|||||||
|
---
|
||||||
|
import { getCollection } from 'astro:content';
|
||||||
|
import NevertellLayout from '../../layouts/NevertellLayout.astro';
|
||||||
|
|
||||||
|
export async function getStaticPaths() {
|
||||||
|
const stories = await getCollection('nevertell');
|
||||||
|
return stories.map((story) => ({
|
||||||
|
params: { slug: story.slug },
|
||||||
|
props: story,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
const story = Astro.props;
|
||||||
|
const { Content } = await story.render();
|
||||||
|
---
|
||||||
|
|
||||||
|
<NevertellLayout title={story.data.title}>
|
||||||
|
<div class="container mx-auto px-4 py-8">
|
||||||
|
<div class="max-w-4xl mx-auto">
|
||||||
|
{/* Story Header */}
|
||||||
|
<div class="mb-8">
|
||||||
|
<a
|
||||||
|
href="/nevertell"
|
||||||
|
class="text-nevertell-primary hover:underline mb-4 inline-block transition-colors"
|
||||||
|
>
|
||||||
|
← Back to Stories
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<h1 class="text-3xl font-bold text-nevertell-text mb-4">
|
||||||
|
{story.data.title}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap items-center text-gray-600 mb-4 gap-4">
|
||||||
|
<span class="italic">Anonymous storyteller</span>
|
||||||
|
{story.data.location && (
|
||||||
|
<span>📍 {story.data.location}</span>
|
||||||
|
)}
|
||||||
|
{story.data.dateOfEvent && (
|
||||||
|
<span>📅 {story.data.dateOfEvent}</span>
|
||||||
|
)}
|
||||||
|
<span>📝 {story.data.publishedAt.toLocaleDateString()}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{story.data.tags.length > 0 && (
|
||||||
|
<div class="flex flex-wrap gap-2 mb-6">
|
||||||
|
{story.data.tags.map(tag => (
|
||||||
|
<a
|
||||||
|
href={`/nevertell?tag=${tag}`}
|
||||||
|
class="px-3 py-1 bg-nevertell-primary/10 text-nevertell-primary rounded-full text-sm hover:bg-nevertell-primary hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
#{tag}
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Story Content */}
|
||||||
|
<div class="bg-white rounded-lg shadow-lg p-8 mb-8">
|
||||||
|
<div class="max-w-none story-content text-gray-800">
|
||||||
|
<Content />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Interactive Voting Section with Alpine.js */}
|
||||||
|
<div
|
||||||
|
class="bg-white rounded-lg shadow-lg p-6 mb-8"
|
||||||
|
x-data="storyInteraction({
|
||||||
|
storyId: '{story.slug}',
|
||||||
|
initialUpvotes: {story.data.upvotes},
|
||||||
|
initialPromotions: {story.data.promotionVotes}
|
||||||
|
})"
|
||||||
|
>
|
||||||
|
<div class="flex flex-col lg:flex-row items-start lg:items-center justify-between gap-6">
|
||||||
|
<div class="flex items-center space-x-6">
|
||||||
|
<button
|
||||||
|
@click="upvote()"
|
||||||
|
:disabled="voting"
|
||||||
|
class="flex items-center space-x-2 text-gray-600 hover:text-nevertell-primary transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<span class="text-xl">👍</span>
|
||||||
|
<span x-text="upvotes + ' upvotes'"></span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="flex items-center space-x-2 text-gray-600">
|
||||||
|
<span class="text-xl">💬</span>
|
||||||
|
<span>{story.data.commentCount} comments</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div x-show="promotionVotes < 10" class="flex items-center space-x-4">
|
||||||
|
<button
|
||||||
|
@click="promote()"
|
||||||
|
:disabled="voting"
|
||||||
|
class="flex items-center space-x-2 px-4 py-2 bg-dignity-primary text-white rounded-lg hover:bg-emerald-700 transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<span>🌟</span>
|
||||||
|
<span>This deserves recognition! (<span x-text="promotionVotes"></span>)</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div x-show="promotionVotes >= 10" class="text-center lg:text-right">
|
||||||
|
<p class="text-dignity-primary font-medium mb-2">
|
||||||
|
<span x-text="promotionVotes"></span> people think this deserves recognition!
|
||||||
|
</p>
|
||||||
|
<a
|
||||||
|
href={`/nevertell/${story.slug}/promote`}
|
||||||
|
class="inline-block px-4 py-2 bg-dignity-primary text-white rounded-lg hover:bg-emerald-700 transition-colors"
|
||||||
|
>
|
||||||
|
Encourage author to go public →
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div x-show="message" x-transition class="mt-4 p-3 bg-green-100 text-green-700 rounded-lg">
|
||||||
|
<p x-text="message"></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Comments Section with Alpine.js */}
|
||||||
|
<div
|
||||||
|
class="bg-white rounded-lg shadow-lg p-8"
|
||||||
|
x-data="commentSystem('{story.slug}')"
|
||||||
|
x-init="loadComments()"
|
||||||
|
>
|
||||||
|
<h3 class="text-xl font-semibold text-nevertell-text mb-6">
|
||||||
|
Comments (<span x-text="comments.length"></span>)
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{/* Comment Form */}
|
||||||
|
<form @submit.prevent="submitComment()" class="mb-8">
|
||||||
|
<div class="mb-4">
|
||||||
|
<textarea
|
||||||
|
x-model="newComment"
|
||||||
|
placeholder="Share your thoughts on this story... (you can comment anonymously)"
|
||||||
|
rows="4"
|
||||||
|
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-nevertell-primary focus:border-transparent resize-vertical"
|
||||||
|
required
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<p class="text-sm text-gray-500">
|
||||||
|
Comments are anonymous by default. Feel free to share your own similar experiences!
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
:disabled="submitting || !newComment.trim()"
|
||||||
|
class="px-6 py-2 bg-nevertell-primary text-white rounded-lg hover:bg-indigo-700 transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<span x-show="!submitting">Post Comment</span>
|
||||||
|
<span x-show="submitting">Posting...</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{/* Comments List */}
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div x-show="comments.length === 0 && !loadingComments" class="text-center py-8">
|
||||||
|
<div class="text-gray-400 text-4xl mb-4">💭</div>
|
||||||
|
<p class="text-gray-500">Be the first to comment on this story!</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div x-show="loadingComments" class="text-center py-8">
|
||||||
|
<div class="text-gray-400 text-2xl mb-4">⏳</div>
|
||||||
|
<p class="text-gray-500">Loading comments...</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template x-for="comment in comments" :key="comment.id">
|
||||||
|
<div class="border-b border-gray-200 pb-4 last:border-b-0">
|
||||||
|
<div class="flex items-center justify-between mb-2">
|
||||||
|
<span class="text-sm text-gray-600 font-medium" x-text="comment.authorName || 'Anonymous'"></span>
|
||||||
|
<span class="text-sm text-gray-500" x-text="$store.utils.formatRelativeTime(comment.createdAt)"></span>
|
||||||
|
</div>
|
||||||
|
<p class="text-gray-700 leading-relaxed" x-text="comment.content"></p>
|
||||||
|
|
||||||
|
{/* Future: Reply functionality */}
|
||||||
|
<div class="mt-2">
|
||||||
|
<button class="text-sm text-nevertell-primary hover:underline">
|
||||||
|
Reply
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Related Stories */}
|
||||||
|
<div class="mt-12">
|
||||||
|
<h3 class="text-xl font-semibold text-nevertell-text mb-6">
|
||||||
|
More Stories Like This
|
||||||
|
</h3>
|
||||||
|
<div class="grid md:grid-cols-2 gap-6">
|
||||||
|
{/* Future: Related stories based on tags */}
|
||||||
|
<div class="p-4 bg-white rounded-lg border border-gray-200">
|
||||||
|
<p class="text-gray-500 text-center">
|
||||||
|
Coming soon: Related stories based on tags and themes
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.addEventListener('alpine:init', () => {
|
||||||
|
Alpine.data('storyInteraction', (config) => ({
|
||||||
|
storyId: config.storyId,
|
||||||
|
upvotes: config.initialUpvotes,
|
||||||
|
promotionVotes: config.initialPromotions,
|
||||||
|
voting: false,
|
||||||
|
message: '',
|
||||||
|
|
||||||
|
async upvote() {
|
||||||
|
this.voting = true;
|
||||||
|
try {
|
||||||
|
const data = await Alpine.store('api').vote(this.storyId, 'upvote');
|
||||||
|
this.upvotes = data.upvotes;
|
||||||
|
this.message = 'Thanks for your upvote! 👍';
|
||||||
|
setTimeout(() => this.message = '', 3000);
|
||||||
|
} catch (error) {
|
||||||
|
this.message = 'Sorry, voting failed. Please try again.';
|
||||||
|
setTimeout(() => this.message = '', 3000);
|
||||||
|
}
|
||||||
|
this.voting = false;
|
||||||
|
},
|
||||||
|
|
||||||
|
async promote() {
|
||||||
|
this.voting = true;
|
||||||
|
try {
|
||||||
|
const data = await Alpine.store('api').vote(this.storyId, 'promote');
|
||||||
|
this.promotionVotes = data.promotionVotes;
|
||||||
|
this.message = 'Thank you for encouraging this author! 🌟';
|
||||||
|
setTimeout(() => this.message = '', 3000);
|
||||||
|
} catch (error) {
|
||||||
|
this.message = 'Sorry, promotion vote failed. Please try again.';
|
||||||
|
setTimeout(() => this.message = '', 3000);
|
||||||
|
}
|
||||||
|
this.voting = false;
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
Alpine.data('commentSystem', (storyId) => ({
|
||||||
|
storyId,
|
||||||
|
comments: [],
|
||||||
|
newComment: '',
|
||||||
|
submitting: false,
|
||||||
|
loadingComments: false,
|
||||||
|
|
||||||
|
async loadComments() {
|
||||||
|
this.loadingComments = true;
|
||||||
|
try {
|
||||||
|
this.comments = await Alpine.store('api').loadComments(this.storyId);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load comments:', error);
|
||||||
|
}
|
||||||
|
this.loadingComments = false;
|
||||||
|
},
|
||||||
|
|
||||||
|
async submitComment() {
|
||||||
|
if (!this.newComment.trim()) return;
|
||||||
|
|
||||||
|
this.submitting = true;
|
||||||
|
try {
|
||||||
|
const result = await Alpine.store('api').submitComment(this.storyId, this.newComment);
|
||||||
|
if (result.success) {
|
||||||
|
this.newComment = '';
|
||||||
|
this.loadComments(); // Refresh comments
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Comment submission failed:', error);
|
||||||
|
}
|
||||||
|
this.submitting = false;
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</NevertellLayout>
|
249
src/pages/nevertell/categories.astro
Normal file
249
src/pages/nevertell/categories.astro
Normal file
@ -0,0 +1,249 @@
|
|||||||
|
---
|
||||||
|
import { getCollection } from 'astro:content';
|
||||||
|
import NevertellLayout from '../../layouts/NevertellLayout.astro';
|
||||||
|
|
||||||
|
// Get all nevertell stories to extract categories
|
||||||
|
const stories = await getCollection('nevertell');
|
||||||
|
|
||||||
|
// Extract all unique tags and count them
|
||||||
|
const tagCounts = {};
|
||||||
|
stories.forEach(story => {
|
||||||
|
story.data.tags.forEach(tag => {
|
||||||
|
tagCounts[tag] = (tagCounts[tag] || 0) + 1;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sort tags by count (descending) then alphabetically
|
||||||
|
const sortedTags = Object.entries(tagCounts)
|
||||||
|
.sort(([a, countA], [b, countB]) => {
|
||||||
|
if (countB !== countA) return countB - countA;
|
||||||
|
return a.localeCompare(b);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Group stories by decade for timeline view
|
||||||
|
const decades = {};
|
||||||
|
stories.forEach(story => {
|
||||||
|
story.data.tags.forEach(tag => {
|
||||||
|
if (tag.match(/^\d{4}s$/)) { // matches "1960s", "1950s", etc.
|
||||||
|
if (!decades[tag]) decades[tag] = [];
|
||||||
|
decades[tag].push(story);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const sortedDecades = Object.entries(decades)
|
||||||
|
.sort(([a], [b]) => a.localeCompare(b));
|
||||||
|
---
|
||||||
|
|
||||||
|
<NevertellLayout title="Story Categories - Anonymous Storytellers">
|
||||||
|
<div class="container mx-auto px-4 py-8">
|
||||||
|
<div class="max-w-6xl mx-auto">
|
||||||
|
{/* Header */}
|
||||||
|
<div class="text-center mb-12">
|
||||||
|
<a
|
||||||
|
href="/nevertell"
|
||||||
|
class="text-nevertell-primary hover:underline mb-4 inline-block transition-colors"
|
||||||
|
>
|
||||||
|
← Back to Anonymous Stories
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<h1 class="text-4xl font-bold text-nevertell-text mb-4">
|
||||||
|
🎭 Story Categories
|
||||||
|
</h1>
|
||||||
|
<p class="text-xl text-gray-600 max-w-3xl mx-auto">
|
||||||
|
Explore anonymous stories by theme, decade, and mischief level.
|
||||||
|
Every category holds its own secrets...
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Popular Categories */}
|
||||||
|
<div class="mb-16">
|
||||||
|
<h2 class="text-2xl font-semibold text-nevertell-text mb-8 text-center">
|
||||||
|
📊 Popular Categories
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div class="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
|
{sortedTags.map(([tag, count]) => (
|
||||||
|
<a
|
||||||
|
href={`/nevertell?tag=${encodeURIComponent(tag)}`}
|
||||||
|
class="group bg-white rounded-lg shadow-md hover:shadow-lg p-6 border-2 border-nevertell-primary/10 hover:border-nevertell-primary/30 transition-all"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between mb-3">
|
||||||
|
<span class="text-lg font-medium text-nevertell-primary group-hover:text-nevertell-primary/80">
|
||||||
|
#{tag}
|
||||||
|
</span>
|
||||||
|
<span class="text-sm text-gray-500 bg-gray-100 px-2 py-1 rounded-full">
|
||||||
|
{count} {count === 1 ? 'story' : 'stories'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-gray-600 text-sm">
|
||||||
|
{tag === 'college' && 'Wild campus adventures and dormitory shenanigans'}
|
||||||
|
{tag === 'pranks' && 'Mischievous schemes and harmless troublemaking'}
|
||||||
|
{tag === '1960s' && 'Tales from the decade that changed everything'}
|
||||||
|
{tag === '1950s' && 'Stories from the era of rock \'n\' roll rebellion'}
|
||||||
|
{tag === 'music' && 'Concert memories and musical adventures'}
|
||||||
|
{tag === 'rebellion' && 'Breaking rules and challenging conventions'}
|
||||||
|
{!['college', 'pranks', '1960s', '1950s', 'music', 'rebellion'].includes(tag) &&
|
||||||
|
'Anonymous stories that will make you say "I can\'t believe they did that!"'
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Timeline by Decades */}
|
||||||
|
{sortedDecades.length > 0 && (
|
||||||
|
<div class="mb-16">
|
||||||
|
<h2 class="text-2xl font-semibold text-nevertell-text mb-8 text-center">
|
||||||
|
📅 Stories Through the Decades
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div class="space-y-8">
|
||||||
|
{sortedDecades.map(([decade, decadeStories]) => (
|
||||||
|
<div class="bg-white rounded-lg shadow-lg p-8 border-l-4 border-nevertell-primary">
|
||||||
|
<div class="flex items-center justify-between mb-6">
|
||||||
|
<h3 class="text-xl font-semibold text-nevertell-primary">
|
||||||
|
The {decade}
|
||||||
|
</h3>
|
||||||
|
<a
|
||||||
|
href={`/nevertell?tag=${encodeURIComponent(decade)}`}
|
||||||
|
class="text-nevertell-primary hover:underline text-sm"
|
||||||
|
>
|
||||||
|
View all {decadeStories.length} stories →
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{decadeStories.slice(0, 6).map(story => (
|
||||||
|
<a
|
||||||
|
href={`/nevertell/${story.slug}`}
|
||||||
|
class="group p-4 bg-gray-50 rounded-lg hover:bg-nevertell-primary/5 transition-colors"
|
||||||
|
>
|
||||||
|
<h4 class="font-medium text-gray-800 group-hover:text-nevertell-primary mb-2 line-clamp-2">
|
||||||
|
{story.data.title}
|
||||||
|
</h4>
|
||||||
|
<p class="text-sm text-gray-600 line-clamp-2">
|
||||||
|
{story.data.excerpt}
|
||||||
|
</p>
|
||||||
|
<div class="flex items-center mt-2 text-xs text-gray-500">
|
||||||
|
<span>🎭</span>
|
||||||
|
<span class="ml-1">{story.data.upvotes} mischief points</span>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{decadeStories.length > 6 && (
|
||||||
|
<div class="mt-4 text-center">
|
||||||
|
<a
|
||||||
|
href={`/nevertell?tag=${encodeURIComponent(decade)}`}
|
||||||
|
class="text-nevertell-primary hover:underline text-sm"
|
||||||
|
>
|
||||||
|
+ {decadeStories.length - 6} more stories from the {decade}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Browse All Stories */}
|
||||||
|
<div class="text-center bg-nevertell-primary/5 rounded-lg p-8">
|
||||||
|
<h2 class="text-xl font-semibold text-nevertell-text mb-4">
|
||||||
|
🎪 Ready for More Mischief?
|
||||||
|
</h2>
|
||||||
|
<p class="text-gray-600 mb-6">
|
||||||
|
Can't find what you're looking for? Browse all anonymous stories or share your own secret tale.
|
||||||
|
</p>
|
||||||
|
<div class="flex flex-col sm:flex-row gap-4 justify-center">
|
||||||
|
<a
|
||||||
|
href="/nevertell"
|
||||||
|
class="px-6 py-3 bg-nevertell-primary text-white rounded-lg hover:bg-indigo-700 transition-colors"
|
||||||
|
>
|
||||||
|
Browse All Stories
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="/nevertell/submit"
|
||||||
|
class="px-6 py-3 border border-nevertell-primary text-nevertell-primary hover:bg-nevertell-primary hover:text-white rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
Share Your Secret
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Interactive Category Search with Alpine.js */}
|
||||||
|
<div
|
||||||
|
class="mt-16 bg-white rounded-lg shadow-lg p-8"
|
||||||
|
x-data="categorySearch()"
|
||||||
|
x-init="init()"
|
||||||
|
data-categories={JSON.stringify(sortedTags.map(([tag, count]) => ({ tag, count })))}
|
||||||
|
>
|
||||||
|
<h2 class="text-xl font-semibold text-nevertell-text mb-6 text-center">
|
||||||
|
🔍 Find Stories by Keyword
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div class="max-w-md mx-auto mb-6">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
x-model="searchTerm"
|
||||||
|
@input="filterCategories()"
|
||||||
|
placeholder="Search categories... (college, pranks, 1960s)"
|
||||||
|
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-nevertell-primary focus:border-transparent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
<template x-for="category in filteredCategories" :key="category.tag">
|
||||||
|
<a
|
||||||
|
:href="`/nevertell?tag=${encodeURIComponent(category.tag)}`"
|
||||||
|
class="block p-4 bg-gray-50 rounded-lg hover:bg-nevertell-primary/5 transition-colors"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="font-medium text-nevertell-primary" x-text="`#${category.tag}`"></span>
|
||||||
|
<span class="text-sm text-gray-500" x-text="`${category.count} stories`"></span>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div x-show="filteredCategories.length === 0 && searchTerm" class="text-center py-8">
|
||||||
|
<div class="text-gray-400 text-4xl mb-4">🤷♀️</div>
|
||||||
|
<p class="text-gray-500">No categories found matching "<span x-text="searchTerm"></span>"</p>
|
||||||
|
<p class="text-sm text-gray-400 mt-2">Try searching for: college, pranks, music, rebellion</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.addEventListener('alpine:init', () => {
|
||||||
|
Alpine.data('categorySearch', () => ({
|
||||||
|
searchTerm: '',
|
||||||
|
allCategories: [],
|
||||||
|
filteredCategories: [],
|
||||||
|
|
||||||
|
init() {
|
||||||
|
// Initialize with all categories from the data attribute
|
||||||
|
const categoriesData = this.$el.dataset.categories;
|
||||||
|
this.allCategories = JSON.parse(categoriesData);
|
||||||
|
this.filteredCategories = [...this.allCategories];
|
||||||
|
},
|
||||||
|
|
||||||
|
filterCategories() {
|
||||||
|
if (!this.searchTerm.trim()) {
|
||||||
|
this.filteredCategories = [...this.allCategories];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const term = this.searchTerm.toLowerCase();
|
||||||
|
this.filteredCategories = this.allCategories.filter(category =>
|
||||||
|
category.tag.toLowerCase().includes(term)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</NevertellLayout>
|
255
src/pages/nevertell/index.astro
Normal file
255
src/pages/nevertell/index.astro
Normal file
@ -0,0 +1,255 @@
|
|||||||
|
---
|
||||||
|
import { getCollection } from 'astro:content';
|
||||||
|
import NevertellLayout from '../../layouts/NevertellLayout.astro';
|
||||||
|
|
||||||
|
const stories = await getCollection('nevertell');
|
||||||
|
const sortedStories = stories.sort((a, b) => b.data.publishedAt.getTime() - a.data.publishedAt.getTime());
|
||||||
|
const allTags = [...new Set(stories.flatMap(story => story.data.tags))];
|
||||||
|
---
|
||||||
|
|
||||||
|
<NevertellLayout title="nevertell.ink - Anonymous Stories That Matter">
|
||||||
|
<div class="container mx-auto px-4 py-8">
|
||||||
|
{/* Hero Section */}
|
||||||
|
<div class="text-center mb-12">
|
||||||
|
<h1 class="text-4xl font-bold text-nevertell-text mb-4">
|
||||||
|
The Stories They'll Only Whisper
|
||||||
|
</h1>
|
||||||
|
<p class="text-lg text-gray-600 max-w-2xl mx-auto mb-6">
|
||||||
|
Anonymous tales of wild adventures, secret rebellions, and the kind of memories
|
||||||
|
that make you say "I can't believe I did that!"
|
||||||
|
</p>
|
||||||
|
<p class="text-nevertell-primary font-medium italic">
|
||||||
|
Mischievous wink, between you and me...
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Story Feed with Alpine.js filtering */}
|
||||||
|
<div
|
||||||
|
class="max-w-4xl mx-auto"
|
||||||
|
x-data="storyFeed({
|
||||||
|
stories: JSON.parse(JSON.stringify({stories.map(s => ({
|
||||||
|
slug: s.slug,
|
||||||
|
...s.data,
|
||||||
|
publishedAt: s.data.publishedAt.toISOString()
|
||||||
|
}))})),
|
||||||
|
allTags: {JSON.stringify(allTags)}
|
||||||
|
})"
|
||||||
|
>
|
||||||
|
<div class="flex flex-col lg:flex-row justify-between items-start lg:items-center mb-8 gap-4">
|
||||||
|
<h2 class="text-2xl font-semibold text-nevertell-text">Recent Stories</h2>
|
||||||
|
|
||||||
|
<div class="flex flex-col sm:flex-row gap-4 w-full lg:w-auto">
|
||||||
|
{/* Search */}
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
x-model="searchTerm"
|
||||||
|
placeholder="Search stories..."
|
||||||
|
class="px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-nevertell-primary focus:border-transparent"
|
||||||
|
>
|
||||||
|
|
||||||
|
{/* Filter button */}
|
||||||
|
<button
|
||||||
|
@click="showFilters = !showFilters"
|
||||||
|
class="px-4 py-2 bg-nevertell-primary/10 text-nevertell-primary rounded-lg hover:bg-nevertell-primary/20 transition-colors"
|
||||||
|
>
|
||||||
|
<span x-show="!showFilters">Show Filters</span>
|
||||||
|
<span x-show="showFilters">Hide Filters</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<a
|
||||||
|
href="/nevertell/submit"
|
||||||
|
class="px-6 py-2 bg-nevertell-primary text-white rounded-lg hover:bg-indigo-700 transition-colors text-center"
|
||||||
|
>
|
||||||
|
Share Your Story
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tag Filters */}
|
||||||
|
<div
|
||||||
|
x-show="showFilters"
|
||||||
|
x-transition
|
||||||
|
class="mb-8 p-4 bg-white rounded-lg shadow-sm border"
|
||||||
|
>
|
||||||
|
<h3 class="text-sm font-medium text-gray-700 mb-3">Filter by tags:</h3>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<template x-for="tag in allTags" :key="tag">
|
||||||
|
<button
|
||||||
|
@click="toggleTag(tag)"
|
||||||
|
:class="selectedTags.includes(tag) ?
|
||||||
|
'bg-nevertell-primary text-white' :
|
||||||
|
'bg-gray-100 text-gray-700 hover:bg-nevertell-primary/10'"
|
||||||
|
class="px-3 py-1 rounded-full text-sm transition-colors"
|
||||||
|
x-text="`#${tag}`"
|
||||||
|
></button>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
<div x-show="selectedTags.length > 0" class="mt-3">
|
||||||
|
<button
|
||||||
|
@click="selectedTags = []"
|
||||||
|
class="text-sm text-nevertell-primary hover:underline"
|
||||||
|
>
|
||||||
|
Clear all filters
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stories Grid */}
|
||||||
|
<div class="space-y-6">
|
||||||
|
<template x-for="story in filteredStories" :key="story.slug">
|
||||||
|
<article class="bg-white rounded-lg shadow-md p-6 border border-gray-200 hover:shadow-lg transition-shadow">
|
||||||
|
<div class="flex justify-between items-start mb-4">
|
||||||
|
<h3 class="text-xl font-semibold text-nevertell-text hover:text-nevertell-primary">
|
||||||
|
<a :href="`/nevertell/${story.slug}`" x-text="story.title"></a>
|
||||||
|
</h3>
|
||||||
|
<span class="text-sm text-gray-500">Anonymous</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p
|
||||||
|
class="text-gray-700 mb-4 text-story-body"
|
||||||
|
x-text="story.excerpt || story.content?.substring(0, 200) + '...'"
|
||||||
|
></p>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap gap-2 mb-4">
|
||||||
|
<template x-for="tag in story.tags" :key="tag">
|
||||||
|
<span
|
||||||
|
class="px-3 py-1 bg-nevertell-primary/10 text-nevertell-primary rounded-full text-sm cursor-pointer hover:bg-nevertell-primary/20 transition-colors"
|
||||||
|
@click="toggleTag(tag)"
|
||||||
|
x-text="`#${tag}`"
|
||||||
|
></span>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center space-x-4">
|
||||||
|
<span class="flex items-center space-x-1 text-gray-600">
|
||||||
|
<span>👍</span>
|
||||||
|
<span x-text="story.upvotes"></span>
|
||||||
|
</span>
|
||||||
|
<a
|
||||||
|
:href="`/nevertell/${story.slug}#comments`"
|
||||||
|
class="flex items-center space-x-1 text-gray-600 hover:text-nevertell-primary transition-colors"
|
||||||
|
>
|
||||||
|
<span>💬</span>
|
||||||
|
<span x-text="story.commentCount"></span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div x-show="story.promotionVotes >= 10" class="text-right">
|
||||||
|
<p class="text-sm text-dignity-primary font-medium">
|
||||||
|
<span x-text="story.promotionVotes"></span> people think this deserves recognition!
|
||||||
|
</p>
|
||||||
|
<a
|
||||||
|
:href="`/nevertell/${story.slug}/promote`"
|
||||||
|
class="text-sm px-3 py-1 bg-dignity-primary text-white rounded hover:bg-emerald-700 transition-colors"
|
||||||
|
>
|
||||||
|
Encourage Author
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div x-show="filteredStories.length === 0" class="text-center py-12">
|
||||||
|
<div class="text-gray-400 text-4xl mb-4">🤷♀️</div>
|
||||||
|
<h3 class="text-lg font-semibold text-gray-600 mb-2">No stories match your search</h3>
|
||||||
|
<p class="text-gray-500 mb-4">Try different keywords or clear your filters</p>
|
||||||
|
<button
|
||||||
|
@click="searchTerm = ''; selectedTags = []"
|
||||||
|
class="px-4 py-2 bg-nevertell-primary text-white rounded-lg hover:bg-indigo-700 transition-colors"
|
||||||
|
>
|
||||||
|
Show All Stories
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Load More */}
|
||||||
|
<div x-show="hasMore" class="text-center mt-12">
|
||||||
|
<button
|
||||||
|
@click="loadMore()"
|
||||||
|
:disabled="loading"
|
||||||
|
class="px-6 py-3 bg-nevertell-primary text-white rounded-lg hover:bg-indigo-700 transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<span x-show="!loading">Load More Stories</span>
|
||||||
|
<span x-show="loading">Loading...</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Categories Preview */}
|
||||||
|
<aside class="mt-16 max-w-4xl mx-auto">
|
||||||
|
<h3 class="text-xl font-semibold text-nevertell-text mb-6 text-center">
|
||||||
|
Browse by Era & Theme
|
||||||
|
</h3>
|
||||||
|
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
|
{[
|
||||||
|
{ name: '1940s Adventures', icon: '🎺' },
|
||||||
|
{ name: '1950s Rebellion', icon: '🎸' },
|
||||||
|
{ name: '1960s College', icon: '✌️' },
|
||||||
|
{ name: 'Secret Romances', icon: '💕' },
|
||||||
|
{ name: 'Career Shenanigans', icon: '🏢' },
|
||||||
|
{ name: 'Travel Tales', icon: '✈️' },
|
||||||
|
{ name: 'Family Secrets', icon: '🤐' },
|
||||||
|
{ name: 'War Stories', icon: '🎖️' }
|
||||||
|
].map(category => (
|
||||||
|
<a
|
||||||
|
href={`/nevertell/categories`}
|
||||||
|
class="p-4 bg-white rounded-lg border border-nevertell-primary/20 hover:bg-nevertell-primary/5 text-center group transition-colors"
|
||||||
|
>
|
||||||
|
<div class="text-2xl mb-2 group-hover:scale-110 transition-transform">
|
||||||
|
{category.icon}
|
||||||
|
</div>
|
||||||
|
<div class="text-sm font-medium text-nevertell-primary">
|
||||||
|
{category.name}
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.addEventListener('alpine:init', () => {
|
||||||
|
Alpine.data('storyFeed', (config) => ({
|
||||||
|
stories: config.stories,
|
||||||
|
allTags: config.allTags,
|
||||||
|
searchTerm: '',
|
||||||
|
selectedTags: [],
|
||||||
|
showFilters: false,
|
||||||
|
loading: false,
|
||||||
|
hasMore: false, // For future pagination
|
||||||
|
|
||||||
|
get filteredStories() {
|
||||||
|
return this.stories.filter(story => {
|
||||||
|
// Text search
|
||||||
|
const searchMatch = !this.searchTerm ||
|
||||||
|
story.title.toLowerCase().includes(this.searchTerm.toLowerCase()) ||
|
||||||
|
(story.excerpt && story.excerpt.toLowerCase().includes(this.searchTerm.toLowerCase()));
|
||||||
|
|
||||||
|
// Tag filter
|
||||||
|
const tagMatch = this.selectedTags.length === 0 ||
|
||||||
|
this.selectedTags.some(tag => story.tags.includes(tag));
|
||||||
|
|
||||||
|
return searchMatch && tagMatch;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
toggleTag(tag) {
|
||||||
|
if (this.selectedTags.includes(tag)) {
|
||||||
|
this.selectedTags = this.selectedTags.filter(t => t !== tag);
|
||||||
|
} else {
|
||||||
|
this.selectedTags.push(tag);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
loadMore() {
|
||||||
|
this.loading = true;
|
||||||
|
// Future implementation for pagination
|
||||||
|
setTimeout(() => {
|
||||||
|
this.loading = false;
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</NevertellLayout>
|
120
src/styles/global.css
Normal file
120
src/styles/global.css
Normal file
@ -0,0 +1,120 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
html {
|
||||||
|
font-family: Inter, system-ui, sans-serif;
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Accessibility improvements for seniors */
|
||||||
|
body {
|
||||||
|
font-size: 18px;
|
||||||
|
line-height: 1.6;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Larger tap targets for mobile/touch */
|
||||||
|
button, a, input, textarea, select {
|
||||||
|
min-height: 44px;
|
||||||
|
min-width: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* High contrast mode support */
|
||||||
|
@media (prefers-contrast: high) {
|
||||||
|
body {
|
||||||
|
background: white !important;
|
||||||
|
color: black !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary, .btn-nevertell, .btn-dignity {
|
||||||
|
background: black !important;
|
||||||
|
color: white !important;
|
||||||
|
border: 2px solid black !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.story-card {
|
||||||
|
border: 2px solid black !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Reduced motion support */
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
* {
|
||||||
|
animation-duration: 0.01ms !important;
|
||||||
|
animation-iteration-count: 1 !important;
|
||||||
|
transition-duration: 0.01ms !important;
|
||||||
|
scroll-behavior: auto !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Large text mode support */
|
||||||
|
@media (min-resolution: 192dpi) {
|
||||||
|
body {
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.story-content {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
line-height: 1.8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Focus improvements for keyboard navigation */
|
||||||
|
*:focus {
|
||||||
|
outline: 3px solid #3b82f6;
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer components {
|
||||||
|
.story-card {
|
||||||
|
@apply bg-white rounded-lg shadow-md p-6 mb-6 border border-gray-200 hover:shadow-lg transition-shadow;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-thread {
|
||||||
|
@apply border-l-2 border-gray-200 pl-4 ml-4 mt-4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
@apply px-6 py-3 rounded-lg font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-nevertell {
|
||||||
|
@apply btn-primary bg-nevertell-primary text-white hover:bg-indigo-700 focus:ring-nevertell-primary;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-dignity {
|
||||||
|
@apply btn-primary bg-dignity-primary text-white hover:bg-emerald-700 focus:ring-dignity-primary;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Story content styling */
|
||||||
|
.story-content {
|
||||||
|
@apply text-lg leading-relaxed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.story-content p {
|
||||||
|
@apply mb-4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.story-content h2 {
|
||||||
|
@apply text-xl font-semibold mt-8 mb-4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.story-content h3 {
|
||||||
|
@apply text-lg font-semibold mt-6 mb-3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.story-content blockquote {
|
||||||
|
@apply border-l-4 border-gray-300 pl-4 italic my-6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.story-content em {
|
||||||
|
@apply italic text-gray-600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.story-content strong {
|
||||||
|
@apply font-semibold;
|
||||||
|
}
|
||||||
|
}
|
36
tailwind.config.mjs
Normal file
36
tailwind.config.mjs
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
export default {
|
||||||
|
content: ['./src/**/*.{astro,html,js,jsx,md,mdx,svelte,ts,tsx,vue}'],
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
// nevertell.ink theme - playful, mischievous
|
||||||
|
nevertell: {
|
||||||
|
primary: '#6366f1', // indigo
|
||||||
|
secondary: '#ec4899', // pink
|
||||||
|
accent: '#f59e0b', // amber
|
||||||
|
background: '#fafafa',
|
||||||
|
text: '#1f2937',
|
||||||
|
},
|
||||||
|
// dignity.ink theme - warm, respectful
|
||||||
|
dignity: {
|
||||||
|
primary: '#059669', // emerald
|
||||||
|
secondary: '#dc2626', // red
|
||||||
|
accent: '#d97706', // amber
|
||||||
|
background: '#f9fafb',
|
||||||
|
text: '#374151',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
fontFamily: {
|
||||||
|
serif: ['Georgia', 'Times New Roman', 'serif'],
|
||||||
|
sans: ['Inter', 'system-ui', 'sans-serif'],
|
||||||
|
},
|
||||||
|
fontSize: {
|
||||||
|
// Larger fonts for accessibility
|
||||||
|
'story-title': ['2.5rem', { lineHeight: '1.2' }],
|
||||||
|
'story-body': ['1.125rem', { lineHeight: '1.7' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
}
|
6
tsconfig.json
Normal file
6
tsconfig.json
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"extends": "astro/tsconfigs/strict",
|
||||||
|
"compilerOptions": {
|
||||||
|
"types": ["alpinejs"]
|
||||||
|
}
|
||||||
|
}
|
13
vite.config.js
Normal file
13
vite.config.js
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import { defineConfig } from 'vite';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
server: {
|
||||||
|
host: '0.0.0.0',
|
||||||
|
port: 4321,
|
||||||
|
allowedHosts: [
|
||||||
|
process.env.DOMAIN || 'st.l.supported.systems',
|
||||||
|
'localhost',
|
||||||
|
'127.0.0.1'
|
||||||
|
]
|
||||||
|
}
|
||||||
|
});
|
Loading…
x
Reference in New Issue
Block a user