initial commit
This commit is contained in:
@@ -0,0 +1,16 @@
|
||||
# Payload
|
||||
PAYLOAD_SECRET=change-me-to-a-long-random-string
|
||||
DATABASE_URI=postgres://payload:payload@localhost:5432/softvowels
|
||||
|
||||
# Public origin of the admin UI
|
||||
PAYLOAD_PUBLIC_SERVER_URL=https://cms.softvowels.example
|
||||
SERVER_URL=https://cms.softvowels.example
|
||||
|
||||
# Origins allowed to call the API (Astro public site)
|
||||
CORS_ORIGINS=https://softvowels.example,http://localhost:4321
|
||||
|
||||
# Internal rebuild webhook
|
||||
ASTRO_REVALIDATE_URL=http://host.docker.internal:4321/__revalidate
|
||||
ASTRO_REVALIDATE_TOKEN=change-me
|
||||
|
||||
PORT=3000
|
||||
@@ -0,0 +1,22 @@
|
||||
# syntax=docker/dockerfile:1.7
|
||||
|
||||
FROM node:20-alpine AS base
|
||||
RUN corepack enable && corepack prepare [email protected] --activate
|
||||
WORKDIR /app
|
||||
|
||||
FROM base AS deps
|
||||
COPY package.json pnpm-lock.yaml* ./
|
||||
COPY ../../package.json ../../pnpm-workspace.yaml* ../../ ./
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
FROM base AS build
|
||||
COPY --from=deps /app /app
|
||||
COPY . .
|
||||
RUN pnpm run build
|
||||
|
||||
FROM base AS runtime
|
||||
ENV NODE_ENV=production
|
||||
WORKDIR /app
|
||||
COPY --from=build /app ./
|
||||
EXPOSE 3000
|
||||
CMD ["pnpm", "start"]
|
||||
Vendored
+5
@@ -0,0 +1,5 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/basic-features/typescript for more information.
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { NextConfig } from 'next';
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
reactStrictMode: true,
|
||||
experimental: {
|
||||
reactCompiler: false,
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"name": "cms",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "cross-env NODE_OPTIONS=--no-deprecation payload dev",
|
||||
"build": "cross-env NODE_OPTIONS=--no-deprecation payload build",
|
||||
"start": "cross-env NODE_OPTIONS=--no-deprecation payload start",
|
||||
"payload": "cross-env NODE_OPTIONS=--no-deprecation payload",
|
||||
"generate:types": "cross-env NODE_OPTIONS=--no-deprecation payload generate:types",
|
||||
"lint": "echo 'no linter wired'",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@payloadcms/db-postgres": "3.36.0",
|
||||
"@payloadcms/next": "3.36.0",
|
||||
"@payloadcms/richtext-lexical": "3.36.0",
|
||||
"cross-env": "7.0.3",
|
||||
"graphql": "16.10.0",
|
||||
"next": "15.1.4",
|
||||
"payload": "3.36.0",
|
||||
"react": "19.0.0",
|
||||
"react-dom": "19.0.0",
|
||||
"sharp": "0.33.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "22.10.5",
|
||||
"@types/react": "19.0.7",
|
||||
"@types/react-dom": "19.0.3",
|
||||
"typescript": "5.7.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.11.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"importMap": {}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
|
||||
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
|
||||
import config from '@payload-config';
|
||||
import '@payloadcms/next/css';
|
||||
import type { ServerFunctionClient } from 'payload';
|
||||
import { handleServerFunctions, RootLayout } from '@payloadcms/next/layouts';
|
||||
import { importMap } from '../../../admin/importMap';
|
||||
|
||||
type Args = { children: React.ReactNode };
|
||||
|
||||
const serverFunction: ServerFunctionClient = async function (args) {
|
||||
'use server';
|
||||
return handleServerFunctions({ ...args, config, importMap });
|
||||
};
|
||||
|
||||
const Layout = ({ children }: Args) => (
|
||||
<RootLayout config={config} importMap={importMap} serverFunction={serverFunction}>
|
||||
{children}
|
||||
</RootLayout>
|
||||
);
|
||||
|
||||
export default Layout;
|
||||
@@ -0,0 +1,19 @@
|
||||
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
|
||||
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
|
||||
import type { Metadata } from 'next';
|
||||
import config from '@payload-config';
|
||||
import { generatePageMetadata, RootPage } from '@payloadcms/next/views';
|
||||
import { importMap } from '../../admin/importMap';
|
||||
|
||||
type Args = {
|
||||
params: Promise<{ segments: string[] }>;
|
||||
searchParams: Promise<{ [key: string]: string | string[] }>;
|
||||
};
|
||||
|
||||
export const generateMetadata = ({ params, searchParams }: Args): Promise<Metadata> =>
|
||||
generatePageMetadata({ config, params, searchParams });
|
||||
|
||||
const Page = ({ params, searchParams }: Args) =>
|
||||
RootPage({ config, params, searchParams, importMap });
|
||||
|
||||
export default Page;
|
||||
@@ -0,0 +1,11 @@
|
||||
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
|
||||
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
|
||||
import config from '@payload-config';
|
||||
import { REST_DELETE, REST_GET, REST_OPTIONS, REST_PATCH, REST_POST, REST_PUT } from '@payloadcms/next/routes';
|
||||
|
||||
export const GET = REST_GET(config);
|
||||
export const POST = REST_POST(config);
|
||||
export const DELETE = REST_DELETE(config);
|
||||
export const PATCH = REST_PATCH(config);
|
||||
export const PUT = REST_PUT(config);
|
||||
export const OPTIONS = REST_OPTIONS(config);
|
||||
@@ -0,0 +1,7 @@
|
||||
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
|
||||
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
|
||||
import config from '@payload-config';
|
||||
import { GRAPHQL_POST, REST_OPTIONS } from '@payloadcms/next/routes';
|
||||
|
||||
export const POST = GRAPHQL_POST(config);
|
||||
export const OPTIONS = REST_OPTIONS(config);
|
||||
@@ -0,0 +1,7 @@
|
||||
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
|
||||
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
|
||||
import config from '@payload-config';
|
||||
import { GRAPHQL_POST, REST_OPTIONS } from '@payloadcms/next/routes';
|
||||
|
||||
export const POST = GRAPHQL_POST(config);
|
||||
export const OPTIONS = REST_OPTIONS(config);
|
||||
@@ -0,0 +1,4 @@
|
||||
/* Custom Payload admin theme overrides — kept minimal so the stock look-and-feel wins. */
|
||||
:root {
|
||||
--theme-base-0: #ffffff;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
|
||||
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
|
||||
import config from '@payload-config';
|
||||
import '@payloadcms/next/css';
|
||||
import type { ServerFunctionClient } from 'payload';
|
||||
import { handleServerFunctions, RootLayout } from '@payloadcms/next/layouts';
|
||||
import { importMap } from './admin/importMap';
|
||||
import './custom.scss';
|
||||
|
||||
type Args = {
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
const serverFunction: ServerFunctionClient = async function (args) {
|
||||
'use server';
|
||||
return handleServerFunctions({
|
||||
...args,
|
||||
config,
|
||||
importMap,
|
||||
});
|
||||
};
|
||||
|
||||
const Layout = ({ children }: Args) => (
|
||||
<RootLayout config={config} importMap={importMap} serverFunction={serverFunction}>
|
||||
{children}
|
||||
</RootLayout>
|
||||
);
|
||||
|
||||
export default Layout;
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { Metadata } from 'next';
|
||||
import config from '@payload-config';
|
||||
import { generatePageMetadata, NotFoundPage } from '@payloadcms/next/views';
|
||||
import { importMap } from '../admin/importMap';
|
||||
|
||||
type Args = {
|
||||
params: Promise<{ segments: string[] }>;
|
||||
searchParams: Promise<{ [key: string]: string | string[] }>;
|
||||
};
|
||||
|
||||
export const generateMetadata = ({ params, searchParams }: Args): Promise<Metadata> =>
|
||||
generatePageMetadata({ config, params, searchParams });
|
||||
|
||||
const NotFound = ({ params, searchParams }: Args) =>
|
||||
NotFoundPage({ config, params, searchParams, importMap });
|
||||
|
||||
export default NotFound;
|
||||
@@ -0,0 +1,23 @@
|
||||
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
|
||||
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
|
||||
import type { Metadata } from 'next';
|
||||
import config from '@payload-config';
|
||||
import { generatePageMetadata, RootPage } from '@payloadcms/next/views';
|
||||
import { importMap } from '../admin/importMap';
|
||||
|
||||
type Args = {
|
||||
params: Promise<{
|
||||
segments: string[];
|
||||
}>;
|
||||
searchParams: Promise<{
|
||||
[key: string]: string | string[];
|
||||
}>;
|
||||
};
|
||||
|
||||
export const generateMetadata = ({ params, searchParams }: Args): Promise<Metadata> =>
|
||||
generatePageMetadata({ config, params, searchParams });
|
||||
|
||||
const Page = ({ params, searchParams }: Args) =>
|
||||
RootPage({ config, params, searchParams, importMap });
|
||||
|
||||
export default Page;
|
||||
@@ -0,0 +1,101 @@
|
||||
import type { CollectionConfig, FieldHook } from 'payload';
|
||||
|
||||
const slugify = (s: string) =>
|
||||
s
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/[^a-z0-9\s-]/g, '')
|
||||
.replace(/\s+/g, '-')
|
||||
.replace(/-+/g, '-');
|
||||
|
||||
const autoSlug: FieldHook = ({ value, data }) => {
|
||||
if (value) return slugify(String(value));
|
||||
if (data?.title) return slugify(String(data.title));
|
||||
return value;
|
||||
};
|
||||
|
||||
export const Articles: CollectionConfig = {
|
||||
slug: 'articles',
|
||||
admin: {
|
||||
useAsTitle: 'title',
|
||||
defaultColumns: ['title', 'status', 'category', 'author', 'publishedAt'],
|
||||
},
|
||||
access: {
|
||||
read: () => true,
|
||||
create: ({ req }) => Boolean(req.user),
|
||||
update: ({ req }) => Boolean(req.user),
|
||||
delete: ({ req }) => req.user?.roles?.includes('admin') ?? false,
|
||||
},
|
||||
versions: { drafts: { autosave: true } },
|
||||
fields: [
|
||||
{ name: 'title', type: 'text', required: true },
|
||||
{
|
||||
name: 'slug',
|
||||
type: 'text',
|
||||
required: true,
|
||||
unique: true,
|
||||
index: true,
|
||||
hooks: { beforeValidate: [autoSlug] },
|
||||
},
|
||||
{ name: 'excerpt', type: 'textarea', required: true, maxLength: 280 },
|
||||
{
|
||||
name: 'body',
|
||||
type: 'richText',
|
||||
required: true,
|
||||
},
|
||||
{ name: 'coverImage', type: 'upload', relationTo: 'media' },
|
||||
{ name: 'category', type: 'relationship', relationTo: 'categories', required: true },
|
||||
{ name: 'tags', type: 'relationship', relationTo: 'tags', hasMany: true },
|
||||
{ name: 'author', type: 'relationship', relationTo: 'authors', required: true },
|
||||
{
|
||||
name: 'status',
|
||||
type: 'select',
|
||||
required: true,
|
||||
defaultValue: 'draft',
|
||||
options: [
|
||||
{ label: 'Draft', value: 'draft' },
|
||||
{ label: 'Published', value: 'published' },
|
||||
],
|
||||
admin: { position: 'sidebar' },
|
||||
},
|
||||
{
|
||||
name: 'publishedAt',
|
||||
type: 'date',
|
||||
admin: { date: { pickerAppearance: 'dayAndTime' }, position: 'sidebar' },
|
||||
},
|
||||
{
|
||||
name: 'readingMinutes',
|
||||
type: 'number',
|
||||
admin: {
|
||||
description: 'Auto-computed if empty. Manual override takes precedence.',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'featured',
|
||||
type: 'checkbox',
|
||||
defaultValue: false,
|
||||
admin: { position: 'sidebar' },
|
||||
},
|
||||
{ name: 'featuredRank', type: 'number', defaultValue: 0, admin: { position: 'sidebar' } },
|
||||
{
|
||||
name: 'seo',
|
||||
type: 'group',
|
||||
label: 'SEO',
|
||||
fields: [
|
||||
{ name: 'title', type: 'text' },
|
||||
{ name: 'description', type: 'textarea' },
|
||||
{ name: 'ogImage', type: 'upload', relationTo: 'media' },
|
||||
],
|
||||
},
|
||||
],
|
||||
hooks: {
|
||||
beforeChange: [
|
||||
({ data }) => {
|
||||
if (data?.status === 'published' && !data.publishedAt) {
|
||||
return { ...data, publishedAt: new Date().toISOString() };
|
||||
}
|
||||
return data;
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { CollectionConfig } from 'payload';
|
||||
|
||||
export const Authors: CollectionConfig = {
|
||||
slug: 'authors',
|
||||
admin: { useAsTitle: 'name', defaultColumns: ['name', 'role', 'updatedAt'] },
|
||||
access: {
|
||||
read: () => true,
|
||||
create: ({ req }) => Boolean(req.user),
|
||||
update: ({ req }) => Boolean(req.user),
|
||||
delete: ({ req }) => req.user?.roles?.includes('admin') ?? false,
|
||||
},
|
||||
fields: [
|
||||
{ name: 'name', type: 'text', required: true },
|
||||
{
|
||||
name: 'slug',
|
||||
type: 'text',
|
||||
required: true,
|
||||
unique: true,
|
||||
index: true,
|
||||
},
|
||||
{ name: 'role', type: 'text', admin: { description: 'e.g. Founding Editor' } },
|
||||
{
|
||||
name: 'avatar',
|
||||
type: 'upload',
|
||||
relationTo: 'media',
|
||||
},
|
||||
{ name: 'bio', type: 'textarea' },
|
||||
{
|
||||
name: 'socials',
|
||||
type: 'group',
|
||||
fields: [
|
||||
{ name: 'twitter', type: 'text' },
|
||||
{ name: 'github', type: 'text' },
|
||||
{ name: 'site', type: 'text' },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { CollectionConfig } from 'payload';
|
||||
|
||||
export const Categories: CollectionConfig = {
|
||||
slug: 'categories',
|
||||
admin: { useAsTitle: 'name', defaultColumns: ['name', 'order', 'updatedAt'] },
|
||||
access: {
|
||||
read: () => true,
|
||||
create: ({ req }) => Boolean(req.user),
|
||||
update: ({ req }) => Boolean(req.user),
|
||||
delete: ({ req }) => req.user?.roles?.includes('admin') ?? false,
|
||||
},
|
||||
fields: [
|
||||
{ name: 'name', type: 'text', required: true },
|
||||
{
|
||||
name: 'slug',
|
||||
type: 'text',
|
||||
required: true,
|
||||
unique: true,
|
||||
index: true,
|
||||
admin: { description: 'Lowercase, dash-separated.' },
|
||||
},
|
||||
{ name: 'description', type: 'textarea' },
|
||||
{
|
||||
name: 'icon',
|
||||
type: 'text',
|
||||
admin: {
|
||||
description: 'Material Symbols icon name. e.g. memory, code, layers.',
|
||||
},
|
||||
},
|
||||
{ name: 'color', type: 'text', admin: { description: 'Optional accent color (hex).' } },
|
||||
{ name: 'order', type: 'number', defaultValue: 0 },
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { CollectionConfig } from 'payload';
|
||||
|
||||
export const Editions: CollectionConfig = {
|
||||
slug: 'editions',
|
||||
admin: { useAsTitle: 'title', defaultColumns: ['number', 'title', 'publishedAt'] },
|
||||
access: {
|
||||
read: () => true,
|
||||
create: ({ req }) => Boolean(req.user),
|
||||
update: ({ req }) => Boolean(req.user),
|
||||
delete: ({ req }) => req.user?.roles?.includes('admin') ?? false,
|
||||
},
|
||||
fields: [
|
||||
{ name: 'number', type: 'number', required: true, unique: true },
|
||||
{ name: 'title', type: 'text', required: true },
|
||||
{ name: 'intro', type: 'textarea' },
|
||||
{ name: 'cover', type: 'upload', relationTo: 'media' },
|
||||
{
|
||||
name: 'publishedAt',
|
||||
type: 'date',
|
||||
admin: { date: { pickerAppearance: 'dayAndTime' } },
|
||||
},
|
||||
{ name: 'articles', type: 'relationship', relationTo: 'articles', hasMany: true },
|
||||
{ name: 'sentCount', type: 'number', defaultValue: 0, admin: { readOnly: true } },
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { CollectionConfig } from 'payload';
|
||||
import { lexicalEditor } from '@payloadcms/richtext-lexical';
|
||||
|
||||
export const Media: CollectionConfig = {
|
||||
slug: 'media',
|
||||
admin: { useAsTitle: 'alt' },
|
||||
access: {
|
||||
read: () => true,
|
||||
create: ({ req }) => Boolean(req.user),
|
||||
update: ({ req }) => Boolean(req.user),
|
||||
delete: ({ req }) => Boolean(req.user),
|
||||
},
|
||||
upload: {
|
||||
staticDir: 'media',
|
||||
mimeTypes: ['image/*'],
|
||||
imageSizes: [
|
||||
{ name: 'thumbnail', width: 160, height: 160, position: 'centre' },
|
||||
{ name: 'card', width: 640, height: 400, position: 'centre' },
|
||||
{ name: 'hero', width: 1280, height: 720, position: 'centre' },
|
||||
],
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'alt',
|
||||
type: 'text',
|
||||
required: true,
|
||||
},
|
||||
{ name: 'caption', type: 'text' },
|
||||
{
|
||||
name: 'credit',
|
||||
type: 'richText',
|
||||
editor: lexicalEditor({}),
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { CollectionConfig } from 'payload';
|
||||
|
||||
export const Subscribers: CollectionConfig = {
|
||||
slug: 'subscribers',
|
||||
admin: { useAsTitle: 'email', defaultColumns: ['email', 'source', 'createdAt'] },
|
||||
access: {
|
||||
read: ({ req }) => Boolean(req.user),
|
||||
create: () => true,
|
||||
update: ({ req }) => Boolean(req.user),
|
||||
delete: ({ req }) => req.user?.roles?.includes('admin') ?? false,
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'email',
|
||||
type: 'email',
|
||||
required: true,
|
||||
unique: true,
|
||||
index: true,
|
||||
},
|
||||
{
|
||||
name: 'source',
|
||||
type: 'text',
|
||||
defaultValue: 'web',
|
||||
},
|
||||
{
|
||||
name: 'unsubscribedAt',
|
||||
type: 'date',
|
||||
},
|
||||
],
|
||||
hooks: {
|
||||
beforeChange: [
|
||||
({ data }) => {
|
||||
if (data?.email) data.email = String(data.email).toLowerCase().trim();
|
||||
return data;
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { CollectionConfig } from 'payload';
|
||||
|
||||
export const Tags: CollectionConfig = {
|
||||
slug: 'tags',
|
||||
admin: { useAsTitle: 'name' },
|
||||
access: {
|
||||
read: () => true,
|
||||
create: ({ req }) => Boolean(req.user),
|
||||
update: ({ req }) => Boolean(req.user),
|
||||
delete: ({ req }) => req.user?.roles?.includes('admin') ?? false,
|
||||
},
|
||||
fields: [
|
||||
{ name: 'name', type: 'text', required: true },
|
||||
{
|
||||
name: 'slug',
|
||||
type: 'text',
|
||||
required: true,
|
||||
unique: true,
|
||||
index: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { CollectionConfig } from 'payload';
|
||||
|
||||
export const Users: CollectionConfig = {
|
||||
slug: 'users',
|
||||
admin: {
|
||||
useAsTitle: 'email',
|
||||
defaultColumns: ['email', 'roles', 'updatedAt'],
|
||||
},
|
||||
auth: true,
|
||||
access: {
|
||||
create: ({ req }) => req.user?.roles?.includes('admin') ?? false,
|
||||
delete: ({ req }) => req.user?.roles?.includes('admin') ?? false,
|
||||
update: ({ req }) => Boolean(req.user),
|
||||
read: ({ req }) => Boolean(req.user),
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'name',
|
||||
type: 'text',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'roles',
|
||||
type: 'select',
|
||||
hasMany: true,
|
||||
defaultValue: ['editor'],
|
||||
options: [
|
||||
{ label: 'Admin', value: 'admin' },
|
||||
{ label: 'Editor', value: 'editor' },
|
||||
{ label: 'Author', value: 'author' },
|
||||
],
|
||||
access: {
|
||||
update: ({ req }) => req.user?.roles?.includes('admin') ?? false,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { CollectionAfterChangeHook } from 'payload';
|
||||
import { logger as payloadLogger } from 'payload';
|
||||
|
||||
const TRIGGERING = new Set(['articles', 'authors', 'categories', 'editions', 'tags']);
|
||||
|
||||
export const revalidateAstro: CollectionAfterChangeHook = async ({ doc, operation, collection }) => {
|
||||
if (!TRIGGERING.has(collection.slug)) return doc;
|
||||
if (process.env.ASTRO_REVALIDATE_URL && process.env.ASTRO_REVALIDATE_TOKEN) {
|
||||
try {
|
||||
const url = `${process.env.ASTRO_REVALIDATE_URL}?secret=${encodeURIComponent(
|
||||
process.env.ASTRO_REVALIDATE_TOKEN,
|
||||
)}&collection=${collection.slug}&op=${operation}`;
|
||||
const res = await fetch(url, { method: 'POST' });
|
||||
payloadLogger.info(`Revalidate ${collection.slug} ${operation}: ${res.status}`);
|
||||
} catch (e) {
|
||||
payloadLogger.warn(`Revalidate failed: ${(e as Error).message}`);
|
||||
}
|
||||
}
|
||||
return doc;
|
||||
};
|
||||
@@ -0,0 +1,58 @@
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { buildConfig } from 'payload';
|
||||
import { postgresAdapter } from '@payloadcms/db-postgres';
|
||||
import { lexicalEditor } from '@payloadcms/richtext-lexical';
|
||||
|
||||
import { Users } from './collections/Users';
|
||||
import { Media } from './collections/Media';
|
||||
import { Articles } from './collections/Articles';
|
||||
import { Authors } from './collections/Authors';
|
||||
import { Categories } from './collections/Categories';
|
||||
import { Tags } from './collections/Tags';
|
||||
import { Editions } from './collections/Editions';
|
||||
import { Subscribers } from './collections/Subscribers';
|
||||
|
||||
import { revalidateAstro } from './hooks/revalidateAstro';
|
||||
|
||||
const filename = fileURLToPath(import.meta.url);
|
||||
const dirname = path.dirname(filename);
|
||||
|
||||
const corsOrigins = (process.env.CORS_ORIGINS || '')
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
export default buildConfig({
|
||||
serverURL: process.env.SERVER_URL,
|
||||
cors: corsOrigins,
|
||||
csrf: corsOrigins,
|
||||
admin: {
|
||||
user: Users.slug,
|
||||
importMap: { baseDir: path.resolve(dirname) },
|
||||
meta: {
|
||||
titleSuffix: ' · SoftVowels CMS',
|
||||
},
|
||||
},
|
||||
collections: [Users, Media, Articles, Authors, Categories, Tags, Editions, Subscribers],
|
||||
editor: lexicalEditor(),
|
||||
secret: process.env.PAYLOAD_SECRET || 'dev-secret-change-me',
|
||||
typescript: {
|
||||
outputFile: path.resolve(dirname, 'payload-types.ts'),
|
||||
},
|
||||
db: postgresAdapter({
|
||||
pool: {
|
||||
connectionString: process.env.DATABASE_URI || '',
|
||||
},
|
||||
}),
|
||||
sharp: (sharp) => sharp,
|
||||
upload: {
|
||||
limits: {
|
||||
fileSize: 25_000_000,
|
||||
},
|
||||
},
|
||||
endpoints: [],
|
||||
hooks: {
|
||||
afterChange: [revalidateAstro],
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["DOM", "DOM.Iterable", "ES2022"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"jsx": "preserve",
|
||||
"allowJs": true,
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"incremental": true,
|
||||
"noEmit": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"],
|
||||
"@payload-config": ["./src/payload.config.ts"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*", "next-env.d.ts", "**/*.ts", "**/*.tsx"],
|
||||
"exclude": ["node_modules", "dist", "build", ".next"]
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
# Public site origin used in canonical/OG URLs
|
||||
PUBLIC_SITE_URL=https://softvowels.example
|
||||
|
||||
# Public (browser-reachable) Payload base URL
|
||||
PUBLIC_PAYLOAD_API=https://cms.softvowels.example
|
||||
|
||||
# Internal Payload base URL used at build time (container-to-container)
|
||||
PAYLOAD_API_INTERNAL=http://cms:3000
|
||||
|
||||
# Token required to call the /__revalidate webhook
|
||||
REVALIDATE_TOKEN=change-me
|
||||
@@ -0,0 +1,33 @@
|
||||
# syntax=docker/dockerfile:1.7
|
||||
#
|
||||
# Single-shot build image for the Astro site. The production deployment uses
|
||||
# this Dockerfile to produce apps/web/dist, then `docker-compose.yml` mounts
|
||||
# that folder into a plain nginx:alpine container (so re-builds from the
|
||||
# webhook don't require a `docker build` / push step).
|
||||
#
|
||||
# You only need this image if you want a self-contained static site image
|
||||
# (e.g. for a registry). For the normal homeserver flow, just use
|
||||
# `docker compose up -d --build web` — there is no `web` service to build.
|
||||
|
||||
FROM node:20-alpine AS base
|
||||
RUN corepack enable && corepack prepare [email protected] --activate
|
||||
WORKDIR /app
|
||||
|
||||
FROM base AS deps
|
||||
COPY package.json pnpm-lock.yaml* ./
|
||||
COPY ../../package.json ../../pnpm-workspace.yaml* ../../ ./
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
FROM base AS build
|
||||
ENV PUBLIC_PAYLOAD_API=https://cms.softvowels.example
|
||||
ENV PUBLIC_SITE_URL=https://softvowels.example
|
||||
ENV PAYLOAD_API_INTERNAL=http://cms:3000
|
||||
COPY --from=deps /app /app
|
||||
COPY . .
|
||||
RUN pnpm run build
|
||||
|
||||
FROM nginx:1.27-alpine AS runtime
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
COPY nginx-default.conf /etc/nginx/conf.d/default.conf
|
||||
EXPOSE 80
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
@@ -0,0 +1,28 @@
|
||||
// @ts-check
|
||||
import { defineConfig } from 'astro/config';
|
||||
import mdx from '@astrojs/mdx';
|
||||
import sitemap from '@astrojs/sitemap';
|
||||
import icon from 'astro-icon';
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
|
||||
const SITE = process.env.PUBLIC_SITE_URL || 'https://softvowels.example';
|
||||
|
||||
export default defineConfig({
|
||||
site: SITE,
|
||||
output: 'static',
|
||||
trailingSlash: 'never',
|
||||
integrations: [mdx(), sitemap(), icon()],
|
||||
vite: {
|
||||
plugins: [/** @type {any} */ (tailwindcss())],
|
||||
ssr: {
|
||||
noExternal: ['@payloadcms/richtext-lexical'],
|
||||
},
|
||||
},
|
||||
build: {
|
||||
inlineStylesheets: 'auto',
|
||||
},
|
||||
prefetch: {
|
||||
prefetchAll: true,
|
||||
defaultStrategy: 'viewport',
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# gzip everything
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_min_length 1024;
|
||||
gzip_types text/plain text/css text/xml application/json application/javascript application/xml+rss image/svg+xml;
|
||||
|
||||
# long cache for fingerprinted assets
|
||||
location ~* \.(woff2|css|js|svg|png|jpg|jpeg|webp|avif|ico)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
# sitemap / rss
|
||||
location = /sitemap.xml { rewrite ^ /sitemap-index.xml redirect; }
|
||||
location ~* ^/(rss\.xml|sitemap.*\.xml|robots\.txt)$ {
|
||||
add_header Cache-Control "public, max-age=300";
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
# Astro static fallback to .html
|
||||
location / {
|
||||
try_files $uri $uri/ $uri.html $uri/index.html /index.html;
|
||||
}
|
||||
|
||||
# /admin goes to the CMS upstream
|
||||
location /admin {
|
||||
return 301 https://cms.softvowels.example$request_uri;
|
||||
}
|
||||
|
||||
error_page 404 /index.html;
|
||||
}
|
||||
Generated
+12999
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "web",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "astro dev",
|
||||
"start": "astro dev",
|
||||
"build": "astro build",
|
||||
"preview": "astro preview",
|
||||
"astro": "astro",
|
||||
"typecheck": "astro check"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/check": "0.9.4",
|
||||
"@astrojs/mdx": "4.0.3",
|
||||
"@astrojs/rss": "4.0.11",
|
||||
"@astrojs/sitemap": "3.2.1",
|
||||
"@iconify-json/heroicons": "1.2.0",
|
||||
"@iconify-json/material-symbols": "1.2.0",
|
||||
"@payloadcms/richtext-lexical": "3.36.0",
|
||||
"@tailwindcss/oxide": "4.1.13",
|
||||
"@tailwindcss/vite": "4.1.13",
|
||||
"astro": "5.1.5",
|
||||
"astro-icon": "1.1.5",
|
||||
"sharp": "0.33.5",
|
||||
"tailwindcss": "4.1.13",
|
||||
"typescript": "5.7.2"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="32" height="32">
|
||||
<rect width="32" height="32" rx="6" fill="#318db8"/>
|
||||
<text x="16" y="22" text-anchor="middle" font-family="ui-sans-serif, system-ui, sans-serif" font-size="18" font-weight="700" fill="#ffffff" letter-spacing="-0.5">SV</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 320 B |
@@ -0,0 +1,8 @@
|
||||
# Fonts
|
||||
|
||||
Place the self-hosted variable woff2 files here:
|
||||
|
||||
- `Geist-Variable.woff2` (https://github.com/vercel/geist-font — `GeistVF.woff2` rename)
|
||||
- `GeistMono-Variable.woff2`
|
||||
|
||||
The `src/styles/global.css` `@font-face` rules reference these paths.
|
||||
@@ -0,0 +1,4 @@
|
||||
User-agent: *
|
||||
Allow: /
|
||||
|
||||
Sitemap: https://softvowels.example/sitemap-index.xml
|
||||
@@ -0,0 +1,14 @@
|
||||
---
|
||||
import '../../styles/prose.css';
|
||||
import type { Article } from '../../lib/types';
|
||||
import { lexicalToHtml } from '../../lib/lexical';
|
||||
|
||||
interface Props {
|
||||
article: Article;
|
||||
}
|
||||
|
||||
const { article } = Astro.props;
|
||||
const html = lexicalToHtml(article.body);
|
||||
---
|
||||
|
||||
<div class="prose-article mx-auto" set:html={html} />
|
||||
@@ -0,0 +1,96 @@
|
||||
---
|
||||
import type { Article } from '../../lib/types';
|
||||
import {
|
||||
formatDate,
|
||||
readingMinutes,
|
||||
categoryOf,
|
||||
authorOf,
|
||||
coverOf,
|
||||
avatarOf,
|
||||
} from '../../lib/format';
|
||||
import Chip from '../ui/Chip.astro';
|
||||
|
||||
interface Props {
|
||||
article: Article;
|
||||
variant?: 'card' | 'list' | 'feature';
|
||||
showCategory?: boolean;
|
||||
showCover?: boolean;
|
||||
}
|
||||
|
||||
const { article, variant = 'card', showCategory = true, showCover = true } = Astro.props;
|
||||
const cat = categoryOf(article);
|
||||
const author = authorOf(article);
|
||||
const cover = showCover ? coverOf(article, 'card') : null;
|
||||
const minutes = readingMinutes(article);
|
||||
const categoryName = cat?.name ?? 'Article';
|
||||
const categorySlug = cat?.slug ?? '';
|
||||
---
|
||||
|
||||
<article class="group flex flex-col">
|
||||
{
|
||||
cover && variant !== 'list' && (
|
||||
<a
|
||||
href={`/articles/${article.slug}`}
|
||||
class="bg-surface-raised border-border-subtle mb-5 block aspect-[16/10] overflow-hidden rounded-lg border"
|
||||
aria-label={article.title}
|
||||
>
|
||||
<img
|
||||
src={cover}
|
||||
alt={article.title}
|
||||
width="640"
|
||||
height="400"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
class="h-full w-full object-cover opacity-90 grayscale transition-all duration-500 group-hover:scale-105 group-hover:grayscale-0"
|
||||
/>
|
||||
</a>
|
||||
)
|
||||
}
|
||||
<div class="mb-3 flex flex-wrap items-center gap-2">
|
||||
{
|
||||
showCategory && cat && (
|
||||
<Chip variant="outline" href={`/categories/${categorySlug}`}>
|
||||
{categoryName}
|
||||
</Chip>
|
||||
)
|
||||
}
|
||||
<span class="text-text-tertiary text-[11px]">
|
||||
{formatDate(article.publishedAt)} · {minutes} min read
|
||||
</span>
|
||||
</div>
|
||||
<h3
|
||||
class:list={[
|
||||
'font-display text-on-surface group-hover:text-primary m-0 mb-3 text-balance transition-colors',
|
||||
variant === 'feature' ? 'text-headline-lg leading-tight' : 'text-headline-md leading-snug',
|
||||
]}
|
||||
>
|
||||
<a href={`/articles/${article.slug}`}>{article.title}</a>
|
||||
</h3>
|
||||
{
|
||||
article.excerpt && variant !== 'list' && (
|
||||
<p class="text-text-secondary mb-4 line-clamp-3 text-sm leading-relaxed">{article.excerpt}</p>
|
||||
)
|
||||
}
|
||||
{
|
||||
(author || variant === 'list') && (
|
||||
<div class="mt-auto flex items-center gap-3">
|
||||
{author && avatarOf(author) && (
|
||||
<img
|
||||
src={avatarOf(author) ?? ''}
|
||||
alt={author.name}
|
||||
width="32"
|
||||
height="32"
|
||||
loading="lazy"
|
||||
class="border-border-subtle h-8 w-8 rounded-full border object-cover"
|
||||
/>
|
||||
)}
|
||||
{author && (
|
||||
<div class="leading-tight">
|
||||
<p class="text-label-md text-on-surface">{author.name}</p>
|
||||
{author.role && <p class="text-text-tertiary text-[11px]">{author.role}</p>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</article>
|
||||
@@ -0,0 +1,99 @@
|
||||
---
|
||||
import type { Article } from '../../lib/types';
|
||||
import {
|
||||
formatDate,
|
||||
categoryOf,
|
||||
authorOf,
|
||||
coverOf,
|
||||
avatarOf,
|
||||
readingMinutes,
|
||||
} from '../../lib/format';
|
||||
import Chip from '../ui/Chip.astro';
|
||||
|
||||
interface Props {
|
||||
article: Article;
|
||||
}
|
||||
|
||||
const { article } = Astro.props;
|
||||
const cat = categoryOf(article);
|
||||
const author = authorOf(article);
|
||||
const cover = coverOf(article, 'hero');
|
||||
const minutes = readingMinutes(article);
|
||||
const categoryName = cat?.name ?? 'Article';
|
||||
const categorySlug = cat?.slug ?? '';
|
||||
---
|
||||
|
||||
<header class="mb-12">
|
||||
<div class="mb-6 flex flex-wrap items-center gap-3">
|
||||
{
|
||||
cat && (
|
||||
<Chip variant="outline" href={`/categories/${categorySlug}`}>
|
||||
{categoryName}
|
||||
</Chip>
|
||||
)
|
||||
}
|
||||
<span class="text-text-tertiary text-label-md">{minutes} min read</span>
|
||||
</div>
|
||||
<h1 class="font-display text-display-lg text-on-surface mb-8 text-balance leading-tight">
|
||||
{article.title}
|
||||
</h1>
|
||||
{
|
||||
author && (
|
||||
<div class="border-border-subtle flex items-center gap-4 border-y py-6">
|
||||
{avatarOf(author) && (
|
||||
<img
|
||||
src={avatarOf(author) ?? ''}
|
||||
alt={author.name}
|
||||
width="48"
|
||||
height="48"
|
||||
loading="eager"
|
||||
class="border-border-subtle h-12 w-12 rounded-full border object-cover"
|
||||
/>
|
||||
)}
|
||||
<div class="leading-tight">
|
||||
<p class="text-on-surface text-body-md font-semibold">
|
||||
<a href={`/authors/${author.slug}`} class="hover:text-primary transition-colors">
|
||||
{author.name}
|
||||
</a>
|
||||
</p>
|
||||
<p class="text-text-tertiary text-label-md">
|
||||
{author.role ?? 'Contributor'} ·{' '}
|
||||
{formatDate(article.publishedAt, { dateStyle: 'long' })}
|
||||
</p>
|
||||
</div>
|
||||
<div class="ml-auto hidden gap-2 sm:flex">
|
||||
<button
|
||||
type="button"
|
||||
class="border-border-subtle hover:bg-surface-floated rounded border p-2 transition-colors"
|
||||
aria-label="Bookmark article"
|
||||
>
|
||||
<span class="material-symbols-outlined text-text-secondary text-[20px]">bookmark</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="border-border-subtle hover:bg-surface-floated rounded border p-2 transition-colors"
|
||||
aria-label="Share article"
|
||||
>
|
||||
<span class="material-symbols-outlined text-text-secondary text-[20px]">share</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</header>
|
||||
|
||||
{
|
||||
cover && (
|
||||
<div class="border-border-subtle bg-surface-raised mb-12 aspect-video overflow-hidden rounded-lg border">
|
||||
<img
|
||||
src={cover}
|
||||
alt={article.title}
|
||||
width="1280"
|
||||
height="720"
|
||||
loading="eager"
|
||||
decoding="async"
|
||||
class="h-full w-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
---
|
||||
import Card from '../ui/Card.astro';
|
||||
import SubscribeForm from '../newsletter/SubscribeForm.astro';
|
||||
import Icon from '../ui/Icon.astro';
|
||||
---
|
||||
|
||||
<aside class="my-16">
|
||||
<Card variant="flat" padding="lg" class="group relative overflow-hidden">
|
||||
<div class="relative z-10">
|
||||
<h3 class="font-display text-headline-md text-on-surface m-0 mb-2">
|
||||
Deep tech in your inbox
|
||||
</h3>
|
||||
<p class="text-text-secondary mb-6 max-w-md">
|
||||
Join 40,000+ engineers receiving our weekly deep dives on distributed systems and software
|
||||
architecture.
|
||||
</p>
|
||||
<SubscribeForm source="article-inline" />
|
||||
</div>
|
||||
<div
|
||||
class="pointer-events-none absolute -bottom-12 -right-12 opacity-5 transition-transform duration-700 group-hover:scale-110"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<Icon name="terminal" size="lg" class="text-[200px]" filled />
|
||||
</div>
|
||||
</Card>
|
||||
</aside>
|
||||
@@ -0,0 +1,38 @@
|
||||
---
|
||||
import type { Article } from '../../lib/types';
|
||||
import { formatDate, readingMinutes, categoryOf } from '../../lib/format';
|
||||
|
||||
interface Props {
|
||||
articles: Article[];
|
||||
title?: string;
|
||||
}
|
||||
|
||||
const { articles, title = 'Related Deep Dives' } = Astro.props;
|
||||
---
|
||||
|
||||
<section>
|
||||
<h4 class="font-label-md text-label-md text-text-tertiary mb-6 uppercase tracking-widest">
|
||||
{title}
|
||||
</h4>
|
||||
<div class="space-y-6">
|
||||
{
|
||||
articles.map((a) => {
|
||||
const cat = categoryOf(a);
|
||||
return (
|
||||
<a class="group block" href={`/articles/${a.slug}`}>
|
||||
<div class="text-primary mb-1 text-[12px]">{cat?.name ?? 'Article'}</div>
|
||||
<div class="text-on-surface group-hover:text-primary mb-2 text-balance font-semibold leading-snug transition-colors">
|
||||
{a.title}
|
||||
</div>
|
||||
<div class="text-text-tertiary flex items-center gap-2 text-[12px]">
|
||||
<span>{readingMinutes(a)} min read</span>
|
||||
<span aria-hidden="true">·</span>
|
||||
<span>{formatDate(a.publishedAt)}</span>
|
||||
</div>
|
||||
</a>
|
||||
);
|
||||
})
|
||||
}
|
||||
{articles.length === 0 && <p class="text-text-tertiary text-sm">No related articles yet.</p>}
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,69 @@
|
||||
---
|
||||
import type { Category } from '../../lib/types';
|
||||
import Icon from '../ui/Icon.astro';
|
||||
|
||||
interface Props {
|
||||
categories: Category[];
|
||||
}
|
||||
|
||||
const { categories } = Astro.props;
|
||||
---
|
||||
|
||||
<section class="mb-20">
|
||||
<div class="mb-8 flex items-center justify-between">
|
||||
<h2 class="font-display text-headline-lg text-headline-lg text-on-surface m-0">
|
||||
Browse intelligence
|
||||
</h2>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="border-border-subtle text-text-tertiary hover:text-on-surface hover:border-primary rounded-md border p-2 transition-all"
|
||||
aria-label="Scroll left"
|
||||
data-cat-scroll="prev"
|
||||
>
|
||||
<Icon name="chevron_left" size="sm" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="border-border-subtle text-text-tertiary hover:text-on-surface hover:border-primary rounded-md border p-2 transition-all"
|
||||
aria-label="Scroll right"
|
||||
data-cat-scroll="next"
|
||||
>
|
||||
<Icon name="chevron_right" size="sm" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="scrollbar-none -mx-4 grid auto-cols-[160px] grid-flow-col gap-6 overflow-x-auto px-4 sm:auto-cols-[180px] md:mx-0 md:auto-cols-auto md:grid-flow-row md:grid-cols-2 md:overflow-visible md:px-0 lg:grid-cols-3 xl:grid-cols-6"
|
||||
id="category-grid"
|
||||
>
|
||||
{
|
||||
categories.map((c) => (
|
||||
<a
|
||||
href={`/categories/${c.slug}`}
|
||||
class="bg-surface-raised border-border-subtle hover:border-primary group min-w-[160px] rounded-lg border p-6 transition-all"
|
||||
>
|
||||
<Icon name={c.icon || 'tag'} size="lg" class="text-primary mb-4 block" />
|
||||
<p class="font-label-md text-label-md group-hover:text-primary m-0 transition-colors">
|
||||
{c.name}
|
||||
</p>
|
||||
<p class="text-text-tertiary m-0 mt-1 text-[11px]">
|
||||
{c.description ? c.description.split(' ').slice(0, 3).join(' ') + '…' : 'View all'}
|
||||
</p>
|
||||
</a>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<script is:inline>
|
||||
(() => {
|
||||
const grid = document.getElementById('category-grid');
|
||||
if (!grid) return;
|
||||
const prev = document.querySelector('[data-cat-scroll="prev"]');
|
||||
const next = document.querySelector('[data-cat-scroll="next"]');
|
||||
const step = 200;
|
||||
prev?.addEventListener('click', () => grid.scrollBy({ left: -step, behavior: 'smooth' }));
|
||||
next?.addEventListener('click', () => grid.scrollBy({ left: step, behavior: 'smooth' }));
|
||||
})();
|
||||
</script>
|
||||
@@ -0,0 +1,47 @@
|
||||
---
|
||||
import type { Article } from '../../lib/types';
|
||||
import { coverOf, readingMinutes, categoryOf } from '../../lib/format';
|
||||
import SectionHead from '../ui/SectionHead.astro';
|
||||
|
||||
interface Props {
|
||||
articles: Article[];
|
||||
}
|
||||
|
||||
const { articles } = Astro.props;
|
||||
---
|
||||
|
||||
<aside>
|
||||
<SectionHead eyebrow="Editor's picks" level={3} />
|
||||
<div class="space-y-6">
|
||||
{
|
||||
articles.slice(0, 3).map((a) => {
|
||||
const cover = coverOf(a, 'thumbnail');
|
||||
const cat = categoryOf(a);
|
||||
return (
|
||||
<a href={`/articles/${a.slug}`} class="group flex gap-4">
|
||||
{cover ? (
|
||||
<img
|
||||
src={cover}
|
||||
alt={a.title}
|
||||
width="80"
|
||||
height="80"
|
||||
loading="lazy"
|
||||
class="border-border-subtle h-20 w-20 rounded-lg border object-cover grayscale"
|
||||
/>
|
||||
) : (
|
||||
<div class="bg-surface-container border-border-subtle h-20 w-20 rounded-lg border" />
|
||||
)}
|
||||
<div class="min-w-0 flex-1">
|
||||
<h5 class="font-label-md text-label-md text-on-surface group-hover:text-primary m-0 mb-1 line-clamp-2 text-balance transition-colors">
|
||||
{a.title}
|
||||
</h5>
|
||||
<p class="text-text-tertiary m-0 text-xs">
|
||||
{cat?.name ?? 'Article'} · {readingMinutes(a)} min
|
||||
</p>
|
||||
</div>
|
||||
</a>
|
||||
);
|
||||
})
|
||||
}
|
||||
</div>
|
||||
</aside>
|
||||
@@ -0,0 +1,96 @@
|
||||
---
|
||||
import type { Article } from '../../lib/types';
|
||||
import { coverOf, categoryOf, authorOf, avatarOf, readingMinutes } from '../../lib/format';
|
||||
import Chip from '../ui/Chip.astro';
|
||||
import Icon from '../ui/Icon.astro';
|
||||
|
||||
interface Props {
|
||||
article: Article;
|
||||
}
|
||||
|
||||
const { article } = Astro.props;
|
||||
const cover = coverOf(article, 'hero');
|
||||
const cat = categoryOf(article);
|
||||
const author = authorOf(article);
|
||||
---
|
||||
|
||||
<section class="mb-16 grid grid-cols-1 gap-6 lg:grid-cols-12">
|
||||
<div class="flex flex-col justify-center lg:col-span-8">
|
||||
<div class="mb-6 flex flex-wrap items-center gap-2">
|
||||
<Chip variant="soft">Featured</Chip>
|
||||
<span class="text-text-tertiary text-xs">
|
||||
{readingMinutes(article)} min read · {cat?.name ?? 'Article'}
|
||||
</span>
|
||||
</div>
|
||||
<h1
|
||||
class="font-display text-display-lg text-on-surface mb-6 max-w-[90%] text-balance leading-tight"
|
||||
>
|
||||
{article.title}
|
||||
</h1>
|
||||
{
|
||||
article.excerpt && (
|
||||
<p class="text-text-secondary text-body-lg mb-8 max-w-2xl leading-relaxed">
|
||||
{article.excerpt}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
<div class="mb-10 flex items-center gap-6">
|
||||
{
|
||||
author && (
|
||||
<div class="flex items-center gap-3">
|
||||
{avatarOf(author) && (
|
||||
<img
|
||||
src={avatarOf(author) ?? ''}
|
||||
alt={author.name}
|
||||
width="40"
|
||||
height="40"
|
||||
loading="eager"
|
||||
class="border-border-subtle h-10 w-10 rounded-full border object-cover"
|
||||
/>
|
||||
)}
|
||||
<div class="leading-tight">
|
||||
<p class="text-on-surface font-label-md m-0">{author.name}</p>
|
||||
{author.role && <p class="text-text-tertiary m-0 text-xs">{author.role}</p>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
<a
|
||||
href={`/articles/${article.slug}`}
|
||||
class="text-primary font-label-md flex items-center gap-2 transition-all duration-300 hover:gap-4"
|
||||
>
|
||||
<span>Start Reading</span>
|
||||
<Icon name="arrow_forward" size="sm" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="relative flex min-h-[360px] items-center justify-center lg:col-span-4">
|
||||
{
|
||||
cover && (
|
||||
<a
|
||||
href={`/articles/${article.slug}`}
|
||||
class="bg-surface-raised border-border-subtle group absolute inset-0 block overflow-hidden rounded-lg border"
|
||||
aria-label={article.title}
|
||||
>
|
||||
<img
|
||||
src={cover}
|
||||
alt={article.title}
|
||||
width="640"
|
||||
height="400"
|
||||
loading="eager"
|
||||
class="h-full w-full object-cover opacity-70 grayscale transition-all duration-700 group-hover:opacity-100 group-hover:grayscale-0"
|
||||
/>
|
||||
<div class="absolute inset-0 bg-gradient-to-t from-white/95 via-white/0 to-transparent" />
|
||||
<div class="absolute bottom-6 left-6 right-6">
|
||||
<span class="text-primary mb-2 block font-mono text-xs">
|
||||
// {cat?.name ?? 'Feature'}
|
||||
</span>
|
||||
<h3 class="font-display text-headline-md text-on-surface m-0 line-clamp-2">
|
||||
{article.title}
|
||||
</h3>
|
||||
</div>
|
||||
</a>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,31 @@
|
||||
---
|
||||
import Card from '../ui/Card.astro';
|
||||
import SubscribeForm from '../newsletter/SubscribeForm.astro';
|
||||
|
||||
interface Props {
|
||||
title?: string;
|
||||
body?: string;
|
||||
}
|
||||
const {
|
||||
title = 'Stay ahead of tech.',
|
||||
body = 'Receive a weekly distillation of the most impactful architectural shifts and technical breakthroughs.',
|
||||
} = Astro.props;
|
||||
---
|
||||
|
||||
<Card padding="none" class="relative overflow-hidden text-center">
|
||||
<div class="pointer-events-none absolute inset-0 opacity-10" aria-hidden="true">
|
||||
<div
|
||||
class="border-primary/30 absolute left-1/2 top-1/2 h-[600px] w-[600px] -translate-x-1/2 -translate-y-1/2 rounded-full border"
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
class="border-primary/20 absolute left-1/2 top-1/2 h-[420px] w-[420px] -translate-x-1/2 -translate-y-1/2 rounded-full border"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div class="relative z-10 mx-auto max-w-xl p-12 md:p-20">
|
||||
<h2 class="font-display text-headline-lg text-headline-lg text-on-surface m-0 mb-6">{title}</h2>
|
||||
<p class="text-text-secondary text-body-lg mb-10">{body}</p>
|
||||
<SubscribeForm source="home-cta" layout="stacked" />
|
||||
</div>
|
||||
</Card>
|
||||
@@ -0,0 +1,39 @@
|
||||
---
|
||||
import type { Article } from '../../lib/types';
|
||||
import { categoryOf, readingMinutes } from '../../lib/format';
|
||||
import SectionHead from '../ui/SectionHead.astro';
|
||||
|
||||
interface Props {
|
||||
articles: Article[];
|
||||
}
|
||||
|
||||
const { articles } = Astro.props;
|
||||
---
|
||||
|
||||
<aside class="space-y-12">
|
||||
<section>
|
||||
<SectionHead eyebrow="Popular this week" level={3} />
|
||||
<div class="space-y-4">
|
||||
{
|
||||
articles.slice(0, 3).map((a, i) => {
|
||||
const cat = categoryOf(a);
|
||||
return (
|
||||
<a
|
||||
href={`/articles/${a.slug}`}
|
||||
class="bg-surface-raised border-border-subtle hover:border-primary group block rounded-lg border p-5 transition-colors"
|
||||
>
|
||||
<span class="text-primary mb-2 block font-mono text-[10px]">
|
||||
{String(i + 1).padStart(2, '0')} / {cat?.name?.toUpperCase() ?? 'ARTICLE'}
|
||||
</span>
|
||||
<h4 class="font-display group-hover:text-primary m-0 text-balance text-[18px] leading-snug transition-colors">
|
||||
{a.title}
|
||||
</h4>
|
||||
<p class="text-text-tertiary m-0 mt-2 text-[12px]">{readingMinutes(a)} min read</p>
|
||||
</a>
|
||||
);
|
||||
})
|
||||
}
|
||||
{articles.length === 0 && <p class="text-text-tertiary text-sm">No articles yet.</p>}
|
||||
</div>
|
||||
</section>
|
||||
</aside>
|
||||
@@ -0,0 +1,36 @@
|
||||
---
|
||||
import Icon from '../ui/Icon.astro';
|
||||
|
||||
interface Props {
|
||||
current?: 'home' | 'search' | 'saved' | 'account';
|
||||
}
|
||||
const { current = 'home' } = Astro.props;
|
||||
|
||||
const items = [
|
||||
{ id: 'home' as const, label: 'Home', href: '/', icon: 'home' },
|
||||
{ id: 'search' as const, label: 'Search', href: '/search', icon: 'search' },
|
||||
{ id: 'saved' as const, label: 'Saved', href: '/saved', icon: 'bookmarks' },
|
||||
{ id: 'account' as const, label: 'Account', href: '/account', icon: 'person' },
|
||||
];
|
||||
---
|
||||
|
||||
<nav
|
||||
class="bg-surface-container-lowest border-border-subtle fixed inset-x-0 bottom-0 z-50 flex h-16 items-stretch border-t md:hidden"
|
||||
aria-label="Mobile primary"
|
||||
>
|
||||
{
|
||||
items.map((it) => (
|
||||
<a
|
||||
href={it.href}
|
||||
class:list={[
|
||||
'flex flex-1 flex-col items-center justify-center gap-1 transition-colors',
|
||||
current === it.id ? 'text-primary' : 'text-on-surface-variant hover:text-on-surface',
|
||||
]}
|
||||
aria-current={current === it.id ? 'page' : undefined}
|
||||
>
|
||||
<Icon name={it.icon} size="md" filled={current === it.id} />
|
||||
<span class="font-label-md text-[10px] uppercase tracking-widest">{it.label}</span>
|
||||
</a>
|
||||
))
|
||||
}
|
||||
</nav>
|
||||
@@ -0,0 +1,67 @@
|
||||
---
|
||||
const year = new Date().getFullYear();
|
||||
---
|
||||
|
||||
<footer class="bg-surface-container-lowest border-border-subtle border-t">
|
||||
<div class="mx-auto grid max-w-[1440px] grid-cols-2 gap-6 px-4 py-16 md:grid-cols-6 md:px-10">
|
||||
<div class="col-span-2">
|
||||
<a
|
||||
href="/"
|
||||
class="font-display text-headline-md text-on-surface mb-4 block font-semibold tracking-tight"
|
||||
>
|
||||
SoftVowels
|
||||
</a>
|
||||
<p class="text-text-tertiary text-label-md max-w-xs leading-relaxed">
|
||||
Engineering-focused insights for the modern software architect. © {year} SoftVowels. Engineered
|
||||
for clarity.
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-col gap-3">
|
||||
<h6 class="text-on-surface text-label-md mb-2">Connect</h6>
|
||||
<a class="text-text-tertiary hover:text-primary text-label-md transition-colors" href="#"
|
||||
>Twitter</a
|
||||
>
|
||||
<a class="text-text-tertiary hover:text-primary text-label-md transition-colors" href="#"
|
||||
>GitHub</a
|
||||
>
|
||||
<a
|
||||
class="text-text-tertiary hover:text-primary text-label-md transition-colors"
|
||||
href="/rss.xml">RSS</a
|
||||
>
|
||||
</div>
|
||||
<div class="flex flex-col gap-3">
|
||||
<h6 class="text-on-surface text-label-md mb-2">Platform</h6>
|
||||
<a
|
||||
class="text-text-tertiary hover:text-primary text-label-md transition-colors"
|
||||
href="/articles">Articles</a
|
||||
>
|
||||
<a
|
||||
class="text-text-tertiary hover:text-primary text-label-md transition-colors"
|
||||
href="/categories">Categories</a
|
||||
>
|
||||
<a
|
||||
class="text-text-tertiary hover:text-primary text-label-md transition-colors"
|
||||
href="/newsletter">Newsletter</a
|
||||
>
|
||||
</div>
|
||||
<div class="flex flex-col gap-3">
|
||||
<h6 class="text-on-surface text-label-md mb-2">Company</h6>
|
||||
<a class="text-text-tertiary hover:text-primary text-label-md transition-colors" href="#"
|
||||
>Security</a
|
||||
>
|
||||
<a class="text-text-tertiary hover:text-primary text-label-md transition-colors" href="#"
|
||||
>Privacy</a
|
||||
>
|
||||
<a class="text-text-tertiary hover:text-primary text-label-md transition-colors" href="#"
|
||||
>Terms</a
|
||||
>
|
||||
</div>
|
||||
<div class="flex flex-col gap-3">
|
||||
<h6 class="text-on-surface text-label-md mb-2">Status</h6>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="bg-success h-1.5 w-1.5 rounded-full" aria-hidden="true"></span>
|
||||
<span class="text-text-tertiary text-label-md">All systems operational</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
@@ -0,0 +1,91 @@
|
||||
---
|
||||
import Icon from '../ui/Icon.astro';
|
||||
|
||||
type NavCurrent =
|
||||
| 'articles'
|
||||
| 'changelog'
|
||||
| 'philosophy'
|
||||
| 'categories'
|
||||
| 'pricing'
|
||||
| 'newsletter'
|
||||
| 'search'
|
||||
| 'home';
|
||||
|
||||
interface Props {
|
||||
current?: NavCurrent;
|
||||
}
|
||||
|
||||
const { current = 'articles' } = Astro.props;
|
||||
|
||||
const links: { id: NavCurrent; label: string; href: string }[] = [
|
||||
{ id: 'articles', label: 'Articles', href: '/articles' },
|
||||
{ id: 'categories', label: 'Categories', href: '/categories' },
|
||||
{ id: 'philosophy', label: 'Philosophy', href: '/philosophy' },
|
||||
{ id: 'changelog', label: 'Changelog', href: '/changelog' },
|
||||
{ id: 'newsletter', label: 'Newsletter', href: '/newsletter' },
|
||||
];
|
||||
---
|
||||
|
||||
<header class="glass-nav border-border-subtle fixed inset-x-0 top-0 z-50 h-16 border-b">
|
||||
<div class="mx-auto flex h-full max-w-[1440px] items-center justify-between gap-6 px-4 md:px-10">
|
||||
<div class="flex items-center gap-8">
|
||||
<a
|
||||
href="/"
|
||||
class="font-display text-headline-md text-on-surface font-semibold tracking-tight"
|
||||
aria-label="SoftVowels home"
|
||||
>
|
||||
SoftVowels
|
||||
</a>
|
||||
<nav class="hidden items-center gap-6 md:flex" aria-label="Primary">
|
||||
{
|
||||
links.map((l) => (
|
||||
<a
|
||||
href={l.href}
|
||||
class:list={[
|
||||
'font-label-md text-label-md transition-colors duration-200',
|
||||
current === l.id
|
||||
? 'text-primary border-primary border-b pb-1'
|
||||
: 'text-text-secondary hover:text-on-surface',
|
||||
]}
|
||||
aria-current={current === l.id ? 'page' : undefined}
|
||||
>
|
||||
{l.label}
|
||||
</a>
|
||||
))
|
||||
}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<form
|
||||
action="/search"
|
||||
method="get"
|
||||
class="bg-surface-raised border-border-subtle focus-within:border-primary relative hidden items-center rounded-md border transition-all sm:flex"
|
||||
>
|
||||
<span class="text-text-tertiary absolute left-3">
|
||||
<Icon name="search" size="sm" />
|
||||
</span>
|
||||
<input
|
||||
type="search"
|
||||
name="q"
|
||||
placeholder="Search articles…"
|
||||
class="placeholder:text-text-tertiary w-48 bg-transparent py-1.5 pl-9 pr-3 text-sm outline-none md:w-64"
|
||||
aria-label="Search"
|
||||
/>
|
||||
</form>
|
||||
<a
|
||||
href="/search"
|
||||
class="text-text-secondary hover:text-on-surface p-2 sm:hidden"
|
||||
aria-label="Search"
|
||||
>
|
||||
<Icon name="search" size="md" />
|
||||
</a>
|
||||
<a
|
||||
href="/newsletter"
|
||||
class="bg-primary font-label-md inline-flex h-10 items-center justify-center rounded-md px-5 text-sm text-white transition-all hover:brightness-110 active:scale-[0.98]"
|
||||
>
|
||||
Subscribe
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
@@ -0,0 +1,59 @@
|
||||
---
|
||||
interface Props {
|
||||
/** Selector for the article body to track scroll progress against. */
|
||||
target?: string;
|
||||
/** Sticky offset from the top (matches the fixed nav). */
|
||||
offset?: number;
|
||||
}
|
||||
const { target = 'article', offset = 64 } = Astro.props;
|
||||
---
|
||||
|
||||
<div
|
||||
id="scroll-progress"
|
||||
class="bg-primary fixed left-0 z-[60] h-[2px] transition-[width] duration-75 ease-out"
|
||||
style="top: 64px; width: 0%;"
|
||||
role="progressbar"
|
||||
aria-label="Reading progress"
|
||||
data-target={target}
|
||||
data-offset={offset}
|
||||
>
|
||||
</div>
|
||||
|
||||
<script is:inline>
|
||||
(() => {
|
||||
const bar = document.getElementById('scroll-progress');
|
||||
if (!bar) return;
|
||||
const targetSelector = bar.dataset.target;
|
||||
const offset = Number(bar.dataset.offset || 64);
|
||||
const target = targetSelector ? document.querySelector(targetSelector) : null;
|
||||
|
||||
function update() {
|
||||
let pct = 0;
|
||||
if (target instanceof HTMLElement) {
|
||||
const rect = target.getBoundingClientRect();
|
||||
const total = rect.height - window.innerHeight + offset;
|
||||
const seen = Math.min(Math.max(-rect.top + offset, 0), Math.max(total, 1));
|
||||
pct = (seen / Math.max(total, 1)) * 100;
|
||||
} else {
|
||||
const h = document.documentElement;
|
||||
const total = h.scrollHeight - h.clientHeight;
|
||||
pct = total > 0 ? (h.scrollTop / total) * 100 : 0;
|
||||
}
|
||||
bar.style.width = `${Math.min(100, Math.max(0, pct))}%`;
|
||||
}
|
||||
|
||||
let ticking = false;
|
||||
const onScroll = () => {
|
||||
if (!ticking) {
|
||||
requestAnimationFrame(() => {
|
||||
update();
|
||||
ticking = false;
|
||||
});
|
||||
ticking = true;
|
||||
}
|
||||
};
|
||||
window.addEventListener('scroll', onScroll, { passive: true });
|
||||
window.addEventListener('resize', onScroll);
|
||||
update();
|
||||
})();
|
||||
</script>
|
||||
@@ -0,0 +1,34 @@
|
||||
---
|
||||
import Card from '../ui/Card.astro';
|
||||
import Icon from '../ui/Icon.astro';
|
||||
|
||||
const items = [
|
||||
{
|
||||
icon: 'insights',
|
||||
title: 'Deep Dives',
|
||||
body: 'We don\u2019t do summaries. We explore the implementation details that actually matter.',
|
||||
},
|
||||
{
|
||||
icon: 'shield_person',
|
||||
title: 'Zero Noise',
|
||||
body: 'Carefully curated by senior architects. No AI-generated fluff or sponsored filler.',
|
||||
},
|
||||
{
|
||||
icon: 'hub',
|
||||
title: 'Exclusive Access',
|
||||
body: 'Subscribers get first access to our private community and beta technical tools.',
|
||||
},
|
||||
];
|
||||
---
|
||||
|
||||
<div class="grid grid-cols-1 gap-6 md:grid-cols-3">
|
||||
{
|
||||
items.map((it) => (
|
||||
<Card padding="lg" class="flex flex-col gap-4">
|
||||
<Icon name={it.icon} size="lg" class="text-primary text-3xl" />
|
||||
<h4 class="font-display text-headline-md text-on-surface m-0">{it.title}</h4>
|
||||
<p class="text-text-secondary m-0">{it.body}</p>
|
||||
</Card>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,54 @@
|
||||
---
|
||||
import type { Edition } from '../../lib/types';
|
||||
import { formatDate } from '../../lib/format';
|
||||
import { imageUrl } from '../../lib/payload';
|
||||
import Icon from '../ui/Icon.astro';
|
||||
import Card from '../ui/Card.astro';
|
||||
|
||||
interface Props {
|
||||
edition: Edition | null;
|
||||
}
|
||||
|
||||
const { edition } = Astro.props;
|
||||
const cover = edition?.cover ? imageUrl(edition.cover) : null;
|
||||
---
|
||||
|
||||
<Card padding="none" class="group overflow-hidden">
|
||||
{
|
||||
cover && (
|
||||
<div class="border-border-subtle h-48 overflow-hidden border-b">
|
||||
<img
|
||||
src={cover}
|
||||
alt={`Edition #${edition?.number ?? ''}`}
|
||||
width="640"
|
||||
height="320"
|
||||
loading="lazy"
|
||||
class="h-full w-full object-cover transition-transform duration-700 group-hover:scale-105"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
<div class="space-y-3 p-6">
|
||||
<div class="text-primary font-label-md text-label-md flex items-center gap-2 tracking-wider">
|
||||
<Icon name="terminal" size="sm" />
|
||||
<span>Engineering Architecture</span>
|
||||
</div>
|
||||
<h3 class="font-display text-headline-md text-on-background m-0 text-balance">
|
||||
{edition?.title ?? 'The latest edition of SoftVowels'}
|
||||
</h3>
|
||||
{
|
||||
edition?.intro && (
|
||||
<p class="text-text-secondary text-body-md m-0 line-clamp-2">{edition.intro}</p>
|
||||
)
|
||||
}
|
||||
<div class="border-border-subtle flex items-center justify-between border-t pt-4">
|
||||
<span class="text-label-md text-text-tertiary">
|
||||
{edition ? `Issue #${edition.number} · ${formatDate(edition.publishedAt)}` : 'Weekly'}
|
||||
</span>
|
||||
<div class="text-primary text-label-md flex items-center gap-1">
|
||||
<span>Read Preview</span>
|
||||
<Icon name="chevron_right" size="sm" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
@@ -0,0 +1,106 @@
|
||||
---
|
||||
import Icon from '../ui/Icon.astro';
|
||||
|
||||
interface Props {
|
||||
source?: string;
|
||||
layout?: 'inline' | 'stacked';
|
||||
class?: string;
|
||||
}
|
||||
|
||||
const { source = 'web', layout = 'inline', class: className = '' } = Astro.props;
|
||||
const endpoint = `${import.meta.env.PUBLIC_PAYLOAD_API || 'http://localhost:3000'}/api/subscribers`;
|
||||
---
|
||||
|
||||
<form
|
||||
method="post"
|
||||
action={endpoint}
|
||||
data-subscribe
|
||||
data-source={source}
|
||||
class:list={[
|
||||
'flex w-full gap-3',
|
||||
layout === 'stacked' ? 'flex-col' : 'flex-col sm:flex-row',
|
||||
className,
|
||||
]}
|
||||
>
|
||||
<label class="sr-only" for={`email-${source}`}>Email</label>
|
||||
<input
|
||||
id={`email-${source}`}
|
||||
type="email"
|
||||
name="email"
|
||||
required
|
||||
placeholder="you@company.com"
|
||||
class:list={[
|
||||
'border-border-subtle flex-1 rounded-md border bg-white outline-none transition-colors',
|
||||
'focus:border-primary text-body-md',
|
||||
layout === 'stacked' ? 'h-14 px-4' : 'h-10 px-4',
|
||||
]}
|
||||
autocomplete="email"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
class:list={[
|
||||
'bg-primary font-label-md inline-flex items-center justify-center gap-2 rounded-md text-sm text-white transition-all hover:brightness-110 active:scale-[0.98] disabled:opacity-70',
|
||||
layout === 'stacked' ? 'text-label-md h-14 px-7' : 'h-10 px-5',
|
||||
]}
|
||||
>
|
||||
<span data-subscribe-label>Subscribe</span>
|
||||
<span data-subscribe-spinner class="hidden">
|
||||
<Icon name="progress_activity" size="sm" class="animate-spin" />
|
||||
</span>
|
||||
</button>
|
||||
<p
|
||||
data-subscribe-status
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
class="text-text-tertiary m-0 basis-full text-[12px]"
|
||||
>
|
||||
Zero spam. One email every Tuesday. Unsubscribe anytime.
|
||||
</p>
|
||||
</form>
|
||||
|
||||
<script is:inline>
|
||||
(() => {
|
||||
const forms = document.querySelectorAll('form[data-subscribe]');
|
||||
forms.forEach((form) => {
|
||||
if (form.dataset.bound === '1') return;
|
||||
form.dataset.bound = '1';
|
||||
form.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const status = form.querySelector('[data-subscribe-status]');
|
||||
const label = form.querySelector('[data-subscribe-label]');
|
||||
const spinner = form.querySelector('[data-subscribe-spinner]');
|
||||
const button = form.querySelector('button[type="submit"]');
|
||||
const input = form.querySelector('input[name="email"]');
|
||||
const source = form.dataset.source || 'web';
|
||||
const action = form.getAttribute('action');
|
||||
if (!input || !action || !status || !label || !spinner || !button) return;
|
||||
if (!input.value) return;
|
||||
button.disabled = true;
|
||||
spinner.classList.remove('hidden');
|
||||
try {
|
||||
const res = await fetch(action, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
body: JSON.stringify({ email: input.value, source }),
|
||||
});
|
||||
if (res.ok) {
|
||||
label.textContent = 'Subscribed';
|
||||
status.textContent = 'Welcome aboard. See you Tuesday.';
|
||||
status.classList.add('text-success');
|
||||
} else {
|
||||
const data = await res.json().catch(() => null);
|
||||
const msg = data?.errors?.[0]?.message || 'Subscription failed. Try again.';
|
||||
status.textContent = msg;
|
||||
status.classList.add('text-error');
|
||||
}
|
||||
} catch {
|
||||
status.textContent = 'Network error. Try again.';
|
||||
status.classList.add('text-error');
|
||||
} finally {
|
||||
spinner.classList.add('hidden');
|
||||
button.disabled = false;
|
||||
}
|
||||
});
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
@@ -0,0 +1,139 @@
|
||||
---
|
||||
import type { Article, Author } from '../../lib/types';
|
||||
import { formatDate, readingMinutes, categoryOf } from '../../lib/format';
|
||||
import Icon from '../ui/Icon.astro';
|
||||
|
||||
interface Props {
|
||||
q: string;
|
||||
articles: Article[];
|
||||
authors: Author[];
|
||||
}
|
||||
|
||||
const { q, articles, authors } = Astro.props;
|
||||
const hasResults = articles.length > 0 || authors.length > 0;
|
||||
---
|
||||
|
||||
<section class="mb-32 mt-16 space-y-12">
|
||||
{
|
||||
!hasResults && q && (
|
||||
<div class="text-text-tertiary py-16 text-center">
|
||||
<Icon name="search_off" size="lg" class="mb-4 text-5xl" />
|
||||
<p class="font-display text-headline-md text-on-surface">
|
||||
No matches for “{q}”
|
||||
</p>
|
||||
<p class="text-body-md">
|
||||
Try a different keyword, or browse{' '}
|
||||
<a class="text-primary hover:underline" href="/articles">
|
||||
all articles
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
{
|
||||
!q && (
|
||||
<div class="text-text-tertiary py-16 text-center">
|
||||
<Icon name="manage_search" size="lg" class="mb-4 text-5xl" />
|
||||
<p class="font-display text-headline-md text-on-surface">Search SoftVowels</p>
|
||||
<p class="text-body-md">Type a topic, technology, or author name.</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
{
|
||||
articles.length > 0 && (
|
||||
<section>
|
||||
<h2 class="font-display text-headline-md text-on-surface m-0 mb-6 flex items-center gap-2">
|
||||
Articles
|
||||
<span class="text-text-tertiary text-label-md ml-2 font-sans">
|
||||
{articles.length} matches
|
||||
</span>
|
||||
</h2>
|
||||
<div class="space-y-4">
|
||||
{articles.map((a) => {
|
||||
const cat = categoryOf(a);
|
||||
return (
|
||||
<a
|
||||
href={`/articles/${a.slug}`}
|
||||
class="bg-surface border-border-subtle hover:bg-surface-floated group block rounded-lg border p-6 transition-all hover:shadow-sm"
|
||||
>
|
||||
<div class="flex flex-col justify-between gap-4 md:flex-row md:items-center">
|
||||
<div>
|
||||
<div class="mb-2 flex items-center gap-3">
|
||||
<span class="text-primary text-[10px] font-bold uppercase tracking-widest">
|
||||
{cat?.name ?? 'Article'}
|
||||
</span>
|
||||
<span class="text-text-tertiary text-[10px]">·</span>
|
||||
<span class="text-text-tertiary text-[10px]">
|
||||
{formatDate(a.publishedAt)}
|
||||
</span>
|
||||
<span class="text-text-tertiary text-[10px]">·</span>
|
||||
<span class="text-text-tertiary text-[10px]">{readingMinutes(a)} min</span>
|
||||
</div>
|
||||
<h3 class="font-display text-headline-md text-on-surface group-hover:text-primary m-0 text-balance transition-colors">
|
||||
{a.title}
|
||||
</h3>
|
||||
{a.excerpt && (
|
||||
<p class="text-text-secondary text-body-md m-0 mt-1 line-clamp-1">
|
||||
{a.excerpt}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div class="hidden md:block">
|
||||
<Icon
|
||||
name="arrow_forward"
|
||||
size="md"
|
||||
class="text-text-tertiary transition-transform group-hover:translate-x-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
{
|
||||
authors.length > 0 && (
|
||||
<section>
|
||||
<h2 class="font-display text-headline-md text-on-surface m-0 mb-6 flex items-center gap-2">
|
||||
Authors
|
||||
<span class="text-text-tertiary text-label-md ml-2 font-sans">
|
||||
{authors.length} matches
|
||||
</span>
|
||||
</h2>
|
||||
<div class="grid grid-cols-1 gap-6 md:grid-cols-3">
|
||||
{authors.map((au) => (
|
||||
<a
|
||||
href={`/authors/${au.slug}`}
|
||||
class="bg-surface border-border-subtle hover:border-primary flex items-center gap-4 rounded-lg border p-6 transition-colors"
|
||||
>
|
||||
{au.avatar ? (
|
||||
<img
|
||||
src={
|
||||
typeof au.avatar === 'string' ? au.avatar : (au.avatar as { url: string }).url
|
||||
}
|
||||
alt={au.name}
|
||||
width="48"
|
||||
height="48"
|
||||
loading="lazy"
|
||||
class="bg-surface-container h-12 w-12 rounded-full object-cover grayscale"
|
||||
/>
|
||||
) : (
|
||||
<div class="bg-surface-container h-12 w-12 rounded-full" aria-hidden="true" />
|
||||
)}
|
||||
<div>
|
||||
<h4 class="font-label-md text-label-md text-on-surface m-0">{au.name}</h4>
|
||||
{au.role && <p class="text-text-tertiary m-0 text-xs">{au.role}</p>}
|
||||
</div>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
</section>
|
||||
@@ -0,0 +1,56 @@
|
||||
---
|
||||
type Variant = 'primary' | 'secondary' | 'ghost' | 'inverse';
|
||||
type Size = 'sm' | 'md' | 'lg';
|
||||
|
||||
interface Props {
|
||||
variant?: Variant;
|
||||
size?: Size;
|
||||
href?: string;
|
||||
type?: 'button' | 'submit' | 'reset';
|
||||
class?: string;
|
||||
ariaLabel?: string;
|
||||
disabled?: boolean;
|
||||
id?: string;
|
||||
}
|
||||
|
||||
const {
|
||||
variant = 'primary',
|
||||
size = 'md',
|
||||
href,
|
||||
type = 'button',
|
||||
class: className = '',
|
||||
ariaLabel,
|
||||
disabled,
|
||||
id,
|
||||
} = Astro.props;
|
||||
|
||||
const base =
|
||||
'inline-flex items-center justify-center gap-2 font-label-md rounded-md transition-all duration-200 select-none disabled:opacity-50 disabled:cursor-not-allowed active:scale-[0.98]';
|
||||
|
||||
const sizes: Record = {
|
||||
sm: 'h-9 px-4 text-[13px]',
|
||||
md: 'h-10 px-5 text-sm',
|
||||
lg: 'h-12 px-7 text-label-md',
|
||||
};
|
||||
|
||||
const variants: Record = {
|
||||
primary: 'bg-primary text-white hover:brightness-110',
|
||||
secondary: 'bg-white text-on-surface border border-border-subtle hover:border-primary',
|
||||
ghost: 'text-primary hover:bg-primary/5',
|
||||
inverse: 'bg-inverse-surface text-white hover:bg-primary',
|
||||
};
|
||||
|
||||
const cls = [base, sizes[size], variants[variant], className].filter(Boolean).join(' ');
|
||||
---
|
||||
|
||||
{
|
||||
href ? (
|
||||
<a href={href} class={cls} aria-label={ariaLabel} id={id}>
|
||||
<slot />
|
||||
</a>
|
||||
) : (
|
||||
<button type={type} class={cls} aria-label={ariaLabel} disabled={disabled} id={id}>
|
||||
<slot />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
---
|
||||
interface Props {
|
||||
variant?: 'raised' | 'flat';
|
||||
padding?: 'sm' | 'md' | 'lg' | 'none';
|
||||
interactive?: boolean;
|
||||
href?: string;
|
||||
class?: string;
|
||||
ariaLabel?: string;
|
||||
}
|
||||
|
||||
const {
|
||||
variant = 'raised',
|
||||
padding = 'md',
|
||||
interactive = false,
|
||||
href,
|
||||
class: className = '',
|
||||
ariaLabel,
|
||||
} = Astro.props;
|
||||
|
||||
const surfaces: Record = {
|
||||
raised: 'bg-surface-raised',
|
||||
flat: 'bg-surface-container',
|
||||
};
|
||||
const pads: Record = {
|
||||
none: '',
|
||||
sm: 'p-4',
|
||||
md: 'p-6',
|
||||
lg: 'p-8',
|
||||
};
|
||||
const base = `${surfaces[variant]} ${pads[padding]} border border-border-subtle rounded-lg`;
|
||||
const hover = interactive ? 'transition-all duration-200 hover:border-primary' : '';
|
||||
const cls = [base, hover, className].filter(Boolean).join(' ');
|
||||
---
|
||||
|
||||
{
|
||||
href ? (
|
||||
<a href={href} class={cls} aria-label={ariaLabel}>
|
||||
<slot />
|
||||
</a>
|
||||
) : (
|
||||
<div class={cls}>
|
||||
<slot />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
---
|
||||
type Variant = 'solid' | 'outline' | 'soft' | 'mono';
|
||||
|
||||
interface Props {
|
||||
variant?: Variant;
|
||||
href?: string;
|
||||
class?: string;
|
||||
ariaLabel?: string;
|
||||
}
|
||||
|
||||
const { variant = 'solid', href, class: className = '', ariaLabel } = Astro.props;
|
||||
const base =
|
||||
'inline-flex items-center gap-1.5 rounded-full font-label-md tracking-wider uppercase text-[11px] px-2.5 py-1 transition-colors duration-200';
|
||||
const variants: Record = {
|
||||
solid: 'bg-primary text-white',
|
||||
outline: 'border border-primary/40 text-primary',
|
||||
soft: 'bg-primary/10 text-primary',
|
||||
mono: 'bg-on-surface text-background font-mono tracking-tighter',
|
||||
};
|
||||
const cls = [base, variants[variant], className].filter(Boolean).join(' ');
|
||||
---
|
||||
|
||||
{
|
||||
href ? (
|
||||
<a href={href} class={cls} aria-label={ariaLabel}>
|
||||
<slot />
|
||||
</a>
|
||||
) : (
|
||||
<span class={cls}>
|
||||
<slot />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
---
|
||||
import { Icon } from 'astro-icon/components';
|
||||
|
||||
interface Props {
|
||||
name: string;
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
class?: string;
|
||||
filled?: boolean;
|
||||
ariaLabel?: string;
|
||||
}
|
||||
|
||||
const { name, size = 'md', class: className = '', filled = false, ariaLabel } = Astro.props;
|
||||
const sizes: Record = {
|
||||
sm: 'text-[18px]',
|
||||
md: 'text-[20px]',
|
||||
lg: 'text-[24px]',
|
||||
};
|
||||
const iconifyName = name.replace(/_/g, '-');
|
||||
const cls =
|
||||
`material-symbols-outlined ${sizes[size]} ${filled ? 'fill-1' : ''} ${className}`.trim();
|
||||
---
|
||||
|
||||
<Icon
|
||||
name={`material-symbols:${iconifyName}`}
|
||||
class={cls}
|
||||
aria-label={ariaLabel}
|
||||
role={ariaLabel ? 'img' : 'presentation'}
|
||||
/>
|
||||
@@ -0,0 +1,33 @@
|
||||
---
|
||||
interface Props {
|
||||
eyebrow?: string;
|
||||
title?: string;
|
||||
href?: string;
|
||||
level?: 2 | 3;
|
||||
class?: string;
|
||||
}
|
||||
const { eyebrow, title, href, level = 2, class: className = '' } = Astro.props;
|
||||
const Tag = `h${level}` as 'h2' | 'h3';
|
||||
const wrapper = `flex items-end justify-between gap-6 mb-6 pb-4 border-b border-border-subtle ${className}`;
|
||||
---
|
||||
|
||||
<div class={wrapper}>
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="bg-primary block h-1.5 w-1.5"></span>
|
||||
<Tag class="font-headline-md text-headline-md text-on-surface m-0">
|
||||
{eyebrow}
|
||||
{title}
|
||||
</Tag>
|
||||
</div>
|
||||
{
|
||||
href && (
|
||||
<a
|
||||
class="text-text-tertiary hover:text-primary transition-colors"
|
||||
href={href}
|
||||
aria-label={`See all ${eyebrow}`}
|
||||
>
|
||||
<span class="material-symbols-outlined text-[20px]">arrow_forward</span>
|
||||
</a>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
Vendored
+13
@@ -0,0 +1,13 @@
|
||||
/// <reference path="../.astro/types.d.ts" />
|
||||
/// <reference types="astro/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly PUBLIC_SITE_URL: string;
|
||||
readonly PUBLIC_PAYLOAD_API: string;
|
||||
readonly PAYLOAD_API_INTERNAL: string;
|
||||
readonly REVALIDATE_TOKEN: string;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
---
|
||||
import BaseLayout from './BaseLayout.astro';
|
||||
import ScrollProgress from '../components/layout/ScrollProgress.astro';
|
||||
import type { SeoInput } from '../lib/seo';
|
||||
|
||||
interface Props {
|
||||
seo: SeoInput;
|
||||
current?:
|
||||
| 'articles'
|
||||
| 'changelog'
|
||||
| 'philosophy'
|
||||
| 'categories'
|
||||
| 'pricing'
|
||||
| 'newsletter'
|
||||
| 'search'
|
||||
| 'home';
|
||||
/** When true the 2px primary scroll progress bar is rendered. */
|
||||
progress?: boolean;
|
||||
bodyClass?: string;
|
||||
}
|
||||
|
||||
const { seo: s, current = 'articles', progress = true, bodyClass = '' } = Astro.props;
|
||||
---
|
||||
|
||||
<BaseLayout seo={s} current={current} bodyClass={bodyClass}>
|
||||
{progress && <ScrollProgress target="article" offset={64} />}
|
||||
<slot />
|
||||
</BaseLayout>
|
||||
@@ -0,0 +1,97 @@
|
||||
---
|
||||
import '../styles/global.css';
|
||||
import Nav from '../components/layout/Nav.astro';
|
||||
import Footer from '../components/layout/Footer.astro';
|
||||
import BottomNav from '../components/layout/BottomNav.astro';
|
||||
import { seo, type SeoInput } from '../lib/seo';
|
||||
|
||||
interface Props {
|
||||
seo: SeoInput;
|
||||
current?:
|
||||
| 'articles'
|
||||
| 'changelog'
|
||||
| 'philosophy'
|
||||
| 'categories'
|
||||
| 'pricing'
|
||||
| 'newsletter'
|
||||
| 'search'
|
||||
| 'home'
|
||||
| 'saved'
|
||||
| 'account'
|
||||
| 'sitemap';
|
||||
/** When true the bottom mobile tab bar is rendered (default true). */
|
||||
bottomNav?: boolean;
|
||||
/** When true the main content gets extra bottom padding to clear the mobile tab bar. */
|
||||
padForBottomNav?: boolean;
|
||||
bodyClass?: string;
|
||||
}
|
||||
|
||||
const {
|
||||
seo: s,
|
||||
current = 'home',
|
||||
bottomNav = true,
|
||||
padForBottomNav = true,
|
||||
bodyClass = '',
|
||||
} = Astro.props;
|
||||
const meta = seo(s);
|
||||
---
|
||||
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="generator" content={Astro.generator} />
|
||||
<meta name="theme-color" content="#ffffff" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="alternate" type="application/rss+xml" title="SoftVowels" href="/rss.xml" />
|
||||
<link rel="sitemap" href="/sitemap-index.xml" />
|
||||
|
||||
<title>{meta.title}</title>
|
||||
<meta name="description" content={meta.description} />
|
||||
<link rel="canonical" href={meta.canonical} />
|
||||
|
||||
<meta property="og:title" content={meta.openGraph.title} />
|
||||
<meta property="og:description" content={meta.openGraph.description} />
|
||||
<meta property="og:url" content={meta.openGraph.url} />
|
||||
<meta property="og:site_name" content={meta.openGraph.siteName} />
|
||||
<meta property="og:image" content={meta.openGraph.images[0]?.url} />
|
||||
<meta property="og:type" content={meta.openGraph.type} />
|
||||
{
|
||||
meta.openGraph.publishedTime && (
|
||||
<meta property="article:published_time" content={meta.openGraph.publishedTime} />
|
||||
)
|
||||
}
|
||||
{meta.openGraph.authors?.map((a) => <meta property="article:author" content={a} />)}
|
||||
{meta.openGraph.tags?.map((t) => <meta property="article:tag" content={t} />)}
|
||||
|
||||
<meta name="twitter:card" content={meta.twitter.card} />
|
||||
<meta name="twitter:title" content={meta.twitter.title} />
|
||||
<meta name="twitter:description" content={meta.twitter.description} />
|
||||
<meta name="twitter:image" content={meta.twitter.images[0]} />
|
||||
</head>
|
||||
<body class:list={[bodyClass, padForBottomNav && bottomNav ? 'pb-16 md:pb-0' : '']}>
|
||||
<a
|
||||
href="#main"
|
||||
class="focus:text-on-surface focus:border-primary sr-only focus:not-sr-only focus:fixed focus:left-3 focus:top-3 focus:z-[100] focus:rounded-md focus:border focus:bg-white focus:px-3 focus:py-2"
|
||||
>
|
||||
Skip to content
|
||||
</a>
|
||||
<Nav
|
||||
current={current as
|
||||
| 'articles'
|
||||
| 'changelog'
|
||||
| 'philosophy'
|
||||
| 'categories'
|
||||
| 'pricing'
|
||||
| 'newsletter'
|
||||
| 'search'
|
||||
| 'home'}
|
||||
/>
|
||||
<main id="main" class="pt-16">
|
||||
<slot />
|
||||
</main>
|
||||
<Footer />
|
||||
{bottomNav && <BottomNav current={current === 'home' ? 'home' : (current as 'search')} />}
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { Article, Author, Category } from './types';
|
||||
import { imageUrl } from './payload';
|
||||
|
||||
export function formatDate(d: string | Date, opts: Intl.DateTimeFormatOptions = {}): string {
|
||||
const date = typeof d === 'string' ? new Date(d) : d;
|
||||
return new Intl.DateTimeFormat('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
...opts,
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
export function readingMinutes(article: Article): number {
|
||||
if (article.readingMinutes) return article.readingMinutes;
|
||||
const body = article.body as { root?: { children?: unknown[] } } | null;
|
||||
const n = JSON.stringify(body ?? '').split(/\s+/).length;
|
||||
return Math.max(1, Math.round(n / 220));
|
||||
}
|
||||
|
||||
export function categoryOf(article: Article): Category | null {
|
||||
return typeof article.category === 'object' ? article.category : null;
|
||||
}
|
||||
|
||||
export function tagsOf(article: Article) {
|
||||
return Array.isArray(article.tags) ? article.tags.filter((t) => typeof t === 'object') : [];
|
||||
}
|
||||
|
||||
export function authorOf(article: Article): Author | null {
|
||||
return typeof article.author === 'object' ? article.author : null;
|
||||
}
|
||||
|
||||
export function avatarOf(author: Author | null): string | null {
|
||||
if (!author) return null;
|
||||
return imageUrl(author.avatar);
|
||||
}
|
||||
|
||||
export function coverOf(
|
||||
article: Article,
|
||||
size: 'card' | 'hero' | 'thumbnail' = 'card',
|
||||
): string | null {
|
||||
const cover = article.coverImage;
|
||||
if (!cover || typeof cover === 'string') return imageUrl(cover);
|
||||
const sized = cover.sizes?.[size]?.url;
|
||||
if (sized) {
|
||||
return sized.startsWith('http')
|
||||
? sized
|
||||
: `${(cover.sizes as Record<string, { url: string }>)[size]?.url}`;
|
||||
}
|
||||
return imageUrl(cover);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { convertLexicalToHTML, defaultHTMLConverters } from '@payloadcms/richtext-lexical/html';
|
||||
import type { SerializedEditorState } from 'lexical';
|
||||
|
||||
export type LexicalDoc = SerializedEditorState | unknown;
|
||||
|
||||
export function lexicalToHtml(doc: LexicalDoc): string {
|
||||
if (!doc || typeof doc !== 'object') return '';
|
||||
const data = doc as SerializedEditorState;
|
||||
try {
|
||||
return convertLexicalToHTML({ data, converters: defaultHTMLConverters });
|
||||
} catch (err) {
|
||||
return `<p class="text-error">Failed to render content: ${(err as Error).message}</p>`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
import type { Article, Author, Category, Edition } from './types';
|
||||
|
||||
const API = (import.meta.env.PAYLOAD_API_INTERNAL ||
|
||||
import.meta.env.PUBLIC_PAYLOAD_API ||
|
||||
'http://localhost:3000') as string;
|
||||
|
||||
const ORIGIN = (import.meta.env.PUBLIC_SITE_URL || 'http://localhost:4321') as string;
|
||||
|
||||
interface ListOpts {
|
||||
limit?: number;
|
||||
page?: number;
|
||||
sort?: string;
|
||||
where?: Record<string, unknown>;
|
||||
depth?: number;
|
||||
draft?: boolean;
|
||||
}
|
||||
|
||||
function qs(opts: ListOpts = {}): string {
|
||||
const sp = new URLSearchParams();
|
||||
if (opts.limit) sp.set('limit', String(opts.limit));
|
||||
if (opts.page) sp.set('page', String(opts.page));
|
||||
if (opts.sort) sp.set('sort', opts.sort);
|
||||
if (opts.depth) sp.set('depth', String(opts.depth));
|
||||
if (opts.draft) sp.set('draft', 'true');
|
||||
if (opts.where) sp.set('where', JSON.stringify(opts.where));
|
||||
const s = sp.toString();
|
||||
return s ? `?${s}` : '';
|
||||
}
|
||||
|
||||
async function get<T>(path: string, opts: ListOpts = {}): Promise<T> {
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(`${API}${path}${qs(opts)}`, {
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
} catch (err) {
|
||||
if (isBuildTime() && process.env.PAYLOAD_BUILD_TOLERANT !== 'false') {
|
||||
console.warn(`[payload] ${path} unreachable at build time: ${(err as Error).message}`);
|
||||
return emptyResult() as T;
|
||||
}
|
||||
throw new Error(`Payload unreachable for ${path}: ${(err as Error).message}`);
|
||||
}
|
||||
if (!res.ok) {
|
||||
if (isBuildTime() && process.env.PAYLOAD_BUILD_TOLERANT !== 'false') {
|
||||
console.warn(`[payload] ${path} returned ${res.status} at build time`);
|
||||
return emptyResult() as T;
|
||||
}
|
||||
throw new Error(`Payload ${res.status} ${res.statusText} for ${path}`);
|
||||
}
|
||||
return (await res.json()) as T;
|
||||
}
|
||||
|
||||
function isBuildTime(): boolean {
|
||||
return process.env.NODE_ENV === 'production' || process.env.ASTRO_BUILD === '1';
|
||||
}
|
||||
|
||||
function emptyResult(): Paginated<unknown> {
|
||||
return { docs: [], totalDocs: 0, totalPages: 0, page: 1, hasNextPage: false, hasPrevPage: false };
|
||||
}
|
||||
|
||||
export const payloadOrigin = () => ORIGIN;
|
||||
export const payloadApiOrigin = () => API;
|
||||
|
||||
export interface Paginated<T> {
|
||||
docs: T[];
|
||||
totalDocs: number;
|
||||
totalPages: number;
|
||||
page: number;
|
||||
hasNextPage: boolean;
|
||||
hasPrevPage: boolean;
|
||||
}
|
||||
|
||||
const PUBLISHED = { status: { equals: 'published' } };
|
||||
|
||||
export async function getCategories(): Promise<Category[]> {
|
||||
const r = await get<Paginated<Category>>('/api/categories', { limit: 100, sort: 'order' });
|
||||
return r.docs;
|
||||
}
|
||||
|
||||
export async function getCategoryBySlug(slug: string): Promise<Category | null> {
|
||||
const r = await get<Paginated<Category>>('/api/categories', {
|
||||
where: { and: [{ slug: { equals: slug } }] },
|
||||
limit: 1,
|
||||
depth: 1,
|
||||
});
|
||||
return r.docs[0] ?? null;
|
||||
}
|
||||
|
||||
export async function getAuthors(): Promise<Author[]> {
|
||||
const r = await get<Paginated<Author>>('/api/authors', { limit: 100, sort: 'name' });
|
||||
return r.docs;
|
||||
}
|
||||
|
||||
export async function getAuthorBySlug(slug: string): Promise<Author | null> {
|
||||
const r = await get<Paginated<Author>>('/api/authors', {
|
||||
where: { and: [{ slug: { equals: slug } }] },
|
||||
limit: 1,
|
||||
depth: 1,
|
||||
});
|
||||
return r.docs[0] ?? null;
|
||||
}
|
||||
|
||||
export async function getArticles(
|
||||
opts: {
|
||||
limit?: number;
|
||||
page?: number;
|
||||
categorySlug?: string;
|
||||
tagSlug?: string;
|
||||
featured?: boolean;
|
||||
} = {},
|
||||
): Promise<Paginated<Article>> {
|
||||
const where: Record<string, unknown>[] = [PUBLISHED];
|
||||
if (opts.categorySlug) where.push({ 'category.slug': { equals: opts.categorySlug } });
|
||||
if (opts.tagSlug) where.push({ 'tags.slug': { equals: opts.tagSlug } });
|
||||
if (opts.featured) where.push({ featured: { equals: true } });
|
||||
|
||||
return get<Paginated<Article>>('/api/articles', {
|
||||
limit: opts.limit ?? 24,
|
||||
page: opts.page ?? 1,
|
||||
sort: '-publishedAt',
|
||||
depth: 1,
|
||||
where: { and: where },
|
||||
});
|
||||
}
|
||||
|
||||
export async function getArticleBySlug(slug: string): Promise<Article | null> {
|
||||
const r = await get<Paginated<Article>>('/api/articles', {
|
||||
where: { and: [PUBLISHED, { slug: { equals: slug } }] },
|
||||
limit: 1,
|
||||
depth: 2,
|
||||
});
|
||||
return r.docs[0] ?? null;
|
||||
}
|
||||
|
||||
export async function getAllArticleSlugs(): Promise<string[]> {
|
||||
const r = await get<Paginated<Article>>('/api/articles', {
|
||||
limit: 1000,
|
||||
sort: '-publishedAt',
|
||||
where: PUBLISHED,
|
||||
});
|
||||
return r.docs.map((a) => a.slug);
|
||||
}
|
||||
|
||||
export async function getRelated(article: Article, limit = 3): Promise<Article[]> {
|
||||
const cat = typeof article.category === 'object' ? article.category.slug : article.category;
|
||||
const r = await get<Paginated<Article>>('/api/articles', {
|
||||
limit,
|
||||
sort: '-publishedAt',
|
||||
where: {
|
||||
and: [PUBLISHED, { 'category.slug': { equals: cat } }, { id: { not_equals: article.id } }],
|
||||
},
|
||||
});
|
||||
return r.docs;
|
||||
}
|
||||
|
||||
export async function getPopular(limit = 3): Promise<Article[]> {
|
||||
const r = await get<Paginated<Article>>('/api/articles', {
|
||||
limit,
|
||||
sort: '-publishedAt',
|
||||
where: PUBLISHED,
|
||||
});
|
||||
return r.docs;
|
||||
}
|
||||
|
||||
export async function getEditorsPicks(limit = 3): Promise<Article[]> {
|
||||
const r = await get<Paginated<Article>>('/api/articles', {
|
||||
limit,
|
||||
where: { and: [PUBLISHED, { featured: { equals: true } }] },
|
||||
sort: 'featuredRank',
|
||||
});
|
||||
return r.docs;
|
||||
}
|
||||
|
||||
export async function search(q: string): Promise<{ articles: Article[]; authors: Author[] }> {
|
||||
if (!q || q.length < 2) return { articles: [], authors: [] };
|
||||
const like = { like: q };
|
||||
let ar: Paginated<Article> = emptyResult() as Paginated<Article>;
|
||||
let au: Paginated<Author> = emptyResult() as Paginated<Author>;
|
||||
try {
|
||||
[ar, au] = await Promise.all([
|
||||
get<Paginated<Article>>('/api/articles', {
|
||||
limit: 12,
|
||||
sort: '-publishedAt',
|
||||
where: { and: [PUBLISHED, { or: [{ title: like }, { excerpt: like }] }] },
|
||||
}),
|
||||
get<Paginated<Author>>('/api/authors', {
|
||||
limit: 6,
|
||||
where: { or: [{ name: like }, { role: like }] },
|
||||
}),
|
||||
]);
|
||||
} catch (e) {
|
||||
console.warn(`[payload] search("${q}") failed: ${(e as Error).message}`);
|
||||
}
|
||||
return { articles: ar.docs, authors: au.docs };
|
||||
}
|
||||
|
||||
export async function getLatestEdition(): Promise<Edition | null> {
|
||||
try {
|
||||
const r = await get<Paginated<Edition>>('/api/editions', {
|
||||
limit: 1,
|
||||
sort: '-publishedAt',
|
||||
depth: 1,
|
||||
});
|
||||
return r.docs[0] ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export interface SubscribeResult {
|
||||
ok: boolean;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export async function subscribe(email: string, source = 'web'): Promise<SubscribeResult> {
|
||||
const url = `${API}/api/subscribers`;
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
body: JSON.stringify({ email, source }),
|
||||
});
|
||||
if (res.ok) return { ok: true, message: 'Subscribed. See you Tuesday.' };
|
||||
const data = (await res.json().catch(() => null)) as { errors?: { message: string }[] } | null;
|
||||
const msg = data?.errors?.[0]?.message || 'Could not subscribe. Try again.';
|
||||
return { ok: false, message: msg };
|
||||
} catch {
|
||||
return { ok: false, message: 'Network error. Try again.' };
|
||||
}
|
||||
}
|
||||
|
||||
export function imageUrl(m: unknown): string | null {
|
||||
if (!m) return null;
|
||||
if (typeof m === 'string') return m;
|
||||
if (typeof m === 'object' && m && 'url' in m) {
|
||||
const url = (m as { url: string }).url;
|
||||
if (url.startsWith('http')) return url;
|
||||
return `${API}${url}`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
const SITE = (import.meta.env.PUBLIC_SITE_URL || 'https://softvowels.example') as string;
|
||||
|
||||
export interface SeoInput {
|
||||
title: string;
|
||||
description?: string;
|
||||
image?: string | null;
|
||||
url?: string;
|
||||
type?: 'website' | 'article';
|
||||
publishedTime?: string;
|
||||
author?: string;
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
export function seo(input: SeoInput) {
|
||||
const title = input.title.endsWith('· SoftVowels') ? input.title : `${input.title} · SoftVowels`;
|
||||
const description =
|
||||
input.description ?? 'Engineering-focused insights for the modern software architect.';
|
||||
const url = input.url ?? SITE;
|
||||
const image = input.image ?? `${SITE}/og-default.png`;
|
||||
return {
|
||||
title,
|
||||
description,
|
||||
canonical: url,
|
||||
openGraph: {
|
||||
title,
|
||||
description,
|
||||
url,
|
||||
siteName: 'SoftVowels',
|
||||
images: [{ url: image }],
|
||||
locale: 'en_US',
|
||||
type: input.type ?? 'website',
|
||||
...(input.publishedTime ? { publishedTime: input.publishedTime } : {}),
|
||||
...(input.author ? { authors: [input.author] } : {}),
|
||||
...(input.tags?.length ? { tags: input.tags } : {}),
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
title,
|
||||
description,
|
||||
images: [image],
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
export interface Media {
|
||||
id: string;
|
||||
url: string;
|
||||
alt?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
mimeType?: string;
|
||||
sizes?: {
|
||||
thumbnail?: { url: string; width: number; height: number };
|
||||
card?: { url: string; width: number; height: number };
|
||||
hero?: { url: string; width: number; height: number };
|
||||
};
|
||||
}
|
||||
|
||||
export interface Author {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
role?: string;
|
||||
bio?: string;
|
||||
avatar?: Media | string | null;
|
||||
socials?: {
|
||||
twitter?: string;
|
||||
github?: string;
|
||||
site?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface Category {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
description?: string;
|
||||
icon?: string;
|
||||
color?: string;
|
||||
order?: number;
|
||||
}
|
||||
|
||||
export interface Tag {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
}
|
||||
|
||||
export interface Article {
|
||||
id: string;
|
||||
title: string;
|
||||
slug: string;
|
||||
excerpt?: string;
|
||||
body: unknown; // Lexical SerializedEditorState — rendered via lib/lexical.ts
|
||||
coverImage?: Media | string | null;
|
||||
category: Category | string;
|
||||
tags?: (Tag | string)[];
|
||||
author: Author | string;
|
||||
status: 'draft' | 'published';
|
||||
publishedAt: string;
|
||||
readingMinutes?: number;
|
||||
featured?: boolean;
|
||||
featuredRank?: number;
|
||||
seo?: {
|
||||
title?: string;
|
||||
description?: string;
|
||||
ogImage?: Media | string | null;
|
||||
};
|
||||
}
|
||||
|
||||
export interface Edition {
|
||||
id: string;
|
||||
number: number;
|
||||
title: string;
|
||||
intro?: string;
|
||||
publishedAt: string;
|
||||
cover?: Media | string | null;
|
||||
articles?: (Article | string)[];
|
||||
}
|
||||
|
||||
export type Resolved<T> = T extends string | number | null | undefined
|
||||
? T
|
||||
: T extends object
|
||||
? { [K in keyof T]: Resolved<T[K]> }
|
||||
: T;
|
||||
@@ -0,0 +1,40 @@
|
||||
---
|
||||
import BaseLayout from '../layouts/BaseLayout.astro';
|
||||
import Card from '../components/ui/Card.astro';
|
||||
import Icon from '../components/ui/Icon.astro';
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
seo={{
|
||||
title: '404 — Not found',
|
||||
description: 'The page you were looking for could not be found.',
|
||||
}}
|
||||
current="home"
|
||||
>
|
||||
<section class="mx-auto max-w-[800px] px-4 pb-32 pt-24 text-center md:px-10">
|
||||
<span class="text-primary text-label-md font-mono uppercase tracking-widest">Error 404</span>
|
||||
<h1 class="font-display text-display-lg text-on-surface m-0 mb-6 mt-3 text-balance">
|
||||
This page wandered off.
|
||||
</h1>
|
||||
<p class="text-text-secondary text-body-lg mb-12">
|
||||
The link may be broken, or the article may have been moved. Try one of these instead:
|
||||
</p>
|
||||
<div class="grid grid-cols-1 gap-4 text-left sm:grid-cols-3">
|
||||
<Card padding="lg" interactive href="/" class="flex flex-col gap-2">
|
||||
<Icon name="home" size="md" class="text-primary" />
|
||||
<h3 class="font-display text-label-md text-on-surface m-0">Home</h3>
|
||||
<p class="text-text-tertiary m-0 text-xs">Latest intelligence</p>
|
||||
</Card>
|
||||
<Card padding="lg" interactive href="/articles" class="flex flex-col gap-2">
|
||||
<Icon name="article" size="md" class="text-primary" />
|
||||
<h3 class="font-display text-label-md text-on-surface m-0">Articles</h3>
|
||||
<p class="text-text-tertiary m-0 text-xs">Browse all</p>
|
||||
</Card>
|
||||
<Card padding="lg" interactive href="/search" class="flex flex-col gap-2">
|
||||
<Icon name="search" size="md" class="text-primary" />
|
||||
<h3 class="font-display text-label-md text-on-surface m-0">Search</h3>
|
||||
<p class="text-text-tertiary m-0 text-xs">Find anything</p>
|
||||
</Card>
|
||||
</div>
|
||||
</section>
|
||||
</BaseLayout>
|
||||
@@ -0,0 +1,19 @@
|
||||
---
|
||||
import BaseLayout from '../layouts/BaseLayout.astro';
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
seo={{ title: 'Account', description: 'Your account.' }}
|
||||
current="home"
|
||||
bottomNav={true}
|
||||
>
|
||||
<section class="mx-auto max-w-[800px] px-4 pb-24 pt-24 text-center md:px-10">
|
||||
<span class="text-primary text-label-md font-mono uppercase tracking-widest">Account</span>
|
||||
<h1 class="font-display text-headline-lg text-on-surface m-0 mb-4 mt-3 text-balance">
|
||||
Sign in to continue.
|
||||
</h1>
|
||||
<p class="text-text-secondary text-body-lg">
|
||||
Account features (saved articles, comments) are coming soon.
|
||||
</p>
|
||||
</section>
|
||||
</BaseLayout>
|
||||
@@ -0,0 +1,95 @@
|
||||
---
|
||||
import ArticleLayout from '../../layouts/ArticleLayout.astro';
|
||||
import ArticleHeader from '../../components/article/ArticleHeader.astro';
|
||||
import ArticleBody from '../../components/article/ArticleBody.astro';
|
||||
import RelatedRail from '../../components/article/RelatedRail.astro';
|
||||
import InlineNewsletter from '../../components/article/InlineNewsletter.astro';
|
||||
import Icon from '../../components/ui/Icon.astro';
|
||||
import { getArticleBySlug, getAllArticleSlugs, getRelated } from '../../lib/payload';
|
||||
import { formatDate, readingMinutes, authorOf } from '../../lib/format';
|
||||
|
||||
export async function getStaticPaths() {
|
||||
const slugs = await getAllArticleSlugs();
|
||||
return slugs.map((slug) => ({ params: { slug } }));
|
||||
}
|
||||
|
||||
const { slug } = Astro.params as { slug: string };
|
||||
const article = await getArticleBySlug(slug);
|
||||
if (!article) {
|
||||
return Astro.redirect('/404');
|
||||
}
|
||||
const related = await getRelated(article, 3);
|
||||
const author = authorOf(article);
|
||||
---
|
||||
|
||||
<ArticleLayout
|
||||
seo={{
|
||||
title: article.seo?.title || article.title,
|
||||
description: article.seo?.description || article.excerpt || '',
|
||||
type: 'article',
|
||||
publishedTime: article.publishedAt,
|
||||
author: author?.name,
|
||||
tags: Array.isArray(article.tags)
|
||||
? article.tags.filter((t) => typeof t === 'object').map((t) => t.name)
|
||||
: [],
|
||||
}}
|
||||
current="articles"
|
||||
>
|
||||
<article
|
||||
class="mx-auto grid max-w-[1440px] grid-cols-1 gap-16 px-4 pb-24 pt-12 md:px-10 lg:grid-cols-[1fr_300px]"
|
||||
>
|
||||
<div class="mx-auto w-full max-w-[720px] lg:mx-0">
|
||||
<ArticleHeader article={article} />
|
||||
<ArticleBody article={article} />
|
||||
<InlineNewsletter />
|
||||
<footer
|
||||
class="border-border-subtle mt-12 flex flex-wrap items-center justify-between gap-3 border-t pt-8"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
class="border-border-subtle hover:bg-surface-floated rounded-md border p-2 transition-colors"
|
||||
aria-label="Applaud"
|
||||
>
|
||||
<Icon name="thumb_up" size="md" class="text-text-secondary" />
|
||||
</button>
|
||||
<button
|
||||
class="border-border-subtle hover:bg-surface-floated rounded-md border p-2 transition-colors"
|
||||
aria-label="Comment"
|
||||
>
|
||||
<Icon name="chat_bubble" size="md" class="text-text-secondary" />
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-text-tertiary text-label-md m-0">
|
||||
Published {formatDate(article.publishedAt, { dateStyle: 'long' })} · {
|
||||
readingMinutes(article)
|
||||
} min read
|
||||
</p>
|
||||
</footer>
|
||||
</div>
|
||||
<aside class="hidden lg:block">
|
||||
<div class="sticky top-24 space-y-12">
|
||||
<RelatedRail articles={related} />
|
||||
<section class="bg-surface-dim border-border-subtle rounded-lg border p-6">
|
||||
<h4 class="font-label-md text-label-md text-on-surface m-0 mb-4">Table of contents</h4>
|
||||
<ul class="text-text-secondary m-0 list-none space-y-3 p-0 text-[13px]">
|
||||
<li class="hover:text-primary cursor-pointer transition-colors">Introduction</li>
|
||||
<li
|
||||
class="hover:text-primary border-border-subtle cursor-pointer border-l pl-4 transition-colors"
|
||||
>
|
||||
Context
|
||||
</li>
|
||||
<li class="text-primary border-primary cursor-pointer border-l pl-4 font-medium">
|
||||
Implementation
|
||||
</li>
|
||||
<li
|
||||
class="hover:text-primary border-border-subtle cursor-pointer border-l pl-4 transition-colors"
|
||||
>
|
||||
Observability
|
||||
</li>
|
||||
<li class="hover:text-primary cursor-pointer transition-colors">Conclusion</li>
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
</aside>
|
||||
</article>
|
||||
</ArticleLayout>
|
||||
@@ -0,0 +1,76 @@
|
||||
---
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro';
|
||||
import ArticleCard from '../../components/article/ArticleCard.astro';
|
||||
import SectionHead from '../../components/ui/SectionHead.astro';
|
||||
import { getArticles, getPopular } from '../../lib/payload';
|
||||
|
||||
const page = Number(Astro.url.searchParams.get('page') || 1);
|
||||
const [{ docs, hasPrevPage, hasNextPage }, popular] = await Promise.all([
|
||||
getArticles({ limit: 12, page }),
|
||||
getPopular(3),
|
||||
]);
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
seo={{ title: 'Articles', description: 'All articles published on SoftVowels.' }}
|
||||
current="articles"
|
||||
>
|
||||
<div
|
||||
class="mx-auto grid max-w-[1440px] grid-cols-1 gap-12 px-4 pb-24 pt-12 md:px-10 lg:grid-cols-12"
|
||||
>
|
||||
<div class="lg:col-span-8">
|
||||
<SectionHead eyebrow="All articles" level={2} />
|
||||
<div class="grid grid-cols-1 gap-8 md:grid-cols-2">
|
||||
{docs.map((a) => <ArticleCard article={a} />)}
|
||||
</div>
|
||||
<nav class="mt-12 flex items-center justify-between" aria-label="Pagination">
|
||||
{
|
||||
hasPrevPage ? (
|
||||
<a
|
||||
href={`/articles?page=${page - 1}`}
|
||||
class="border-border-subtle text-label-md hover:border-primary inline-flex h-10 items-center rounded-md border px-5 transition-colors"
|
||||
>
|
||||
← Newer
|
||||
</a>
|
||||
) : (
|
||||
<span />
|
||||
)
|
||||
}
|
||||
{
|
||||
hasNextPage ? (
|
||||
<a
|
||||
href={`/articles?page=${page + 1}`}
|
||||
class="border-border-subtle text-label-md hover:border-primary inline-flex h-10 items-center rounded-md border px-5 transition-colors"
|
||||
>
|
||||
Older →
|
||||
</a>
|
||||
) : (
|
||||
<span />
|
||||
)
|
||||
}
|
||||
</nav>
|
||||
</div>
|
||||
<aside class="space-y-8 lg:col-span-4">
|
||||
<h3 class="font-label-md text-label-md text-text-tertiary m-0 uppercase tracking-widest">
|
||||
Popular this week
|
||||
</h3>
|
||||
<div class="space-y-4">
|
||||
{
|
||||
popular.map((a, i) => (
|
||||
<a
|
||||
href={`/articles/${a.slug}`}
|
||||
class="bg-surface-raised border-border-subtle hover:border-primary group block rounded-lg border p-5 transition-colors"
|
||||
>
|
||||
<span class="text-primary mb-2 block font-mono text-[10px]">
|
||||
{String(i + 1).padStart(2, '0')}
|
||||
</span>
|
||||
<h4 class="font-display group-hover:text-primary m-0 text-balance text-[18px] leading-snug transition-colors">
|
||||
{a.title}
|
||||
</h4>
|
||||
</a>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</BaseLayout>
|
||||
@@ -0,0 +1,97 @@
|
||||
---
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro';
|
||||
import ArticleCard from '../../components/article/ArticleCard.astro';
|
||||
import { getAuthorBySlug, getAuthors, getArticles } from '../../lib/payload';
|
||||
import { imageUrl } from '../../lib/payload';
|
||||
|
||||
export async function getStaticPaths() {
|
||||
const authors = await getAuthors();
|
||||
return authors.map((a) => ({ params: { slug: a.slug } }));
|
||||
}
|
||||
|
||||
const { slug } = Astro.params as { slug: string };
|
||||
const author = await getAuthorBySlug(slug);
|
||||
if (!author) return Astro.redirect('/404');
|
||||
const { docs } = await getArticles({ limit: 24 });
|
||||
const myArticles = docs.filter((a) => {
|
||||
const aId = typeof a.author === 'object' ? a.author.id : a.author;
|
||||
return aId === author.id;
|
||||
});
|
||||
const avatar = imageUrl(author.avatar);
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
seo={{ title: author.name, description: author.role ?? `Articles by ${author.name}` }}
|
||||
current="articles"
|
||||
>
|
||||
<div class="mx-auto max-w-[1100px] px-4 pb-24 pt-12 md:px-10">
|
||||
<header class="border-border-subtle mb-12 flex items-center gap-6 border-b pb-8">
|
||||
{
|
||||
avatar && (
|
||||
<img
|
||||
src={avatar}
|
||||
alt={author.name}
|
||||
width="96"
|
||||
height="96"
|
||||
loading="eager"
|
||||
class="border-border-subtle h-24 w-24 rounded-full border object-cover"
|
||||
/>
|
||||
)
|
||||
}
|
||||
<div>
|
||||
<h1 class="font-display text-headline-lg text-on-surface m-0 mb-2 text-balance">
|
||||
{author.name}
|
||||
</h1>
|
||||
{author.role && <p class="text-text-secondary text-label-md m-0">{author.role}</p>}
|
||||
{
|
||||
author.bio && (
|
||||
<p class="text-text-secondary text-body-md m-0 mt-3 max-w-prose">{author.bio}</p>
|
||||
)
|
||||
}
|
||||
<div class="mt-4 flex gap-3">
|
||||
{
|
||||
author.socials?.twitter && (
|
||||
<a
|
||||
class="text-text-tertiary hover:text-primary transition-colors"
|
||||
href={`https://twitter.com/${author.socials.twitter}`}
|
||||
rel="me noopener"
|
||||
>
|
||||
Twitter
|
||||
</a>
|
||||
)
|
||||
}
|
||||
{
|
||||
author.socials?.github && (
|
||||
<a
|
||||
class="text-text-tertiary hover:text-primary transition-colors"
|
||||
href={`https://github.com/${author.socials.github}`}
|
||||
rel="me noopener"
|
||||
>
|
||||
GitHub
|
||||
</a>
|
||||
)
|
||||
}
|
||||
{
|
||||
author.socials?.site && (
|
||||
<a
|
||||
class="text-text-tertiary hover:text-primary transition-colors"
|
||||
href={author.socials.site}
|
||||
rel="me noopener"
|
||||
>
|
||||
Site
|
||||
</a>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<div class="grid grid-cols-1 gap-8 md:grid-cols-2 lg:grid-cols-3">
|
||||
{myArticles.map((a) => <ArticleCard article={a} />)}
|
||||
{
|
||||
myArticles.length === 0 && (
|
||||
<p class="text-text-tertiary text-sm">No published articles yet.</p>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</BaseLayout>
|
||||
@@ -0,0 +1,127 @@
|
||||
---
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro';
|
||||
import ArticleCard from '../../components/article/ArticleCard.astro';
|
||||
import Icon from '../../components/ui/Icon.astro';
|
||||
import { getArticles, getCategories, getCategoryBySlug, getPopular } from '../../lib/payload';
|
||||
|
||||
export async function getStaticPaths() {
|
||||
const cats = await getCategories();
|
||||
return cats.map((c) => ({ params: { slug: c.slug } }));
|
||||
}
|
||||
|
||||
const { slug } = Astro.params as { slug: string };
|
||||
const category = await getCategoryBySlug(slug);
|
||||
if (!category) return Astro.redirect('/404');
|
||||
|
||||
const [{ docs }, popular] = await Promise.all([
|
||||
getArticles({ categorySlug: slug, limit: 24 }),
|
||||
getPopular(3),
|
||||
]);
|
||||
const featured = docs.find((a) => a.featured) ?? docs[0];
|
||||
const rest = docs.filter((a) => a.id !== featured?.id);
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
seo={{
|
||||
title: category.name,
|
||||
description: category.description ?? `Articles in ${category.name}`,
|
||||
}}
|
||||
current="categories"
|
||||
>
|
||||
<div class="mx-auto max-w-[1440px] px-4 pb-24 pt-12 md:px-10">
|
||||
<div class="max-w-article mb-12">
|
||||
<div class="text-primary mb-4 flex items-center gap-2">
|
||||
<Icon name={category.icon || 'psychology'} size="sm" />
|
||||
<span class="font-label-md text-label-md uppercase tracking-widest">Intelligence</span>
|
||||
</div>
|
||||
<h1 class="font-display text-display-lg text-on-surface m-0 mb-6 text-balance">
|
||||
{category.name}
|
||||
</h1>
|
||||
{
|
||||
category.description && (
|
||||
<p class="text-text-secondary text-body-lg leading-relaxed">{category.description}</p>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
|
||||
<section class="mb-12 flex flex-wrap gap-3">
|
||||
<button
|
||||
class="border-border-subtle bg-surface-floated text-on-surface font-label-md text-label-md hover:bg-surface-container rounded-full border px-4 py-1.5 transition-all"
|
||||
>
|
||||
All {category.name}
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<div class="grid grid-cols-1 gap-6 lg:grid-cols-12">
|
||||
<aside class="order-2 lg:order-1 lg:col-span-3">
|
||||
<div class="sticky top-24 space-y-8">
|
||||
<div>
|
||||
<h3
|
||||
class="font-label-md text-label-md text-text-tertiary mb-6 uppercase tracking-widest"
|
||||
>
|
||||
Trending now
|
||||
</h3>
|
||||
<div class="space-y-6">
|
||||
{
|
||||
popular.map((a, i) => (
|
||||
<a class="group block" href={`/articles/${a.slug}`}>
|
||||
<span class="text-text-tertiary mb-1 block font-mono text-xs">
|
||||
{String(i + 1).padStart(2, '0')}
|
||||
</span>
|
||||
<h4 class="font-display text-body-md text-on-surface group-hover:text-primary mb-2 text-balance transition-colors">
|
||||
{a.title}
|
||||
</h4>
|
||||
<div class="text-text-tertiary flex items-center gap-2 text-[12px]">
|
||||
<span>Quick read</span>
|
||||
</div>
|
||||
</a>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="bg-surface-raised border-border-subtle relative overflow-hidden rounded-lg border p-6"
|
||||
>
|
||||
<h5 class="font-display text-label-md text-on-surface relative z-10 m-0 mb-2">
|
||||
SoftVowels Pro
|
||||
</h5>
|
||||
<p class="text-text-secondary relative z-10 mb-4 text-[13px] leading-snug">
|
||||
Unlock deep-dive technical reports and exclusive datasets on the {category.name} sector.
|
||||
</p>
|
||||
<a
|
||||
href="/newsletter"
|
||||
class="bg-on-surface text-background font-label-md text-label-md hover:bg-primary relative z-10 block w-full rounded py-2 text-center transition-colors"
|
||||
>
|
||||
Join the waitlist
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
<div class="order-1 lg:order-2 lg:col-span-9">
|
||||
<div class="grid grid-cols-1 gap-6 md:grid-cols-2 xl:grid-cols-3">
|
||||
{
|
||||
featured && (
|
||||
<div class="md:col-span-2">
|
||||
<ArticleCard article={featured} variant="feature" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
{rest.map((a) => <ArticleCard article={a} />)}
|
||||
</div>
|
||||
<div class="mt-16 flex justify-center">
|
||||
<a
|
||||
href={`/articles?category=${slug}`}
|
||||
class="border-border-subtle font-label-md text-label-md text-on-surface hover:bg-surface-container group flex items-center gap-2 rounded border px-8 py-3 transition-colors"
|
||||
>
|
||||
Load more articles
|
||||
<Icon
|
||||
name="expand_more"
|
||||
size="sm"
|
||||
class="transition-transform group-hover:translate-y-0.5"
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</BaseLayout>
|
||||
@@ -0,0 +1,47 @@
|
||||
---
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro';
|
||||
import Card from '../../components/ui/Card.astro';
|
||||
import Icon from '../../components/ui/Icon.astro';
|
||||
import { getCategories } from '../../lib/payload';
|
||||
|
||||
const categories = await getCategories();
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
seo={{ title: 'Categories', description: 'Browse SoftVowels articles by topic.' }}
|
||||
current="categories"
|
||||
>
|
||||
<div class="mx-auto max-w-[1440px] px-4 pb-24 pt-12 md:px-10">
|
||||
<div class="max-w-article mb-12">
|
||||
<span class="text-primary text-label-md font-mono uppercase tracking-widest"
|
||||
>Intelligence</span
|
||||
>
|
||||
<h1 class="font-display text-display-lg text-on-surface m-0 mb-6 mt-2 text-balance">
|
||||
All categories
|
||||
</h1>
|
||||
<p class="text-text-secondary text-body-lg leading-relaxed">
|
||||
Deep dives, technical analysis, and architectural patterns — organized by topic.
|
||||
</p>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{
|
||||
categories.map((c) => (
|
||||
<Card
|
||||
padding="lg"
|
||||
interactive
|
||||
href={`/categories/${c.slug}`}
|
||||
class="flex items-start gap-4"
|
||||
>
|
||||
<Icon name={c.icon || 'tag'} size="lg" class="text-primary text-3xl" />
|
||||
<div>
|
||||
<h3 class="font-display text-headline-md text-on-surface group-hover:text-primary m-0 mb-2 transition-colors">
|
||||
{c.name}
|
||||
</h3>
|
||||
{c.description && <p class="text-text-secondary m-0 text-sm">{c.description}</p>}
|
||||
</div>
|
||||
</Card>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</BaseLayout>
|
||||
@@ -0,0 +1,51 @@
|
||||
---
|
||||
import '../styles/global.css';
|
||||
import BaseLayout from '../layouts/BaseLayout.astro';
|
||||
|
||||
const changes = [
|
||||
{
|
||||
v: '0.1.0',
|
||||
d: '2026-06-17',
|
||||
items: ['Initial release.', 'Articles, categories, authors, search, newsletter.'],
|
||||
},
|
||||
{ v: '0.0.1', d: '2026-06-10', items: ['Scaffold.'] },
|
||||
];
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
seo={{ title: 'Changelog', description: 'What is new on SoftVowels.' }}
|
||||
current="changelog"
|
||||
>
|
||||
<article class="max-w-article mx-auto px-4 pb-24 pt-16 md:px-10">
|
||||
<header class="mb-12">
|
||||
<span class="text-primary text-label-md font-mono uppercase tracking-widest">Changelog</span>
|
||||
<h1 class="font-display text-display-lg text-on-surface m-0 mb-6 mt-3 text-balance">
|
||||
What is new.
|
||||
</h1>
|
||||
<p class="text-text-secondary text-body-lg leading-relaxed">
|
||||
A running log of changes to the site. Frequency: roughly once a week.
|
||||
</p>
|
||||
</header>
|
||||
<ol class="m-0 list-none space-y-12 p-0">
|
||||
{
|
||||
changes.map((c) => (
|
||||
<li class="border-border-subtle relative border-l-2 pl-6">
|
||||
<span class="bg-primary absolute -left-[7px] top-1 h-3 w-3 rounded-full" />
|
||||
<div class="mb-3 flex items-baseline gap-3">
|
||||
<span class="text-primary text-label-md font-mono">v{c.v}</span>
|
||||
<span class="text-text-tertiary text-xs">{c.d}</span>
|
||||
</div>
|
||||
<ul class="m-0 list-none space-y-2 p-0">
|
||||
{c.items.map((it) => (
|
||||
<li class="text-text-secondary text-body-md flex gap-2">
|
||||
<span class="text-primary">+</span>
|
||||
<span>{it}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</li>
|
||||
))
|
||||
}
|
||||
</ol>
|
||||
</article>
|
||||
</BaseLayout>
|
||||
@@ -0,0 +1,73 @@
|
||||
---
|
||||
import BaseLayout from '../layouts/BaseLayout.astro';
|
||||
import Hero from '../components/home/Hero.astro';
|
||||
import CategoryGrid from '../components/home/CategoryGrid.astro';
|
||||
import ArticleCard from '../components/article/ArticleCard.astro';
|
||||
import PopularList from '../components/home/PopularList.astro';
|
||||
import EditorsPicks from '../components/home/EditorsPicks.astro';
|
||||
import NewsletterCTA from '../components/home/NewsletterCTA.astro';
|
||||
import SectionHead from '../components/ui/SectionHead.astro';
|
||||
import { getArticles, getCategories, getEditorsPicks, getPopular } from '../lib/payload';
|
||||
|
||||
const [categories, popular, picks, latest] = await Promise.all([
|
||||
getCategories(),
|
||||
getPopular(3),
|
||||
getEditorsPicks(6),
|
||||
getArticles({ limit: 4 }),
|
||||
]);
|
||||
|
||||
const featured = picks[0] ?? latest.docs[0] ?? null;
|
||||
const feed = latest.docs;
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
seo={{
|
||||
title: 'SoftVowels — Engineered for clarity',
|
||||
description: 'A premium tech publication for the modern software architect.',
|
||||
}}
|
||||
current="home"
|
||||
>
|
||||
<div class="mx-auto max-w-[1440px] px-4 pb-24 pt-12 md:px-10">
|
||||
{featured && <Hero article={featured} />}
|
||||
|
||||
<CategoryGrid categories={categories} />
|
||||
|
||||
<div class="grid grid-cols-1 gap-12 lg:grid-cols-12">
|
||||
<div class="lg:col-span-8">
|
||||
<SectionHead eyebrow="Latest intelligence" level={3} href="/articles" />
|
||||
<div class="grid grid-cols-1 gap-8 md:grid-cols-2">
|
||||
{feed.map((a) => <ArticleCard article={a} />)}
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-12 lg:col-span-4">
|
||||
<PopularList articles={popular} />
|
||||
<EditorsPicks articles={picks.slice(1, 4)} />
|
||||
<aside
|
||||
class="bg-surface-container border-border-subtle relative overflow-hidden rounded-lg border p-8"
|
||||
>
|
||||
<div
|
||||
class="bg-primary/5 absolute right-0 top-0 -mr-16 -mt-16 h-32 w-32 rounded-full blur-3xl"
|
||||
aria-hidden="true"
|
||||
>
|
||||
</div>
|
||||
<h4 class="font-display text-headline-md text-on-surface relative z-10 m-0 mb-4">
|
||||
Join the collective
|
||||
</h4>
|
||||
<p class="text-text-secondary relative z-10 mb-6 text-sm">
|
||||
Access our full archives and exclusive technical whitepapers by becoming a member today.
|
||||
</p>
|
||||
<a
|
||||
href="/newsletter"
|
||||
class="bg-inverse-surface font-label-md text-label-md hover:bg-primary block w-full rounded-md py-3 text-center text-white transition-colors"
|
||||
>
|
||||
Learn more
|
||||
</a>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-24">
|
||||
<NewsletterCTA />
|
||||
</div>
|
||||
</div>
|
||||
</BaseLayout>
|
||||
@@ -0,0 +1,130 @@
|
||||
---
|
||||
import BaseLayout from '../layouts/BaseLayout.astro';
|
||||
import SubscribeForm from '../components/newsletter/SubscribeForm.astro';
|
||||
import BentoBenefits from '../components/newsletter/BentoBenefits.astro';
|
||||
import { getLatestEdition } from '../lib/payload';
|
||||
import { imageUrl } from '../lib/payload';
|
||||
|
||||
const edition = await getLatestEdition();
|
||||
const cover = edition?.cover ? imageUrl(edition.cover) : null;
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
seo={{
|
||||
title: 'Stay ahead of tech',
|
||||
description:
|
||||
'Weekly insights into the architectural patterns, engineering philosophies, and technical breakthroughs shaping the next decade of software.',
|
||||
}}
|
||||
current="newsletter"
|
||||
>
|
||||
<div class="mx-auto max-w-[1440px] px-4 pb-24 pt-12 md:px-10">
|
||||
<div class="mx-auto mb-16 max-w-[600px] text-center">
|
||||
<span
|
||||
class="font-label-md text-label-md text-primary bg-primary/5 border-primary/10 mb-6 inline-block rounded-full border px-3 py-1"
|
||||
>
|
||||
Weekly intelligence
|
||||
</span>
|
||||
<h1
|
||||
class="font-display text-display-lg text-on-surface mb-6 text-balance tracking-tighter md:text-[64px]"
|
||||
>
|
||||
Stay ahead of tech.
|
||||
</h1>
|
||||
<p class="text-text-secondary text-body-lg">
|
||||
Weekly insights into the architectural patterns, engineering philosophies, and technical
|
||||
breakthroughs shaping the next decade of software.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="border-border-subtle bg-surface-raised mx-auto grid max-w-[1000px] grid-cols-1 gap-0 overflow-hidden rounded-lg border md:grid-cols-2"
|
||||
>
|
||||
<div
|
||||
class="border-border-subtle flex flex-col justify-center border-b p-8 md:border-b-0 md:border-r md:p-12"
|
||||
>
|
||||
<div class="mb-8 flex -space-x-2">
|
||||
<div class="border-surface-raised bg-primary/30 h-8 w-8 rounded-full border-2"></div>
|
||||
<div class="border-surface-raised bg-primary/40 h-8 w-8 rounded-full border-2"></div>
|
||||
<div class="border-surface-raised bg-primary/50 h-8 w-8 rounded-full border-2"></div>
|
||||
<div
|
||||
class="border-surface-raised bg-surface-variant text-on-surface-variant flex h-8 w-8 items-center justify-center rounded-full border-2 text-[10px] font-bold"
|
||||
>
|
||||
+50k
|
||||
</div>
|
||||
</div>
|
||||
<p class="font-label-md text-label-md text-text-tertiary mb-8">
|
||||
Joined by 50k+ engineers from OpenAI, Stripe, and Vercel.
|
||||
</p>
|
||||
<SubscribeForm source="newsletter" layout="stacked" />
|
||||
<p class="font-label-md text-label-md text-text-tertiary m-0 mt-6 text-center md:text-left">
|
||||
No spam. One email every Tuesday. Unsubscribe anytime.
|
||||
</p>
|
||||
</div>
|
||||
<div class="bg-surface-container-lowest flex flex-col p-8 md:p-12">
|
||||
<div class="mb-8 flex items-center justify-between">
|
||||
<span class="font-label-md text-label-md text-text-tertiary uppercase tracking-widest"
|
||||
>Latest edition</span
|
||||
>
|
||||
<span class="text-primary font-mono">#{edition?.number ?? '—'}</span>
|
||||
</div>
|
||||
<div class="flex-grow space-y-6">
|
||||
{
|
||||
edition ? (
|
||||
<>
|
||||
<h3 class="font-display text-headline-md text-on-surface m-0 text-balance">
|
||||
{edition.title}
|
||||
</h3>
|
||||
{edition.intro && (
|
||||
<p class="text-text-secondary text-body-md m-0">{edition.intro}</p>
|
||||
)}
|
||||
{cover && (
|
||||
<img
|
||||
src={cover}
|
||||
alt={`Edition #${edition.number}`}
|
||||
width="640"
|
||||
height="320"
|
||||
loading="lazy"
|
||||
class="border-border-subtle rounded-lg border"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<p class="text-text-secondary text-body-md m-0">
|
||||
No editions yet. Add one in /admin to see it here.
|
||||
</p>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
<a
|
||||
href="/articles"
|
||||
class="text-primary font-label-md text-label-md mt-12 inline-flex items-center gap-2 hover:underline"
|
||||
>
|
||||
Read full archive
|
||||
<span class="material-symbols-outlined">arrow_forward</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-24">
|
||||
<BentoBenefits />
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="border-border-subtle bg-surface-container-lowest relative mt-32 h-[400px] w-full overflow-hidden rounded-2xl border"
|
||||
>
|
||||
<div class="grid-noise pointer-events-none absolute inset-0 opacity-20" aria-hidden="true">
|
||||
</div>
|
||||
<div
|
||||
class="absolute inset-0 flex flex-col items-center justify-center bg-gradient-to-t from-white to-transparent p-12 text-center"
|
||||
>
|
||||
<h2
|
||||
class="font-display text-headline-lg text-headline-lg text-on-surface m-0 mb-4 text-balance"
|
||||
>
|
||||
Engineered for clarity.
|
||||
</h2>
|
||||
<p class="text-text-secondary m-0 max-w-[500px]">
|
||||
Join the mailing list for technical minds who value precision over hype.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</BaseLayout>
|
||||
@@ -0,0 +1,41 @@
|
||||
---
|
||||
import '../styles/global.css';
|
||||
import BaseLayout from '../layouts/BaseLayout.astro';
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
seo={{ title: 'Philosophy', description: 'How we think about the work.' }}
|
||||
current="philosophy"
|
||||
>
|
||||
<article class="max-w-article mx-auto px-4 pb-24 pt-16 md:px-10">
|
||||
<header class="mb-12">
|
||||
<span class="text-primary text-label-md font-mono uppercase tracking-widest">Philosophy</span>
|
||||
<h1 class="font-display text-display-lg text-on-surface m-0 mb-6 mt-3 text-balance">
|
||||
Engineered for clarity.
|
||||
</h1>
|
||||
<p class="text-text-secondary text-body-lg leading-relaxed">
|
||||
SoftVowels is a publication, not a feed. Every article is selected, edited, and signed by a
|
||||
human being. The goal is not volume; it is signal.
|
||||
</p>
|
||||
</header>
|
||||
<div class="prose-article">
|
||||
<h2>Why this exists</h2>
|
||||
<p>
|
||||
Modern software is built on an enormous amount of invisible craft. We want to surface that
|
||||
craft: the architectural trade-offs, the failure modes, the small decisions that compound
|
||||
over years. We do not chase trends. We do not publish summaries.
|
||||
</p>
|
||||
<h2>How we choose what to publish</h2>
|
||||
<p>
|
||||
An article makes the cut when it teaches a senior engineer something they did not know an
|
||||
hour earlier. We do not run sponsored content. We do not auto-generate copy. The byline is
|
||||
the brand.
|
||||
</p>
|
||||
<h2>What we will not do</h2>
|
||||
<p>
|
||||
We will not pad articles for SEO. We will not run popups. We will not invent urgency. If the
|
||||
work does not deserve your attention, it does not get published.
|
||||
</p>
|
||||
</div>
|
||||
</article>
|
||||
</BaseLayout>
|
||||
@@ -0,0 +1,27 @@
|
||||
import rss from '@astrojs/rss';
|
||||
import { getArticles } from '../lib/payload';
|
||||
import { authorOf } from '../lib/format';
|
||||
|
||||
export async function GET(context: { site: URL | undefined }) {
|
||||
const { docs } = await getArticles({ limit: 50 });
|
||||
return rss({
|
||||
title: 'SoftVowels',
|
||||
description: 'Engineering-focused insights for the modern software architect.',
|
||||
site: context.site ?? 'https://softvowels.example',
|
||||
items: docs.map((a) => {
|
||||
const author = authorOf(a);
|
||||
return {
|
||||
title: a.title,
|
||||
pubDate: new Date(a.publishedAt),
|
||||
description: a.excerpt ?? '',
|
||||
link: `/articles/${a.slug}`,
|
||||
author: author?.name,
|
||||
categories:
|
||||
a.tags && Array.isArray(a.tags)
|
||||
? a.tags.filter((t) => typeof t === 'object').map((t) => t.name)
|
||||
: [],
|
||||
};
|
||||
}),
|
||||
customData: '<language>en-us</language>',
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
---
|
||||
import BaseLayout from '../layouts/BaseLayout.astro';
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
seo={{ title: 'Saved', description: 'Your saved articles.' }}
|
||||
current="home"
|
||||
bottomNav={true}
|
||||
>
|
||||
<section class="mx-auto max-w-[800px] px-4 pb-24 pt-24 text-center md:px-10">
|
||||
<span class="text-primary text-label-md font-mono uppercase tracking-widest">Saved</span>
|
||||
<h1 class="font-display text-headline-lg text-on-surface m-0 mb-4 mt-3 text-balance">
|
||||
Your reading list.
|
||||
</h1>
|
||||
<p class="text-text-secondary text-body-lg">
|
||||
Sign in to save articles for later. (Coming soon.)
|
||||
</p>
|
||||
</section>
|
||||
</BaseLayout>
|
||||
@@ -0,0 +1,70 @@
|
||||
---
|
||||
import BaseLayout from '../layouts/BaseLayout.astro';
|
||||
import Results from '../components/search/Results.astro';
|
||||
import { search } from '../lib/payload';
|
||||
|
||||
const q = (Astro.url.searchParams.get('q') || '').trim();
|
||||
const { articles, authors } = q ? await search(q) : { articles: [], authors: [] };
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
seo={{
|
||||
title: q ? `Search · ${q}` : 'Search',
|
||||
description: 'Search articles, authors, and tutorials.',
|
||||
}}
|
||||
current="search"
|
||||
>
|
||||
<section class="max-w-article mx-auto max-w-[1440px] px-4 pb-24 pt-12 md:px-10">
|
||||
<form
|
||||
method="get"
|
||||
action="/search"
|
||||
class="bg-surface border-border-subtle focus-within:border-primary flex items-center rounded-lg border p-4 shadow-sm transition-all duration-300"
|
||||
>
|
||||
<span class="material-symbols-outlined text-text-tertiary mr-4">search</span>
|
||||
<input
|
||||
autofocus
|
||||
name="q"
|
||||
value={q}
|
||||
type="search"
|
||||
placeholder="Search engineering docs, philosophy, or authors…"
|
||||
class="text-headline-md font-display text-on-surface placeholder:text-text-tertiary w-full border-0 bg-transparent outline-none"
|
||||
aria-label="Search query"
|
||||
/>
|
||||
<kbd
|
||||
class="bg-surface-variant/50 border-border-subtle text-text-tertiary ml-4 hidden items-center rounded border px-2 py-1 font-mono text-[10px] md:inline-flex"
|
||||
>
|
||||
ESC
|
||||
</kbd>
|
||||
</form>
|
||||
|
||||
<div class="mt-8 flex flex-wrap gap-3">
|
||||
<span class="text-text-tertiary font-label-md text-label-md mr-2 flex items-center"
|
||||
>Popular:</span
|
||||
>
|
||||
{
|
||||
['Distributed Systems', 'Rust', 'UI Precision', 'LLM Ops'].map((t) => (
|
||||
<a
|
||||
href={`/search?q=${encodeURIComponent(t)}`}
|
||||
class="bg-surface-floated border-border-subtle font-label-md text-label-md text-text-secondary hover:border-primary rounded-full border px-3 py-1 transition-colors"
|
||||
>
|
||||
{t}
|
||||
</a>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
|
||||
<Results q={q} articles={articles} authors={authors} />
|
||||
</section>
|
||||
</BaseLayout>
|
||||
|
||||
<script is:inline>
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') {
|
||||
const input = document.querySelector('input[type="search"][name="q"]');
|
||||
if (input instanceof HTMLInputElement) {
|
||||
input.value = '';
|
||||
input.blur();
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,84 @@
|
||||
---
|
||||
import BaseLayout from '../layouts/BaseLayout.astro';
|
||||
import NewsletterCTA from '../components/home/NewsletterCTA.astro';
|
||||
import { getArticles } from '../lib/payload';
|
||||
|
||||
const { docs } = await getArticles({ limit: 50 });
|
||||
const map = new Map();
|
||||
for (const a of docs) {
|
||||
const cat = typeof a.category === 'object' ? a.category : null;
|
||||
if (!cat) continue;
|
||||
const list = map.get(cat.id) ?? { name: cat.name, slug: cat.slug, count: 0, authors: new Set() };
|
||||
list.count += 1;
|
||||
if (typeof a.author === 'object' && a.author) list.authors.add(a.author.name);
|
||||
map.set(cat.id, list);
|
||||
}
|
||||
const summary = [...map.values()].map((v) => ({ ...v, authors: [...v.authors] }));
|
||||
---
|
||||
|
||||
<BaseLayout seo={{ title: 'Sitemap', description: 'Every page on SoftVowels.' }} current="home">
|
||||
<div class="mx-auto max-w-[1100px] px-4 pb-24 pt-12 md:px-10">
|
||||
<header class="mb-12">
|
||||
<span class="text-primary text-label-md font-mono uppercase tracking-widest">Index</span>
|
||||
<h1 class="font-display text-display-lg text-on-surface m-0 mt-3 text-balance">Sitemap</h1>
|
||||
</header>
|
||||
<div class="grid grid-cols-1 gap-12 md:grid-cols-2">
|
||||
<section>
|
||||
<h2 class="font-display text-headline-md text-on-surface m-0 mb-4">Sections</h2>
|
||||
<ul class="m-0 list-none space-y-2 p-0">
|
||||
{
|
||||
['/articles', '/categories', '/search', '/newsletter', '/philosophy', '/changelog'].map(
|
||||
(p) => (
|
||||
<li>
|
||||
<a class="text-text-secondary hover:text-primary transition-colors" href={p}>
|
||||
{p}
|
||||
</a>
|
||||
</li>
|
||||
),
|
||||
)
|
||||
}
|
||||
</ul>
|
||||
</section>
|
||||
<section>
|
||||
<h2 class="font-display text-headline-md text-on-surface m-0 mb-4">Categories</h2>
|
||||
<ul class="m-0 list-none space-y-2 p-0">
|
||||
{
|
||||
summary.map((c) => (
|
||||
<li>
|
||||
<a
|
||||
class="text-text-secondary hover:text-primary transition-colors"
|
||||
href={`/categories/${c.slug}`}
|
||||
>
|
||||
{c.name}
|
||||
</a>
|
||||
<span class="text-text-tertiary ml-2 text-xs">
|
||||
{c.count} articles · {c.authors.length} authors
|
||||
</span>
|
||||
</li>
|
||||
))
|
||||
}
|
||||
</ul>
|
||||
</section>
|
||||
<section class="md:col-span-2">
|
||||
<h2 class="font-display text-headline-md text-on-surface m-0 mb-4">Articles</h2>
|
||||
<ul class="m-0 grid list-none grid-cols-1 gap-2 p-0 md:grid-cols-2">
|
||||
{
|
||||
docs.map((a) => (
|
||||
<li>
|
||||
<a
|
||||
class="text-text-secondary hover:text-primary transition-colors"
|
||||
href={`/articles/${a.slug}`}
|
||||
>
|
||||
{a.title}
|
||||
</a>
|
||||
</li>
|
||||
))
|
||||
}
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
<div class="mt-16">
|
||||
<NewsletterCTA />
|
||||
</div>
|
||||
</div>
|
||||
</BaseLayout>
|
||||
@@ -0,0 +1,256 @@
|
||||
@import 'tailwindcss';
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Fonts — Geist Sans + Geist Mono (self-hosted, .woff2) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
@font-face {
|
||||
font-family: 'Geist';
|
||||
font-style: normal;
|
||||
font-weight: 100 900;
|
||||
font-display: swap;
|
||||
src: url('/fonts/Geist-Variable.woff2') format('woff2-variations');
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Geist Mono';
|
||||
font-style: normal;
|
||||
font-weight: 100 900;
|
||||
font-display: swap;
|
||||
src: url('/fonts/GeistMono-Variable.woff2') format('woff2-variations');
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Material Symbols (variable) — used for the icon set */
|
||||
/* Add new icons in markup as <span class="material-symbols-outlined"> */
|
||||
/* ------------------------------------------------------------------ */
|
||||
.material-symbols-outlined {
|
||||
font-family: 'Material Symbols Outlined';
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
font-size: 24px;
|
||||
line-height: 1;
|
||||
letter-spacing: normal;
|
||||
text-transform: none;
|
||||
display: inline-block;
|
||||
white-space: nowrap;
|
||||
word-wrap: normal;
|
||||
direction: ltr;
|
||||
font-variation-settings:
|
||||
'FILL' 0,
|
||||
'wght' 400,
|
||||
'GRAD' 0,
|
||||
'opsz' 24;
|
||||
}
|
||||
.material-symbols-outlined.fill-1 {
|
||||
font-variation-settings:
|
||||
'FILL' 1,
|
||||
'wght' 400,
|
||||
'GRAD' 0,
|
||||
'opsz' 24;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Design tokens — lifted from .opencode/stitch/DESIGN.md */
|
||||
/* ------------------------------------------------------------------ */
|
||||
@theme {
|
||||
/* surfaces */
|
||||
--color-background: #ffffff;
|
||||
--color-surface: #ffffff;
|
||||
--color-surface-dim: #f9fafb;
|
||||
--color-surface-raised: #f9fafb;
|
||||
--color-surface-container-low: #ffffff;
|
||||
--color-surface-container: #f9fafb;
|
||||
--color-surface-container-high: #f3f4f6;
|
||||
--color-surface-container-highest: #f3f4f6;
|
||||
--color-surface-variant: #f3f4f6;
|
||||
--color-border-subtle: #e5e7eb;
|
||||
--color-outline: #d1d5db;
|
||||
--color-outline-variant: #e5e7eb;
|
||||
|
||||
/* text */
|
||||
--color-on-surface: #111827;
|
||||
--color-on-surface-variant: #4b5563;
|
||||
--color-on-background: #111827;
|
||||
--color-text-secondary: #4b5563;
|
||||
--color-text-tertiary: #6b7280;
|
||||
--color-inverse-surface: #111827;
|
||||
--color-inverse-on-surface: #ffffff;
|
||||
|
||||
/* brand */
|
||||
--color-primary: #318db8;
|
||||
--color-primary-container: #dcf0fa;
|
||||
--color-on-primary: #ffffff;
|
||||
--color-on-primary-container: #001e2c;
|
||||
--color-accent-blue: #318db8;
|
||||
--color-accent-gold: #eab308;
|
||||
|
||||
/* secondary */
|
||||
--color-secondary: #4b5563;
|
||||
--color-on-secondary: #ffffff;
|
||||
--color-secondary-container: #f3f4f6;
|
||||
--color-on-secondary-container: #4b5563;
|
||||
|
||||
/* tertiary */
|
||||
--color-tertiary: #ffb95f;
|
||||
--color-on-tertiary: #ffffff;
|
||||
|
||||
/* status */
|
||||
--color-error: #ef4444;
|
||||
--color-on-error: #ffffff;
|
||||
--color-success: #10b981;
|
||||
|
||||
/* radius */
|
||||
--radius-sm: 0.25rem;
|
||||
--radius-md: 0.5rem;
|
||||
--radius-lg: 0.75rem;
|
||||
--radius-xl: 0.75rem;
|
||||
--radius-2xl: 1rem;
|
||||
--radius-full: 9999px;
|
||||
|
||||
/* type */
|
||||
--font-sans:
|
||||
'Geist', ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial,
|
||||
sans-serif;
|
||||
--font-display: 'Geist', ui-sans-serif, system-ui, sans-serif;
|
||||
--font-mono: 'Geist Mono', ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
|
||||
--text-display-lg: 48px;
|
||||
--text-display-lg--line-height: 1.1;
|
||||
--text-display-lg--letter-spacing: -0.04em;
|
||||
--text-display-lg--font-weight: 700;
|
||||
|
||||
--text-headline-lg: 32px;
|
||||
--text-headline-lg--line-height: 1.2;
|
||||
--text-headline-lg--letter-spacing: -0.02em;
|
||||
--text-headline-lg--font-weight: 600;
|
||||
|
||||
--text-headline-md: 24px;
|
||||
--text-headline-md--line-height: 1.3;
|
||||
--text-headline-md--letter-spacing: -0.01em;
|
||||
--text-headline-md--font-weight: 600;
|
||||
|
||||
--text-body-lg: 18px;
|
||||
--text-body-lg--line-height: 1.6;
|
||||
|
||||
--text-body-md: 16px;
|
||||
--text-body-md--line-height: 1.5;
|
||||
|
||||
--text-label-md: 14px;
|
||||
--text-label-md--line-height: 1;
|
||||
--text-label-md--letter-spacing: 0.02em;
|
||||
--text-label-md--font-weight: 500;
|
||||
|
||||
--text-code: 14px;
|
||||
--text-code--line-height: 1.6;
|
||||
|
||||
/* spacing (4px baseline) */
|
||||
--spacing-gutter: 24px;
|
||||
--spacing-margin-mobile: 16px;
|
||||
--spacing-margin-desktop: 40px;
|
||||
--spacing-section: 80px;
|
||||
--spacing-xxl: 80px;
|
||||
|
||||
/* containers */
|
||||
--container-site: 1440px;
|
||||
--container-article: 720px;
|
||||
--container-reading-lane: 800px;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Base layer */
|
||||
/* ------------------------------------------------------------------ */
|
||||
@layer base {
|
||||
html {
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
text-rendering: optimizeLegibility;
|
||||
background-color: var(--color-background);
|
||||
color: var(--color-on-surface);
|
||||
font-family: var(--font-sans);
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--color-background);
|
||||
color: var(--color-on-surface);
|
||||
font-size: var(--text-body-md);
|
||||
line-height: var(--text-body-md--line-height);
|
||||
min-height: 100dvh;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background-color: color-mix(in oklab, var(--color-primary) 20%, transparent);
|
||||
}
|
||||
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--color-primary);
|
||||
outline-offset: 2px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
img,
|
||||
video,
|
||||
svg {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Utilities (component classes referenced across pages) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
@layer components {
|
||||
.hairline {
|
||||
border: 1px solid var(--color-border-subtle);
|
||||
}
|
||||
.hairline-hover {
|
||||
border: 1px solid var(--color-border-subtle);
|
||||
transition: border-color 0.2s ease;
|
||||
}
|
||||
.hairline-hover:hover {
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
.mask-fade-b {
|
||||
mask-image: linear-gradient(to bottom, black 80%, transparent 100%);
|
||||
-webkit-mask-image: linear-gradient(to bottom, black 80%, transparent 100%);
|
||||
}
|
||||
.glass-nav {
|
||||
background-color: color-mix(in oklab, var(--color-background) 80%, transparent);
|
||||
backdrop-filter: saturate(180%) blur(12px);
|
||||
-webkit-backdrop-filter: saturate(180%) blur(12px);
|
||||
}
|
||||
.grid-noise {
|
||||
background-image:
|
||||
linear-gradient(var(--color-border-subtle) 1px, transparent 1px),
|
||||
linear-gradient(90deg, var(--color-border-subtle) 1px, transparent 1px);
|
||||
background-size: 40px 40px;
|
||||
}
|
||||
.text-balance {
|
||||
text-wrap: balance;
|
||||
}
|
||||
.scrollbar-none {
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
.scrollbar-none::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Reduced motion */
|
||||
/* ------------------------------------------------------------------ */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
/* Prose styles applied to rendered article body (Lexical → HTML). */
|
||||
.prose-article {
|
||||
color: var(--color-on-surface);
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--text-body-lg);
|
||||
line-height: 1.8;
|
||||
max-width: var(--container-article);
|
||||
}
|
||||
.prose-article > * + * {
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
.prose-article h2 {
|
||||
font-family: var(--font-display);
|
||||
font-size: var(--text-headline-lg);
|
||||
line-height: 1.2;
|
||||
letter-spacing: -0.02em;
|
||||
font-weight: 600;
|
||||
margin-top: 3.5rem;
|
||||
margin-bottom: 1.25rem;
|
||||
color: var(--color-on-surface);
|
||||
}
|
||||
.prose-article h3 {
|
||||
font-family: var(--font-display);
|
||||
font-size: var(--text-headline-md);
|
||||
line-height: 1.3;
|
||||
letter-spacing: -0.01em;
|
||||
font-weight: 600;
|
||||
margin-top: 2.5rem;
|
||||
margin-bottom: 1rem;
|
||||
color: var(--color-on-surface);
|
||||
}
|
||||
.prose-article p {
|
||||
color: var(--color-on-surface);
|
||||
}
|
||||
.prose-article a {
|
||||
color: var(--color-primary);
|
||||
text-decoration: underline;
|
||||
text-decoration-color: color-mix(in oklab, var(--color-primary) 30%, transparent);
|
||||
text-underline-offset: 3px;
|
||||
transition: text-decoration-color 0.2s ease;
|
||||
}
|
||||
.prose-article a:hover {
|
||||
text-decoration-color: var(--color-primary);
|
||||
}
|
||||
.prose-article strong {
|
||||
font-weight: 600;
|
||||
color: var(--color-on-surface);
|
||||
}
|
||||
.prose-article em {
|
||||
font-style: italic;
|
||||
}
|
||||
.prose-article blockquote {
|
||||
border-left: 3px solid var(--color-primary);
|
||||
padding-left: 1.25rem;
|
||||
color: var(--color-on-surface-variant);
|
||||
font-style: italic;
|
||||
margin-block: 2rem;
|
||||
}
|
||||
.prose-article ul,
|
||||
.prose-article ol {
|
||||
padding-left: 1.5rem;
|
||||
}
|
||||
.prose-article ul {
|
||||
list-style: disc;
|
||||
}
|
||||
.prose-article ol {
|
||||
list-style: decimal;
|
||||
}
|
||||
.prose-article li + li {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
.prose-article hr {
|
||||
border: none;
|
||||
border-top: 1px solid var(--color-border-subtle);
|
||||
margin-block: 3rem;
|
||||
}
|
||||
.prose-article img {
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--color-border-subtle);
|
||||
margin-block: 2rem;
|
||||
}
|
||||
.prose-article pre,
|
||||
.prose-article code {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.875em;
|
||||
}
|
||||
.prose-article code:not(pre code) {
|
||||
background-color: var(--color-surface-container-high);
|
||||
border: 1px solid var(--color-border-subtle);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 0.1em 0.35em;
|
||||
color: var(--color-on-surface);
|
||||
}
|
||||
.prose-article pre {
|
||||
background-color: var(--color-inverse-surface);
|
||||
color: #e5e7eb;
|
||||
border: 1px solid var(--color-border-subtle);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 1.25rem 1.5rem;
|
||||
overflow-x: auto;
|
||||
line-height: 1.6;
|
||||
margin-block: 2rem;
|
||||
}
|
||||
.prose-article pre code {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
color: inherit;
|
||||
}
|
||||
.prose-article .inline-newsletter {
|
||||
margin-block: 2.5rem;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"extends": "astro/tsconfigs/strict",
|
||||
"include": [".astro/types.d.ts", "**/*"],
|
||||
"exclude": ["dist"],
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"],
|
||||
"@components/*": ["src/components/*"],
|
||||
"@layouts/*": ["src/layouts/*"],
|
||||
"@lib/*": ["src/lib/*"],
|
||||
"@styles/*": ["src/styles/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user