Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 10 additions & 3 deletions apps/web/app/(app)/new/page.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,15 @@
export const dynamic = "force-dynamic"

import { SnippetEditor } from "@/components/snippet-editor"

export default function NewSnippetPage() {
return (
<div className="p-6 lg:p-8">
<h1 className="font-heading text-2xl font-semibold tracking-tight">New Snippet</h1>
<p className="mt-1 text-sm text-muted-foreground">Save a code snippet — coming in Day 20.</p>
<div className="mx-auto w-full max-w-3xl px-4 py-8 sm:px-6 lg:px-8">
<div className="mb-5">
<h1 className="font-heading text-lg font-semibold tracking-tight">New Snippet</h1>
<p className="mt-0.5 text-sm text-muted-foreground">Save a code snippet to your library</p>
</div>
<SnippetEditor />
</div>
)
}
54 changes: 54 additions & 0 deletions apps/web/components/duplicate-warning.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
"use client"

import Link from "next/link"
import type { DuplicateMatch } from "@/types/api"

interface DuplicateWarningProps {
matches: DuplicateMatch[]
onDismiss: () => void
}

export function DuplicateWarning({ matches, onDismiss }: DuplicateWarningProps) {
const top = matches[0]
const pct = Math.round(top.similarity * 100)

return (
<div className="flex items-start gap-3 rounded-xl border border-pulse/30 bg-pulse/8 p-4 text-sm">
<span className="mt-0.5 shrink-0 text-pulse">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden>
<path
d="M8 1.5a6.5 6.5 0 1 1 0 13 6.5 6.5 0 0 1 0-13ZM8 5v4M8 10.5v1"
stroke="currentColor"
strokeWidth="1.4"
strokeLinecap="round"
/>
</svg>
</span>
<div className="flex-1 min-w-0">
<p className="font-medium text-foreground">
Similar snippet found ({pct}% match)
</p>
<p className="mt-0.5 text-muted-foreground">
You may already have this code saved.{" "}
<Link
href={`/snippets/${top.id}`}
className="text-primary underline underline-offset-2 hover:opacity-80 transition-opacity"
>
View existing snippet
</Link>
{matches.length > 1 && ` and ${matches.length - 1} more`}.
</p>
</div>
<button
type="button"
onClick={onDismiss}
className="shrink-0 rounded-lg p-1 text-muted-foreground hover:text-foreground active:scale-[0.96] transition-[scale,color] duration-100"
aria-label="Dismiss warning"
>
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden>
<path d="M2 2l10 10M12 2L2 12" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg>
</button>
</div>
)
}
317 changes: 317 additions & 0 deletions apps/web/components/snippet-editor.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,317 @@
"use client"

import { useState } from "react"
import { useRouter } from "next/navigation"
import { useForm } from "@tanstack/react-form"
import CodeMirror from "@uiw/react-codemirror"
import { vscodeDark } from "@uiw/codemirror-theme-vscode"
import { cn } from "@/lib/utils"
import { LANGUAGES, getLanguageExtension, type Language } from "@/lib/languages"
import { TagInput } from "@/components/tag-input"
import { DuplicateWarning } from "@/components/duplicate-warning"
import { Button } from "@/components/ui/button"
import { Textarea } from "@/components/ui/textarea"
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { useCreateSnippet, useSnippets } from "@/hooks/use-snippets"
import { api } from "@/lib/api"
import type { DuplicateMatch } from "@/types/api"

type FormValues = {
title: string
language: Language
code: string
description: string
tags: string[]
}

export function SnippetEditor() {
const router = useRouter()
const createSnippet = useCreateSnippet()
const { data: existingSnippets = [] } = useSnippets()

const [duplicates, setDuplicates] = useState<DuplicateMatch[]>([])
const [ignoreDuplicate, setIgnoreDuplicate] = useState(false)
const [pendingValues, setPendingValues] = useState<FormValues | null>(null)

const form = useForm({
defaultValues: {
title: "",
language: "typescript" as Language,
code: "",
description: "",
tags: [] as string[],
},
onSubmit: async ({ value }) => {
if (!value.title.trim() || !value.code.trim()) return

if (!ignoreDuplicate && value.code) {
const candidates = existingSnippets
.filter((s) => s.code)
.map((s) => ({ id: s.id, code: s.code! }))

if (candidates.length > 0) {
const result = await api.duplicate.check({
new_code: value.code,
existing_snippets: candidates,
})

if (result.is_duplicate) {
setDuplicates(result.matches)
setPendingValues(value)
return
}
}
}

await saveSnippet(value)
},
})

async function saveSnippet(value: FormValues) {
const tomorrow = new Date()
tomorrow.setDate(tomorrow.getDate() + 1)
const nextReview = tomorrow.toISOString().split("T")[0]

await createSnippet.mutateAsync({
title: value.title,
language: value.language,
code: value.code,
description: value.description || null,
tags: value.tags,
ease_factor: 2.5,
interval_days: 1,
repetitions: 0,
next_review: nextReview,
})

router.push("/dashboard")
}

const handleDismissDuplicate = () => {
setDuplicates([])
setPendingValues(null)
}

const handleSaveAnyway = async () => {
if (!pendingValues) return
setIgnoreDuplicate(true)
setDuplicates([])
await saveSnippet(pendingValues)
}

return (
<form
onSubmit={(e) => {
e.preventDefault()
form.handleSubmit()
}}
className="flex flex-col divide-y divide-border rounded-2xl border border-border bg-card overflow-hidden"
>
{/* ── Title ───────────────────────────────────────── */}
<form.Field
name="title"
validators={{ onBlur: ({ value }) => (!value.trim() ? "Title is required" : undefined) }}
>
{(field) => (
<div className="px-6 pt-6 pb-5">
<input
id="title"
type="text"
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
onBlur={field.handleBlur}
placeholder="Snippet title…"
autoFocus
className={cn(
"w-full bg-transparent font-heading text-xl font-semibold tracking-tight text-foreground outline-none placeholder:text-muted-foreground/35",
"text-wrap-balance",
)}
/>
{field.state.meta.errors.length > 0 && (
<p className="mt-1.5 text-xs text-destructive">
{String(field.state.meta.errors[0])}
</p>
)}
</div>
)}
</form.Field>

{/* ── Language + Tags ─────────────────────────────── */}
<div className="flex flex-wrap items-center gap-2 px-6 py-3">
<form.Field name="language">
{(field) => (
<Select
value={field.state.value}
onValueChange={(val) => field.handleChange(val as Language)}
>
<SelectTrigger
size="sm"
className="h-7 shrink-0 rounded-md border-border bg-muted/60 px-2.5 font-mono text-xs text-muted-foreground"
>
<SelectValue placeholder="Language" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{LANGUAGES.map((lang) => (
<SelectItem key={lang.value} value={lang.value}>
{lang.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
)}
</form.Field>

<div className="h-4 w-px bg-border" />

<form.Field name="tags">
{(field) => (
<TagInput
value={field.state.value}
onChange={(tags) => field.handleChange(tags)}
className="flex-1 min-w-36 min-h-0 border-0 bg-transparent px-0 py-0 rounded-none focus-within:ring-0"
placeholder="Add tags…"
/>
)}
</form.Field>
</div>

{/* ── Code editor ─────────────────────────────────── */}
<form.Field
name="code"
validators={{ onBlur: ({ value }) => (!value.trim() ? "Paste your code to save it" : undefined) }}
>
{(field) => (
<div className={cn(field.state.meta.errors.length > 0 && "ring-1 ring-inset ring-destructive/40")}>
<form.Subscribe selector={(s) => s.values.language}>
{(language) => (
<CodeMirror
value={field.state.value}
onChange={(val) => field.handleChange(val)}
theme={vscodeDark}
extensions={[
...(getLanguageExtension(language as Language)
? [getLanguageExtension(language as Language)!]
: []),
]}
minHeight="240px"
basicSetup={{
lineNumbers: true,
foldGutter: false,
highlightActiveLine: true,
autocompletion: true,
}}
className="text-sm"
style={{ fontFamily: "var(--font-mono, ui-monospace, monospace)" }}
/>
)}
</form.Subscribe>
{field.state.meta.errors.length > 0 && (
<p className="px-4 py-2 text-xs text-destructive bg-destructive/5">
{String(field.state.meta.errors[0])}
</p>
)}
</div>
)}
</form.Field>

{/* ── Notes / Description ─────────────────────────── */}
<form.Field name="description">
{(field) => (
<div className="px-6 py-4">
<Textarea
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
onBlur={field.handleBlur}
placeholder="Add a note — what does this solve? (optional)"
rows={2}
className="resize-none border-0 bg-transparent p-0 text-sm shadow-none focus-visible:ring-0 placeholder:text-muted-foreground/35 field-sizing-content min-h-0"
/>
</div>
)}
</form.Field>

{/* ── Duplicate warning ───────────────────────────── */}
{duplicates.length > 0 && (
<div className="px-6 py-4">
<DuplicateWarning matches={duplicates} onDismiss={handleDismissDuplicate} />
</div>
)}

{/* ── Footer ──────────────────────────────────────── */}
<div className="flex items-center justify-between gap-3 px-6 py-3.5 bg-muted/30">
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => router.back()}
className="text-muted-foreground hover:text-foreground active:scale-[0.96] transition-[transform,opacity] duration-100"
>
Cancel
</Button>

<div className="flex items-center gap-2">
{duplicates.length > 0 && (
<Button
type="button"
variant="outline"
size="sm"
onClick={handleSaveAnyway}
disabled={createSnippet.isPending}
className="active:scale-[0.96] transition-[transform,opacity] duration-100"
>
Save anyway
</Button>
)}

<form.Subscribe selector={(s) => s.isSubmitting}>
{(isSubmitting) => (
<Button
type="submit"
size="sm"
disabled={isSubmitting || createSnippet.isPending}
className="active:scale-[0.96] transition-[transform,opacity] duration-100"
>
{isSubmitting || createSnippet.isPending ? (
<span className="flex items-center gap-1.5">
<svg
className="size-3.5 animate-spin"
viewBox="0 0 24 24"
fill="none"
aria-hidden
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
/>
</svg>
Saving…
</span>
) : (
"Save snippet"
)}
</Button>
)}
</form.Subscribe>
</div>
</div>
</form>
)
}
Loading
Loading