mirror of https://github.com/usememos/memos.git
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:
parent
cf65f0867b
commit
9afb7b5f0a
|
|
@ -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
|
||||
|
||||
[](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
|
||||
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
|
|||
Loading…
Reference in New Issue