uploading files and a lot more
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -2,3 +2,5 @@ filething
|
||||
|
||||
# nuxt buildCache is bugged and weird on my setup
|
||||
node_modules
|
||||
|
||||
data/
|
||||
59
main.go
59
main.go
@@ -1,5 +1,5 @@
|
||||
//go:generate bun --cwd=./ui install
|
||||
//go:generate bun --cwd=./ui run generate
|
||||
//go:generate bun --bun --cwd=./ui run generate
|
||||
package main
|
||||
|
||||
import (
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"filething/routes"
|
||||
"filething/ui"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
@@ -27,7 +28,7 @@ func main() {
|
||||
dbUser := os.Getenv("DB_USER")
|
||||
dbPasswd := os.Getenv("DB_PASSWD")
|
||||
|
||||
if dbHost == "" || dbName == "" || dbUser == "" {
|
||||
if dbHost == "" || dbName == "" || dbUser == "" || os.Getenv("STORAGE_PATH") == "" {
|
||||
panic("Missing database environment variabled!")
|
||||
}
|
||||
|
||||
@@ -41,6 +42,11 @@ func main() {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
err = seedPlans(db)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
e := echo.New()
|
||||
|
||||
e.Use(func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
@@ -64,17 +70,20 @@ func main() {
|
||||
{
|
||||
api.POST("/login", routes.LoginHandler)
|
||||
api.POST("/signup", routes.SignupHandler)
|
||||
|
||||
// everything past this needs auth
|
||||
api.Use(middleware.SessionMiddleware(db))
|
||||
api.GET("/user", func(c echo.Context) error {
|
||||
user := c.Get("user").(*models.User)
|
||||
message := fmt.Sprintf("You are %s", user.ID)
|
||||
return c.JSON(http.StatusOK, map[string]string{"message": message})
|
||||
})
|
||||
api.GET("/hello", func(c echo.Context) error {
|
||||
return c.JSON(http.StatusOK, map[string]string{"message": "Hello, World!!!"})
|
||||
})
|
||||
api.GET("/user", routes.GetUser)
|
||||
api.GET("/user/usage", routes.GetUsage)
|
||||
|
||||
api.POST("/upload*", routes.UploadFile)
|
||||
api.GET("/files*", routes.GetFiles)
|
||||
}
|
||||
|
||||
// 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
|
||||
e.Use(middleware.AuthCheckMiddleware)
|
||||
|
||||
e.GET("/*", echo.StaticDirectoryHandler(ui.DistDirFS, false))
|
||||
|
||||
e.HTTPErrorHandler = customHTTPErrorHandler
|
||||
@@ -83,8 +92,6 @@ func main() {
|
||||
}
|
||||
|
||||
func customHTTPErrorHandler(err error, c echo.Context) {
|
||||
c.Logger().Error(err)
|
||||
|
||||
if he, ok := err.(*echo.HTTPError); ok && he.Code == http.StatusNotFound {
|
||||
path := c.Request().URL.Path
|
||||
|
||||
@@ -123,6 +130,7 @@ func createSchema(db *bun.DB) error {
|
||||
models := []interface{}{
|
||||
(*models.User)(nil),
|
||||
(*models.Session)(nil),
|
||||
(*models.Plan)(nil),
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
@@ -134,3 +142,30 @@ func createSchema(db *bun.DB) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func seedPlans(db *bun.DB) error {
|
||||
ctx := context.Background()
|
||||
count, err := db.NewSelect().Model((*models.Plan)(nil)).Count(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to count plans: %w", err)
|
||||
}
|
||||
|
||||
// If the table is not empty, no need to seed
|
||||
if count > 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
plans := []models.Plan{
|
||||
{MaxStorage: 10 * 1024 * 1024 * 1024}, // 10GB
|
||||
{MaxStorage: 50 * 1024 * 1024 * 1024}, // 50GB
|
||||
{MaxStorage: 100 * 1024 * 1024 * 1024}, // 100GB
|
||||
}
|
||||
|
||||
_, err = db.NewInsert().Model(&plans).Exec(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to seed plans: %w", err)
|
||||
}
|
||||
|
||||
log.Println("Successfully seeded the plans table")
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -12,15 +12,6 @@ import (
|
||||
"github.com/uptrace/bun"
|
||||
)
|
||||
|
||||
// import (
|
||||
// "database/sql"
|
||||
// "net/http"
|
||||
|
||||
// "github.com/go-pg/pg/v10"
|
||||
|
||||
// "github.com/labstack/echo/v4"
|
||||
// )
|
||||
|
||||
const UserContextKey = "user"
|
||||
|
||||
func SessionMiddleware(db *bun.DB) echo.MiddlewareFunc {
|
||||
@@ -46,7 +37,7 @@ func SessionMiddleware(db *bun.DB) echo.MiddlewareFunc {
|
||||
session := &models.Session{
|
||||
ID: sessionId,
|
||||
}
|
||||
err = db.NewSelect().Model(session).Relation("User").WherePK().Scan(context.Background())
|
||||
err = db.NewSelect().Model(session).WherePK().Scan(context.Background())
|
||||
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
@@ -56,7 +47,18 @@ func SessionMiddleware(db *bun.DB) echo.MiddlewareFunc {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Database error")
|
||||
}
|
||||
|
||||
user := &session.User
|
||||
user := &models.User{
|
||||
ID: session.UserID,
|
||||
}
|
||||
err = db.NewSelect().Model(user).Relation("Plan").WherePK().Scan(context.Background())
|
||||
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "Invalid session token")
|
||||
}
|
||||
fmt.Println(err)
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Database error")
|
||||
}
|
||||
|
||||
// Store the user in the context
|
||||
c.Set(UserContextKey, user)
|
||||
|
||||
49
middleware/route.go
Normal file
49
middleware/route.go
Normal file
@@ -0,0 +1,49 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
var unauthenticatedPages = []string{
|
||||
"/login",
|
||||
"/signup",
|
||||
"/",
|
||||
}
|
||||
|
||||
var authenticatedPages = []string{
|
||||
"/home",
|
||||
}
|
||||
|
||||
func AuthCheckMiddleware(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
path := c.Request().URL.Path
|
||||
_, cookieErr := c.Cookie("sessionToken")
|
||||
authenticated := cookieErr == nil
|
||||
|
||||
if Contains(unauthenticatedPages, path) && authenticated {
|
||||
return c.Redirect(http.StatusFound, "/home")
|
||||
}
|
||||
|
||||
if Contains(authenticatedPages, path) && !authenticated {
|
||||
return c.Redirect(http.StatusFound, "/login")
|
||||
}
|
||||
|
||||
if strings.Contains(path, "/home") && !authenticated {
|
||||
return c.Redirect(http.StatusFound, "/login")
|
||||
}
|
||||
|
||||
return next(c)
|
||||
}
|
||||
}
|
||||
|
||||
func Contains(s []string, element string) bool {
|
||||
for _, v := range s {
|
||||
if v == element {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -17,16 +17,24 @@ type SignupData struct {
|
||||
}
|
||||
|
||||
type User struct {
|
||||
bun.BaseModel `bun:"table:users,alias:u"`
|
||||
ID uuid.UUID `bun:",pk,type:uuid,default:uuid_generate_v4()"`
|
||||
Username string `bun:"username,notnull,unique"`
|
||||
Email string `bun:"email,notnull,unique"`
|
||||
PasswordHash string `bun:"passwordHash,notnull"`
|
||||
bun.BaseModel `bun:"table:users"`
|
||||
ID uuid.UUID `bun:"id,pk,type:uuid,default:uuid_generate_v4()" json:"id"`
|
||||
Username string `bun:"username,notnull,unique" json:"username"`
|
||||
Email string `bun:"email,notnull,unique" json:"email"`
|
||||
PasswordHash string `bun:"passwordHash,notnull" json:"-"`
|
||||
PlanID int64 `bun:"plan_id,notnull" json:"-"`
|
||||
Plan Plan `bun:"rel:belongs-to,join:plan_id=id" json:"plan"`
|
||||
}
|
||||
|
||||
type Session struct {
|
||||
bun.BaseModel `bun:"table:sessions,alias:u"`
|
||||
ID uuid.UUID `bun:",pk,type:uuid,default:uuid_generate_v4()"`
|
||||
bun.BaseModel `bun:"table:sessions"`
|
||||
ID uuid.UUID `bun:"id,pk,type:uuid,default:uuid_generate_v4()"`
|
||||
UserID uuid.UUID `bun:"user_id,notnull,type:uuid"`
|
||||
User User `bun:"rel:belongs-to,join:user_id=id"`
|
||||
}
|
||||
|
||||
type Plan struct {
|
||||
bun.BaseModel `bun:"table:plans"`
|
||||
ID int64 `bun:"id,pk,autoincrement" json:"id"`
|
||||
MaxStorage int64 `bun:"max_storage,notnull" json:"max_storage"`
|
||||
}
|
||||
|
||||
@@ -3,8 +3,11 @@ package routes
|
||||
import (
|
||||
"context"
|
||||
"filething/models"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"regexp"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/labstack/echo/v4"
|
||||
@@ -40,33 +43,17 @@ func LoginHandler(c echo.Context) error {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"message": "An unknown error occoured!"})
|
||||
}
|
||||
|
||||
expiration := time.Now().Add(time.Hour * 24 * 365 * 100)
|
||||
|
||||
c.SetCookie(&http.Cookie{
|
||||
Name: "sessionToken",
|
||||
Value: session.ID.String(),
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
Expires: expiration,
|
||||
Path: "/",
|
||||
})
|
||||
|
||||
return c.JSON(http.StatusOK, map[string]string{"message": "Login successful!"})
|
||||
|
||||
// sessionID := uuid.New().String()
|
||||
// session := &models.Session{ID: sessionID, UserID: user.ID, ExpiresAt: time.Now().Add(time.Hour * 24)}
|
||||
|
||||
// key := "session:" + session.ID
|
||||
// err = client.HSet(ctx, key, session).Err()
|
||||
|
||||
// if err != nil {
|
||||
// c.JSON(http.StatusInternalServerError, gin.H{"message": "An unknown error occoured!"})
|
||||
// return
|
||||
// }
|
||||
|
||||
// http.SetCookie(c.Writer, &http.Cookie{
|
||||
// Name: "sessionToken",
|
||||
// Value: sessionID,
|
||||
// Path: "/",
|
||||
// })
|
||||
|
||||
// c.JSON(http.StatusOK, gin.H{"message": "Login successful"})
|
||||
}
|
||||
|
||||
func SignupHandler(c echo.Context) error {
|
||||
@@ -96,6 +83,7 @@ func SignupHandler(c echo.Context) error {
|
||||
Username: signupData.Username,
|
||||
Email: signupData.Email,
|
||||
PasswordHash: string(hash),
|
||||
PlanID: 1, // basic 10GB plan
|
||||
}
|
||||
_, err = db.NewInsert().Model(user).Exec(context.Background())
|
||||
|
||||
@@ -103,28 +91,33 @@ func SignupHandler(c echo.Context) error {
|
||||
return c.JSON(http.StatusConflict, map[string]string{"message": "A user with that email or username already exists!"})
|
||||
}
|
||||
|
||||
err = os.Mkdir(fmt.Sprintf("%s/%s", os.Getenv("STORAGE_PATH"), user.ID), os.ModePerm)
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return err
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"message": "An unknown error occoured!"})
|
||||
}
|
||||
|
||||
session, err := GenerateSessionToken(db, user.ID)
|
||||
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"message": "An unknown error occoured!"})
|
||||
}
|
||||
|
||||
expiration := time.Now().Add(time.Hour * 24 * 365 * 100)
|
||||
|
||||
c.SetCookie(&http.Cookie{
|
||||
Name: "sessionToken",
|
||||
Value: session.ID.String(),
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
Expires: expiration,
|
||||
Path: "/",
|
||||
})
|
||||
|
||||
return c.JSON(http.StatusOK, map[string]string{"message": "Signup successful!"})
|
||||
|
||||
// http.SetCookie(c.Writer, &http.Cookie{
|
||||
// Name: "sessionToken",
|
||||
// Value: sessionID,
|
||||
// Path: "/",
|
||||
// })
|
||||
|
||||
// c.JSON(http.StatusOK, gin.H{"message": "Signup successful"})
|
||||
}
|
||||
|
||||
func GenerateSessionToken(db *bun.DB, userId uuid.UUID) (*models.Session, error) {
|
||||
@@ -136,3 +129,12 @@ func GenerateSessionToken(db *bun.DB, userId uuid.UUID) (*models.Session, error)
|
||||
|
||||
return session, err
|
||||
}
|
||||
|
||||
func GetUser(c echo.Context) error {
|
||||
user := c.Get("user")
|
||||
if user == nil {
|
||||
return c.JSON(http.StatusNotFound, map[string]string{"message": "User not found"})
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, user.(*models.User))
|
||||
}
|
||||
|
||||
194
routes/files.go
Normal file
194
routes/files.go
Normal file
@@ -0,0 +1,194 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
"filething/models"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
type UploadResponse struct {
|
||||
Usage int64 `json:"usage"`
|
||||
File File `json:"file"`
|
||||
}
|
||||
|
||||
func UploadFile(c echo.Context) error {
|
||||
user := c.Get("user").(*models.User)
|
||||
|
||||
fullPath := strings.Trim(c.Param("*"), "/")
|
||||
basePath := fmt.Sprintf("%s/%s/%s/", os.Getenv("STORAGE_PATH"), user.ID, fullPath)
|
||||
|
||||
currentUsage, err := calculateStorageUsage(basePath)
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return err
|
||||
}
|
||||
|
||||
reader, err := c.Request().MultipartReader()
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return err
|
||||
}
|
||||
|
||||
part, err := reader.NextPart()
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return err
|
||||
}
|
||||
|
||||
filepath := filepath.Join(basePath, part.FileName())
|
||||
|
||||
if _, err = os.Stat(filepath); err == nil {
|
||||
return c.JSON(http.StatusConflict, map[string]string{"message": "File with that name already exists"})
|
||||
}
|
||||
|
||||
dst, err := os.Create(filepath)
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return err
|
||||
}
|
||||
defer dst.Close()
|
||||
|
||||
// Read the file manually because otherwise we are limited by the arbitrarily small size of /tmp
|
||||
buffer := make([]byte, 4096)
|
||||
totalSize := int64(0)
|
||||
|
||||
for {
|
||||
n, readErr := part.Read(buffer)
|
||||
|
||||
if readErr != nil && readErr == io.ErrUnexpectedEOF {
|
||||
dst.Close()
|
||||
os.Remove(filepath)
|
||||
return c.JSON(http.StatusRequestTimeout, map[string]string{"message": "Upload canceled"})
|
||||
}
|
||||
|
||||
if readErr != nil && readErr != io.EOF {
|
||||
fmt.Println(err)
|
||||
return readErr
|
||||
}
|
||||
|
||||
totalSize += int64(n)
|
||||
|
||||
if currentUsage+totalSize > user.Plan.MaxStorage {
|
||||
dst.Close()
|
||||
os.Remove(filepath)
|
||||
return c.JSON(http.StatusInsufficientStorage, map[string]string{"message": "Insufficient storage space"})
|
||||
}
|
||||
|
||||
if _, err := dst.Write(buffer[:n]); err != nil {
|
||||
fmt.Println(err)
|
||||
return err
|
||||
}
|
||||
|
||||
if n == 0 || readErr == io.EOF {
|
||||
entry, err := os.Stat(filepath)
|
||||
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return err
|
||||
}
|
||||
|
||||
uploadFile := &UploadResponse{
|
||||
Usage: currentUsage + totalSize,
|
||||
File: File{
|
||||
Name: entry.Name(),
|
||||
IsDir: entry.IsDir(),
|
||||
Size: entry.Size(),
|
||||
LastModified: entry.ModTime().Format("1/2/2006"),
|
||||
},
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, uploadFile)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func calculateStorageUsage(basePath string) (int64, error) {
|
||||
var totalSize int64
|
||||
|
||||
// Read the directory
|
||||
entries, err := os.ReadDir(basePath)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
// Iterate over directory entries
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
// Recursively calculate size of directories
|
||||
dirPath := filepath.Join(basePath, entry.Name())
|
||||
dirSize, err := calculateStorageUsage(dirPath)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
totalSize += dirSize
|
||||
} else {
|
||||
// Calculate size of file
|
||||
_ = filepath.Join(basePath, entry.Name())
|
||||
fileInfo, err := entry.Info()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
totalSize += fileInfo.Size()
|
||||
}
|
||||
}
|
||||
|
||||
return totalSize, nil
|
||||
}
|
||||
|
||||
type File struct {
|
||||
Name string `json:"name"`
|
||||
IsDir bool `json:"is_dir"`
|
||||
Size int64 `json:"size"`
|
||||
LastModified string `json:"last_modified"`
|
||||
}
|
||||
|
||||
func GetFiles(c echo.Context) error {
|
||||
user := c.Get("user").(*models.User)
|
||||
|
||||
fullPath := strings.Trim(c.Param("*"), "/")
|
||||
basePath := fmt.Sprintf("%s/%s/%s/", os.Getenv("STORAGE_PATH"), user.ID, fullPath)
|
||||
|
||||
f, err := os.Open(basePath)
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return err
|
||||
}
|
||||
|
||||
files, err := f.Readdir(0)
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return err
|
||||
}
|
||||
|
||||
jsonFiles := make([]File, 0)
|
||||
|
||||
for _, f := range files {
|
||||
jsonFiles = append(jsonFiles, File{
|
||||
Name: f.Name(),
|
||||
IsDir: f.IsDir(),
|
||||
Size: f.Size(),
|
||||
LastModified: f.ModTime().Format("1/2/2006"),
|
||||
})
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, jsonFiles)
|
||||
}
|
||||
|
||||
func GetUsage(c echo.Context) error {
|
||||
user := c.Get("user").(*models.User)
|
||||
|
||||
fullPath := strings.Trim(c.Param("*"), "/")
|
||||
basePath := fmt.Sprintf("%s/%s/%s/", os.Getenv("STORAGE_PATH"), user.ID, fullPath)
|
||||
storageUsage, err := calculateStorageUsage(basePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, map[string]int64{"usage": storageUsage})
|
||||
}
|
||||
@@ -4,37 +4,47 @@
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--color-base: 250 244 237;
|
||||
--color-surface: 255 250 243;
|
||||
--color-overlay: 242 233 225;
|
||||
--color-muted: 152 147 165;
|
||||
--color-subtle: 121 117 147;
|
||||
--color-text: 87 82 121;
|
||||
--highlight-low: 33 32 46;
|
||||
--highlight-med: 64 61 82;
|
||||
--highlight-high: 82 79 103;
|
||||
--color-base: 239 237 242;
|
||||
--color-surface: 247 246 249;
|
||||
--color-overlay: 242 240 245;
|
||||
--color-muted: 146 142 175;
|
||||
--color-subtle: 139 136 160;
|
||||
--color-text: 14 13 17;
|
||||
--highlight-low: 11 18 22;
|
||||
--highlight-med: 32 37 38;
|
||||
--highlight-high: 49 55 58;
|
||||
--color-foam: 86 148 159;
|
||||
--color-love: 180 99 122;
|
||||
--color-love: 220 100 130;
|
||||
--color-pine: 40 105 131;
|
||||
|
||||
--nav-height: 48px;
|
||||
}
|
||||
}
|
||||
|
||||
.dark {
|
||||
--color-base: 25 23 36;
|
||||
--color-surface: 31 29 46;
|
||||
--color-overlay: 38 35 58;
|
||||
--color-muted: 110 106 134;
|
||||
--color-subtle: 144 140 170;
|
||||
--color-text: 224 222 244;
|
||||
--color-base: 14 13 17;
|
||||
--color-surface: 21 18 28;
|
||||
--color-overlay: 30 26 40;
|
||||
--color-muted: 99 92 117;
|
||||
--color-subtle: 152 146 171;
|
||||
--color-text: 247 246 249;
|
||||
--highlight-low: 244 237 232;
|
||||
--highlight-med: 223 218 217;
|
||||
--highlight-high: 206 202 205;
|
||||
--color-foam: 156 207 216;
|
||||
--color-love: 235 111 146;
|
||||
--color-pine: 49 116 143;
|
||||
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
*, ::before, ::after {
|
||||
border-color: rgb(var(--color-muted) / 0.2);
|
||||
@apply ease-[cubic-bezier(0.25,_1,_0.5,_1)];
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: rgb(var(--color-base));
|
||||
color: rgb(var(--color-text));
|
||||
}
|
||||
|
||||
|
||||
BIN
ui/bun.lockb
BIN
ui/bun.lockb
Binary file not shown.
33
ui/components/Breadcrumbs.vue
Normal file
33
ui/components/Breadcrumbs.vue
Normal file
@@ -0,0 +1,33 @@
|
||||
<script setup lang="js">
|
||||
const props = defineProps({
|
||||
path: {
|
||||
type: String,
|
||||
required: true
|
||||
}
|
||||
});
|
||||
|
||||
const crumbs = computed(() => {
|
||||
const paths = props.path.split("/").filter(x => !!x);
|
||||
return paths.map((crumb, index) => {
|
||||
return {
|
||||
name: crumb,
|
||||
link: "/" + paths.slice(0, index + 1).join("/")
|
||||
};
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-row">
|
||||
<span v-for="(crumb, index) in crumbs" class="flex items-center">
|
||||
<svg v-if="index != 0" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24"
|
||||
class="text-subtle mx-1">
|
||||
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="m9 6l6 6l-6 6" />
|
||||
</svg>
|
||||
<a class="hover:text-text" :class="index === crumbs.length - 1 ? 'text-foam' : 'text-subtle'"
|
||||
:href="crumb.link">{{
|
||||
crumb.name }}</a>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
116
ui/components/FileNav.vue
Normal file
116
ui/components/FileNav.vue
Normal file
@@ -0,0 +1,116 @@
|
||||
<script lang="ts" setup>
|
||||
import { useUser } from '~/composables/useUser'
|
||||
const { getUser } = useUser()
|
||||
|
||||
const props = defineProps({
|
||||
usageBytes: Number
|
||||
})
|
||||
|
||||
const user = await getUser()
|
||||
|
||||
const route = useRoute();
|
||||
|
||||
let capacityBytes = ref(user.plan.max_storage);
|
||||
|
||||
const radius = 13;
|
||||
const circumference = 2 * Math.PI * radius;
|
||||
|
||||
const percentage = computed(() => {
|
||||
return (props.usageBytes / capacityBytes.value);
|
||||
});
|
||||
|
||||
console.log(percentage.value, props.usageBytes, capacityBytes.value)
|
||||
const offset = computed(() => {
|
||||
return circumference - percentage.value * circumference;
|
||||
});
|
||||
const usage = computed(() => {
|
||||
return formatBytes(props.usageBytes)
|
||||
});
|
||||
const capacity = computed(() => {
|
||||
return formatBytes(capacityBytes.value)
|
||||
});
|
||||
|
||||
if (props.usageBytes > capacityBytes.value) {
|
||||
console.log("SCAN SCAN SCAM SCAM")
|
||||
}
|
||||
|
||||
const isAllFilesActive = computed(() => route.path === '/home');
|
||||
|
||||
const isInFolder = computed(() => route.path.startsWith('/home/') && route.path !== '/home');
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside class="h-screen flex flex-col w-56 pt-3 bg-surface border-r">
|
||||
<div class="pl-9 h-14 flex items-center">
|
||||
<h2>Home</h2>
|
||||
</div>
|
||||
<div class="p-4 flex-grow">
|
||||
<ul class="flex flex-col gap-y-2">
|
||||
<li>
|
||||
<NuxtLink to="/home"
|
||||
class="flex py-1.5 px-4 rounded-lg transition-bg duration-300 hover:bg-muted/10"
|
||||
:class="{ 'bg-muted/10': isAllFilesActive }">
|
||||
<svg class="m-0.5 mr-2" xmlns="http://www.w3.org/2000/svg" width="20" height="20"
|
||||
viewBox="0 0 256 256">
|
||||
<g fill="currentColor">
|
||||
<path d="M208 72v112a8 8 0 0 1-8 8h-24v-88l-40-40H80V40a8 8 0 0 1 8-8h80Z"
|
||||
opacity=".2" />
|
||||
<path
|
||||
d="m213.66 66.34l-40-40A8 8 0 0 0 168 24H88a16 16 0 0 0-16 16v16H56a16 16 0 0 0-16 16v144a16 16 0 0 0 16 16h112a16 16 0 0 0 16-16v-16h16a16 16 0 0 0 16-16V72a8 8 0 0 0-2.34-5.66ZM168 216H56V72h76.69L168 107.31V216Zm32-32h-16v-80a8 8 0 0 0-2.34-5.66l-40-40A8 8 0 0 0 136 56H88V40h76.69L200 75.31Zm-56-32a8 8 0 0 1-8 8H88a8 8 0 0 1 0-16h48a8 8 0 0 1 8 8Zm0 32a8 8 0 0 1-8 8H88a8 8 0 0 1 0-16h48a8 8 0 0 1 8 8Z" />
|
||||
</g>
|
||||
</svg>
|
||||
All files
|
||||
</NuxtLink>
|
||||
</li>
|
||||
<li class="flex flex-col">
|
||||
<NuxtLink to="/home/name"
|
||||
class="flex py-1.5 px-4 rounded-lg transition-bg duration-300 hover:bg-muted/10"
|
||||
:class="{ 'bg-muted/10': isInFolder }">
|
||||
<svg v-if="isInFolder" class="m-0.5 mr-2" 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"
|
||||
stroke-width="2"
|
||||
d="m5 19l2.757-7.351A1 1 0 0 1 8.693 11H21a1 1 0 0 1 .986 1.164l-.996 5.211A2 2 0 0 1 19.026 19za2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h4l3 3h7a2 2 0 0 1 2 2v2" />
|
||||
</svg>
|
||||
<svg v-else class="m-0.5 mr-2" 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"
|
||||
stroke-width="2"
|
||||
d="M5 4h4l3 3h7a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2" />
|
||||
</svg>
|
||||
Folders
|
||||
</NuxtLink>
|
||||
<!-- <ul class="flex flex-col gap-y-2 w-4/5 mt-2 ml-auto">
|
||||
<li>
|
||||
<a href="/folder/thing" class="flex py-1.5 px-4 rounded-lg transition-bg duration-300"
|
||||
:class="isActive('/folder/thing') ? 'bg-muted/10' : 'hover:bg-muted/10'">
|
||||
<svg class="m-0.5 mr-2" xmlns="http://www.w3.org/2000/svg" width="20" height="20"
|
||||
viewBox="0 0 256 256">
|
||||
<g fill="currentColor">
|
||||
<path d="M208 72v112a8 8 0 0 1-8 8h-24v-88l-40-40H80V40a8 8 0 0 1 8-8h80Z"
|
||||
opacity=".2" />
|
||||
<path
|
||||
d="m213.66 66.34l-40-40A8 8 0 0 0 168 24H88a16 16 0 0 0-16 16v16H56a16 16 0 0 0-16 16v144a16 16 0 0 0 16 16h112a16 16 0 0 0 16-16v-16h16a16 16 0 0 0 16-16V72a8 8 0 0 0-2.34-5.66ZM168 216H56V72h76.69L168 107.31V216Zm32-32h-16v-80a8 8 0 0 0-2.34-5.66l-40-40A8 8 0 0 0 136 56H88V40h76.69L200 75.31Zm-56-32a8 8 0 0 1-8 8H88a8 8 0 0 1 0-16h48a8 8 0 0 1 8 8Zm0 32a8 8 0 0 1-8 8H88a8 8 0 0 1 0-16h48a8 8 0 0 1 8 8Z" />
|
||||
</g>
|
||||
</svg>
|
||||
All files
|
||||
</a>
|
||||
</li>
|
||||
</ul> -->
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="m-2 w-[calc(100%-16px)]">
|
||||
<div class="p-3 bg-overlay border rounded-lg flex items-end">
|
||||
<svg width="32" height="32" class="-rotate-90 mr-2" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- Background Track -->
|
||||
<circle class="stroke-foam/20" cx="16" cy="16" :r="radius" fill="none" stroke-width="3" />
|
||||
<!-- Progress Track -->
|
||||
<circle class="stroke-foam" cx="16" cy="16" :r="radius" fill="none" stroke-width="3"
|
||||
:stroke-dasharray="circumference" :stroke-dashoffset="offset" stroke-linecap="round" />
|
||||
</svg>
|
||||
<p class="text-sm h-min"> {{ usage }} of {{ capacity }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
13
ui/components/Footer.vue
Normal file
13
ui/components/Footer.vue
Normal file
@@ -0,0 +1,13 @@
|
||||
<script lang="ts" setup>
|
||||
let year = new Date().getFullYear()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<footer class="flex flex-col gap-2 sm:flex-row py-6 w-full shrink-0 items-center px-4 md:px-6 border-t">
|
||||
<div class="flex flex-row gap-x-2">
|
||||
<p>Privacy policy</p>
|
||||
<p>Other policy</p>
|
||||
</div>
|
||||
<p class="text-xs text-subtle ml-auto">© {{ year }} juls0730. All rights reserved.</p>
|
||||
</footer>
|
||||
</template>
|
||||
@@ -12,6 +12,6 @@ function updateValue(value) {
|
||||
|
||||
<template>
|
||||
<input
|
||||
class="py-2 px-4 resize-none bg-overlay rounded-md my-2 border border-muted/20 hover:border-muted/40 focus:border-muted/60 placeholder:italic placeholder:text-subtle transition-[border-color] max-w-64"
|
||||
class="py-2 px-4 resize-none bg-overlay rounded-md my-2 border hover:border-muted/40 focus:border-muted/60 placeholder:italic placeholder:text-subtle transition-[border-color] max-w-64"
|
||||
:placeholder="placeholder" :type="type" v-on:input="updateValue($event.target.value)" />
|
||||
</template>
|
||||
@@ -1,2 +1,69 @@
|
||||
<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;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header class="flex h-[var(--nav-height)] px-4 justify-center sticky top-0 z-50 border-b bg-base">
|
||||
<div class="flex w-full items-center justify-between space-x-2.5">
|
||||
<p
|
||||
class="-ml-2.5 flex shrink-0 items-center px-2.5 py-1.5 focus:outline-none focus:ring rounded-m font-semiboldd">
|
||||
filething
|
||||
</p>
|
||||
</div>
|
||||
<nav class="hidden md:flex" aria-label="Main">
|
||||
<ul class="flex items-center gap-3" role="list">
|
||||
<li>
|
||||
<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>
|
||||
</li>
|
||||
<li>
|
||||
<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>
|
||||
</li>
|
||||
<li>
|
||||
<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>
|
||||
</li>
|
||||
<li class="h-6 border-r"></li>
|
||||
<li>
|
||||
<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"
|
||||
@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>
|
||||
</nav>
|
||||
</header>
|
||||
</template>
|
||||
239
ui/components/UploadPane.vue
Normal file
239
ui/components/UploadPane.vue
Normal file
@@ -0,0 +1,239 @@
|
||||
<script setup lang="ts">
|
||||
import { formatBytes } from '~/utils/formatBytes';
|
||||
import type { FileUpload } from '~/types/user';
|
||||
|
||||
const props = defineProps({
|
||||
uploadingFiles: {
|
||||
type: Array<FileUpload>,
|
||||
required: true
|
||||
},
|
||||
closed: Boolean,
|
||||
})
|
||||
defineEmits(['update:closed'])
|
||||
|
||||
const abortUpload = (id: string) => {
|
||||
let file = props.uploadingFiles.find(upload => upload.id === id);
|
||||
if (!file) {
|
||||
throw new Error("Upload cannot be aborted file is missing!")
|
||||
}
|
||||
|
||||
const controller = file.controller;
|
||||
if (controller) {
|
||||
controller.abort();
|
||||
}
|
||||
};
|
||||
|
||||
const formatRemainingTime = (seconds: number): string => {
|
||||
if (seconds < 60) {
|
||||
return `${Math.floor(seconds)} second${Math.floor(seconds) === 1 ? '' : 's'} left`;
|
||||
}
|
||||
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
if (minutes < 60) {
|
||||
return `${minutes} minute${minutes === 1 ? '' : 's'} left`;
|
||||
}
|
||||
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) {
|
||||
return `${hours} hour${hours === 1 ? '' : 's'} left`;
|
||||
}
|
||||
|
||||
const days = Math.floor(hours / 24);
|
||||
return `${days} day${days === 1 ? '' : 's'} left`;
|
||||
};
|
||||
|
||||
const truncateFilenameToFitWidth = (filename: string, maxWidthPx: number, font = '18px ui-sans-serif,system-ui,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji') => {
|
||||
const canvas = document.createElement('canvas');
|
||||
const context = canvas.getContext('2d');
|
||||
context.font = font;
|
||||
|
||||
const name = filename.substring(0, filename.lastIndexOf('.'));
|
||||
const extension = filename.substring(filename.lastIndexOf('.'));
|
||||
|
||||
function getTextWidth(text) {
|
||||
return context.measureText(text).width;
|
||||
}
|
||||
|
||||
if (getTextWidth(filename) <= maxWidthPx) {
|
||||
return filename;
|
||||
}
|
||||
|
||||
let truncatedName = name;
|
||||
let charsToRemove = 4;
|
||||
while (getTextWidth(truncatedName + extension) > maxWidthPx && truncatedName.length > charsToRemove) {
|
||||
const start = Math.ceil((truncatedName.length - charsToRemove) / 2);
|
||||
const end = Math.floor((truncatedName.length + charsToRemove) / 2);
|
||||
|
||||
truncatedName = truncatedName.substring(0, start) + '...' + truncatedName.substring(end);
|
||||
charsToRemove++;
|
||||
}
|
||||
|
||||
canvas.remove()
|
||||
|
||||
return truncatedName + extension;
|
||||
}
|
||||
|
||||
let collapsed = ref(false);
|
||||
let closeable = computed(() => props.uploadingFiles.filter(x => x.uploading === true).length === 0);
|
||||
let overallRemaining = computed(() => {
|
||||
if (closeable.value) {
|
||||
return
|
||||
}
|
||||
const uploadingFiles = props.uploadingFiles.filter(x => x.uploading === true);
|
||||
|
||||
return uploadingFiles.reduce((max, item) => item.remainingTime > max.remainingTime ? item : max).remainingTime
|
||||
});
|
||||
let overallPercentage = computed(() => {
|
||||
const uploadingFiles = props.uploadingFiles.filter(x => x.uploading === true);
|
||||
|
||||
const totalLoaded = uploadingFiles.reduce((acc, file) => acc + file.length.loaded, 0);
|
||||
const totalSize = uploadingFiles.reduce((acc, file) => acc + file.length.total, 0);
|
||||
|
||||
if (totalSize === 0) return 0; // Avoid division by zero
|
||||
|
||||
return (totalLoaded / totalSize) * 100; // Return percentage
|
||||
})
|
||||
|
||||
let uploadedSuccessfully = computed(() => props.uploadingFiles.filter(x => x.status.error === false));
|
||||
let uploadFailed = computed(() => props.uploadingFiles.filter(x => x.status.error === true));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="absolute bottom-0 right-0 m-3 rounded-2xl border flex flex-col sm:w-[440px] w-[calc(100%-24px)] shadow-md bg-surface"
|
||||
:class="{ 'h-[510px]': !collapsed, 'hidden': closed }">
|
||||
<div class="flex flex-row justify-between h-14 items-center mb-3 px-4" :class="{ 'hidden': collapsed }">
|
||||
<h3 class="text-xl font-semibold">Upload</h3>
|
||||
<div class="flex flex-row gap-x-2">
|
||||
<button v-on:click="collapsed = !collapsed"
|
||||
class="p-1 border h-fit rounded-md hover:bg-muted/10 active:bg-muted/20 transition-bg">
|
||||
<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"
|
||||
stroke-width="2" d="m6 9l6 6l6-6" />
|
||||
</svg>
|
||||
</button>
|
||||
<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">
|
||||
<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"
|
||||
stroke-width="2" d="M18 6L6 18M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-grow px-4 overflow-y-auto max-h-[358px]" :class="{ 'hidden': collapsed }">
|
||||
<div v-for="(upload, index) in uploadingFiles" :key="index" :id="`file-upload-${upload.id}`">
|
||||
<div class="flex flex-row gap-x-2 py-2 w-full">
|
||||
<div>
|
||||
<svg v-if="upload.uploading" xmlns="http://www.w3.org/2000/svg" width="20" height="20"
|
||||
viewBox="0 0 24 24">
|
||||
<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"
|
||||
stroke-width="2">
|
||||
<path d="M7 18a4.6 4.4 0 0 1 0-9a5 4.5 0 0 1 11 2h1a3.5 3.5 0 0 1 0 7h-1" />
|
||||
<path d="m9 15l3-3l3 3m-3-3v9" />
|
||||
</g>
|
||||
</svg>
|
||||
<svg v-else-if="upload.status.aborted" 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"
|
||||
stroke-width="2"
|
||||
d="M13 18.004H6.657C4.085 18 2 15.993 2 13.517s2.085-4.482 4.657-4.482c.393-1.762 1.794-3.2 3.675-3.773c1.88-.572 3.956-.193 5.444 1c1.488 1.19 2.162 3.007 1.77 4.769h.99c1.37 0 2.556.8 3.117 1.964M22 22l-5-5m0 5l5-5" />
|
||||
</svg>
|
||||
<svg v-else-if="upload.status.error" 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"
|
||||
stroke-width="2"
|
||||
d="M15 18.004H6.657C4.085 18 2 15.993 2 13.517s2.085-4.482 4.657-4.482c.393-1.762 1.794-3.2 3.675-3.773c1.88-.572 3.956-.193 5.444 1c1.488 1.19 2.162 3.007 1.77 4.769h.99c1.374 0 2.562.805 3.121 1.972M19 16v3m0 3v.01" />
|
||||
</svg>
|
||||
<svg v-else 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"
|
||||
stroke-width="2"
|
||||
d="M11 18.004H6.657C4.085 18 2 15.993 2 13.517s2.085-4.482 4.657-4.482c.393-1.762 1.794-3.2 3.675-3.773c1.88-.572 3.956-.193 5.444 1c1.488 1.19 2.162 3.007 1.77 4.769h.99c1.388 0 2.585.82 3.138 2.007M15 19l2 2l4-4" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="px-2 flex-grow">
|
||||
<div class="flex flex-col">
|
||||
<span
|
||||
class="font-medium overflow-hidden overflow-ellipsis whitespace-nowrap inline-block max-w-[220px]">{{
|
||||
truncateFilenameToFitWidth(upload.file.name, 220) }}</span>
|
||||
<div class="flex flex-row">
|
||||
<div
|
||||
class="font-medium uppercase rounded-full bg-overlay text-[10px] px-2 py-0.5 -ml-1 mr-2 w-fit max-w-20 overflow-hidden overflow-ellipsis max-h-[19px] whitespace-nowrap inline-block">
|
||||
{{ upload.file.name.split(".")[upload.file.name.split(".").length - 1] }}
|
||||
</div>
|
||||
<div class="flex text-[10px] items-end text-subtle">
|
||||
<span
|
||||
class="h-min overflow-hidden overflow-ellipsis max-w-56 whitespace-nowrap inline-block"
|
||||
v-if="upload.uploading">
|
||||
Uploading - {{ formatBytes(upload.length.loaded, 1) }} / {{
|
||||
formatBytes(upload.length.total, 1) }} - {{
|
||||
formatRemainingTime(upload.remainingTime) }}
|
||||
</span>
|
||||
<span v-else-if="upload.status.code >= 200 && upload.status.code < 300"
|
||||
class="h-min overflow-hidden overflow-ellipsis max-w-56 whitespace-nowrap inline-block">
|
||||
Uploaded to upload path
|
||||
</span>
|
||||
<span
|
||||
class="h-min overflow-hidden overflow-ellipsis max-w-56 whitespace-nowrap inline-block"
|
||||
v-else-if="upload.status.aborted">
|
||||
Canceled
|
||||
</span>
|
||||
<span
|
||||
class="h-min overflow-hidden overflow-ellipsis max-w-56 whitespace-nowrap inline-block"
|
||||
v-else-if="upload.status.error">
|
||||
{{ upload.status.message }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center" v-if="upload.uploading">
|
||||
<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">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="upload.length.loaded !== undefined && upload.status.code === undefined"
|
||||
class="w-full rounded-full h-1 bg-foam/20 relative -mt-1">
|
||||
<div class="bg-foam rounded-full absolute left-0 top-0 bottom-0 transition-[width]"
|
||||
:style="'width: ' + Math.round((upload.length.loaded / upload.length.total) * 100) + '%;'">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="m-3 rounded-md bg-overlay border bottom-2 flex flex-row">
|
||||
<div class="flex p-3 w-fit rounded-md">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24">
|
||||
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"
|
||||
stroke-width="2" d="M4 17v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-2M7 9l5-5l5 5m-5-5v12" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex flex-row flex-grow items-center">
|
||||
<div v-if="!closeable" class="p-2 flex flex-col flex-grow relative">
|
||||
<span class="font-medium font-pine">Uploading Files</span>
|
||||
<span class="text-xs items-end text-subtle" v-if="overallRemaining">{{
|
||||
formatRemainingTime(overallRemaining) }}</span>
|
||||
<div class="bg-pine/25 absolute left-0 bottom-0 top-0"
|
||||
:style="'width: ' + overallPercentage + '%;'">
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="uploadFailed.length === 0" class="p-2 flex flex-col flex-grow">
|
||||
<span class="font-medium">Successfully Uploaded all files</span>
|
||||
</div>
|
||||
<div v-else-if="uploadedSuccessfully.length === 0" class="p-2 flex flex-col flex-grow">
|
||||
<span class="font-medium">Failed to Uploaded all files</span>
|
||||
</div>
|
||||
<div v-else class="p-2 flex flex-col flex-grow">
|
||||
<span class="font-medium">Successfully Uploaded some files</span>
|
||||
</div>
|
||||
<button v-if="collapsed" v-on:click="collapsed = !collapsed"
|
||||
class="p-1 border h-fit rounded-md hover:bg-muted/10 active:bg-muted/20 transition-bg mr-4">
|
||||
<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"
|
||||
stroke-width="2" d="m6 15l6-6l6 6" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
49
ui/composables/useUser.ts
Normal file
49
ui/composables/useUser.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import type { User } from '~/types/user'
|
||||
import { useFetch } from '#app'
|
||||
|
||||
export const useUser = () => {
|
||||
// Global state for storing the user
|
||||
const user = useState('user', () => { return { fetched: false, user: <User>{} } })
|
||||
|
||||
// Fetch the user only if it's uninitialized (i.e., null)
|
||||
const getUser = async () => {
|
||||
if (!user.value.fetched && import.meta.client) {
|
||||
await fetchUser()
|
||||
}
|
||||
|
||||
return user.value.user
|
||||
}
|
||||
|
||||
const fetchUser = async () => {
|
||||
try {
|
||||
const { data, error } = await useFetch<User, { message: string }>('/api/user')
|
||||
user.value.fetched = true
|
||||
|
||||
if (error.value || !data.value) {
|
||||
throw new Error('Failed to fetch user')
|
||||
}
|
||||
|
||||
user.value.user = data.value
|
||||
} catch (e) {
|
||||
console.error(e.message)
|
||||
user.value.user = {}
|
||||
}
|
||||
}
|
||||
|
||||
// Manually set the user (e.g., after login/signup)
|
||||
const setUser = (userData: User) => {
|
||||
user.value.user = userData
|
||||
}
|
||||
|
||||
// Clear the user data (e.g., on logout)
|
||||
const resetUser = () => {
|
||||
user.value.user = {}
|
||||
}
|
||||
|
||||
return {
|
||||
getUser,
|
||||
setUser,
|
||||
resetUser,
|
||||
fetchUser,
|
||||
}
|
||||
}
|
||||
15
ui/middleware/auth.ts
Normal file
15
ui/middleware/auth.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.id) {
|
||||
return navigateTo('/login')
|
||||
}
|
||||
})
|
||||
15
ui/middleware/unauth.ts
Normal file
15
ui/middleware/unauth.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.id) {
|
||||
return navigateTo('/home')
|
||||
}
|
||||
})
|
||||
@@ -1,12 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<Nav />
|
||||
WOAH IS THAT FRICKING SCOTT THE WOZ DUDE
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
|
||||
</script>
|
||||
|
||||
<style></style>
|
||||
266
ui/pages/home/[...name].vue
Normal file
266
ui/pages/home/[...name].vue
Normal file
@@ -0,0 +1,266 @@
|
||||
<script lang="ts" setup>
|
||||
import { useUser } from '~/composables/useUser'
|
||||
import type { File } from '~/types/file';
|
||||
import type { FileUpload } from '~/types/user';
|
||||
const { getUser } = useUser()
|
||||
|
||||
definePageMeta({
|
||||
middleware: "auth"
|
||||
});
|
||||
|
||||
const user = await getUser()
|
||||
|
||||
let { data: usageBytes } = await useFetch<{ usage: number }>('/api/user/usage')
|
||||
let { data: files } = await useFetch<[File]>('/api/files')
|
||||
|
||||
const route = useRoute();
|
||||
let folder = ref("");
|
||||
let uploadPaneClosed = ref(true);
|
||||
|
||||
if (typeof route.params.name == "object") {
|
||||
folder.value = route.params.name.join("/");
|
||||
}
|
||||
|
||||
let recentFiles = ref([]);
|
||||
|
||||
const fileInput: Ref<HTMLInputElement | null> = ref(null);
|
||||
|
||||
const uploadingFiles: Ref<Array<FileUpload>> = ref([]);
|
||||
|
||||
const handleFileChange = (event: Event) => {
|
||||
const files = (<HTMLInputElement>event.target).files;
|
||||
if (!files) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
uploadFile(files[i])
|
||||
}
|
||||
|
||||
if (!fileInput.value) {
|
||||
return
|
||||
}
|
||||
|
||||
if (fileInput.value.files.length > 0) {
|
||||
fileInput.value.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
const uploadFile = (file: File) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
const startTime = Date.now();
|
||||
let id = `${file.name}-${Math.floor(Math.random() * 1000)}`;
|
||||
|
||||
let uploading_file: FileUpload = {
|
||||
id,
|
||||
uploading: true,
|
||||
controller: xhr,
|
||||
startTime,
|
||||
file: file,
|
||||
length: {},
|
||||
status: {}
|
||||
}
|
||||
|
||||
uploadingFiles.value.push(uploading_file)
|
||||
|
||||
if (uploadPaneClosed.value === true) {
|
||||
uploadPaneClosed.value = false;
|
||||
}
|
||||
|
||||
xhr.open('POST', '/api/upload', true);
|
||||
|
||||
xhr.upload.onprogress = (event) => {
|
||||
if (event.lengthComputable) {
|
||||
let file = uploadingFiles.value.find(upload => upload.id === id);
|
||||
if (!file) {
|
||||
throw new Error("Upload is progressing but file is missing!")
|
||||
}
|
||||
|
||||
|
||||
const currentTime = Date.now();
|
||||
const timeElapsed = (currentTime - file.startTime) / 1000;
|
||||
|
||||
file.length = { loaded: event.loaded, total: event.total };
|
||||
|
||||
const uploadedBytes = event.loaded;
|
||||
const totalBytes = event.total;
|
||||
const uploadSpeed = uploadedBytes / timeElapsed;
|
||||
const remainingBytes = totalBytes - uploadedBytes;
|
||||
const remainingTime = remainingBytes / uploadSpeed;
|
||||
|
||||
file.speed = uploadSpeed;
|
||||
file.remainingTime = remainingTime;
|
||||
}
|
||||
};
|
||||
|
||||
xhr.onload = () => {
|
||||
let data = JSON.parse(xhr.response)
|
||||
usageBytes.value.usage = data.usage
|
||||
files.value?.push(data.file)
|
||||
|
||||
let file = uploadingFiles.value.find(upload => upload.id === id);
|
||||
if (!file) {
|
||||
throw new Error("Upload has finished but file is missing!")
|
||||
}
|
||||
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
file.uploading = false;
|
||||
|
||||
file.status = {
|
||||
error: false,
|
||||
aborted: false,
|
||||
code: xhr.status,
|
||||
message: xhr.statusText
|
||||
};
|
||||
} else {
|
||||
file.uploading = false;
|
||||
|
||||
file.status = {
|
||||
error: true,
|
||||
aborted: false,
|
||||
code: xhr.status,
|
||||
message: xhr.statusText
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
xhr.onerror = () => {
|
||||
let file = uploadingFiles.value.find(upload => upload.id === id);
|
||||
if (!file) {
|
||||
throw new Error("Upload has errored but file is missing!")
|
||||
}
|
||||
|
||||
file.uploading = false;
|
||||
|
||||
file.status = {
|
||||
error: true,
|
||||
aborted: false,
|
||||
code: xhr.status,
|
||||
message: xhr.statusText
|
||||
};
|
||||
};
|
||||
|
||||
xhr.onabort = () => {
|
||||
let file = uploadingFiles.value.find(upload => upload.id === id);
|
||||
if (!file) {
|
||||
throw new Error("Upload has been aborted but file is missing!")
|
||||
}
|
||||
|
||||
file.uploading = false;
|
||||
|
||||
file.status = {
|
||||
error: true,
|
||||
aborted: true,
|
||||
code: 0,
|
||||
message: "aborted"
|
||||
};
|
||||
};
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
xhr.send(formData);
|
||||
};
|
||||
|
||||
const openFilePicker = () => {
|
||||
fileInput.value?.click();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex relative min-h-[100dvh]">
|
||||
<div class="fixed md:relative -translate-x-full md:translate-x-0">
|
||||
<FileNav :usageBytes="usageBytes?.usage" />
|
||||
</div>
|
||||
<UploadPane :closed="uploadPaneClosed" v-on:update:closed="(newValue) => uploadPaneClosed = newValue"
|
||||
:uploadingFiles="uploadingFiles" />
|
||||
<div class="w-full">
|
||||
<Nav />
|
||||
<div class="pt-6 pl-12 overflow-auto max-h-[calc(100vh-var(--nav-height))]">
|
||||
<div class="flex gap-x-4 flex-col">
|
||||
<div class="py-5 flex flex-row gap-x-4">
|
||||
<input type="file" ref="fileInput" @change="handleFileChange" multiple class="hidden" />
|
||||
<button v-on:click="openFilePicker"
|
||||
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">
|
||||
<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"
|
||||
stroke-width="2">
|
||||
<path d="M7 18a4.6 4.4 0 0 1 0-9a5 4.5 0 0 1 11 2h1a3.5 3.5 0 0 1 0 7h-1" />
|
||||
<path d="m9 15l3-3l3 3m-3-3v9" />
|
||||
</g>
|
||||
</svg>
|
||||
Upload
|
||||
</button>
|
||||
<button
|
||||
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">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
|
||||
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 19H5a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h4l3 3h7a2 2 0 0 1 2 2v3.5M16 19h6m-3-3v6" />
|
||||
</svg>
|
||||
New folder
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="recentFiles.length > 0">
|
||||
<h2 class="font-semibold text-2xl">Recent</h2>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-semibold text-xl">
|
||||
<Breadcrumbs :path="route.path" />
|
||||
</h3>
|
||||
<table class="w-full text-sm mt-2">
|
||||
<thead class="border-b">
|
||||
<tr class="flex flex-row h-10 group pl-[30px] -ml-7 relative items-center">
|
||||
<th class="left-0 absolute">
|
||||
<div>
|
||||
<input class="w-4 h-4 hidden group-hover:block" type="checkbox" />
|
||||
</div>
|
||||
</th>
|
||||
<th class="flex-grow text-start">
|
||||
Name
|
||||
</th>
|
||||
<th class="min-w-40 text-start">
|
||||
Size
|
||||
</th>
|
||||
<th class="min-w-40 text-start sm:block hidden">
|
||||
Last modified
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="block">
|
||||
<tr class="flex flex-row h-10 group items-center border-b hover:bg-muted/10 transition-bg"
|
||||
v-for="file in files">
|
||||
<td class="-ml-7 pr-3.5">
|
||||
<div class="w-4 h-4">
|
||||
<input class="w-4 h-4 hidden group-hover:block" type="checkbox" />
|
||||
</div>
|
||||
</td>
|
||||
<td class="flex-grow text-start">
|
||||
<div class="flex items-center">
|
||||
<svg class="mr-2" xmlns="http://www.w3.org/2000/svg" width="16" height="16"
|
||||
viewBox="0 0 24 24">
|
||||
<g fill="none" stroke="currentColor" stroke-linecap="round"
|
||||
stroke-linejoin="round" stroke-width="2">
|
||||
<path d="M14 3v4a1 1 0 0 0 1 1h4" />
|
||||
<path
|
||||
d="M17 21H7a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h7l5 5v11a2 2 0 0 1-2 2M9 9h1m-1 4h6m-6 4h6" />
|
||||
</g>
|
||||
</svg>
|
||||
{{ file.name }}
|
||||
</div>
|
||||
</td>
|
||||
<td class="min-w-40 text-start">
|
||||
{{ formatBytes(file.size) }}
|
||||
</td>
|
||||
<td class="min-w-40 text-start sm:block hidden">
|
||||
{{ file.last_modified }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,10 +1,193 @@
|
||||
<script lang="ts" setup>
|
||||
definePageMeta({
|
||||
middleware: "unauth"
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
OMG IS THAT SCOTT THE WOZ?!?!?!?!?!?!??!?!??!?!?!?!?!?!?
|
||||
<div class="flex flex-col min-h-[100dvh]">
|
||||
<Nav />
|
||||
<main class="flex-1">
|
||||
<section
|
||||
class="flex justify-center w-full py-12 md:py-24 lg:py-32 xl:py-48 min-h-[calc(100dvh-var(--nav-height))]">
|
||||
<div class="container px-4 md:px-6 h-fit">
|
||||
<div class="grid gap-6 lg:grid-cols-[1fr_400px] lg:gap-12 xl:grid-cols-[1fr_600px]">
|
||||
<img src="/placeholder.svg" width="550" height="310" alt="Hero"
|
||||
class="mx-auto aspect-video overflow-hidden rounded-xl object-cover sm:w-full lg:order-last" />
|
||||
<div class="flex flex-col justify-center space-y-4">
|
||||
<div class="space-y-2">
|
||||
<h1 class="text-3xl font-bold tracking-tight sm:text-5xl xl:text-6xl/none">
|
||||
Effortless Open Source File Hosting
|
||||
</h1>
|
||||
<p class="max-w-[600px] text-subtle md:text-xl">
|
||||
Store, share, and access your files from anywhere with our powerful file hosting
|
||||
platform.
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2 min-[400px]:flex-row">
|
||||
<NuxtLink to="/signup"
|
||||
class="inline-flex h-10 items-center justify-center rounded-md boder px-8 text-sm font-medium transition-bg border-love/40 bg-love/10 text-love hover:bg-love/15 active:bg-love/25 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring">
|
||||
Sign Up
|
||||
</NuxtLink>
|
||||
<NuxtLink to="/login"
|
||||
class="inline-flex h-10 items-center justify-center rounded-md border px-8 text-sm font-medium transition-bg hover:bg-muted/10 active:bg-muted/15 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring">
|
||||
Log In
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="flex justify-center w-full py-12 md:py-24 lg:py-32">
|
||||
<div class="container px-4 md:px-6">
|
||||
<div class="flex flex-col items-center justify-center space-y-4 text-center">
|
||||
<div class="space-y-2">
|
||||
<div class="inline-block rounded-lg bg-subtle/20 px-3 py-1 text-sm">
|
||||
Secure File Hosting
|
||||
</div>
|
||||
<h2 class="text-3xl font-bold tracking-tighter sm:text-5xl">Store Your Files Safely</h2>
|
||||
<p
|
||||
class="max-w-[900px] text-muted-foreground md:text-xl/relaxed lg:text-base/relaxed xl:text-xl/relaxed">
|
||||
Our platform uses industry-leading encryption to keep your files secure and protected.
|
||||
Access your
|
||||
data from any device, anywhere.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mx-auto grid max-w-5xl items-center gap-6 py-12 lg:grid-cols-2 lg:gap-12">
|
||||
<img src="/placeholder.svg" width="550" height="310" alt="Image"
|
||||
class="mx-auto aspect-video overflow-hidden rounded-xl object-cover object-center sm:w-full lg:order-last" />
|
||||
<div class="flex flex-col justify-center space-y-4">
|
||||
<ul class="grid gap-6">
|
||||
<li>
|
||||
<div class="grid gap-1">
|
||||
<h3 class="text-xl font-bold">Secure Storage</h3>
|
||||
<p class="text-muted-foreground">
|
||||
Your files are encrypted and stored in a secure environment.
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
<li>
|
||||
<div class="grid gap-1">
|
||||
<h3 class="text-xl font-bold">Easy Sharing</h3>
|
||||
<p class="text-muted-foreground">
|
||||
Share files with your team or clients with just a few clicks.
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
<li>
|
||||
<div class="grid gap-1">
|
||||
<h3 class="text-xl font-bold">Cross-Device Access</h3>
|
||||
<p class="text-muted-foreground">Access your files from any device, anywhere
|
||||
in the world.</p>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="flex justify-center w-full py-12 md:py-24 lg:py-32 bg-subtle/10">
|
||||
<div class="container px-4 md:px-6">
|
||||
<div class="flex flex-col items-center justify-center space-y-4 text-center">
|
||||
<div class="space-y-2">
|
||||
<div class="inline-block rounded-lg bg-subtle/20 px-3 py-1 text-sm">
|
||||
File Management
|
||||
</div>
|
||||
<h2 class="text-3xl font-bold tracking-tighter sm:text-5xl">Effortless File Management</h2>
|
||||
<p
|
||||
class="max-w-[900px] text-muted-foreground md:text-xl/relaxed lg:text-base/relaxed xl:text-xl/relaxed">
|
||||
Our intuitive file browser makes it easy to upload, download, and manage your files.
|
||||
Organize your data with folders and tags, and share files with your team or clients.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mx-auto grid max-w-5xl items-center gap-6 py-12 lg:grid-cols-2 lg:gap-12">
|
||||
<img src="/placeholder.svg" width="550" height="310" alt="Image"
|
||||
class="mx-auto aspect-video overflow-hidden rounded-xl object-cover object-center sm:w-full lg:order-last" />
|
||||
<div class="flex flex-col justify-center space-y-4">
|
||||
<ul class="grid gap-6">
|
||||
<li>
|
||||
<div class="grid gap-1">
|
||||
<h3 class="text-xl font-bold">Intuitive File Browser</h3>
|
||||
<p class="text-muted-foreground">
|
||||
Easily navigate and manage your files with our user-friendly interface.
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
<li>
|
||||
<div class="grid gap-1">
|
||||
<h3 class="text-xl font-bold">Folder Organization</h3>
|
||||
<p class="text-muted-foreground">
|
||||
Create and organize your files into folders for better structure.
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
<li>
|
||||
<div class="grid gap-1">
|
||||
<h3 class="text-xl font-bold">Sharing and Collaboration</h3>
|
||||
<p class="text-muted-foreground">
|
||||
Easily share files with your team or clients
|
||||
and collaborate on projects.
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="flex justify-center w-full py-12 md:py-24 lg:py-32">
|
||||
<div class="container px-4 md:px-6">
|
||||
<div class="flex flex-col items-center justify-center space-y-4 text-center">
|
||||
<div class="space-y-2">
|
||||
<div class="inline-block rounded-lg bg-subtle/20 px-3 py-1 text-sm">
|
||||
Storage Plans
|
||||
</div>
|
||||
<h2 class="text-3xl font-bold tracking-tighter sm:text-5xl">Store 10GB For Free</h2>
|
||||
<p
|
||||
class="max-w-[900px] text-muted-foreground md:text-xl/relaxed lg:text-base/relaxed xl:text-xl/relaxed">
|
||||
Our storage plans give you flexibility to store whatever you need, whether it's
|
||||
presentations and documents, or pictures and vides, there's a plan for you.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mx-auto grid max-w-5xl items-center gap-6 py-12 lg:grid-cols-2 lg:gap-12">
|
||||
<img src="/placeholder.svg" width="550" height="310" alt="Image"
|
||||
class="mx-auto aspect-video overflow-hidden rounded-xl object-cover object-center sm:w-full lg:order-last" />
|
||||
<div class="flex flex-col justify-center space-y-4">
|
||||
<ul class="grid gap-6">
|
||||
<li>
|
||||
<div class="grid gap-1">
|
||||
<h3 class="text-xl font-bold">Lorem Ipsum</h3>
|
||||
<p class="text-muted-foreground">
|
||||
Lorem ipsum amet dolar sit.
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
<li>
|
||||
<div class="grid gap-1">
|
||||
<h3 class="text-xl font-bold">Lorem Ipsum</h3>
|
||||
<p class="text-muted-foreground">
|
||||
Lorem ipsum amet dolar sit.
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
<li>
|
||||
<div class="grid gap-1">
|
||||
<h3 class="text-xl font-bold">Lorem Ipsum</h3>
|
||||
<p class="text-muted-foreground">Lorem ipsum amet dolar sit.</p>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
<Footer />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
</script>
|
||||
|
||||
<style></style>
|
||||
@@ -1,9 +1,10 @@
|
||||
<script setup>
|
||||
console.log(useCookie("sessionToken").value)
|
||||
<script lang="ts" setup>
|
||||
import type { User } from '~/types/user';
|
||||
const { fetchUser } = useUser()
|
||||
|
||||
if (useCookie("sessionToken").value) {
|
||||
await navigateTo('/')
|
||||
}
|
||||
definePageMeta({
|
||||
middleware: "unauth"
|
||||
});
|
||||
|
||||
let username_or_email = ref('')
|
||||
let password = ref('')
|
||||
@@ -11,7 +12,7 @@ let password = ref('')
|
||||
let error = ref('')
|
||||
|
||||
const submitForm = async () => {
|
||||
let response = await useFetch('/api/login', {
|
||||
let response = await useFetch<User>('/api/login', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
"username_or_email": username_or_email.value,
|
||||
@@ -24,15 +25,15 @@ const submitForm = async () => {
|
||||
error.value = response.error.value.data.message
|
||||
setTimeout(() => error.value = "", 15000)
|
||||
} else {
|
||||
await navigateTo('/')
|
||||
await fetchUser()
|
||||
await navigateTo('/home')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-screen min-w-screen grid place-content-center bg-base">
|
||||
<div
|
||||
class="flex flex-col text-center bg-surface border border-muted/20 shadow-md px-10 py-8 rounded-2xl min-w-0 max-w-[313px]">
|
||||
<div class="flex flex-col text-center bg-surface border shadow-md px-10 py-8 rounded-2xl min-w-0 max-w-[313px]">
|
||||
<h2 class="font-semibold text-2xl mb-2">Login</h2>
|
||||
<Input v-model="username_or_email" placeholder="Username or Email..." />
|
||||
<Input v-model="password" type="password" placeholder="Password..." />
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
<script setup>
|
||||
console.log(useCookie("sessionToken").value)
|
||||
<script lang="ts" setup>
|
||||
import type { User } from '~/types/user'
|
||||
const { fetchUser } = useUser()
|
||||
|
||||
if (useCookie("sessionToken").value) {
|
||||
await navigateTo('/')
|
||||
}
|
||||
definePageMeta({
|
||||
middleware: "unauth"
|
||||
});
|
||||
|
||||
let username = ref('')
|
||||
let email = ref('')
|
||||
@@ -12,7 +13,7 @@ let password = ref('')
|
||||
let error = ref('')
|
||||
|
||||
const submitForm = async () => {
|
||||
const response = await useFetch('/api/signup', {
|
||||
const response = await useFetch<User>('/api/signup', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
"username": username.value,
|
||||
@@ -26,7 +27,8 @@ const submitForm = async () => {
|
||||
error.value = response.error.value.data.message
|
||||
setTimeout(() => error.value = "", 15000)
|
||||
} else {
|
||||
await navigateTo('/')
|
||||
await fetchUser()
|
||||
await navigateTo('/home')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
1
ui/public/placeholder.svg
Normal file
1
ui/public/placeholder.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="1200" fill="none"><rect width="1200" height="1200" fill="#EAEAEA" rx="3"/><g opacity=".5"><g opacity=".5"><path fill="#FAFAFA" d="M600.709 736.5c-75.454 0-136.621-61.167-136.621-136.62 0-75.454 61.167-136.621 136.621-136.621 75.453 0 136.62 61.167 136.62 136.621 0 75.453-61.167 136.62-136.62 136.62Z"/><path stroke="#C9C9C9" stroke-width="2.418" d="M600.709 736.5c-75.454 0-136.621-61.167-136.621-136.62 0-75.454 61.167-136.621 136.621-136.621 75.453 0 136.62 61.167 136.62 136.621 0 75.453-61.167 136.62-136.62 136.62Z"/></g><path stroke="url(#a)" stroke-width="2.418" d="M0-1.209h553.581" transform="scale(1 -1) rotate(45 1163.11 91.165)"/><path stroke="url(#b)" stroke-width="2.418" d="M404.846 598.671h391.726"/><path stroke="url(#c)" stroke-width="2.418" d="M599.5 795.742V404.017"/><path stroke="url(#d)" stroke-width="2.418" d="m795.717 796.597-391.441-391.44"/><path fill="#fff" d="M600.709 656.704c-31.384 0-56.825-25.441-56.825-56.824 0-31.384 25.441-56.825 56.825-56.825 31.383 0 56.824 25.441 56.824 56.825 0 31.383-25.441 56.824-56.824 56.824Z"/><g clip-path="url(#e)"><path fill="#666" fill-rule="evenodd" d="M616.426 586.58h-31.434v16.176l3.553-3.554.531-.531h9.068l.074-.074 8.463-8.463h2.565l7.18 7.181V586.58Zm-15.715 14.654 3.698 3.699 1.283 1.282-2.565 2.565-1.282-1.283-5.2-5.199h-6.066l-5.514 5.514-.073.073v2.876a2.418 2.418 0 0 0 2.418 2.418h26.598a2.418 2.418 0 0 0 2.418-2.418v-8.317l-8.463-8.463-7.181 7.181-.071.072Zm-19.347 5.442v4.085a6.045 6.045 0 0 0 6.046 6.045h26.598a6.044 6.044 0 0 0 6.045-6.045v-7.108l1.356-1.355-1.282-1.283-.074-.073v-17.989h-38.689v23.43l-.146.146.146.147Z" clip-rule="evenodd"/></g><path stroke="#C9C9C9" stroke-width="2.418" d="M600.709 656.704c-31.384 0-56.825-25.441-56.825-56.824 0-31.384 25.441-56.825 56.825-56.825 31.383 0 56.824 25.441 56.824 56.825 0 31.383-25.441 56.824-56.824 56.824Z"/></g><defs><linearGradient id="a" x1="554.061" x2="-.48" y1=".083" y2=".087" gradientUnits="userSpaceOnUse"><stop stop-color="#C9C9C9" stop-opacity="0"/><stop offset=".208" stop-color="#C9C9C9"/><stop offset=".792" stop-color="#C9C9C9"/><stop offset="1" stop-color="#C9C9C9" stop-opacity="0"/></linearGradient><linearGradient id="b" x1="796.912" x2="404.507" y1="599.963" y2="599.965" gradientUnits="userSpaceOnUse"><stop stop-color="#C9C9C9" stop-opacity="0"/><stop offset=".208" stop-color="#C9C9C9"/><stop offset=".792" stop-color="#C9C9C9"/><stop offset="1" stop-color="#C9C9C9" stop-opacity="0"/></linearGradient><linearGradient id="c" x1="600.792" x2="600.794" y1="403.677" y2="796.082" gradientUnits="userSpaceOnUse"><stop stop-color="#C9C9C9" stop-opacity="0"/><stop offset=".208" stop-color="#C9C9C9"/><stop offset=".792" stop-color="#C9C9C9"/><stop offset="1" stop-color="#C9C9C9" stop-opacity="0"/></linearGradient><linearGradient id="d" x1="404.85" x2="796.972" y1="403.903" y2="796.02" gradientUnits="userSpaceOnUse"><stop stop-color="#C9C9C9" stop-opacity="0"/><stop offset=".208" stop-color="#C9C9C9"/><stop offset=".792" stop-color="#C9C9C9"/><stop offset="1" stop-color="#C9C9C9" stop-opacity="0"/></linearGradient><clipPath id="e"><path fill="#fff" d="M581.364 580.535h38.689v38.689h-38.689z"/></clipPath></defs></svg>
|
||||
|
After Width: | Height: | Size: 3.2 KiB |
@@ -25,6 +25,9 @@ module.exports = {
|
||||
"highlight-med": "rgb(var(--highlight-med))",
|
||||
"highlight-high": "rgb(var(--highlight-high))",
|
||||
},
|
||||
transitionProperty: {
|
||||
bg: "background-color",
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
|
||||
6
ui/types/file.ts
Normal file
6
ui/types/file.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export interface File {
|
||||
name: string,
|
||||
is_dir: boolean,
|
||||
size: number,
|
||||
last_modified: string
|
||||
}
|
||||
29
ui/types/user.ts
Normal file
29
ui/types/user.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
export interface User {
|
||||
id: number,
|
||||
username: string,
|
||||
email: string,
|
||||
plan: {
|
||||
id: number,
|
||||
max_storage: number
|
||||
}
|
||||
}
|
||||
|
||||
export interface FileUpload {
|
||||
id: string,
|
||||
uploading: boolean,
|
||||
file: File,
|
||||
startTime: number,
|
||||
speed: number,
|
||||
remainingTime: number,
|
||||
controller: XMLHttpRequest,
|
||||
length: {
|
||||
total: number,
|
||||
loaded: number,
|
||||
},
|
||||
status: {
|
||||
error: boolean,
|
||||
aborted: boolean,
|
||||
code: number,
|
||||
message: string
|
||||
},
|
||||
}
|
||||
12
ui/utils/formatBytes.ts
Normal file
12
ui/utils/formatBytes.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export function formatBytes(bytes: number, decimalPlaces = 2) {
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
|
||||
const k = 1024;
|
||||
const dm = decimalPlaces;
|
||||
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
|
||||
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
|
||||
}
|
||||
Reference in New Issue
Block a user