Selaa lähdekoodia

Switch to tailwind styles by OpenCode

jerryliao 1 päivä sitten
vanhempi
commit
462a5be3cc

+ 7 - 441
assets/global.css

@@ -1,97 +1,6 @@
-* {
-  margin: 0;
-  padding: 0;
-}
-
-.pd-page {
-  width: 100vw;
-  min-width: 375px;
-  height: 100vh;
-  padding: 0.75rem;
-  display: flex;
-  flex-direction: column;
-  box-sizing: border-box;
-  overflow: hidden;
-}
-
-.pd-page.pd-page-centered {
-  display: flex;
-  align-items: center;
-  justify-content: center;
-}
-
-/* Global form styles start */
-input:not([type="radio"]):not([type="checkbox"]),
-textarea {
-  width: 100%;
-  display: block;
-  box-sizing: border-box;
-  border-radius: 0.375rem;
-  border: 1px solid #ced4da;
-  font-size: 14px;
-  outline: none;
-}
-
-input:not([type="radio"]):not([type="checkbox"]) {
-  height: 38px;
-  line-height: 30px;
-  padding: 4px 0.375rem;
-}
-
-input[type="checkbox"] {
-  width: 16px;
-  height: 16px;
-}
-
-textarea {
-  height: 100%;
-  padding: 0.375rem;
-  resize: none;
-}
-
-button {
-  box-sizing: border-box;
-  padding: 6px 12px;
-  background-color: #fff;
-  line-height: 16px;
-  color: #212529;
-  cursor: pointer;
-  font-size: 14px;
-  border: 1px solid #ced4da;
-  border-radius: 0.375rem;
-}
-/* Global form styles end */
-
-/* Loading styles start */
-/* Loading spin from https://loading.io/css/ */
-.pd-loading-cover {
-  position: fixed;
-  top: 0;
-  left: 0;
-  right: 0;
-  bottom: 0;
-  background-color: rgba(0, 0, 0, 0.6);
-  z-index: 9;
-  display: flex;
-  align-items: center;
-  justify-content: center;
-}
-
-.pd-loading-cover .pd-loading-spin {
-  display: inline-block;
-  transform: translateZ(1px);
-}
-
-.pd-loading-cover .pd-loading-spin .pd-loading-spin-inner {
-  display: inline-block;
-  width: 64px;
-  height: 64px;
-  margin: 8px;
-  border-radius: 50%;
-  background: #fff;
-  animation: pd-loading-spin 5s cubic-bezier(0, 0.2, 0.8, 1) infinite;
-}
+@import "tailwindcss";
 
+/* Loading spin animation */
 @keyframes pd-loading-spin {
   0%,
   100% {
@@ -108,365 +17,22 @@ button {
     transform: rotateY(3600deg);
   }
 }
-/* Loading styles end */
-
-/* Modal styles start */
-.pd-modal {
-  position: fixed;
-  left: 0;
-  right: 0;
-  bottom: 0;
-  top: 0;
-  background-color: rgba(0, 0, 0, 0.6);
-  z-index: 8;
-  display: flex;
-  align-items: center;
-  justify-content: center;
-}
-
-.pd-modal.pd-modal-hidden {
-  display: none;
-}
-
-.pd-modal .pd-modal-content {
-  background-color: #fff;
-  border: 1px solid #dee2e6;
-  border-radius: 0.5rem;
-  width: 500px;
-  max-width: 90%;
-  max-height: 60%;
-  position: relative;
-  font-size: 16px;
-  cursor: pointer;
-}
-
-.pd-modal .pd-modal-content .pd-modal-close {
-  position: absolute;
-  right: 1rem;
-  top: 0.75rem;
-  font-size: 1.5rem;
-}
-
-.pd-modal .pd-modal-content .pd-modal-title {
-  padding: 1rem;
-  border-bottom: 1px solid #dee2e6;
-  font-weight: 500;
-}
-
-.pd-modal .pd-modal-content .pd-modal-body {
-  padding: 1rem;
-}
-
-.pd-modal .pd-modal-content .pd-modal-footer {
-  display: flex;
-  justify-content: flex-end;
-  border-top: 1px solid #dee2e6;
-  padding: 1rem;
-}
-
-.pd-modal .pd-modal-content .pd-modal-footer button:not(:last-child) {
-  margin-right: 0.5rem;
-}
-/* Modal styles end */
-
-/* Welcome frame styles start */
-.pd-welcome-frame {
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  flex-direction: column;
-  width: 100%;
-  height: 100%;
-}
-
-.pd-welcome-frame .pd-welcome-title {
-  display: flex;
-  align-items: center;
-}
-
-.pd-welcome-frame .pd-welcome-title .pd-logo {
-  width: 128px;
-  height: 128px;
-  margin-right: 8px;
-}
-
-.pd-welcome-frame .pd-welcome-intro {
-  font-size: 1.25rem;
-  margin-bottom: 32px;
-}
-
-.pd-welcome-frame .pd-welcome-actions button {
-  width: 128px;
-  margin: 0 8px;
-}
-/* Welcome frame styles end */
-
-/* Login frame styles start */
-.pd-login-frame {
-  width: 375px;
-  margin-top: 16px;
-  box-sizing: border-box;
-  padding: 16px;
-  color: #212529;
-  display: flex;
-  flex-direction: column;
-}
-
-.pd-login-frame .pd-login-input {
-  margin-bottom: 8px;
-}
-
-.pd-login-frame .pd-login-input.error {
-  border-color: #dc3545;
-}
-
-.pd-login-frame .pd-login-input-label {
-  margin-bottom: 4px;
-  font-size: 14px;
-}
-
-.pd-login-frame .pd-login-btn {
-  background-color: #0d6efd;
-  margin-top: 8px;
-  margin-bottom: 8px;
-  height: 38px;
-  color: #fff;
-}
-/* Login frame styles end */
-
-/* TopBar styles start */
-.pd-top-bar {
-  width: 100%;
-  display: flex;
-  margin-bottom: 0.75rem;
-  justify-content: space-between;
-  box-sizing: border-box;
-  flex-shrink: 0;
-}
-
-.pd-top-bar .pd-top-bar-mode-switcher {
-  border: 1px solid #ced4da;
-  border-radius: 0.375rem;
-  box-sizing: border-box;
-  font-size: 14px;
-}
-
-.pd-top-bar .pd-top-bar-mode-switcher.hidden {
-  display: none;
-}
 
-.pd-top-bar .pd-top-bar-mode-switcher .pd-top-bar-btn {
-  border: none;
-}
-
-.pd-top-bar .pd-top-bar-mode-switcher .pd-top-bar-btn.active {
-  background-color: #0d6efd;
-  color: #fff;
-}
-
-.pd-top-bar .pd-top-bar-mode-switcher .pd-top-bar-btn.disabled {
-  background-color: #e9ecef;
-  color: #212529;
-  cursor: not-allowed;
-  pointer-events: none;
-}
-
-.pd-top-bar .pd-top-bar-mode-switcher .pd-top-bar-btn:first-child {
-  border-top-right-radius: 0;
-  border-bottom-right-radius: 0;
-}
-
-.pd-top-bar .pd-top-bar-mode-switcher .pd-top-bar-btn:last-child {
-  border-top-left-radius: 0;
-  border-bottom-left-radius: 0;
-}
-
-.pd-top-bar .pd-top-bar-mode-switcher .pd-top-bar-btn:nth-child(2) {
-  padding: 6px 10px;
-  border-left: 1px solid #ced4da;
-  border-right: 1px solid #ced4da;
-  border-radius: 0;
-}
-
-.pd-top-bar .pd-top-bar-title {
-  line-height: 30px;
-  font-size: 24px;
-  font-weight: 500;
-  text-align: center;
-  width: 100%;
-}
-
-.pd-top-bar .pd-top-bar-tool-icons {
-  height: 30px;
-  line-height: 28px;
-  box-sizing: border-box;
-  padding: 0 8px;
-  border: 1px solid #ced4da;
-  border-radius: 0.375rem;
-}
-
-.pd-top-bar .pd-top-bar-tool-icons i.bi {
-  margin-right: 16px;
-  font-size: 16px;
-  cursor: pointer;
-  height: 16px;
-  width: 16px;
-}
-
-.pd-top-bar .pd-top-bar-tool-icons i.bi::before {
-  line-height: unset;
-}
-
-.pd-top-bar .pd-top-bar-tool-icons i.bi:hover {
-  color: #0d6efd;
-}
-
-.pd-top-bar .pd-top-bar-tool-icons i.bi:last-child {
-  margin-right: 0;
-}
-/* TopBar styles end */
-
-/* Editor styles start */
-.pd-editor {
-  width: 100%;
-  display: flex;
-  justify-content: space-between;
-  box-sizing: border-box;
-  overflow: hidden;
-  flex-shrink: 0;
-  flex-grow: 1;
-}
-
-.pd-editor .pd-edit-view,
-.pd-editor .pd-read-view {
-  height: calc(
-    100vh - 0.75rem * 3 - 30px
-  ); /* Exact height to prevent flex height expansion */
-  border: 1px solid #ced4da;
-  border-radius: 0.375rem;
-  box-sizing: border-box;
-  color: #212529;
-  overflow: auto;
-  flex-shrink: 0;
-  flex-basis: 0;
-  flex-grow: 1;
-}
-
-.pd-editor .pd-edit-view textarea {
-  border: none;
-}
-
-.pd-editor .pd-edit-view textarea::-webkit-scrollbar,
-.pd-editor .pd-read-view::-webkit-scrollbar {
+/* Custom scrollbar styles */
+.custom-scrollbar::-webkit-scrollbar {
   width: 8px;
   height: 8px;
 }
 
-.pd-editor .pd-edit-view textarea::-webkit-scrollbar-track,
-.pd-editor .pd-read-view::-webkit-scrollbar-track {
+.custom-scrollbar::-webkit-scrollbar-track {
   background-color: transparent;
 }
 
-.pd-editor .pd-edit-view textarea::-webkit-scrollbar-thumb,
-.pd-editor .pd-read-view::-webkit-scrollbar-thumb {
+.custom-scrollbar::-webkit-scrollbar-thumb {
   background-color: #d6dee1;
   border-radius: 8px;
 }
 
-.pd-editor .pd-edit-view textarea::-webkit-scrollbar-thumb:hover,
-.pd-editor .pd-read-view::-webkit-scrollbar-thumb:hover {
+.custom-scrollbar::-webkit-scrollbar-thumb:hover {
   background-color: #a8bbbf;
 }
-
-.pd-editor .pd-read-view {
-  padding: 0.375rem;
-}
-
-.pd-editor.pd-mode-both .pd-edit-view {
-  margin-right: 0.375rem;
-}
-
-.pd-editor.pd-mode-both .pd-read-view {
-  margin-left: 0.375rem;
-}
-
-.pd-editor.pd-mode-edit .pd-read-view {
-  display: none;
-}
-
-.pd-editor.pd-mode-read .pd-edit-view {
-  display: none;
-}
-/* Editor styles end */
-
-/* HomeBar styles start */
-.pd-home-bar {
-  display: flex;
-  align-items: center;
-  justify-content: space-between;
-}
-
-.pd-home-bar button {
-  width: unset;
-}
-
-.pd-home-bar .pd-home-user-info button {
-  margin-left: 8px;
-}
-/* HomeBar styles end */
-
-/* PostList styles start */
-.pd-post-list {
-  width: 100%;
-  display: grid;
-  row-gap: 16px;
-  column-gap: 16px;
-  grid-template-columns: 1fr 1fr 1fr 1fr;
-  margin-top: 16px;
-  padding-bottom: 16px;
-  overflow: auto;
-}
-
-.pd-post-list .pd-post {
-  width: 100%;
-  min-width: 180px;
-  border: 1px solid #dee2e6;
-  border-radius: 0.375rem;
-  box-sizing: border-box;
-  position: relative;
-  padding: 1rem;
-  display: flex;
-  font-size: 16px;
-  flex-direction: column;
-}
-
-.pd-post-list .pd-post:last-child {
-  margin-right: 0;
-}
-
-.pd-post-list .pd-post span {
-  margin-bottom: 8px;
-}
-
-.pd-post-list .pd-post .pd-post-title {
-  font-weight: 500;
-}
-
-.pd-post-list .pd-post .pd-post-action {
-  position: absolute;
-  right: 1rem;
-  top: 0.75rem;
-  font-size: 1.5rem;
-  z-index: 1;
-}
-
-.pd-post-list .pd-post .pd-post-action i {
-  cursor: pointer;
-}
-
-.pd-post-list .pd-post .pd-post-digest {
-  overflow: hidden;
-  text-overflow: ellipsis;
-  white-space: nowrap;
-}
-/* PostList styles end */

+ 37 - 0
components/form/Button.tsx

@@ -0,0 +1,37 @@
+import { JSX } from "preact";
+
+interface ButtonProps extends JSX.ButtonHTMLAttributes<HTMLButtonElement> {
+  variant?: "default" | "primary" | "danger";
+  size?: "sm" | "md" | "lg";
+  children: JSX.Element | string;
+}
+
+export default function Button({ 
+  variant = "default", 
+  size = "md", 
+  children, 
+  className = "", 
+  ...props 
+}: ButtonProps) {
+  const baseClasses = "box-border cursor-pointer font-normal text-sm border border-gray-300 rounded";
+  
+  const variantClasses = {
+    default: "bg-white text-gray-800",
+    primary: "bg-blue-600 text-white border-blue-600",
+    danger: "bg-red-600 text-white border-red-600"
+  };
+  
+  const sizeClasses = {
+    sm: "px-2 py-1 text-xs",
+    md: "px-3 py-1.5",
+    lg: "px-4 py-2 text-base"
+  };
+  
+  const combinedClasses = `${baseClasses} ${variantClasses[variant]} ${sizeClasses[size]} ${className}`;
+  
+  return (
+    <button className={combinedClasses} {...props}>
+      {children}
+    </button>
+  );
+}

+ 24 - 0
components/form/Checkbox.tsx

@@ -0,0 +1,24 @@
+import { JSX } from "preact";
+
+interface CheckboxProps extends JSX.InputHTMLAttributes<HTMLInputElement> {
+  label?: string;
+}
+
+export default function Checkbox({ 
+  label, 
+  className = "", 
+  ...props 
+}: CheckboxProps) {
+  const checkboxClasses = `w-4 h-4 ${className}`;
+  
+  if (label) {
+    return (
+      <label className="flex items-center">
+        <input type="checkbox" className={checkboxClasses} {...props} />
+        {label && <span className="ml-2">{label}</span>}
+      </label>
+    );
+  }
+  
+  return <input type="checkbox" className={checkboxClasses} {...props} />;
+}

+ 26 - 0
components/form/Input.tsx

@@ -0,0 +1,26 @@
+import { JSX } from "preact";
+
+interface InputProps extends JSX.InputHTMLAttributes<HTMLInputElement> {
+  error?: boolean;
+  label?: string;
+}
+
+export default function Input({ 
+  error = false, 
+  label, 
+  className = "", 
+  ...props 
+}: InputProps) {
+  const inputClasses = `w-full block box-border rounded border ${error ? "border-red-600" : "border-gray-300"} text-sm outline-none h-[38px] leading-[30px] py-1 px-1.5 ${className}`;
+  
+  if (label) {
+    return (
+      <div className="mb-2">
+        <label className="block mb-1 text-sm">{label}</label>
+        <input className={inputClasses} {...props} />
+      </div>
+    );
+  }
+  
+  return <input className={inputClasses} {...props} />;
+}

+ 26 - 0
components/form/Textarea.tsx

@@ -0,0 +1,26 @@
+import { JSX } from "preact";
+
+interface TextareaProps extends JSX.TextareaHTMLAttributes<HTMLTextAreaElement> {
+  error?: boolean;
+  label?: string;
+}
+
+export default function Textarea({ 
+  error = false, 
+  label, 
+  className = "", 
+  ...props 
+}: TextareaProps) {
+  const textareaClasses = `w-full block box-border rounded border ${error ? "border-red-600" : "border-gray-300"} text-sm outline-none h-full p-1.5 resize-none ${className}`;
+  
+  if (label) {
+    return (
+      <div className="mb-2">
+        <label className="block mb-1 text-sm">{label}</label>
+        <textarea className={textareaClasses} {...props} />
+      </div>
+    );
+  }
+  
+  return <textarea className={textareaClasses} {...props} />;
+}

+ 18 - 0
components/layout/PageContainer.tsx

@@ -0,0 +1,18 @@
+import { JSX } from "preact";
+
+interface PageContainerProps {
+  children: JSX.Element | JSX.Element[] | string;
+  centered?: boolean;
+  className?: string;
+}
+
+export default function PageContainer({ children, centered = false, className = "" }: PageContainerProps) {
+  const baseClasses = "w-screen min-w-[375px] h-screen p-3 flex flex-col box-border overflow-hidden";
+  const centeredClasses = centered ? "items-center justify-center" : "";
+  
+  return (
+    <div className={`${baseClasses} ${centeredClasses} ${className}`}>
+      {children}
+    </div>
+  );
+}

+ 2 - 0
deno.json

@@ -32,6 +32,8 @@
     "@fresh/plugin-vite": "jsr:@fresh/plugin-vite@^1.0.8",
     "usid": "npm:usid@^2.0.0",
     "vite": "npm:vite@^7.1.3",
+    "@tailwindcss/vite": "npm:@tailwindcss/vite@^4.2.2",
+    "tailwindcss": "npm:tailwindcss@^4.2.2",
     "utils/": "./utils/"
   },
   "compilerOptions": {

+ 204 - 0
deno.lock

@@ -43,6 +43,7 @@
     "npm:@preact/signals@^2.5.0": "2.9.0_preact@10.29.0",
     "npm:@preact/signals@^2.5.1": "2.9.0_preact@10.29.0",
     "npm:@prefresh/vite@^2.4.8": "2.4.12_preact@10.29.0_vite@7.3.1",
+    "npm:@tailwindcss/vite@^4.2.2": "4.2.2_vite@7.3.1",
     "npm:@types/showdown@^2.0.6": "2.0.6",
     "npm:esbuild-wasm@~0.25.11": "0.25.12",
     "npm:esbuild@0.25.7": "0.25.7",
@@ -53,6 +54,7 @@
     "npm:preact@^10.28.3": "10.29.0",
     "npm:rollup@^4.50.0": "4.60.1",
     "npm:showdown@^2.1.0": "2.1.0",
+    "npm:tailwindcss@^4.2.2": "4.2.2",
     "npm:usid@2": "2.0.0",
     "npm:vite@^7.1.3": "7.3.1",
     "npm:vite@^7.1.4": "7.3.1"
@@ -968,6 +970,103 @@
       "os": ["win32"],
       "cpu": ["x64"]
     },
+    "@tailwindcss/node@4.2.2": {
+      "integrity": "sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA==",
+      "dependencies": [
+        "@jridgewell/remapping",
+        "enhanced-resolve",
+        "jiti",
+        "lightningcss",
+        "magic-string",
+        "source-map-js",
+        "tailwindcss"
+      ]
+    },
+    "@tailwindcss/oxide-android-arm64@4.2.2": {
+      "integrity": "sha512-dXGR1n+P3B6748jZO/SvHZq7qBOqqzQ+yFrXpoOWWALWndF9MoSKAT3Q0fYgAzYzGhxNYOoysRvYlpixRBBoDg==",
+      "os": ["android"],
+      "cpu": ["arm64"]
+    },
+    "@tailwindcss/oxide-darwin-arm64@4.2.2": {
+      "integrity": "sha512-iq9Qjr6knfMpZHj55/37ouZeykwbDqF21gPFtfnhCCKGDcPI/21FKC9XdMO/XyBM7qKORx6UIhGgg6jLl7BZlg==",
+      "os": ["darwin"],
+      "cpu": ["arm64"]
+    },
+    "@tailwindcss/oxide-darwin-x64@4.2.2": {
+      "integrity": "sha512-BlR+2c3nzc8f2G639LpL89YY4bdcIdUmiOOkv2GQv4/4M0vJlpXEa0JXNHhCHU7VWOKWT/CjqHdTP8aUuDJkuw==",
+      "os": ["darwin"],
+      "cpu": ["x64"]
+    },
+    "@tailwindcss/oxide-freebsd-x64@4.2.2": {
+      "integrity": "sha512-YUqUgrGMSu2CDO82hzlQ5qSb5xmx3RUrke/QgnoEx7KvmRJHQuZHZmZTLSuuHwFf0DJPybFMXMYf+WJdxHy/nQ==",
+      "os": ["freebsd"],
+      "cpu": ["x64"]
+    },
+    "@tailwindcss/oxide-linux-arm-gnueabihf@4.2.2": {
+      "integrity": "sha512-FPdhvsW6g06T9BWT0qTwiVZYE2WIFo2dY5aCSpjG/S/u1tby+wXoslXS0kl3/KXnULlLr1E3NPRRw0g7t2kgaQ==",
+      "os": ["linux"],
+      "cpu": ["arm"]
+    },
+    "@tailwindcss/oxide-linux-arm64-gnu@4.2.2": {
+      "integrity": "sha512-4og1V+ftEPXGttOO7eCmW7VICmzzJWgMx+QXAJRAhjrSjumCwWqMfkDrNu1LXEQzNAwz28NCUpucgQPrR4S2yw==",
+      "os": ["linux"],
+      "cpu": ["arm64"]
+    },
+    "@tailwindcss/oxide-linux-arm64-musl@4.2.2": {
+      "integrity": "sha512-oCfG/mS+/+XRlwNjnsNLVwnMWYH7tn/kYPsNPh+JSOMlnt93mYNCKHYzylRhI51X+TbR+ufNhhKKzm6QkqX8ag==",
+      "os": ["linux"],
+      "cpu": ["arm64"]
+    },
+    "@tailwindcss/oxide-linux-x64-gnu@4.2.2": {
+      "integrity": "sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg==",
+      "os": ["linux"],
+      "cpu": ["x64"]
+    },
+    "@tailwindcss/oxide-linux-x64-musl@4.2.2": {
+      "integrity": "sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ==",
+      "os": ["linux"],
+      "cpu": ["x64"]
+    },
+    "@tailwindcss/oxide-wasm32-wasi@4.2.2": {
+      "integrity": "sha512-eKSztKsmEsn1O5lJ4ZAfyn41NfG7vzCg496YiGtMDV86jz1q/irhms5O0VrY6ZwTUkFy/EKG3RfWgxSI3VbZ8Q==",
+      "cpu": ["wasm32"]
+    },
+    "@tailwindcss/oxide-win32-arm64-msvc@4.2.2": {
+      "integrity": "sha512-qPmaQM4iKu5mxpsrWZMOZRgZv1tOZpUm+zdhhQP0VhJfyGGO3aUKdbh3gDZc/dPLQwW4eSqWGrrcWNBZWUWaXQ==",
+      "os": ["win32"],
+      "cpu": ["arm64"]
+    },
+    "@tailwindcss/oxide-win32-x64-msvc@4.2.2": {
+      "integrity": "sha512-1T/37VvI7WyH66b+vqHj/cLwnCxt7Qt3WFu5Q8hk65aOvlwAhs7rAp1VkulBJw/N4tMirXjVnylTR72uI0HGcA==",
+      "os": ["win32"],
+      "cpu": ["x64"]
+    },
+    "@tailwindcss/oxide@4.2.2": {
+      "integrity": "sha512-qEUA07+E5kehxYp9BVMpq9E8vnJuBHfJEC0vPC5e7iL/hw7HR61aDKoVoKzrG+QKp56vhNZe4qwkRmMC0zDLvg==",
+      "optionalDependencies": [
+        "@tailwindcss/oxide-android-arm64",
+        "@tailwindcss/oxide-darwin-arm64",
+        "@tailwindcss/oxide-darwin-x64",
+        "@tailwindcss/oxide-freebsd-x64",
+        "@tailwindcss/oxide-linux-arm-gnueabihf",
+        "@tailwindcss/oxide-linux-arm64-gnu",
+        "@tailwindcss/oxide-linux-arm64-musl",
+        "@tailwindcss/oxide-linux-x64-gnu",
+        "@tailwindcss/oxide-linux-x64-musl",
+        "@tailwindcss/oxide-wasm32-wasi",
+        "@tailwindcss/oxide-win32-arm64-msvc",
+        "@tailwindcss/oxide-win32-x64-msvc"
+      ]
+    },
+    "@tailwindcss/vite@4.2.2_vite@7.3.1": {
+      "integrity": "sha512-mEiF5HO1QqCLXoNEfXVA1Tzo+cYsrqV7w9Juj2wdUFyW07JRenqMG225MvPwr3ZD9N1bFQj46X7r33iHxLUW0w==",
+      "dependencies": [
+        "@tailwindcss/node",
+        "@tailwindcss/oxide",
+        "tailwindcss",
+        "vite"
+      ]
+    },
     "@types/estree@1.0.8": {
       "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="
     },
@@ -1004,9 +1103,19 @@
         "ms"
       ]
     },
+    "detect-libc@2.1.2": {
+      "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="
+    },
     "electron-to-chromium@1.5.328": {
       "integrity": "sha512-QNQ5l45DzYytThO21403XN3FvK0hOkWDG8viNf6jqS42msJ8I4tGDSpBCgvDRRPnkffafiwAym2X2eHeGD2V0w=="
     },
+    "enhanced-resolve@5.20.1": {
+      "integrity": "sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==",
+      "dependencies": [
+        "graceful-fs",
+        "tapable"
+      ]
+    },
     "esbuild-wasm@0.25.12": {
       "integrity": "sha512-rZqkjL3Y6FwLpSHzLnaEy8Ps6veCNo1kZa9EOfJvmWtBq5dJH4iVjfmOO6Mlkv9B0tt9WFPFmb/VxlgJOnueNg==",
       "bin": true
@@ -1133,6 +1242,13 @@
     "gensync@1.0.0-beta.2": {
       "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="
     },
+    "graceful-fs@4.2.11": {
+      "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="
+    },
+    "jiti@2.6.1": {
+      "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==",
+      "bin": true
+    },
     "js-tokens@4.0.0": {
       "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="
     },
@@ -1144,12 +1260,92 @@
       "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
       "bin": true
     },
+    "lightningcss-android-arm64@1.32.0": {
+      "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==",
+      "os": ["android"],
+      "cpu": ["arm64"]
+    },
+    "lightningcss-darwin-arm64@1.32.0": {
+      "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==",
+      "os": ["darwin"],
+      "cpu": ["arm64"]
+    },
+    "lightningcss-darwin-x64@1.32.0": {
+      "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==",
+      "os": ["darwin"],
+      "cpu": ["x64"]
+    },
+    "lightningcss-freebsd-x64@1.32.0": {
+      "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==",
+      "os": ["freebsd"],
+      "cpu": ["x64"]
+    },
+    "lightningcss-linux-arm-gnueabihf@1.32.0": {
+      "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==",
+      "os": ["linux"],
+      "cpu": ["arm"]
+    },
+    "lightningcss-linux-arm64-gnu@1.32.0": {
+      "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==",
+      "os": ["linux"],
+      "cpu": ["arm64"]
+    },
+    "lightningcss-linux-arm64-musl@1.32.0": {
+      "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==",
+      "os": ["linux"],
+      "cpu": ["arm64"]
+    },
+    "lightningcss-linux-x64-gnu@1.32.0": {
+      "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==",
+      "os": ["linux"],
+      "cpu": ["x64"]
+    },
+    "lightningcss-linux-x64-musl@1.32.0": {
+      "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==",
+      "os": ["linux"],
+      "cpu": ["x64"]
+    },
+    "lightningcss-win32-arm64-msvc@1.32.0": {
+      "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==",
+      "os": ["win32"],
+      "cpu": ["arm64"]
+    },
+    "lightningcss-win32-x64-msvc@1.32.0": {
+      "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==",
+      "os": ["win32"],
+      "cpu": ["x64"]
+    },
+    "lightningcss@1.32.0": {
+      "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==",
+      "dependencies": [
+        "detect-libc"
+      ],
+      "optionalDependencies": [
+        "lightningcss-android-arm64",
+        "lightningcss-darwin-arm64",
+        "lightningcss-darwin-x64",
+        "lightningcss-freebsd-x64",
+        "lightningcss-linux-arm-gnueabihf",
+        "lightningcss-linux-arm64-gnu",
+        "lightningcss-linux-arm64-musl",
+        "lightningcss-linux-x64-gnu",
+        "lightningcss-linux-x64-musl",
+        "lightningcss-win32-arm64-msvc",
+        "lightningcss-win32-x64-msvc"
+      ]
+    },
     "lru-cache@5.1.1": {
       "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
       "dependencies": [
         "yallist"
       ]
     },
+    "magic-string@0.30.21": {
+      "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
+      "dependencies": [
+        "@jridgewell/sourcemap-codec"
+      ]
+    },
     "ms@2.1.3": {
       "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
     },
@@ -1238,6 +1434,12 @@
     "source-map-js@1.2.1": {
       "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="
     },
+    "tailwindcss@4.2.2": {
+      "integrity": "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q=="
+    },
+    "tapable@2.3.2": {
+      "integrity": "sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA=="
+    },
     "tinyglobby@0.2.15": {
       "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
       "dependencies": [
@@ -1296,9 +1498,11 @@
       "jsr:@std/encoding@^1.0.10",
       "jsr:@std/http@^1.0.25",
       "npm:@preact/signals@^2.5.0",
+      "npm:@tailwindcss/vite@^4.2.2",
       "npm:@types/showdown@^2.0.6",
       "npm:preact@^10.27.2",
       "npm:showdown@^2.1.0",
+      "npm:tailwindcss@^4.2.2",
       "npm:usid@2",
       "npm:vite@^7.1.3"
     ]

+ 8 - 7
islands/Editor.tsx

@@ -2,7 +2,8 @@ import { useEffect, useRef, useState } from "preact/hooks";
 import showdown, { Converter } from "showdown";
 import { asset } from "fresh/runtime";
 import { debounce, DebouncedFunction } from "@std/async";
-import { hideLoading } from "utils/ui.ts";
+import Textarea from "../components/form/Textarea.tsx";
+
 
 export enum EditorMode {
   Edit = 1,
@@ -164,7 +165,7 @@ export default function Editor(props: EditorProps) {
   useEffect(() => {
     setDisplayContent(props.content);
     convertText(props.content);
-    hideLoading();
+    globalThis.$loading?.hide();
   }, [props.content]);
 
   const convertText = (text: string) => {
@@ -208,18 +209,18 @@ export default function Editor(props: EditorProps) {
   };
 
   return (
-    <div className={`pd-editor pd-mode-${getModeText(mode)}`}>
+    <div className="w-full flex justify-between box-border overflow-hidden flex-shrink-0 flex-grow-1">
       {props.allowMode !== EditorMode.Read
         ? (
-          <div className="pd-edit-view" ref={editViewRef}>
-            <textarea
+          <div className={`h-[calc(100vh-0.75rem*3-30px)] border border-gray-300 rounded box-border text-gray-800 overflow-auto flex-shrink-0 flex-basis-0 flex-grow-1 custom-scrollbar ${mode === EditorMode.Both ? "mr-1.5" : ""} ${mode === EditorMode.Read ? "hidden" : ""}`} ref={editViewRef}>
+            <Textarea
+              className="border-none rounded-none custom-scrollbar"
               spellcheck={false}
               placeholder="Some Markdown here"
               onScroll={() => {
                 onScroll(EditorMode.Edit);
               }}
               onPaste={() => {
-                // Sync scroll again after render
                 setTimeout(() => {
                   onScroll(EditorMode.Edit);
                 }, 100);
@@ -235,7 +236,7 @@ export default function Editor(props: EditorProps) {
       {props.allowMode !== EditorMode.Edit
         ? (
           <div
-            className="pd-read-view"
+            className={`h-[calc(100vh-0.75rem*3-30px)] border border-gray-300 rounded box-border text-gray-800 overflow-auto flex-shrink-0 flex-basis-0 flex-grow-1 p-1.5 custom-scrollbar ${mode === EditorMode.Both ? "ml-1.5" : ""} ${mode === EditorMode.Edit ? "hidden" : ""}`}
             ref={readViewRef}
             onScroll={() => {
               onScroll(EditorMode.Read);

+ 52 - 42
islands/HomeBar.tsx

@@ -1,4 +1,5 @@
-import { showLoading } from "utils/ui.ts";
+import Input from "../components/form/Input.tsx";
+import Button from "../components/form/Button.tsx";
 
 interface HomeBarProps {
   name: string;
@@ -8,7 +9,7 @@ const settingsData: { [key: string]: string } = {};
 
 export default function HomeBar(props: HomeBarProps) {
   const doNewPost = async () => {
-    showLoading();
+    globalThis.$loading?.show();
     const resp = await fetch("/api/post", {
       method: "POST",
       headers: { "Content-Type": "application/json" },
@@ -42,41 +43,33 @@ export default function HomeBar(props: HomeBarProps) {
     globalThis.$modal?.show(
       "Reset password",
       <div>
-        <div style="display: flex; align-items: center; margin-bottom: 8px">
-          <span style="width: 120px; margin-right: 8px">Old password</span>
-          <input
-            type="password"
-            placeholder="Old password"
-            value={settingsData["old"]}
-            onInput={(e) => {
-              settingsData["old"] = (e.target as HTMLInputElement).value;
-            }}
-          />
-        </div>
-        <div style="display: flex; align-items: center; margin-bottom: 8px">
-          <span style="width: 120px; margin-right: 8px">New password</span>
-          <input
-            type="password"
-            placeholder="New password"
-            value={settingsData["new"]}
-            onInput={(e) => {
-              settingsData["new"] = (e.target as HTMLInputElement).value;
-            }}
-          />
-        </div>
-        <div style="display: flex; align-items: center;">
-          <span style="width: 120px; margin-right: 8px">
-            Repeat new password
-          </span>
-          <input
-            type="password"
-            placeholder="Repeat new password"
-            value={settingsData["repeat"]}
-            onInput={(e) => {
-              settingsData["repeat"] = (e.target as HTMLInputElement).value;
-            }}
-          />
-        </div>
+        <Input
+          label="Old password"
+          type="password"
+          placeholder="Old password"
+          value={settingsData["old"]}
+          onInput={(e) => {
+            settingsData["old"] = (e.target as HTMLInputElement).value;
+          }}
+        />
+        <Input
+          label="New password"
+          type="password"
+          placeholder="New password"
+          value={settingsData["new"]}
+          onInput={(e) => {
+            settingsData["new"] = (e.target as HTMLInputElement).value;
+          }}
+        />
+        <Input
+          label="Repeat new password"
+          type="password"
+          placeholder="Repeat new password"
+          value={settingsData["repeat"]}
+          onInput={(e) => {
+            settingsData["repeat"] = (e.target as HTMLInputElement).value;
+          }}
+        />
       </div>,
       [
         {
@@ -107,12 +100,29 @@ export default function HomeBar(props: HomeBarProps) {
   };
 
   return (
-    <div className="pd-home-bar">
-      <button type="button" onClick={doNewPost}>New Post</button>
-      <div className="pd-home-user-info">
+    <div className="flex items-center justify-between">
+      <Button
+        type="button"
+        onClick={doNewPost}
+      >
+        New Post
+      </Button>
+      <div>
         <span>{props.name}</span>
-        <button type="button" onClick={showReset}>Password</button>
-        <button type="button" onClick={doLogout}>Logout</button>
+        <Button
+          type="button"
+          className="ml-2"
+          onClick={showReset}
+        >
+          Password
+        </Button>
+        <Button
+          type="button"
+          className="ml-2"
+          onClick={doLogout}
+        >
+          Logout
+        </Button>
       </div>
     </div>
   );

+ 33 - 0
islands/Loading.tsx

@@ -0,0 +1,33 @@
+import { useEffect, useState } from "preact/hooks";
+
+interface LoadingGlobalHook {
+  show: () => void;
+  hide: () => void;
+}
+
+declare global {
+  var $loading: LoadingGlobalHook | undefined;
+}
+
+export default function Loading() {
+  const [visible, setVisible] = useState(false);
+
+  useEffect(() => {
+    globalThis.$loading = {
+      show: () => setVisible(true),
+      hide: () => setVisible(false),
+    };
+
+    return () => {
+      delete globalThis.$loading;
+    };
+  }, []);
+
+  return (
+    <div className={`fixed inset-0 bg-black/60 z-[9] flex items-center justify-center ${visible ? "" : "hidden"}`}>
+      <div className="inline-block transform translate-z-[1px]">
+        <div className="inline-block w-16 h-16 m-2 rounded-full bg-white animate-[pd-loading-spin_5s_cubic-bezier(0,0.2,0.8,1)_infinite]" />
+      </div>
+    </div>
+  );
+}

+ 32 - 34
islands/LoginFrame.tsx

@@ -1,5 +1,7 @@
 import { useEffect, useState } from "preact/hooks";
-import { hideLoading, showLoading } from "utils/ui.ts";
+import Input from "../components/form/Input.tsx";
+import Button from "../components/form/Button.tsx";
+
 
 interface LoginFrameProps {
   mode: "login" | "register";
@@ -63,7 +65,7 @@ export default function LoginFrame(props: LoginFrameProps) {
   }, []);
 
   const onSubmit = async () => {
-    showLoading();
+    globalThis.$loading?.show();
 
     // Do request
     if (email && password && props.mode === "login") {
@@ -91,14 +93,14 @@ export default function LoginFrame(props: LoginFrameProps) {
     if (!confirmPassword && props.mode === "register") {
       setConfirmPasswordError(true);
     }
-    hideLoading();
+    globalThis.$loading?.hide();
   };
 
   return (
-    <div className="pd-login-frame">
-      <span className="pd-login-input-label">Email</span>
-      <input
-        className={`pd-login-input${emailError ? " error" : ""}`}
+    <div className="w-[375px] mt-4 box-border p-4 text-gray-800 flex flex-col">
+      <Input
+        label="Email"
+        error={emailError}
         type="text"
         placeholder="Your email"
         value={email}
@@ -107,9 +109,9 @@ export default function LoginFrame(props: LoginFrameProps) {
           setEmail((e.target as HTMLInputElement).value);
         }}
       />
-      <span className="pd-login-input-label">Password</span>
-      <input
-        className={`pd-login-input${passwordError ? " error" : ""}`}
+      <Input
+        label="Password"
+        error={passwordError}
         type="password"
         placeholder="Your password"
         value={password}
@@ -125,39 +127,35 @@ export default function LoginFrame(props: LoginFrameProps) {
       />
       {props.mode === "register"
         ? (
-          <>
-            <span className="pd-login-input-label">Confirm Password</span>
-            <input
-              className={`pd-login-input${
-                confirmPasswordError ? " error" : ""
-              }`}
-              type="password"
-              placeholder="Confirm your password"
-              value={confirmPassword}
-              onInput={(e) => {
-                setConfirmPasswordError(false);
-                setConfirmPassword((e.target as HTMLInputElement).value);
-              }}
-              onKeyDown={(e) => {
-                if (e.key === "Enter") {
-                  onSubmit();
-                }
-              }}
-            />
-          </>
+          <Input
+            label="Confirm Password"
+            error={confirmPasswordError}
+            type="password"
+            placeholder="Confirm your password"
+            value={confirmPassword}
+            onInput={(e) => {
+              setConfirmPasswordError(false);
+              setConfirmPassword((e.target as HTMLInputElement).value);
+            }}
+            onKeyDown={(e) => {
+              if (e.key === "Enter") {
+                onSubmit();
+              }
+            }}
+          />
         )
         : null}
-      <button className="pd-login-btn" type="button" onClick={onSubmit}>
+      <Button variant="primary" className="h-[38px] mt-2 mb-2" type="button" onClick={onSubmit}>
         {props.mode === "register" ? "Register" : "Sign in"}
-      </button>
-      <button
+      </Button>
+      <Button
         type="button"
         onClick={() => {
           location.href = props.mode === "register" ? "/login" : "/register";
         }}
       >
         {props.mode === "register" ? "Go Login" : "Go Register"}
-      </button>
+      </Button>
     </div>
   );
 }

+ 10 - 8
islands/Modal.tsx

@@ -1,5 +1,6 @@
 import { JSX } from "preact";
 import { useEffect, useState } from "preact/hooks";
+import Button from "../components/form/Button.tsx";
 
 interface ModalAction {
   text: string;
@@ -57,23 +58,24 @@ export default function Modal() {
 
   return (
     <>
-      <div className={`pd-modal${!visible ? " pd-modal-hidden" : ""}`}>
-        <div className="pd-modal-content">
+      <div className={`fixed inset-0 bg-black/60 z-[8] flex items-center justify-center ${!visible ? "hidden" : ""}`}>
+        <div className="bg-white border border-gray-200 rounded-lg w-[500px] max-w-[90%] max-h-[60%] relative text-base cursor-pointer">
           <i
-            className="bi bi-x pd-modal-close"
+            className="bi bi-x absolute right-4 top-3 text-2xl"
             onClick={() => {
               hideModal();
             }}
           />
-          {title ? <div className="pd-modal-title">{title}</div> : null}
-          <div className="pd-modal-body">{content}</div>
+          {title ? <div className="p-4 border-b border-gray-200 font-medium">{title}</div> : null}
+          <div className="p-4">{content}</div>
           {actions.length > 0
             ? (
-              <div className="pd-modal-footer">
+              <div className="flex justify-end border-t border-gray-200 p-4">
                 {actions.map((action, index) => (
-                  <button
+                  <Button
                     type="button"
                     key={index}
+                    className="ml-2 first:ml-0"
                     onClick={() => {
                       action.onClick
                         ? action.onClick(action.text)
@@ -81,7 +83,7 @@ export default function Modal() {
                     }}
                   >
                     {action.text}
-                  </button>
+                  </Button>
                 ))}
               </div>
             )

+ 10 - 8
islands/PostList.tsx

@@ -1,3 +1,5 @@
+import Button from "../components/form/Button.tsx";
+
 interface PostListProps {
   posts: { id: string; title: string; content: string; shared: boolean }[];
 }
@@ -36,27 +38,27 @@ export default function PostList(props: PostListProps) {
   };
 
   return (
-    <div className="pd-post-list">
+    <div className="w-full grid grid-cols-4 gap-4 mt-4 pb-4 overflow-auto">
       {props.posts.map((post) => (
-        <div className="pd-post" key={post.id}>
-          <span className="pd-post-title">{post.title || "Untitled"}</span>
-          <div className="pd-post-action">
+        <div className="w-full min-w-[180px] border border-gray-200 rounded box-border relative p-4 text-base flex flex-col" key={post.id}>
+          <span className="font-medium mb-2">{post.title || "Untitled"}</span>
+          <div className="absolute right-4 top-3 text-2xl z-[1]">
             <i
-              className="bi bi-x"
+              className="bi bi-x cursor-pointer"
               onClick={() => {
                 onDelete(post.id, post.title);
               }}
             />
           </div>
-          <span className="pd-post-digest">{post.content || "No content"}</span>
-          <button
+          <span className="overflow-hidden text-ellipsis whitespace-nowrap mb-2">{post.content || "No content"}</span>
+          <Button
             type="button"
             onClick={() => {
               onEdit(post.id);
             }}
           >
             Edit
-          </button>
+          </Button>
         </div>
       ))}
     </div>

+ 46 - 30
islands/TopBar.tsx

@@ -1,5 +1,8 @@
 import { useEffect, useState } from "preact/hooks";
 import { EditorMode } from "./Editor.tsx";
+import Button from "../components/form/Button.tsx";
+import Input from "../components/form/Input.tsx";
+import Checkbox from "../components/form/Checkbox.tsx";
 
 interface TopBarProps {
   allowMode: EditorMode;
@@ -36,8 +39,8 @@ export default function TopBar(props: TopBarProps) {
     globalThis.$modal?.show(
       "Share options",
       <div style="display: flex; align-items: center">
-        <input
-          type="checkbox"
+        <Checkbox
+          label="Share to friends"
           checked={shareData["shared"]}
           onChange={(e) => {
             shareData["shared"] = (e.target as HTMLInputElement).checked;
@@ -46,17 +49,17 @@ export default function TopBar(props: TopBarProps) {
             ).style.visibility = shareData["shared"] ? "visible" : "hidden";
           }}
         />
-        <span style="margin: 0 8px">Shared to friends</span>
-        <button
+        <Button
           type="button"
           id="shared-button"
+          className="ml-2"
           style={`${!shareData["shared"] ? ";visibility: hidden" : ""}`}
           onClick={async () => {
             await navigator.clipboard.writeText(location.href.split("?")[0]);
           }}
         >
           Copy Link
-        </button>
+        </Button>
       </div>,
       [
         {
@@ -87,7 +90,7 @@ export default function TopBar(props: TopBarProps) {
       "Post Settings",
       <div style="display: flex; align-items: center">
         <span style="margin-right: 8px">Title</span>
-        <input
+        <Input
           placeholder="Post title here"
           value={settingsData["title"]}
           onInput={(e) => {
@@ -145,16 +148,15 @@ export default function TopBar(props: TopBarProps) {
   }, []);
 
   return (
-    <div className="pd-top-bar">
+    <div className="w-full flex mb-3 justify-between box-border flex-shrink-0">
       <div
-        className={`pd-top-bar-mode-switcher${
-          props.allowMode !== EditorMode.Both ? " hidden" : ""
+        className={`border border-gray-300 rounded box-border text-sm ${
+          props.allowMode !== EditorMode.Both ? "hidden" : ""
         }`}
       >
-        <button
-          className={`pd-top-bar-btn${
-            mode === EditorMode.Edit ? " active" : ""
-          }`}
+        <Button
+          variant={mode === EditorMode.Edit ? "primary" : "default"}
+          className="rounded-tr-none rounded-br-none"
           id="edit"
           type="button"
           onClick={() => {
@@ -162,11 +164,10 @@ export default function TopBar(props: TopBarProps) {
           }}
         >
           Edit
-        </button>
-        <button
-          className={`pd-top-bar-btn${
-            mode === EditorMode.Read ? " active" : ""
-          }`}
+        </Button>
+        <Button
+          variant={mode === EditorMode.Read ? "primary" : "default"}
+          className="rounded-none"
           id="read"
           type="button"
           onClick={() => {
@@ -174,11 +175,10 @@ export default function TopBar(props: TopBarProps) {
           }}
         >
           Read
-        </button>
-        <button
-          className={`pd-top-bar-btn${
-            mode === EditorMode.Both ? " active" : ""
-          }`}
+        </Button>
+        <Button
+          variant={mode === EditorMode.Both ? "primary" : "default"}
+          className="rounded-tl-none rounded-bl-none"
           id="both"
           type="button"
           onClick={() => {
@@ -186,18 +186,34 @@ export default function TopBar(props: TopBarProps) {
           }}
         >
           Both
-        </button>
+        </Button>
       </div>
       {!props.isLogined
-        ? <span className="pd-top-bar-title">{props.title}</span>
+        ? (
+          <span className="leading-[30px] text-2xl font-medium text-center w-full">
+            {props.title}
+          </span>
+        )
         : null}
       {props.isLogined
         ? (
-          <div className="pd-top-bar-tool-icons">
-            <i className="bi bi-box-arrow-left" onClick={doLogout} />
-            <i className="bi bi-house-door" onClick={goHome} />
-            <i className="bi bi-share" onClick={showShare} />
-            <i className="bi bi-gear" onClick={showSetting} />
+          <div className="h-[30px] leading-[28px] box-border px-2 border border-gray-300 rounded">
+            <i
+              className="bi bi-box-arrow-left mr-4 text-base cursor-pointer h-4 w-4 hover:text-blue-600"
+              onClick={doLogout}
+            />
+            <i
+              className="bi bi-house-door mr-4 text-base cursor-pointer h-4 w-4 hover:text-blue-600"
+              onClick={goHome}
+            />
+            <i
+              className="bi bi-share mr-4 text-base cursor-pointer h-4 w-4 hover:text-blue-600"
+              onClick={showShare}
+            />
+            <i
+              className="bi bi-gear text-base cursor-pointer h-4 w-4 hover:text-blue-600"
+              onClick={showSetting}
+            />
           </div>
         )
         : null}

+ 20 - 7
islands/WelcomeFrame.tsx

@@ -1,4 +1,5 @@
 import { asset } from "fresh/runtime";
+import Button from "../components/form/Button.tsx";
 
 export default function WelcomeFrame() {
   const goLogin = () => {
@@ -10,21 +11,33 @@ export default function WelcomeFrame() {
   };
 
   return (
-    <div className="pd-welcome-frame">
-      <div className="pd-welcome-title">
+    <div className="flex items-center justify-center flex-col w-full h-full">
+      <div className="flex items-center">
         <img
-          className="pd-logo"
+          className="w-32 h-32 mr-2"
           src={asset("/postdown.png")}
           alt="Postdown"
         />
         <h1>Postdown</h1>
       </div>
-      <span className="pd-welcome-intro">
+      <span className="text-xl mb-8">
         A web-based, shareable, self-hosted Markdown editor built with deno
       </span>
-      <div className="pd-welcome-actions">
-        <button type="button" onClick={goLogin}>Login</button>
-        <button type="button" onClick={goRegister}>Register</button>
+      <div>
+        <Button
+          type="button"
+          className="w-32 mx-2"
+          onClick={goLogin}
+        >
+          Login
+        </Button>
+        <Button
+          type="button"
+          className="w-32 mx-2"
+          onClick={goRegister}
+        >
+          Register
+        </Button>
       </div>
     </div>
   );

+ 3 - 2
routes/[id].tsx

@@ -6,6 +6,7 @@ import { checkToken } from "utils/server.ts";
 import { find } from "utils/db.ts";
 import TopBar from "../islands/TopBar.tsx";
 import Editor, { EditorMode } from "../islands/Editor.tsx";
+import PageContainer from "../components/layout/PageContainer.tsx";
 
 interface PostProps {
   id: string;
@@ -47,7 +48,7 @@ export default function Post(props: PageProps<PostProps>) {
       <Head>
         <title>{props.data.title}</title>
       </Head>
-      <div className="pd-page">
+      <PageContainer>
         <TopBar
           id={props.data.id}
           title={props.data.title}
@@ -60,7 +61,7 @@ export default function Post(props: PageProps<PostProps>) {
           content={props.data.content}
           allowMode={props.data.allowMode}
         />
-      </div>
+      </PageContainer>
     </>
   );
 }

+ 2 - 0
routes/_app.tsx

@@ -1,6 +1,7 @@
 import type { PageProps } from "fresh";
 import { define } from "utils/state.ts";
 import Modal from "../islands/Modal.tsx";
+import Loading from "../islands/Loading.tsx";
 
 export default define.page(({ Component }: PageProps) => {
   return (
@@ -13,6 +14,7 @@ export default define.page(({ Component }: PageProps) => {
       </head>
       <body>
         <Modal />
+        <Loading />
         <Component />
       </body>
     </html>

+ 5 - 4
routes/_error.tsx

@@ -1,18 +1,19 @@
 import { HttpError } from "fresh";
 import { define } from "utils/state.ts";
 import type { PageProps } from "fresh";
+import PageContainer from "../components/layout/PageContainer.tsx";
 
 export default define.page((props: PageProps) => {
   if (props.error instanceof HttpError && props.error.status === 404) {
     return (
-      <div className="pd-page pd-page-centered">
+      <PageContainer centered>
         Not Found: {props.url.pathname}
-      </div>
+      </PageContainer>
     );
   }
   return (
-    <div className="pd-page pd-page-centered">
+    <PageContainer centered>
       Something went wrong
-    </div>
+    </PageContainer>
   );
 });

+ 3 - 2
routes/index.tsx

@@ -6,6 +6,7 @@ import { define } from "utils/state.ts";
 import HomeBar from "../islands/HomeBar.tsx";
 import PostList from "../islands/PostList.tsx";
 import WelcomeFrame from "../islands/WelcomeFrame.tsx";
+import PageContainer from "../components/layout/PageContainer.tsx";
 
 interface HomeProps {
   name: string;
@@ -49,7 +50,7 @@ export default define.page((props: PageProps<HomeProps>) => {
       <Head>
         <title>Home</title>
       </Head>
-      <div className="pd-page">
+      <PageContainer>
         {props.data.name
           ? (
             <>
@@ -58,7 +59,7 @@ export default define.page((props: PageProps<HomeProps>) => {
             </>
           )
           : <WelcomeFrame />}
-      </div>
+      </PageContainer>
     </>
   );
 });

+ 3 - 2
routes/login.tsx

@@ -1,6 +1,7 @@
 import { Head } from "fresh/runtime";
 import { define } from "utils/state.ts";
 import LoginFrame from "../islands/LoginFrame.tsx";
+import PageContainer from "../components/layout/PageContainer.tsx";
 
 export default define.page(() => {
   return (
@@ -8,10 +9,10 @@ export default define.page(() => {
       <Head>
         <title>Login</title>
       </Head>
-      <div className="pd-page pd-page-centered">
+      <PageContainer centered>
         <h2>Sign in to Postdown</h2>
         <LoginFrame mode="login" />
-      </div>
+      </PageContainer>
     </>
   );
 });

+ 3 - 2
routes/register.tsx

@@ -1,6 +1,7 @@
 import { Head } from "fresh/runtime";
 import { define } from "utils/state.ts";
 import LoginFrame from "../islands/LoginFrame.tsx";
+import PageContainer from "../components/layout/PageContainer.tsx";
 
 export default define.page(() => {
   return (
@@ -8,10 +9,10 @@ export default define.page(() => {
       <Head>
         <title>Register</title>
       </Head>
-      <div className="pd-page pd-page-centered">
+      <PageContainer centered>
         <h2>Register to Postdown</h2>
         <LoginFrame mode="register" />
-      </div>
+      </PageContainer>
     </>
   );
 });

+ 13 - 0
tailwind.config.ts

@@ -0,0 +1,13 @@
+import type { Config } from "tailwindcss";
+
+export default {
+  content: [
+    "./routes/**/*.{ts,tsx}",
+    "./islands/**/*.{ts,tsx}",
+    "./components/**/*.{ts,tsx}",
+  ],
+  theme: {
+    extend: {},
+  },
+  plugins: [],
+} satisfies Config;

+ 0 - 25
utils/ui.ts

@@ -1,25 +0,0 @@
-export function showLoading() {
-  if (document && document.body) {
-    const coverEle = document.body.querySelector(".pd-loading-cover");
-    if (!coverEle) {
-      const newCoverEle = document.createElement("div");
-      const newCoverSpinEle = document.createElement("div");
-      const newCoverSpinInnerEle = document.createElement("div");
-      newCoverEle.className = "pd-loading-cover";
-      newCoverSpinEle.className = "pd-loading-spin";
-      newCoverSpinInnerEle.className = "pd-loading-spin-inner";
-      newCoverSpinEle.appendChild(newCoverSpinInnerEle);
-      newCoverEle.appendChild(newCoverSpinEle);
-      document.body.appendChild(newCoverEle);
-    }
-  }
-}
-
-export function hideLoading() {
-  if (document && document.body) {
-    const coverEle = document.body.querySelector(".pd-loading-cover");
-    if (coverEle) {
-      document.body.removeChild(coverEle);
-    }
-  }
-}

+ 2 - 1
vite.config.ts

@@ -1,8 +1,9 @@
 import { defineConfig } from "vite";
 import { fresh } from "@fresh/plugin-vite";
+import tailwindcss from "@tailwindcss/vite";
 
 export default defineConfig({
-  plugins: [fresh()],
+  plugins: [fresh(), tailwindcss()],
   server: {
     port: 8000,
     watch: {