Squashed commit of the following:
commit d8926d88656f6eb1d7d69272e39875e383f91b14 Author: Ivan Dimitrov <ivan@idimitrov.dev> Date: Sat Nov 11 16:20:17 2023 +0200 maybe prod? commit 4a8854e7218f4b179310daadf9b95f6833686fdb Author: Ivan Dimitrov <ivan@idimitrov.dev> Date: Sat Nov 11 15:30:31 2023 +0200 better markdown commit c36d0308d2a9766174fde8432866e572a4b82d5e Author: Ivan Dimitrov <ivan@idimitrov.dev> Date: Sat Nov 11 14:50:00 2023 +0200 cases list styles commit 905d7edaccf2c2fd447f5cae35ef7bd88d86a2b1 Author: Ivan Dimitrov <ivan@idimitrov.dev> Date: Sat Nov 11 14:46:35 2023 +0200 contact page commit d09bd61eba53c403522d046a48ee6b66f62f9ffb Author: Ivan Dimitrov <ivan@idimitrov.dev> Date: Sat Nov 11 14:02:47 2023 +0200 more refactor and contact page commit d6257bb9ca35549031c9953fced97bdd53d10454 Author: Ivan Dimitrov <ivan@idimitrov.dev> Date: Sat Nov 11 13:56:47 2023 +0200 refactor commit 7d2a6ad6002a1e3d7b2ed83822caaffa49d3f37d Author: Ivan Dimitrov <ivan@idimitrov.dev> Date: Sat Nov 11 13:51:10 2023 +0200 working pages commit 6fa36883854c2e702a0c8b9fa6c9e397e4981575 Author: Ivan Dimitrov <ivan@idimitrov.dev> Date: Sat Nov 11 11:51:15 2023 +0200 style fixes commit 478412187792104b6c29e33def1cca0ef38ee2bf Author: Ivan Dimitrov <ivan@idimitrov.dev> Date: Sat Nov 11 08:42:50 2023 +0200 making markdown workd a little bit commit ba24b023fdbc32a2eaab15e29e10a031162287b4 Author: Ivan Dimitrov <ivan@idimitrov.dev> Date: Fri Nov 10 20:01:59 2023 +0200 content logic
This commit is contained in:
parent
baf71333cf
commit
9dcca1e3dc
44
_content/cases/stepsy.wiki.md
Normal file
44
_content/cases/stepsy.wiki.md
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
---
|
||||||
|
title: Multi-tenant knowledge base website based on Google APIs
|
||||||
|
goal: Create a modern multi-tenant web app that lets users use their Google Drive as a knowledge base
|
||||||
|
role: Design and implement the web app
|
||||||
|
date: Jul 29, 2023 - Nov 5, 2023
|
||||||
|
---
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Solution</summary>
|
||||||
|
|
||||||
|
I chose NextJS as the backbone for this project as it offers the greatest amount of flexibility while still being very powerful both on the client as well as on the server with an active community and thriving ecosystem.
|
||||||
|
|
||||||
|
For styles I chose TailwindCSS with DaisyUI for the optimizations and development speed that come out of using them. Tailwind uses purgecss to minimize the final bundle making the page load and feel faster.
|
||||||
|
|
||||||
|
The database is PostgreSQL with Prisma ORM running on Vercel's cloud infrastructure.
|
||||||
|
|
||||||
|
For authentication I chose NextAuth with JWT as it's the preferred way to handle auth in a NextJS project.
|
||||||
|
|
||||||
|
|
||||||
|
The actual implementation is a lengthy process involving many moving parts and lots of code. I'll go over the three most challenging problems in no particular order.
|
||||||
|
|
||||||
|
Interfacing with Google Drive is done to read the content there and almost never used for writing except for setting and removing permissions. To read the content the reader must have appropriate permissions and that's determined by the auth system with a JWT.
|
||||||
|
For each request we can get the JWT and use it in the google client to auth unless it's an anonymous user, in which case we must use a google service account JWT. This JWT holds a google client access token used by google in determining permissions.
|
||||||
|
Once the client is set up we can start making drive requests on behalf of the user getting their drive content inside the web app including folders, files, documents, pictures, shared drives and so on, which can later be rendered on a page.
|
||||||
|
These requests are a bottleneck, which required many optimizations and concurrency tricks to make the site considerably faster than the competition.
|
||||||
|
|
||||||
|
The storage API uses Prisma ORM for storing and getting all the user info including wikis and spaces. When a user logs in they can see their wiki as well as all the wikis they are allowed to manage. It's used to handle authorized requests like changing the wiki/space name, url, permissions and more. Storage is an integral part of any web application.
|
||||||
|
|
||||||
|
The UI/UX uses TailwindCSS and DaisyUI to make everything a fast, modern, optimized and intuitive experience with extra features like dozens of themes as well as a custom theme builder.
|
||||||
|
React was used with TypeScript to provide a nice modern client-side experience between transitions and interactions.
|
||||||
|
This setup supports maximum optimization as you can see in the screenshots below allowing the app to reach a lighthouse score of 100 on all but one (it has 99) pages.
|
||||||
|
Both mobile and desktop is supported.
|
||||||
|
</details>
|
||||||
|
|
||||||
|
This project aims to be a Google Drive frontend. It uses the Google APIs to fetch document data and display that data in a wiki-style web page.
|
||||||
|
|
||||||
|
|
||||||
|
![thumbnail](/thumbnail.png)
|
||||||
|
|
||||||
|
|
||||||
|
It supports Google Docs, Google Sheets, Google Slides, PDFs and regular files.
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -20,7 +20,9 @@
|
|||||||
tmux new-session -s my_session -d
|
tmux new-session -s my_session -d
|
||||||
tmux new-window -t my_session:1
|
tmux new-window -t my_session:1
|
||||||
tmux new-window -t my_session:2
|
tmux new-window -t my_session:2
|
||||||
|
tmux new-window -t my_session:3
|
||||||
tmux send-keys -t my_session:1.0 'vi' C-m
|
tmux send-keys -t my_session:1.0 'vi' C-m
|
||||||
|
tmux send-keys -t my_session:3.0 'bun run dev' C-m
|
||||||
tmux attach-session -t my_session
|
tmux attach-session -t my_session
|
||||||
'';
|
'';
|
||||||
in
|
in
|
||||||
|
@ -14,9 +14,16 @@
|
|||||||
"@fortawesome/free-regular-svg-icons": "^6.4.2",
|
"@fortawesome/free-regular-svg-icons": "^6.4.2",
|
||||||
"@fortawesome/free-solid-svg-icons": "^6.4.2",
|
"@fortawesome/free-solid-svg-icons": "^6.4.2",
|
||||||
"@fortawesome/react-fontawesome": "latest",
|
"@fortawesome/react-fontawesome": "latest",
|
||||||
|
"gray-matter": "^4.0.3",
|
||||||
"next": "latest",
|
"next": "latest",
|
||||||
"react": "latest",
|
"react": "latest",
|
||||||
"react-dom": "latest"
|
"react-dom": "latest",
|
||||||
|
"react-markdown": "^9.0.0",
|
||||||
|
"rehype-highlight": "^7.0.0",
|
||||||
|
"rehype-raw": "^7.0.0",
|
||||||
|
"remark-frontmatter": "^5.0.0",
|
||||||
|
"remark-gfm": "^4.0.0",
|
||||||
|
"tailwind-highlightjs": "^2.0.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"typescript": "latest",
|
"typescript": "latest",
|
||||||
|
BIN
public/c/cases/stepsy.wiki.md/thumbnail.png
Normal file
BIN
public/c/cases/stepsy.wiki.md/thumbnail.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 43 KiB |
47
src/app/c/[...slug]/content.module.css
Normal file
47
src/app/c/[...slug]/content.module.css
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
.md ol {
|
||||||
|
@apply list-decimal list-inside
|
||||||
|
}
|
||||||
|
.md ul {
|
||||||
|
@apply list-disc list-inside
|
||||||
|
}
|
||||||
|
|
||||||
|
.md blockquote {
|
||||||
|
@apply border-l-neutral-500 border-l-4 p-2
|
||||||
|
}
|
||||||
|
|
||||||
|
.md blockquote p {
|
||||||
|
@apply before:content-['"'] after:content-['"']
|
||||||
|
}
|
||||||
|
|
||||||
|
.md details {
|
||||||
|
@apply p-20
|
||||||
|
}
|
||||||
|
|
||||||
|
.md details * {
|
||||||
|
@apply p-2
|
||||||
|
}
|
||||||
|
|
||||||
|
.md h1 {
|
||||||
|
@apply text-6xl
|
||||||
|
}
|
||||||
|
|
||||||
|
.md h2 {
|
||||||
|
@apply text-5xl
|
||||||
|
}
|
||||||
|
|
||||||
|
.md h3 {
|
||||||
|
@apply text-4xl
|
||||||
|
}
|
||||||
|
|
||||||
|
.md h4 {
|
||||||
|
@apply text-3xl
|
||||||
|
}
|
||||||
|
|
||||||
|
.md h5 {
|
||||||
|
@apply text-2xl
|
||||||
|
}
|
||||||
|
|
||||||
|
.md h6 {
|
||||||
|
@apply text-xl
|
||||||
|
}
|
||||||
|
|
88
src/app/c/[...slug]/page.tsx
Normal file
88
src/app/c/[...slug]/page.tsx
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
import { getAllPaths, getContent } from "$lib/content";
|
||||||
|
import Markdown from "react-markdown";
|
||||||
|
import remarkGfm from "remark-gfm";
|
||||||
|
import remarkFrontmatter from "remark-frontmatter";
|
||||||
|
import styles from "./content.module.css"
|
||||||
|
import Image from "next/image";
|
||||||
|
import rehypeRaw from "rehype-raw";
|
||||||
|
import rehypeHighlight from "rehype-highlight";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
slug: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
params: Params
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateStaticParams(): Promise<Params[]> {
|
||||||
|
return getAllPaths().map(p => ({ slug: p.split("/") }))
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Content({ params }: Props) {
|
||||||
|
const imgSize = 1024;
|
||||||
|
const { data, content } = getContent(params.slug);
|
||||||
|
|
||||||
|
const title = () => {
|
||||||
|
return (
|
||||||
|
<span className="text-3xl">
|
||||||
|
{data.title}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const goal = () => {
|
||||||
|
const g = data.goal
|
||||||
|
return g ?
|
||||||
|
(
|
||||||
|
<div>
|
||||||
|
<h2>The goal</h2>
|
||||||
|
{g}
|
||||||
|
</div>
|
||||||
|
) :
|
||||||
|
""
|
||||||
|
}
|
||||||
|
const role = () => {
|
||||||
|
const r = data.role
|
||||||
|
return r ?
|
||||||
|
(
|
||||||
|
<div>
|
||||||
|
<h2>My role</h2>
|
||||||
|
{r}
|
||||||
|
</div>
|
||||||
|
) :
|
||||||
|
""
|
||||||
|
}
|
||||||
|
|
||||||
|
const ctnt = () => {
|
||||||
|
return (
|
||||||
|
<Markdown
|
||||||
|
className={styles.md}
|
||||||
|
remarkPlugins={[remarkGfm, remarkFrontmatter]}
|
||||||
|
rehypePlugins={[rehypeRaw, rehypeHighlight]}
|
||||||
|
components={{
|
||||||
|
img({ height, width, src, alt }) {
|
||||||
|
return (
|
||||||
|
<span className="w-full h-max p-20">
|
||||||
|
<Image className="w-full h-full" alt={alt!} height={Number(height) || imgSize} width={Number(width) || imgSize} src={`${data.slug}${src}`}></Image>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</Markdown>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="w-full h-full p-20 overflow-x-hidden overflow-scroll">
|
||||||
|
<div className="flex flex-col gap-4 text-center">
|
||||||
|
{title()}
|
||||||
|
{goal()}
|
||||||
|
{role()}
|
||||||
|
</div>
|
||||||
|
<div className="w-3/4 m-auto">
|
||||||
|
{ctnt()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
7
src/app/cases/page.tsx
Normal file
7
src/app/cases/page.tsx
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
import Cases from "$components/cases";
|
||||||
|
|
||||||
|
export default function CasesPage() {
|
||||||
|
return (
|
||||||
|
<Cases />
|
||||||
|
)
|
||||||
|
}
|
25
src/app/components/cases.tsx
Normal file
25
src/app/components/cases.tsx
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import { GrayMatterFile } from "gray-matter";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { getCases } from "../lib/content";
|
||||||
|
|
||||||
|
export default function Cases() {
|
||||||
|
const cases: GrayMatterFile<string>[] = getCases()
|
||||||
|
return (
|
||||||
|
<div className="p-20 w-3/4 mx-auto">
|
||||||
|
{cases.map((c) => {
|
||||||
|
const d = c.data;
|
||||||
|
const date = d.date.split("-")
|
||||||
|
const from = date[0]?.trim()
|
||||||
|
const to = date[1]?.trim()
|
||||||
|
return (
|
||||||
|
<div key={d.slug} className="w-full h-max flex justify-center">
|
||||||
|
<Link className="btn flex flex-col w-full text-center" href={d.slug}>
|
||||||
|
<span className="text-lg px-6">{d.title}</span>
|
||||||
|
{from} {to ? `- ${to}` : ""}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
@ -5,13 +5,15 @@ import Link from "next/link";
|
|||||||
export default function Links() {
|
export default function Links() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`grid grid-cols-2 gap-4`}>
|
<div className="grid w-full h-full place-content-center">
|
||||||
<Link key="github" aria-label="GitHub" href={process.env.NEXT_PUBLIC_GITHUB_URL!} target="_blank">
|
<div className={"grid grid-cols-2 gap-4 place-content-center"}>
|
||||||
|
<Link aria-label="GitHub" href={process.env.NEXT_PUBLIC_GITHUB_URL!} target="_blank">
|
||||||
<FontAwesomeIcon icon={faGithub} />
|
<FontAwesomeIcon icon={faGithub} />
|
||||||
</Link>
|
</Link>
|
||||||
<Link key="gitlab" aria-label="GitLab" href={process.env.NEXT_PUBLIC_GITLAB_URL!} target="_blank">
|
<Link aria-label="GitLab" href={process.env.NEXT_PUBLIC_GITLAB_URL!} target="_blank">
|
||||||
<FontAwesomeIcon icon={faGitlab} />
|
<FontAwesomeIcon icon={faGitlab} />
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
26
src/app/components/navbar.tsx
Normal file
26
src/app/components/navbar.tsx
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
"use client"
|
||||||
|
import Link from "next/link";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
import { ReactNode } from "react";
|
||||||
|
|
||||||
|
export default function Navbar() {
|
||||||
|
const path = usePathname()
|
||||||
|
|
||||||
|
const link = (text: ReactNode, href: string) => {
|
||||||
|
return (
|
||||||
|
<Link aria-selected={path === href} className="btn" aria-label="Home" href={href}>
|
||||||
|
{text}
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-max h-max px-6 py-2 mx-auto rounded-full bg-slate-900 grid place-content-center">
|
||||||
|
<div className="flex flex-row gap-6">
|
||||||
|
{link("Home", "/")}
|
||||||
|
{link("Cases", "/cases")}
|
||||||
|
{link("Contact", "/contact")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
15
src/app/contact/page.tsx
Normal file
15
src/app/contact/page.tsx
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import { faEnvelope } from "@fortawesome/free-solid-svg-icons";
|
||||||
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
|
|
||||||
|
export default function Contact() {
|
||||||
|
|
||||||
|
const email = "ivan@idimitrov.dev";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full h-full p-2 grid place-content-center">
|
||||||
|
<div className="flex flex-row gap-4">
|
||||||
|
<a href={`mailto:${email}`}><FontAwesomeIcon icon={faEnvelope}/></a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
@ -7,10 +7,14 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
main {
|
main {
|
||||||
@apply grid w-full h-full
|
@apply flex flex-col w-full h-full
|
||||||
}
|
}
|
||||||
|
|
||||||
svg {
|
svg {
|
||||||
@apply w-14 h-14 text-amber-100 hover:text-cyan-500
|
@apply w-14 h-14 text-amber-100 hover:text-cyan-500
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
@apply bg-gray-900 text-gray-300 hover:bg-gray-700 hover:text-white rounded-md px-3 py-2 text-sm font-medium aria-selected:bg-gray-600
|
||||||
|
}
|
||||||
|
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import './globals.css'
|
import './globals.css'
|
||||||
|
import Navbar from './components/navbar'
|
||||||
import type { Metadata } from 'next'
|
import type { Metadata } from 'next'
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
@ -13,7 +14,12 @@ export default function RootLayout({
|
|||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<body>{children}</body>
|
<body>
|
||||||
|
<main>
|
||||||
|
<Navbar />
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
</html>
|
</html>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
43
src/app/lib/content.ts
Normal file
43
src/app/lib/content.ts
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
import fs from "fs";
|
||||||
|
import matter, { GrayMatterFile } from "gray-matter";
|
||||||
|
import path from "path";
|
||||||
|
|
||||||
|
const baseDir = "./_content/"
|
||||||
|
|
||||||
|
export const getContent = (slug: string[]): GrayMatterFile<string> => {
|
||||||
|
let p = path.join(baseDir)
|
||||||
|
slug.forEach(s => {
|
||||||
|
p = path.join(p, s)
|
||||||
|
})
|
||||||
|
const file = fs.readFileSync(p, "utf8")
|
||||||
|
const m = matter(file);
|
||||||
|
m.data.slug = `/c/${slug.join("/")}`
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
const getAllPathsRecursive = (base = baseDir): string[] => {
|
||||||
|
let results = [] as string[];
|
||||||
|
|
||||||
|
const files = fs.readdirSync(base);
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
const filePath = path.join(base, file);
|
||||||
|
const stat = fs.statSync(filePath);
|
||||||
|
|
||||||
|
if (stat.isDirectory()) {
|
||||||
|
results = results.concat(getAllPathsRecursive(filePath));
|
||||||
|
} else if (path.extname(filePath) === '.md') {
|
||||||
|
results.push(filePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getAllPaths = (base = baseDir): string[] => {
|
||||||
|
return getAllPathsRecursive(base).map(p => p.substring(9))
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getCases = (): GrayMatterFile<string>[] => {
|
||||||
|
return getAllPaths(`${baseDir}cases/`).map(s => s.split("/")).map(getContent)
|
||||||
|
}
|
||||||
|
|
@ -2,8 +2,6 @@ import Links from "$components/links";
|
|||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
return (
|
return (
|
||||||
<main className="place-content-center text-center">
|
|
||||||
<Links />
|
<Links />
|
||||||
</main>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -6,15 +6,14 @@ const config: Config = {
|
|||||||
'./src/components/**/*.{js,ts,jsx,tsx,mdx}',
|
'./src/components/**/*.{js,ts,jsx,tsx,mdx}',
|
||||||
'./src/app/**/*.{js,ts,jsx,tsx,mdx}',
|
'./src/app/**/*.{js,ts,jsx,tsx,mdx}',
|
||||||
],
|
],
|
||||||
|
safelist: [{
|
||||||
|
pattern: /hljs+/,
|
||||||
|
}],
|
||||||
theme: {
|
theme: {
|
||||||
extend: {
|
hljs: {
|
||||||
backgroundImage: {
|
theme: 'night-owl',
|
||||||
'gradient-radial': 'radial-gradient(var(--tw-gradient-stops))',
|
|
||||||
'gradient-conic':
|
|
||||||
'conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))',
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
plugins: [require('tailwind-highlightjs')],
|
||||||
plugins: [],
|
|
||||||
}
|
}
|
||||||
export default config
|
export default config
|
||||||
|
Loading…
Reference in New Issue
Block a user