diff --git a/docs/STYLING.md b/docs/STYLING.md new file mode 100644 index 00000000..40849abe --- /dev/null +++ b/docs/STYLING.md @@ -0,0 +1,237 @@ +# Styling System + +Components in the Puck editor are styled through **design tokens** — named choices that map to arbitrary properties, but mostly Tailwind classes. Editors pick tokens from dropdowns; the system turns those choices into class names at render time. + +Any token can be made **responsive**, letting editors pick different values per breakpoint (base / tablet / desktop). Unset breakpoints inherit from the nearest smaller one. + +## Core concepts + +### 1. Tokens + +A token is a constrained set of options. `defineToken` creates one (`src/lib/puck/tokens.ts`). There are two forms: + +**Class tokens** map each key to a Tailwind class string: + +```ts +const padding = defineToken({ + sm: { label: "Small", classes: "p-2" }, + md: { label: "Medium", classes: "p-4" }, +}); + +padding.classes // → { sm: "p-2", md: "p-4" } +``` + +You can add extra columns beyond `classes` — any property besides `label` becomes a `Record` on the token: + +```ts +const spacing = defineToken({ + sm: { label: "Small", classes: "gap-2", px: 8 }, + lg: { label: "Large", classes: "gap-6", px: 24 }, +}); + +spacing.classes // → { sm: "gap-2", lg: "gap-6" } +spacing.px // → { sm: 8, lg: 24 } +``` + +**Plain tokens** use simple string values (key = value, string = editor label). Useful when there are no Tailwind classes to map to: + +```ts +const variant = defineToken({ default: "Default", outline: "Outline" }); + +variant.options // → [{ value: "default", label: "Default" }, ...] +variant.defaultValue // → "default" +``` + +The first key is the default unless you pass an explicit second argument: + +```ts +const size = defineToken({ sm: "Small", md: "Medium", lg: "Large" }, "md"); +size.defaultValue // → "md" +``` + +Use `TokenValue` to extract a token's value union for type annotations: + +```ts +type Spacing = TokenValue; // "sm" | "md" +``` + +### 2. Responsive values + +A `ResponsiveValue` has a required `base` and optional `md` / `lg` overrides (`src/lib/puck/responsive.ts`): + +```ts +type ResponsiveValue = { base: T } & Partial>; + +{ base: "sm" } // same at all sizes +{ base: "sm", md: "md", lg: "lg" } // changes at each breakpoint +{ base: "sm", lg: "lg" } // skip md — it inherits from base +``` + +**Helper functions** for working with responsive values: + +| Function | Purpose | +|---|---| +| `resolveAt(value, breakpoint)` | Returns the effective value at a breakpoint, falling back to the nearest smaller one. `resolveAt({ base: "sm", lg: "lg" }, "md")` → `"sm"` | +| `hasOverride(value)` | Returns `true` if any non-base overrides are set | +| `map(value, fn)` | Transforms each set breakpoint. `map(value, (v) => v.toUpperCase())` | +| `setAt(value, breakpoint, newVal)` | Returns a new `ResponsiveValue` with one breakpoint changed. Pass `undefined` to clear an override | + +These helpers are for component logic — see [Rendering](#4-rendering) for how responsive values become Tailwind classes. + +### 3. Props + +`defineProps` wires tokens to Puck editor fields (`src/lib/puck/define-props.ts`). It returns `{ fields, defaultProps }` to spread onto a `ComponentConfig`. + +There are four field builders: + +```ts +const props = defineProps({ + // Slot — a Puck drop zone for child components + content: field.slot(), + + // Static select / radio — token-backed, single value + radius: field.select(radius, { label: "Corners" }), + layout: field.radio(layout, { label: "Layout" }), + + // Responsive — per-breakpoint picker, also token-backed + padding: responsive.token(padding, { label: "Padding" }), + + // Raw — any Puck field descriptor, for things tokens don't cover + url: field.raw({ type: "text", label: "URL" }, ""), +}); +``` + +**Defaults.** Every builder falls back to the token's first key if no `default` is given. You can override with a scalar or a full responsive object: + +```ts +responsive.token(columnCount, { label: "Columns", default: "3" }) +responsive.token(columnCount, { label: "Columns", default: { base: "1", md: "3" } }) +``` + +**Slots** accept an optional allow/disallow list to restrict which child components can be dropped in: + +```ts +field.slot({ allow: ["Card", "Button"] }) +field.slot({ disallow: ["Section"] }) +``` + +The same token definition works for both static and responsive use — only the field builder differs. + +### 4. Rendering + +Static tokens look up the class map directly. Responsive tokens go through `resolveResponsive` (`src/lib/puck/responsive-tailwind.ts`), which prefixes each class with its breakpoint: + +```ts +// Static — direct lookup +radius.classes[r] // → "rounded-md" + +// Responsive — produces prefixed classes for each set breakpoint +resolveResponsive(padding, paddingToken.classes) +// { base: "sm", md: "lg" } → "p-2 md:p-6" +``` + +Multi-class values get each utility prefixed individually: +`"grid-rows-2 auto-rows-[0] overflow-hidden"` → `"md:grid-rows-2 md:auto-rows-[0] md:overflow-hidden"` + +Combine everything with `cn()` (re-exported from `src/lib/utils`): + +```ts +const classes = cn( + "w-full", + bgColor.classes[bg], // static + resolveResponsive(py, paddingY.classes), // responsive +); +``` + +## Tailwind source hints + +Because `resolveResponsive` builds prefixed class names like `md:p-4` at runtime, Tailwind's scanner never sees them in source. Every responsive token needs a corresponding `@source inline(...)` directive in `src/app/styles.css`: + +```css +@source inline("{md:,lg:,}{p,px,py}-{0,1,2,4,6,8,12}"); +@source inline("{md:,lg:,}columns-{1,2,3,4}"); +``` + +Static tokens don't need this — their class strings appear literally in `tokens.ts` and are picked up by the scanner automatically. + +## Creating a new component + +This section walks through building a component from scratch. + +### Step 1 — Define or reuse tokens + +Check `src/lib/puck/tokens.ts` for existing tokens before creating new ones. The file already exports tokens for padding, gap, colors, radius, shadow, alignment, layout, width, columns, and grid rows. + +If you need a new token, add it to `tokens.ts` and export a type alias: + +```ts +export const fontSize = defineToken({ + sm: { label: "Small", classes: "text-sm" }, + base: { label: "Base", classes: "text-base" }, + lg: { label: "Large", classes: "text-lg" }, +}); + +export type FontSize = TokenValue; +``` + +### Step 2 — Define the props type and field spec + +Write a `Props` type that mirrors the field spec. Use `Slot` for drop zones, `ResponsiveValue` for responsive fields, and the token's value type for static fields: + +```ts +import type { ComponentConfig, Slot } from "@puckeditor/core"; +import type { ResponsiveValue } from "@/lib/puck/responsive"; +import { defineProps, responsive, field } from "@/lib/puck/define-props"; +import { padding, bgColor, type Spacing, type Color } from "@/lib/puck/tokens"; + +type CardProps = { + content: Slot; + padding: ResponsiveValue; + bgColor: Color; + title: string; +}; + +const props = defineProps({ + content: field.slot(), + padding: responsive.token(padding, { label: "Padding", default: "md" }), + bgColor: field.select(bgColor, { label: "Background" }), + title: field.raw({ type: "text", label: "Title" }, ""), +}); +``` + +### Step 3 — Export the component config + +Spread `props` onto the config and destructure in `render`. Use `resolveResponsive` for responsive props and direct `token.classes[value]` lookups for static ones: + +```ts +import { resolveResponsive } from "@/lib/puck/responsive-tailwind"; +import { cn } from "@/lib/utils"; + +export const Card: ComponentConfig = { + label: "Card", + ...props, + render: ({ content: Content, padding: pad, bgColor: bg, title }) => ( +
+ {title &&

{title}

} + {Content && } +
+ ), +}; +``` + +### Step 4 — Register the component + +Add the export to your Puck config so the editor can use it. + +### Step 5 — Add Tailwind source hints (responsive tokens only) + +If your component introduces responsive tokens with class patterns not already covered in `src/app/styles.css`, add a `@source inline(...)` directive. Check what's already there first — common patterns like padding and gap are already covered. + +### Checklist + +- Token exists in `tokens.ts` (or you added a new one with a `type` alias) +- Props type matches the `defineProps` spec +- `...props` is spread on the config (this includes both `fields` and `defaultProps`) +- Static tokens resolved via `token.classes[value]` +- Responsive tokens resolved via `resolveResponsive(value, token.classes)` +- `@source inline(...)` added for any new responsive class patterns diff --git a/prisma/seed.ts b/prisma/seed.ts index c593af2c..9434ad8e 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -1,8 +1,9 @@ import "dotenv/config"; -import { Prisma, PrismaClient } from "../src/generated/prisma/client"; +import { PrismaClient } from "../src/generated/prisma/client"; import { PrismaPg } from "@prisma/adapter-pg"; import fs from "fs"; import path from "path"; +import type { Prisma } from "../src/generated/prisma/client"; const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL }); const prisma = new PrismaClient({ adapter }); diff --git a/src/app/editor/[id]/[slug]/EditorPage.tsx b/src/app/editor/[id]/[slug]/EditorPage.tsx index 8fc62b63..af87e050 100644 --- a/src/app/editor/[id]/[slug]/EditorPage.tsx +++ b/src/app/editor/[id]/[slug]/EditorPage.tsx @@ -5,6 +5,7 @@ import { notFound } from "next/navigation"; import { Client } from "./client"; import { getDocumentById } from "../../../../lib/documents/queries"; import { getEditorSlug, getEditorUrl } from "../../../../lib/editor-url"; +import { createEmptyPuckData } from "../../../../lib/puck/utils"; export default async function EditorPage({ documentId, @@ -63,5 +64,5 @@ function resolveVersion( return { data: versions[0].content as Data, versionId: versions[0].id }; } - return { data: { content: [], root: {} } as Data }; + return { data: createEmptyPuckData() }; } diff --git a/src/app/editor/[id]/[slug]/client.tsx b/src/app/editor/[id]/[slug]/client.tsx index 04130fd3..2182367b 100644 --- a/src/app/editor/[id]/[slug]/client.tsx +++ b/src/app/editor/[id]/[slug]/client.tsx @@ -45,13 +45,12 @@ export function Client({ }: { documentId: number; documentName: string; - data: Partial; + data: Data; versionId?: number; publishedVersionId?: number; versions: Version[]; isArchived: boolean; }) { - const [currentData, setCurrentData] = useState(data as Data); const [versions, setVersions] = useState(initialVersions); const [versionId, setVersionId] = useState(initialVersionId); const [publishedVersionId, setPublishedVersionId] = useState(initialPublishedVersionId); @@ -67,7 +66,7 @@ export function Client({ > { - setCurrentData(data); - }} /> ); diff --git a/src/app/layout.tsx b/src/app/layout.tsx index b8a9d97e..97af976c 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,6 +1,18 @@ import "./styles.css"; import { Toaster } from "@/components/ui/sonner"; import { DialogProvider } from "@/components/ui/dialog-provider"; +import { Inter, Bebas_Neue } from "next/font/google"; + +const inter = Inter({ + subsets: ["latin"], + variable: "--font-inter", +}); + +const bebasNeue = Bebas_Neue({ + weight: "400", + subsets: ["latin"], + variable: "--font-bebas-neue", +}); export default function RootLayout({ children, @@ -8,7 +20,7 @@ export default function RootLayout({ children: React.ReactNode }) { return ( - + {children} diff --git a/src/app/styles.css b/src/app/styles.css index 93e48b85..7c238b3d 100644 --- a/src/app/styles.css +++ b/src/app/styles.css @@ -2,6 +2,15 @@ @import "tw-animate-css"; @import "shadcn/tailwind.css"; +@source inline("{md:,lg:,}{p,px,py}-{0,1,2,4,6,8,12}"); +@source inline("{md:,lg:,}gap-{0,1,2,4,6,8,12}"); +@source inline("{md:,lg:,}grid-cols-{1,2,3,4,5,6}"); +@source inline("{md:,lg:,}grid-rows-{none,1,2,3,4,5,6}"); +@source inline("{md:,lg:,}auto-rows-{auto,[0]}"); +@source inline("{md:,lg:,}overflow-{visible,hidden}"); +@source inline("{md:,lg:,}columns-{1,2,3,4}"); +@source inline("{md:,lg:,}gap-x-{0,1,2,4,6,8,12}"); + @custom-variant dark (&:is(.dark *)); @theme { @@ -10,10 +19,11 @@ --color-solid-black: #000000; --color-semi-transparent-white: rgba(255, 255, 255, 0.9); --color-sga-red-alt: #dc2626; + --font-display: var(--font-bebas-neue), sans-serif; } :root { - font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; + font-family: var(--font-inter), system-ui, Avenir, Helvetica, Arial, sans-serif; line-height: 1.5; font-weight: 400; -webkit-font-smoothing: antialiased; @@ -71,3 +81,32 @@ body { @apply bg-background text-foreground; } } + +.prose h1 { + @apply font-display text-5xl; +} + +.prose h2 { + @apply font-display text-4xl text-sga-red; +} + +.prose h3, .prose h4, .prose h5, .prose h6 { + @apply font-display; +} + +.prose ul { + @apply list-disc pl-6; +} + +.prose ol { + @apply list-decimal pl-6; +} + +.prose p:has(br:only-child), +.prose p:empty { + @apply block min-h-lh; +} + +.rich-text-line-spacing .rich-text * { + line-height: inherit; +} diff --git a/src/components/puck/BulletList.tsx b/src/components/puck/BulletList.tsx deleted file mode 100644 index 5ffca8ad..00000000 --- a/src/components/puck/BulletList.tsx +++ /dev/null @@ -1,66 +0,0 @@ -import { ComponentConfig } from "@puckeditor/core"; -import { JSX } from "react"; -import { textColorSettingField } from "../../lib/settings-fields"; - -export type BulletType = "disc" | "decimal" | "none" - -export interface BulletListProps { - items: { text: string }[]; - bullet: BulletType; - textColor: string; -} - -export function BulletList({ - items, - bullet = "disc", - textColor = "text-black" -}: BulletListProps) { - const ListComponent = bullet === "decimal" ? "ol" : "ul" as keyof JSX.IntrinsicElements; - - const listStyleMap = { - disc: "list-disc list-outside", - decimal: "list-decimal list-outside", - none: "list-none" - } - const listStyles = listStyleMap[bullet] - - return ( - - {items.map(({ text }, index) => ( -
  • - {text} -
  • - ))} -
    - ) -} - -export const BulletListConfig: ComponentConfig = { - fields: { - bullet: { - type: "select", - options: [ - { label: "Disc", value: "disc" }, - { label: "Decimal", value: "decimal" }, - { label: "None", value: "none" }, - ] - }, - items: { - type: "array", - arrayFields: { - text: { type: "text" }, - }, - }, - textColor: textColorSettingField - }, - defaultProps: { - bullet: "disc", - items: [ - { text: "First item" }, - { text: "Second item" }, - { text: "Third item" }, - ], - textColor: "text-black" - }, - render: (props) => , -} \ No newline at end of file diff --git a/src/components/puck/Button.tsx b/src/components/puck/Button.tsx deleted file mode 100644 index b3215173..00000000 --- a/src/components/puck/Button.tsx +++ /dev/null @@ -1,88 +0,0 @@ -import { ComponentConfig } from "@puckeditor/core"; -import { paddingSettingsField, heightSettingsField, widthSettingsField } from "../../lib/settings-fields"; - -const buttonStylesMap = { - primary: "bg-sga-red hover:bg-sga-red-alt text-white rounded-lg p-4 flex items-center justify-center text-center", - secondary: "bg-black text-white rounded-lg p-4 flex items-center justify-center text-center", - tertiary: "bg-white text-sga-red rounded-lg p-4 flex items-center justify-center text-center" -}; - -interface BaseButtonProps { - label: string; - style: "primary" | "secondary" | "tertiary"; - padding: string; - height: string; - width: string; -} - -export interface ButtonProps extends BaseButtonProps { - onClick: () => void; -} - -export interface LinkButtonProps extends BaseButtonProps { - href: string; -} - -export function Button({ - label, - style, - padding = "p-4", - height = "h-full", - width = "w-full", - onClick -}: ButtonProps) { - return ( - - ) -} - - -export function LinkButton({ - label, - style, - padding = "p-4", - height = "h-full", - width = "w-full", - href -}: LinkButtonProps) { - return ( - - {label} - - ) -} - -export const LinkButtonConfig: ComponentConfig = { - fields: { - label: { type: "text" }, - style: { - type: "select", - options: [ - { label: "Red", value: "primary" }, - { label: "Black", value: "secondary" }, - { label: "White", value: "tertiary"} - ] - }, - href: { type: "text" }, - padding: paddingSettingsField, - height: heightSettingsField, - width: widthSettingsField, - }, - defaultProps: { - label: "Button", - style: "primary", - href: "#", - padding: "p-4", - height: "h-full", - width: "w-full", - }, - render: (props) => , -} \ No newline at end of file diff --git a/src/components/puck/Container.tsx b/src/components/puck/Container.tsx deleted file mode 100644 index 4ccbfce6..00000000 --- a/src/components/puck/Container.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import { ComponentConfig, SlotComponent } from "@puckeditor/core" -import { gapSettingsField, outlineSettingsField, paddingSettingsField } from "../../lib/settings-fields"; - -export interface ContainerProps { - content?: SlotComponent; - padding: string; - gap: string; - outline: string; -} - -export function Container({ - content: Content, - padding = "", - gap = "", - outline = "", -}: ContainerProps) { - return ( -
    - {Content && } -
    - ) -} - -export const ContainerConfig: ComponentConfig = { - fields: { - content: { type: "slot" }, - padding: paddingSettingsField, - gap: gapSettingsField, - outline: outlineSettingsField - }, - defaultProps: { - padding: "p-10", - gap: "gap-6", - outline: "", - }, - render: (props) => , -} \ No newline at end of file diff --git a/src/components/puck/Footer.tsx b/src/components/puck/Footer.tsx deleted file mode 100644 index 54488aa8..00000000 --- a/src/components/puck/Footer.tsx +++ /dev/null @@ -1,185 +0,0 @@ -import { ComponentConfig } from '@puckeditor/core'; - -export type FooterProps = { - showActionButtons?: boolean; - actionButton1Text?: string; - actionButton1Href?: string; - actionButton2Text?: string; - actionButton2Href?: string; - actionButton3Text?: string; - actionButton3Href?: string; - showSocialMedia?: boolean; - socialMedia1Label?: string; - socialMedia1LogoSrc?: string; - socialMedia1LogoAlt?: string; - socialMedia2Label?: string; - socialMedia2LogoSrc?: string; - socialMedia2LogoAlt?: string; - socialMedia3Label?: string; - socialMedia3LogoSrc?: string; - socialMedia3LogoAlt?: string; - organizationName?: string; - organizationAddress?: string; - webmasterEmail?: string; - webmasterLabel?: string; - mediaInquiriesEmail?: string; - mediaInquiriesLabel?: string; -}; - -export function Footer({ - showActionButtons, - actionButton1Text, - actionButton1Href, - actionButton2Text, - actionButton2Href, - actionButton3Text, - actionButton3Href, - showSocialMedia, - socialMedia1Label, - socialMedia1LogoSrc, - socialMedia1LogoAlt, - socialMedia2Label, - socialMedia2LogoSrc, - socialMedia2LogoAlt, - socialMedia3Label, - socialMedia3LogoSrc, - socialMedia3LogoAlt, - organizationName, - organizationAddress, - webmasterEmail, - webmasterLabel, - mediaInquiriesEmail, - mediaInquiriesLabel, -}: FooterProps) { - return ( -
    -
    - - {showActionButtons && ( -
    - - - -
    - )} - - {showSocialMedia && ( -
    -
    -

    {socialMedia1Label}

    - {socialMedia1LogoSrc ? ( - {socialMedia1LogoAlt} - ) : ( -
    Logo
    - )} -
    -
    -

    {socialMedia2Label}

    - {socialMedia2LogoSrc ? ( - {socialMedia2LogoAlt} - ) : ( -
    Logo
    - )} -
    -
    -

    {socialMedia3Label}

    - {socialMedia3LogoSrc ? ( - {socialMedia3LogoAlt} - ) : ( -
    Logo
    - )} -
    -
    - )} - -
    -

    {organizationName}

    -

    {organizationAddress}

    -

    {webmasterLabel}  - - {webmasterEmail} - -

    -

    {mediaInquiriesLabel}  - - {mediaInquiriesEmail} - -

    -
    -
    -
    - ) -} - - -export const FooterConfig: ComponentConfig = { - fields: { - showActionButtons: { - type: "radio", - options: [ - { label: "Show", value: true }, - { label: "Hide", value: false }, - ] - }, - actionButton1Text: { type: "text" }, - actionButton1Href: { type: "text" }, - actionButton2Text: { type: "text" }, - actionButton2Href: { type: "text" }, - actionButton3Text: { type: "text" }, - actionButton3Href: { type: "text" }, - showSocialMedia: { - type: "radio", - options: [ - { label: "Show", value: true }, - { label: "Hide", value: false }, - ] - }, - socialMedia1Label: { type: "text" }, - socialMedia1LogoSrc: { type: "text" }, - socialMedia1LogoAlt: { type: "text" }, - socialMedia2Label: { type: "text" }, - socialMedia2LogoSrc: { type: "text" }, - socialMedia2LogoAlt: { type: "text" }, - socialMedia3Label: { type: "text" }, - socialMedia3LogoSrc: { type: "text" }, - socialMedia3LogoAlt: { type: "text" }, - organizationName: { type: "text" }, - organizationAddress: { type: "text" }, - webmasterEmail: { type: "text" }, - webmasterLabel: { type: "text" }, - mediaInquiriesEmail: { type: "text" }, - mediaInquiriesLabel: { type: "text" }, - }, - defaultProps: { - showActionButtons: true, - actionButton1Text: "GIVE FEEDBACK", - actionButton1Href: "#", - actionButton2Text: "MAILING LIST", - actionButton2Href: "#", - actionButton3Text: "GET INVOLVED", - actionButton3Href: "#", - showSocialMedia: true, - socialMedia1Label: "SGA", - socialMedia1LogoSrc: "", - socialMedia1LogoAlt: "SGA Instagram", - socialMedia2Label: "Campus Affairs", - socialMedia2LogoSrc: "", - socialMedia2LogoAlt: "Campus Affairs Instagram", - socialMedia3Label: "SGA", - socialMedia3LogoSrc: "", - socialMedia3LogoAlt: "SGA TikTok", - organizationName: "Northeastern University Student Government Association", - organizationAddress: "332 Curry Student Center, 360 Huntington Avenue, Boston, MA 02115", - webmasterEmail: "sgaOperations@northeastern.edu", - webmasterLabel: "Webmaster:", - mediaInquiriesEmail: "sgaExternalAffairs@northeastern.edu", - mediaInquiriesLabel: "Media Inquiries:", - }, - render: (props) =>