From 2a372fdec256b8be890e181c221c2a012ead9efa Mon Sep 17 00:00:00 2001 From: cashmere Date: Mon, 27 Jul 2026 01:21:02 +0200 Subject: refactor: strip landing page and upstream branding / redirects to /projects; marketing landing components, the feedback popover + API route, and all Discord links removed (editor header, footer, onboarding copy). --- apps/web/src/app/api/feedback/route.ts | 31 --- apps/web/src/app/page.tsx | 26 +-- apps/web/src/components/editor/onboarding.tsx | 5 +- apps/web/src/components/footer.tsx | 53 +++-- apps/web/src/components/header.tsx | 6 +- apps/web/src/components/landing/handlebars.tsx | 203 ----------------- apps/web/src/components/landing/hero.tsx | 41 ---- .../src/feedback/components/feedback-popover.tsx | 242 --------------------- apps/web/src/feedback/index.ts | 3 - apps/web/src/feedback/queries.ts | 14 -- apps/web/src/feedback/types.ts | 11 - apps/web/src/site/social.ts | 1 - 12 files changed, 33 insertions(+), 603 deletions(-) delete mode 100644 apps/web/src/app/api/feedback/route.ts delete mode 100644 apps/web/src/components/landing/handlebars.tsx delete mode 100644 apps/web/src/components/landing/hero.tsx delete mode 100644 apps/web/src/feedback/components/feedback-popover.tsx delete mode 100644 apps/web/src/feedback/index.ts delete mode 100644 apps/web/src/feedback/queries.ts delete mode 100644 apps/web/src/feedback/types.ts (limited to 'apps/web/src') diff --git a/apps/web/src/app/api/feedback/route.ts b/apps/web/src/app/api/feedback/route.ts deleted file mode 100644 index cd853eea..00000000 --- a/apps/web/src/app/api/feedback/route.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { type NextRequest, NextResponse } from "next/server"; -import { z } from "zod"; -import { checkRateLimit } from "@/auth/rate-limit"; -import { submitFeedback, MAX_MESSAGE_LENGTH } from "@/feedback"; - -const submitSchema = z.object({ - message: z - .string() - .min(1, "Message is required") - .max(MAX_MESSAGE_LENGTH, "Message too long"), -}); - -export async function POST(request: NextRequest) { - const { limited } = await checkRateLimit({ request }); - if (limited) { - return NextResponse.json({ error: "Too many requests" }, { status: 429 }); - } - - const body = await request.json(); - const result = submitSchema.safeParse(body); - - if (!result.success) { - return NextResponse.json( - { error: "Invalid input", details: result.error.flatten().fieldErrors }, - { status: 400 }, - ); - } - - const entry = await submitFeedback(result.data); - return NextResponse.json({ entry }, { status: 201 }); -} diff --git a/apps/web/src/app/page.tsx b/apps/web/src/app/page.tsx index 6084a912..287775c5 100644 --- a/apps/web/src/app/page.tsx +++ b/apps/web/src/app/page.tsx @@ -1,21 +1,5 @@ -import { Hero } from "@/components/landing/hero"; -import { Header } from "@/components/header"; -import { Footer } from "@/components/footer"; -import type { Metadata } from "next"; -import { SITE_URL } from "@/site/brand"; - -export const metadata: Metadata = { - alternates: { - canonical: SITE_URL, - }, -}; - -export default async function Home() { - return ( -
-
- -
-
- ); -} +import { redirect } from "next/navigation"; + +export default function Home() { + redirect("/projects"); +} diff --git a/apps/web/src/components/editor/onboarding.tsx b/apps/web/src/components/editor/onboarding.tsx index bc5533ce..08ef3318 100644 --- a/apps/web/src/components/editor/onboarding.tsx +++ b/apps/web/src/components/editor/onboarding.tsx @@ -3,7 +3,6 @@ import { ArrowRightIcon } from "lucide-react"; import { useState } from "react"; import ReactMarkdown from "react-markdown"; -import { SOCIAL_LINKS } from "@/site/social"; import { useLocalStorage } from "@/services/storage/use-local-storage"; import { Button } from "../ui/button"; import { Dialog, DialogBody, DialogContent, DialogTitle } from "../ui/dialog"; @@ -67,9 +66,7 @@ export function Onboarding() {
- <Description - description={`Join our [Discord](${SOCIAL_LINKS.discord}), chat with cool people and share feedback to help make OpenCut the best editor ever.`} - /> + <Description description="Have fun testing! Your projects and media stay local in your browser." /> </div> <NextButton onClick={handleClose}>Finish</NextButton> </div> diff --git a/apps/web/src/components/footer.tsx b/apps/web/src/components/footer.tsx index b56f8c6c..e7a8fc39 100644 --- a/apps/web/src/components/footer.tsx +++ b/apps/web/src/components/footer.tsx @@ -1,5 +1,5 @@ import Link from "next/link"; -import { RiDiscordFill, RiTwitterXLine } from "react-icons/ri"; +import { RiTwitterXLine } from "react-icons/ri"; import { FaGithub } from "react-icons/fa6"; import Image from "next/image"; import { DEFAULT_LOGO_URL } from "@/site/brand"; @@ -13,23 +13,30 @@ interface FooterLink { href: string; } -type CategoryLinks = Record<Category, FooterLink[]>; +interface LinkCategory { + category: Category; + links: FooterLink[]; +} -const links: CategoryLinks = { - resources: [ - { label: "Roadmap", href: "/roadmap" }, - { label: "Changelog", href: "/changelog" }, - { label: "Blog", href: "/blog" }, - { label: "Privacy", href: "/privacy" }, - { label: "Terms of use", href: "/terms" }, - ], - company: [ - { label: "Contributors", href: "/contributors" }, - { label: "Sponsors", href: "/sponsors" }, - { label: "Brand", href: "/brand" }, - { label: "About", href: `${SOCIAL_LINKS.github}/blob/main/README.md` }, - ], -}; +const linkCategories: LinkCategory[] = [ + { + category: "resources", + links: [ + { label: "Roadmap", href: "/roadmap" }, + { label: "Privacy", href: "/privacy" }, + { label: "Terms of use", href: "/terms" }, + ], + }, + { + category: "company", + links: [ + { label: "Contributors", href: "/contributors" }, + { label: "Sponsors", href: "/sponsors" }, + { label: "Brand", href: "/brand" }, + { label: "About", href: `${SOCIAL_LINKS.github}/blob/main/README.md` }, + ], + }, +]; export function Footer() { return ( @@ -68,25 +75,17 @@ export function Footer() { > <RiTwitterXLine className="size-5" /> </Link> - <Link - href={SOCIAL_LINKS.discord} - className="text-muted-foreground hover:text-foreground transition-colors" - target="_blank" - rel="noopener noreferrer" - > - <RiDiscordFill className="size-5" /> - </Link> </div> </div> <div className="flex items-start justify-start gap-12 py-2"> - {(Object.keys(links) as Category[]).map((category) => ( + {linkCategories.map(({ category, links: categoryLinks }) => ( <div key={category} className="flex flex-col gap-2"> <h3 className="text-foreground font-semibold"> {capitalizeFirstLetter({ string: category })} </h3> <ul className="space-y-2 text-sm"> - {links[category].map((link) => ( + {categoryLinks.map((link) => ( <li key={link.href}> <Link href={link.href} diff --git a/apps/web/src/components/header.tsx b/apps/web/src/components/header.tsx index 99ba1730..44a2864f 100644 --- a/apps/web/src/components/header.tsx +++ b/apps/web/src/components/header.tsx @@ -16,7 +16,7 @@ import { } from "@hugeicons/core-free-icons"; import { HugeiconsIcon } from "@hugeicons/react"; import { cn } from "@/utils/ui"; -import { DEFAULT_LOGO_URL, SITE_URL } from "@/site/brand"; +import { DEFAULT_LOGO_URL } from "@/site/brand"; import { SOCIAL_LINKS } from "@/site/social"; import { ContextMenu, @@ -42,10 +42,6 @@ export function Header() { label: "Sponsors", href: "/sponsors", }, - { - label: "Blog", - href: "/blog", - }, ]; return ( diff --git a/apps/web/src/components/landing/handlebars.tsx b/apps/web/src/components/landing/handlebars.tsx deleted file mode 100644 index 4a81cdba..00000000 --- a/apps/web/src/components/landing/handlebars.tsx +++ /dev/null @@ -1,203 +0,0 @@ -"use client"; - -import { - type PropsWithChildren, - useEffect, - useLayoutEffect, - useRef, - useState, -} from "react"; - -type HandlebarsProps = PropsWithChildren; - -const MIN_HANDLE_SEPARATION_PX = 60; -const MASK_GRADIENT_EDGE_PADDING_PX = 10; -const HANDLEBARS_ROTATE_DEG = 2.76; -const RIGHT_HANDLE_LEFT_OFFSET_PX = -30; - -export function Handlebars({ children }: HandlebarsProps) { - const containerRef = useRef<HTMLDivElement>(null); - const leftHandleRef = useRef<HTMLDivElement>(null); - const rightHandleRef = useRef<HTMLDivElement>(null); - - const [width, setWidth] = useState(0); - const [leftHandle, setLeftHandle] = useState(0); - const [rightHandle, setRightHandle] = useState(0); - - const widthRef = useRef(0); - const leftHandlePositionRef = useRef(0); - const rightHandlePositionRef = useRef(0); - - const dragRef = useRef<{ - isDragging: boolean; - side: "left" | "right" | null; - pointerId: number | null; - startX: number; - initialPosition: number; - }>({ - isDragging: false, - side: null, - pointerId: null, - startX: 0, - initialPosition: 0, - }); - - useLayoutEffect(() => { - const container = containerRef.current; - if (!container) return; - - const updateWidth = () => { - const newWidth = container.offsetWidth; - setWidth(newWidth); - setRightHandle(newWidth); - }; - - const observer = new ResizeObserver(updateWidth); - observer.observe(container); - updateWidth(); - - return () => observer.disconnect(); - }, []); - - useEffect(() => { - widthRef.current = width; - leftHandlePositionRef.current = leftHandle; - rightHandlePositionRef.current = rightHandle; - }, [leftHandle, rightHandle, width]); - - useEffect(() => { - const handlePointerMove = (event: PointerEvent) => { - const { isDragging, side, pointerId, startX, initialPosition } = - dragRef.current; - - if (!isDragging) return; - if (pointerId !== null && event.pointerId !== pointerId) return; - if (!side) return; - - const deltaX = event.clientX - startX; - - if (side === "left") { - const maxLeft = Math.max( - 0, - rightHandlePositionRef.current - MIN_HANDLE_SEPARATION_PX, - ); - const nextLeftHandle = Math.max( - 0, - Math.min(maxLeft, initialPosition + deltaX), - ); - setLeftHandle(nextLeftHandle); - return; - } - - const minRight = Math.min( - widthRef.current, - leftHandlePositionRef.current + MIN_HANDLE_SEPARATION_PX, - ); - const nextRightHandle = Math.max( - minRight, - Math.min(widthRef.current, initialPosition + deltaX), - ); - setRightHandle(nextRightHandle); - }; - - const handlePointerEnd = (event: PointerEvent) => { - const { pointerId } = dragRef.current; - if (pointerId !== null && event.pointerId !== pointerId) return; - - dragRef.current.isDragging = false; - dragRef.current.side = null; - dragRef.current.pointerId = null; - }; - - window.addEventListener("pointermove", handlePointerMove); - window.addEventListener("pointerup", handlePointerEnd); - window.addEventListener("pointercancel", handlePointerEnd); - - return () => { - window.removeEventListener("pointermove", handlePointerMove); - window.removeEventListener("pointerup", handlePointerEnd); - window.removeEventListener("pointercancel", handlePointerEnd); - }; - }, []); - - const hasMeasuredWidth = width > 0; - const leftGradientPercent = hasMeasuredWidth - ? (leftHandle / (width - MASK_GRADIENT_EDGE_PADDING_PX)) * 100 - : 0; - const rightGradientPercent = hasMeasuredWidth - ? (rightHandle / (width + MASK_GRADIENT_EDGE_PADDING_PX)) * 100 - : 100; - const textMask = hasMeasuredWidth - ? `linear-gradient(90deg, - rgba(255, 255, 255, 0) 0%, - rgba(255, 255, 255, 0) ${leftGradientPercent}%, - rgba(0, 0, 0) ${leftGradientPercent}%, - rgba(0, 0, 0) ${rightGradientPercent}%, - rgba(255, 255, 255, 0) ${rightGradientPercent}%, - rgba(255, 255, 255, 0) 100%)` - : undefined; - - return ( - <div className="flex justify-center gap-4 leading-16"> - <div - ref={containerRef} - className="relative mt-0.5" - style={{ transform: `rotate(-${HANDLEBARS_ROTATE_DEG}deg)` }} - > - <div className="absolute inset-0 z-10 flex size-full justify-between rounded-2xl border border-yellow-500"> - <div - ref={leftHandleRef} - className="bg-background absolute left-0 z-20 flex h-full w-7 cursor-ew-resize touch-none items-center justify-center rounded-full border border-yellow-500 select-none" - style={{ - translate: `${leftHandle}px 0`, - }} - onPointerDown={(event) => { - event.preventDefault(); - leftHandleRef.current?.setPointerCapture(event.pointerId); - dragRef.current.isDragging = true; - dragRef.current.side = "left"; - dragRef.current.pointerId = event.pointerId; - dragRef.current.startX = event.clientX; - dragRef.current.initialPosition = leftHandlePositionRef.current; - }} - > - <div className="h-8 w-2 rounded-full bg-yellow-500" /> - </div> - - <div - ref={rightHandleRef} - className="bg-background absolute z-20 flex h-full w-7 cursor-ew-resize touch-none items-center justify-center rounded-full border border-yellow-500 select-none" - style={{ - left: hasMeasuredWidth - ? `${RIGHT_HANDLE_LEFT_OFFSET_PX}px` - : undefined, - right: hasMeasuredWidth ? undefined : "0px", - translate: hasMeasuredWidth ? `${rightHandle}px 0` : undefined, - }} - onPointerDown={(event) => { - event.preventDefault(); - rightHandleRef.current?.setPointerCapture(event.pointerId); - dragRef.current.isDragging = true; - dragRef.current.side = "right"; - dragRef.current.pointerId = event.pointerId; - dragRef.current.startX = event.clientX; - dragRef.current.initialPosition = rightHandlePositionRef.current; - }} - > - <div className="h-8 w-2 rounded-full bg-yellow-500" /> - </div> - </div> - - <span - className="relative z-0 inline-flex size-full items-center justify-center rounded-2xl px-9 will-change-auto" - style={{ - mask: textMask, - WebkitMask: textMask, - }} - > - {children} - </span> - </div> - </div> - ); -} diff --git a/apps/web/src/components/landing/hero.tsx b/apps/web/src/components/landing/hero.tsx deleted file mode 100644 index ac49e4be..00000000 --- a/apps/web/src/components/landing/hero.tsx +++ /dev/null @@ -1,41 +0,0 @@ -"use client"; - -import { Button } from "../ui/button"; -import { ArrowRight } from "lucide-react"; -import Image from "next/image"; -import { Handlebars } from "./handlebars"; -import Link from "next/link"; - -export function Hero() { - return ( - <div className="flex min-h-[calc(100svh-4.5rem)] flex-col items-center justify-between px-4 text-center"> - <Image - className="absolute top-0 left-0 -z-50 size-full object-cover opacity-85 invert dark:invert-0" - src="/landing-page-dark.png" - height={1903.5} - width={1269} - alt="OpenCut video editor landing page background" - /> - <div className="mx-auto flex w-full max-w-3xl flex-1 flex-col justify-center"> - <div className="inline-block text-4xl font-bold tracking-tighter md:text-[4rem]"> - <h1>The open source</h1> - <Handlebars>Video editor</Handlebars> - </div> - - <p className="text-muted-foreground mx-auto mt-10 max-w-xl text-base font-light tracking-wide sm:text-xl"> - A simple but powerful video editor that gets the job done. Works on - any platform. - </p> - - <div className="mt-8 flex justify-center gap-8"> - <Link href="/projects"> - <Button type="submit" size="lg" className="h-11 text-base"> - Try early beta - <ArrowRight className="ml-0.5" /> - </Button> - </Link> - </div> - </div> - </div> - ); -} diff --git a/apps/web/src/feedback/components/feedback-popover.tsx b/apps/web/src/feedback/components/feedback-popover.tsx deleted file mode 100644 index f94b7253..00000000 --- a/apps/web/src/feedback/components/feedback-popover.tsx +++ /dev/null @@ -1,242 +0,0 @@ -"use client"; - -import { useState } from "react"; -import { useForm } from "react-hook-form"; -import { toast } from "sonner"; -import { ClockIcon } from "lucide-react"; -import { - Popover, - PopoverContent, - PopoverTrigger, -} from "@/components/ui/popover"; -import { Button } from "@/components/ui/button"; -import { Textarea } from "@/components/ui/textarea"; -import { Spinner } from "@/components/ui/spinner"; -import { - Form, - FormField, - FormItem, - FormControl, - clearFormDraft, -} from "@/components/ui/form"; -import type { FeedbackEntry } from "../types"; - -const PERSIST_KEY = "feedback-draft"; -const HISTORY_KEY = "feedback-history"; -const MAX_HISTORY = 20; - -interface FeedbackFormValues { - message: string; -} - -function readHistory(): FeedbackEntry[] { - try { - const stored = localStorage.getItem(HISTORY_KEY); - return stored ? (JSON.parse(stored) as FeedbackEntry[]) : []; - } catch { - return []; - } -} - -function writeHistory({ entries }: { entries: FeedbackEntry[] }): void { - try { - localStorage.setItem(HISTORY_KEY, JSON.stringify(entries)); - } catch { - // localStorage may be full or unavailable - } -} - -function useFeedback() { - const [entries, setEntries] = useState<FeedbackEntry[]>(readHistory); - const [isSubmitting, setIsSubmitting] = useState(false); - - async function submit({ - values, - onSuccess, - }: { - values: FeedbackFormValues; - onSuccess: () => void; - }) { - if (isSubmitting) return; - setIsSubmitting(true); - - try { - const res = await fetch("/api/feedback", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(values), - }); - - if (!res.ok) { - const data = await res.json().catch(() => null); - throw new Error(data?.error ?? "Failed to submit"); - } - - const { entry } = await res.json(); - const next = [entry, ...entries].slice(0, MAX_HISTORY); - setEntries(next); - writeHistory({ entries: next }); - onSuccess(); - toast.success("Feedback sent"); - } catch (error) { - toast.error( - error instanceof Error ? error.message : "Failed to send feedback", - ); - } finally { - setIsSubmitting(false); - } - } - - return { entries, isSubmitting, submit }; -} - -export function FeedbackPopover() { - const [open, setOpen] = useState(false); - - return ( - <Popover open={open} onOpenChange={setOpen}> - <PopoverTrigger asChild> - <Button variant="outline" className="h-8"> - Send feedback - </Button> - </PopoverTrigger> - <PopoverContent align="end" className="w-80 p-0"> - <FeedbackPopoverContent onClose={() => setOpen(false)} /> - </PopoverContent> - </Popover> - ); -} - -type View = "compose" | "history"; - -function FeedbackPopoverContent({ onClose }: { onClose: () => void }) { - const { entries, isSubmitting, submit } = useFeedback(); - const [view, setView] = useState<View>("compose"); - - const form = useForm<FeedbackFormValues>({ - defaultValues: { message: "" }, - }); - - async function handleSubmit(values: FeedbackFormValues) { - await submit({ - values, - onSuccess: () => { - form.reset({ message: "" }); - clearFormDraft({ key: PERSIST_KEY }); - onClose(); - }, - }); - } - - if (view === "history") { - return ( - <div className="flex flex-col"> - <div - className="max-h-72 overflow-y-auto divide-y" - style={{ - maskImage: - "linear-gradient(to bottom, black 80%, transparent 100%)", - }} - > - {entries.map((entry) => ( - <FeedbackEntryItem key={entry.id} entry={entry} /> - ))} - </div> - <div className="border-t px-3 py-2"> - <button - type="button" - onClick={() => setView("compose")} - className="text-xs text-muted-foreground/60 hover:text-muted-foreground transition-colors" - > - ← Back - </button> - </div> - </div> - ); - } - - return ( - <div className="flex flex-col"> - <Form persistKey={PERSIST_KEY} {...form}> - <form onSubmit={form.handleSubmit(handleSubmit)} className="flex flex-col"> - <FormField - control={form.control} - name="message" - render={({ field }) => ( - <FormItem> - <FormControl> - <Textarea - placeholder="Thoughts, bugs, ideas..." - className="min-h-[7rem] text-sm p-3 bg-background shadow-none border-none! resize-none" - {...field} - /> - </FormControl> - </FormItem> - )} - /> - <div className="flex items-center justify-between border-t px-3 py-2"> - {entries.length > 0 ? ( - <button - type="button" - onClick={() => setView("history")} - className="flex items-center gap-1.5 text-xs text-muted-foreground/60 hover:text-muted-foreground transition-colors" - > - <ClockIcon className="size-3" /> - {entries.length} - </button> - ) : ( - <span /> - )} - <div className="flex gap-2"> - {!form.watch("message").trim() && ( - <Button - type="button" - variant="outline" - size="sm" - onClick={onClose} - > - Cancel - </Button> - )} - <Button - type="submit" - size="sm" - disabled={isSubmitting || !form.watch("message").trim()} - > - {isSubmitting ? <Spinner /> : "Send"} - </Button> - </div> - </div> - </form> - </Form> - </div> - ); -} - -function relativeDate(iso: string): string { - const diff = Date.now() - new Date(iso).getTime(); - const mins = Math.floor(diff / 60_000); - if (mins < 1) return "just now"; - if (mins < 60) return `${mins}m ago`; - const hrs = Math.floor(mins / 60); - if (hrs < 24) return `${hrs}h ago`; - const days = Math.floor(hrs / 24); - if (days < 7) return `${days}d ago`; - return new Date(iso).toLocaleDateString(undefined, { - month: "short", - day: "numeric", - }); -} - -function FeedbackEntryItem({ entry }: { entry: FeedbackEntry }) { - return ( - <div className="px-3 py-2.5"> - <p className="text-sm text-muted-foreground leading-snug whitespace-pre-wrap break-words"> - {entry.message} - </p> - <span className="mt-1 block text-[11px] text-muted-foreground/50"> - {relativeDate(entry.createdAt)} - </span> - </div> - ); -} diff --git a/apps/web/src/feedback/index.ts b/apps/web/src/feedback/index.ts deleted file mode 100644 index dfd0743c..00000000 --- a/apps/web/src/feedback/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { submitFeedback } from "./queries"; -export { MAX_MESSAGE_LENGTH } from "./types"; -export type { FeedbackEntry, SubmitFeedbackInput } from "./types"; diff --git a/apps/web/src/feedback/queries.ts b/apps/web/src/feedback/queries.ts deleted file mode 100644 index 121c6fe9..00000000 --- a/apps/web/src/feedback/queries.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { db, feedback } from "@/db"; -import { generateUUID } from "@/utils/id"; -import type { FeedbackEntry, SubmitFeedbackInput } from "./types"; - -export async function submitFeedback({ - message, -}: SubmitFeedbackInput): Promise<FeedbackEntry> { - const id = generateUUID(); - const now = new Date(); - - await db.insert(feedback).values({ id, message, createdAt: now }); - - return { id, message, createdAt: now.toISOString() }; -} diff --git a/apps/web/src/feedback/types.ts b/apps/web/src/feedback/types.ts deleted file mode 100644 index 358b1354..00000000 --- a/apps/web/src/feedback/types.ts +++ /dev/null @@ -1,11 +0,0 @@ -export const MAX_MESSAGE_LENGTH = 5000; - -export interface FeedbackEntry { - id: string; - message: string; - createdAt: string; -} - -export interface SubmitFeedbackInput { - message: string; -} diff --git a/apps/web/src/site/social.ts b/apps/web/src/site/social.ts index 571e45d5..365bfabe 100644 --- a/apps/web/src/site/social.ts +++ b/apps/web/src/site/social.ts @@ -1,5 +1,4 @@ export const SOCIAL_LINKS = { x: "https://x.com/opencutapp", github: "https://github.com/OpenCut-app/OpenCut", - discord: "https://discord.com/invite/Mu3acKZvCp", }; -- cgit v1.3.1