Skip to content

Commit c1be069

Browse files
committed
feat[frontend]: add dark mode
1 parent 56f136b commit c1be069

File tree

4 files changed

+174
-70
lines changed

4 files changed

+174
-70
lines changed

frontend/icons.tsx

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
export const moonIcon = (
2+
<svg
3+
xmlns="http://www.w3.org/2000/svg"
4+
fill="none"
5+
viewBox="0 0 24 24"
6+
strokeWidth={1.5}
7+
stroke="currentColor"
8+
className="size-6 inline"
9+
>
10+
<path
11+
strokeLinecap="round"
12+
strokeLinejoin="round"
13+
d="M21.752 15.002A9.72 9.72 0 0 1 18 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 0 0 3 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 0 0 9.002-5.998Z"
14+
/>
15+
</svg>
16+
)
17+
18+
export const sunIcon = (
19+
<svg
20+
xmlns="http://www.w3.org/2000/svg"
21+
fill="none"
22+
viewBox="0 0 24 24"
23+
strokeWidth={1.5}
24+
stroke="currentColor"
25+
className="size-6 inline"
26+
>
27+
<path
28+
strokeLinecap="round"
29+
strokeLinejoin="round"
30+
d="M12 3v2.25m6.364.386-1.591 1.591M21 12h-2.25m-.386 6.364-1.591-1.591M12 18.75V21m-4.773-4.227-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0Z"
31+
/>
32+
</svg>
33+
)
34+
35+
export const computerIcon = (
36+
<svg
37+
xmlns="http://www.w3.org/2000/svg"
38+
fill="none"
39+
viewBox="0 0 24 24"
40+
strokeWidth={1.5}
41+
stroke="currentColor"
42+
className="size-6 inline"
43+
>
44+
<path
45+
strokeLinecap="round"
46+
strokeLinejoin="round"
47+
d="M9 17.25v1.007a3 3 0 0 1-.879 2.122L7.5 21h9l-.621-.621A3 3 0 0 1 15 18.257V17.25m6-12V15a2.25 2.25 0 0 1-2.25 2.25H5.25A2.25 2.25 0 0 1 3 15V5.25m18 0A2.25 2.25 0 0 0 18.75 3H5.25A2.25 2.25 0 0 0 3 5.25m18 0V12a2.25 2.25 0 0 1-2.25 2.25H5.25A2.25 2.25 0 0 1 3 12V5.25"
48+
/>
49+
</svg>
50+
)

frontend/index.tsx

Lines changed: 61 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -22,84 +22,38 @@ import {
2222
ModalContent,
2323
ModalHeader,
2424
ModalFooter,
25+
Tooltip,
2526
} from "@heroui/react"
2627

28+
import { PasteResponse, parsePath, parseFilenameFromContentDisposition } from "../src/shared.js"
29+
2730
import {
28-
parseExpiration,
29-
PasteResponse,
30-
NAME_REGEX,
31-
PASSWD_SEP,
32-
parsePath,
33-
parseFilenameFromContentDisposition,
34-
parseExpirationReadable,
35-
} from "../src/shared.js"
31+
verifyExpiration,
32+
verifyManageUrl,
33+
verifyName,
34+
formatSize,
35+
maxExpirationReadable,
36+
BaseUrl,
37+
APIUrl,
38+
} from "./utils.js"
3639

3740
import "./style.css"
41+
import { computerIcon, moonIcon, sunIcon } from "./icons.js"
3842

39-
function formatSize(size: number): string {
40-
if (!size) return "0"
41-
if (size < 1024) {
42-
return `${size} Bytes`
43-
} else if (size < 1024 * 1024) {
44-
return `${(size / 1024).toFixed(2)} KB`
45-
} else if (size < 1024 * 1024 * 1024) {
46-
return `${(size / 1024 / 1024).toFixed(2)} MB`
47-
} else {
48-
return `${(size / 1024 / 1024 / 1024).toFixed(2)} GB`
49-
}
50-
}
51-
52-
const BaseUrl = DEPLOY_URL
53-
const APIUrl = DEPLOY_URL || ""
54-
const maxExpirationSeconds = parseExpiration(MAX_EXPIRATION)!
55-
const maxExpirationReadable = parseExpirationReadable(MAX_EXPIRATION)!
56-
57-
function verifyExpiration(expiration: string): [boolean, string] {
58-
const parsed = parseExpiration(expiration)
59-
if (parsed === null) {
60-
return [false, "Invalid expiration"]
61-
} else {
62-
if (parsed > maxExpirationSeconds) {
63-
return [false, `Exceed max expiration (${maxExpirationReadable})`]
64-
} else {
65-
return [true, `Expires in ${parseExpirationReadable(expiration)!}`]
66-
}
67-
}
68-
}
43+
type EditKind = "edit" | "file"
44+
type UploadKind = "short" | "long" | "custom" | "manage"
45+
type DarkMode = "dark" | "light" | "system"
6946

70-
function verifyName(name: string): [boolean, string] {
71-
if (name.length < 3) {
72-
return [false, "Should have at least 3 characters"]
73-
} else if (!NAME_REGEX.test(name)) {
74-
return [false, "Should only contain alphanumeric and +_-[]*$@,;"]
47+
function defaultDarkMode(): DarkMode {
48+
const storedDarkModeSelect = localStorage.getItem("darkModeSelect")
49+
if (storedDarkModeSelect !== null && ["light", "dark", "system"].includes(storedDarkModeSelect)) {
50+
return storedDarkModeSelect as DarkMode
7551
} else {
76-
return [true, ""]
77-
}
78-
}
79-
80-
function verifyManageUrl(url: string): [boolean, string] {
81-
try {
82-
const url_parsed = new URL(url)
83-
if (url_parsed.origin !== BaseUrl) {
84-
return [false, `URL should starts with ${BaseUrl}`]
85-
} else if (url_parsed.pathname.indexOf(PASSWD_SEP) < 0) {
86-
return [false, `URL should contain a colon`]
87-
} else {
88-
return [true, ""]
89-
}
90-
} catch (e) {
91-
if (e instanceof TypeError) {
92-
return [false, "Invalid URL"]
93-
} else {
94-
throw e
95-
}
52+
return "system"
9653
}
9754
}
9855

9956
function PasteBin() {
100-
type EditKind = "edit" | "file"
101-
type UploadKind = "short" | "long" | "custom" | "manage"
102-
10357
const [editKind, setEditKind] = useState<EditKind>("edit")
10458
const [pasteEdit, setPasteEdit] = useState("")
10559
const [uploadFile, setUploadFile] = useState<File | null>(null)
@@ -117,6 +71,11 @@ function PasteBin() {
11771
const [isModalOpen, setModalOpen] = useState(false)
11872
const [modalErrMsg, setModalErrMsg] = useState("")
11973

74+
const [darkModeSelect, setDarkModeSelect] = useState<DarkMode>(defaultDarkMode())
75+
76+
const systemDark = window.matchMedia("(prefers-color-scheme: dark)").matches
77+
const isDark = darkModeSelect === "system" ? systemDark : darkModeSelect === "dark"
78+
12079
function showErrorMsg(err: string) {
12180
setModalErrMsg(err)
12281
setModalOpen(true)
@@ -153,6 +112,11 @@ function PasteBin() {
153112
</Modal>
154113
)
155114

115+
useEffect(() => {
116+
localStorage.setItem("darkModeSelect", darkModeSelect)
117+
}, [darkModeSelect])
118+
119+
// handle admin URL
156120
useEffect(() => {
157121
const pathname = location.pathname
158122
const { nameFromPath, passwd, filename, ext } = parsePath(pathname)
@@ -270,10 +234,34 @@ function PasteBin() {
270234
}
271235
}
272236

237+
const iconsMap = new Map([
238+
["system", computerIcon],
239+
["dark", moonIcon],
240+
["light", sunIcon],
241+
])
242+
const toggleDarkModeButton = (
243+
<Tooltip content="Toggle Dark Mode">
244+
<span
245+
className="absolute right-0"
246+
onClick={() => {
247+
if (darkModeSelect === "system") {
248+
setDarkModeSelect("dark")
249+
} else if (darkModeSelect === "dark") {
250+
setDarkModeSelect("light")
251+
} else {
252+
setDarkModeSelect("system")
253+
}
254+
}}
255+
>
256+
{iconsMap.get(darkModeSelect)}
257+
</span>
258+
</Tooltip>
259+
)
260+
273261
const info = (
274262
<div className="mx-4 lg:mx-0">
275-
<h1 className="text-3xl mt-8 mb-4">Pastebin Worker</h1>
276-
<p className="my-2">This is an open source pastebin deployed on Cloudflare Workers.</p>
263+
<h1 className="text-3xl mt-8 mb-4 relative">Pastebin Worker {toggleDarkModeButton}</h1>
264+
<p className="my-2">This is an open source pastebin deployed on Cloudflare Workers. </p>
277265
<p className="my-2">
278266
<b>Usage</b>: paste any text here, submit, then share it with URL. (
279267
<Link href={`${BaseUrl}/api`}>API Documentation</Link>)
@@ -534,7 +522,11 @@ function PasteBin() {
534522
)
535523

536524
return (
537-
<div className="flex flex-col items-center min-h-screen font-sans">
525+
<div
526+
className={
527+
"flex flex-col items-center min-h-screen font-sans bg-background text-foreground" + (isDark ? " dark" : "")
528+
}
529+
>
538530
<div className="grow w-full max-w-[64rem]">
539531
{info}
540532
{editor}

frontend/style.css

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
@plugin "./hero.ts";
44
@source '../node_modules/@heroui/theme/dist/**/*.{js,ts,jsx,tsx}';
5-
@custom-variant dark (&:is(.dark *));
5+
@custom-variant dark (&:where(.dark, .dark *));
66

77
@theme {
88
--font-sans:

frontend/utils.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import { NAME_REGEX, parseExpiration, parseExpirationReadable, PASSWD_SEP } from "../src/shared.js"
2+
3+
export const BaseUrl = DEPLOY_URL
4+
export const APIUrl = DEPLOY_URL || ""
5+
6+
export const maxExpirationSeconds = parseExpiration(MAX_EXPIRATION)!
7+
export const maxExpirationReadable = parseExpirationReadable(MAX_EXPIRATION)!
8+
9+
export function formatSize(size: number): string {
10+
if (!size) return "0"
11+
if (size < 1024) {
12+
return `${size} Bytes`
13+
} else if (size < 1024 * 1024) {
14+
return `${(size / 1024).toFixed(2)} KB`
15+
} else if (size < 1024 * 1024 * 1024) {
16+
return `${(size / 1024 / 1024).toFixed(2)} MB`
17+
} else {
18+
return `${(size / 1024 / 1024 / 1024).toFixed(2)} GB`
19+
}
20+
}
21+
22+
export function verifyExpiration(expiration: string): [boolean, string] {
23+
const parsed = parseExpiration(expiration)
24+
if (parsed === null) {
25+
return [false, "Invalid expiration"]
26+
} else {
27+
if (parsed > maxExpirationSeconds) {
28+
return [false, `Exceed max expiration (${maxExpirationReadable})`]
29+
} else {
30+
return [true, `Expires in ${parseExpirationReadable(expiration)!}`]
31+
}
32+
}
33+
}
34+
35+
export function verifyName(name: string): [boolean, string] {
36+
if (name.length < 3) {
37+
return [false, "Should have at least 3 characters"]
38+
} else if (!NAME_REGEX.test(name)) {
39+
return [false, "Should only contain alphanumeric and +_-[]*$@,;"]
40+
} else {
41+
return [true, ""]
42+
}
43+
}
44+
45+
export function verifyManageUrl(url: string): [boolean, string] {
46+
try {
47+
const url_parsed = new URL(url)
48+
if (url_parsed.origin !== BaseUrl) {
49+
return [false, `URL should starts with ${BaseUrl}`]
50+
} else if (url_parsed.pathname.indexOf(PASSWD_SEP) < 0) {
51+
return [false, `URL should contain a colon`]
52+
} else {
53+
return [true, ""]
54+
}
55+
} catch (e) {
56+
if (e instanceof TypeError) {
57+
return [false, "Invalid URL"]
58+
} else {
59+
throw e
60+
}
61+
}
62+
}

0 commit comments

Comments
 (0)