Squashed commit of the following:
commit 263ac7d58539235c9c3762d9bee037449f7099e9 Author: Ivan Dimitrov <ivan@idimitrov.dev> Date: Sun Nov 12 14:11:50 2023 +0200 touches commit 81f56ab63bd60f27a1d2635673b3834bf31a25da Author: Ivan Dimitrov <ivan@idimitrov.dev> Date: Sun Nov 12 14:00:41 2023 +0200 writing technical details for the API commit fc90f9b2288b4dc955ece07af43a42979398a3e7 Author: Ivan Dimitrov <ivan@idimitrov.dev> Date: Sun Nov 12 13:22:15 2023 +0200 code blocks with a copy button commit 9d08417a639cfc5673a2f00cd1fed56b6d640bb0 Author: Ivan Dimitrov <ivan@idimitrov.dev> Date: Sat Nov 11 21:53:55 2023 +0200 content n sheet
This commit is contained in:
parent
fe788fb0d1
commit
cda70cce35
@ -6,8 +6,17 @@ date: Jul 29, 2023 - Nov 5, 2023
|
|||||||
z: 5
|
z: 5
|
||||||
---
|
---
|
||||||
|
|
||||||
<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.
|
||||||
<summary>Solution</summary>
|
|
||||||
|
|
||||||
|
![thumbnail](/thumbnail.png)
|
||||||
|
|
||||||
|
|
||||||
|
It supports Google Docs, Google Sheets, Google Slides, PDFs and regular files.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Technical overview
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
@ -31,12 +40,100 @@ The UI/UX uses TailwindCSS and DaisyUI to make everything a fast, modern, optimi
|
|||||||
React was used with TypeScript to provide a nice modern client-side experience between transitions and interactions.
|
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.
|
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.
|
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.
|
### Google API details
|
||||||
|
|
||||||
|
Configure NextAuth for Google:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export default NextAuth({
|
||||||
|
...
|
||||||
|
providers: [
|
||||||
|
GoogleProvider({
|
||||||
|
clientId: process.env.GOOGLE_CLIENT_ID!,
|
||||||
|
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
|
||||||
|
authorization: {
|
||||||
|
params: {
|
||||||
|
access_type: "offline",
|
||||||
|
prompt: "consent",
|
||||||
|
scope: "openid profile email https://www.googleapis.com/auth/drive",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
Create an auth client for logged in users
|
||||||
|
|
||||||
|
```ts
|
||||||
|
let authClient = new google.auth.OAuth2(
|
||||||
|
process.env.GOOGLE_CLIENT_ID,
|
||||||
|
process.env.GOOGLE_CLIENT_SECRET,
|
||||||
|
);
|
||||||
|
authClient.setCredentials({
|
||||||
|
access_token: accessToken, // this comes from the logged in user info
|
||||||
|
refresh_token: refreshToken, // same for this
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
or one for anonymous users using a Google service account
|
||||||
|
|
||||||
|
```ts
|
||||||
|
authClient = new google.auth.JWT({
|
||||||
|
email: serviceAccount.client_email,
|
||||||
|
key: serviceAccount.private_key,
|
||||||
|
scopes: ["https://www.googleapis.com/auth/drive"],
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Create the drive client
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const drive = google.drive({
|
||||||
|
version: "v3",
|
||||||
|
auth: authClient,
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
You can now use this client to query the API
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const file = (await drive.files.get({fileId})).data;
|
||||||
|
```
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const folderContents = (await drive.files.list({ q: `'${folderId}' in parents` }))
|
||||||
|
.data.files;
|
||||||
|
```
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const googleDocHtml = (await drive.files.export({
|
||||||
|
fileId: googleDocId,
|
||||||
|
mimeType: "text/html",
|
||||||
|
})).data;
|
||||||
|
```
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const shortcutTarget = await drive.files.get({
|
||||||
|
fileId,
|
||||||
|
fields: "shortcutDetails/targetId",
|
||||||
|
});
|
||||||
|
const targetId = shortcutTarget.data.shortcutDetails?.targetId
|
||||||
|
```
|
||||||
|
|
||||||
|
Google doesn't export everything to HTML. They provide document renderers as iFrames.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<iframe src={`https://docs.google.com/{{"spreadsheets" or "presentation"}}/d/${docId}/preview`}></iframe>
|
||||||
|
```
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// This is used for PDFs or regular text files
|
||||||
|
<iframe src={`https://drive.google.com/file/d/${docId}/preview`}></iframe>
|
||||||
|
```
|
||||||
|
|
||||||
|
@ -16,6 +16,7 @@
|
|||||||
"@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",
|
"gray-matter": "^4.0.3",
|
||||||
|
"highlight.js": "^11.9.0",
|
||||||
"next": "latest",
|
"next": "latest",
|
||||||
"react": "latest",
|
"react": "latest",
|
||||||
"react-dom": "latest",
|
"react-dom": "latest",
|
||||||
@ -23,8 +24,7 @@
|
|||||||
"rehype-highlight": "^7.0.0",
|
"rehype-highlight": "^7.0.0",
|
||||||
"rehype-raw": "^7.0.0",
|
"rehype-raw": "^7.0.0",
|
||||||
"remark-frontmatter": "^5.0.0",
|
"remark-frontmatter": "^5.0.0",
|
||||||
"remark-gfm": "^4.0.0",
|
"remark-gfm": "^4.0.0"
|
||||||
"tailwind-highlightjs": "^2.0.1"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"typescript": "latest",
|
"typescript": "latest",
|
||||||
|
@ -1,3 +1,7 @@
|
|||||||
|
.md * {
|
||||||
|
@apply py-2
|
||||||
|
}
|
||||||
|
|
||||||
.md ol {
|
.md ol {
|
||||||
@apply list-decimal list-inside
|
@apply list-decimal list-inside
|
||||||
}
|
}
|
||||||
@ -5,6 +9,10 @@
|
|||||||
@apply list-disc list-inside
|
@apply list-disc list-inside
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.md hr {
|
||||||
|
@apply m-4
|
||||||
|
}
|
||||||
|
|
||||||
.md blockquote {
|
.md blockquote {
|
||||||
@apply border-l-neutral-500 border-l-4 p-2
|
@apply border-l-neutral-500 border-l-4 p-2
|
||||||
}
|
}
|
||||||
@ -17,8 +25,8 @@
|
|||||||
@apply p-20
|
@apply p-20
|
||||||
}
|
}
|
||||||
|
|
||||||
.md details * {
|
.md code {
|
||||||
@apply p-2
|
@apply rounded-lg
|
||||||
}
|
}
|
||||||
|
|
||||||
.md h1 {
|
.md h1 {
|
||||||
|
@ -1,12 +1,15 @@
|
|||||||
|
import "highlight.js/styles/github-dark.css"
|
||||||
|
import styles from "./content.module.css"
|
||||||
import { getAllPaths, getContent } from "$lib/content";
|
import { getAllPaths, getContent } from "$lib/content";
|
||||||
import Markdown from "react-markdown";
|
import Markdown from "react-markdown";
|
||||||
import remarkGfm from "remark-gfm";
|
import remarkGfm from "remark-gfm";
|
||||||
import remarkFrontmatter from "remark-frontmatter";
|
import remarkFrontmatter from "remark-frontmatter";
|
||||||
import styles from "./content.module.css"
|
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import rehypeRaw from "rehype-raw";
|
import rehypeRaw from "rehype-raw";
|
||||||
import rehypeHighlight from "rehype-highlight";
|
import rehypeHighlight from "rehype-highlight";
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
|
import Link from "next/link";
|
||||||
|
import CodeBlock from "$components/code-block";
|
||||||
|
|
||||||
type Params = {
|
type Params = {
|
||||||
slug: string[]
|
slug: string[]
|
||||||
@ -58,12 +61,22 @@ export default function Content({ params }: Props) {
|
|||||||
remarkPlugins={[remarkGfm, remarkFrontmatter]}
|
remarkPlugins={[remarkGfm, remarkFrontmatter]}
|
||||||
rehypePlugins={[rehypeRaw, rehypeHighlight]}
|
rehypePlugins={[rehypeRaw, rehypeHighlight]}
|
||||||
components={{
|
components={{
|
||||||
img({ height, width, src, alt }) {
|
img({ height, width, src, alt, className }) {
|
||||||
return (
|
return (
|
||||||
<span className="w-full h-max p-20">
|
<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>
|
<Image className={`w-full h-full border-2 px-2 ${className || ""}`} alt={alt!} height={Number(height) || imgSize} width={Number(width) || imgSize} src={`${data.slug}${src}`}></Image>
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
|
},
|
||||||
|
a({ href, children, className }) {
|
||||||
|
return (
|
||||||
|
<Link className={className || ""} aria-label={href} href={href!} target="_blank">{children}</Link>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
pre({ children, className }) {
|
||||||
|
return (
|
||||||
|
<CodeBlock className={className} children={children} />
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@ -72,7 +85,7 @@ export default function Content({ params }: Props) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full h-full p-20 overflow-x-hidden overflow-scroll">
|
<div className="w-full h-full p-20 overflow-x-hidden overflow-scroll">
|
||||||
<div className="flex flex-col gap-4 text-center">
|
<div className="flex flex-col gap-4 text-center border-amber-50 border-2 p-2 m-2 rounded-full">
|
||||||
{title()}
|
{title()}
|
||||||
{goal()}
|
{goal()}
|
||||||
{role()}
|
{role()}
|
||||||
|
@ -4,7 +4,7 @@ import { getCases } from "../lib/content";
|
|||||||
const Cases = () =>
|
const Cases = () =>
|
||||||
<div className="p-20 w-3/4 mx-auto flex flex-col gap-4">
|
<div className="p-20 w-3/4 mx-auto flex flex-col gap-4">
|
||||||
{getCases().filter(c => !c.data.draft).sort(c => Number(c.data.z)).reverse().map((c) => c.data).map((d) =>
|
{getCases().filter(c => !c.data.draft).sort(c => Number(c.data.z)).reverse().map((c) => c.data).map((d) =>
|
||||||
<div key={d.slug} className="w-full h-max flex justify-center">
|
<div key={d.slug} className="w-full h-max flex justify-center rounded-lg border-2">
|
||||||
<Link className="btn flex flex-col w-full text-center" href={d.slug}>
|
<Link className="btn flex flex-col w-full text-center" href={d.slug}>
|
||||||
<span className="text-lg px-6">{d.title}</span>
|
<span className="text-lg px-6">{d.title}</span>
|
||||||
<span>{d.date}</span>
|
<span>{d.date}</span>
|
||||||
|
40
src/app/components/code-block.tsx
Normal file
40
src/app/components/code-block.tsx
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
"use client"
|
||||||
|
import { faCheck, faCopy } from "@fortawesome/free-solid-svg-icons"
|
||||||
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"
|
||||||
|
import { ReactNode, useState } from "react"
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
className?: string
|
||||||
|
children?: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
const getText = (node: any) => {
|
||||||
|
const props = node.props
|
||||||
|
if (!props) {
|
||||||
|
return node
|
||||||
|
}
|
||||||
|
const c = props.children
|
||||||
|
return typeof c === "string" ? c : c.map(getText).join("")
|
||||||
|
}
|
||||||
|
|
||||||
|
const CodeBlock = ({ className, children }: Props) => {
|
||||||
|
const [visible, setVisible] = useState("invisible")
|
||||||
|
return (
|
||||||
|
<div style={{ position: 'relative' }}>
|
||||||
|
<button
|
||||||
|
className="absolute top-5 right-5"
|
||||||
|
onClick={() => {
|
||||||
|
navigator.clipboard.writeText(getText(children))
|
||||||
|
setVisible("visible")
|
||||||
|
setTimeout(() => setVisible("invisible"), 1000)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className={`${visible} absolute bottom-5 left-5`}><FontAwesomeIcon className="text-green-400" icon={faCheck} /></span>
|
||||||
|
<FontAwesomeIcon icon={faCopy} />
|
||||||
|
</button>
|
||||||
|
<pre className={`${className || ""}`}>{children}</pre>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CodeBlock;
|
@ -10,6 +10,10 @@ main {
|
|||||||
@apply flex flex-col w-full h-full
|
@apply flex flex-col w-full h-full
|
||||||
}
|
}
|
||||||
|
|
||||||
|
main * {
|
||||||
|
@apply border-amber-50
|
||||||
|
}
|
||||||
|
|
||||||
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
|
||||||
}
|
}
|
||||||
|
@ -6,14 +6,6 @@ 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: [{
|
plugins: [],
|
||||||
pattern: /hljs+/,
|
|
||||||
}],
|
|
||||||
theme: {
|
|
||||||
hljs: {
|
|
||||||
theme: 'night-owl',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
plugins: [require('tailwind-highlightjs')],
|
|
||||||
}
|
}
|
||||||
export default config
|
export default config
|
||||||
|
Loading…
Reference in New Issue
Block a user