diff options
Diffstat (limited to 'apps/web/src/app')
| -rw-r--r-- | apps/web/src/app/changelog/[version]/page.tsx | 105 | ||||
| -rw-r--r-- | apps/web/src/app/changelog/components/copy-markdown-button.tsx | 68 | ||||
| -rw-r--r-- | apps/web/src/app/changelog/components/release.tsx | 106 | ||||
| -rw-r--r-- | apps/web/src/app/changelog/page.tsx | 66 | ||||
| -rw-r--r-- | apps/web/src/app/changelog/utils.ts | 43 | ||||
| -rw-r--r-- | apps/web/src/app/editor/[project_id]/page.tsx | 21 | ||||
| -rw-r--r-- | apps/web/src/app/globals.css | 8 | ||||
| -rw-r--r-- | apps/web/src/app/layout.tsx | 7 | ||||
| -rw-r--r-- | apps/web/src/app/roadmap/page.tsx | 2 |
9 files changed, 410 insertions, 16 deletions
diff --git a/apps/web/src/app/changelog/[version]/page.tsx b/apps/web/src/app/changelog/[version]/page.tsx new file mode 100644 index 00000000..af382822 --- /dev/null +++ b/apps/web/src/app/changelog/[version]/page.tsx @@ -0,0 +1,105 @@ +import type { Metadata } from "next"; +import Link from "next/link"; +import { notFound } from "next/navigation"; +import { BasePage } from "@/app/base-page"; +import { allChangelogs } from "content-collections"; +import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react"; +import { getSortedReleases } from "../utils"; +import { + ReleaseArticle, + ReleaseMeta, + ReleaseTitle, + ReleaseDescription, + ReleaseChanges, +} from "../components/release"; +import { CopyMarkdownButton } from "../components/copy-markdown-button"; + +type Props = { params: Promise<{ version: string }> }; + +export async function generateStaticParams() { + return allChangelogs.map((r) => ({ version: r.version })); +} + +export async function generateMetadata({ params }: Props): Promise<Metadata> { + const { version } = await params; + const release = allChangelogs.find((r) => r.version === version); + if (!release) return {}; + return { + title: `${release.title} (${release.version}) - OpenCut Changelog`, + description: release.description, + }; +} + +export default async function ReleaseDetailPage({ params }: Props) { + const { version } = await params; + const releases = getSortedReleases(); + const index = releases.findIndex((r) => r.version === version); + + if (index === -1) notFound(); + + const release = releases[index]; + const newer = index > 0 ? releases[index - 1] : null; + const older = index < releases.length - 1 ? releases[index + 1] : null; + + return ( + <BasePage> + <div className="mx-auto w-full max-w-3xl flex flex-col gap-12"> + <Link + href="/changelog" + className="text-sm text-muted-foreground hover:text-foreground flex items-center gap-1 w-fit" + > + <ChevronLeftIcon className="size-4" /> + All releases + </Link> + + <ReleaseArticle variant="detail"> + <div className="flex flex-col gap-4"> + <div className="flex items-center justify-between"> + <ReleaseMeta release={release} /> + <CopyMarkdownButton + description={release.description} + changes={release.changes} + /> + </div> + <ReleaseTitle as="h1">{release.title}</ReleaseTitle> + {release.description && ( + <ReleaseDescription>{release.description}</ReleaseDescription> + )} + </div> + <ReleaseChanges release={release} /> + </ReleaseArticle> + + <nav className="flex items-center justify-between border-t pt-8"> + {older ? ( + <Link + href={`/changelog/${older.version}`} + className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground group" + > + <ChevronLeftIcon className="size-4" /> + <div className="flex flex-col"> + <span className="text-xs text-muted-foreground/60">Older</span> + <span className="font-medium">{older.title}</span> + </div> + </Link> + ) : ( + <div /> + )} + {newer ? ( + <Link + href={`/changelog/${newer.version}`} + className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground group text-right" + > + <div className="flex flex-col"> + <span className="text-xs text-muted-foreground/60">Newer</span> + <span className="font-medium">{newer.title}</span> + </div> + <ChevronRightIcon className="size-4" /> + </Link> + ) : ( + <div /> + )} + </nav> + </div> + </BasePage> + ); +} diff --git a/apps/web/src/app/changelog/components/copy-markdown-button.tsx b/apps/web/src/app/changelog/components/copy-markdown-button.tsx new file mode 100644 index 00000000..2366550a --- /dev/null +++ b/apps/web/src/app/changelog/components/copy-markdown-button.tsx @@ -0,0 +1,68 @@ +"use client"; + +import { useState } from "react"; +import { CheckIcon, ClipboardIcon } from "lucide-react"; +import { getSectionTitle, groupAndOrderChanges } from "../utils"; +import type { Change } from "../utils"; +import { cn } from "@/utils/ui"; +import { Button } from "@/components/ui/button"; + +function buildMarkdown({ + description, + changes, +}: { + description?: string; + changes: Change[]; +}): string { + const lines: string[] = []; + + if (description) { + lines.push(description, ""); + } + + const { grouped, orderedTypes } = groupAndOrderChanges({ changes }); + + for (const type of orderedTypes) { + lines.push(`## ${getSectionTitle(type)}`); + for (const change of grouped[type]) { + lines.push(`- ${change.text}`); + } + lines.push(""); + } + + return lines.join("\n").trimEnd(); +} + +export function CopyMarkdownButton({ + description, + changes, +}: { + description?: string; + changes: Change[]; +}) { + const [copied, setCopied] = useState(false); + + const handleCopy = async () => { + const markdown = buildMarkdown({ description, changes }); + await navigator.clipboard.writeText(markdown); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }; + + return ( + <Button + size="sm" + variant="text" + onClick={handleCopy} + className={cn("flex items-center gap-1.5", copied && "pointer-events-none")} + title="Copy as markdown" + > + {copied ? ( + <CheckIcon className="size-4" /> + ) : ( + <ClipboardIcon className="size-4" /> + )} + {copied ? "Copied!" : "Copy markdown"} + </Button> + ); +} diff --git a/apps/web/src/app/changelog/components/release.tsx b/apps/web/src/app/changelog/components/release.tsx new file mode 100644 index 00000000..eb04c1f0 --- /dev/null +++ b/apps/web/src/app/changelog/components/release.tsx @@ -0,0 +1,106 @@ +import type { ReactNode } from "react"; +import Link from "next/link"; +import { cn } from "@/utils/ui"; +import { getSectionTitle, groupAndOrderChanges } from "../utils"; +import type { Release } from "../utils"; + +export function ReleaseArticle({ + variant, + isLatest, + children, +}: { + variant: "list" | "detail"; + isLatest?: boolean; + children: ReactNode; +}) { + if (variant === "list") { + return ( + <article className="relative sm:pl-10"> + <div aria-hidden className="absolute left-0 top-[3px] hidden sm:block"> + <div + className={cn( + "size-[11px] rounded-full border-[1.5px]", + isLatest + ? "border-foreground bg-foreground" + : "border-muted-foreground/30 bg-background", + )} + /> + </div> + <div className="flex flex-col gap-5">{children}</div> + </article> + ); + } + + return <article className="flex flex-col gap-8">{children}</article>; +} + +export function ReleaseMeta({ release }: { release: Release }) { + return ( + <span className="text-sm font-medium tracking-widest text-muted-foreground"> + {release.version} — {release.date} + </span> + ); +} + +const titleSizes: Record<"h1" | "h2", string> = { + h1: "text-4xl", + h2: "text-2xl", +}; + +export function ReleaseTitle({ + as: As, + href, + children, +}: { + as: "h1" | "h2"; + href?: string; + children: ReactNode; +}) { + return ( + <As className={cn("font-bold tracking-tight", titleSizes[As])}> + {href ? ( + <Link href={href} className="hover:underline underline-offset-4"> + {children} + </Link> + ) : ( + children + )} + </As> + ); +} + +export function ReleaseDescription({ children }: { children: ReactNode }) { + return ( + <p className="text-base text-foreground leading-relaxed max-w-xl"> + {children} + </p> + ); +} + +export function ReleaseChanges({ release }: { release: Release }) { + const { grouped, orderedTypes } = groupAndOrderChanges({ + changes: release.changes, + }); + + return ( + <div className="flex flex-col gap-4"> + {orderedTypes.map((type) => ( + <div key={type} className="flex flex-col gap-1.5"> + <h3 className="text-base font-semibold text-foreground"> + {getSectionTitle(type)}: + </h3> + <ul className="list-disc pl-5 space-y-1.5"> + {grouped[type].map((change) => ( + <li + key={change.text} + className="text-base text-foreground leading-relaxed" + > + {change.text} + </li> + ))} + </ul> + </div> + ))} + </div> + ); +} diff --git a/apps/web/src/app/changelog/page.tsx b/apps/web/src/app/changelog/page.tsx new file mode 100644 index 00000000..8cb585b6 --- /dev/null +++ b/apps/web/src/app/changelog/page.tsx @@ -0,0 +1,66 @@ +import type { Metadata } from "next"; +import { BasePage } from "@/app/base-page"; +import { Separator } from "@/components/ui/separator"; +import { type Release as ReleaseType, getSortedReleases } from "./utils"; +import { + ReleaseArticle, + ReleaseMeta, + ReleaseTitle, + ReleaseDescription, + ReleaseChanges, +} from "./components/release"; + +export const metadata: Metadata = { + title: "Changelog - OpenCut", + description: "Every update, improvement, and fix to OpenCut — documented.", + openGraph: { + title: "Changelog - OpenCut", + description: "Every update, improvement, and fix to OpenCut — documented.", + type: "website", + }, +}; + +export default function ChangelogPage() { + const releases = getSortedReleases(); + + return ( + <BasePage title="Changelog" description="See what's new in OpenCut"> + <div className="mx-auto w-full max-w-3xl"> + <div className="relative"> + <div + aria-hidden + className="absolute top-2 bottom-0 left-[5px] w-px bg-border hidden sm:block" + /> + + <div className="flex flex-col"> + {releases.map((release, releaseIndex) => ( + <div key={release.version} className="flex flex-col"> + <ReleaseEntry release={release} /> + {releaseIndex < releases.length - 1 && ( + <Separator className="my-10 sm:ml-1.5" /> + )} + </div> + ))} + </div> + </div> + </div> + </BasePage> + ); +} + +function ReleaseEntry({ release }: { release: ReleaseType }) { + return ( + <ReleaseArticle variant="list" isLatest={release.isLatest}> + <ReleaseMeta release={release} /> + <div className="flex flex-col gap-4"> + <ReleaseTitle as="h2" href={`/changelog/${release.version}`}> + {release.title} + </ReleaseTitle> + {release.description && ( + <ReleaseDescription>{release.description}</ReleaseDescription> + )} + </div> + <ReleaseChanges release={release} /> + </ReleaseArticle> + ); +} diff --git a/apps/web/src/app/changelog/utils.ts b/apps/web/src/app/changelog/utils.ts new file mode 100644 index 00000000..163207c0 --- /dev/null +++ b/apps/web/src/app/changelog/utils.ts @@ -0,0 +1,43 @@ +import { allChangelogs } from "content-collections"; + +export type Change = { type: string; text: string }; +export type Release = (typeof allChangelogs)[number]; + +const knownSectionOrder = ["new", "improved", "fixed", "breaking"]; + +const knownSectionTitles: Record<string, string> = { + new: "Features", + improved: "Improvements", + fixed: "Fixes", + breaking: "Breaking Changes", +}; + +export function getSectionTitle(type: string): string { + return ( + knownSectionTitles[type] ?? type.charAt(0).toUpperCase() + type.slice(1) + ); +} + +export function groupAndOrderChanges({ changes }: { changes: Change[] }) { + const grouped = changes.reduce<Record<string, Change[]>>((acc, change) => { + if (!acc[change.type]) acc[change.type] = []; + acc[change.type].push(change); + return acc; + }, {}); + + const customTypes = Object.keys(grouped).filter( + (type) => !knownSectionOrder.includes(type), + ); + const orderedTypes = [ + ...knownSectionOrder.filter((type) => grouped[type]?.length > 0), + ...customTypes, + ]; + + return { grouped, orderedTypes }; +} + +export function getSortedReleases() { + return [...allChangelogs].sort((a, b) => + b.version.localeCompare(a.version, undefined, { numeric: true }), + ); +} diff --git a/apps/web/src/app/editor/[project_id]/page.tsx b/apps/web/src/app/editor/[project_id]/page.tsx index 987af7c6..3ec6ddb8 100644 --- a/apps/web/src/app/editor/[project_id]/page.tsx +++ b/apps/web/src/app/editor/[project_id]/page.tsx @@ -16,22 +16,25 @@ import { Onboarding } from "@/components/editor/onboarding"; import { MigrationDialog } from "@/components/editor/dialogs/migration-dialog"; import { usePanelStore } from "@/stores/panel-store"; import { usePasteMedia } from "@/hooks/use-paste-media"; +import { MobileGate } from "@/components/editor/mobile-gate"; export default function Editor() { const params = useParams(); const projectId = params.project_id as string; return ( - <EditorProvider projectId={projectId}> - <div className="bg-background flex h-screen w-screen flex-col overflow-hidden"> - <EditorHeader /> - <div className="min-h-0 min-w-0 flex-1"> - <EditorLayout /> + <MobileGate> + <EditorProvider projectId={projectId}> + <div className="bg-background flex h-screen w-screen flex-col overflow-hidden"> + <EditorHeader /> + <div className="min-h-0 min-w-0 flex-1"> + <EditorLayout /> + </div> + <Onboarding /> + <MigrationDialog /> </div> - <Onboarding /> - <MigrationDialog /> - </div> - </EditorProvider> + </EditorProvider> + </MobileGate> ); } diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css index 2d051c93..6d3e984f 100644 --- a/apps/web/src/app/globals.css +++ b/apps/web/src/app/globals.css @@ -60,9 +60,7 @@ --muted-foreground: hsl(0 0% 48%); --accent: hsl(0, 0%, 93%); --accent-foreground: hsl(0 0% 5%); - --destructive: hsl(0, 83%, 50%); --destructive-foreground: hsl(0, 0%, 98%); - --constructive: hsl(141, 71%, 48%); --constructive-foreground: hsl(0, 0%, 98%); --border: hsl(0 0% 89%); --input: hsl(0 0% 93%); @@ -83,7 +81,6 @@ --muted: hsl(0 0% 20%); --accent: hsl(0, 0%, 14%); --accent-foreground: hsl(0 0% 95%); - --constructive: hsl(141, 71%, 52%); --border: hsl(0 0% 16%); --input: hsl(0 0% 5%); --ring: hsl(0, 0%, 50%); @@ -103,13 +100,12 @@ --foreground: hsl(0 0% 85%); --card: hsl(0, 0%, 10%); --card-foreground: hsl(0 0% 85%); - --secondary: hsl(204, 100%, 12%); - --secondary-border: hsl(204, 100%, 17%); + --secondary: hsl(204, 67%, 9%); + --secondary-border: hsl(204, 100%, 14%); --secondary-foreground: hsl(200, 98%, 63%); --muted: hsl(0 0% 22%); --accent: hsl(0, 0%, 15%); --accent-foreground: hsl(0 0% 93%); - --constructive: hsl(141, 71%, 52%); --border: hsl(0 0% 18%); --input: hsl(0 0% 22%); --ring: hsl(0, 0%, 52%); diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx index 2b3d0c30..c2608bd9 100644 --- a/apps/web/src/app/layout.tsx +++ b/apps/web/src/app/layout.tsx @@ -28,6 +28,13 @@ export default function RootLayout({ <html lang="en" suppressHydrationWarning> <head> <BotIdClient protect={protectedRoutes} /> + {process.env.NODE_ENV === "development" && ( + <Script + src="//unpkg.com/react-scan/dist/auto.global.js" + crossOrigin="anonymous" + strategy="beforeInteractive" + /> + )} </head> <body className={`${siteFont.className} font-sans antialiased`}> <ThemeProvider diff --git a/apps/web/src/app/roadmap/page.tsx b/apps/web/src/app/roadmap/page.tsx index 77d9a0cc..59c36d78 100644 --- a/apps/web/src/app/roadmap/page.tsx +++ b/apps/web/src/app/roadmap/page.tsx @@ -112,7 +112,7 @@ export default function RoadmapPage() { function RoadmapItem({ item, index }: { item: RoadmapItem; index: number }) { return ( <div className="flex flex-col gap-2"> - <div className="flex items-center gap-2 text-lg font-medium"> + <div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-lg font-medium"> <span className="leading-normal select-none">{index + 1}</span> <h3>{item.title}</h3> <StatusBadge status={item.status} className="ml-1" /> |
