bug fixes, half-finished admin ui, and a more

This commit is contained in:
Zoe
2024-09-23 01:21:28 -05:00
parent 6e6bc1c45b
commit 66f8437351
35 changed files with 1039 additions and 141 deletions

View File

@@ -20,5 +20,5 @@ DB_HOST=localhost:5432 DB_NAME=filething DB_USER=postgres STORAGE_PATH=data ./fi
To run filething in dev mode with a hot reloading Ui server and auto rebuilding backend server, run To run filething in dev mode with a hot reloading Ui server and auto rebuilding backend server, run
```BASH ```BASH
DB_HOST=localhost:5432 DB_NAME=filething DB_USER=postgres STORAGE_PATH=data CompileDaemon --build="go build -tags netgo,dev -ldflags=-s" --command=./filething --exclude-dir=data/ --exclude-dir=ui. --graceful-kill DB_HOST=localhost:5432 DB_NAME=filething DB_USER=postgres STORAGE_PATH=data CompileDaemon --build="go build -tags netgo,dev -ldflags=-s" --command=./filething --exclude-dir=data/ --exclude-dir=ui/ --graceful-kill
``` ```

1
go.mod
View File

@@ -5,6 +5,7 @@ go 1.23.0
require github.com/google/uuid v1.6.0 require github.com/google/uuid v1.6.0
require ( require (
github.com/dustin/go-humanize v1.0.1
github.com/golang-jwt/jwt v3.2.2+incompatible // indirect github.com/golang-jwt/jwt v3.2.2+incompatible // indirect
github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/inflection v1.0.0 // indirect
github.com/labstack/echo/v4 v4.12.0 github.com/labstack/echo/v4 v4.12.0

2
go.sum
View File

@@ -1,5 +1,7 @@
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY= github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY=
github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=

52
main.go
View File

@@ -8,11 +8,10 @@ import (
"filething/middleware" "filething/middleware"
"filething/models" "filething/models"
"filething/routes" "filething/routes"
"filething/ui"
"fmt" "fmt"
"net/http" "net/http"
"os" "os"
"strings" "time"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
echoMiddleware "github.com/labstack/echo/v4/middleware" echoMiddleware "github.com/labstack/echo/v4/middleware"
@@ -84,61 +83,32 @@ func main() {
api.GET("/files/get/*", routes.GetFiles) api.GET("/files/get/*", routes.GetFiles)
api.GET("/files/download*", routes.GetFile) api.GET("/files/download*", routes.GetFile)
api.POST("/files/delete*", routes.DeleteFiles) api.POST("/files/delete*", routes.DeleteFiles)
admin := api.Group("/admin")
{
admin.Use(middleware.AdminMiddleware())
admin.GET("/system-status", routes.SystemStatus)
admin.GET("/get-users/:page", routes.GetUsers)
admin.GET("/get-total-users", routes.GetUsersCount)
}
} }
// redirects to the proper pages if you are trying to access one that expects you have/dont have an api key // redirects to the proper pages if you are trying to access one that expects you have/dont have an api key
// this isnt explicitly required, but it provides a better experience than doing this same thing clientside // this isnt explicitly required, but it provides a better experience than doing this same thing clientside
e.Use(middleware.AuthCheckMiddleware) e.Use(middleware.AuthCheckMiddleware)
// calls out to a function set by either server.go server_dev.go based on the value of the dev tag, and hosts // calls out to a function set by either server.go server_dev.go based on the presence of the dev tag, and hosts
// either the static files that get embedded into the binary in ui/embed.go or proxies the dev server that gets // either the static files that get embedded into the binary in ui/embed.go or proxies the dev server that gets
// run in the provided function // run in the provided function
initUi(e) initUi(e)
e.HTTPErrorHandler = customHTTPErrorHandler routes.AppStartTime = time.Now().UTC()
if err := e.Start(":1323"); err != nil && err != http.ErrServerClosed { if err := e.Start(":1323"); err != nil && err != http.ErrServerClosed {
fmt.Println("Error starting HTTP server:", err) fmt.Println("Error starting HTTP server:", err)
} }
} }
// Custom Error handling since Nuxt relies on the 404 page for dynamic pages we still want api routes to use the default
// error handling built into echo
func customHTTPErrorHandler(err error, c echo.Context) {
if he, ok := err.(*echo.HTTPError); ok && he.Code == http.StatusNotFound {
path := c.Request().URL.Path
if !strings.HasPrefix(path, "/api") {
file, err := ui.DistDirFS.Open("404.html")
if err != nil {
c.Logger().Error(err)
}
fileInfo, err := file.Stat()
if err != nil {
c.Logger().Error(err)
}
fileBuf := make([]byte, fileInfo.Size())
_, err = file.Read(fileBuf)
defer func() {
if err := file.Close(); err != nil {
panic(err)
}
}()
if err != nil {
c.Logger().Error(err)
panic(err)
}
c.HTML(http.StatusNotFound, string(fileBuf))
return
}
}
c.Echo().DefaultHTTPErrorHandler(err, c)
}
// creates tables in the db if they dont already exist // creates tables in the db if they dont already exist
func createSchema(db *bun.DB) error { func createSchema(db *bun.DB) error {
models := []interface{}{ models := []interface{}{

22
middleware/admin.go Normal file
View File

@@ -0,0 +1,22 @@
package middleware
import (
"filething/models"
"net/http"
"github.com/labstack/echo/v4"
)
func AdminMiddleware() echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
user := c.Get("user").(*models.User)
if !user.Admin {
return echo.NewHTTPError(http.StatusForbidden, "You are not an administrator")
}
return next(c)
}
}
}

View File

@@ -41,6 +41,10 @@ func AuthCheckMiddleware(next echo.HandlerFunc) echo.HandlerFunc {
return c.Redirect(http.StatusFound, "/login") return c.Redirect(http.StatusFound, "/login")
} }
if strings.Contains(path, "/admin") && !authenticated {
return c.Redirect(http.StatusFound, "/login")
}
return next(c) return next(c)
} }
} }

View File

@@ -1,6 +1,8 @@
package models package models
import ( import (
"time"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/uptrace/bun" "github.com/uptrace/bun"
) )
@@ -18,13 +20,15 @@ type SignupData struct {
type User struct { type User struct {
bun.BaseModel `bun:"table:users"` bun.BaseModel `bun:"table:users"`
ID uuid.UUID `bun:"id,pk,type:uuid,default:uuid_generate_v4()" json:"id"` ID uuid.UUID `bun:",pk,type:uuid,default:uuid_generate_v4()" json:"id"`
Username string `bun:"username,notnull,unique" json:"username"` Username string `bun:",notnull,unique" json:"username"`
Email string `bun:"email,notnull,unique" json:"email"` Email string `bun:",notnull,unique" json:"email"`
PasswordHash string `bun:"passwordHash,notnull" json:"-"` PasswordHash string `bun:",notnull" json:"-"`
PlanID int64 `bun:"plan_id,notnull" json:"-"` PlanID int64 `bun:"plan_id,notnull" json:"-"`
Plan Plan `bun:"rel:belongs-to,join:plan_id=id" json:"plan"` Plan Plan `bun:"rel:belongs-to,join:plan_id=id" json:"plan"`
Usage int64 `bun:"-" json:"usage"` Usage int64 `bun:"-" json:"usage"`
CreatedAt time.Time `bun:",nullzero,notnull,default:current_timestamp" json:"created_at"`
Admin bool `bin:"admin,type:bool" json:"is_admin"`
} }
type Session struct { type Session struct {

172
routes/admin.go Normal file
View File

@@ -0,0 +1,172 @@
package routes
import (
"context"
"filething/models"
"fmt"
"net/http"
"runtime"
"strconv"
"time"
"github.com/dustin/go-humanize"
"github.com/labstack/echo/v4"
"github.com/uptrace/bun"
)
func GetUsers(c echo.Context) error {
db := c.Get("db").(*bun.DB)
pageStr := c.Param("page")
page, err := strconv.Atoi(pageStr)
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Invalid page number")
}
offset := page * 30
limit := 30
var users []models.User
err = db.NewSelect().
Model(&users).
Limit(limit).
Offset(offset).
Scan(context.Background())
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to retrieve users")
}
return c.JSON(http.StatusOK, users)
}
func GetUsersCount(c echo.Context) error {
db := c.Get("db").(*bun.DB)
count, err := db.NewSelect().Model(&models.User{}).Count(context.Background())
if err != nil {
fmt.Println(err)
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to retrieve users")
}
return c.JSON(http.StatusOK, map[string]int{"total_users": count})
}
// Stolen from Gitea https://github.com/go-gitea/gitea
func SystemStatus(c echo.Context) error {
updateSystemStatus()
return c.JSON(http.StatusOK, map[string]interface{}{
"uptime": sysStatus.StartTime,
"num_goroutine": sysStatus.NumGoroutine,
"cur_mem_usage": sysStatus.MemAllocated,
"total_mem_usage": sysStatus.MemTotal,
"mem_obtained": sysStatus.MemSys,
"ptr_lookup_times": sysStatus.Lookups,
"mem_allocations": sysStatus.MemMallocs,
"mem_frees": sysStatus.MemFrees,
"cur_heap_usage": sysStatus.HeapAlloc,
"heap_mem_obtained": sysStatus.HeapSys,
"heap_mem_idle": sysStatus.HeapIdle,
"heap_mem_inuse": sysStatus.HeapInuse,
"heap_mem_release": sysStatus.HeapReleased,
"heap_objects": sysStatus.HeapObjects,
"bootstrap_stack_usage": sysStatus.StackInuse,
"stack_mem_obtained": sysStatus.StackSys,
"mspan_structures_usage": sysStatus.MSpanInuse,
"mspan_structures_obtained": sysStatus.MSpanSys,
"mcache_structures_usage": sysStatus.MSpanInuse,
"mcache_structures_obtained": sysStatus.MCacheSys,
"buck_hash_sys": sysStatus.BuckHashSys,
"gc_sys": sysStatus.GCSys,
"other_sys": sysStatus.OtherSys,
"next_gc": sysStatus.NextGC,
"last_gc_time": sysStatus.LastGCTime,
"pause_total_ns": sysStatus.PauseTotalNs,
"pause_ns": sysStatus.PauseNs,
"num_gc": sysStatus.NumGC,
})
}
var AppStartTime time.Time
var sysStatus struct {
StartTime string
NumGoroutine int
// General statistics.
MemAllocated string // bytes allocated and still in use
MemTotal string // bytes allocated (even if freed)
MemSys string // bytes obtained from system (sum of XxxSys below)
Lookups uint64 // number of pointer lookups
MemMallocs uint64 // number of mallocs
MemFrees uint64 // number of frees
// Main allocation heap statistics.
HeapAlloc string // bytes allocated and still in use
HeapSys string // bytes obtained from system
HeapIdle string // bytes in idle spans
HeapInuse string // bytes in non-idle span
HeapReleased string // bytes released to the OS
HeapObjects uint64 // total number of allocated objects
// Low-level fixed-size structure allocator statistics.
// Inuse is bytes used now.
// Sys is bytes obtained from system.
StackInuse string // bootstrap stacks
StackSys string
MSpanInuse string // mspan structures
MSpanSys string
MCacheInuse string // mcache structures
MCacheSys string
BuckHashSys string // profiling bucket hash table
GCSys string // GC metadata
OtherSys string // other system allocations
// Garbage collector statistics.
NextGC string // next run in HeapAlloc time (bytes)
LastGCTime string // last run time
PauseTotalNs string
PauseNs string // circular buffer of recent GC pause times, most recent at [(NumGC+255)%256]
NumGC uint32
}
func updateSystemStatus() {
sysStatus.StartTime = AppStartTime.Format(time.RFC3339)
m := new(runtime.MemStats)
runtime.ReadMemStats(m)
sysStatus.NumGoroutine = runtime.NumGoroutine()
sysStatus.MemAllocated = FileSize(int64(m.Alloc))
sysStatus.MemTotal = FileSize(int64(m.TotalAlloc))
sysStatus.MemSys = FileSize(int64(m.Sys))
sysStatus.Lookups = m.Lookups
sysStatus.MemMallocs = m.Mallocs
sysStatus.MemFrees = m.Frees
sysStatus.HeapAlloc = FileSize(int64(m.HeapAlloc))
sysStatus.HeapSys = FileSize(int64(m.HeapSys))
sysStatus.HeapIdle = FileSize(int64(m.HeapIdle))
sysStatus.HeapInuse = FileSize(int64(m.HeapInuse))
sysStatus.HeapReleased = FileSize(int64(m.HeapReleased))
sysStatus.HeapObjects = m.HeapObjects
sysStatus.StackInuse = FileSize(int64(m.StackInuse))
sysStatus.StackSys = FileSize(int64(m.StackSys))
sysStatus.MSpanInuse = FileSize(int64(m.MSpanInuse))
sysStatus.MSpanSys = FileSize(int64(m.MSpanSys))
sysStatus.MCacheInuse = FileSize(int64(m.MCacheInuse))
sysStatus.MCacheSys = FileSize(int64(m.MCacheSys))
sysStatus.BuckHashSys = FileSize(int64(m.BuckHashSys))
sysStatus.GCSys = FileSize(int64(m.GCSys))
sysStatus.OtherSys = FileSize(int64(m.OtherSys))
sysStatus.NextGC = FileSize(int64(m.NextGC))
sysStatus.LastGCTime = time.Unix(0, int64(m.LastGC)).Format(time.RFC3339)
sysStatus.PauseTotalNs = fmt.Sprintf("%.1fs", float64(m.PauseTotalNs)/1000/1000/1000)
sysStatus.PauseNs = fmt.Sprintf("%.3fs", float64(m.PauseNs[(m.NumGC+255)%256])/1000/1000/1000)
sysStatus.NumGC = m.NumGC
}
func FileSize(s int64) string {
return humanize.IBytes(uint64(s))
}

View File

@@ -64,6 +64,8 @@ func LoginHandler(c echo.Context) error {
return c.JSON(http.StatusOK, user) return c.JSON(http.StatusOK, user)
} }
var firstUserCreated *bool
func SignupHandler(c echo.Context) error { func SignupHandler(c echo.Context) error {
var signupData models.SignupData var signupData models.SignupData
@@ -87,11 +89,22 @@ func SignupHandler(c echo.Context) error {
return c.JSON(http.StatusInternalServerError, map[string]string{"message": "An unknown error occoured!"}) return c.JSON(http.StatusInternalServerError, map[string]string{"message": "An unknown error occoured!"})
} }
if firstUserCreated == nil {
count, err := db.NewSelect().Model((*models.User)(nil)).Count(context.Background())
if err != nil {
return fmt.Errorf("failed to count plans: %w", err)
}
firstUserCreated = new(bool)
*firstUserCreated = count != 0
}
user := &models.User{ user := &models.User{
Username: signupData.Username, Username: signupData.Username,
Email: signupData.Email, Email: signupData.Email,
PasswordHash: string(hash), PasswordHash: string(hash),
PlanID: 1, // basic 10GB plan PlanID: 1, // basic 10GB plan
Admin: !*firstUserCreated,
} }
_, err = db.NewInsert().Model(user).Exec(context.Background()) _, err = db.NewInsert().Model(user).Exec(context.Background())
@@ -99,6 +112,10 @@ func SignupHandler(c echo.Context) error {
return c.JSON(http.StatusConflict, map[string]string{"message": "A user with that email or username already exists!"}) return c.JSON(http.StatusConflict, map[string]string{"message": "A user with that email or username already exists!"})
} }
if !*firstUserCreated {
*firstUserCreated = true
}
err = db.NewSelect().Model(user).WherePK().Relation("Plan").Scan(context.Background()) err = db.NewSelect().Model(user).WherePK().Relation("Plan").Scan(context.Background())
if err != nil { if err != nil {
return c.JSON(http.StatusNotFound, map[string]string{"message": "An unknown error occoured!"}) return c.JSON(http.StatusNotFound, map[string]string{"message": "An unknown error occoured!"})

View File

@@ -5,6 +5,8 @@ package main
import ( import (
"filething/ui" "filething/ui"
"net/http"
"strings"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
) )
@@ -12,5 +14,44 @@ import (
func init() { func init() {
initUi = func(e *echo.Echo) { initUi = func(e *echo.Echo) {
e.GET("/*", echo.StaticDirectoryHandler(ui.DistDirFS, false)) e.GET("/*", echo.StaticDirectoryHandler(ui.DistDirFS, false))
e.HTTPErrorHandler = customHTTPErrorHandler
} }
} }
// Custom Error handling since Nuxt relies on the 404 page for dynamic pages we still want api routes to use the default
// error handling built into echo
func customHTTPErrorHandler(err error, c echo.Context) {
if he, ok := err.(*echo.HTTPError); ok && he.Code == http.StatusNotFound {
path := c.Request().URL.Path
if !strings.HasPrefix(path, "/api") {
file, err := ui.DistDirFS.Open("404.html")
if err != nil {
c.Logger().Error(err)
}
fileInfo, err := file.Stat()
if err != nil {
c.Logger().Error(err)
}
fileBuf := make([]byte, fileInfo.Size())
_, err = file.Read(fileBuf)
defer func() {
if err := file.Close(); err != nil {
panic(err)
}
}()
if err != nil {
c.Logger().Error(err)
panic(err)
}
c.HTML(http.StatusNotFound, string(fileBuf))
return
}
}
c.Echo().DefaultHTTPErrorHandler(err, c)
}

View File

@@ -1,5 +1,7 @@
<template> <template>
<div> <div>
<NuxtPage /> <NuxtLayout>
<NuxtPage />
</NuxtLayout>
</div> </div>
</template> </template>

View File

@@ -48,8 +48,10 @@
} }
html, body { html, body {
overflow: hidden !important; min-height: 100vh;
overflow-x: hidden !important;
background-color: rgb(var(--color-base)); background-color: rgb(var(--color-base));
overflow-wrap: break-word;
color: rgb(var(--color-text)); color: rgb(var(--color-text));
} }

Binary file not shown.

View File

@@ -25,7 +25,7 @@ const crumbs = computed(() => {
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" <path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="m9 6l6 6l-6 6" /> d="m9 6l6 6l-6 6" />
</svg> </svg>
<NuxtLink class="focus:outline-none focus:ring focus:ring-inset" <NuxtLink class="focus-visible:outline-none focus-visible:ring focus-visible:ring-inset"
:class="index === crumbs.length - 1 ? 'text-foam' : 'text-subtle hover:text-text focus:text-text'" :class="index === crumbs.length - 1 ? 'text-foam' : 'text-subtle hover:text-text focus:text-text'"
:to="crumb.link">{{ :to="crumb.link">{{
crumb.name }}</NuxtLink> crumb.name }}</NuxtLink>

View File

@@ -1,6 +1,6 @@
<template> <template>
<div v-on:click="toggle()" v-on:keypress.enter="toggle()" v-on:keypress.space="toggle()" tabindex="0" <div v-on:click="toggle()" v-on:keypress.enter="toggle()" v-on:keypress.space="toggle()" tabindex="0"
class="w-5 h-5 border rounded cursor-pointer flex items-center justify-center focus:outline-none focus:ring focus:ring-inset" class="w-5 h-5 border rounded cursor-pointer flex items-center justify-center focus-visible:outline-none focus-visible:ring focus-visible:ring-inset"
:class="state === 'unchecked' ? 'hover:bg-muted/5 active:bg-muted/15' : 'bg-accent/10 hover:bg-accent/15 active:bg-accent/25 text-accent'"> :class="state === 'unchecked' ? 'hover:bg-muted/5 active:bg-muted/15' : 'bg-accent/10 hover:bg-accent/15 active:bg-accent/25 text-accent'">
<div v-if="state === 'some'" class="w-8/12 h-0.5 bg-current rounded-full"></div> <div v-if="state === 'some'" class="w-8/12 h-0.5 bg-current rounded-full"></div>
<span v-else-if="state === 'checked'"> <span v-else-if="state === 'checked'">

View File

@@ -3,7 +3,10 @@ import { useUser } from '~/composables/useUser'
const { getUser } = useUser() const { getUser } = useUser()
const props = defineProps({ const props = defineProps({
usageBytes: Number, usageBytes: {
type: Number,
required: true,
}
}) })
const user = await getUser() const user = await getUser()
@@ -47,7 +50,7 @@ const isInFolder = computed(() => route.path.startsWith('/home/') && route.path
<ul class="flex flex-col gap-y-2"> <ul class="flex flex-col gap-y-2">
<li> <li>
<NuxtLink to="/home" <NuxtLink to="/home"
class="flex py-1.5 px-4 rounded-lg transition-bg duration-300 hover:bg-muted/10 focus:outline-none focus:ring focus:ring-inset" class="flex py-1.5 px-4 rounded-lg transition-bg duration-300 hover:bg-muted/10 focus-visible:outline-none focus-visible:ring focus-visible:ring-inset"
:class="{ 'bg-muted/10': isAllFilesActive }"> :class="{ 'bg-muted/10': isAllFilesActive }">
<div class="flex relative"> <div class="flex relative">
<svg class="m-0.5 mr-2" xmlns="http://www.w3.org/2000/svg" width="20" height="20" <svg class="m-0.5 mr-2" xmlns="http://www.w3.org/2000/svg" width="20" height="20"

View File

@@ -1,24 +1,40 @@
<script setup lang="ts"> <script setup lang="ts">
let user = await useUser().getUser()
defineEmits(["update:filenav"]) defineEmits(["update:filenav"])
defineProps(["filenav"]) defineProps(["filenav", "user"])
let colorMode = useColorMode();
const changeTheme = () => {
if (colorMode.preference === "dark") {
// from dark => light
colorMode.preference = "light"
} else if (colorMode.preference === "light") {
// from light => system
colorMode.preference = "system";
} else {
// from system => dark
colorMode.preference = "dark";
}
return;
}
</script> </script>
<template> <template>
<header class="flex h-[var(--nav-height)] px-4 justify-center sticky top-0 z-10 border-b bg-base"> <header class="flex h-[var(--nav-height)] px-4 justify-center sticky top-0 z-10 border-b bg-base">
<div class="flex w-full items-center justify-between space-x-2.5"> <div class="flex w-full items-center justify-between space-x-2.5">
<p <NuxtLink
class="-ml-2.5 flex shrink-0 items-center px-2.5 py-1.5 focus:outline-none focus:ring rounded-m font-semiboldd"> class="-ml-2.5 flex shrink-0 items-center px-2.5 py-1.5 transition-bg duration-300 hover:bg-muted/10 rounded-md focus-visible:outline-none focus-visible:ring focus-visible:ring-inset font-semibold"
:to="user === undefined ? '/' : '/home'">
filething filething
</p> </NuxtLink>
</div> </div>
<nav class="flex md:hidden"> <nav class="flex md:hidden">
<ul class="flex items-center gap-3" role="list"> <ul class="flex items-center gap-3" role="list">
<li> <li v-if="user">
<span class="group relative flex items-center"> <span class="group relative flex items-center">
<button <button
class="flex items-center px-3 h-8 text-[15px] font-semibold transition-bg duration-300 hover:bg-muted/10 focus:outline-none focus:ring focus:ring-inset rounded-md"> class="flex items-center px-3 h-8 text-[15px] font-semibold transition-bg duration-300 hover:bg-muted/10 rounded-md focus-visible:outline-none focus-visible:ring focus-visible:ring-inset">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24"> <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24">
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" <path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"
stroke-width="2" stroke-width="2"
@@ -31,13 +47,36 @@ defineProps(["filenav"])
</svg> </svg>
</span> </span>
</button> </button>
<NavUserDropdown :user="user" /> <NavUserDropdown :changeTheme="changeTheme" :user="user" />
</span> </span>
</li> </li>
<li class="h-6 border-r"></li> <li v-else>
<li> <button
class="flex items-center px-3 h-8 text-[15px] font-semibold transition-bg duration-300 hover:bg-muted/10 rounded-md focus-visible:outline-none focus-visible:ring focus-visible:ring-inset"
v-on:click="changeTheme">
<span class="inline-block">
<svg v-if="$colorMode.preference === 'dark'" xmlns="http://www.w3.org/2000/svg" width="22"
height="22" viewBox="0 0 24 24">
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"
stroke-width="2" d="M12 3h.393a7.5 7.5 0 0 0 7.92 12.446A9 9 0 1 1 12 2.992z" />
</svg>
<svg v-else-if="$colorMode.preference === 'light'" xmlns="http://www.w3.org/2000/svg"
width="22" height="22" viewBox="0 0 24 24">
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"
stroke-width="2"
d="M14.828 14.828a4 4 0 1 0-5.656-5.656a4 4 0 0 0 5.656 5.656m-8.485 2.829l-1.414 1.414M6.343 6.343L4.929 4.929m12.728 1.414l1.414-1.414m-1.414 12.728l1.414 1.414M4 12H2m10-8V2m8 10h2m-10 8v2" />
</svg>
<svg v-else xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 256 256">
<path fill="currentColor"
d="M208 36H48a28 28 0 0 0-28 28v112a28 28 0 0 0 28 28h160a28 28 0 0 0 28-28V64a28 28 0 0 0-28-28Zm4 140a4 4 0 0 1-4 4H48a4 4 0 0 1-4-4V64a4 4 0 0 1 4-4h160a4 4 0 0 1 4 4Zm-40 52a12 12 0 0 1-12 12H96a12 12 0 0 1 0-24h64a12 12 0 0 1 12 12Z" />
</svg>
</span>
</button>
</li>
<li v-if="filenav" class="h-6 border-r"></li>
<li v-if="filenav">
<button v-on:click="$emit('update:filenav', !filenav)" <button v-on:click="$emit('update:filenav', !filenav)"
class="flex items-center px-3 h-8 text-[15px] font-semibold transition-bg duration-300 hover:bg-muted/10 focus:outline-none focus:ring focus:ring-inset rounded-md"> class="flex items-center px-3 h-8 text-[15px] font-semibold transition-bg duration-300 hover:bg-muted/10 rounded-md focus-visible:outline-none focus-visible:ring focus-visible:ring-inset">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24"> <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24">
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" <path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"
stroke-width="2" d="M4 6h16M7 12h13m-10 6h10" /> stroke-width="2" d="M4 6h16M7 12h13m-10 6h10" />
@@ -50,21 +89,21 @@ defineProps(["filenav"])
<ul class="flex items-center gap-3" role="list"> <ul class="flex items-center gap-3" role="list">
<li> <li>
<a href="#" <a href="#"
class="px-2.5 py-1.5 text-[15px] font-semibold transition-bg duration-300 hover:bg-muted/10 focus:outline-none focus:ring focus:ring-inset rounded-md">Link</a> class="px-2.5 py-1.5 text-[15px] font-semibold transition-bg duration-300 hover:bg-muted/10 rounded-md focus-visible:outline-none focus-visible:ring focus-visible:ring-inset">Link</a>
</li> </li>
<li> <li>
<a href="#" <a href="#"
class="px-2.5 py-1.5 text-[15px] font-semibold transition-bg duration-300 hover:bg-muted/10 focus:outline-none focus:ring focus:ring-inset rounded-md">Link</a> class="px-2.5 py-1.5 text-[15px] font-semibold transition-bg duration-300 hover:bg-muted/10 rounded-md focus-visible:outline-none focus-visible:ring focus-visible:ring-inset">Link</a>
</li> </li>
<li> <li>
<a href="#" <a href="#"
class="px-2.5 py-1.5 text-[15px] font-semibold transition-bg duration-300 hover:bg-muted/10 focus:outline-none focus:ring focus:ring-inset rounded-md">Link</a> class="px-2.5 py-1.5 text-[15px] font-semibold transition-bg duration-300 hover:bg-muted/10 rounded-md focus-visible:outline-none focus-visible:ring focus-visible:ring-inset">Link</a>
</li> </li>
<li class="h-6 border-r"></li> <li class="h-6 border-r"></li>
<li> <li v-if="user">
<span class="group relative flex items-center"> <span class="group relative flex items-center">
<button <button
class="flex items-center px-3 h-8 text-[15px] font-semibold transition-bg duration-300 hover:bg-muted/10 focus:outline-none focus:ring focus:ring-inset rounded-md"> class="flex items-center px-3 h-8 text-[15px] font-semibold transition-bg duration-300 hover:bg-muted/10 rounded-md focus-visible:outline-none focus-visible:ring focus-visible:ring-inset">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24"> <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24">
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" <path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"
stroke-width="2" stroke-width="2"
@@ -77,9 +116,32 @@ defineProps(["filenav"])
</svg> </svg>
</span> </span>
</button> </button>
<NavUserDropdown :user="user" /> <NavUserDropdown :changeTheme="changeTheme" :user="user" />
</span> </span>
</li> </li>
<li v-else>
<button
class="flex items-center px-3 h-8 text-[15px] font-semibold transition-bg duration-300 hover:bg-muted/10 rounded-md"
v-on:click="changeTheme">
<span class="inline-block">
<svg v-if="$colorMode.preference === 'dark'" xmlns="http://www.w3.org/2000/svg" width="22"
height="22" viewBox="0 0 24 24">
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"
stroke-width="2" d="M12 3h.393a7.5 7.5 0 0 0 7.92 12.446A9 9 0 1 1 12 2.992z" />
</svg>
<svg v-else-if="$colorMode.preference === 'light'" xmlns="http://www.w3.org/2000/svg"
width="22" height="22" viewBox="0 0 24 24">
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"
stroke-width="2"
d="M14.828 14.828a4 4 0 1 0-5.656-5.656a4 4 0 0 0 5.656 5.656m-8.485 2.829l-1.414 1.414M6.343 6.343L4.929 4.929m12.728 1.414l1.414-1.414m-1.414 12.728l1.414 1.414M4 12H2m10-8V2m8 10h2m-10 8v2" />
</svg>
<svg v-else xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 256 256">
<path fill="currentColor"
d="M208 36H48a28 28 0 0 0-28 28v112a28 28 0 0 0 28 28h160a28 28 0 0 0 28-28V64a28 28 0 0 0-28-28Zm4 140a4 4 0 0 1-4 4H48a4 4 0 0 1-4-4V64a4 4 0 0 1 4-4h160a4 4 0 0 1 4 4Zm-40 52a12 12 0 0 1-12 12H96a12 12 0 0 1 0-24h64a12 12 0 0 1 12 12Z" />
</svg>
</span>
</button>
</li>
</ul> </ul>
</nav> </nav>
</header> </header>

View File

@@ -1,21 +1,4 @@
<script setup lang="ts"> <script setup lang="ts">
let colorMode = useColorMode();
const changeTheme = () => {
if (colorMode.preference === "dark") {
// from dark => light
colorMode.preference = "light"
} else if (colorMode.preference === "light") {
// from light => system
colorMode.preference = "system";
} else {
// from system => dark
colorMode.preference = "dark";
}
return;
}
const logout = async () => { const logout = async () => {
await $fetch('/api/logout', { await $fetch('/api/logout', {
method: "POST" method: "POST"
@@ -31,6 +14,10 @@ defineProps({
type: Object, type: Object,
required: true required: true
}, },
changeTheme: {
type: Function,
required: true,
}
}) })
</script> </script>
@@ -38,18 +25,19 @@ defineProps({
<div <div
class="invisible z-10 w-fit h-fit absolute -right-[4px] top-full opacity-0 group-hover:visible group-focus-within:visible group-focus-within:scale-100 group-focus-within:opacity-100 group-hover:scale-100 group-hover:opacity-100 transition"> class="invisible z-10 w-fit h-fit absolute -right-[4px] top-full opacity-0 group-hover:visible group-focus-within:visible group-focus-within:scale-100 group-focus-within:opacity-100 group-hover:scale-100 group-hover:opacity-100 transition">
<div class="mt-1 w-64 origin-top-right scale-[.97] rounded-xl bg-surface shadow-lg"> <div class="mt-1 w-64 origin-top-right scale-[.97] rounded-xl bg-surface shadow-lg">
<div class="border-b max-w-64 overflow-hidden text-ellipsis p-2"> <div class="max-w-64 overflow-hidden text-ellipsis p-2">
<p class="text-lg font-semibold">{{ user.username }}</p> <p class="text-lg font-semibold">{{ user.username }}</p>
<p class="text-subtle text-xs">{{ user.email }}</p> <p class="text-subtle text-xs">{{ user.email }}</p>
<p class="text-subtle text-xs"> <p class="text-subtle text-xs">
you have {{ formatBytes(user.plan.max_storage) }} of storage you have {{ formatBytes(user.plan.max_storage) }} of storage
</p> </p>
</div> </div>
<ul class="p-2 flex flex-col gap-x-1"> <hr />
<li class="select-none"> <ul class="py-2 flex flex-col gap-x-1">
<li class="select-none mx-2">
<button v-on:click="changeTheme" <button v-on:click="changeTheme"
class="flex items-center hover:bg-muted/10 active:bg-muted/20 transition-bg w-full px-2 py-1 rounded-md focus:outline-none focus:ring focus:ring-inset"> class="flex items-center hover:bg-muted/10 active:bg-muted/20 transition-bg w-full px-2 py-1 rounded-md focus-visible:outline-none focus-visible:ring focus-visible:ring-inset">
<span class="mr-1.5"> <span class="mr-2">
<svg v-if="$colorMode.preference === 'dark'" xmlns="http://www.w3.org/2000/svg" width="18" <svg v-if="$colorMode.preference === 'dark'" xmlns="http://www.w3.org/2000/svg" width="18"
height="18" viewBox="0 0 24 24"> height="18" viewBox="0 0 24 24">
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" <path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"
@@ -69,10 +57,36 @@ defineProps({
Change Theme Change Theme
</button> </button>
</li> </li>
<li class="select-none"> <hr class="my-2" />
<li v-if="user.is_admin" class="select-none mx-2">
<NuxtLink
class="flex items-center hover:bg-muted/10 active:bg-muted/20 transition-bg w-full px-2 py-1 rounded-md focus-visible:outline-none focus-visible:ring focus-visible:ring-inset"
to="/admin">
<span class="mr-2">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24">
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"
stroke-width="2"
d="M3 7a3 3 0 0 1 3-3h12a3 3 0 0 1 3 3v2a3 3 0 0 1-3 3H6a3 3 0 0 1-3-3zm0 8a3 3 0 0 1 3-3h12a3 3 0 0 1 3 3v2a3 3 0 0 1-3 3H6a3 3 0 0 1-3-3zm4-7v.01M7 16v.01M11 8h6m-6 8h6" />
</svg>
</span>
Site Administration
</NuxtLink>
</li>
<hr v-if="user.is_admin" class="my-2" />
<li class="select-none mx-2">
<button <button
class="flex hover:bg-muted/10 active:bg-muted/20 transition-bg w-full px-2 py-1 rounded-md focus:outline-none focus:ring focus:ring-inset" class="flex items-center hover:bg-muted/10 active:bg-muted/20 transition-bg w-full px-2 py-1 rounded-md focus-visible:outline-none focus-visible:ring focus-visible:ring-inset"
v-on:click="logout"> v-on:click="logout">
<span class="mr-2">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24">
<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"
stroke-width="2">
<path
d="M14 8V6a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h7a2 2 0 0 0 2-2v-2" />
<path d="M9 12h12l-3-3m0 6l3-3" />
</g>
</svg>
</span>
Logout Logout
</button> </button>
</li> </li>

View File

@@ -16,7 +16,7 @@ const emit = defineEmits(['update:modelValue'])
<div class="flex justify-between mb-2 items-center"> <div class="flex justify-between mb-2 items-center">
<h3 class="text-xl font-semibold">{{ header }}</h3> <h3 class="text-xl font-semibold">{{ header }}</h3>
<button v-on:click=" $emit('update:modelValue', !modelValue)" <button v-on:click=" $emit('update:modelValue', !modelValue)"
class="p-1 border h-fit rounded-md hover:bg-muted/10 active:bg-muted/20 transition-bg"> class="p-1 border h-fit rounded-md hover:bg-muted/10 active:bg-muted/20 transition-bg focus-visible:outline-none focus-visible:ring focus-visible:ring-inset">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24"> <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24">
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" <path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"
stroke-width="2" d="M18 6L6 18M6 6l12 12" /> stroke-width="2" d="M18 6L6 18M6 6l12 12" />

View File

@@ -114,14 +114,14 @@ let uploadFailed = computed(() => props.uploadingFiles.filter(x => x.status.erro
<h3 class="text-xl font-semibold">Upload</h3> <h3 class="text-xl font-semibold">Upload</h3>
<div class="flex flex-row gap-x-2"> <div class="flex flex-row gap-x-2">
<button v-on:click="collapsed = !collapsed" <button v-on:click="collapsed = !collapsed"
class="p-1 border h-fit rounded-md hover:bg-muted/10 active:bg-muted/20 transition-bg"> class="p-1 border h-fit rounded-md hover:bg-muted/10 active:bg-muted/20 transition-bg focus-visible:outline-none focus-visible:ring focus-visible:ring-inset">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24"> <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24">
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" <path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"
stroke-width="2" d="m6 9l6 6l6-6" /> stroke-width="2" d="m6 9l6 6l6-6" />
</svg> </svg>
</button> </button>
<button v-on:click="$emit('update:closed', true)" v-if="closeable" <button v-on:click="$emit('update:closed', true)" v-if="closeable"
class="p-1 border h-fit rounded-md hover:bg-muted/10 active:bg-muted/20 transition-bg"> class="p-1 border h-fit rounded-md hover:bg-muted/10 active:bg-muted/20 transition-bg focus-visible:outline-none focus-visible:ring focus-visible:ring-inset">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24"> <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24">
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" <path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"
stroke-width="2" d="M18 6L6 18M6 6l12 12" /> stroke-width="2" d="M18 6L6 18M6 6l12 12" />
@@ -197,7 +197,7 @@ let uploadFailed = computed(() => props.uploadingFiles.filter(x => x.status.erro
</div> </div>
<div class="flex items-center" v-if="upload.uploading"> <div class="flex items-center" v-if="upload.uploading">
<button v-on:click="abortUpload(upload.id)" <button v-on:click="abortUpload(upload.id)"
class="h-fit p-1 border rounded-md hover:bg-love/10 active:bg-love/20 hover:text-love transition-[background-color,color] text-sm py-1 px-2"> class="h-fit p-1 border rounded-md hover:bg-love/10 active:bg-love/20 hover:text-love focus-visible:text-love focus-visible:bg-love/10 transition-[background-color,color] text-sm py-1 px-2 focus-visible:outline-none focus-visible:ring focus-visible:ring-inset">
Cancel Cancel
</button> </button>
</div> </div>

View File

@@ -0,0 +1,39 @@
<script setup>
const item = inject('accordionItem');
const contentHeight = ref(0);
const content = ref(null);
let timeout;
watch(item.hidden, () => {
if (!item.hidden.value) {
timeout = setTimeout(() => {
let styles = window.getComputedStyle(content.value);
let margin = parseFloat(styles['marginTop']) +
parseFloat(styles['marginBottom']);
contentHeight.value = content.value.offsetHeight + margin;
})
}
})
const attrs = useAttrs();
defineOptions({
inheritAttrs: false
});
onUnmounted(() => {
clearTimeout(timeout)
})
</script>
<template>
<div :id="`vueless-${item.index}`" role="region" :aria-labelledby="`vueless-${item.index}`"
class="vl-accordion-content" :style="`--vueless-accordion-content-height: ${contentHeight}px`"
:data-state="(item.isOpen.value) ? 'open' : 'closed'"
@animationend="(!item.isOpen.value) ? item.hidden.value = true : ''" :hidden="item.hidden.value">
<div ref="content" v-bind="attrs">
<slot />
</div>
</div>
</template>

View File

@@ -0,0 +1,139 @@
<script setup>
const props = defineProps({
type: {
type: String,
default: "multiple"
},
defaultValue: {
type: String
}
})
const accordion = ref(null);
const accordionItems = ref([])
function toggleAccordion(index) {
const item = accordionItems.value[index];
if (props.type === "single") {
// close everything but the one we just opened
accordionItems.value.forEach((item, i) => {
if (i === index) return;
item.isOpen = false;
})
}
if (item.hidden) {
item.hidden = false;
}
item.isOpen = !item.isOpen;
}
const registerAccordionItem = (value) => {
const item = { isOpen: ref(false), hidden: ref(true), value }
accordionItems.value.push(item);
return { index: accordionItems.value.indexOf(item), isOpen: item.isOpen, hidden: item.hidden, value };
};
const unregisterAccordionItem = (index) => {
accordionItems.value.splice(index, 1);
};
provide('accordion', { registerAccordionItem, unregisterAccordionItem, toggleAccordion })
function keydown(event) {
const headers = Array.from(accordion.value.querySelectorAll(".vl-accordion-header"));
if (event.key === "ArrowUp") {
event.preventDefault();
const focusedElement = document.activeElement;
const currentIndex = headers.indexOf(focusedElement);
const nextIndex = currentIndex > 0 ? currentIndex - 1 : headers.length - 1;
console.log(nextIndex, headers)
const nextButton = headers[nextIndex];
nextButton.focus();
return;
}
if (event.key === "ArrowDown") {
event.preventDefault();
const focusedElement = document.activeElement;
const currentIndex = headers.indexOf(focusedElement);
const nextIndex = currentIndex < headers.length - 1 ? currentIndex + 1 : 0;
const nextButton = headers[nextIndex];
console.log(nextIndex, headers)
nextButton.focus();
return;
}
if (event.key === "End") {
event.preventDefault();
return headers[headers.length - 1].focus();
}
if (event.key === "Home") {
event.preventDefault();
return headers[0].focus();
}
}
onMounted(() => {
if (!!props.defaultValue) {
const item = accordionItems.value.filter(item => item.value === props.defaultValue)[0];
item.isOpen = true;
item.hidden = false;
}
})
watch(props, () => {
if (!!props.defaultValue) {
const item = accordionItems.value.filter(item => item.value === props.defaultValue)[0];
item.isOpen = true;
item.hidden = false;
}
})
</script>
<template>
<div class="vl-accordion" ref="accordion" @keydown="keydown($event)">
<slot />
</div>
</template>
<style>
.vl-accordion-content {
overflow: hidden;
transform-origin: top center;
height: 0;
}
.vl-accordion-content[data-state="closed"] {
animation: 300ms cubic-bezier(0.25, 1, 0.5, 1) 0s 1 normal forwards running closeAccordion;
}
.vl-accordion-content[data-state="open"] {
animation: 300ms cubic-bezier(0.25, 1, 0.5, 1) 0s 1 normal forwards running openAccordion;
}
@keyframes closeAccordion {
0% {
height: var(--vueless-accordion-content-height);
}
100% {
height: 0px;
}
}
@keyframes openAccordion {
0% {
height: 0px;
}
100% {
height: var(--vueless-accordion-content-height);
}
}
</style>

View File

@@ -0,0 +1,19 @@
<script setup>
const props = defineProps({
value: {
type: String,
default: "",
}
})
const accordion = inject('accordion')
const item = accordion.registerAccordionItem(props.value);
provide('accordionItem', item);
</script>
<template>
<div class="vl-accordion-item" :data-state="(item.isOpen.value) ? 'open' : 'closed'">
<slot />
</div>
</template>

View File

@@ -0,0 +1,29 @@
<script setup>
const item = inject('accordionItem');
const { toggleAccordion } = inject('accordion');
</script>
<template>
<button
class="vl-accordion-header focus-visible:outline-none focus-visible:ring focus-visible:ring-inset select-none cursor-pointer w-full"
@click="toggleAccordion(item.index)">
<div class="flex flex-1 justify-between items-center w-full" :id="`vueless-${item.index}`"
:aria-controls="`vueless-${item.index}`" :aria-expanded="item.isOpen.value">
<slot />
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24">
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="m6 9l6 6l6-6" />
</svg>
</div>
</button>
</template>
<style>
.vl-accordion-item button div svg {
transition: transform 300ms ease;
}
.vl-accordion-item[data-state="open"] button div svg {
transform: rotate(180deg);
}
</style>

82
ui/layouts/admin.vue Normal file
View File

@@ -0,0 +1,82 @@
<script setup lang="ts">
import { useUser } from '~/composables/useUser'
const { getUser } = useUser()
definePageMeta({
middleware: ["auth", "admin"]
});
const user = await getUser();
const route = useRoute();
console.log("setup", route.path)
const accordionMapping = {
'/admin': 'item-1',
'/admin/config/settings': 'item-2',
'/admin/users': '',
};
const getActiveAccordion = () => {
const path = Object.keys(accordionMapping).find(key => route.path === key);
return path ? accordionMapping[path] : null;
};
const isActiveLink = (path: string) => route.path === path;
</script>
<template>
<div class="w-full">
<Nav :user="user" />
<div class="py-4 px-4 lg:px-8 flex flex-col md:flex-row gap-4">
<div class="w-auto md:w-60 flex-shrink-0">
<aside class="rounded-md border overflow-hidden w-full h-fit">
<div class="px-4 py-3.5 bg-surface border-b">Admin Settings</div>
<VlAccordion type="single" :defaultValue="getActiveAccordion()">
<VlAccordionItem value="item-1" class="text-sm">
<VlAccordionTrigger class="transition-bg hover:bg-muted/10 px-4 py-3.5">
Maintenance
</VlAccordionTrigger>
<VlAccordionContent class="mt-1 mb-2">
<div class="text-xs">
<NuxtLink to="/admin"
class="w-full indent-4 px-4 py-1.5 hover:text-text text-subtle block"
:class="isActiveLink('/admin') ? 'text-text' : 'text-subtle'">
Dashboard
</NuxtLink>
</div>
</VlAccordionContent>
</VlAccordionItem>
<VlAccordionItem value="item-2" class="text-sm">
<VlAccordionTrigger class="transition-bg hover:bg-muted/10 px-4 py-3.5">
Configuration
</VlAccordionTrigger>
<VlAccordionContent class="mt-1 mb-2">
<div class="text-xs">
<NuxtLink to="/admin/config/settings"
class="w-full indent-4 px-4 py-1.5 hover:text-text block"
:class="isActiveLink('/admin/config/settings') ? 'text-text' : 'text-subtle'">
Settings
</NuxtLink>
</div>
</VlAccordionContent>
</VlAccordionItem>
<NuxtLink
class="vl-accordion-header focus-visible:outline-none focus-visible:ring focus-visible:ring-inset flex flex-1 justify-between items-center w-full transition-bg px-4 py-3.5 text-sm"
:class="isActiveLink('/admin/users') ? 'bg-muted/15' : 'hover:bg-muted/10'"
to="/admin/users">
Users
</NuxtLink>
</VlAccordion>
</aside>
</div>
<slot />
</div>
</div>
</template>
<style>
.vl-accordion .vl-accordion-item:not(:last-child) {
border-bottom-width: 1px;
}
</style>

15
ui/middleware/admin.ts Normal file
View File

@@ -0,0 +1,15 @@
import { useUser } from '~/composables/useUser'
// We have server side things that does effectively this, but that wont stop SPA navigation
export default defineNuxtRouteMiddleware(async (to, from) => {
if (import.meta.server) {
return
}
const { getUser } = useUser()
const user = await getUser()
if (!user.is_admin) {
return navigateTo('/home')
}
})

View File

@@ -11,14 +11,5 @@ export default defineNuxtConfig({
devtools: { enabled: true }, devtools: { enabled: true },
modules: [ modules: ['@nuxtjs/color-mode', '@nuxtjs/tailwindcss']
'@nuxtjs/color-mode',
],
postcss: {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
},
}) })

View File

@@ -11,12 +11,8 @@
}, },
"dependencies": { "dependencies": {
"@nuxtjs/color-mode": "^3.4.4", "@nuxtjs/color-mode": "^3.4.4",
"@nuxtjs/tailwindcss": "^6.12.1",
"nuxt": "^3.13.1", "nuxt": "^3.13.1",
"vue": "latest" "vue": "latest"
},
"devDependencies": {
"autoprefixer": "^10.4.20",
"postcss": "^8.4.45",
"tailwindcss": "^3.4.10"
} }
} }

View File

@@ -0,0 +1,10 @@
<script setup lang="ts">
definePageMeta({
middleware: ["auth", "admin"],
layout: "admin"
});
</script>
<template>
Hey
</template>

156
ui/pages/admin/index.vue Normal file
View File

@@ -0,0 +1,156 @@
<script setup lang="ts">
import { useUser } from '~/composables/useUser'
const { getUser } = useUser()
definePageMeta({
middleware: ["auth", "admin"],
layout: "admin"
});
let systemStatusData = await $fetch("/api/admin/system-status")
const calculateTimeSince = (time) => {
const now = new Date();
const date = new Date(time);
const diffInSeconds = Math.floor((now - date) / 1000);
const days = Math.floor(diffInSeconds / (3600 * 24));
const hours = Math.floor((diffInSeconds % (3600 * 24)) / 3600);
const minutes = Math.floor((diffInSeconds % 3600) / 60);
const seconds = diffInSeconds % 60;
// Constructing the output based on non-zero values
const timeParts = [];
if (days > 0) timeParts.push(`${days} days`);
if (hours > 0 || days > 0) timeParts.push(`${hours} hours`);
if (minutes > 0 || hours > 0 || days > 0) timeParts.push(`${minutes} minutes`);
timeParts.push(`${seconds} seconds`);
return timeParts.join(', ');
}
let uptime = ref('');
let lastGcTime = ref('');
let systemStatusInterval;
let timeInterval;
const updateTime = () => {
uptime.value = calculateTimeSince(systemStatusData.uptime);
lastGcTime.value = calculateTimeSince(systemStatusData.last_gc_time)
};
onMounted(() => {
updateTime();
systemStatusInterval = setInterval(async () => {
console.log("refresh")
systemStatusData = await $fetch("/api/admin/system-status")
}, 5000);
timeInterval = setInterval(updateTime, 1000);
});
onUnmounted(() => {
clearInterval(systemStatusInterval);
clearInterval(timeInterval);
});
</script>
<template>
<div class="w-full overflow-hidden rounded-md border h-fit text-[15px]">
<h4 class="bg-surface px-3.5 py-3 border-b">System Status</h4>
<div class="p-3.5 text-sm">
<dl class="flex-wrap">
<dt>Server Uptime</dt>
<dd>{{ uptime }}</dd>
<dt>Current Goroutine</dt>
<dd>{{ systemStatusData.num_goroutine }}</dd>
<hr />
<dt>Current Memory Usage</dt>
<dd>{{ systemStatusData.cur_mem_usage }}</dd>
<dt>Total Memory Allocated</dt>
<dd>{{ systemStatusData.total_mem_usage }}</dd>
<dt>Memory Obtained</dt>
<dd>{{ systemStatusData.mem_obtained }}</dd>
<dt>Pointer Lookup Times</dt>
<dd>{{ systemStatusData.ptr_lookup_times }}</dd>
<dt>Memory Allocations</dt>
<dd>{{ systemStatusData.mem_allocations }}</dd>
<dt>Memory Frees</dt>
<dd>{{ systemStatusData.mem_frees }}</dd>
<hr />
<dt>Current Heap Usage</dt>
<dd>{{ systemStatusData.cur_heap_usage }}</dd>
<dt>Heap Memory Obtained</dt>
<dd>{{ systemStatusData.heap_mem_obtained }}</dd>
<dt>Heap Memory Idle</dt>
<dd>{{ systemStatusData.heap_mem_idle }}</dd>
<dt>Heap Memory In Use</dt>
<dd>{{ systemStatusData.heap_mem_inuse }}</dd>
<dt>Heap Memory Released</dt>
<dd>{{ systemStatusData.heap_mem_release }}</dd>
<dt>Heap Objects</dt>
<dd>{{ systemStatusData.heap_objects }}</dd>
<hr />
<dt>Bootstrap Stack Usage</dt>
<dd>{{ systemStatusData.bootstrap_stack_usage }}</dd>
<dt>Stack Memory Obtained</dt>
<dd>{{ systemStatusData.stack_mem_obtained }}</dd>
<dt>MSpan Structures Usage</dt>
<dd>{{ systemStatusData.mspan_structures_usage }}</dd>
<dt>MSpan Structures Obtained</dt>
<dd>{{ systemStatusData.mspan_structures_obtained }}</dd>
<dt>MCache Structures Usage</dt>
<dd>{{ systemStatusData.mcache_structures_usage }}</dd>
<dt>MCache Structures Obtained</dt>
<dd>{{ systemStatusData.mcache_structures_obtained }}</dd>
<dt>Profiling Bucket Hash Table Obtained</dt>
<dd>{{ systemStatusData.buck_hash_sys }}</dd>
<dt>GC Metadata Obtained</dt>
<dd>{{ systemStatusData.gc_sys }}</dd>
<dt>Other System Allocation Obtained</dt>
<dd>{{ systemStatusData.other_sys }}</dd>
<hr />
<dt>Next GC Recycle</dt>
<dd>{{ systemStatusData.next_gc }}</dd>
<dt>Since Last GC Time</dt>
<dd>{{ lastGcTime }}</dd>
<dt>Total GC Pause</dt>
<dd>{{ systemStatusData.pause_total_ns }}</dd>
<dt>Last GC Pause</dt>
<dd>{{ systemStatusData.pause_ns }}</dd>
<dt>GC Times</dt>
<dd>{{ systemStatusData.num_gc }}</dd>
</dl>
</div>
</div>
</template>
<style>
dl {
display: flex;
flex-wrap: wrap;
}
dt {
font-weight: 600;
width: 300px;
max-width: calc(100% - 100px - 1em);
padding-top: 5px;
padding-bottom: 5px;
}
dd {
padding-top: 5px;
padding-bottom: 5px;
width: calc(100% - 300px);
min-width: 100px;
}
hr {
width: 100%;
margin-top: 4px;
margin-bottom: 4px;
}
</style>

View File

@@ -0,0 +1,92 @@
<script setup lang="ts">
import { useUser } from "~/composables/useUser"
import type { User } from "~/types/user";
const { getUser } = useUser()
definePageMeta({
middleware: ["auth", "admin"],
layout: "admin"
});
let page = ref(0)
const { data: users } = await useFetch<User[]>('/api/admin/get-users/' + page.value);
const { data: usersCount } = await useFetch<{ total_users: number }>('/api/admin/get-total-users');
const fetchNextPage = async () => {
page.value += 1;
let moreUsers = await $fetch('/api/admin/get-users/' + page.value);
console.log(moreUsers)
users.value = users.value?.concat(moreUsers)
}
</script>
<template>
<div class="w-full h-fit mb-4">
<div class="overflow-hidden rounded-md border text-[15px]">
<h4 class="bg-surface px-3.5 py-3 border-b">User Account Management (Total: {{ usersCount.total_users }})
</h4>
<div class="overflow-x-scroll max-w-full">
<table class="min-w-full">
<thead>
<tr class="text-left">
<th class="py-2 px-4">ID</th>
<th class="py-2 px-4">Username</th>
<th class="py-2 px-4">Email Address</th>
<th class="py-2 px-4">Restricted</th>
<th class="py-2 px-4">Created</th>
<th class="py-2 px-4 text-right">Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="user in users" class="border-t">
<td class="py-2 px-4 max-w-44" :title="user.id">{{ user.id }}</td>
<td class="py-2 px-4">
{{ user.username }}
<span v-if="user.is_admin"
class="ml-2 text-xs bg-accent/10 text-accent py-1 px-2 rounded">Admin</span>
</td>
<td class="py-2 px-4">{{ user.email }} </td>
<td class="py-2 px-4">
<svg v-if="true" xmlns="http://www.w3.org/2000/svg" width="16" height="16"
viewBox="0 0 24 24">
<path fill="none" stroke="currentColor" stroke-linecap="round"
stroke-linejoin="round" stroke-width="2" d="M18 6L6 18M6 6l12 12" />
</svg>
<svg v-else xmlns="http://www.w3.org/2000/svg" width="16" height="16"
viewBox="0 0 24 24">
<path fill="none" stroke="currentColor" stroke-linecap="round"
stroke-linejoin="round" stroke-width="2" d="m5 12l5 5L20 7" />
</svg>
</td>
<td class="py-2 px-4">{{ new Date(user.created_at).toLocaleDateString('en-US', {
year:
'numeric', month: 'short', day: 'numeric'
}) }}</td>
<td class="py-2 px-4 h-full">
<div class="flex items-center justify-end">
<NuxtLink :to="`/admin/users/${user.id}/edit`"></NuxtLink>
<button
class="my-auto hover:bg-muted/10 p-1 transition-bg active:bg-muted/20 rounded-md">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"
viewBox="0 0 24 24">
<g class="stroke-blue-400/90" fill="none" stroke="currentColor"
stroke-linecap="round" stroke-linejoin="round" stroke-width="2">
<path d="M7 7H6a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h9a2 2 0 0 0 2-2v-1" />
<path d="M20.385 6.585a2.1 2.1 0 0 0-2.97-2.97L9 12v3h3zM16 5l3 3" />
</g>
</svg>
</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="w-full h-full flex justify-center mt-4" v-if="users?.length != usersCount.total_users">
<button class="bg-accent/10 text-accent px-2 py-1 rounded-md hover:" v-on:click="fetchNextPage()">Load
More</button>
</div>
</div>
</template>

View File

@@ -8,7 +8,7 @@ definePageMeta({
middleware: "auth" middleware: "auth"
}); });
const user = await getUser() const user = await getUser();
const route = useRoute(); const route = useRoute();
let { data: files } = await useFetch<File[]>('/api/files/get/' + route.path.replace(/^\/home/, '')) let { data: files } = await useFetch<File[]>('/api/files/get/' + route.path.replace(/^\/home/, ''))
@@ -33,7 +33,7 @@ const sortedFiles = computed(() => {
let selectAll: Ref<"unchecked" | "some" | "checked"> = ref('unchecked'); let selectAll: Ref<"unchecked" | "some" | "checked"> = ref('unchecked');
let selectedFiles = computed(() => sortedFiles.value?.filter(file => file.toggled === 'checked')) let selectedFiles = computed(() => sortedFiles.value?.filter(file => file.toggled === 'checked'))
watch(sortedFiles, (newVal, oldVal) => { watch(sortedFiles, (newVal) => {
let checkedFilesLength = newVal?.filter(file => file.toggled === 'checked').length; let checkedFilesLength = newVal?.filter(file => file.toggled === 'checked').length;
if (newVal !== undefined && checkedFilesLength !== undefined && checkedFilesLength > 0) { if (newVal !== undefined && checkedFilesLength !== undefined && checkedFilesLength > 0) {
if (checkedFilesLength < newVal.length) { if (checkedFilesLength < newVal.length) {
@@ -46,7 +46,7 @@ watch(sortedFiles, (newVal, oldVal) => {
} }
}) })
watch(selectAll, (newVal, oldVal) => { watch(selectAll, (newVal) => {
if (newVal === 'some') { if (newVal === 'some') {
return return
} }
@@ -231,8 +231,6 @@ const createFolder = async () => {
}) })
) )
console.log(error.value)
if (data.value != null) { if (data.value != null) {
user.usage = data.value.usage user.usage = data.value.usage
files.value?.push(data.value.file) files.value?.push(data.value.file)
@@ -318,27 +316,28 @@ const downloadFiles = async () => {
<div class="flex flex-col p-2"> <div class="flex flex-col p-2">
<div class="mb-3 flex flex-col"> <div class="mb-3 flex flex-col">
<label for="folderNameInput" class="text-sm">name</label> <label for="folderNameInput" class="text-sm">name</label>
<!-- TODO figure out why I cant focus this when the popup opens -->
<Input id="folderNameInput" v-model="folderName" placeholder="Folder name" /> <Input id="folderNameInput" v-model="folderName" placeholder="Folder name" />
<p class="text-love">{{ folderError }}</p> <p class="text-love">{{ folderError }}</p>
</div> </div>
<div class="ml-auto flex gap-x-1.5"> <div class="ml-auto flex gap-x-1.5">
<button v-on:click="popupVisable = !popupVisable" <button v-on:click="popupVisable = !popupVisable"
class=" px-2 py-1 rounded-md text-sm border bg-muted/10 hover:bg-muted/15 active:bg-muted/25 transition-bg">Close</button> class=" px-2 py-1 rounded-md text-sm border bg-muted/10 hover:bg-muted/15 active:bg-muted/25 transition-bg focus-visible:outline-none focus-visible:ring focus-visible:ring-inset">Close</button>
<button v-on:click="createFolder" :disabled="folderName === ''" <button v-on:click="createFolder" :disabled="folderName === ''"
class=" px-2 py-1 rounded-md text-sm class=" px-2 py-1 rounded-md text-sm
disabled:bg-highlight-med/50 bg-highlight-med not:hover:brightness-105 not:active:brightness-110 transition-[background-color,filter] text-surface disabled:cursor-not-allowed">Confirm</button> disabled:bg-highlight-med/50 bg-highlight-med not:hover:brightness-105 not:active:brightness-110 transition-[background-color,filter] text-surface disabled:cursor-not-allowed focus-visible:outline-none focus-visible:ring focus-visible:ring-inset">Confirm</button>
</div> </div>
</div> </div>
</Popup> </Popup>
<div class="w-full"> <div class="w-full">
<Nav v-on:update:filenav="(e) => fileNavClosed = e" :filenav="fileNavClosed" /> <Nav v-on:update:filenav="(e) => fileNavClosed = e" :filenav="fileNavClosed" :user="user" />
<div class="pt-6 pl-12 overflow-y-auto max-h-[calc(100vh-var(--nav-height))]" id="main"> <div class="pt-6 pl-12 overflow-y-auto max-h-[calc(100vh-var(--nav-height))]" id="main">
<div class="flex gap-x-4 flex-col"> <div class="flex gap-x-4 flex-col">
<div class="py-5 flex flex-row gap-x-4"> <div class="py-5 flex flex-row gap-x-4">
<input type="file" ref="fileInput" @change="handleFileChange" multiple class="hidden" /> <input type="file" ref="fileInput" @change="handleFileChange" multiple class="hidden" />
<button v-on:click="openFilePicker" <button v-on:click="openFilePicker"
class="focus:outline-none focus:ring focus:ring-inset rounded-xl border-2 border-surface flex flex-col gap-y-2 px-2 py-3 w-40 justify-center items-center hover:bg-muted/10 active:bg-muted/20 transition-bg"> class="rounded-xl border-2 border-surface flex flex-col gap-y-2 px-2 py-3 w-40 justify-center items-center hover:bg-muted/10 active:bg-muted/20 transition-bg focus-visible:outline-none focus-visible:ring focus-visible:ring-inset">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"> <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" <g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"
stroke-width="2"> stroke-width="2">
@@ -349,7 +348,7 @@ const downloadFiles = async () => {
Upload Upload
</button> </button>
<button v-on:click="popupVisable = !popupVisable" <button v-on:click="popupVisable = !popupVisable"
class="focus:outline-none focus:ring focus:ring-inset rounded-xl border-2 border-surface flex flex-col gap-y-2 px-2 py-3 w-40 justify-center items-center hover:bg-muted/10 active:bg-muted/20 transition-bg"> class="rounded-xl border-2 border-surface flex flex-col gap-y-2 px-2 py-3 w-40 justify-center items-center hover:bg-muted/10 active:bg-muted/20 transition-bg focus-visible:outline-none focus-visible:ring focus-visible:ring-inset">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"> <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" <g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"
stroke-width="2"> stroke-width="2">
@@ -371,7 +370,7 @@ const downloadFiles = async () => {
<div class="flex flex-row gap-x-2" <div class="flex flex-row gap-x-2"
v-if="selectedFiles !== undefined && selectedFiles.length > 0"> v-if="selectedFiles !== undefined && selectedFiles.length > 0">
<button v-on:click="downloadFiles" <button v-on:click="downloadFiles"
class="flex flex-row px-2 py-1 rounded-md transition-bg text-xs border hover:bg-muted/10 active:bg-muted/20 items-center focus:outline-none focus:ring focus:ring-inset"> class="flex flex-row px-2 py-1 rounded-md transition-bg text-xs border hover:bg-muted/10 active:bg-muted/20 items-center focus-visible:outline-none focus-visible:ring focus-visible:ring-inset">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24"> <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24">
<path fill="none" stroke="currentColor" stroke-linecap="round" <path fill="none" stroke="currentColor" stroke-linecap="round"
stroke-linejoin="round" stroke-width="2" stroke-linejoin="round" stroke-width="2"
@@ -380,7 +379,7 @@ const downloadFiles = async () => {
Download Download
</button> </button>
<button v-on:click="deleteFiles" <button v-on:click="deleteFiles"
class="flex flex-row px-2 py-1 rounded-md transition-bg text-xs border hover:bg-love/10 active:bg-love/20 hover:text-love active:text-love items-center focus:outline-none focus:ring focus:ring-inset"> class="flex flex-row px-2 py-1 rounded-md transition-bg text-xs border hover:bg-love/10 active:bg-love/20 hover:text-love active:text-love items-center focus-visible:outline-none focus-visible:ring focus-visible:ring-inset">
<svg class="mr-1" xmlns="http://www.w3.org/2000/svg" width="16" height="16" <svg class="mr-1" xmlns="http://www.w3.org/2000/svg" width="16" height="16"
viewBox="0 0 24 24"> viewBox="0 0 24 24">
<path fill="none" stroke="currentColor" stroke-linecap="round" <path fill="none" stroke="currentColor" stroke-linecap="round"
@@ -393,15 +392,18 @@ const downloadFiles = async () => {
</div> </div>
<table class="w-full text-sm mt-2 table-fixed"> <table class="w-full text-sm mt-2 table-fixed">
<thead class="border-b"> <thead class="border-b">
<tr class="flex flex-row h-10 group pl-[30px] -ml-7 relative items-center"> <tr class="flex flex-row h-10 group relative items-center focus-visible:outline-none focus-visible:ring focus-visible:ring-inset"
<th class="left-0 absolute"> v-on:keypress.enter="selectAll === 'unchecked' ? selectAll = 'checked' : selectAll = 'unchecked'"
<div> v-on:keypress.space.prevent="selectAll === 'unchecked' ? selectAll = 'checked' : selectAll = 'unchecked'"
tabindex="0">
<th class="-ml-7 flex-shrink-0">
<div class="w-5 h-5">
<Checkbox :class="{ 'hidden': selectAll === 'unchecked' }" <Checkbox :class="{ 'hidden': selectAll === 'unchecked' }"
v-model="selectAll" class="group-hover:flex" type="checkbox" /> v-model="selectAll" class="group-hover:flex" type="checkbox" />
</div> </div>
</th> </th>
<th v-on:click="selectAll === 'unchecked' ? selectAll = 'checked' : selectAll = 'unchecked'" <th v-on:click="selectAll === 'unchecked' ? selectAll = 'checked' : selectAll = 'unchecked'"
class="flex-grow min-w-40 text-start flex items-center h-full"> class="pl-4 flex-grow min-w-40 text-start flex items-center h-full">
Name Name
</th> </th>
<th class="min-w-32 text-start"> <th class="min-w-32 text-start">
@@ -413,9 +415,12 @@ const downloadFiles = async () => {
</tr> </tr>
</thead> </thead>
<tbody class="block"> <tbody class="block">
<tr class="flex border-l-2 flex-row h-10 group items-center border-b active:bg-surface/45 transition-bg relative" <tr v-for="file in sortedFiles"
v-for="file in sortedFiles" class="flex border-l-2 flex-row h-10 group items-center border-b active:bg-surface/45 transition-bg relative focus-visible:outline-none focus-visible:ring focus-visible:ring-inset"
:class="file.toggled === 'checked' ? 'bg-accent/20 border-l-accent' : 'border-l-transparent hover:bg-surface'"> :class="file.toggled === 'checked' ? 'bg-accent/20 border-l-accent' : 'border-l-transparent hover:bg-surface'"
v-on:keypress.enter="file.toggled === 'unchecked' ? file.toggled = 'checked' : file.toggled = 'unchecked'"
v-on:keypress.space.prevent="file.toggled === 'unchecked' ? file.toggled = 'checked' : file.toggled = 'unchecked'"
tabindex="0">
<td class="-ml-7 flex-shrink-0"> <td class="-ml-7 flex-shrink-0">
<div class="w-5 h-5"> <div class="w-5 h-5">
<Checkbox class="group-hover:flex" <Checkbox class="group-hover:flex"
@@ -424,10 +429,7 @@ const downloadFiles = async () => {
</div> </div>
</td> </td>
<td v-on:click="file.toggled === 'unchecked' ? file.toggled = 'checked' : file.toggled = 'unchecked'" <td v-on:click="file.toggled === 'unchecked' ? file.toggled = 'checked' : file.toggled = 'unchecked'"
v-on:keypress.enter="file.toggled === 'unchecked' ? file.toggled = 'checked' : file.toggled = 'unchecked'" class="flex-grow text-start flex items-center h-full min-w-40 pl-4">
v-on:keypress.space="file.toggled === 'unchecked' ? file.toggled = 'checked' : file.toggled = 'unchecked'"
class="flex-grow text-start flex items-center h-full min-w-40 focus:outline-none focus:ring focus:ring-inset pl-4"
tabindex="0">
<div class="flex items-center min-w-40"> <div class="flex items-center min-w-40">
<svg v-if="!file.is_dir" class="mr-2 flex-shrink-0" <svg v-if="!file.is_dir" class="mr-2 flex-shrink-0"
xmlns="http://www.w3.org/2000/svg" width="16" height="16" xmlns="http://www.w3.org/2000/svg" width="16" height="16"
@@ -463,7 +465,7 @@ const downloadFiles = async () => {
<td :class="file.toggled === 'checked' ? 'context-active' : 'context'" <td :class="file.toggled === 'checked' ? 'context-active' : 'context'"
class="absolute pl-6 top-0 bottom-0 right-0 hidden group-hover:flex group-focus-within:flex items-center pr-8"> class="absolute pl-6 top-0 bottom-0 right-0 hidden group-hover:flex group-focus-within:flex items-center pr-8">
<button v-on:click="downloadFile(file)" <button v-on:click="downloadFile(file)"
class="p-2 rounded hover:bg-muted/10 active:bg-muted/20 focus:outline-none focus:ring focus:ring-inset"> class="p-2 rounded hover:bg-muted/10 active:bg-muted/20 focus-visible:outline-none focus-visible:ring focus-visible:ring-inset">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20"
viewBox="0 0 24 24"> viewBox="0 0 24 24">
<path fill="none" stroke="currentColor" stroke-linecap="round" <path fill="none" stroke="currentColor" stroke-linecap="round"

View File

@@ -12,6 +12,7 @@ let password = ref('')
let error = ref('') let error = ref('')
let timeout;
const submitForm = async () => { const submitForm = async () => {
let { data, error: fetchError } = await useAsyncData<User, NuxtError<{ message: string }>>( let { data, error: fetchError } = await useAsyncData<User, NuxtError<{ message: string }>>(
() => $fetch('/api/login', { () => $fetch('/api/login', {
@@ -25,12 +26,16 @@ const submitForm = async () => {
if (fetchError.value !== null && fetchError.value.data !== undefined) { if (fetchError.value !== null && fetchError.value.data !== undefined) {
error.value = fetchError.value.data.message error.value = fetchError.value.data.message
setTimeout(() => error.value = "", 15000) timeout = setTimeout(() => error.value = "", 15000)
} else if (data.value !== null) { } else if (data.value !== null) {
setUser(data.value) setUser(data.value)
await navigateTo('/home') await navigateTo('/home')
} }
} }
onUnmounted(() => {
clearTimeout(timeout)
})
</script> </script>
<template> <template>

View File

@@ -13,6 +13,7 @@ let password = ref('')
let error = ref('') let error = ref('')
let timeout;
const submitForm = async () => { const submitForm = async () => {
let { data, error: fetchError } = await useAsyncData<User, NuxtError<{ message: string }>>( let { data, error: fetchError } = await useAsyncData<User, NuxtError<{ message: string }>>(
() => $fetch('/api/signup', { () => $fetch('/api/signup', {
@@ -27,12 +28,16 @@ const submitForm = async () => {
if (fetchError.value != null && fetchError.value.data !== undefined) { if (fetchError.value != null && fetchError.value.data !== undefined) {
error.value = fetchError.value.data.message error.value = fetchError.value.data.message
setTimeout(() => error.value = "", 15000) timeout = setTimeout(() => error.value = "", 15000)
} else if (data.value !== null) { } else if (data.value !== null) {
setUser(data.value) setUser(data.value)
await navigateTo('/home') await navigateTo('/home')
} }
} }
onUnmounted(() => {
clearTimeout(timeout)
})
</script> </script>
<template> <template>

View File

@@ -1,5 +1,5 @@
export interface User { export interface User {
id: number, id: string,
username: string, username: string,
email: string, email: string,
plan: { plan: {
@@ -7,4 +7,6 @@ export interface User {
max_storage: number max_storage: number
}, },
usage: number, usage: number,
created_at: string,
is_admin: boolean,
} }