From b02203b781ccacde691e4ab57a80d744098ac452 Mon Sep 17 00:00:00 2001 From: Jack Stupple Date: Tue, 12 Sep 2023 22:28:19 +0100 Subject: [PATCH] feat(#97): implement list index and rename list index implemented, however with unhelpful names it would be nice to see more pleasant name options. so ability to rename and specify name has been added too --- internal/config/config.go | 6 + internal/db/db.go | 4 + internal/db/db_sqlite.go | 87 +++++++++++- internal/http/api/v1/handlers.go | 56 ++++++++ internal/http/assets.go | 1 + internal/http/handlers.go | 32 +++++ internal/http/service.go | 17 ++- internal/ssh/flags.go | 47 +++++++ internal/ssh/handler.go | 73 +++++++++- internal/tui/views/browser/options.go | 4 + internal/tui/views/prompt/kind.go | 1 + internal/tui/views/prompt/prompt.go | 40 +++++- web/static/css/index.css | 186 ++++++++++++++------------ web/static/js/list.js | 105 +++++++++++++++ web/templates/file.go.html | 87 ++++++------ web/templates/list.go.html | 3 + 16 files changed, 615 insertions(+), 134 deletions(-) create mode 100644 internal/http/api/v1/handlers.go create mode 100644 web/static/js/list.js create mode 100644 web/templates/list.go.html diff --git a/internal/config/config.go b/internal/config/config.go index f87df9b..6aaae2b 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -30,6 +30,12 @@ type Config struct { // https://github.com/robherley/snips.sh/issues/39 EnableGuesser bool `default:"True" desc:"enable guesslang model to detect file types"` + // Generally more geared towards self-hosted instances, or public instances + // with modifications to snips.sh. + EnableApi bool `default:"False" desc:"enable the snips.sh api"` + + ListIndex bool `default:"False" desc:"enable index page showing recent snippets"` + HMACKey string `default:"hmac-and-cheese" desc:"symmetric key used to sign URLs"` FileCompression bool `default:"True" desc:"enable compression of file contents"` diff --git a/internal/db/db.go b/internal/db/db.go index c5c9f36..b178240 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -16,8 +16,12 @@ type DB interface { CreateFile(ctx context.Context, file *snips.File, maxFiles uint64) error // UpdateFile updates a file. UpdateFile(ctx context.Context, file *snips.File) error + // RenameFile updates a file od. + RenameFile(ctx context.Context, file *snips.File, oldId string) error // DeleteFile deletes a file by its ID. DeleteFile(ctx context.Context, id string) error + // FindFiles find files, paginated + FindFiles(ctx context.Context, page int) ([]*snips.File, error) // FindFilesByUser returns all files for a user. Does not include file content. FindFilesByUser(ctx context.Context, userID string) ([]*snips.File, error) // FindPublicKeyByFingerprint returns a public key by its fingerprint. diff --git a/internal/db/db_sqlite.go b/internal/db/db_sqlite.go index 855970c..ebf96fa 100644 --- a/internal/db/db_sqlite.go +++ b/internal/db/db_sqlite.go @@ -113,7 +113,10 @@ func (s *Sqlite) CreateFile(ctx context.Context, file *snips.File, maxFileCount return ErrFileLimit } - file.ID = id.New() + if file.ID == "" { + file.ID = id.New() + } + file.CreatedAt = time.Now().UTC() file.UpdatedAt = time.Now().UTC() @@ -146,12 +149,43 @@ func (s *Sqlite) CreateFile(ctx context.Context, file *snips.File, maxFileCount return nil } +func (s *Sqlite) RenameFile(ctx context.Context, file *snips.File, oldId string) error { + file.UpdatedAt = time.Now().UTC() + + const query = ` + UPDATE files + SET + id = ?, + updated_at = ?, + size = ?, + content = ?, + private = ?, + type = ? + WHERE id = ? + ` + + if _, err := s.ExecContext(ctx, query, + file.ID, + file.UpdatedAt, + file.Size, + file.RawContent, + file.Private, + file.Type, + oldId, + ); err != nil { + return err + } + + return nil +} + func (s *Sqlite) UpdateFile(ctx context.Context, file *snips.File) error { file.UpdatedAt = time.Now().UTC() const query = ` UPDATE files SET + id = ?, updated_at = ?, size = ?, content = ?, @@ -161,6 +195,7 @@ func (s *Sqlite) UpdateFile(ctx context.Context, file *snips.File) error { ` if _, err := s.ExecContext(ctx, query, + file.ID, file.UpdatedAt, file.Size, file.RawContent, @@ -187,6 +222,56 @@ func (s *Sqlite) DeleteFile(ctx context.Context, id string) error { return nil } +func (s *Sqlite) FindFiles(ctx context.Context, page int) ([]*snips.File, error) { + const query = ` + SELECT + id, + created_at, + updated_at, + size, + private, + type, + user_id + FROM files + WHERE private = 0 + ORDER BY created_at DESC + LIMIT ?, 10 + ` + + if page < 0 { + page = 0 + } + + files := make([]*snips.File, 0) + rows, err := s.QueryContext(ctx, query, page) + if err != nil { + return files, err + } + + for rows.Next() { + file := &snips.File{} + if err := rows.Scan( + &file.ID, + &file.CreatedAt, + &file.UpdatedAt, + &file.Size, + &file.Private, + &file.Type, + &file.UserID, + ); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + + return nil, err + } + + files = append(files, file) + } + + return files, nil +} + func (s *Sqlite) FindFilesByUser(ctx context.Context, userID string) ([]*snips.File, error) { // note that content is _not_ included const query = ` diff --git a/internal/http/api/v1/handlers.go b/internal/http/api/v1/handlers.go new file mode 100644 index 0000000..b7dfe52 --- /dev/null +++ b/internal/http/api/v1/handlers.go @@ -0,0 +1,56 @@ +package api_v1 + +import ( + "encoding/json" + "net/http" + "strconv" + + "github.com/go-chi/chi/v5" + "github.com/robherley/snips.sh/internal/config" + "github.com/robherley/snips.sh/internal/db" + "github.com/robherley/snips.sh/internal/logger" +) + +// GetSnips +// Retrieve a list of snips on the server, paginated, in date created DESC by +// default. +// Ideal utilisation, when trying to view all snips, is to iterate through pages +// until you reach a payload size of 0. +func GetSnips(database db.DB) func(http.ResponseWriter, *http.Request) { + return func(w http.ResponseWriter, r *http.Request) { + log := logger.From(r.Context()) + + page := 0 + if r.URL.Query().Has("page") { + strPage := r.URL.Query().Get("page") + p, err := strconv.Atoi(strPage) + if err == nil { + page = p + } + } + + files, err := database.FindFiles(r.Context(), page) + if err != nil { + log.Error().Err(err).Msg("unable to render template") + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + + filesMarshalled, err := json.Marshal(files) + if err != nil { + log.Error().Err(err).Msg("unable to render template") + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + + w.Write(filesMarshalled) + } +} + +func ApiHandler(cfg *config.Config, database db.DB) *chi.Mux { + apiRouter := chi.NewMux() + + apiRouter.Get("/snips", GetSnips(database)) + + return apiRouter +} diff --git a/internal/http/assets.go b/internal/http/assets.go index 737f091..8f5e537 100644 --- a/internal/http/assets.go +++ b/internal/http/assets.go @@ -39,6 +39,7 @@ var ( jsFiles = []string{ "snips.js", + "list.js", } ) diff --git a/internal/http/handlers.go b/internal/http/handlers.go index f7aeaae..0dbcc33 100644 --- a/internal/http/handlers.go +++ b/internal/http/handlers.go @@ -1,8 +1,10 @@ package http import ( + "bytes" "encoding/json" "html/template" + "io" "net/http" "net/url" "strings" @@ -49,6 +51,36 @@ func MetaHandler(cfg *config.Config) http.HandlerFunc { } } +func ListHandler(config *config.Config, database db.DB, assets Assets) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var err error + + log := logger.From(r.Context()) + + buff := bytes.NewBuffer([]byte{}) + assets.Template().ExecuteTemplate(buff, "list.go.html", nil) + html, _ := io.ReadAll(buff) + + vars := map[string]interface{}{ + "FileID": "Latest snips", + "FileSize": nil, + "CreatedAt": nil, + "UpdatedAt": nil, + "FileType": nil, + "RawHREF": "", + "HTML": template.HTML(string(html)), + "Private": false, + } + + err = assets.Template().ExecuteTemplate(w, "file.go.html", vars) + if err != nil { + log.Error().Err(err).Msg("unable to render template") + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + } +} + func DocHandler(assets Assets) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { log := logger.From(r.Context()) diff --git a/internal/http/service.go b/internal/http/service.go index 06ec774..076eeb7 100644 --- a/internal/http/service.go +++ b/internal/http/service.go @@ -7,6 +7,8 @@ import ( "github.com/go-chi/chi/v5/middleware" "github.com/robherley/snips.sh/internal/config" "github.com/robherley/snips.sh/internal/db" + api_v1 "github.com/robherley/snips.sh/internal/http/api/v1" + "github.com/rs/zerolog/log" ) type Service struct { @@ -22,7 +24,16 @@ func New(cfg *config.Config, database db.DB, assets Assets) (*Service, error) { router.Use(WithMetrics) router.Use(WithRecover) - router.Get("/", DocHandler(assets)) + if cfg.ListIndex && cfg.EnableApi { + router.Get("/", ListHandler(cfg, database, assets)) + } else { + if cfg.ListIndex && !cfg.EnableApi { + log.Warn().Bool("ListIndex", cfg.ListIndex).Bool("EnableApi", cfg.EnableApi).Msg("cannot list index without enabling api") + } + + router.Get("/", DocHandler(assets)) + } + router.Get("/docs/{name}", DocHandler(assets)) router.Get("/health", HealthHandler) router.Get("/f/{fileID}", FileHandler(cfg, database, assets)) @@ -30,6 +41,10 @@ func New(cfg *config.Config, database db.DB, assets Assets) (*Service, error) { router.Get("/assets/index.css", assets.ServeCSS) router.Get("/meta.json", MetaHandler(cfg)) + if cfg.EnableApi { + router.Mount("/api/v1", api_v1.ApiHandler(cfg, database)) + } + if cfg.Debug { router.Mount("/_debug", middleware.Profiler()) } diff --git a/internal/ssh/flags.go b/internal/ssh/flags.go index 2087f0c..6260860 100644 --- a/internal/ssh/flags.go +++ b/internal/ssh/flags.go @@ -22,12 +22,14 @@ type UploadFlags struct { Private bool Extension string TTL time.Duration + Name string } func (uf *UploadFlags) Parse(out io.Writer, args []string) error { uf.FlagSet = flag.NewFlagSet("", flag.ContinueOnError) uf.SetOutput(out) + uf.StringVar(&uf.Name, "name", "", "define custom name, if unavailable then a unique hash will be appended") uf.BoolVar(&uf.Private, "private", false, "only accessible via creator or signed urls (optional)") uf.StringVar(&uf.Extension, "ext", "", "set the file extension (optional)") addDurationFlag(uf.FlagSet, &uf.TTL, "ttl", 0, "lifetime of the signed url (optional)") @@ -41,6 +43,26 @@ func (uf *UploadFlags) Parse(out io.Writer, args []string) error { } uf.Extension = strings.TrimPrefix(strings.ToLower(uf.Extension), ".") + uf.Name = strings.Trim(uf.Name, " ") + + return nil +} + +type RenameFlags struct { + *flag.FlagSet + + Name string +} + +func (sf *RenameFlags) Parse(out io.Writer, args []string) error { + sf.FlagSet = flag.NewFlagSet("", flag.ContinueOnError) + sf.SetOutput(out) + + addStringFlag(sf.FlagSet, &sf.Name, "name", "", "define custom name, if unavailable then a unique hash will be appended") + + if err := sf.FlagSet.Parse(args); err != nil { + return err + } return nil } @@ -111,3 +133,28 @@ func (d *durationFlagValue) Get() any { func (d *durationFlagValue) String() string { return (*time.Duration)(d).String() } + +// stringFlagValue is a wrapper around string that implements the flag.Value interface using a custom parser. +type stringFlagValue string + +// addStringFlag adds a flag for a string to the given flag.FlagSet. +func addStringFlag(fs *flag.FlagSet, flagValue *string, name string, value string, usage string) { + *flagValue = value + fs.Var((*stringFlagValue)(flagValue), name, usage) +} + +// Set implements the flag.Value interface. +func (sf *stringFlagValue) Set(s string) error { + *sf = stringFlagValue(s) + return nil +} + +// Get implements the flag.Getter interface. +func (sf *stringFlagValue) Get() any { + return string(*sf) +} + +// String implements the flag.Value interface. +func (sf *stringFlagValue) String() string { + return string(*sf) +} diff --git a/internal/ssh/handler.go b/internal/ssh/handler.go index b40d1f0..19cfa09 100644 --- a/internal/ssh/handler.go +++ b/internal/ssh/handler.go @@ -19,6 +19,7 @@ import ( "github.com/muesli/termenv" "github.com/robherley/snips.sh/internal/config" "github.com/robherley/snips.sh/internal/db" + "github.com/robherley/snips.sh/internal/id" "github.com/robherley/snips.sh/internal/logger" "github.com/robherley/snips.sh/internal/renderer" "github.com/robherley/snips.sh/internal/snips" @@ -152,8 +153,10 @@ func (h *SessionHandler) FileRequest(sesh *UserSession) { h.DeleteFile(sesh, file) case "sign": h.SignFile(sesh, file) + case "rename": + h.RenameFile(sesh, file) default: - sesh.Error(ErrUnknownCommand, "Unknown command", "Unknown command specified: %q", args[0]) + sesh.Error(ErrUnknownCommand, "Unknown command", "Unknown command specified: %q. Known commands: rm, sign, rename", args[0]) } } @@ -263,6 +266,59 @@ func (h *SessionHandler) SignFile(sesh *UserSession, file *snips.File) { noti.Render(sesh) } +func (h *SessionHandler) RenameFile(sesh *UserSession, file *snips.File) { + log := logger.From(sesh.Context()) + + flags := RenameFlags{} + args := sesh.Command()[1:] + + if err := flags.Parse(sesh.Stderr(), args); err != nil { + if !errors.Is(err, flag.ErrHelp) { + log.Warn().Err(err).Msg("invalid user specified flags") + flags.PrintDefaults() + } + return + } + + old := file.ID + file.ID = flags.Name + err := h.DB.RenameFile(sesh.Context(), file, old) + if err != nil { + log.Warn().Err(err).Msg("could not update file") + return + } + + log.Info().Str("old_name", old).Str("new_name", file.ID).Msg("updated file name") + + metrics.IncrCounter([]string{"file", "rename"}, 1) + + noti := Notification{ + Color: styles.Colors.Cyan, + Title: "File renamed 📝", + WithStyle: func(s *lipgloss.Style) { + s.MarginTop(1) + }, + } + visibility := styles.C(styles.Colors.White, "public") + if file.Private { + visibility = styles.C(styles.Colors.Red, "private") + } + + attrs := make([]string, 0) + kvp := map[string]string{ + "type": styles.C(styles.Colors.White, file.Type), + "size": styles.C(styles.Colors.White, humanize.Bytes(file.Size)), + "visibility": visibility, + } + for k, v := range kvp { + key := styles.C(styles.Colors.Muted, k+": ") + attrs = append(attrs, key+v) + } + sort.Strings(attrs) + noti.Messagef("id: %s\n%s", styles.C(styles.Colors.White, file.ID), strings.Join(attrs, styles.C(styles.Colors.Muted, " • "))) + noti.Render(sesh) +} + func (h *SessionHandler) DownloadFile(sesh *UserSession, file *snips.File) { content, err := file.GetContent() if err != nil { @@ -318,12 +374,27 @@ func (h *SessionHandler) Upload(sesh *UserSession) { } file := snips.File{ + ID: id.New(), Private: flags.Private, Size: size, UserID: sesh.UserID(), Type: renderer.DetectFileType(content, flags.Extension, h.Config.EnableGuesser), } + if flags.Name != "" { + file.ID = flags.Name + } + + dupe, err := h.DB.FindFile(sesh.Context(), flags.Name) + if err != nil { + sesh.Error(err, "Unable to create file", "There was an error creating the file: %s", err.Error()) + return + } + + if dupe != nil { + file.ID = file.ID + "-" + id.New() + } + if err := file.SetContent(content, h.Config.FileCompression); err != nil { sesh.Error(err, "Unable to create file", "There was an error creating the file: %s", err.Error()) } diff --git a/internal/tui/views/browser/options.go b/internal/tui/views/browser/options.go index 32b5afd..d6efbb7 100644 --- a/internal/tui/views/browser/options.go +++ b/internal/tui/views/browser/options.go @@ -21,6 +21,10 @@ type option struct { } var options = []option{ + { + name: "rename", + prompt: prompt.Rename, + }, { name: "edit extension", prompt: prompt.ChangeExtension, diff --git a/internal/tui/views/prompt/kind.go b/internal/tui/views/prompt/kind.go index a80d92c..3bcfb4b 100644 --- a/internal/tui/views/prompt/kind.go +++ b/internal/tui/views/prompt/kind.go @@ -8,4 +8,5 @@ const ( ChangeVisibility GenerateSignedURL DeleteFile + Rename ) diff --git a/internal/tui/views/prompt/prompt.go b/internal/tui/views/prompt/prompt.go index 8e40ac5..b416119 100644 --- a/internal/tui/views/prompt/prompt.go +++ b/internal/tui/views/prompt/prompt.go @@ -17,6 +17,7 @@ import ( "github.com/muesli/reflow/wrap" "github.com/robherley/snips.sh/internal/config" "github.com/robherley/snips.sh/internal/db" + "github.com/robherley/snips.sh/internal/id" "github.com/robherley/snips.sh/internal/logger" "github.com/robherley/snips.sh/internal/snips" "github.com/robherley/snips.sh/internal/tui/cmds" @@ -96,7 +97,7 @@ func (p Prompt) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } switch p.kind { - case GenerateSignedURL, DeleteFile, ChangeVisibility: + case GenerateSignedURL, DeleteFile, Rename, ChangeVisibility: p.textInput, cmd = p.textInput.Update(msg) commands = append(commands, cmd) case ChangeExtension: @@ -131,6 +132,8 @@ func (p Prompt) renderPrompt() string { switch p.kind { case ChangeExtension: question = "What extension do you want to change the file to?" + case Rename: + question = "What would you like to rename your file to?" case ChangeVisibility: question = fmt.Sprintf("Do you want to make the file %q ", p.file.ID) if p.file.Private { @@ -155,7 +158,7 @@ func (p Prompt) renderPrompt() string { var prompt string switch p.kind { - case GenerateSignedURL, DeleteFile, ChangeVisibility: + case GenerateSignedURL, DeleteFile, Rename, ChangeVisibility: prompt = p.textInput.View() case ChangeExtension: prompt = p.extensionSelector.View() @@ -234,6 +237,37 @@ func (p Prompt) handleSubmit() tea.Cmd { msg := styles.C(styles.Colors.Green, fmt.Sprintf("file %q extension set to %q", p.file.ID, item.name)) commands = append(commands, cmds.ReloadFiles(p.db, p.file.UserID), SetPromptFeedbackCmd(msg, true)) + + case Rename: + old := p.file.ID + p.file.ID = p.textInput.Value() + + // check for duplicates + dupe, err := p.db.FindFile(p.ctx, p.file.ID) + if err != nil { + return SetPromptErrorCmd(err) + } + + if dupe != nil { + dupeRename := p.file.ID + "-" + id.New() + log.Info().Str("name", p.file.ID).Str("dupe_name", dupeRename).Msg("dupe found, appending hash") + p.file.ID = dupeRename + } + + err = p.db.RenameFile(p.ctx, p.file, old) + if err != nil { + return SetPromptErrorCmd(err) + } + + metrics.IncrCounterWithLabels([]string{"file", "change", "name"}, 1, []metrics.Label{ + {Name: "old", Value: old}, + {Name: "new", Value: p.file.ID}, + }) + log.Info().Str("file", p.file.ID).Str("old_name", old).Str("new_name", p.file.ID).Msg("updating file name") + + msg := styles.C(styles.Colors.Green, fmt.Sprintf("rename file %q to %q", old, p.file.ID)) + commands = append(commands, cmds.ReloadFiles(p.db, p.file.UserID), SetPromptFeedbackCmd(msg, true)) + case GenerateSignedURL: dur, err := time.ParseDuration(p.textInput.Value()) if err != nil { @@ -251,6 +285,7 @@ func (p Prompt) handleSubmit() tea.Cmd { msg := styles.C(styles.Colors.Green, fmt.Sprintf("%s\n\nexpires at: %s", url.String(), expires.Format(time.RFC3339))) commands = append(commands, SetPromptFeedbackCmd(msg, true)) + case DeleteFile: if cmd := p.validateInputIsFileID(); cmd != nil { return cmd @@ -266,6 +301,7 @@ func (p Prompt) handleSubmit() tea.Cmd { msg := styles.C(styles.Colors.Green, fmt.Sprintf("file %q deleted", p.file.ID)) commands = append(commands, cmds.ReloadFiles(p.db, p.file.UserID), SetPromptFeedbackCmd(msg, true)) + default: return nil } diff --git a/web/static/css/index.css b/web/static/css/index.css index 8c3af41..86b91d1 100644 --- a/web/static/css/index.css +++ b/web/static/css/index.css @@ -11,87 +11,87 @@ these lovely colors are from vercel https://vercel.com/design/color */ - --color-black:#0a0a0a; - --color-gray-1:#1a1a1a; - --color-gray-2:#1f1f1f; - --color-gray-3:#292929; - --color-gray-4:#2e2e2e; - --color-gray-5:#454545; - --color-gray-6:#878787; - --color-gray-7:#8f8f8f; - --color-gray-8:#7d7d7d; - --color-gray-9:#a0a0a0; - --color-gray-10:#ededed; - --color-blue-1:#0f1b2d; - --color-blue-2:#10243e; - --color-blue-3:#0f3058; - --color-blue-4:#0d3768; - --color-blue-5:#0a4481; - --color-blue-6:#0091ff; - --color-blue-7:#0071f3; - --color-blue-8:#0062d1; - --color-blue-9:#52a9ff; - --color-blue-10:#eaf6ff; - --color-red-1:#2a1314; - --color-red-2:#3d1719; - --color-red-3:#551a1e; - --color-red-4:#671e22; - --color-red-5:#822025; - --color-red-6:#e5484d; - --color-red-7:#e5484d; - --color-red-8:#da3036; - --color-red-9:#ff6368; - --color-red-10:#feecee; - --color-amber-1:#271700; - --color-amber-2:#341c00; - --color-amber-3:#4a2900; - --color-amber-4:#573300; - --color-amber-5:#693f05; - --color-amber-6:#e79d13; - --color-amber-7:#ffb224; - --color-amber-8:#ffa90a; - --color-amber-9:#f1a10d; - --color-amber-10:#fef3dd; - --color-green-1:#0b2211; - --color-green-2:#0f2c17; - --color-green-3:#11351b; - --color-green-4:#0c461c; - --color-green-5:#126426; - --color-green-6:#1a9338; - --color-green-7:#46a758; - --color-green-8:#388e4b; - --color-green-9:#63c174; - --color-green-10:#e5fbeb; - --color-teal-1:#04201b; - --color-teal-2:#062923; - --color-teal-3:#083a33; - --color-teal-4:#053c34; - --color-teal-5:#085e53; - --color-teal-6:#0c9785; - --color-teal-7:#12a594; - --color-teal-8:#0f8a7c; - --color-teal-9:#0ac5b2; - --color-teal-10:#e1faf4; - --color-purple-1:#221527; - --color-purple-2:#301a3a; - --color-purple-3:#432155; - --color-purple-4:#4e2667; - --color-purple-5:#5e2d84; - --color-purple-6:#8e4ec6; - --color-purple-7:#8e4ec6; - --color-purple-8:#773da9; - --color-purple-9:#bf7af0; - --color-purple-10:#f7ecfc; - --color-pink-1:#27141c; - --color-pink-2:#3c1827; - --color-pink-3:#4f1c31; - --color-pink-4:#541b33; - --color-pink-5:#6c1e3f; - --color-pink-6:#b21a57; - --color-pink-7:#e93d82; - --color-pink-8:#de2670; - --color-pink-9:#f76191; - --color-pink-10:#feecf4; + --color-black: #0a0a0a; + --color-gray-1: #1a1a1a; + --color-gray-2: #1f1f1f; + --color-gray-3: #292929; + --color-gray-4: #2e2e2e; + --color-gray-5: #454545; + --color-gray-6: #878787; + --color-gray-7: #8f8f8f; + --color-gray-8: #7d7d7d; + --color-gray-9: #a0a0a0; + --color-gray-10: #ededed; + --color-blue-1: #0f1b2d; + --color-blue-2: #10243e; + --color-blue-3: #0f3058; + --color-blue-4: #0d3768; + --color-blue-5: #0a4481; + --color-blue-6: #0091ff; + --color-blue-7: #0071f3; + --color-blue-8: #0062d1; + --color-blue-9: #52a9ff; + --color-blue-10: #eaf6ff; + --color-red-1: #2a1314; + --color-red-2: #3d1719; + --color-red-3: #551a1e; + --color-red-4: #671e22; + --color-red-5: #822025; + --color-red-6: #e5484d; + --color-red-7: #e5484d; + --color-red-8: #da3036; + --color-red-9: #ff6368; + --color-red-10: #feecee; + --color-amber-1: #271700; + --color-amber-2: #341c00; + --color-amber-3: #4a2900; + --color-amber-4: #573300; + --color-amber-5: #693f05; + --color-amber-6: #e79d13; + --color-amber-7: #ffb224; + --color-amber-8: #ffa90a; + --color-amber-9: #f1a10d; + --color-amber-10: #fef3dd; + --color-green-1: #0b2211; + --color-green-2: #0f2c17; + --color-green-3: #11351b; + --color-green-4: #0c461c; + --color-green-5: #126426; + --color-green-6: #1a9338; + --color-green-7: #46a758; + --color-green-8: #388e4b; + --color-green-9: #63c174; + --color-green-10: #e5fbeb; + --color-teal-1: #04201b; + --color-teal-2: #062923; + --color-teal-3: #083a33; + --color-teal-4: #053c34; + --color-teal-5: #085e53; + --color-teal-6: #0c9785; + --color-teal-7: #12a594; + --color-teal-8: #0f8a7c; + --color-teal-9: #0ac5b2; + --color-teal-10: #e1faf4; + --color-purple-1: #221527; + --color-purple-2: #301a3a; + --color-purple-3: #432155; + --color-purple-4: #4e2667; + --color-purple-5: #5e2d84; + --color-purple-6: #8e4ec6; + --color-purple-7: #8e4ec6; + --color-purple-8: #773da9; + --color-purple-9: #bf7af0; + --color-purple-10: #f7ecfc; + --color-pink-1: #27141c; + --color-pink-2: #3c1827; + --color-pink-3: #4f1c31; + --color-pink-4: #541b33; + --color-pink-5: #6c1e3f; + --color-pink-6: #b21a57; + --color-pink-7: #e93d82; + --color-pink-8: #de2670; + --color-pink-9: #f76191; + --color-pink-10: #feecf4; --color-primary: var(--color-teal-9); } @@ -164,11 +164,9 @@ a:hover { display: block; width: 100%; height: 1rem; - background-image: repeating-linear-gradient( - -45deg, - var(--color-primary) 0 2px, - transparent 2px 8px - ); + background-image: repeating-linear-gradient(-45deg, + var(--color-primary) 0 2px, + transparent 2px 8px); } .file-header { @@ -203,7 +201,8 @@ a:hover { } .file-content * { - scroll-margin-top: 3.5rem; // offset header + /* offset header */ + scroll-margin-top: 3.5rem; } .file-footer { @@ -225,3 +224,16 @@ a:hover { padding: 0 0.5rem; } } + +#list-snips { + background-color: var(--color-black); + border: var(--border); + border-radius: var(--border-radius); + display: flex; + flex-direction: column; +} + +#list-snips>div { + padding: 1rem; + color: var(--color-gray-9); +} \ No newline at end of file diff --git a/web/static/js/list.js b/web/static/js/list.js new file mode 100644 index 0000000..2917a29 --- /dev/null +++ b/web/static/js/list.js @@ -0,0 +1,105 @@ +const $snipsList = document.getElementById('list-snips') +const snipsList = function ($snipsList) { + /** + * Format bytes as human-readable text. + * + * @link https://stackoverflow.com/a/14919494 + * + * @param bytes Number of bytes. + * @param si True to use metric (SI) units, aka powers of 1000. False to use + * binary (IEC), aka powers of 1024. + * @param dp Number of decimal places to display. + * + * @return Formatted string. + */ + const humanFileSize = function (bytes, si = false, dp = 1) { + const thresh = si ? 1000 : 1024; + + if (Math.abs(bytes) < thresh) { + return bytes + ' B'; + } + + const units = si + ? ['kB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'] + : ['KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB']; + let u = -1; + const r = 10 ** dp; + + do { + bytes /= thresh; + ++u; + } while (Math.round(Math.abs(bytes) * r) / r >= thresh && u < units.length - 1); + + + return bytes.toFixed(dp) + ' ' + units[u]; + } + + /** + * timeAgo + * https://stackoverflow.com/a/3177838 + * + * @param {Date} date + * + * @returns {string} + */ + const timeAgo = function (date) { + console.dir(date) + var seconds = Math.floor((new Date() - date) / 1000); + + var interval = seconds / 31536000; + + if (interval > 1) { + return Math.floor(interval) + " years"; + } + interval = seconds / 2592000; + if (interval > 1) { + return Math.floor(interval) + " months"; + } + interval = seconds / 86400; + if (interval > 1) { + return Math.floor(interval) + " days"; + } + interval = seconds / 3600; + if (interval > 1) { + return Math.floor(interval) + " hours"; + } + interval = seconds / 60; + if (interval > 1) { + return Math.floor(interval) + " minutes"; + } + + return Math.floor(seconds) + " seconds"; + } + + const success = function (snips) { + $snipsList.innerHTML = ''; + if (snips.length === 0) { + $snipsList.innerHTML = 'No snips found.'; + return + } + + for (let snip of snips) { + const snipsListItem = document.createElement('div') + const updatedAt = new Date(snip.UpdatedAt) + + snipsListItem.innerHTML = `${snip.ID} (${snip.Type}) · ${humanFileSize(snip.Size, false, 0)} · ${timeAgo(updatedAt)} ago` + $snipsList.insertAdjacentElement('beforeend', snipsListItem) + } + } + + const failure = function (error) { + $snipsList.innerHTML = '
There was an issue retrieving the latest snips.
'; + console.error(error) + } + + fetch('api/v1/snips', { + headers: { + 'Content-Type': 'application/json', + 'Accept-Encoding': 'br;q=1.0, gzip;q=0.8, *;q=0.1', + } + }).then(d => d.json().then(success)).catch(e => failure(e)) +} + +if ($snipsList) { + snipsList($snipsList) +} \ No newline at end of file diff --git a/web/templates/file.go.html b/web/templates/file.go.html index 63daf9f..db0f413 100644 --- a/web/templates/file.go.html +++ b/web/templates/file.go.html @@ -2,50 +2,53 @@ - - - - - - - - - - - {{ .FileID }} - snips.sh - - - {{ ExtendedHeadContent }} + + + + + + + + + + + {{ .FileID }} - snips.sh + + + {{ ExtendedHeadContent }} -
-
-

snips.sh

- -
- {{ .HTML }} -
-
- + - + + \ No newline at end of file diff --git a/web/templates/list.go.html b/web/templates/list.go.html new file mode 100644 index 0000000..cecda64 --- /dev/null +++ b/web/templates/list.go.html @@ -0,0 +1,3 @@ +
+
Loading latest snips...
+
\ No newline at end of file