Skip to content

Commit 07bb7c8

Browse files
committedSep 6, 2024
basic sync
1 parent 420fb5e commit 07bb7c8

File tree

15 files changed

+393
-180
lines changed

15 files changed

+393
-180
lines changed
 

‎bun.lockb

-287 KB
Binary file not shown.

‎package-lock.json

+103-85
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎src/actions/noteActions.ts

+57-17
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,61 @@
11
"use server";
22

3+
import { validateRequest } from "@/auth";
4+
import { db } from "@/db/drizzle";
35
import { notes } from "@/db/schema";
4-
import { neon } from "@neondatabase/serverless";
5-
import { eq } from "drizzle-orm";
6-
import { drizzle } from "drizzle-orm/neon-http";
7-
8-
export async function updateNote(id: string, content: string) {
9-
const sql = neon(process.env.DATABASE_URL!);
10-
const db = drizzle(sql);
11-
12-
const note = await db
13-
.update(notes)
14-
.set({
15-
content,
16-
})
17-
.where(eq(notes.noteId, id))
18-
.returning();
19-
20-
return note;
6+
import { eq, InferSelectModel } from "drizzle-orm";
7+
8+
export type NoteError = {
9+
error: string;
10+
}
11+
12+
export type SelectNote = InferSelectModel<typeof notes> & { error: null };
13+
14+
export async function getOrCreateNote(): Promise<NoteError | SelectNote> {
15+
const { user } = await validateRequest();
16+
if (!user) {
17+
return {
18+
error: "Unauthorized",
19+
};
20+
}
21+
22+
const result = await db.select().from(notes).where(eq(notes.owner, user.id));
23+
24+
if (result.length === 0) {
25+
const note = await db.insert(notes).values({
26+
owner: user.id,
27+
content: `# Welcome!\n\nWrite something here.\n`
28+
}).returning();
29+
30+
if (note.length === 0) {
31+
return {
32+
error: "Failed to create note.",
33+
}
34+
}
35+
36+
return { error: null, ...note[0] };
37+
}
38+
39+
return { error: null, ...result[0] };
40+
}
41+
42+
export async function updateNote(content: string) {
43+
const { user } = await validateRequest();
44+
if (!user) {
45+
return {
46+
error: "Unauthorized",
47+
};
48+
}
49+
50+
const result = await db.update(notes).set({
51+
content
52+
}).where(eq(notes.owner, user.id)).returning();
53+
54+
if (result.length === 0) {
55+
return {
56+
error: "Note not found",
57+
}
58+
}
59+
60+
return { error: null, ...result[0] };
2161
}

‎src/app/(auth-pages)/login/github/callback/route.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ export async function GET(request: Request): Promise<Response> {
7272
}
7373
}
7474

75-
interface GitHubUser {
75+
export interface GitHubUser {
7676
id: number;
7777
login: string;
7878
}

‎src/app/(auth-pages)/login/page.tsx

+10-4
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,14 @@
11
export default async function Page() {
22
return (
3-
<>
4-
<h1>Sign in</h1>
5-
<a href="/login/github">Sign in with GitHub</a>
6-
</>
3+
<div className="m-auto flex flex-col items-center gap-16">
4+
<span className="text-4xl">OAuth Login</span>
5+
<a href="/login/github">
6+
<div className="bg-gray-900 text-gray-100 hover:text-white shadow font-bold text-sm py-3 px-4 rounded flex justify-start items-center cursor-pointer w-64 mt-2">
7+
<svg viewBox="0 0 24 24" className="fill-current mr-3 w-6 h-6" xmlns="http://www.w3.org/2000/svg"><path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12" /></svg>
8+
<span className="border-l border-gray-800 h-6 w-1 block mr-1"></span>
9+
<span className="pl-3">Sign up with Github</span>
10+
</div>
11+
</a>
12+
</div>
713
);
814
}

‎src/app/(auth-pages)/logout/page.tsx

-31
This file was deleted.

‎src/app/_components/Editor.tsx

+39-22
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"use client";
22

3-
import { useLocalStorage } from "@/lib/hooks";
3+
import { useNoteLocalStorage } from "@/lib/hooks";
44
import {
55
MDXEditor,
66
MDXEditorMethods,
@@ -21,10 +21,10 @@ import { FC, Fragment, useContext, useEffect } from "react";
2121
import { Toolbar } from "./EditorToolbar";
2222
import { debounce } from "@/lib/utils";
2323
import { TimedMessageContext } from "../_contexts/TimedMessageContext";
24-
import { SAVED_NOTE, SAVING_NOTE } from "@/lib/snippets";
24+
import { UserAuthContext } from "../_contexts/UserAuthContext";
25+
import { FAIL_SAVE_NOTE, NOT_LOGGED_IN, SAVED_NOTE, SAVING_NOTE } from "@/lib/snippets";
2526
import { updateNote } from "@/actions/noteActions";
26-
27-
// import "@mdxeditor/editor/style.css";
27+
import { InsertNote } from "@/lib/note";
2828

2929
export const ALL_PLUGINS = [
3030
toolbarPlugin({ toolbarContents: () => <Toolbar /> }),
@@ -50,37 +50,54 @@ export const ALL_PLUGINS = [
5050
];
5151

5252
interface EditorProps {
53-
id: string;
53+
note: InsertNote;
5454
editorRef?: React.MutableRefObject<MDXEditorMethods | null>;
5555
}
5656

57-
const defaultMarkdown = `
58-
# Welcome!
59-
60-
Write something here.
61-
`;
62-
63-
/**
64-
* Extend this Component further with the necessary plugins or props you need.
65-
* proxying the ref is necessary. Next.js dynamically imported components don't support refs.
66-
*/
67-
const Editor: FC<EditorProps> = ({ id, editorRef }) => {
68-
const [content, setContent] = useLocalStorage(id, defaultMarkdown);
57+
const Editor: FC<EditorProps> = ({ note, editorRef }) => {
58+
console.log("Editor.tsx: EditorProps: ", note);
59+
const [curNote, setCurNote] = useNoteLocalStorage("note", note);
6960
const { setTimedValue } = useContext(TimedMessageContext);
61+
const user = useContext(UserAuthContext);
62+
63+
useEffect(() => {
64+
const updateFirst = async () => {
65+
if (user) {
66+
await updateNote(curNote.content || "");
67+
console.log("Editor.tsx: Updated note on first render.");
68+
}
69+
}
70+
updateFirst();
71+
}, []);
7072

7173
const handleChange = debounce(async (content: string) => {
72-
setTimedValue(SAVING_NOTE);
73-
setContent(content);
74-
setTimedValue(SAVED_NOTE, 2000);
75-
}, 1000);
74+
setTimedValue(<SAVING_NOTE/>);
75+
setCurNote((note) => { return {
76+
...note,
77+
content,
78+
updatedAt: new Date(),
79+
}});
80+
81+
if (user) {
82+
const updatedNote = await updateNote(content);
83+
if (!updatedNote.error) {
84+
setTimedValue(<SAVED_NOTE/>, 4000);
85+
} else {
86+
setTimedValue(<FAIL_SAVE_NOTE/>, 4000);
87+
}
88+
} else {
89+
setTimedValue(<NOT_LOGGED_IN/>, 4000);
90+
}
91+
92+
}, 800);
7693

7794
return (
7895
<div className="flex flex-col justify-center align-middle mx-auto max-w-full">
7996
<Fragment>
8097
<MDXEditor
8198
onChange={handleChange}
8299
ref={editorRef}
83-
markdown={content}
100+
markdown={curNote.content || ""}
84101
plugins={ALL_PLUGINS}
85102
contentEditableClassName="prose prose-invert max-w-[80ch] mx-auto"
86103
className="dark-theme dark-editor scroll-p-16"

‎src/app/_components/Header.tsx

+47-5
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,17 @@
1+
import { lucia, validateRequest } from "@/auth";
2+
import { redirect } from "next/navigation";
3+
import { cookies } from "next/headers";
14
import { TimedDisplay } from "./TimedDisplay";
5+
import Link from "next/link";
26

3-
export default function Header() {
7+
export default async function Header() {
8+
const { user } = await validateRequest();
49
return (
510
<header className="flex h-14 lg:h-[60px] justify-between gap-4 border-b bg-gray-800/40 px-4">
611
<div className="flex h-[60px] items-center">
7-
<a
12+
<Link
813
className="flex items-center justify-between gap-2 font-semibold"
9-
href="#"
14+
href="/"
1015
>
1116
<svg
1217
xmlns="http://www.w3.org/2000/svg"
@@ -30,8 +35,45 @@ export default function Header() {
3035
<div className="flex items-center text-xs text-gray-500 px-2">
3136
<TimedDisplay />
3237
</div>
33-
</a>
38+
</Link>
3439
</div>
35-
</header>
40+
<div className="flex items-center gap-2">
41+
{user ?
42+
(
43+
<>
44+
<span className="text-gray-300">Hi, <strong>{user.username}</strong></span>
45+
<form action={logout}>
46+
<button>Sign out</button>
47+
</form>
48+
</>
49+
) : (
50+
<Link href="/login" className="text-blue-400">
51+
<button>Login</button>
52+
</Link>
53+
)}
54+
</div>
55+
56+
</header >
3657
);
3758
}
59+
60+
61+
async function logout(): Promise<ActionResult> {
62+
"use server";
63+
const { session } = await validateRequest();
64+
if (!session) {
65+
return {
66+
error: "Unauthorized"
67+
};
68+
}
69+
70+
await lucia.invalidateSession(session.id);
71+
72+
const sessionCookie = lucia.createBlankSessionCookie();
73+
cookies().set(sessionCookie.name, sessionCookie.value, sessionCookie.attributes);
74+
return redirect("/login");
75+
}
76+
77+
interface ActionResult {
78+
error: string | null;
79+
}

‎src/app/_contexts/UserAuthContext.tsx

+18
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
"use client";
2+
import { createContext } from "react";
3+
import { validateRequest } from "@/auth";
4+
import { type User } from "lucia";
5+
6+
export const UserAuthContext = createContext<User | null>(null);
7+
export function UserAuthProvider({
8+
children,
9+
user
10+
}: {
11+
children: React.ReactNode;
12+
user: User | null;
13+
}) {
14+
return (
15+
<UserAuthContext.Provider value={user}>
16+
{children}
17+
</UserAuthContext.Provider>);
18+
}

‎src/app/favicon.ico

-10.3 KB
Binary file not shown.

‎src/app/page.tsx

+36-4
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,48 @@
11
import dynamic from "next/dynamic";
22
import { Suspense } from "react";
3+
import { validateRequest } from "@/auth";
4+
import { UserAuthProvider } from "./_contexts/UserAuthContext";
5+
import { createDefaultNote, InsertNote } from "@/lib/note";
6+
import { getOrCreateNote, SelectNote } from "@/actions/noteActions";
37

48
const EditorComp = dynamic(() => import("./_components/Editor"), {
59
ssr: false,
610
});
7-
const id = "note";
811

9-
export default function App() {
12+
export default async function App() {
13+
const { user } = await validateRequest();
14+
15+
// serverNote acts as a note from a remote source.
16+
// If there is a server, it will be populated with the note from the server.
17+
// Otherwise it will be populated with a default note created at Date(0)
18+
// The Editor will merge this data with localStorage to determine which is latest.
19+
let serverNote: InsertNote;
20+
21+
// If the user is logged in, check if they have a note. If not, create one.
22+
// Pass the note to the editor.
23+
if (user) {
24+
const note = await getOrCreateNote();
25+
// TODO: fix typing pepegery here
26+
if (!note.error) {
27+
serverNote = {
28+
owner: (note as SelectNote).owner,
29+
content: (note as SelectNote).content,
30+
createdAt: (note as SelectNote).createdAt,
31+
updatedAt: (note as SelectNote).updatedAt,
32+
}
33+
} else {
34+
serverNote = createDefaultNote();
35+
}
36+
} else {
37+
serverNote = createDefaultNote();
38+
}
39+
1040
return (
1141
<main className="flex flex-1 flex-col max-w-full">
12-
<Suspense fallback={<div>loading...</div>}>
13-
<EditorComp id={id} />
42+
<Suspense fallback={null}>
43+
<UserAuthProvider user={user}>
44+
<EditorComp note={serverNote} />
45+
</UserAuthProvider>
1446
</Suspense>
1547
</main>
1648
);

‎src/db/schema.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { InferSelectModel } from "drizzle-orm";
12
import { text, pgTable, serial, uuid, timestamp, integer } from "drizzle-orm/pg-core";
23

34
export const userTable = pgTable("user", {
@@ -30,4 +31,4 @@ export const notes = pgTable("notes", {
3031
.notNull()
3132
.defaultNow()
3233
.$onUpdate(() => new Date()),
33-
});
34+
});

‎src/lib/hooks.ts

+50
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { useState, useRef } from "react";
2+
import { InsertNote } from "./note";
23

34
export function useLocalStorage<T>(key: string, initialValue: T) {
45
const [storedValue, setStoredValue] = useState<T>(() => {
@@ -25,6 +26,54 @@ export function useLocalStorage<T>(key: string, initialValue: T) {
2526
return [storedValue, setValue] as const;
2627
}
2728

29+
/**
30+
* Sync the initialValue note with an existing note from localStorage if it exists.
31+
*/
32+
export function useNoteLocalStorage(key: string, defaultNote: InsertNote) {
33+
const [storedValue, setStoredValue] = useState<InsertNote>(() => {
34+
35+
const item = window.localStorage.getItem(key);
36+
if (!item) {
37+
window.localStorage.setItem(key, JSON.stringify(defaultNote));
38+
console.log("No note found in localStorage. Setting default note.", defaultNote);
39+
return defaultNote;
40+
}
41+
const parsedItem: InsertNote = JSON.parse(item);
42+
43+
console.log("Parsed item from localStorage: ", parsedItem);
44+
console.log("Default note: ", defaultNote);
45+
46+
console.log("Parsed item updatedAt: ", new Date(parsedItem.updatedAt!));
47+
console.log("Default note updatedAt: ", new Date(defaultNote.updatedAt!));
48+
49+
console.log("Parsed item updatedAt >= Default note updatedAt: ", new Date(parsedItem.updatedAt!) >= new Date(defaultNote.updatedAt!));
50+
51+
if (new Date(parsedItem.updatedAt!) >= new Date(defaultNote.updatedAt!)) {
52+
// If the stored note is newer than the default note, return the stored note
53+
console.log("Found newer note in localStorage. Using stored note.", parsedItem);
54+
return parsedItem;
55+
} else {
56+
// Otherwise, set the stored note to the default note
57+
window.localStorage.setItem(key, JSON.stringify(defaultNote));
58+
console.log("Found older note in localStorage. Setting default note.", defaultNote);
59+
return defaultNote;
60+
}
61+
});
62+
63+
const setValue = (value: InsertNote | ((val: InsertNote) => InsertNote)) => {
64+
try {
65+
const valueToStore = value instanceof Function ? value(storedValue) : value;
66+
setStoredValue(valueToStore);
67+
window.localStorage.setItem(key, JSON.stringify(valueToStore));
68+
} catch (error) {
69+
console.error(error);
70+
}
71+
};
72+
73+
return [storedValue, setValue] as const;
74+
75+
}
76+
2877
export function useTimed<T>(defaultValue: T) {
2978
const [value, setValue] = useState<T>(defaultValue);
3079
const timeoutRef = useRef<ReturnType<typeof setTimeout> | undefined>(
@@ -52,3 +101,4 @@ export function useTimed<T>(defaultValue: T) {
52101

53102
return [value, setTimedValue] as const;
54103
}
104+

‎src/lib/note.ts

+13-7
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,13 @@
1-
export type Note = {
2-
id: string;
3-
title: string;
4-
content: string;
5-
createdAt: number;
6-
updatedAt: number;
7-
};
1+
import { notes } from "@/db/schema";
2+
import { InferInsertModel } from "drizzle-orm";
3+
4+
5+
export type InsertNote = InferInsertModel<typeof notes>;
6+
export function createDefaultNote(): InsertNote {
7+
return {
8+
content: `# Welcome!\n\nWrite something here.\n`,
9+
owner: "",
10+
createdAt: new Date(),
11+
updatedAt: new Date(0),
12+
};
13+
}

‎src/lib/snippets.tsx

+17-3
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,28 @@
1-
import { Loader, CheckCircle } from "lucide-react";
2-
export const SAVING_NOTE = (
1+
import { Loader, CheckCircle, CircleX, CircleAlert } from "lucide-react";
2+
export const SAVING_NOTE = () => (
33
<div className="flex items-center gap-2">
44
<span>Saving note...</span>
55
<Loader className="animate-spin w-4 h-4 mr-2" />
66
</div>
77
);
88

9-
export const SAVED_NOTE = (
9+
export const SAVED_NOTE = () => (
1010
<div className="flex items-center gap-2">
1111
<CheckCircle className="w-4 h-4 text-green-500 mr-2" />
1212
<span>Note saved!</span>
1313
</div>
1414
);
15+
16+
export const FAIL_SAVE_NOTE = () => (
17+
<div className="flex items-center gap-2">
18+
<CircleX className="w-4 h-4 text-red-500 mr-2" />
19+
<span>Note saved locally, failed to save note remotely.</span>
20+
</div>
21+
)
22+
23+
export const NOT_LOGGED_IN = () => (
24+
<div className="flex items-center gap-2">
25+
<CircleAlert className="w-4 h-4 text-red-500 mr-2" />
26+
<span>Note saved locally, login to sync notes.</span>
27+
</div>
28+
)

0 commit comments

Comments
 (0)
Please sign in to comment.