16 Commits

Author SHA1 Message Date
53c1d8fa91 chore: upgrade version to 0.3.0 2023-07-21 22:39:21 +08:00
b32fdbfc0a fix: icon style 2023-07-21 22:13:05 +08:00
db2aebcf57 chore: update create shortcut dialog 2023-07-21 22:06:06 +08:00
b4e23fc8a0 chore: update seed data 2023-07-21 21:50:59 +08:00
7ab66113ac fix: field name 2023-07-21 21:37:07 +08:00
2909676ed3 chore: update metadata tags 2023-07-21 21:35:37 +08:00
5af9236c19 chore: update 2023-07-21 21:30:54 +08:00
04c0f47559 feat: add inputs for og metadata 2023-07-21 21:27:26 +08:00
a91997683b feat: add open graph metadata field 2023-07-21 21:27:05 +08:00
014dd7d660 chore: update create shortcut dialog 2023-07-21 20:30:16 +08:00
a1b633e4db chore: update store statement execution 2023-07-19 22:33:30 +08:00
57496c9b46 chore: add loading view to analytics 2023-07-19 21:47:44 +08:00
c4f38f1de6 chore: add toast to reset button 2023-07-19 09:05:40 +08:00
e7cf0c2f79 feat: add view order setting 2023-07-17 22:43:15 +08:00
15ffd0738c chore: update button hover style 2023-07-17 21:14:57 +08:00
21ff8ba797 chore: code clean 2023-07-17 21:14:40 +08:00
24 changed files with 765 additions and 646 deletions

View File

@ -88,6 +88,7 @@ func JWTMiddleware(server *APIV1Service, next echo.HandlerFunc, secret string) e
if util.HasPrefixes(path, "/s/*") && method == http.MethodGet {
return next(c)
}
auth.RemoveTokensAndCookies(c)
return echo.NewHTTPError(http.StatusUnauthorized, "Missing access token")
}
@ -103,9 +104,7 @@ func JWTMiddleware(server *APIV1Service, next echo.HandlerFunc, secret string) e
}
return nil, errors.Errorf("unexpected access token kid=%v", t.Header["kid"])
})
if !audienceContains(claims.Audience, auth.AccessTokenAudienceName) {
return echo.NewHTTPError(http.StatusUnauthorized, fmt.Sprintf("Invalid access token, audience mismatch, got %q, expected %q.", claims.Audience, auth.AccessTokenAudienceName))
}
generateToken := time.Until(claims.ExpiresAt.Time) < auth.RefreshThresholdDuration
if err != nil {
var ve *jwt.ValidationError
@ -116,10 +115,15 @@ func JWTMiddleware(server *APIV1Service, next echo.HandlerFunc, secret string) e
generateToken = true
}
} else {
auth.RemoveTokensAndCookies(c)
return echo.NewHTTPError(http.StatusUnauthorized, errors.Wrap(err, "Invalid or expired access token"))
}
}
if !audienceContains(claims.Audience, auth.AccessTokenAudienceName) {
return echo.NewHTTPError(http.StatusUnauthorized, fmt.Sprintf("Invalid access token, audience mismatch, got %q, expected %q.", claims.Audience, auth.AccessTokenAudienceName))
}
// We either have a valid access token or we will attempt to generate new access token and refresh token
ctx := c.Request().Context()
userID, err := strconv.Atoi(claims.Subject)

View File

@ -5,6 +5,7 @@ import (
"fmt"
"net/http"
"net/url"
"strings"
"github.com/boojack/slash/store"
"github.com/labstack/echo/v4"
@ -42,11 +43,43 @@ func (s *APIV1Service) registerRedirectorRoutes(g *echo.Group) {
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to create activity, err: %s", err)).SetInternal(err)
}
if isValidURLString(shortcut.Link) {
return redirectToShortcut(c, shortcut)
})
}
func redirectToShortcut(c echo.Context, shortcut *store.Shortcut) error {
isValidURL := isValidURLString(shortcut.Link)
if shortcut.OpenGraphMetadata == nil {
if isValidURL {
return c.Redirect(http.StatusSeeOther, shortcut.Link)
}
return c.String(http.StatusOK, shortcut.Link)
})
}
htmlTemplate := `<html><head>%s</head><body>%s</body></html>`
metadataList := []string{
fmt.Sprintf(`<title>%s</title>`, shortcut.OpenGraphMetadata.Title),
fmt.Sprintf(`<meta name="description" content="%s" />`, shortcut.OpenGraphMetadata.Description),
fmt.Sprintf(`<meta property="og:title" content="%s" />`, shortcut.OpenGraphMetadata.Title),
fmt.Sprintf(`<meta property="og:description" content="%s" />`, shortcut.OpenGraphMetadata.Description),
fmt.Sprintf(`<meta property="og:image" content="%s" />`, shortcut.OpenGraphMetadata.Image),
// Twitter related metadata.
fmt.Sprintf(`<meta name="twitter:title" content="%s" />`, shortcut.OpenGraphMetadata.Title),
fmt.Sprintf(`<meta name="twitter:description" content="%s" />`, shortcut.OpenGraphMetadata.Description),
fmt.Sprintf(`<meta name="twitter:image" content="%s" />`, shortcut.OpenGraphMetadata.Image),
`<meta name="twitter:card" content="summary_large_image" />`,
}
if isValidURL {
metadataList = append(metadataList, fmt.Sprintf(`<meta property="og:url" content="%s" />`, shortcut.Link))
}
body := ""
if isValidURL {
body = fmt.Sprintf(`<script>window.location.href = "%s";</script>`, shortcut.Link)
} else {
body = shortcut.Link
}
htmlString := fmt.Sprintf(htmlTemplate, strings.Join(metadataList, ""), body)
return c.HTML(http.StatusOK, htmlString)
}
func (s *APIV1Service) createShortcutViewActivity(c echo.Context, shortcut *store.Shortcut) error {

View File

@ -30,6 +30,12 @@ func (v Visibility) String() string {
return string(v)
}
type OpenGraphMetadata struct {
Title string `json:"title"`
Description string `json:"description"`
Image string `json:"image"`
}
type Shortcut struct {
ID int `json:"id"`
@ -41,29 +47,32 @@ type Shortcut struct {
RowStatus RowStatus `json:"rowStatus"`
// Domain specific fields
Name string `json:"name"`
Link string `json:"link"`
Description string `json:"description"`
Visibility Visibility `json:"visibility"`
Tags []string `json:"tags"`
View int `json:"view"`
Name string `json:"name"`
Link string `json:"link"`
Description string `json:"description"`
Visibility Visibility `json:"visibility"`
Tags []string `json:"tags"`
View int `json:"view"`
OpenGraphMetadata *OpenGraphMetadata `json:"openGraphMetadata"`
}
type CreateShortcutRequest struct {
Name string `json:"name"`
Link string `json:"link"`
Description string `json:"description"`
Visibility Visibility `json:"visibility"`
Tags []string `json:"tags"`
Name string `json:"name"`
Link string `json:"link"`
Description string `json:"description"`
Visibility Visibility `json:"visibility"`
Tags []string `json:"tags"`
OpenGraphMetadata *OpenGraphMetadata `json:"openGraphMetadata"`
}
type PatchShortcutRequest struct {
RowStatus *RowStatus `json:"rowStatus"`
Name *string `json:"name"`
Link *string `json:"link"`
Description *string `json:"description"`
Visibility *Visibility `json:"visibility"`
Tags []string `json:"tags"`
RowStatus *RowStatus `json:"rowStatus"`
Name *string `json:"name"`
Link *string `json:"link"`
Description *string `json:"description"`
Visibility *Visibility `json:"visibility"`
Tags []string `json:"tags"`
OpenGraphMetadata *OpenGraphMetadata `json:"openGraphMetadata"`
}
func (s *APIV1Service) registerShortcutRoutes(g *echo.Group) {
@ -85,6 +94,11 @@ func (s *APIV1Service) registerShortcutRoutes(g *echo.Group) {
Description: create.Description,
Visibility: store.Visibility(create.Visibility.String()),
Tag: strings.Join(create.Tags, " "),
OpenGraphMetadata: &store.OpenGraphMetadata{
Title: create.OpenGraphMetadata.Title,
Description: create.OpenGraphMetadata.Description,
Image: create.OpenGraphMetadata.Image,
},
})
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to create shortcut, err: %s", err)).SetInternal(err)
@ -156,6 +170,13 @@ func (s *APIV1Service) registerShortcutRoutes(g *echo.Group) {
tag := strings.Join(patch.Tags, " ")
shortcutUpdate.Tag = &tag
}
if patch.OpenGraphMetadata != nil {
shortcutUpdate.OpenGraphMetadata = &store.OpenGraphMetadata{
Title: patch.OpenGraphMetadata.Title,
Description: patch.OpenGraphMetadata.Description,
Image: patch.OpenGraphMetadata.Image,
}
}
shortcut, err = s.Store.UpdateShortcut(ctx, shortcutUpdate)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to patch shortcut, err: %s", err)).SetInternal(err)
@ -261,37 +282,14 @@ func (s *APIV1Service) registerShortcutRoutes(g *echo.Group) {
return echo.NewHTTPError(http.StatusForbidden, "Unauthorized to delete shortcut")
}
if err := s.Store.DeleteShortcut(ctx, &store.DeleteShortcut{
ID: shortcutID,
}); err != nil {
err = s.Store.DeleteShortcut(ctx, &store.DeleteShortcut{ID: shortcutID})
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to delete shortcut, err: %s", err)).SetInternal(err)
}
return c.JSON(http.StatusOK, true)
})
}
func (s *APIV1Service) createShortcutCreateActivity(ctx context.Context, shortcut *store.Shortcut) error {
payload := &ActivityShorcutCreatePayload{
ShortcutID: shortcut.ID,
}
payloadStr, err := json.Marshal(payload)
if err != nil {
return errors.Wrap(err, "Failed to marshal activity payload")
}
activity := &store.Activity{
CreatorID: shortcut.CreatorID,
Type: store.ActivityShortcutCreate,
Level: store.ActivityInfo,
Payload: string(payloadStr),
}
_, err = s.Store.CreateActivity(ctx, activity)
if err != nil {
return errors.Wrap(err, "Failed to create activity")
}
return nil
}
func (s *APIV1Service) composeShortcut(ctx context.Context, shortcut *Shortcut) (*Shortcut, error) {
if shortcut == nil {
return nil, nil
@ -338,5 +336,31 @@ func convertShortcutFromStore(shortcut *store.Shortcut) *Shortcut {
Visibility: Visibility(shortcut.Visibility),
RowStatus: RowStatus(shortcut.RowStatus),
Tags: tags,
OpenGraphMetadata: &OpenGraphMetadata{
Title: shortcut.OpenGraphMetadata.Title,
Description: shortcut.OpenGraphMetadata.Description,
Image: shortcut.OpenGraphMetadata.Image,
},
}
}
func (s *APIV1Service) createShortcutCreateActivity(ctx context.Context, shortcut *store.Shortcut) error {
payload := &ActivityShorcutCreatePayload{
ShortcutID: shortcut.ID,
}
payloadStr, err := json.Marshal(payload)
if err != nil {
return errors.Wrap(err, "Failed to marshal activity payload")
}
activity := &store.Activity{
CreatorID: shortcut.CreatorID,
Type: store.ActivityShortcutCreate,
Level: store.ActivityInfo,
Payload: string(payloadStr),
}
_, err = s.Store.CreateActivity(ctx, activity)
if err != nil {
return errors.Wrap(err, "Failed to create activity")
}
return nil
}

View File

@ -9,10 +9,10 @@ import (
// Version is the service current released version.
// Semantic versioning: https://semver.org/
var Version = "0.2.0"
var Version = "0.3.0"
// DevVersion is the service current development version.
var DevVersion = "0.2.0"
var DevVersion = "0.3.0"
func GetCurrentVersion(mode string) string {
if mode == "dev" || mode == "demo" {

View File

@ -2,7 +2,6 @@ package store
import (
"context"
"database/sql"
"strings"
)
@ -64,13 +63,7 @@ type FindActivity struct {
}
func (s *Store) CreateActivity(ctx context.Context, create *Activity) (*Activity, error) {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return nil, err
}
defer tx.Rollback()
query := `
stmt := `
INSERT INTO activity (
creator_id,
type,
@ -80,7 +73,7 @@ func (s *Store) CreateActivity(ctx context.Context, create *Activity) (*Activity
VALUES (?, ?, ?, ?)
RETURNING id, created_ts
`
if err := tx.QueryRowContext(ctx, query,
if err := s.db.QueryRowContext(ctx, stmt,
create.CreatorID,
create.Type.String(),
create.Level.String(),
@ -92,50 +85,11 @@ func (s *Store) CreateActivity(ctx context.Context, create *Activity) (*Activity
return nil, err
}
if err := tx.Commit(); err != nil {
return nil, err
}
activity := create
return activity, nil
}
func (s *Store) ListActivities(ctx context.Context, find *FindActivity) ([]*Activity, error) {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return nil, err
}
defer tx.Rollback()
list, err := listActivities(ctx, tx, find)
if err != nil {
return nil, err
}
return list, nil
}
func (s *Store) GetActivity(ctx context.Context, find *FindActivity) (*Activity, error) {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return nil, err
}
defer tx.Rollback()
list, err := listActivities(ctx, tx, find)
if err != nil {
return nil, err
}
if len(list) == 0 {
return nil, nil
}
activity := list[0]
return activity, nil
}
func listActivities(ctx context.Context, tx *sql.Tx, find *FindActivity) ([]*Activity, error) {
where, args := []string{"1 = 1"}, []any{}
if find.Type != "" {
where, args = append(where, "type = ?"), append(args, find.Type.String())
@ -157,11 +111,10 @@ func listActivities(ctx context.Context, tx *sql.Tx, find *FindActivity) ([]*Act
payload
FROM activity
WHERE ` + strings.Join(where, " AND ")
rows, err := tx.QueryContext(ctx, query, args...)
rows, err := s.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
list := []*Activity{}
@ -187,3 +140,17 @@ func listActivities(ctx context.Context, tx *sql.Tx, find *FindActivity) ([]*Act
return list, nil
}
func (s *Store) GetActivity(ctx context.Context, find *FindActivity) (*Activity, error) {
list, err := s.ListActivities(ctx, find)
if err != nil {
return nil, err
}
if len(list) == 0 {
return nil, nil
}
activity := list[0]
return activity, nil
}

View File

@ -23,12 +23,12 @@ var migrationFS embed.FS
var seedFS embed.FS
type DB struct {
profile *profile.Profile
// sqlite db connection instance
DBInstance *sql.DB
profile *profile.Profile
}
// NewDB returns a new instance of DB.
// NewDB returns a new instance of DB associated with the given datasource name.
func NewDB(profile *profile.Profile) *DB {
db := &DB{
profile: profile,
@ -42,8 +42,21 @@ func (db *DB) Open(ctx context.Context) (err error) {
return fmt.Errorf("dsn required")
}
// Connect to the database without foreign_key.
sqliteDB, err := sql.Open("sqlite", db.profile.DSN+"?cache=shared&_foreign_keys=0&_journal_mode=WAL")
// Connect to the database with some sane settings:
// - No shared-cache: it's obsolete; WAL journal mode is a better solution.
// - No foreign key constraints: it's currently disabled by default, but it's a
// good practice to be explicit and prevent future surprises on SQLite upgrades.
// - Journal mode set to WAL: it's the recommended journal mode for most applications
// as it prevents locking issues.
//
// Notes:
// - When using the `modernc.org/sqlite` driver, each pragma must be prefixed with `_pragma=`.
//
// References:
// - https://pkg.go.dev/modernc.org/sqlite#Driver.Open
// - https://www.sqlite.org/sharedcache.html
// - https://www.sqlite.org/pragma.html
sqliteDB, err := sql.Open("sqlite", db.profile.DSN+"?_pragma=foreign_keys(0)&_pragma=busy_timeout(10000)&_pragma=journal_mode(WAL)")
if err != nil {
return fmt.Errorf("failed to open db with dsn: %s, err: %w", db.profile.DSN, err)
}
@ -52,16 +65,16 @@ func (db *DB) Open(ctx context.Context) (err error) {
if db.profile.Mode == "prod" {
_, err := os.Stat(db.profile.DSN)
if err != nil {
// If db file not exists, we should apply the latest schema.
// If db file not exists, we should create a new one with latest schema.
if errors.Is(err, os.ErrNotExist) {
if err := db.applyLatestSchema(ctx); err != nil {
return fmt.Errorf("failed to apply latest schema: %w", err)
return fmt.Errorf("failed to apply latest schema, err: %w", err)
}
} else {
return fmt.Errorf("failed to check database file: %w", err)
return fmt.Errorf("failed to get db file stat, err: %w", err)
}
} else {
// If db file exists, we should check the migration history and apply the migration if needed.
// If db file exists, we should check if we need to migrate the database.
currentVersion := version.GetCurrentVersion(db.profile.Mode)
migrationHistoryList, err := db.FindMigrationHistoryList(ctx, &MigrationHistoryFind{})
if err != nil {
@ -177,21 +190,15 @@ func (db *DB) applyMigrationForMinorVersion(ctx context.Context, minorVersion st
}
}
tx, err := db.DBInstance.Begin()
if err != nil {
return err
}
defer tx.Rollback()
// upsert the newest version to migration_history
// Upsert the newest version to migration_history.
version := minorVersion + ".0"
if _, err = upsertMigrationHistory(ctx, tx, &MigrationHistoryUpsert{
if _, err = db.UpsertMigrationHistory(ctx, &MigrationHistoryUpsert{
Version: version,
}); err != nil {
return fmt.Errorf("failed to upsert migration history with version: %s, err: %w", version, err)
}
return tx.Commit()
return nil
}
func (db *DB) seed(ctx context.Context) error {
@ -218,17 +225,11 @@ func (db *DB) seed(ctx context.Context) error {
// execute runs a single SQL statement within a transaction.
func (db *DB) execute(ctx context.Context, stmt string) error {
tx, err := db.DBInstance.Begin()
if err != nil {
return err
}
defer tx.Rollback()
if _, err := tx.ExecContext(ctx, stmt); err != nil {
if _, err := db.DBInstance.ExecContext(ctx, stmt); err != nil {
return fmt.Errorf("failed to execute statement, err: %w", err)
}
return tx.Commit()
return nil
}
// minorDirRegexp is a regular expression for minor version directory.

View File

@ -43,7 +43,8 @@ CREATE TABLE shortcut (
link TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
visibility TEXT NOT NULL CHECK (visibility IN ('PRIVATE', 'WORKSPACE', 'PUBLIC')) DEFAULT 'PRIVATE',
tag TEXT NOT NULL DEFAULT ''
tag TEXT NOT NULL DEFAULT '',
og_metadata TEXT NOT NULL DEFAULT '{}'
);
CREATE INDEX idx_shortcut_name ON shortcut(name);

View File

@ -0,0 +1 @@
ALTER TABLE shortcut ADD COLUMN og_metadata TEXT NOT NULL DEFAULT '{}';

View File

@ -43,7 +43,8 @@ CREATE TABLE shortcut (
link TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
visibility TEXT NOT NULL CHECK (visibility IN ('PRIVATE', 'WORKSPACE', 'PUBLIC')) DEFAULT 'PRIVATE',
tag TEXT NOT NULL DEFAULT ''
tag TEXT NOT NULL DEFAULT '',
og_metadata TEXT NOT NULL DEFAULT '{}'
);
CREATE INDEX idx_shortcut_name ON shortcut(name);

View File

@ -2,7 +2,6 @@ package db
import (
"context"
"database/sql"
"strings"
)
@ -20,47 +19,13 @@ type MigrationHistoryFind struct {
}
func (db *DB) FindMigrationHistoryList(ctx context.Context, find *MigrationHistoryFind) ([]*MigrationHistory, error) {
tx, err := db.DBInstance.BeginTx(ctx, nil)
if err != nil {
return nil, err
}
defer tx.Rollback()
list, err := findMigrationHistoryList(ctx, tx, find)
if err != nil {
return nil, err
}
return list, nil
}
func (db *DB) UpsertMigrationHistory(ctx context.Context, upsert *MigrationHistoryUpsert) (*MigrationHistory, error) {
tx, err := db.DBInstance.BeginTx(ctx, nil)
if err != nil {
return nil, err
}
defer tx.Rollback()
migrationHistory, err := upsertMigrationHistory(ctx, tx, upsert)
if err != nil {
return nil, err
}
if err := tx.Commit(); err != nil {
return nil, err
}
return migrationHistory, nil
}
func findMigrationHistoryList(ctx context.Context, tx *sql.Tx, find *MigrationHistoryFind) ([]*MigrationHistory, error) {
where, args := []string{"1 = 1"}, []any{}
if v := find.Version; v != nil {
where, args = append(where, "version = ?"), append(args, *v)
}
query := `
stmt := `
SELECT
version,
created_ts
@ -69,7 +34,7 @@ func findMigrationHistoryList(ctx context.Context, tx *sql.Tx, find *MigrationHi
WHERE ` + strings.Join(where, " AND ") + `
ORDER BY created_ts DESC
`
rows, err := tx.QueryContext(ctx, query, args...)
rows, err := db.DBInstance.QueryContext(ctx, stmt, args...)
if err != nil {
return nil, err
}
@ -84,7 +49,6 @@ func findMigrationHistoryList(ctx context.Context, tx *sql.Tx, find *MigrationHi
); err != nil {
return nil, err
}
migrationHistoryList = append(migrationHistoryList, &migrationHistory)
}
@ -95,7 +59,7 @@ func findMigrationHistoryList(ctx context.Context, tx *sql.Tx, find *MigrationHi
return migrationHistoryList, nil
}
func upsertMigrationHistory(ctx context.Context, tx *sql.Tx, upsert *MigrationHistoryUpsert) (*MigrationHistory, error) {
func (db *DB) UpsertMigrationHistory(ctx context.Context, upsert *MigrationHistoryUpsert) (*MigrationHistory, error) {
query := `
INSERT INTO migration_history (
version
@ -107,7 +71,7 @@ func upsertMigrationHistory(ctx context.Context, tx *sql.Tx, upsert *MigrationHi
RETURNING version, created_ts
`
migrationHistory := &MigrationHistory{}
if err := tx.QueryRowContext(ctx, query, upsert.Version).Scan(
if err := db.DBInstance.QueryRowContext(ctx, query, upsert.Version).Scan(
&migrationHistory.Version,
&migrationHistory.CreatedTs,
); err != nil {

View File

@ -38,15 +38,36 @@ INSERT INTO
`creator_id`,
`name`,
`link`,
`visibility`
`visibility`,
`og_metadata`
)
VALUES
(
3,
101,
'ai-infra',
'https://star-history.com/blog/open-source-ai-infra-projects',
'PUBLIC',
'{"title":"Open Source AI Infra for Your Next Project","description":"Some open-source infra projects that can be directly used for your next project. 💡","image":"https://star-history.com/blog/assets/open-source-ai-infra-projects/banner.webp"}'
);
INSERT INTO
shortcut (
`id`,
`creator_id`,
`name`,
`link`,
`visibility`,
`og_metadata`
)
VALUES
(
4,
101,
'schema-change',
'https://www.bytebase.com/blog/how-to-handle-database-schema-change/#what-is-a-database-schema-change',
'PUBLIC'
'PUBLIC',
'{"title":"How to Handle Database Migration / Schema Change?","description":"A database schema is the structure of a database, which describes the relationships between the different tables and fields in the database. A database schema change, also known as schema migration, or simply migration refers to any alteration to this structure, such as adding a new table, modifying the data type of a field, or changing the relationships between tables.","image":"https://www.bytebase.com/_next/image/?url=%2Fcontent%2Fblog%2Fhow-to-handle-database-schema-change%2Fchange.webp\u0026w=2048\u0026q=75"}'
);
INSERT INTO
@ -59,7 +80,7 @@ INSERT INTO
)
VALUES
(
4,
5,
102,
'stevenlgtm',
'https://github.com/boojack',

View File

@ -3,6 +3,7 @@ package store
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"strings"
)
@ -31,6 +32,12 @@ func (e Visibility) String() string {
return "PRIVATE"
}
type OpenGraphMetadata struct {
Title string `json:"title"`
Description string `json:"description"`
Image string `json:"image"`
}
type Shortcut struct {
ID int
@ -41,22 +48,24 @@ type Shortcut struct {
RowStatus RowStatus
// Domain specific fields
Name string
Link string
Description string
Visibility Visibility
Tag string
Name string
Link string
Description string
Visibility Visibility
Tag string
OpenGraphMetadata *OpenGraphMetadata
}
type UpdateShortcut struct {
ID int
RowStatus *RowStatus
Name *string
Link *string
Description *string
Visibility *Visibility
Tag *string
RowStatus *RowStatus
Name *string
Link *string
Description *string
Visibility *Visibility
Tag *string
OpenGraphMetadata *OpenGraphMetadata
}
type FindShortcut struct {
@ -73,24 +82,27 @@ type DeleteShortcut struct {
}
func (s *Store) CreateShortcut(ctx context.Context, create *Shortcut) (*Shortcut, error) {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return nil, err
}
defer tx.Rollback()
set := []string{"creator_id", "name", "link", "description", "visibility", "tag"}
args := []any{create.CreatorID, create.Name, create.Link, create.Description, create.Visibility, create.Tag}
placeholder := []string{"?", "?", "?", "?", "?", "?"}
if create.OpenGraphMetadata != nil {
set = append(set, "og_metadata")
openGraphMetadataBytes, err := json.Marshal(create.OpenGraphMetadata)
if err != nil {
return nil, err
}
args = append(args, string(openGraphMetadataBytes))
placeholder = append(placeholder, "?")
}
query := `
stmt := `
INSERT INTO shortcut (
` + strings.Join(set, ", ") + `
)
VALUES (` + strings.Join(placeholder, ",") + `)
RETURNING id, created_ts, updated_ts, row_status
`
if err := tx.QueryRowContext(ctx, query, args...).Scan(
if err := s.db.QueryRowContext(ctx, stmt, args...).Scan(
&create.ID,
&create.CreatedTs,
&create.UpdatedTs,
@ -99,20 +111,10 @@ func (s *Store) CreateShortcut(ctx context.Context, create *Shortcut) (*Shortcut
return nil, err
}
if err := tx.Commit(); err != nil {
return nil, err
}
return create, nil
}
func (s *Store) UpdateShortcut(ctx context.Context, update *UpdateShortcut) (*Shortcut, error) {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return nil, err
}
defer tx.Rollback()
set, args := []string{}, []any{}
if update.RowStatus != nil {
set, args = append(set, "row_status = ?"), append(args, update.RowStatus.String())
@ -132,21 +134,29 @@ func (s *Store) UpdateShortcut(ctx context.Context, update *UpdateShortcut) (*Sh
if update.Tag != nil {
set, args = append(set, "tag = ?"), append(args, *update.Tag)
}
if update.OpenGraphMetadata != nil {
openGraphMetadataBytes, err := json.Marshal(update.OpenGraphMetadata)
if err != nil {
return nil, err
}
set, args = append(set, "og_metadata = ?"), append(args, string(openGraphMetadataBytes))
}
if len(set) == 0 {
return nil, fmt.Errorf("no update specified")
}
args = append(args, update.ID)
query := `
stmt := `
UPDATE shortcut
SET
` + strings.Join(set, ", ") + `
WHERE
id = ?
RETURNING id, creator_id, created_ts, updated_ts, row_status, name, link, description, visibility, tag
RETURNING id, creator_id, created_ts, updated_ts, row_status, name, link, description, visibility, tag, og_metadata
`
shortcut := &Shortcut{}
if err := tx.QueryRowContext(ctx, query, args...).Scan(
openGraphMetadataString := ""
if err := s.db.QueryRowContext(ctx, stmt, args...).Scan(
&shortcut.ID,
&shortcut.CreatorID,
&shortcut.CreatedTs,
@ -157,12 +167,15 @@ func (s *Store) UpdateShortcut(ctx context.Context, update *UpdateShortcut) (*Sh
&shortcut.Description,
&shortcut.Visibility,
&shortcut.Tag,
&openGraphMetadataString,
); err != nil {
return nil, err
}
if err := tx.Commit(); err != nil {
return nil, err
if openGraphMetadataString != "" {
shortcut.OpenGraphMetadata = &OpenGraphMetadata{}
if err := json.Unmarshal([]byte(openGraphMetadataString), shortcut.OpenGraphMetadata); err != nil {
return nil, err
}
}
s.shortcutCache.Store(shortcut.ID, shortcut)
@ -170,73 +183,7 @@ func (s *Store) UpdateShortcut(ctx context.Context, update *UpdateShortcut) (*Sh
}
func (s *Store) ListShortcuts(ctx context.Context, find *FindShortcut) ([]*Shortcut, error) {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return nil, err
}
defer tx.Rollback()
list, err := listShortcuts(ctx, tx, find)
if err != nil {
return nil, err
}
for _, shortcut := range list {
s.shortcutCache.Store(shortcut.ID, shortcut)
}
return list, nil
}
func (s *Store) GetShortcut(ctx context.Context, find *FindShortcut) (*Shortcut, error) {
if find.ID != nil {
if cache, ok := s.shortcutCache.Load(*find.ID); ok {
return cache.(*Shortcut), nil
}
}
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return nil, err
}
defer tx.Rollback()
shortcuts, err := listShortcuts(ctx, tx, find)
if err != nil {
return nil, err
}
if len(shortcuts) == 0 {
return nil, nil
}
shortcut := shortcuts[0]
s.shortcutCache.Store(shortcut.ID, shortcut)
return shortcut, nil
}
func (s *Store) DeleteShortcut(ctx context.Context, delete *DeleteShortcut) error {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
if _, err := tx.ExecContext(ctx, `DELETE FROM shortcut WHERE id = ?`, delete.ID); err != nil {
return err
}
if err := tx.Commit(); err != nil {
// do nothing here to prevent linter warning.
return err
}
s.shortcutCache.Delete(delete.ID)
return nil
}
func listShortcuts(ctx context.Context, tx *sql.Tx, find *FindShortcut) ([]*Shortcut, error) {
where, args := []string{"1 = 1"}, []any{}
if v := find.ID; v != nil {
where, args = append(where, "id = ?"), append(args, *v)
}
@ -261,7 +208,7 @@ func listShortcuts(ctx context.Context, tx *sql.Tx, find *FindShortcut) ([]*Shor
where, args = append(where, "tag LIKE ?"), append(args, "%"+*v+"%")
}
rows, err := tx.QueryContext(ctx, `
rows, err := s.db.QueryContext(ctx, `
SELECT
id,
creator_id,
@ -272,7 +219,8 @@ func listShortcuts(ctx context.Context, tx *sql.Tx, find *FindShortcut) ([]*Shor
link,
description,
visibility,
tag
tag,
og_metadata
FROM shortcut
WHERE `+strings.Join(where, " AND ")+`
ORDER BY created_ts DESC`,
@ -286,6 +234,7 @@ func listShortcuts(ctx context.Context, tx *sql.Tx, find *FindShortcut) ([]*Shor
list := make([]*Shortcut, 0)
for rows.Next() {
shortcut := &Shortcut{}
openGraphMetadataString := ""
if err := rows.Scan(
&shortcut.ID,
&shortcut.CreatorID,
@ -297,9 +246,16 @@ func listShortcuts(ctx context.Context, tx *sql.Tx, find *FindShortcut) ([]*Shor
&shortcut.Description,
&shortcut.Visibility,
&shortcut.Tag,
&openGraphMetadataString,
); err != nil {
return nil, err
}
if openGraphMetadataString != "" {
shortcut.OpenGraphMetadata = &OpenGraphMetadata{}
if err := json.Unmarshal([]byte(openGraphMetadataString), shortcut.OpenGraphMetadata); err != nil {
return nil, err
}
}
list = append(list, shortcut)
}
@ -307,9 +263,43 @@ func listShortcuts(ctx context.Context, tx *sql.Tx, find *FindShortcut) ([]*Shor
return nil, err
}
for _, shortcut := range list {
s.shortcutCache.Store(shortcut.ID, shortcut)
}
return list, nil
}
func (s *Store) GetShortcut(ctx context.Context, find *FindShortcut) (*Shortcut, error) {
if find.ID != nil {
if cache, ok := s.shortcutCache.Load(*find.ID); ok {
return cache.(*Shortcut), nil
}
}
shortcuts, err := s.ListShortcuts(ctx, find)
if err != nil {
return nil, err
}
if len(shortcuts) == 0 {
return nil, nil
}
shortcut := shortcuts[0]
s.shortcutCache.Store(shortcut.ID, shortcut)
return shortcut, nil
}
func (s *Store) DeleteShortcut(ctx context.Context, delete *DeleteShortcut) error {
if _, err := s.db.ExecContext(ctx, `DELETE FROM shortcut WHERE id = ?`, delete.ID); err != nil {
return err
}
s.shortcutCache.Delete(delete.ID)
return nil
}
func vacuumShortcut(ctx context.Context, tx *sql.Tx) error {
stmt := `
DELETE FROM

View File

@ -2,7 +2,6 @@ package store
import (
"context"
"database/sql"
"fmt"
"strings"
)
@ -55,13 +54,7 @@ type DeleteUser struct {
}
func (s *Store) CreateUser(ctx context.Context, create *User) (*User, error) {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return nil, err
}
defer tx.Rollback()
query := `
stmt := `
INSERT INTO user (
email,
nickname,
@ -71,7 +64,7 @@ func (s *Store) CreateUser(ctx context.Context, create *User) (*User, error) {
VALUES (?, ?, ?, ?)
RETURNING id, created_ts, updated_ts, row_status
`
if err := tx.QueryRowContext(ctx, query,
if err := s.db.QueryRowContext(ctx, stmt,
create.Email,
create.Nickname,
create.PasswordHash,
@ -85,22 +78,12 @@ func (s *Store) CreateUser(ctx context.Context, create *User) (*User, error) {
return nil, err
}
if err := tx.Commit(); err != nil {
return nil, err
}
user := create
s.userCache.Store(user.ID, user)
return user, nil
}
func (s *Store) UpdateUser(ctx context.Context, update *UpdateUser) (*User, error) {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return nil, err
}
defer tx.Rollback()
set, args := []string{}, []any{}
if v := update.RowStatus; v != nil {
set, args = append(set, "row_status = ?"), append(args, *v)
@ -122,7 +105,7 @@ func (s *Store) UpdateUser(ctx context.Context, update *UpdateUser) (*User, erro
return nil, fmt.Errorf("no fields to update")
}
query := `
stmt := `
UPDATE user
SET ` + strings.Join(set, ", ") + `
WHERE id = ?
@ -130,7 +113,7 @@ func (s *Store) UpdateUser(ctx context.Context, update *UpdateUser) (*User, erro
`
args = append(args, update.ID)
user := &User{}
if err := tx.QueryRowContext(ctx, query, args...).Scan(
if err := s.db.QueryRowContext(ctx, stmt, args...).Scan(
&user.ID,
&user.CreatedTs,
&user.UpdatedTs,
@ -143,23 +126,68 @@ func (s *Store) UpdateUser(ctx context.Context, update *UpdateUser) (*User, erro
return nil, err
}
if err := tx.Commit(); err != nil {
return nil, err
}
s.userCache.Store(user.ID, user)
return user, nil
}
func (s *Store) ListUsers(ctx context.Context, find *FindUser) ([]*User, error) {
tx, err := s.db.BeginTx(ctx, nil)
where, args := []string{"1 = 1"}, []any{}
if v := find.ID; v != nil {
where, args = append(where, "id = ?"), append(args, *v)
}
if v := find.RowStatus; v != nil {
where, args = append(where, "row_status = ?"), append(args, v.String())
}
if v := find.Email; v != nil {
where, args = append(where, "email = ?"), append(args, *v)
}
if v := find.Nickname; v != nil {
where, args = append(where, "nickname = ?"), append(args, *v)
}
if v := find.Role; v != nil {
where, args = append(where, "role = ?"), append(args, *v)
}
query := `
SELECT
id,
created_ts,
updated_ts,
row_status,
email,
nickname,
password_hash,
role
FROM user
WHERE ` + strings.Join(where, " AND ") + `
ORDER BY updated_ts DESC, created_ts DESC
`
rows, err := s.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, err
}
defer tx.Rollback()
defer rows.Close()
list, err := listUsers(ctx, tx, find)
if err != nil {
list := make([]*User, 0)
for rows.Next() {
user := &User{}
if err := rows.Scan(
&user.ID,
&user.CreatedTs,
&user.UpdatedTs,
&user.RowStatus,
&user.Email,
&user.Nickname,
&user.PasswordHash,
&user.Role,
); err != nil {
return nil, err
}
list = append(list, user)
}
if err := rows.Err(); err != nil {
return nil, err
}
@ -177,13 +205,7 @@ func (s *Store) GetUser(ctx context.Context, find *FindUser) (*User, error) {
}
}
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return nil, err
}
defer tx.Rollback()
list, err := listUsers(ctx, tx, find)
list, err := s.ListUsers(ctx, find)
if err != nil {
return nil, err
}
@ -217,7 +239,6 @@ func (s *Store) DeleteUser(ctx context.Context, delete *DeleteUser) error {
}
if err := tx.Commit(); err != nil {
// do nothing here to prevent linter warning.
return err
}
@ -225,67 +246,3 @@ func (s *Store) DeleteUser(ctx context.Context, delete *DeleteUser) error {
return nil
}
func listUsers(ctx context.Context, tx *sql.Tx, find *FindUser) ([]*User, error) {
where, args := []string{"1 = 1"}, []any{}
if v := find.ID; v != nil {
where, args = append(where, "id = ?"), append(args, *v)
}
if v := find.RowStatus; v != nil {
where, args = append(where, "row_status = ?"), append(args, v.String())
}
if v := find.Email; v != nil {
where, args = append(where, "email = ?"), append(args, *v)
}
if v := find.Nickname; v != nil {
where, args = append(where, "nickname = ?"), append(args, *v)
}
if v := find.Role; v != nil {
where, args = append(where, "role = ?"), append(args, *v)
}
query := `
SELECT
id,
created_ts,
updated_ts,
row_status,
email,
nickname,
password_hash,
role
FROM user
WHERE ` + strings.Join(where, " AND ") + `
ORDER BY updated_ts DESC, created_ts DESC
`
rows, err := tx.QueryContext(ctx, query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
list := make([]*User, 0)
for rows.Next() {
user := &User{}
if err := rows.Scan(
&user.ID,
&user.CreatedTs,
&user.UpdatedTs,
&user.RowStatus,
&user.Email,
&user.Nickname,
&user.PasswordHash,
&user.Role,
); err != nil {
return nil, err
}
list = append(list, user)
}
if err := rows.Err(); err != nil {
return nil, err
}
return list, nil
}

View File

@ -18,13 +18,7 @@ type FindUserSetting struct {
}
func (s *Store) UpsertUserSetting(ctx context.Context, upsert *UserSetting) (*UserSetting, error) {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return nil, err
}
defer tx.Rollback()
query := `
stmt := `
INSERT INTO user_setting (
user_id, key, value
)
@ -32,11 +26,7 @@ func (s *Store) UpsertUserSetting(ctx context.Context, upsert *UserSetting) (*Us
ON CONFLICT(user_id, key) DO UPDATE
SET value = EXCLUDED.value
`
if _, err := tx.ExecContext(ctx, query, upsert.UserID, upsert.Key, upsert.Value); err != nil {
return nil, err
}
if err := tx.Commit(); err != nil {
if _, err := s.db.ExecContext(ctx, stmt, upsert.UserID, upsert.Key, upsert.Value); err != nil {
return nil, err
}
@ -46,51 +36,6 @@ func (s *Store) UpsertUserSetting(ctx context.Context, upsert *UserSetting) (*Us
}
func (s *Store) ListUserSettings(ctx context.Context, find *FindUserSetting) ([]*UserSetting, error) {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return nil, err
}
defer tx.Rollback()
userSettingList, err := listUserSettings(ctx, tx, find)
if err != nil {
return nil, err
}
for _, userSetting := range userSettingList {
s.userSettingCache.Store(getUserSettingCacheKey(userSetting.UserID, userSetting.Key), userSetting)
}
return userSettingList, nil
}
func (s *Store) GetUserSetting(ctx context.Context, find *FindUserSetting) (*UserSetting, error) {
if find.UserID != nil && find.Key != "" {
if cache, ok := s.userSettingCache.Load(getUserSettingCacheKey(*find.UserID, find.Key)); ok {
return cache.(*UserSetting), nil
}
}
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return nil, err
}
defer tx.Rollback()
list, err := listUserSettings(ctx, tx, find)
if err != nil {
return nil, err
}
if len(list) == 0 {
return nil, nil
}
userSettingMessage := list[0]
s.userSettingCache.Store(getUserSettingCacheKey(userSettingMessage.UserID, userSettingMessage.Key), userSettingMessage)
return userSettingMessage, nil
}
func listUserSettings(ctx context.Context, tx *sql.Tx, find *FindUserSetting) ([]*UserSetting, error) {
where, args := []string{"1 = 1"}, []any{}
if v := find.Key; v != "" {
@ -107,30 +52,54 @@ func listUserSettings(ctx context.Context, tx *sql.Tx, find *FindUserSetting) ([
value
FROM user_setting
WHERE ` + strings.Join(where, " AND ")
rows, err := tx.QueryContext(ctx, query, args...)
rows, err := s.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
userSettingMessageList := make([]*UserSetting, 0)
userSettingList := make([]*UserSetting, 0)
for rows.Next() {
userSettingMessage := &UserSetting{}
userSetting := &UserSetting{}
if err := rows.Scan(
&userSettingMessage.UserID,
&userSettingMessage.Key,
&userSettingMessage.Value,
&userSetting.UserID,
&userSetting.Key,
&userSetting.Value,
); err != nil {
return nil, err
}
userSettingMessageList = append(userSettingMessageList, userSettingMessage)
userSettingList = append(userSettingList, userSetting)
}
if err := rows.Err(); err != nil {
return nil, err
}
return userSettingMessageList, nil
for _, userSetting := range userSettingList {
s.userSettingCache.Store(getUserSettingCacheKey(userSetting.UserID, userSetting.Key), userSetting)
}
return userSettingList, nil
}
func (s *Store) GetUserSetting(ctx context.Context, find *FindUserSetting) (*UserSetting, error) {
if find.UserID != nil && find.Key != "" {
if cache, ok := s.userSettingCache.Load(getUserSettingCacheKey(*find.UserID, find.Key)); ok {
return cache.(*UserSetting), nil
}
}
list, err := s.ListUserSettings(ctx, find)
if err != nil {
return nil, err
}
if len(list) == 0 {
return nil, nil
}
userSettingMessage := list[0]
s.userSettingCache.Store(getUserSettingCacheKey(userSettingMessage.UserID, userSettingMessage.Key), userSettingMessage)
return userSettingMessage, nil
}
func vacuumUserSetting(ctx context.Context, tx *sql.Tx) error {

View File

@ -2,7 +2,6 @@ package store
import (
"context"
"database/sql"
"strings"
)
@ -17,13 +16,7 @@ const (
// String returns the string format of WorkspaceSettingKey type.
func (key WorkspaceSettingKey) String() string {
switch key {
case WorkspaceDisallowSignUp:
return "disallow-signup"
case WorkspaceSecretSessionName:
return "secret-session-name"
}
return ""
return string(key)
}
type WorkspaceSetting struct {
@ -36,13 +29,7 @@ type FindWorkspaceSetting struct {
}
func (s *Store) UpsertWorkspaceSetting(ctx context.Context, upsert *WorkspaceSetting) (*WorkspaceSetting, error) {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return nil, err
}
defer tx.Rollback()
query := `
stmt := `
INSERT INTO workspace_setting (
key,
value
@ -51,11 +38,7 @@ func (s *Store) UpsertWorkspaceSetting(ctx context.Context, upsert *WorkspaceSet
ON CONFLICT(key) DO UPDATE
SET value = EXCLUDED.value
`
if _, err := tx.ExecContext(ctx, query, upsert.Key, upsert.Value); err != nil {
return nil, err
}
if err := tx.Commit(); err != nil {
if _, err := s.db.ExecContext(ctx, stmt, upsert.Key, upsert.Value); err != nil {
return nil, err
}
@ -65,53 +48,8 @@ func (s *Store) UpsertWorkspaceSetting(ctx context.Context, upsert *WorkspaceSet
}
func (s *Store) ListWorkspaceSettings(ctx context.Context, find *FindWorkspaceSetting) ([]*WorkspaceSetting, error) {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return nil, err
}
defer tx.Rollback()
list, err := listWorkspaceSettings(ctx, tx, find)
if err != nil {
return nil, err
}
for _, workspaceSetting := range list {
s.workspaceSettingCache.Store(workspaceSetting.Key, workspaceSetting)
}
return list, nil
}
func (s *Store) GetWorkspaceSetting(ctx context.Context, find *FindWorkspaceSetting) (*WorkspaceSetting, error) {
if find.Key != "" {
if cache, ok := s.workspaceSettingCache.Load(find.Key); ok {
return cache.(*WorkspaceSetting), nil
}
}
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return nil, err
}
defer tx.Rollback()
list, err := listWorkspaceSettings(ctx, tx, find)
if err != nil {
return nil, err
}
if len(list) == 0 {
return nil, nil
}
workspaceSetting := list[0]
s.workspaceSettingCache.Store(workspaceSetting.Key, workspaceSetting)
return workspaceSetting, nil
}
func listWorkspaceSettings(ctx context.Context, tx *sql.Tx, find *FindWorkspaceSetting) ([]*WorkspaceSetting, error) {
where, args := []string{"1 = 1"}, []any{}
if find.Key != "" {
where, args = append(where, "key = ?"), append(args, find.Key)
}
@ -122,7 +60,7 @@ func listWorkspaceSettings(ctx context.Context, tx *sql.Tx, find *FindWorkspaceS
value
FROM workspace_setting
WHERE ` + strings.Join(where, " AND ")
rows, err := tx.QueryContext(ctx, query, args...)
rows, err := s.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, err
}
@ -146,5 +84,30 @@ func listWorkspaceSettings(ctx context.Context, tx *sql.Tx, find *FindWorkspaceS
return nil, err
}
for _, workspaceSetting := range list {
s.workspaceSettingCache.Store(workspaceSetting.Key, workspaceSetting)
}
return list, nil
}
func (s *Store) GetWorkspaceSetting(ctx context.Context, find *FindWorkspaceSetting) (*WorkspaceSetting, error) {
if find.Key != "" {
if cache, ok := s.workspaceSettingCache.Load(find.Key); ok {
return cache.(*WorkspaceSetting), nil
}
}
list, err := s.ListWorkspaceSettings(ctx, find)
if err != nil {
return nil, err
}
if len(list) == 0 {
return nil, nil
}
workspaceSetting := list[0]
s.workspaceSettingCache.Store(workspaceSetting.Key, workspaceSetting)
return workspaceSetting, nil
}

View File

@ -14,12 +14,13 @@ func TestShortcutStore(t *testing.T) {
user, err := createTestingAdminUser(ctx, ts)
require.NoError(t, err)
shortcut, err := ts.CreateShortcut(ctx, &store.Shortcut{
CreatorID: user.ID,
Name: "test",
Link: "https://test.link",
Description: "A test shortcut",
Visibility: store.VisibilityPrivate,
Tag: "test link",
CreatorID: user.ID,
Name: "test",
Link: "https://test.link",
Description: "A test shortcut",
Visibility: store.VisibilityPrivate,
Tag: "test link",
OpenGraphMetadata: &store.OpenGraphMetadata{},
})
require.NoError(t, err)
shortcuts, err := ts.ListShortcuts(ctx, &store.FindShortcut{

View File

@ -11,7 +11,7 @@ interface Props {
const AnalyticsDialog: React.FC<Props> = (props: Props) => {
const { shortcutId, onClose } = props;
const [analytics, setAnalytics] = useState<AnalysisData | null>(null);
const [selectedDeviceTab, setSelectedDeviceTab] = useState<"os" | "browser">("os");
const [selectedDeviceTab, setSelectedDeviceTab] = useState<"os" | "browser">("browser");
useEffect(() => {
api.getShortcutAnalytics(shortcutId).then(({ data }) => {
@ -32,22 +32,16 @@ const AnalyticsDialog: React.FC<Props> = (props: Props) => {
{analytics ? (
<>
<p className="w-full py-1 px-2">Top Sources</p>
<div className="mt-1 overflow-hidden shadow ring-1 ring-black ring-opacity-5 sm:rounded-lg">
<table className="min-w-full divide-y divide-gray-300">
<thead>
<tr>
<th scope="col" className="py-1 px-2 text-left font-semibold text-sm text-gray-500">
Source
</th>
<th scope="col" className="py-1 pr-2 text-right font-semibold text-sm text-gray-500">
Visitors
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200">
<div className="w-full mt-1 overflow-hidden shadow ring-1 ring-black ring-opacity-5 rounded-lg">
<div className="w-full divide-y divide-gray-300">
<div className="w-full flex flex-row justify-between items-center">
<span className="py-1 px-2 text-left font-semibold text-sm text-gray-500">Source</span>
<span className="py-1 pr-2 text-right font-semibold text-sm text-gray-500">Visitors</span>
</div>
<div className="w-full divide-y divide-gray-200">
{analytics.referenceData.map((reference) => (
<tr key={reference.name}>
<td className="whitespace-nowrap py-2 px-2 text-sm text-gray-900">
<div key={reference.name} className="w-full flex flex-row justify-between items-center">
<span className="whitespace-nowrap py-2 px-2 text-sm truncate text-gray-900">
{reference.name ? (
<a className="hover:underline hover:text-blue-600" href={reference.name} target="_blank">
{reference.name}
@ -55,28 +49,17 @@ const AnalyticsDialog: React.FC<Props> = (props: Props) => {
) : (
"Direct"
)}
</td>
<td className="whitespace-nowrap py-2 pr-2 text-sm text-gray-500 text-right">{reference.count}</td>
</tr>
</span>
<span className="whitespace-nowrap py-2 pr-2 text-sm text-gray-500 text-right shrink-0">{reference.count}</span>
</div>
))}
</tbody>
</table>
</div>
</div>
</div>
<div className="w-full mt-4 py-1 px-2 flex flex-row justify-between items-center">
<span>Devices</span>
<div>
<button
className={`whitespace-nowrap border-b-2 px-1 text-sm font-medium ${
selectedDeviceTab === "os"
? "border-blue-600 text-blue-600"
: "border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700"
}`}
onClick={() => setSelectedDeviceTab("os")}
>
OS
</button>
<span className="text-gray-200 font-mono mx-1">/</span>
<button
className={`whitespace-nowrap border-b-2 px-1 text-sm font-medium ${
selectedDeviceTab === "browser"
@ -87,56 +70,60 @@ const AnalyticsDialog: React.FC<Props> = (props: Props) => {
>
Browser
</button>
<span className="text-gray-200 font-mono mx-1">/</span>
<button
className={`whitespace-nowrap border-b-2 px-1 text-sm font-medium ${
selectedDeviceTab === "os"
? "border-blue-600 text-blue-600"
: "border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700"
}`}
onClick={() => setSelectedDeviceTab("os")}
>
OS
</button>
</div>
</div>
<div className="mt-1 overflow-hidden shadow ring-1 ring-black ring-opacity-5 sm:rounded-lg">
{selectedDeviceTab === "os" ? (
<table className="min-w-full divide-y divide-gray-300">
<thead>
<tr>
<th scope="col" className="py-2 px-2 text-left text-sm font-semibold text-gray-500">
Operating system
</th>
<th scope="col" className="py-2 pr-2 text-right text-sm font-semibold text-gray-500">
Visitors
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200">
{analytics.deviceData.map((reference) => (
<tr key={reference.name}>
<td className="whitespace-nowrap py-2 px-2 text-sm text-gray-900">{reference.name || "Unknown"}</td>
<td className="whitespace-nowrap py-2 pr-2 text-sm text-gray-500 text-right">{reference.count}</td>
</tr>
))}
</tbody>
</table>
) : (
<table className="min-w-full divide-y divide-gray-300">
<thead>
<tr>
<th scope="col" className="py-2 px-2 text-left text-sm font-semibold text-gray-500">
Browsers
</th>
<th scope="col" className="py-2 pr-2 text-right text-sm font-semibold text-gray-500">
Visitors
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200">
<div className="w-full mt-1 overflow-hidden shadow ring-1 ring-black ring-opacity-5 rounded-lg">
{selectedDeviceTab === "browser" ? (
<div className="w-full divide-y divide-gray-300">
<div className="w-full flex flex-row justify-between items-center">
<span className="py-2 px-2 text-left text-sm font-semibold text-gray-500">Browsers</span>
<span className="py-2 pr-2 text-right text-sm font-semibold text-gray-500">Visitors</span>
</div>
<div className="w-full divide-y divide-gray-200">
{analytics.browserData.map((reference) => (
<tr key={reference.name}>
<td className="whitespace-nowrap py-2 px-2 text-sm text-gray-900">{reference.name || "Unknown"}</td>
<td className="whitespace-nowrap py-2 pr-2 text-sm text-gray-500 text-right">{reference.count}</td>
</tr>
<div key={reference.name} className="w-full flex flex-row justify-between items-center">
<span className="whitespace-nowrap py-2 px-2 text-sm text-gray-900 truncate">{reference.name || "Unknown"}</span>
<span className="whitespace-nowrap py-2 pr-2 text-sm text-gray-500 text-right shrink-0">{reference.count}</span>
</div>
))}
</tbody>
</table>
</div>
</div>
) : (
<div className="w-full divide-y divide-gray-300">
<div className="w-full flex flex-row justify-between items-center">
<span className="py-2 px-2 text-left text-sm font-semibold text-gray-500">Operating system</span>
<span className="py-2 pr-2 text-right text-sm font-semibold text-gray-500">Visitors</span>
</div>
<div className="w-full divide-y divide-gray-200">
{analytics.deviceData.map((device) => (
<div key={device.name} className="w-full flex flex-row justify-between items-center">
<span className="whitespace-nowrap py-2 px-2 text-sm text-gray-900 truncate">{device.name || "Unknown"}</span>
<span className="whitespace-nowrap py-2 pr-2 text-sm text-gray-500 text-right shrink-0">{device.count}</span>
</div>
))}
</div>
</div>
)}
</div>
</>
) : null}
) : (
<div className="py-12 w-full flex flex-row justify-center items-center opacity-80">
<Icon.Loader className="mr-2 w-5 h-auto animate-spin" />
loading
</div>
)}
</div>
</ModalDialog>
</Modal>

View File

@ -1,4 +1,4 @@
import { Button, Input, Modal, ModalDialog, Radio, RadioGroup } from "@mui/joy";
import { Button, Divider, Input, Modal, ModalDialog, Radio, RadioGroup, Textarea } from "@mui/joy";
import { isUndefined } from "lodash-es";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
@ -29,26 +29,34 @@ const CreateShortcutDialog: React.FC<Props> = (props: Props) => {
description: "",
visibility: "PRIVATE",
tags: [],
openGraphMetadata: {
title: "",
description: "",
image: "",
},
},
});
const [showDescriptionAndTag, setShowDescriptionAndTag] = useState<boolean>(false);
const [showOpenGraphMetadata, setShowOpenGraphMetadata] = useState<boolean>(false);
const [tag, setTag] = useState<string>("");
const requestState = useLoading(false);
const isCreating = isUndefined(shortcutId);
useEffect(() => {
if (shortcutId) {
const shortcutTemp = shortcutService.getShortcutById(shortcutId);
if (shortcutTemp) {
const shortcut = shortcutService.getShortcutById(shortcutId);
if (shortcut) {
setState({
...state,
shortcutCreate: Object.assign(state.shortcutCreate, {
name: shortcutTemp.name,
link: shortcutTemp.link,
description: shortcutTemp.description,
visibility: shortcutTemp.visibility,
name: shortcut.name,
link: shortcut.link,
description: shortcut.description,
visibility: shortcut.visibility,
openGraphMetadata: shortcut.openGraphMetadata,
}),
});
setTag(shortcutTemp.tags.join(" "));
setTag(shortcut.tags.join(" "));
}
}
}, [shortcutId]);
@ -76,6 +84,14 @@ const CreateShortcutDialog: React.FC<Props> = (props: Props) => {
});
};
const handleVisibilityInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setPartialState({
shortcutCreate: Object.assign(state.shortcutCreate, {
visibility: e.target.value,
}),
});
};
const handleDescriptionInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setPartialState({
shortcutCreate: Object.assign(state.shortcutCreate, {
@ -89,10 +105,35 @@ const CreateShortcutDialog: React.FC<Props> = (props: Props) => {
setTag(text);
};
const handleVisibilityInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const handleOpenGraphMetadataImageChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setPartialState({
shortcutCreate: Object.assign(state.shortcutCreate, {
visibility: e.target.value,
openGraphMetadata: {
...state.shortcutCreate.openGraphMetadata,
image: e.target.value,
},
}),
});
};
const handleOpenGraphMetadataTitleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setPartialState({
shortcutCreate: Object.assign(state.shortcutCreate, {
openGraphMetadata: {
...state.shortcutCreate.openGraphMetadata,
title: e.target.value,
},
}),
});
};
const handleOpenGraphMetadataDescriptionChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
setPartialState({
shortcutCreate: Object.assign(state.shortcutCreate, {
openGraphMetadata: {
...state.shortcutCreate.openGraphMetadata,
description: e.target.value,
},
}),
});
};
@ -112,6 +153,7 @@ const CreateShortcutDialog: React.FC<Props> = (props: Props) => {
description: state.shortcutCreate.description,
visibility: state.shortcutCreate.visibility,
tags: tag.split(" "),
openGraphMetadata: state.shortcutCreate.openGraphMetadata,
});
} else {
await shortcutService.createShortcut({
@ -140,7 +182,7 @@ const CreateShortcutDialog: React.FC<Props> = (props: Props) => {
<Icon.X className="w-5 h-auto text-gray-600" />
</Button>
</div>
<div>
<div className="overflow-y-auto">
<div className="w-full flex flex-col justify-start items-start mb-3">
<span className="mb-2">
Name <span className="text-red-600">*</span>
@ -157,30 +199,16 @@ const CreateShortcutDialog: React.FC<Props> = (props: Props) => {
</div>
<div className="w-full flex flex-col justify-start items-start mb-3">
<span className="mb-2">
Link <span className="text-red-600">*</span>
Destination URL <span className="text-red-600">*</span>
</span>
<Input
className="w-full"
type="text"
placeholder="The full URL of the page you want to get to"
placeholder="e.g. https://github.com/boojack/slash"
value={state.shortcutCreate.link}
onChange={handleLinkInputChange}
/>
</div>
<div className="w-full flex flex-col justify-start items-start mb-3">
<span className="mb-2">Description</span>
<Input
className="w-full"
type="text"
placeholder="Something to describe the link"
value={state.shortcutCreate.description}
onChange={handleDescriptionInputChange}
/>
</div>
<div className="w-full flex flex-col justify-start items-start mb-3">
<span className="mb-2">Tags</span>
<Input className="w-full" type="text" placeholder="Separated by spaces" value={tag} onChange={handleTagsInputChange} />
</div>
<div className="w-full flex flex-col justify-start items-start mb-3">
<span className="mb-2">
Visibility <span className="text-red-600">*</span>
@ -196,6 +224,100 @@ const CreateShortcutDialog: React.FC<Props> = (props: Props) => {
{t(`shortcut.visibility.${state.shortcutCreate.visibility.toLowerCase()}.description`)}
</p>
</div>
<Divider className="text-gray-500">Optional</Divider>
<div className="w-full flex flex-col justify-start items-start border rounded-md overflow-hidden my-3">
<div
className={`w-full flex flex-row justify-between items-center px-2 py-1 cursor-pointer hover:bg-gray-100 ${
showDescriptionAndTag ? "bg-gray-100" : ""
}`}
onClick={() => setShowDescriptionAndTag(!showDescriptionAndTag)}
>
<span className="text-sm">Description and tags</span>
<button className="w-7 h-7 p-1 rounded-md">
<Icon.ChevronDown className={`w-4 h-auto text-gray-500 ${showDescriptionAndTag ? "transform rotate-180" : ""}`} />
</button>
</div>
{showDescriptionAndTag && (
<div className="w-full px-2 py-1">
<div className="w-full flex flex-col justify-start items-start mb-3">
<span className="mb-2 text-sm">Description</span>
<Input
className="w-full"
type="text"
placeholder="Something to describe the url"
size="sm"
value={state.shortcutCreate.description}
onChange={handleDescriptionInputChange}
/>
</div>
<div className="w-full flex flex-col justify-start items-start mb-3">
<span className="mb-2 text-sm">Tags</span>
<Input
className="w-full"
type="text"
placeholder="Separated by spaces"
size="sm"
value={tag}
onChange={handleTagsInputChange}
/>
</div>
</div>
)}
</div>
<div className="w-full flex flex-col justify-start items-start border rounded-md overflow-hidden">
<div
className={`w-full flex flex-row justify-between items-center px-2 py-1 cursor-pointer hover:bg-gray-100 ${
showOpenGraphMetadata ? "bg-gray-100" : ""
}`}
onClick={() => setShowOpenGraphMetadata(!showOpenGraphMetadata)}
>
<span className="text-sm flex flex-row justify-start items-center">
Social media metadata
<Icon.Sparkles className="ml-1 w-4 h-auto text-blue-600" />
</span>
<button className="w-7 h-7 p-1 rounded-md">
<Icon.ChevronDown className={`w-4 h-auto text-gray-500 ${showDescriptionAndTag ? "transform rotate-180" : ""}`} />
</button>
</div>
{showOpenGraphMetadata && (
<div className="w-full px-2 py-1">
<div className="w-full flex flex-col justify-start items-start mb-3">
<span className="mb-2 text-sm">Image URL</span>
<Input
className="w-full"
type="text"
placeholder="The image url"
size="sm"
value={state.shortcutCreate.openGraphMetadata.image}
onChange={handleOpenGraphMetadataImageChange}
/>
</div>
<div className="w-full flex flex-col justify-start items-start mb-3">
<span className="mb-2 text-sm">Title</span>
<Input
className="w-full"
type="text"
placeholder="Slash - A bookmarking and url shortener"
size="sm"
value={state.shortcutCreate.openGraphMetadata.title}
onChange={handleOpenGraphMetadataTitleChange}
/>
</div>
<div className="w-full flex flex-col justify-start items-start mb-3">
<span className="mb-2 text-sm">Description</span>
<Textarea
className="w-full"
placeholder="A bookmarking and url shortener, save and share your links very easily."
size="sm"
maxRows={3}
value={state.shortcutCreate.openGraphMetadata.description}
onChange={handleOpenGraphMetadataDescriptionChange}
/>
</div>
</div>
)}
</div>
<div className="w-full flex flex-row justify-end items-center mt-4 space-x-2">
<Button color="neutral" variant="plain" disabled={requestState.isLoading} loading={requestState.isLoading} onClick={onClose}>
Cancel

View File

@ -0,0 +1,54 @@
import { Select, Option, Button } from "@mui/joy";
import { toast } from "react-hot-toast";
import useViewStore from "../stores/v1/view";
import Dropdown from "./common/Dropdown";
import Icon from "./Icon";
const OrderSetting = () => {
const viewStore = useViewStore();
const order = viewStore.getOrder();
const { field, direction } = order;
const handleReset = () => {
viewStore.setOrder({ field: "name", direction: "asc" });
toast.success("Order reset");
};
return (
<Dropdown
trigger={
<button className="p-1 mr-2">
<Icon.ListFilter className="w-5 h-auto text-gray-500" />
</button>
}
actions={
<div className="w-52 p-2 pt-0 gap-2 flex flex-col justify-start items-start" onClick={(e) => e.stopPropagation()}>
<div className="w-full flex flex-row justify-between items-center mt-1">
<span className="text-sm font-medium">View order</span>
<Button size="sm" variant="plain" color="neutral" onClick={handleReset}>
<Icon.RefreshCw className="w-4 h-auto text-gray-500" />
</Button>
</div>
<div className="w-full flex flex-row justify-between items-center">
<span className="text-sm shrink-0 mr-2">Order by</span>
<Select size="sm" value={field} onChange={(_, value) => viewStore.setOrder({ field: value as any })}>
<Option value={"name"}>Name</Option>
<Option value={"updatedTs"}>CreatedAt</Option>
<Option value={"createdTs"}>UpdatedAt</Option>
<Option value={"view"}>Visits</Option>
</Select>
</div>
<div className="w-full flex flex-row justify-between items-center">
<span className="text-sm shrink-0 mr-2">Direction</span>
<Select size="sm" value={direction} onChange={(_, value) => viewStore.setOrder({ direction: value as any })}>
<Option value={"asc"}>ASC</Option>
<Option value={"desc"}>DESC</Option>
</Select>
</div>
</div>
}
></Dropdown>
);
};
export default OrderSetting;

View File

@ -81,7 +81,7 @@ const ShortcutView = (props: Props) => {
</a>
<Tooltip title="Copy" variant="solid" placement="top" arrow>
<button
className="hidden group-hover:block w-6 h-6 cursor-pointer rounded-full text-gray-500 hover:bg-gray-100 hover:shadow hover:text-blue-600"
className="hidden group-hover:block w-6 h-6 cursor-pointer rounded-md text-gray-500 hover:bg-gray-100 hover:shadow"
onClick={() => handleCopyButtonClick()}
>
<Icon.Clipboard className="w-4 h-auto mx-auto" />
@ -89,7 +89,7 @@ const ShortcutView = (props: Props) => {
</Tooltip>
<Tooltip title="QR Code" variant="solid" placement="top" arrow>
<button
className="hidden group-hover:block ml-1 w-6 h-6 cursor-pointer rounded-full text-gray-500 hover:bg-gray-100 hover:shadow hover:text-blue-600"
className="hidden group-hover:block ml-1 w-6 h-6 cursor-pointer rounded-md text-gray-500 hover:bg-gray-100 hover:shadow"
onClick={() => setShowQRCodeDialog(true)}
>
<Icon.QrCode className="w-4 h-auto mx-auto" />

View File

@ -21,10 +21,15 @@ const Dropdown: React.FC<Props> = (props: Props) => {
toggleDropdownStatus(false);
}
};
window.addEventListener("click", handleClickOutside, {
capture: true,
once: true,
});
return () => {
window.removeEventListener("click", handleClickOutside, {
capture: true,
});
};
}
}, [dropdownStatus]);

View File

@ -2,41 +2,19 @@ import { Button, Tab, TabList, Tabs } from "@mui/joy";
import { useEffect, useState } from "react";
import { shortcutService } from "../services";
import { useAppSelector } from "../stores";
import useViewStore, { Filter } from "../stores/v1/view";
import useViewStore, { getFilteredShortcutList, getOrderedShortcutList } from "../stores/v1/view";
import useUserStore from "../stores/v1/user";
import useLoading from "../hooks/useLoading";
import Icon from "../components/Icon";
import ShortcutListView from "../components/ShortcutListView";
import CreateShortcutDialog from "../components/CreateShortcutDialog";
import FilterView from "../components/FilterView";
import OrderSetting from "../components/OrderSetting";
interface State {
showCreateShortcutDialog: boolean;
}
const getFilteredShortcutList = (shortcutList: Shortcut[], filter: Filter, currentUser: User) => {
const { tag, mineOnly, visibility } = filter;
const filteredShortcutList = shortcutList.filter((shortcut) => {
if (tag) {
if (!shortcut.tags.includes(tag)) {
return false;
}
}
if (mineOnly) {
if (shortcut.creatorId !== currentUser.id) {
return false;
}
}
if (visibility) {
if (shortcut.visibility !== visibility) {
return false;
}
}
return true;
});
return filteredShortcutList;
};
const Home: React.FC = () => {
const loadingState = useLoading();
const currentUser = useUserStore().getCurrentUser();
@ -47,6 +25,7 @@ const Home: React.FC = () => {
});
const filter = viewStore.filter;
const filteredShortcutList = getFilteredShortcutList(shortcutList, filter, currentUser);
const orderedShortcutList = getOrderedShortcutList(filteredShortcutList, viewStore.order);
useEffect(() => {
Promise.all([shortcutService.getMyAllShortcuts()]).finally(() => {
@ -69,6 +48,12 @@ const Home: React.FC = () => {
</div>
<div className="w-full flex flex-row justify-between items-center mb-4">
<div className="flex flex-row justify-start items-center">
<Button className="hover:shadow" variant="soft" size="sm" onClick={() => setShowCreateShortcutDialog(true)}>
<Icon.Plus className="w-5 h-auto" /> New
</Button>
</div>
<div className="flex flex-row justify-end items-center">
<OrderSetting />
<Tabs
value={filter.mineOnly ? "PRIVATE" : "ALL"}
size="sm"
@ -80,11 +65,6 @@ const Home: React.FC = () => {
</TabList>
</Tabs>
</div>
<div>
<Button className="shadow" variant="soft" size="sm" onClick={() => setShowCreateShortcutDialog(true)}>
<Icon.Plus className="w-5 h-auto" /> New
</Button>
</div>
</div>
<FilterView />
@ -94,13 +74,13 @@ const Home: React.FC = () => {
<Icon.Loader className="mr-2 w-5 h-auto animate-spin" />
loading
</div>
) : filteredShortcutList.length === 0 ? (
) : orderedShortcutList.length === 0 ? (
<div className="py-16 w-full flex flex-col justify-center items-center">
<Icon.PackageOpen className="w-16 h-auto text-gray-400" />
<p className="mt-4">No shortcuts found.</p>
</div>
) : (
<ShortcutListView shortcutList={filteredShortcutList} />
<ShortcutListView shortcutList={orderedShortcutList} />
)}
</div>

View File

@ -7,18 +7,39 @@ export interface Filter {
visibility?: Visibility;
}
export interface Order {
field: "name" | "createdTs" | "updatedTs" | "view";
direction: "asc" | "desc";
}
interface ViewState {
filter: Filter;
order: Order;
setFilter: (filter: Partial<Filter>) => void;
getOrder: () => Order;
setOrder: (order: Partial<Order>) => void;
}
const useViewStore = create<ViewState>()(
persist(
(set, get) => ({
filter: {},
order: {
field: "name",
direction: "asc",
},
setFilter: (filter: Partial<Filter>) => {
set({ filter: { ...get().filter, ...filter } });
},
getOrder: () => {
return {
field: get().order.field || "name",
direction: get().order.direction || "asc",
};
},
setOrder: (order: Partial<Order>) => {
set({ order: { ...get().order, ...order } });
},
}),
{
name: "view",
@ -26,4 +47,48 @@ const useViewStore = create<ViewState>()(
)
);
export const getFilteredShortcutList = (shortcutList: Shortcut[], filter: Filter, currentUser: User) => {
const { tag, mineOnly, visibility } = filter;
const filteredShortcutList = shortcutList.filter((shortcut) => {
if (tag) {
if (!shortcut.tags.includes(tag)) {
return false;
}
}
if (mineOnly) {
if (shortcut.creatorId !== currentUser.id) {
return false;
}
}
if (visibility) {
if (shortcut.visibility !== visibility) {
return false;
}
}
return true;
});
return filteredShortcutList;
};
export const getOrderedShortcutList = (shortcutList: Shortcut[], order: Order) => {
const { field, direction } = {
field: order.field || "name",
direction: order.direction || "asc",
};
const orderedShortcutList = shortcutList.sort((a, b) => {
if (field === "name") {
return direction === "asc" ? a.name.localeCompare(b.name) : b.name.localeCompare(a.name);
} else if (field === "createdTs") {
return direction === "asc" ? a.createdTs - b.createdTs : b.createdTs - a.createdTs;
} else if (field === "updatedTs") {
return direction === "asc" ? a.updatedTs - b.updatedTs : b.updatedTs - a.updatedTs;
} else if (field === "view") {
return direction === "asc" ? a.view - b.view : b.view - a.view;
} else {
return 0;
}
});
return orderedShortcutList;
};
export default useViewStore;

View File

@ -2,6 +2,12 @@ type ShortcutId = number;
type Visibility = "PRIVATE" | "WORKSPACE" | "PUBLIC";
interface OpenGraphMetadata {
title: string;
description: string;
image: string;
}
interface Shortcut {
id: ShortcutId;
@ -16,6 +22,7 @@ interface Shortcut {
description: string;
visibility: Visibility;
tags: string[];
openGraphMetadata: OpenGraphMetadata;
view: number;
}
@ -25,6 +32,7 @@ interface ShortcutCreate {
description: string;
visibility: Visibility;
tags: string[];
openGraphMetadata: OpenGraphMetadata;
}
interface ShortcutPatch {
@ -35,6 +43,7 @@ interface ShortcutPatch {
description?: string;
visibility?: Visibility;
tags?: string[];
openGraphMetadata?: OpenGraphMetadata;
}
interface ShortcutFind {