feat: add Vercel deployment with GitHub Issues backend

- Replace Go backend with GitHub Issues API for storing memos
- Each memo is stored as a GitHub Issue with labels for tags, pinned status, and visibility
- Add GitHub authentication using Personal Access Token
- Add dark mode support based on system preference
- Configure Vercel for static site deployment
- Add deployment documentation

https://claude.ai/code/session_019sc9EGpjtZnZGhpXEXRTai
This commit is contained in:
Claude 2026-02-04 17:15:54 +00:00
parent cf65f0867b
commit 9afb7b5f0a
No known key found for this signature in database
16 changed files with 9792 additions and 80 deletions

123
DEPLOY.md Normal file
View File

@ -0,0 +1,123 @@
# Deploy Memos to Vercel with GitHub Issues Backend
This version of Memos uses GitHub Issues as a backend to store your memos. Each memo is stored as a GitHub Issue in your repository.
## How It Works
- **Memos** are stored as GitHub Issues with the `memo` label
- **Tags** are stored as GitHub labels prefixed with `tag:`
- **Pinned memos** have the `pinned` label
- **Private memos** have the `private` label
- **Deleted memos** are closed and marked with `archived` label
## Prerequisites
1. A GitHub account
2. A GitHub repository (can be private) to store your memos
3. A GitHub Personal Access Token with `repo` scope
## Setup Instructions
### 1. Create a GitHub Repository
Create a new repository on GitHub to store your memos. You can make it private if you want your memos to be private.
### 2. Generate a Personal Access Token
1. Go to [GitHub Settings > Developer Settings > Personal Access Tokens](https://github.com/settings/tokens/new?scopes=repo&description=Memos%20App)
2. Create a new token with the `repo` scope
3. Copy the token (you won't be able to see it again)
### 3. Deploy to Vercel
#### Option A: Deploy with Vercel Button
[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https://github.com/YOUR_USERNAME/memos)
#### Option B: Deploy via CLI
1. Install Vercel CLI:
```bash
npm i -g vercel
```
2. Deploy:
```bash
vercel
```
3. Follow the prompts to configure your project.
#### Option C: Deploy via Vercel Dashboard
1. Go to [Vercel Dashboard](https://vercel.com/dashboard)
2. Click "Add New..." > "Project"
3. Import your GitHub repository
4. Vercel will automatically detect the configuration
5. Click "Deploy"
### 4. Configure the App
After deployment, visit your deployed URL and:
1. Enter your GitHub Personal Access Token
2. Enter the repository owner (your GitHub username or organization)
3. Enter the repository name
4. Click "Connect"
Your memos will now be stored as GitHub Issues in your repository.
## Features
- **Markdown Support**: Write memos in Markdown with full GFM support
- **Tags**: Use `#hashtags` in your memos to organize them
- **Pinning**: Pin important memos to the top
- **Search**: Search through all your memos
- **Dark Mode**: Automatic dark mode based on system preference
- **Privacy**: Your token is stored locally in your browser
## Local Development
```bash
# Navigate to web directory
cd web
# Install dependencies
npm install
# Start development server
npm run dev
```
Visit `http://localhost:3001` to see the app.
## Build for Production
```bash
cd web
npm run build
```
The built files will be in the `web/dist` directory.
## Security Notes
- Your GitHub token is stored only in your browser's localStorage
- The token is never sent to any server except GitHub's API
- Using a private repository ensures your memos are not publicly visible
- The token only needs `repo` scope for reading/writing issues
## Troubleshooting
### "Not authenticated" error
- Check that your token hasn't expired
- Verify the token has `repo` scope
- Clear localStorage and sign in again
### "Not Found" error
- Verify the repository owner and name are correct
- Ensure your token has access to the repository
### Labels not appearing
- The app automatically creates required labels on first use
- If labels are missing, try signing out and signing in again

31
vercel.json Normal file
View File

@ -0,0 +1,31 @@
{
"$schema": "https://openapi.vercel.sh/vercel.json",
"buildCommand": "cd web && npm install && npm run build",
"outputDirectory": "web/dist",
"framework": "vite",
"rewrites": [
{
"source": "/(.*)",
"destination": "/index.html"
}
],
"headers": [
{
"source": "/(.*)",
"headers": [
{
"key": "X-Content-Type-Options",
"value": "nosniff"
},
{
"key": "X-Frame-Options",
"value": "DENY"
},
{
"key": "X-XSS-Protection",
"value": "1; mode=block"
}
]
}
]
}

8355
web/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -4,6 +4,7 @@
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"release": "vite build --mode release --outDir=../server/router/frontend/dist --emptyOutDir",
"lint": "tsc --noEmit --skipLibCheck && biome check src",
"lint:fix": "biome check --write src",

117
web/src/github-app/App.tsx Normal file
View File

@ -0,0 +1,117 @@
import { useEffect, useState } from "react";
import { useAuth, useMemos, useTags } from "../lib/hooks";
import type { Memo } from "../lib/github";
import { SignIn } from "./SignIn";
import { MemoEditor } from "./MemoEditor";
import { MemoList } from "./MemoList";
import { Header } from "./Header";
import { Sidebar } from "./Sidebar";
export default function App() {
const { isAuthenticated, user, loading: authLoading, signOut } = useAuth();
const { memos, loading: memosLoading, fetchMemos, createMemo, updateMemo, deleteMemo, togglePin } = useMemos();
const { tags, fetchTags } = useTags();
const [selectedTag, setSelectedTag] = useState<string | null>(null);
const [editingMemo, setEditingMemo] = useState<Memo | null>(null);
const [searchQuery, setSearchQuery] = useState("");
useEffect(() => {
if (isAuthenticated) {
fetchMemos();
fetchTags();
}
}, [isAuthenticated, fetchMemos, fetchTags]);
useEffect(() => {
if (isAuthenticated && selectedTag) {
fetchMemos({ labels: `tag:${selectedTag}` });
} else if (isAuthenticated) {
fetchMemos();
}
}, [selectedTag, isAuthenticated, fetchMemos]);
if (authLoading) {
return (
<div className="min-h-screen flex items-center justify-center bg-zinc-50 dark:bg-zinc-900">
<div className="text-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto"></div>
<p className="mt-4 text-zinc-600 dark:text-zinc-400">Loading...</p>
</div>
</div>
);
}
if (!isAuthenticated) {
return <SignIn />;
}
const handleCreateMemo = async (content: string, options?: { visibility?: "PUBLIC" | "PRIVATE"; pinned?: boolean }) => {
await createMemo(content, options);
fetchTags();
};
const handleUpdateMemo = async (id: string, content: string, options?: { visibility?: "PUBLIC" | "PRIVATE"; pinned?: boolean }) => {
await updateMemo(id, content, options);
setEditingMemo(null);
fetchTags();
};
const handleDeleteMemo = async (id: string) => {
if (confirm("Are you sure you want to delete this memo?")) {
await deleteMemo(id);
}
};
const filteredMemos = searchQuery
? memos.filter((m) => m.content.toLowerCase().includes(searchQuery.toLowerCase()))
: memos;
// Sort: pinned first, then by date
const sortedMemos = [...filteredMemos].sort((a, b) => {
if (a.pinned && !b.pinned) return -1;
if (!a.pinned && b.pinned) return 1;
return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime();
});
return (
<div className="min-h-screen bg-zinc-50 dark:bg-zinc-900">
<Header
user={user}
onSignOut={signOut}
searchQuery={searchQuery}
onSearchChange={setSearchQuery}
/>
<div className="flex">
<Sidebar
tags={tags}
selectedTag={selectedTag}
onSelectTag={setSelectedTag}
memoCount={memos.length}
/>
<main className="flex-1 max-w-3xl mx-auto px-4 py-6">
<MemoEditor
onSubmit={handleCreateMemo}
editingMemo={editingMemo}
onUpdate={handleUpdateMemo}
onCancelEdit={() => setEditingMemo(null)}
/>
{memosLoading ? (
<div className="text-center py-8">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-600 mx-auto"></div>
</div>
) : (
<MemoList
memos={sortedMemos}
onEdit={setEditingMemo}
onDelete={handleDeleteMemo}
onTogglePin={togglePin}
/>
)}
</main>
</div>
</div>
);
}

View File

@ -0,0 +1,66 @@
import type { GitHubUser } from "../lib/github";
interface HeaderProps {
user: GitHubUser | null;
onSignOut: () => void;
searchQuery: string;
onSearchChange: (query: string) => void;
}
export function Header({ user, onSignOut, searchQuery, onSearchChange }: HeaderProps) {
return (
<header className="sticky top-0 z-10 bg-white dark:bg-zinc-800 border-b border-zinc-200 dark:border-zinc-700">
<div className="max-w-6xl mx-auto px-4 py-3 flex items-center justify-between gap-4">
<div className="flex items-center gap-3">
<h1 className="text-xl font-bold text-zinc-900 dark:text-zinc-100">Memos</h1>
</div>
<div className="flex-1 max-w-md">
<div className="relative">
<input
type="text"
value={searchQuery}
onChange={(e) => onSearchChange(e.target.value)}
placeholder="Search memos..."
className="w-full px-4 py-2 pl-10 bg-zinc-100 dark:bg-zinc-700 border-0 rounded-lg text-zinc-900 dark:text-zinc-100 placeholder-zinc-500 focus:ring-2 focus:ring-blue-500"
/>
<svg
className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-zinc-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
</div>
</div>
<div className="flex items-center gap-3">
{user && (
<div className="flex items-center gap-2">
<img
src={user.avatar_url}
alt={user.login}
className="w-8 h-8 rounded-full"
/>
<span className="text-sm text-zinc-700 dark:text-zinc-300 hidden sm:inline">
{user.login}
</span>
</div>
)}
<button
onClick={onSignOut}
className="text-sm text-zinc-600 dark:text-zinc-400 hover:text-zinc-900 dark:hover:text-zinc-100"
>
Sign out
</button>
</div>
</div>
</header>
);
}

View File

@ -0,0 +1,142 @@
import { useEffect, useRef, useState } from "react";
import type { Memo } from "../lib/github";
interface MemoEditorProps {
onSubmit: (content: string, options?: { visibility?: "PUBLIC" | "PRIVATE"; pinned?: boolean }) => Promise<void>;
editingMemo?: Memo | null;
onUpdate?: (id: string, content: string, options?: { visibility?: "PUBLIC" | "PRIVATE"; pinned?: boolean }) => Promise<void>;
onCancelEdit?: () => void;
}
export function MemoEditor({ onSubmit, editingMemo, onUpdate, onCancelEdit }: MemoEditorProps) {
const [content, setContent] = useState("");
const [visibility, setVisibility] = useState<"PUBLIC" | "PRIVATE">("PRIVATE");
const [pinned, setPinned] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [isExpanded, setIsExpanded] = useState(false);
const textareaRef = useRef<HTMLTextAreaElement>(null);
useEffect(() => {
if (editingMemo) {
setContent(editingMemo.content);
setVisibility(editingMemo.visibility);
setPinned(editingMemo.pinned);
setIsExpanded(true);
textareaRef.current?.focus();
}
}, [editingMemo]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!content.trim() || isSubmitting) return;
setIsSubmitting(true);
try {
if (editingMemo && onUpdate) {
await onUpdate(editingMemo.id, content, { visibility, pinned });
} else {
await onSubmit(content, { visibility, pinned });
}
setContent("");
setVisibility("PRIVATE");
setPinned(false);
setIsExpanded(false);
} finally {
setIsSubmitting(false);
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
handleSubmit(e);
}
if (e.key === "Escape") {
if (editingMemo && onCancelEdit) {
onCancelEdit();
setContent("");
setIsExpanded(false);
}
}
};
const handleCancel = () => {
if (editingMemo && onCancelEdit) {
onCancelEdit();
}
setContent("");
setVisibility("PRIVATE");
setPinned(false);
setIsExpanded(false);
};
return (
<div className="bg-white dark:bg-zinc-800 rounded-lg shadow-sm border border-zinc-200 dark:border-zinc-700 mb-6">
<form onSubmit={handleSubmit}>
<div className="p-4">
<textarea
ref={textareaRef}
value={content}
onChange={(e) => setContent(e.target.value)}
onFocus={() => setIsExpanded(true)}
onKeyDown={handleKeyDown}
placeholder="What's on your mind? Use #tags to organize..."
className="w-full min-h-[100px] resize-none bg-transparent text-zinc-900 dark:text-zinc-100 placeholder-zinc-400 focus:outline-none"
rows={isExpanded ? 5 : 2}
/>
</div>
{isExpanded && (
<div className="px-4 pb-4 flex items-center justify-between border-t border-zinc-100 dark:border-zinc-700 pt-3">
<div className="flex items-center gap-4">
<label className="flex items-center gap-2 text-sm text-zinc-600 dark:text-zinc-400">
<select
value={visibility}
onChange={(e) => setVisibility(e.target.value as "PUBLIC" | "PRIVATE")}
className="bg-zinc-100 dark:bg-zinc-700 border-0 rounded px-2 py-1 text-sm focus:ring-2 focus:ring-blue-500"
>
<option value="PRIVATE">Private</option>
<option value="PUBLIC">Public</option>
</select>
</label>
<label className="flex items-center gap-2 text-sm text-zinc-600 dark:text-zinc-400 cursor-pointer">
<input
type="checkbox"
checked={pinned}
onChange={(e) => setPinned(e.target.checked)}
className="rounded border-zinc-300 dark:border-zinc-600 text-blue-600 focus:ring-blue-500"
/>
Pin
</label>
</div>
<div className="flex items-center gap-2">
{(content || editingMemo) && (
<button
type="button"
onClick={handleCancel}
className="px-3 py-1.5 text-sm text-zinc-600 dark:text-zinc-400 hover:text-zinc-900 dark:hover:text-zinc-100"
>
Cancel
</button>
)}
<button
type="submit"
disabled={!content.trim() || isSubmitting}
className="px-4 py-1.5 bg-blue-600 text-white text-sm rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{isSubmitting ? "Saving..." : editingMemo ? "Update" : "Save"}
</button>
</div>
</div>
)}
</form>
{isExpanded && (
<div className="px-4 pb-3 text-xs text-zinc-400">
Press <kbd className="px-1.5 py-0.5 bg-zinc-100 dark:bg-zinc-700 rounded">Cmd/Ctrl + Enter</kbd> to save
</div>
)}
</div>
);
}

View File

@ -0,0 +1,179 @@
import type { Memo } from "../lib/github";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
interface MemoListProps {
memos: Memo[];
onEdit: (memo: Memo) => void;
onDelete: (id: string) => void;
onTogglePin: (id: string, pinned: boolean) => void;
}
export function MemoList({ memos, onEdit, onDelete, onTogglePin }: MemoListProps) {
if (memos.length === 0) {
return (
<div className="text-center py-12">
<div className="text-zinc-400 dark:text-zinc-500 mb-2">
<svg className="w-12 h-12 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>
</div>
<p className="text-zinc-500 dark:text-zinc-400">No memos yet</p>
<p className="text-sm text-zinc-400 dark:text-zinc-500 mt-1">
Start writing your first memo above
</p>
</div>
);
}
return (
<div className="space-y-4">
{memos.map((memo) => (
<MemoCard
key={memo.id}
memo={memo}
onEdit={() => onEdit(memo)}
onDelete={() => onDelete(memo.id)}
onTogglePin={() => onTogglePin(memo.id, !memo.pinned)}
/>
))}
</div>
);
}
interface MemoCardProps {
memo: Memo;
onEdit: () => void;
onDelete: () => void;
onTogglePin: () => void;
}
function MemoCard({ memo, onEdit, onDelete, onTogglePin }: MemoCardProps) {
const formatDate = (date: Date) => {
const now = new Date();
const diff = now.getTime() - date.getTime();
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
if (days === 0) {
const hours = Math.floor(diff / (1000 * 60 * 60));
if (hours === 0) {
const minutes = Math.floor(diff / (1000 * 60));
return minutes <= 1 ? "Just now" : `${minutes} minutes ago`;
}
return hours === 1 ? "1 hour ago" : `${hours} hours ago`;
}
if (days === 1) return "Yesterday";
if (days < 7) return `${days} days ago`;
return date.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: date.getFullYear() !== now.getFullYear() ? "numeric" : undefined,
});
};
return (
<article className="bg-white dark:bg-zinc-800 rounded-lg shadow-sm border border-zinc-200 dark:border-zinc-700 overflow-hidden">
<div className="p-4">
{/* Header */}
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2 text-sm text-zinc-500 dark:text-zinc-400">
<img
src={memo.creator.avatarUrl}
alt={memo.creator.name}
className="w-5 h-5 rounded-full"
/>
<span>{memo.creator.name}</span>
<span>·</span>
<time>{formatDate(memo.updatedAt)}</time>
{memo.pinned && (
<>
<span>·</span>
<span className="text-yellow-600 dark:text-yellow-500 flex items-center gap-1">
<svg className="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 20 20">
<path d="M10 2a1 1 0 011 1v1.323l3.954 1.582 1.599-.8a1 1 0 01.894 1.79l-1.233.616 1.738 5.42a1 1 0 01-.285 1.05A3.989 3.989 0 0115 15a3.989 3.989 0 01-2.667-1.019 1 1 0 01-.285-1.05l1.715-5.349L10 6.477V16h2a1 1 0 110 2H8a1 1 0 110-2h2V6.477L6.237 7.582l1.715 5.349a1 1 0 01-.285 1.05A3.989 3.989 0 015 15a3.989 3.989 0 01-2.667-1.019 1 1 0 01-.285-1.05l1.738-5.42-1.233-.617a1 1 0 01.894-1.788l1.599.799L9 4.323V3a1 1 0 011-1z" />
</svg>
Pinned
</span>
</>
)}
{memo.visibility === "PRIVATE" && (
<>
<span>·</span>
<span className="text-zinc-400 flex items-center gap-1">
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"
/>
</svg>
Private
</span>
</>
)}
</div>
{/* Actions dropdown */}
<div className="relative group">
<button className="p-1 text-zinc-400 hover:text-zinc-600 dark:hover:text-zinc-300 rounded">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 5v.01M12 12v.01M12 19v.01M12 6a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2z"
/>
</svg>
</button>
<div className="absolute right-0 top-full mt-1 w-36 bg-white dark:bg-zinc-700 rounded-lg shadow-lg border border-zinc-200 dark:border-zinc-600 opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all z-10">
<button
onClick={onEdit}
className="w-full px-4 py-2 text-left text-sm text-zinc-700 dark:text-zinc-200 hover:bg-zinc-100 dark:hover:bg-zinc-600 first:rounded-t-lg"
>
Edit
</button>
<button
onClick={onTogglePin}
className="w-full px-4 py-2 text-left text-sm text-zinc-700 dark:text-zinc-200 hover:bg-zinc-100 dark:hover:bg-zinc-600"
>
{memo.pinned ? "Unpin" : "Pin"}
</button>
<button
onClick={onDelete}
className="w-full px-4 py-2 text-left text-sm text-red-600 dark:text-red-400 hover:bg-zinc-100 dark:hover:bg-zinc-600 last:rounded-b-lg"
>
Delete
</button>
</div>
</div>
</div>
{/* Content */}
<div className="prose prose-zinc dark:prose-invert prose-sm max-w-none">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{memo.content}</ReactMarkdown>
</div>
{/* Tags */}
{memo.tags.length > 0 && (
<div className="mt-3 flex flex-wrap gap-2">
{memo.tags.map((tag) => (
<span
key={tag}
className="text-xs px-2 py-1 bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400 rounded"
>
#{tag}
</span>
))}
</div>
)}
</div>
</article>
);
}

View File

@ -0,0 +1,64 @@
interface SidebarProps {
tags: string[];
selectedTag: string | null;
onSelectTag: (tag: string | null) => void;
memoCount: number;
}
export function Sidebar({ tags, selectedTag, onSelectTag, memoCount }: SidebarProps) {
return (
<aside className="hidden md:block w-64 border-r border-zinc-200 dark:border-zinc-700 bg-white dark:bg-zinc-800 min-h-[calc(100vh-57px)]">
<div className="p-4">
<nav className="space-y-1">
<button
onClick={() => onSelectTag(null)}
className={`w-full flex items-center justify-between px-3 py-2 rounded-lg text-left transition-colors ${
selectedTag === null
? "bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300"
: "text-zinc-700 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-700"
}`}
>
<span className="flex items-center gap-2">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"
/>
</svg>
All Memos
</span>
<span className="text-xs bg-zinc-200 dark:bg-zinc-600 px-2 py-0.5 rounded-full">
{memoCount}
</span>
</button>
</nav>
{tags.length > 0 && (
<div className="mt-6">
<h3 className="px-3 text-xs font-semibold text-zinc-500 dark:text-zinc-400 uppercase tracking-wider mb-2">
Tags
</h3>
<nav className="space-y-1">
{tags.map((tag) => (
<button
key={tag}
onClick={() => onSelectTag(tag)}
className={`w-full flex items-center gap-2 px-3 py-2 rounded-lg text-left transition-colors ${
selectedTag === tag
? "bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300"
: "text-zinc-700 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-700"
}`}
>
<span className="text-blue-500">#</span>
{tag}
</button>
))}
</nav>
</div>
)}
</div>
</aside>
);
}

View File

@ -0,0 +1,144 @@
import { useState } from "react";
import { useAuth } from "../lib/hooks";
export function SignIn() {
const { signIn, error, loading } = useAuth();
const [token, setToken] = useState("");
const [owner, setOwner] = useState("");
const [repo, setRepo] = useState("");
const [step, setStep] = useState<"info" | "form">("info");
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
try {
await signIn(token, owner, repo);
} catch {
// Error is handled by useAuth
}
};
return (
<div className="min-h-screen flex items-center justify-center bg-zinc-50 dark:bg-zinc-900 px-4">
<div className="max-w-md w-full">
<div className="text-center mb-8">
<h1 className="text-3xl font-bold text-zinc-900 dark:text-zinc-100">Memos</h1>
<p className="mt-2 text-zinc-600 dark:text-zinc-400">
Your personal notes, stored in GitHub Issues
</p>
</div>
<div className="bg-white dark:bg-zinc-800 rounded-lg shadow-lg p-6">
{step === "info" ? (
<div className="space-y-4">
<h2 className="text-lg font-semibold text-zinc-900 dark:text-zinc-100">
How it works
</h2>
<ul className="space-y-3 text-sm text-zinc-600 dark:text-zinc-400">
<li className="flex items-start gap-2">
<span className="text-blue-600 font-bold">1.</span>
<span>Create a GitHub repository to store your memos (can be private)</span>
</li>
<li className="flex items-start gap-2">
<span className="text-blue-600 font-bold">2.</span>
<span>
Generate a Personal Access Token at{" "}
<a
href="https://github.com/settings/tokens/new?scopes=repo&description=Memos%20App"
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline"
>
GitHub Settings
</a>{" "}
with <code className="bg-zinc-100 dark:bg-zinc-700 px-1 rounded">repo</code> scope
</span>
</li>
<li className="flex items-start gap-2">
<span className="text-blue-600 font-bold">3.</span>
<span>Enter your token and repository details below</span>
</li>
</ul>
<button
onClick={() => setStep("form")}
className="w-full mt-4 bg-blue-600 text-white py-2 px-4 rounded-lg hover:bg-blue-700 transition-colors"
>
Continue
</button>
</div>
) : (
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-zinc-700 dark:text-zinc-300 mb-1">
GitHub Personal Access Token
</label>
<input
type="password"
value={token}
onChange={(e) => setToken(e.target.value)}
placeholder="ghp_xxxxxxxxxxxx"
className="w-full px-3 py-2 border border-zinc-300 dark:border-zinc-600 rounded-lg bg-white dark:bg-zinc-700 text-zinc-900 dark:text-zinc-100 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-zinc-700 dark:text-zinc-300 mb-1">
Repository Owner (username or org)
</label>
<input
type="text"
value={owner}
onChange={(e) => setOwner(e.target.value)}
placeholder="your-username"
className="w-full px-3 py-2 border border-zinc-300 dark:border-zinc-600 rounded-lg bg-white dark:bg-zinc-700 text-zinc-900 dark:text-zinc-100 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-zinc-700 dark:text-zinc-300 mb-1">
Repository Name
</label>
<input
type="text"
value={repo}
onChange={(e) => setRepo(e.target.value)}
placeholder="my-memos"
className="w-full px-3 py-2 border border-zinc-300 dark:border-zinc-600 rounded-lg bg-white dark:bg-zinc-700 text-zinc-900 dark:text-zinc-100 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
required
/>
</div>
{error && (
<div className="text-red-600 dark:text-red-400 text-sm bg-red-50 dark:bg-red-900/20 p-3 rounded-lg">
{error}
</div>
)}
<div className="flex gap-2">
<button
type="button"
onClick={() => setStep("info")}
className="px-4 py-2 text-zinc-600 dark:text-zinc-400 hover:text-zinc-900 dark:hover:text-zinc-100"
>
Back
</button>
<button
type="submit"
disabled={loading}
className="flex-1 bg-blue-600 text-white py-2 px-4 rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? "Connecting..." : "Connect"}
</button>
</div>
</form>
)}
</div>
<p className="mt-4 text-center text-xs text-zinc-500 dark:text-zinc-500">
Your token is stored locally in your browser and never sent to any server except GitHub.
</p>
</div>
</div>
);
}

View File

@ -0,0 +1,10 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import App from "./App";
import "../index.css";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<App />
</StrictMode>
);

321
web/src/lib/github.ts Normal file
View File

@ -0,0 +1,321 @@
// GitHub API service for storing memos as GitHub Issues
const GITHUB_API = "https://api.github.com";
export interface GitHubUser {
id: number;
login: string;
name: string | null;
avatar_url: string;
email: string | null;
}
export interface GitHubIssue {
id: number;
number: number;
title: string;
body: string | null;
state: "open" | "closed";
labels: Array<{ name: string; color: string }>;
created_at: string;
updated_at: string;
user: GitHubUser;
}
export interface Memo {
id: string;
content: string;
visibility: "PUBLIC" | "PRIVATE";
pinned: boolean;
tags: string[];
createdAt: Date;
updatedAt: Date;
creator: {
name: string;
avatarUrl: string;
};
}
// Convert GitHub Issue to Memo
function issueToMemo(issue: GitHubIssue): Memo {
const labels = issue.labels.map((l) => l.name);
const isPinned = labels.includes("pinned");
const isPrivate = labels.includes("private");
const tags = labels.filter((l) => l.startsWith("tag:")).map((l) => l.replace("tag:", ""));
return {
id: issue.number.toString(),
content: issue.body || "",
visibility: isPrivate ? "PRIVATE" : "PUBLIC",
pinned: isPinned,
tags,
createdAt: new Date(issue.created_at),
updatedAt: new Date(issue.updated_at),
creator: {
name: issue.user.login,
avatarUrl: issue.user.avatar_url,
},
};
}
// Extract tags from content (e.g., #tag1 #tag2)
function extractTags(content: string): string[] {
const tagRegex = /#([a-zA-Z0-9_-]+)/g;
const matches = content.match(tagRegex) || [];
return [...new Set(matches.map((tag) => tag.slice(1)))];
}
class GitHubMemoService {
private token: string | null = null;
private repo: string | null = null;
private owner: string | null = null;
setAuth(token: string, owner: string, repo: string) {
this.token = token;
this.owner = owner;
this.repo = repo;
}
clearAuth() {
this.token = null;
this.owner = null;
this.repo = null;
}
isAuthenticated(): boolean {
return !!this.token && !!this.owner && !!this.repo;
}
private async request<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
if (!this.token) {
throw new Error("Not authenticated");
}
const response = await fetch(`${GITHUB_API}${endpoint}`, {
...options,
headers: {
Authorization: `Bearer ${this.token}`,
Accept: "application/vnd.github.v3+json",
"Content-Type": "application/json",
...options.headers,
},
});
if (!response.ok) {
const error = await response.json().catch(() => ({}));
throw new Error(error.message || `GitHub API error: ${response.status}`);
}
return response.json();
}
async getCurrentUser(): Promise<GitHubUser> {
return this.request<GitHubUser>("/user");
}
async listMemos(options: { state?: "open" | "closed" | "all"; labels?: string } = {}): Promise<Memo[]> {
const params = new URLSearchParams({
state: options.state || "open",
per_page: "100",
sort: "updated",
direction: "desc",
});
if (options.labels) {
params.set("labels", options.labels);
}
// Filter by "memo" label to distinguish from other issues
const labelsParam = options.labels ? `memo,${options.labels}` : "memo";
params.set("labels", labelsParam);
const issues = await this.request<GitHubIssue[]>(
`/repos/${this.owner}/${this.repo}/issues?${params.toString()}`
);
return issues.map(issueToMemo);
}
async getMemo(id: string): Promise<Memo> {
const issue = await this.request<GitHubIssue>(
`/repos/${this.owner}/${this.repo}/issues/${id}`
);
return issueToMemo(issue);
}
async createMemo(content: string, options: { visibility?: "PUBLIC" | "PRIVATE"; pinned?: boolean } = {}): Promise<Memo> {
const tags = extractTags(content);
const labels = ["memo"];
if (options.visibility === "PRIVATE") {
labels.push("private");
}
if (options.pinned) {
labels.push("pinned");
}
tags.forEach((tag) => labels.push(`tag:${tag}`));
// Create a title from first line or first 50 chars
const firstLine = content.split("\n")[0].replace(/^#\s*/, "").trim();
const title = firstLine.slice(0, 50) || "Untitled memo";
const issue = await this.request<GitHubIssue>(
`/repos/${this.owner}/${this.repo}/issues`,
{
method: "POST",
body: JSON.stringify({
title,
body: content,
labels,
}),
}
);
return issueToMemo(issue);
}
async updateMemo(
id: string,
content: string,
options: { visibility?: "PUBLIC" | "PRIVATE"; pinned?: boolean } = {}
): Promise<Memo> {
const tags = extractTags(content);
const labels = ["memo"];
if (options.visibility === "PRIVATE") {
labels.push("private");
}
if (options.pinned) {
labels.push("pinned");
}
tags.forEach((tag) => labels.push(`tag:${tag}`));
const firstLine = content.split("\n")[0].replace(/^#\s*/, "").trim();
const title = firstLine.slice(0, 50) || "Untitled memo";
const issue = await this.request<GitHubIssue>(
`/repos/${this.owner}/${this.repo}/issues/${id}`,
{
method: "PATCH",
body: JSON.stringify({
title,
body: content,
labels,
}),
}
);
return issueToMemo(issue);
}
async deleteMemo(id: string): Promise<void> {
// GitHub doesn't allow deleting issues, so we close it and add "archived" label
await this.request<GitHubIssue>(
`/repos/${this.owner}/${this.repo}/issues/${id}`,
{
method: "PATCH",
body: JSON.stringify({
state: "closed",
labels: ["memo", "archived"],
}),
}
);
}
async togglePin(id: string, pinned: boolean): Promise<Memo> {
const memo = await this.getMemo(id);
return this.updateMemo(id, memo.content, { ...memo, pinned });
}
async searchMemos(query: string): Promise<Memo[]> {
const searchQuery = `repo:${this.owner}/${this.repo} is:issue label:memo ${query}`;
const result = await this.request<{ items: GitHubIssue[] }>(
`/search/issues?q=${encodeURIComponent(searchQuery)}&per_page=50`
);
return result.items.map(issueToMemo);
}
async getAllTags(): Promise<string[]> {
const labels = await this.request<Array<{ name: string }>>(
`/repos/${this.owner}/${this.repo}/labels?per_page=100`
);
return labels
.filter((l) => l.name.startsWith("tag:"))
.map((l) => l.name.replace("tag:", ""));
}
async ensureLabelsExist(): Promise<void> {
const requiredLabels = [
{ name: "memo", color: "0969da", description: "A memo entry" },
{ name: "pinned", color: "fbca04", description: "Pinned memo" },
{ name: "private", color: "d73a4a", description: "Private memo" },
{ name: "archived", color: "6e7681", description: "Archived memo" },
];
const existingLabels = await this.request<Array<{ name: string }>>(
`/repos/${this.owner}/${this.repo}/labels?per_page=100`
);
const existingNames = new Set(existingLabels.map((l) => l.name));
for (const label of requiredLabels) {
if (!existingNames.has(label.name)) {
await this.request(`/repos/${this.owner}/${this.repo}/labels`, {
method: "POST",
body: JSON.stringify(label),
}).catch(() => {
// Label might already exist, ignore error
});
}
}
}
}
export const githubMemoService = new GitHubMemoService();
// Auth helpers
const TOKEN_KEY = "memos_github_token";
const OWNER_KEY = "memos_github_owner";
const REPO_KEY = "memos_github_repo";
export function saveAuth(token: string, owner: string, repo: string) {
localStorage.setItem(TOKEN_KEY, token);
localStorage.setItem(OWNER_KEY, owner);
localStorage.setItem(REPO_KEY, repo);
githubMemoService.setAuth(token, owner, repo);
}
export function loadAuth(): boolean {
const token = localStorage.getItem(TOKEN_KEY);
const owner = localStorage.getItem(OWNER_KEY);
const repo = localStorage.getItem(REPO_KEY);
if (token && owner && repo) {
githubMemoService.setAuth(token, owner, repo);
return true;
}
return false;
}
export function clearAuth() {
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(OWNER_KEY);
localStorage.removeItem(REPO_KEY);
githubMemoService.clearAuth();
}
export function getStoredRepo(): { owner: string; repo: string } | null {
const owner = localStorage.getItem(OWNER_KEY);
const repo = localStorage.getItem(REPO_KEY);
if (owner && repo) {
return { owner, repo };
}
return null;
}

186
web/src/lib/hooks.ts Normal file
View File

@ -0,0 +1,186 @@
// React hooks for GitHub-backed memos
import { useCallback, useEffect, useState } from "react";
import {
githubMemoService,
loadAuth,
saveAuth,
clearAuth as clearStoredAuth,
type Memo,
type GitHubUser
} from "./github";
export function useAuth() {
const [isAuthenticated, setIsAuthenticated] = useState(false);
const [user, setUser] = useState<GitHubUser | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const initAuth = async () => {
if (loadAuth()) {
try {
const currentUser = await githubMemoService.getCurrentUser();
setUser(currentUser);
setIsAuthenticated(true);
// Ensure required labels exist
await githubMemoService.ensureLabelsExist();
} catch (err) {
clearStoredAuth();
setError("Session expired. Please sign in again.");
}
}
setLoading(false);
};
initAuth();
}, []);
const signIn = useCallback(async (token: string, owner: string, repo: string) => {
setLoading(true);
setError(null);
try {
saveAuth(token, owner, repo);
const currentUser = await githubMemoService.getCurrentUser();
setUser(currentUser);
setIsAuthenticated(true);
await githubMemoService.ensureLabelsExist();
} catch (err) {
clearStoredAuth();
setError(err instanceof Error ? err.message : "Failed to sign in");
throw err;
} finally {
setLoading(false);
}
}, []);
const signOut = useCallback(() => {
clearStoredAuth();
setUser(null);
setIsAuthenticated(false);
}, []);
return { isAuthenticated, user, loading, error, signIn, signOut };
}
export function useMemos() {
const [memos, setMemos] = useState<Memo[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const fetchMemos = useCallback(async (options?: { labels?: string }) => {
if (!githubMemoService.isAuthenticated()) return;
setLoading(true);
setError(null);
try {
const data = await githubMemoService.listMemos(options);
setMemos(data);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to fetch memos");
} finally {
setLoading(false);
}
}, []);
const createMemo = useCallback(async (
content: string,
options?: { visibility?: "PUBLIC" | "PRIVATE"; pinned?: boolean }
) => {
setError(null);
try {
const memo = await githubMemoService.createMemo(content, options);
setMemos((prev) => [memo, ...prev]);
return memo;
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to create memo");
throw err;
}
}, []);
const updateMemo = useCallback(async (
id: string,
content: string,
options?: { visibility?: "PUBLIC" | "PRIVATE"; pinned?: boolean }
) => {
setError(null);
try {
const memo = await githubMemoService.updateMemo(id, content, options);
setMemos((prev) => prev.map((m) => (m.id === id ? memo : m)));
return memo;
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to update memo");
throw err;
}
}, []);
const deleteMemo = useCallback(async (id: string) => {
setError(null);
try {
await githubMemoService.deleteMemo(id);
setMemos((prev) => prev.filter((m) => m.id !== id));
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to delete memo");
throw err;
}
}, []);
const togglePin = useCallback(async (id: string, pinned: boolean) => {
setError(null);
try {
const memo = await githubMemoService.togglePin(id, pinned);
setMemos((prev) => prev.map((m) => (m.id === id ? memo : m)));
return memo;
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to toggle pin");
throw err;
}
}, []);
const searchMemos = useCallback(async (query: string) => {
if (!githubMemoService.isAuthenticated()) return;
setLoading(true);
setError(null);
try {
const data = await githubMemoService.searchMemos(query);
setMemos(data);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to search memos");
} finally {
setLoading(false);
}
}, []);
return {
memos,
loading,
error,
fetchMemos,
createMemo,
updateMemo,
deleteMemo,
togglePin,
searchMemos,
};
}
export function useTags() {
const [tags, setTags] = useState<string[]>([]);
const [loading, setLoading] = useState(false);
const fetchTags = useCallback(async () => {
if (!githubMemoService.isAuthenticated()) return;
setLoading(true);
try {
const data = await githubMemoService.getAllTags();
setTags(data);
} catch {
// Ignore errors for tags
} finally {
setLoading(false);
}
}, []);
return { tags, loading, fetchTags };
}

View File

@ -1,68 +1,30 @@
import "@github/relative-time-element";
import { QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import React, { useEffect, useRef } from "react";
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { Toaster } from "react-hot-toast";
import { RouterProvider } from "react-router-dom";
import "./i18n";
import App from "./github-app/App";
import "./index.css";
import { ErrorBoundary } from "@/components/ErrorBoundary";
import { AuthProvider, useAuth } from "@/contexts/AuthContext";
import { InstanceProvider, useInstance } from "@/contexts/InstanceContext";
import { ViewProvider } from "@/contexts/ViewContext";
import { queryClient } from "@/lib/query-client";
import router from "./router";
import { applyLocaleEarly } from "./utils/i18n";
import { applyThemeEarly } from "./utils/theme";
import "leaflet/dist/leaflet.css";
import "katex/dist/katex.min.css";
// Apply theme and locale early to prevent flash
applyThemeEarly();
applyLocaleEarly();
// Inner component that initializes contexts
function AppInitializer({ children }: { children: React.ReactNode }) {
const { isInitialized: authInitialized, initialize: initAuth } = useAuth();
const { isInitialized: instanceInitialized, initialize: initInstance } = useInstance();
const initStartedRef = useRef(false);
// Initialize on mount - run in parallel for better performance
useEffect(() => {
if (initStartedRef.current) return;
initStartedRef.current = true;
const init = async () => {
await Promise.all([initInstance(), initAuth()]);
};
init();
}, [initAuth, initInstance]);
if (!authInitialized || !instanceInitialized) {
return null;
}
return <>{children}</>;
// Apply dark mode based on system preference
const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
if (prefersDark) {
document.documentElement.classList.add("dark");
}
// Listen for system theme changes
window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change", (e) => {
if (e.matches) {
document.documentElement.classList.add("dark");
} else {
document.documentElement.classList.remove("dark");
}
});
function Main() {
return (
<ErrorBoundary>
<QueryClientProvider client={queryClient}>
<InstanceProvider>
<AuthProvider>
<ViewProvider>
<AppInitializer>
<RouterProvider router={router} />
<Toaster position="top-right" />
</AppInitializer>
</ViewProvider>
</AuthProvider>
</InstanceProvider>
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
</ErrorBoundary>
<StrictMode>
<App />
<Toaster position="top-right" />
</StrictMode>
);
}

View File

@ -101,3 +101,34 @@
--shadow-xl: var(--shadow-xl);
--shadow-2xl: var(--shadow-2xl);
}
/* Dark mode theme */
.dark {
--background: oklch(0.1451 0.0049 106.5859);
--foreground: oklch(0.9245 0.0138 92.9892);
--card: oklch(0.2071 0.0089 106.5859);
--card-foreground: oklch(0.9245 0.0138 92.9892);
--popover: oklch(0.2071 0.0089 106.5859);
--popover-foreground: oklch(0.9245 0.0138 92.9892);
--primary: oklch(0.55 0.12 250);
--primary-foreground: oklch(0.1451 0.0049 106.5859);
--secondary: oklch(0.2671 0.0096 106.5859);
--secondary-foreground: oklch(0.9245 0.0138 92.9892);
--muted: oklch(0.2671 0.0096 106.5859);
--muted-foreground: oklch(0.5559 0.0075 97.4233);
--accent: oklch(0.2671 0.0096 106.5859);
--accent-foreground: oklch(0.9245 0.0138 92.9892);
--destructive: oklch(0.55 0.15 27);
--destructive-foreground: oklch(0.9245 0.0138 92.9892);
--border: oklch(0.3271 0.0096 106.5859);
--input: oklch(0.3271 0.0096 106.5859);
--ring: oklch(0.55 0.12 250);
--sidebar: oklch(0.1908 0.0069 106.5859);
--sidebar-foreground: oklch(0.9245 0.0138 92.9892);
--sidebar-primary: oklch(0.55 0.12 250);
--sidebar-primary-foreground: oklch(0.1451 0.0049 106.5859);
--sidebar-accent: oklch(0.2671 0.0096 106.5859);
--sidebar-accent-foreground: oklch(0.9245 0.0138 92.9892);
--sidebar-border: oklch(0.3271 0.0096 106.5859);
--sidebar-ring: oklch(0.55 0.12 250);
}

View File

@ -3,32 +3,12 @@ import { resolve } from "path";
import { defineConfig } from "vite";
import tailwindcss from "@tailwindcss/vite";
let devProxyServer = "http://localhost:8081";
if (process.env.DEV_PROXY_SERVER && process.env.DEV_PROXY_SERVER.length > 0) {
console.log("Use devProxyServer from environment: ", process.env.DEV_PROXY_SERVER);
devProxyServer = process.env.DEV_PROXY_SERVER;
}
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react(), tailwindcss()],
server: {
host: "0.0.0.0",
port: 3001,
proxy: {
"^/api": {
target: devProxyServer,
xfwd: true,
},
"^/memos.api.v1": {
target: devProxyServer,
xfwd: true,
},
"^/file": {
target: devProxyServer,
xfwd: true,
},
},
},
resolve: {
alias: {
@ -36,12 +16,12 @@ export default defineConfig({
},
},
build: {
outDir: "dist",
rollupOptions: {
output: {
manualChunks: {
"utils-vendor": ["dayjs", "lodash-es"],
"mermaid-vendor": ["mermaid"],
"leaflet-vendor": ["leaflet", "react-leaflet"],
"react-vendor": ["react", "react-dom"],
"markdown-vendor": ["react-markdown", "remark-gfm"],
},
},
},