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
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
DATABASE_URL=postgres://user:password@host:5432/memo
MEMO_PASSWORD=change-me
MEMO_SECRET=generate-a-long-random-string

# Optional: Obsidian/Notion-style image paste → R2/CDN Markdown
# MEDIA_UPLOAD_URL=https://chasehuh-media-upload.example.workers.dev
# MEDIA_UPLOAD_SECRET=shared-secret-with-upload-worker
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ Live: [memo.chasehuh.com](https://memo.chasehuh.com)
## Stack

- Next.js (App Router)
- CodeMirror 6 note editor (Zed-like chrome, soft wrap, Tab→spaces)
- Postgres (`pg`)
- Vercel + env-based auth gate

Expand All @@ -24,6 +25,10 @@ Fill in `.env.local`:
| `DATABASE_URL` | Postgres connection string |
| `MEMO_PASSWORD` | Login password |
| `MEMO_SECRET` | HMAC secret for session cookies |
| `MEDIA_UPLOAD_URL` | (optional) Upload worker URL for pasted/dropped images |
| `MEDIA_UPLOAD_SECRET` | (optional) Shared bearer secret for the upload worker |

When media env vars are set, pasting or dropping an image uploads it and inserts `![alt](url)` Markdown at the caret. CodeMirror renders that mark as an inline preview under the source line (Obsidian Live Preview–style). Drag the corner handle to rewrite Obsidian `|width` syntax (`![alt|480](url)`); double-click the handle to clear the width. The Markdown string remains the only source of truth for body sync and persistence.

```bash
pnpm dev
Expand Down
56 changes: 56 additions & 0 deletions app/api/upload/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { NextResponse } from "next/server";
import {
isAllowedImageType,
mediaUploadConfigured,
uploadImageBytes,
} from "@/lib/media";

export const runtime = "nodejs";

export async function POST(request: Request) {
if (!mediaUploadConfigured()) {
return NextResponse.json(
{ error: "Media upload is not configured" },
{ status: 503 },
);
}

try {
const contentType = request.headers.get("content-type") ?? "";

let bytes: ArrayBuffer;
let type: string;

if (contentType.includes("multipart/form-data")) {
const form = await request.formData();
const file = form.get("file");
if (!(file instanceof File)) {
return NextResponse.json({ error: "Missing file" }, { status: 400 });
}
type = file.type || "application/octet-stream";
bytes = await file.arrayBuffer();
} else {
type = contentType.split(";")[0].trim() || "application/octet-stream";
bytes = await request.arrayBuffer();
}

if (!isAllowedImageType(type)) {
return NextResponse.json(
{ error: `Unsupported image type: ${type}` },
{ status: 415 },
);
}

const uploaded = await uploadImageBytes(bytes, type);
return NextResponse.json(
{ url: uploaded.url, key: uploaded.key },
{ status: 201 },
);
} catch (error) {
console.error("image upload failed", error);
return NextResponse.json(
{ error: error instanceof Error ? error.message : "Upload failed" },
{ status: 500 },
);
}
}
107 changes: 36 additions & 71 deletions app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -398,100 +398,65 @@ button:disabled {
}

.zed-buffer {
/* Scrollport: Zed default scroll_beyond_last_line = one_page */
container-type: size;
flex: 1;
min-height: 0;
display: flex;
align-items: flex-start;
overflow: auto;
background: var(--c-editor);
}

.zed-gutter {
flex-shrink: 0;
width: var(--c-gutter-w);
overflow: visible;
padding: 20px 0;
/* Match .zed-editor__beyond so line numbers stay aligned while scrolling. */
padding-bottom: max(0px, calc(100cqh - 1lh));
align-items: stretch;
overflow: hidden;
background: var(--c-editor);
font-family: var(--c-buffer-font);
font-size: var(--c-buffer-size);
line-height: var(--c-buffer-line-height);
text-align: right;
user-select: none;
}

.zed-editor-column {
flex: 1;
min-width: 0;
display: flex;
.zed-buffer--cm {
flex-direction: column;
}

.zed-editor__beyond {
flex: 0 0 auto;
.zed-cm-host {
flex: 1;
min-height: 0;
width: 100%;
font-size: var(--c-buffer-size);
line-height: var(--c-buffer-line-height);
height: max(0px, calc(100cqh - 1lh));
cursor: text;
}

.zed-gutter__line {
box-sizing: content-box;
height: calc(var(--c-buffer-size) * var(--c-buffer-line-height));
padding-right: 12px;
color: var(--c-editor-line-number);
line-height: calc(var(--c-buffer-size) * var(--c-buffer-line-height));
height: 100%;
}

.zed-gutter[data-wrap="true"] .zed-gutter__line {
/* Height is set inline from measured visual rows when wrap is on. */
min-height: calc(var(--c-buffer-size) * var(--c-buffer-line-height));
.zed-cm-host .cm-editor {
height: 100%;
background: var(--c-editor);
}

.zed-gutter__line[data-active="true"] {
color: var(--c-editor-active-line-number);
.cm-md-image {
position: relative;
display: block;
max-width: calc(100% - 28px);
margin: 8px 28px 16px 14px;
line-height: 0;
}

.zed-editor__body {
.cm-md-image img {
display: block;
box-sizing: content-box;
flex: 0 0 auto;
min-width: 0;
width: 100%;
/* Height is set in JS from scrollHeight so the buffer (not the textarea) scrolls. */
height: auto;
min-height: calc(1lh);
border: 0;
outline: none;
resize: none;
background: transparent;
padding: 20px 28px 0 14px;
font-family: var(--c-buffer-font);
font-size: var(--c-buffer-size);
font-weight: 400;
line-height: var(--c-buffer-line-height);
color: var(--c-editor-fg);
caret-color: var(--c-text-accent);
tab-size: 4;
font-variant-ligatures: contextual;
font-feature-settings: "calt" 1;
white-space: pre-wrap;
overflow-wrap: anywhere;
overflow: hidden;
field-sizing: content;
border: 1px solid var(--c-border-variant);
user-select: none;
cursor: pointer;
}

.zed-editor__body[data-wrap="false"] {
white-space: pre;
overflow-wrap: normal;
.cm-md-image__handle {
position: absolute;
right: -5px;
bottom: -5px;
width: 14px;
height: 14px;
padding: 0;
border: 1px solid var(--c-border);
background: var(--c-elevated);
cursor: nwse-resize;
touch-action: none;
}

.zed-editor__body::placeholder {
color: var(--c-text-placeholder);
.cm-md-image__handle:hover,
.cm-md-image__handle:focus-visible {
background: var(--c-text-accent);
border-color: var(--c-text-accent);
outline: none;
}

.zed-empty {
Expand Down
Loading