diff --git a/internal/routes/mod.go b/internal/routes/mod.go index 714c1e4..29334b5 100644 --- a/internal/routes/mod.go +++ b/internal/routes/mod.go @@ -3,6 +3,7 @@ package routes import ( "fmt" "os" + "path/filepath" "git.wh64.net/devproje/kuma-archive/internal/service" "github.com/gin-contrib/static" @@ -14,22 +15,28 @@ func New(app *gin.Engine, apiOnly bool) { { api.GET("/path/*path", func(ctx *gin.Context) { worker := service.NewWorkerService() - path := ctx.Param("path") data, err := worker.Read(path) if err != nil { - fmt.Fprintf(os.Stderr, "%v\n", err) + _, _ = fmt.Fprintf(os.Stderr, "%v\n", err) ctx.Status(404) return } if !data.IsDir { - ctx.FileAttachment(data.Path, data.Name) + ctx.JSON(200, gin.H{ + "ok": 1, + "path": path, + "total": data.FileSize, + "is_dir": false, + "entries": nil, + }) return } raw, err := os.ReadDir(data.Path) if err != nil { + _, _ = fmt.Fprintf(os.Stderr, "%v\n", err) ctx.Status(500) return } @@ -44,7 +51,8 @@ func New(app *gin.Engine, apiOnly bool) { entries = append(entries, service.DirEntry{ Name: entry.Name(), - Path: path, + Path: filepath.Join(path, entry.Name()), + Date: finfo.ModTime().Unix(), FileSize: uint64(finfo.Size()), IsDir: finfo.IsDir(), }) @@ -53,9 +61,47 @@ func New(app *gin.Engine, apiOnly bool) { ctx.JSON(200, gin.H{ "ok": 1, "path": path, + "total": data.FileSize, + "is_dir": true, "entries": entries, }) }) + + api.GET("/raw/*path", func(ctx *gin.Context) { + worker := service.NewWorkerService() + path := ctx.Param("path") + data, err := worker.Read(path) + if err != nil { + _, _ = fmt.Fprintf(os.Stderr, "%v\n", err) + ctx.Status(404) + return + } + + if data.IsDir { + ctx.String(400, "current path is not file") + return + } + + ctx.File(data.Path) + }) + + api.GET("/download/*path", func(ctx *gin.Context) { + worker := service.NewWorkerService() + path := ctx.Param("path") + data, err := worker.Read(path) + if err != nil { + _, _ = fmt.Fprintf(os.Stderr, "%v\n", err) + ctx.Status(404) + return + } + + if data.IsDir { + ctx.String(400, "current path is not file") + return + } + + ctx.FileAttachment(data.Path, data.Name) + }) } if apiOnly { diff --git a/internal/service/worker.go b/internal/service/worker.go index 3859d54..dfc8c80 100644 --- a/internal/service/worker.go +++ b/internal/service/worker.go @@ -12,6 +12,7 @@ type WorkerService struct{} type DirEntry struct { Name string `json:"name"` Path string `json:"path"` + Date int64 `json:"date"` FileSize uint64 `json:"file_size"` IsDir bool `json:"is_dir"` } @@ -29,8 +30,10 @@ func (sv *WorkerService) Read(path string) (*DirEntry, error) { ret := DirEntry{ Name: info.Name(), + Date: info.ModTime().Unix(), Path: fullpath, FileSize: uint64(info.Size()), + IsDir: info.IsDir(), } return &ret, nil } diff --git a/src/App.scss b/src/App.scss index c665890..3510121 100644 --- a/src/App.scss +++ b/src/App.scss @@ -43,17 +43,40 @@ .ka-menu-item { width: 100%; - height: 55px; - padding: 5px 0.5rem; + height: 45px; display: flex; + padding: 0 0.5rem; align-items: center; span { margin: 0 5px; } + + &:hover { + background-color: var(--nav-hover); + } + + &:focus { + color: var(--focus); + } } @media (max-width: 640px) { width: 100%; } } + +.ka-footer { + width: 100%; + display: flex; + align-items: center; + flex-direction: column; + justify-content: center; + + div { + display: flex; + align-items: center; + flex-direction: row; + justify-content: center; + } +} diff --git a/src/App.tsx b/src/App.tsx index 1008747..97bf2b0 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,10 +1,12 @@ -import { BrowserRouter, Route, Routes, useLocation } from "react-router"; -import { usePath } from "./store/path"; import { useEffect, useState } from "react"; +import Directory from "./components/directory"; +import { DirEntry, usePath } from "./store/path"; +import { DynamicIcon, IconName } from "lucide-react/dynamic"; +import { BrowserRouter, Route, Routes, useLocation } from "react-router"; import "./App.scss"; import kuma from "./assets/kuma.png"; -import { DynamicIcon, IconName } from "lucide-react/dynamic"; +import FileView from "./components/file-view"; function App() { return ( @@ -24,19 +26,37 @@ function Dashboard() { useEffect(() => { if (!load) { path.update(location.pathname.substring(1, location.pathname.length)); - setLoad(true); - } + setLoad(true); - const id = setInterval(() => { - path.update(location.pathname.substring(1, location.pathname.length)); - }, 5000); + return; + } + + const id = setInterval(() => { + path.update(location.pathname.substring(1, location.pathname.length)); + }, 5000); return () => clearInterval(id); }, [load, path, location]); + if (!load) { + return <>>; + } + return ( + {typeof path.data !== "undefined" ? path.data.is_dir ? : : ( + <> + 404 Not Found + + { + ev.preventDefault(); + document.location.href = "/"; + }}>Back to home + > + )} + + ); } @@ -46,7 +66,7 @@ function Header() { return ( - + Kuma Archive @@ -77,16 +97,47 @@ function MenuItem({ icon, name, block }: { icon: IconName, name: string, block?: return ( { ev.preventDefault(); - if (typeof block === "undefined") return; block(); }}> - + {name} ); } +function Footer() { + const path = usePath(); + let file = 0; + let dir = 0; + + if (typeof path.data !== "undefined") { + if (path.data.is_dir) { + path.data.entries.forEach((entry: DirEntry) => { + if (entry.is_dir) { + dir += 1; + } else { + file += 1; + } + }); + } + } + + return ( + + ); +} + export default App; diff --git a/src/components/directory/directory.scss b/src/components/directory/directory.scss index 8b13789..84300f0 100644 --- a/src/components/directory/directory.scss +++ b/src/components/directory/directory.scss @@ -1 +1,30 @@ +.ka-dir { + width: 100%; + height: 100%; + display: flex; + margin: 10px 0; + flex-direction: column; + border-bottom: solid 2px var(--foreground); + .ka-dir-top { + border-bottom: solid 2px var(--foreground); + } + + .ka-dir-row { + padding: 0 10px; + width: 100%; + height: 25px; + display: grid; + grid-template-columns: 0.1fr 2fr 1fr 1fr; + + .ka-dir-item { + display: flex; + align-items: center; + + } + + a:hover.ka-dir-item { + text-decoration: underline; + } + } +} diff --git a/src/components/directory/index.tsx b/src/components/directory/index.tsx index d505fa6..667f7a8 100644 --- a/src/components/directory/index.tsx +++ b/src/components/directory/index.tsx @@ -1,6 +1,68 @@ +import { convert } from "../../util/unit"; +import { DynamicIcon } from "lucide-react/dynamic"; +import { DirEntry, usePath } from "../../store/path"; + +import "./directory.scss"; + function Directory() { + const path = usePath(); + if (typeof path.data === "undefined") + return <>>; + return ( - <>> + + + + Name + Size + Date + + + {path.data.path === "/" ? <>> : ( + + )} + + {path.data.entries.map((entry, key) => { + return ; + })} + + ); +} + +function DirItem({ data }: { data: DirEntry }) { + return ( + + + {data.is_dir ? ( + + ) : <>>} + + + {data.name} + + + {data.is_dir ? ( + "-" + ): convert(data.file_size)} + + + {data.date === -1 ? "-" : new Date(data.date * 1000).toLocaleString("en-US", { + weekday: "short", + year: "numeric", + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + hour12: false + }).replace(/,/g, "")} + + ); } diff --git a/src/components/file-view/fview.scss b/src/components/file-view/fview.scss index e69de29..5fb0087 100644 --- a/src/components/file-view/fview.scss +++ b/src/components/file-view/fview.scss @@ -0,0 +1,51 @@ +.ka-fileview { + width: 100%; + display: flex; + margin: 1rem 0; + min-height: 300px; + border-radius: 15px; + flex-direction: column; + background-color: var(--nav-color); + border: 1px solid var(--foreground); + + .title { + width: 100%; + display: flex; + padding: 5px 10px; + align-items: center; + flex-direction: row; + border-radius: 15px 15px 0 0; + justify-content: space-between; + background-color: var(--nav-hover); + border-bottom: 1px solid var(--foreground); + + .name { + width: fit-content; + display: flex; + + span { + margin: 0 5px; + } + } + + .action-row { + display: flex; + width: fit-content; + align-items: center; + justify-content: center; + + .btn { + padding: 0; + margin-left: 5px; + } + } + } + + pre, code { + margin: 0; + width: 100%; + padding: 10px; + display: flex; + border-radius: 0 0 15px 15px; + } +} \ No newline at end of file diff --git a/src/components/file-view/index.tsx b/src/components/file-view/index.tsx index 8b77489..33b417d 100644 --- a/src/components/file-view/index.tsx +++ b/src/components/file-view/index.tsx @@ -1,6 +1,63 @@ +import { useEffect, useState } from "react"; +import { useRaw } from "../../store/raw"; +import "./fview.scss"; +import { useLocation } from "react-router"; +import { DynamicIcon } from "lucide-react/dynamic"; + function FileView() { + const raw = useRaw(); + const location = useLocation(); + const [load, setLoad] = useState(false); + + useEffect(() => { + if (!load) { + raw.update(location.pathname.substring(1, location.pathname.length)); + setLoad(true); + return; + } + + const id = setInterval(() => { + raw.update(location.pathname.substring(1, location.pathname.length)); + }, 5000); + + return () => clearInterval(id); + }, [raw, location, load]); + return ( - <>> + + + + + + + {location.pathname} + + + { + ev.preventDefault(); + fetch(`/api/download${location.pathname}`) + .then(response => response.blob()) + .then(blob => { + const url = window.URL.createObjectURL(blob); + const a = document.createElement("a"); + + a.style.display = "none"; + a.href = url; + a.download = location.pathname.split("/").pop() || "download"; + + document.body.appendChild(a); + a.click(); + + window.URL.revokeObjectURL(url); + }) + .catch(error => console.error("Download failed:", error)); + }}> + + + + + {raw.data} + ); } diff --git a/src/index.scss b/src/index.scss index d75c8b9..8edbb98 100644 --- a/src/index.scss +++ b/src/index.scss @@ -1,4 +1,5 @@ @import url("https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/variable/pretendardvariable-dynamic-subset.min.css"); +@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:ital,wght@0,100..800;1,100..800&display=swap'); :root { --background: #242424; @@ -31,6 +32,7 @@ --btn-danger-focus: #a72532; --font-family: "Pretendard Variable", Pretendard, -apple-system, BlinkMacSystemFont, system-ui, Roboto, "Helvetica Neue", "Segoe UI", "Apple SD Gothic Neo", "Noto Sans KR", "Malgun Gothic", "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", sans-serif; + --font-mono: "JetBrains Mono", monospace; } html, body { @@ -184,3 +186,7 @@ input, button { border-radius: 25px; padding: 0.25rem 15px; } + +code, pre { + font-family: var(--font-mono); +} diff --git a/src/store/path.ts b/src/store/path.ts index 466bdf1..201516c 100644 --- a/src/store/path.ts +++ b/src/store/path.ts @@ -1,19 +1,24 @@ import { create } from "zustand"; interface PathState { - data: PathResponse | string | undefined; + data: PathResponse | undefined; update(path: string): Promise; } interface PathResponse { ok: number; path: string; + total: number; + is_dir: boolean; entries: Array } export interface DirEntry { name: string; + path: string; + date: number; file_size: number; + is_dir: boolean; } export const usePath = create((set) => ({ @@ -25,10 +30,6 @@ export const usePath = create((set) => ({ return; } - try { - set({ data: await res.json() }); - } catch { - set({ data: `/api/path/${path}` }); - } + set({ data: await res.json() }); } })); diff --git a/src/store/raw.ts b/src/store/raw.ts new file mode 100644 index 0000000..1260fb6 --- /dev/null +++ b/src/store/raw.ts @@ -0,0 +1,22 @@ +import { create } from "zustand"; + +interface RawState { + data: string | undefined; + update(path: string): Promise; +} + +export const useRaw = create((set) => ({ + data: undefined, + update: async (path: string) => { + const res = await fetch(`/api/raw/${path}`, { + cache: "no-cache" + }); + if (res.status !== 200 && res.status !== 304) { + set({ data: undefined }); + return; + } + + const text = await res.text(); + set({ data: text }); + } +})); diff --git a/src/util/unit.ts b/src/util/unit.ts new file mode 100644 index 0000000..a9abc11 --- /dev/null +++ b/src/util/unit.ts @@ -0,0 +1,18 @@ +export function convert(bytes: number): string { + if (bytes >= 1125899906842624) + return (bytes / 1125899906842624).toFixed(2) + " PiB"; + + if (bytes >= 1099511627776) + return (bytes / 1099511627776).toFixed(2) + " TiB"; + + if (bytes >= 1073741824) + return (bytes / 1073741824).toFixed(2) + " GiB"; + + if (bytes >= 1048576) + return (bytes / 1048576).toFixed(2) + " MiB"; + + if (bytes >= 1024) + return (bytes / 1024).toFixed(2) + " KiB"; + + return bytes + " Byte"; +}
{raw.data}