bug fixes, half-finished admin ui, and a more
This commit is contained in:
@@ -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
1
go.mod
@@ -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
2
go.sum
@@ -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
52
main.go
@@ -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
22
middleware/admin.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
172
routes/admin.go
Normal 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))
|
||||||
|
}
|
||||||
@@ -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!"})
|
||||||
|
|||||||
41
server.go
41
server.go
@@ -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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<NuxtPage />
|
<NuxtLayout>
|
||||||
|
<NuxtPage />
|
||||||
|
</NuxtLayout>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -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));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
BIN
ui/bun.lockb
BIN
ui/bun.lockb
Binary file not shown.
@@ -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>
|
||||||
|
|||||||
@@ -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'">
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
39
ui/components/vlAccordion/content.vue
Executable file
39
ui/components/vlAccordion/content.vue
Executable 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>
|
||||||
139
ui/components/vlAccordion/index.vue
Executable file
139
ui/components/vlAccordion/index.vue
Executable 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>
|
||||||
19
ui/components/vlAccordion/item.vue
Executable file
19
ui/components/vlAccordion/item.vue
Executable 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>
|
||||||
29
ui/components/vlAccordion/trigger.vue
Executable file
29
ui/components/vlAccordion/trigger.vue
Executable 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
82
ui/layouts/admin.vue
Normal 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
15
ui/middleware/admin.ts
Normal 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')
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -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: {},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
@@ -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"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
10
ui/pages/admin/config/settings.vue
Normal file
10
ui/pages/admin/config/settings.vue
Normal 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
156
ui/pages/admin/index.vue
Normal 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>
|
||||||
92
ui/pages/admin/users/index.vue
Normal file
92
ui/pages/admin/users/index.vue
Normal 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>
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user