mirror of
https://github.com/aykhans/slash-e.git
synced 2025-07-06 21:22:36 +00:00
Compare commits
16 Commits
Author | SHA1 | Date | |
---|---|---|---|
53c1d8fa91 | |||
b32fdbfc0a | |||
db2aebcf57 | |||
b4e23fc8a0 | |||
7ab66113ac | |||
2909676ed3 | |||
5af9236c19 | |||
04c0f47559 | |||
a91997683b | |||
014dd7d660 | |||
a1b633e4db | |||
57496c9b46 | |||
c4f38f1de6 | |||
e7cf0c2f79 | |||
15ffd0738c | |||
21ff8ba797 |
@ -88,6 +88,7 @@ func JWTMiddleware(server *APIV1Service, next echo.HandlerFunc, secret string) e
|
|||||||
if util.HasPrefixes(path, "/s/*") && method == http.MethodGet {
|
if util.HasPrefixes(path, "/s/*") && method == http.MethodGet {
|
||||||
return next(c)
|
return next(c)
|
||||||
}
|
}
|
||||||
|
auth.RemoveTokensAndCookies(c)
|
||||||
return echo.NewHTTPError(http.StatusUnauthorized, "Missing access token")
|
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"])
|
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
|
generateToken := time.Until(claims.ExpiresAt.Time) < auth.RefreshThresholdDuration
|
||||||
if err != nil {
|
if err != nil {
|
||||||
var ve *jwt.ValidationError
|
var ve *jwt.ValidationError
|
||||||
@ -116,10 +115,15 @@ func JWTMiddleware(server *APIV1Service, next echo.HandlerFunc, secret string) e
|
|||||||
generateToken = true
|
generateToken = true
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
auth.RemoveTokensAndCookies(c)
|
||||||
return echo.NewHTTPError(http.StatusUnauthorized, errors.Wrap(err, "Invalid or expired access token"))
|
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
|
// We either have a valid access token or we will attempt to generate new access token and refresh token
|
||||||
ctx := c.Request().Context()
|
ctx := c.Request().Context()
|
||||||
userID, err := strconv.Atoi(claims.Subject)
|
userID, err := strconv.Atoi(claims.Subject)
|
||||||
|
@ -5,6 +5,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/boojack/slash/store"
|
"github.com/boojack/slash/store"
|
||||||
"github.com/labstack/echo/v4"
|
"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)
|
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.Redirect(http.StatusSeeOther, shortcut.Link)
|
||||||
}
|
}
|
||||||
return c.String(http.StatusOK, 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 {
|
func (s *APIV1Service) createShortcutViewActivity(c echo.Context, shortcut *store.Shortcut) error {
|
||||||
|
@ -30,6 +30,12 @@ func (v Visibility) String() string {
|
|||||||
return string(v)
|
return string(v)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type OpenGraphMetadata struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Image string `json:"image"`
|
||||||
|
}
|
||||||
|
|
||||||
type Shortcut struct {
|
type Shortcut struct {
|
||||||
ID int `json:"id"`
|
ID int `json:"id"`
|
||||||
|
|
||||||
@ -41,29 +47,32 @@ type Shortcut struct {
|
|||||||
RowStatus RowStatus `json:"rowStatus"`
|
RowStatus RowStatus `json:"rowStatus"`
|
||||||
|
|
||||||
// Domain specific fields
|
// Domain specific fields
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Link string `json:"link"`
|
Link string `json:"link"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
Visibility Visibility `json:"visibility"`
|
Visibility Visibility `json:"visibility"`
|
||||||
Tags []string `json:"tags"`
|
Tags []string `json:"tags"`
|
||||||
View int `json:"view"`
|
View int `json:"view"`
|
||||||
|
OpenGraphMetadata *OpenGraphMetadata `json:"openGraphMetadata"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type CreateShortcutRequest struct {
|
type CreateShortcutRequest struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Link string `json:"link"`
|
Link string `json:"link"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
Visibility Visibility `json:"visibility"`
|
Visibility Visibility `json:"visibility"`
|
||||||
Tags []string `json:"tags"`
|
Tags []string `json:"tags"`
|
||||||
|
OpenGraphMetadata *OpenGraphMetadata `json:"openGraphMetadata"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type PatchShortcutRequest struct {
|
type PatchShortcutRequest struct {
|
||||||
RowStatus *RowStatus `json:"rowStatus"`
|
RowStatus *RowStatus `json:"rowStatus"`
|
||||||
Name *string `json:"name"`
|
Name *string `json:"name"`
|
||||||
Link *string `json:"link"`
|
Link *string `json:"link"`
|
||||||
Description *string `json:"description"`
|
Description *string `json:"description"`
|
||||||
Visibility *Visibility `json:"visibility"`
|
Visibility *Visibility `json:"visibility"`
|
||||||
Tags []string `json:"tags"`
|
Tags []string `json:"tags"`
|
||||||
|
OpenGraphMetadata *OpenGraphMetadata `json:"openGraphMetadata"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *APIV1Service) registerShortcutRoutes(g *echo.Group) {
|
func (s *APIV1Service) registerShortcutRoutes(g *echo.Group) {
|
||||||
@ -85,6 +94,11 @@ func (s *APIV1Service) registerShortcutRoutes(g *echo.Group) {
|
|||||||
Description: create.Description,
|
Description: create.Description,
|
||||||
Visibility: store.Visibility(create.Visibility.String()),
|
Visibility: store.Visibility(create.Visibility.String()),
|
||||||
Tag: strings.Join(create.Tags, " "),
|
Tag: strings.Join(create.Tags, " "),
|
||||||
|
OpenGraphMetadata: &store.OpenGraphMetadata{
|
||||||
|
Title: create.OpenGraphMetadata.Title,
|
||||||
|
Description: create.OpenGraphMetadata.Description,
|
||||||
|
Image: create.OpenGraphMetadata.Image,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to create shortcut, err: %s", err)).SetInternal(err)
|
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, " ")
|
tag := strings.Join(patch.Tags, " ")
|
||||||
shortcutUpdate.Tag = &tag
|
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)
|
shortcut, err = s.Store.UpdateShortcut(ctx, shortcutUpdate)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to patch shortcut, err: %s", err)).SetInternal(err)
|
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")
|
return echo.NewHTTPError(http.StatusForbidden, "Unauthorized to delete shortcut")
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := s.Store.DeleteShortcut(ctx, &store.DeleteShortcut{
|
err = s.Store.DeleteShortcut(ctx, &store.DeleteShortcut{ID: shortcutID})
|
||||||
ID: shortcutID,
|
if err != nil {
|
||||||
}); err != nil {
|
|
||||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to delete shortcut, err: %s", err)).SetInternal(err)
|
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to delete shortcut, err: %s", err)).SetInternal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.JSON(http.StatusOK, true)
|
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) {
|
func (s *APIV1Service) composeShortcut(ctx context.Context, shortcut *Shortcut) (*Shortcut, error) {
|
||||||
if shortcut == nil {
|
if shortcut == nil {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
@ -338,5 +336,31 @@ func convertShortcutFromStore(shortcut *store.Shortcut) *Shortcut {
|
|||||||
Visibility: Visibility(shortcut.Visibility),
|
Visibility: Visibility(shortcut.Visibility),
|
||||||
RowStatus: RowStatus(shortcut.RowStatus),
|
RowStatus: RowStatus(shortcut.RowStatus),
|
||||||
Tags: tags,
|
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
|
||||||
|
}
|
||||||
|
@ -9,10 +9,10 @@ import (
|
|||||||
|
|
||||||
// Version is the service current released version.
|
// Version is the service current released version.
|
||||||
// Semantic versioning: https://semver.org/
|
// Semantic versioning: https://semver.org/
|
||||||
var Version = "0.2.0"
|
var Version = "0.3.0"
|
||||||
|
|
||||||
// DevVersion is the service current development version.
|
// DevVersion is the service current development version.
|
||||||
var DevVersion = "0.2.0"
|
var DevVersion = "0.3.0"
|
||||||
|
|
||||||
func GetCurrentVersion(mode string) string {
|
func GetCurrentVersion(mode string) string {
|
||||||
if mode == "dev" || mode == "demo" {
|
if mode == "dev" || mode == "demo" {
|
||||||
|
@ -2,7 +2,6 @@ package store
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -64,13 +63,7 @@ type FindActivity struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) CreateActivity(ctx context.Context, create *Activity) (*Activity, error) {
|
func (s *Store) CreateActivity(ctx context.Context, create *Activity) (*Activity, error) {
|
||||||
tx, err := s.db.BeginTx(ctx, nil)
|
stmt := `
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer tx.Rollback()
|
|
||||||
|
|
||||||
query := `
|
|
||||||
INSERT INTO activity (
|
INSERT INTO activity (
|
||||||
creator_id,
|
creator_id,
|
||||||
type,
|
type,
|
||||||
@ -80,7 +73,7 @@ func (s *Store) CreateActivity(ctx context.Context, create *Activity) (*Activity
|
|||||||
VALUES (?, ?, ?, ?)
|
VALUES (?, ?, ?, ?)
|
||||||
RETURNING id, created_ts
|
RETURNING id, created_ts
|
||||||
`
|
`
|
||||||
if err := tx.QueryRowContext(ctx, query,
|
if err := s.db.QueryRowContext(ctx, stmt,
|
||||||
create.CreatorID,
|
create.CreatorID,
|
||||||
create.Type.String(),
|
create.Type.String(),
|
||||||
create.Level.String(),
|
create.Level.String(),
|
||||||
@ -92,50 +85,11 @@ func (s *Store) CreateActivity(ctx context.Context, create *Activity) (*Activity
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := tx.Commit(); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
activity := create
|
activity := create
|
||||||
return activity, nil
|
return activity, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) ListActivities(ctx context.Context, find *FindActivity) ([]*Activity, error) {
|
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{}
|
where, args := []string{"1 = 1"}, []any{}
|
||||||
if find.Type != "" {
|
if find.Type != "" {
|
||||||
where, args = append(where, "type = ?"), append(args, find.Type.String())
|
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
|
payload
|
||||||
FROM activity
|
FROM activity
|
||||||
WHERE ` + strings.Join(where, " AND ")
|
WHERE ` + strings.Join(where, " AND ")
|
||||||
rows, err := tx.QueryContext(ctx, query, args...)
|
rows, err := s.db.QueryContext(ctx, query, args...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
|
|
||||||
list := []*Activity{}
|
list := []*Activity{}
|
||||||
@ -187,3 +140,17 @@ func listActivities(ctx context.Context, tx *sql.Tx, find *FindActivity) ([]*Act
|
|||||||
|
|
||||||
return list, nil
|
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
|
||||||
|
}
|
||||||
|
@ -23,12 +23,12 @@ var migrationFS embed.FS
|
|||||||
var seedFS embed.FS
|
var seedFS embed.FS
|
||||||
|
|
||||||
type DB struct {
|
type DB struct {
|
||||||
profile *profile.Profile
|
|
||||||
// sqlite db connection instance
|
// sqlite db connection instance
|
||||||
DBInstance *sql.DB
|
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 {
|
func NewDB(profile *profile.Profile) *DB {
|
||||||
db := &DB{
|
db := &DB{
|
||||||
profile: profile,
|
profile: profile,
|
||||||
@ -42,8 +42,21 @@ func (db *DB) Open(ctx context.Context) (err error) {
|
|||||||
return fmt.Errorf("dsn required")
|
return fmt.Errorf("dsn required")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Connect to the database without foreign_key.
|
// Connect to the database with some sane settings:
|
||||||
sqliteDB, err := sql.Open("sqlite", db.profile.DSN+"?cache=shared&_foreign_keys=0&_journal_mode=WAL")
|
// - 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 {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to open db with dsn: %s, err: %w", db.profile.DSN, err)
|
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" {
|
if db.profile.Mode == "prod" {
|
||||||
_, err := os.Stat(db.profile.DSN)
|
_, err := os.Stat(db.profile.DSN)
|
||||||
if err != nil {
|
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 errors.Is(err, os.ErrNotExist) {
|
||||||
if err := db.applyLatestSchema(ctx); err != nil {
|
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 {
|
} else {
|
||||||
return fmt.Errorf("failed to check database file: %w", err)
|
return fmt.Errorf("failed to get db file stat, err: %w", err)
|
||||||
}
|
}
|
||||||
} else {
|
} 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)
|
currentVersion := version.GetCurrentVersion(db.profile.Mode)
|
||||||
migrationHistoryList, err := db.FindMigrationHistoryList(ctx, &MigrationHistoryFind{})
|
migrationHistoryList, err := db.FindMigrationHistoryList(ctx, &MigrationHistoryFind{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -177,21 +190,15 @@ func (db *DB) applyMigrationForMinorVersion(ctx context.Context, minorVersion st
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
tx, err := db.DBInstance.Begin()
|
// Upsert the newest version to migration_history.
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer tx.Rollback()
|
|
||||||
|
|
||||||
// upsert the newest version to migration_history
|
|
||||||
version := minorVersion + ".0"
|
version := minorVersion + ".0"
|
||||||
if _, err = upsertMigrationHistory(ctx, tx, &MigrationHistoryUpsert{
|
if _, err = db.UpsertMigrationHistory(ctx, &MigrationHistoryUpsert{
|
||||||
Version: version,
|
Version: version,
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
return fmt.Errorf("failed to upsert migration history with version: %s, err: %w", version, err)
|
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 {
|
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.
|
// execute runs a single SQL statement within a transaction.
|
||||||
func (db *DB) execute(ctx context.Context, stmt string) error {
|
func (db *DB) execute(ctx context.Context, stmt string) error {
|
||||||
tx, err := db.DBInstance.Begin()
|
if _, err := db.DBInstance.ExecContext(ctx, stmt); err != nil {
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer tx.Rollback()
|
|
||||||
|
|
||||||
if _, err := tx.ExecContext(ctx, stmt); err != nil {
|
|
||||||
return fmt.Errorf("failed to execute statement, err: %w", err)
|
return fmt.Errorf("failed to execute statement, err: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return tx.Commit()
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// minorDirRegexp is a regular expression for minor version directory.
|
// minorDirRegexp is a regular expression for minor version directory.
|
||||||
|
@ -43,7 +43,8 @@ CREATE TABLE shortcut (
|
|||||||
link TEXT NOT NULL,
|
link TEXT NOT NULL,
|
||||||
description TEXT NOT NULL DEFAULT '',
|
description TEXT NOT NULL DEFAULT '',
|
||||||
visibility TEXT NOT NULL CHECK (visibility IN ('PRIVATE', 'WORKSPACE', 'PUBLIC')) DEFAULT 'PRIVATE',
|
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);
|
CREATE INDEX idx_shortcut_name ON shortcut(name);
|
||||||
|
1
store/db/migration/prod/0.3/00__add_og_metadata.sql
Normal file
1
store/db/migration/prod/0.3/00__add_og_metadata.sql
Normal file
@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE shortcut ADD COLUMN og_metadata TEXT NOT NULL DEFAULT '{}';
|
@ -43,7 +43,8 @@ CREATE TABLE shortcut (
|
|||||||
link TEXT NOT NULL,
|
link TEXT NOT NULL,
|
||||||
description TEXT NOT NULL DEFAULT '',
|
description TEXT NOT NULL DEFAULT '',
|
||||||
visibility TEXT NOT NULL CHECK (visibility IN ('PRIVATE', 'WORKSPACE', 'PUBLIC')) DEFAULT 'PRIVATE',
|
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);
|
CREATE INDEX idx_shortcut_name ON shortcut(name);
|
||||||
|
@ -2,7 +2,6 @@ package db
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -20,47 +19,13 @@ type MigrationHistoryFind struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (db *DB) FindMigrationHistoryList(ctx context.Context, find *MigrationHistoryFind) ([]*MigrationHistory, error) {
|
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{}
|
where, args := []string{"1 = 1"}, []any{}
|
||||||
|
|
||||||
if v := find.Version; v != nil {
|
if v := find.Version; v != nil {
|
||||||
where, args = append(where, "version = ?"), append(args, *v)
|
where, args = append(where, "version = ?"), append(args, *v)
|
||||||
}
|
}
|
||||||
|
|
||||||
query := `
|
stmt := `
|
||||||
SELECT
|
SELECT
|
||||||
version,
|
version,
|
||||||
created_ts
|
created_ts
|
||||||
@ -69,7 +34,7 @@ func findMigrationHistoryList(ctx context.Context, tx *sql.Tx, find *MigrationHi
|
|||||||
WHERE ` + strings.Join(where, " AND ") + `
|
WHERE ` + strings.Join(where, " AND ") + `
|
||||||
ORDER BY created_ts DESC
|
ORDER BY created_ts DESC
|
||||||
`
|
`
|
||||||
rows, err := tx.QueryContext(ctx, query, args...)
|
rows, err := db.DBInstance.QueryContext(ctx, stmt, args...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@ -84,7 +49,6 @@ func findMigrationHistoryList(ctx context.Context, tx *sql.Tx, find *MigrationHi
|
|||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
migrationHistoryList = append(migrationHistoryList, &migrationHistory)
|
migrationHistoryList = append(migrationHistoryList, &migrationHistory)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -95,7 +59,7 @@ func findMigrationHistoryList(ctx context.Context, tx *sql.Tx, find *MigrationHi
|
|||||||
return migrationHistoryList, nil
|
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 := `
|
query := `
|
||||||
INSERT INTO migration_history (
|
INSERT INTO migration_history (
|
||||||
version
|
version
|
||||||
@ -107,7 +71,7 @@ func upsertMigrationHistory(ctx context.Context, tx *sql.Tx, upsert *MigrationHi
|
|||||||
RETURNING version, created_ts
|
RETURNING version, created_ts
|
||||||
`
|
`
|
||||||
migrationHistory := &MigrationHistory{}
|
migrationHistory := &MigrationHistory{}
|
||||||
if err := tx.QueryRowContext(ctx, query, upsert.Version).Scan(
|
if err := db.DBInstance.QueryRowContext(ctx, query, upsert.Version).Scan(
|
||||||
&migrationHistory.Version,
|
&migrationHistory.Version,
|
||||||
&migrationHistory.CreatedTs,
|
&migrationHistory.CreatedTs,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
|
@ -38,15 +38,36 @@ INSERT INTO
|
|||||||
`creator_id`,
|
`creator_id`,
|
||||||
`name`,
|
`name`,
|
||||||
`link`,
|
`link`,
|
||||||
`visibility`
|
`visibility`,
|
||||||
|
`og_metadata`
|
||||||
)
|
)
|
||||||
VALUES
|
VALUES
|
||||||
(
|
(
|
||||||
3,
|
3,
|
||||||
101,
|
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',
|
'schema-change',
|
||||||
'https://www.bytebase.com/blog/how-to-handle-database-schema-change/#what-is-a-database-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
|
INSERT INTO
|
||||||
@ -59,7 +80,7 @@ INSERT INTO
|
|||||||
)
|
)
|
||||||
VALUES
|
VALUES
|
||||||
(
|
(
|
||||||
4,
|
5,
|
||||||
102,
|
102,
|
||||||
'stevenlgtm',
|
'stevenlgtm',
|
||||||
'https://github.com/boojack',
|
'https://github.com/boojack',
|
||||||
|
@ -3,6 +3,7 @@ package store
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
@ -31,6 +32,12 @@ func (e Visibility) String() string {
|
|||||||
return "PRIVATE"
|
return "PRIVATE"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type OpenGraphMetadata struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Image string `json:"image"`
|
||||||
|
}
|
||||||
|
|
||||||
type Shortcut struct {
|
type Shortcut struct {
|
||||||
ID int
|
ID int
|
||||||
|
|
||||||
@ -41,22 +48,24 @@ type Shortcut struct {
|
|||||||
RowStatus RowStatus
|
RowStatus RowStatus
|
||||||
|
|
||||||
// Domain specific fields
|
// Domain specific fields
|
||||||
Name string
|
Name string
|
||||||
Link string
|
Link string
|
||||||
Description string
|
Description string
|
||||||
Visibility Visibility
|
Visibility Visibility
|
||||||
Tag string
|
Tag string
|
||||||
|
OpenGraphMetadata *OpenGraphMetadata
|
||||||
}
|
}
|
||||||
|
|
||||||
type UpdateShortcut struct {
|
type UpdateShortcut struct {
|
||||||
ID int
|
ID int
|
||||||
|
|
||||||
RowStatus *RowStatus
|
RowStatus *RowStatus
|
||||||
Name *string
|
Name *string
|
||||||
Link *string
|
Link *string
|
||||||
Description *string
|
Description *string
|
||||||
Visibility *Visibility
|
Visibility *Visibility
|
||||||
Tag *string
|
Tag *string
|
||||||
|
OpenGraphMetadata *OpenGraphMetadata
|
||||||
}
|
}
|
||||||
|
|
||||||
type FindShortcut struct {
|
type FindShortcut struct {
|
||||||
@ -73,24 +82,27 @@ type DeleteShortcut struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) CreateShortcut(ctx context.Context, create *Shortcut) (*Shortcut, error) {
|
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"}
|
set := []string{"creator_id", "name", "link", "description", "visibility", "tag"}
|
||||||
args := []any{create.CreatorID, create.Name, create.Link, create.Description, create.Visibility, create.Tag}
|
args := []any{create.CreatorID, create.Name, create.Link, create.Description, create.Visibility, create.Tag}
|
||||||
placeholder := []string{"?", "?", "?", "?", "?", "?"}
|
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 (
|
INSERT INTO shortcut (
|
||||||
` + strings.Join(set, ", ") + `
|
` + strings.Join(set, ", ") + `
|
||||||
)
|
)
|
||||||
VALUES (` + strings.Join(placeholder, ",") + `)
|
VALUES (` + strings.Join(placeholder, ",") + `)
|
||||||
RETURNING id, created_ts, updated_ts, row_status
|
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.ID,
|
||||||
&create.CreatedTs,
|
&create.CreatedTs,
|
||||||
&create.UpdatedTs,
|
&create.UpdatedTs,
|
||||||
@ -99,20 +111,10 @@ func (s *Store) CreateShortcut(ctx context.Context, create *Shortcut) (*Shortcut
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := tx.Commit(); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return create, nil
|
return create, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) UpdateShortcut(ctx context.Context, update *UpdateShortcut) (*Shortcut, error) {
|
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{}
|
set, args := []string{}, []any{}
|
||||||
if update.RowStatus != nil {
|
if update.RowStatus != nil {
|
||||||
set, args = append(set, "row_status = ?"), append(args, update.RowStatus.String())
|
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 {
|
if update.Tag != nil {
|
||||||
set, args = append(set, "tag = ?"), append(args, *update.Tag)
|
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 {
|
if len(set) == 0 {
|
||||||
return nil, fmt.Errorf("no update specified")
|
return nil, fmt.Errorf("no update specified")
|
||||||
}
|
}
|
||||||
args = append(args, update.ID)
|
args = append(args, update.ID)
|
||||||
|
|
||||||
query := `
|
stmt := `
|
||||||
UPDATE shortcut
|
UPDATE shortcut
|
||||||
SET
|
SET
|
||||||
` + strings.Join(set, ", ") + `
|
` + strings.Join(set, ", ") + `
|
||||||
WHERE
|
WHERE
|
||||||
id = ?
|
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{}
|
shortcut := &Shortcut{}
|
||||||
if err := tx.QueryRowContext(ctx, query, args...).Scan(
|
openGraphMetadataString := ""
|
||||||
|
if err := s.db.QueryRowContext(ctx, stmt, args...).Scan(
|
||||||
&shortcut.ID,
|
&shortcut.ID,
|
||||||
&shortcut.CreatorID,
|
&shortcut.CreatorID,
|
||||||
&shortcut.CreatedTs,
|
&shortcut.CreatedTs,
|
||||||
@ -157,12 +167,15 @@ func (s *Store) UpdateShortcut(ctx context.Context, update *UpdateShortcut) (*Sh
|
|||||||
&shortcut.Description,
|
&shortcut.Description,
|
||||||
&shortcut.Visibility,
|
&shortcut.Visibility,
|
||||||
&shortcut.Tag,
|
&shortcut.Tag,
|
||||||
|
&openGraphMetadataString,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
if openGraphMetadataString != "" {
|
||||||
if err := tx.Commit(); err != nil {
|
shortcut.OpenGraphMetadata = &OpenGraphMetadata{}
|
||||||
return nil, err
|
if err := json.Unmarshal([]byte(openGraphMetadataString), shortcut.OpenGraphMetadata); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
s.shortcutCache.Store(shortcut.ID, shortcut)
|
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) {
|
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{}
|
where, args := []string{"1 = 1"}, []any{}
|
||||||
|
|
||||||
if v := find.ID; v != nil {
|
if v := find.ID; v != nil {
|
||||||
where, args = append(where, "id = ?"), append(args, *v)
|
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+"%")
|
where, args = append(where, "tag LIKE ?"), append(args, "%"+*v+"%")
|
||||||
}
|
}
|
||||||
|
|
||||||
rows, err := tx.QueryContext(ctx, `
|
rows, err := s.db.QueryContext(ctx, `
|
||||||
SELECT
|
SELECT
|
||||||
id,
|
id,
|
||||||
creator_id,
|
creator_id,
|
||||||
@ -272,7 +219,8 @@ func listShortcuts(ctx context.Context, tx *sql.Tx, find *FindShortcut) ([]*Shor
|
|||||||
link,
|
link,
|
||||||
description,
|
description,
|
||||||
visibility,
|
visibility,
|
||||||
tag
|
tag,
|
||||||
|
og_metadata
|
||||||
FROM shortcut
|
FROM shortcut
|
||||||
WHERE `+strings.Join(where, " AND ")+`
|
WHERE `+strings.Join(where, " AND ")+`
|
||||||
ORDER BY created_ts DESC`,
|
ORDER BY created_ts DESC`,
|
||||||
@ -286,6 +234,7 @@ func listShortcuts(ctx context.Context, tx *sql.Tx, find *FindShortcut) ([]*Shor
|
|||||||
list := make([]*Shortcut, 0)
|
list := make([]*Shortcut, 0)
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
shortcut := &Shortcut{}
|
shortcut := &Shortcut{}
|
||||||
|
openGraphMetadataString := ""
|
||||||
if err := rows.Scan(
|
if err := rows.Scan(
|
||||||
&shortcut.ID,
|
&shortcut.ID,
|
||||||
&shortcut.CreatorID,
|
&shortcut.CreatorID,
|
||||||
@ -297,9 +246,16 @@ func listShortcuts(ctx context.Context, tx *sql.Tx, find *FindShortcut) ([]*Shor
|
|||||||
&shortcut.Description,
|
&shortcut.Description,
|
||||||
&shortcut.Visibility,
|
&shortcut.Visibility,
|
||||||
&shortcut.Tag,
|
&shortcut.Tag,
|
||||||
|
&openGraphMetadataString,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
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)
|
list = append(list, shortcut)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -307,9 +263,43 @@ func listShortcuts(ctx context.Context, tx *sql.Tx, find *FindShortcut) ([]*Shor
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for _, shortcut := range list {
|
||||||
|
s.shortcutCache.Store(shortcut.ID, shortcut)
|
||||||
|
}
|
||||||
return list, nil
|
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 {
|
func vacuumShortcut(ctx context.Context, tx *sql.Tx) error {
|
||||||
stmt := `
|
stmt := `
|
||||||
DELETE FROM
|
DELETE FROM
|
||||||
|
159
store/user.go
159
store/user.go
@ -2,7 +2,6 @@ package store
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
@ -55,13 +54,7 @@ type DeleteUser struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) CreateUser(ctx context.Context, create *User) (*User, error) {
|
func (s *Store) CreateUser(ctx context.Context, create *User) (*User, error) {
|
||||||
tx, err := s.db.BeginTx(ctx, nil)
|
stmt := `
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer tx.Rollback()
|
|
||||||
|
|
||||||
query := `
|
|
||||||
INSERT INTO user (
|
INSERT INTO user (
|
||||||
email,
|
email,
|
||||||
nickname,
|
nickname,
|
||||||
@ -71,7 +64,7 @@ func (s *Store) CreateUser(ctx context.Context, create *User) (*User, error) {
|
|||||||
VALUES (?, ?, ?, ?)
|
VALUES (?, ?, ?, ?)
|
||||||
RETURNING id, created_ts, updated_ts, row_status
|
RETURNING id, created_ts, updated_ts, row_status
|
||||||
`
|
`
|
||||||
if err := tx.QueryRowContext(ctx, query,
|
if err := s.db.QueryRowContext(ctx, stmt,
|
||||||
create.Email,
|
create.Email,
|
||||||
create.Nickname,
|
create.Nickname,
|
||||||
create.PasswordHash,
|
create.PasswordHash,
|
||||||
@ -85,22 +78,12 @@ func (s *Store) CreateUser(ctx context.Context, create *User) (*User, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := tx.Commit(); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
user := create
|
user := create
|
||||||
s.userCache.Store(user.ID, user)
|
s.userCache.Store(user.ID, user)
|
||||||
return user, nil
|
return user, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) UpdateUser(ctx context.Context, update *UpdateUser) (*User, error) {
|
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{}
|
set, args := []string{}, []any{}
|
||||||
if v := update.RowStatus; v != nil {
|
if v := update.RowStatus; v != nil {
|
||||||
set, args = append(set, "row_status = ?"), append(args, *v)
|
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")
|
return nil, fmt.Errorf("no fields to update")
|
||||||
}
|
}
|
||||||
|
|
||||||
query := `
|
stmt := `
|
||||||
UPDATE user
|
UPDATE user
|
||||||
SET ` + strings.Join(set, ", ") + `
|
SET ` + strings.Join(set, ", ") + `
|
||||||
WHERE id = ?
|
WHERE id = ?
|
||||||
@ -130,7 +113,7 @@ func (s *Store) UpdateUser(ctx context.Context, update *UpdateUser) (*User, erro
|
|||||||
`
|
`
|
||||||
args = append(args, update.ID)
|
args = append(args, update.ID)
|
||||||
user := &User{}
|
user := &User{}
|
||||||
if err := tx.QueryRowContext(ctx, query, args...).Scan(
|
if err := s.db.QueryRowContext(ctx, stmt, args...).Scan(
|
||||||
&user.ID,
|
&user.ID,
|
||||||
&user.CreatedTs,
|
&user.CreatedTs,
|
||||||
&user.UpdatedTs,
|
&user.UpdatedTs,
|
||||||
@ -143,23 +126,68 @@ func (s *Store) UpdateUser(ctx context.Context, update *UpdateUser) (*User, erro
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := tx.Commit(); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
s.userCache.Store(user.ID, user)
|
s.userCache.Store(user.ID, user)
|
||||||
return user, nil
|
return user, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) ListUsers(ctx context.Context, find *FindUser) ([]*User, error) {
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer tx.Rollback()
|
defer rows.Close()
|
||||||
|
|
||||||
list, err := listUsers(ctx, tx, find)
|
list := make([]*User, 0)
|
||||||
if err != nil {
|
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 nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -177,13 +205,7 @@ func (s *Store) GetUser(ctx context.Context, find *FindUser) (*User, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
tx, err := s.db.BeginTx(ctx, nil)
|
list, err := s.ListUsers(ctx, find)
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer tx.Rollback()
|
|
||||||
|
|
||||||
list, err := listUsers(ctx, tx, find)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@ -217,7 +239,6 @@ func (s *Store) DeleteUser(ctx context.Context, delete *DeleteUser) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err := tx.Commit(); err != nil {
|
if err := tx.Commit(); err != nil {
|
||||||
// do nothing here to prevent linter warning.
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -225,67 +246,3 @@ func (s *Store) DeleteUser(ctx context.Context, delete *DeleteUser) error {
|
|||||||
|
|
||||||
return nil
|
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
|
|
||||||
}
|
|
||||||
|
@ -18,13 +18,7 @@ type FindUserSetting struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) UpsertUserSetting(ctx context.Context, upsert *UserSetting) (*UserSetting, error) {
|
func (s *Store) UpsertUserSetting(ctx context.Context, upsert *UserSetting) (*UserSetting, error) {
|
||||||
tx, err := s.db.BeginTx(ctx, nil)
|
stmt := `
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer tx.Rollback()
|
|
||||||
|
|
||||||
query := `
|
|
||||||
INSERT INTO user_setting (
|
INSERT INTO user_setting (
|
||||||
user_id, key, value
|
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
|
ON CONFLICT(user_id, key) DO UPDATE
|
||||||
SET value = EXCLUDED.value
|
SET value = EXCLUDED.value
|
||||||
`
|
`
|
||||||
if _, err := tx.ExecContext(ctx, query, upsert.UserID, upsert.Key, upsert.Value); err != nil {
|
if _, err := s.db.ExecContext(ctx, stmt, upsert.UserID, upsert.Key, upsert.Value); err != nil {
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := tx.Commit(); err != nil {
|
|
||||||
return nil, err
|
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) {
|
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{}
|
where, args := []string{"1 = 1"}, []any{}
|
||||||
|
|
||||||
if v := find.Key; v != "" {
|
if v := find.Key; v != "" {
|
||||||
@ -107,30 +52,54 @@ func listUserSettings(ctx context.Context, tx *sql.Tx, find *FindUserSetting) ([
|
|||||||
value
|
value
|
||||||
FROM user_setting
|
FROM user_setting
|
||||||
WHERE ` + strings.Join(where, " AND ")
|
WHERE ` + strings.Join(where, " AND ")
|
||||||
rows, err := tx.QueryContext(ctx, query, args...)
|
rows, err := s.db.QueryContext(ctx, query, args...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
|
|
||||||
userSettingMessageList := make([]*UserSetting, 0)
|
userSettingList := make([]*UserSetting, 0)
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
userSettingMessage := &UserSetting{}
|
userSetting := &UserSetting{}
|
||||||
if err := rows.Scan(
|
if err := rows.Scan(
|
||||||
&userSettingMessage.UserID,
|
&userSetting.UserID,
|
||||||
&userSettingMessage.Key,
|
&userSetting.Key,
|
||||||
&userSettingMessage.Value,
|
&userSetting.Value,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
userSettingMessageList = append(userSettingMessageList, userSettingMessage)
|
userSettingList = append(userSettingList, userSetting)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := rows.Err(); err != nil {
|
if err := rows.Err(); err != nil {
|
||||||
return nil, err
|
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 {
|
func vacuumUserSetting(ctx context.Context, tx *sql.Tx) error {
|
||||||
|
@ -2,7 +2,6 @@ package store
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -17,13 +16,7 @@ const (
|
|||||||
|
|
||||||
// String returns the string format of WorkspaceSettingKey type.
|
// String returns the string format of WorkspaceSettingKey type.
|
||||||
func (key WorkspaceSettingKey) String() string {
|
func (key WorkspaceSettingKey) String() string {
|
||||||
switch key {
|
return string(key)
|
||||||
case WorkspaceDisallowSignUp:
|
|
||||||
return "disallow-signup"
|
|
||||||
case WorkspaceSecretSessionName:
|
|
||||||
return "secret-session-name"
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type WorkspaceSetting struct {
|
type WorkspaceSetting struct {
|
||||||
@ -36,13 +29,7 @@ type FindWorkspaceSetting struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) UpsertWorkspaceSetting(ctx context.Context, upsert *WorkspaceSetting) (*WorkspaceSetting, error) {
|
func (s *Store) UpsertWorkspaceSetting(ctx context.Context, upsert *WorkspaceSetting) (*WorkspaceSetting, error) {
|
||||||
tx, err := s.db.BeginTx(ctx, nil)
|
stmt := `
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer tx.Rollback()
|
|
||||||
|
|
||||||
query := `
|
|
||||||
INSERT INTO workspace_setting (
|
INSERT INTO workspace_setting (
|
||||||
key,
|
key,
|
||||||
value
|
value
|
||||||
@ -51,11 +38,7 @@ func (s *Store) UpsertWorkspaceSetting(ctx context.Context, upsert *WorkspaceSet
|
|||||||
ON CONFLICT(key) DO UPDATE
|
ON CONFLICT(key) DO UPDATE
|
||||||
SET value = EXCLUDED.value
|
SET value = EXCLUDED.value
|
||||||
`
|
`
|
||||||
if _, err := tx.ExecContext(ctx, query, upsert.Key, upsert.Value); err != nil {
|
if _, err := s.db.ExecContext(ctx, stmt, upsert.Key, upsert.Value); err != nil {
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := tx.Commit(); err != nil {
|
|
||||||
return nil, err
|
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) {
|
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{}
|
where, args := []string{"1 = 1"}, []any{}
|
||||||
|
|
||||||
if find.Key != "" {
|
if find.Key != "" {
|
||||||
where, args = append(where, "key = ?"), append(args, 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
|
value
|
||||||
FROM workspace_setting
|
FROM workspace_setting
|
||||||
WHERE ` + strings.Join(where, " AND ")
|
WHERE ` + strings.Join(where, " AND ")
|
||||||
rows, err := tx.QueryContext(ctx, query, args...)
|
rows, err := s.db.QueryContext(ctx, query, args...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@ -146,5 +84,30 @@ func listWorkspaceSettings(ctx context.Context, tx *sql.Tx, find *FindWorkspaceS
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for _, workspaceSetting := range list {
|
||||||
|
s.workspaceSettingCache.Store(workspaceSetting.Key, workspaceSetting)
|
||||||
|
}
|
||||||
|
|
||||||
return list, nil
|
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
|
||||||
|
}
|
||||||
|
@ -14,12 +14,13 @@ func TestShortcutStore(t *testing.T) {
|
|||||||
user, err := createTestingAdminUser(ctx, ts)
|
user, err := createTestingAdminUser(ctx, ts)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
shortcut, err := ts.CreateShortcut(ctx, &store.Shortcut{
|
shortcut, err := ts.CreateShortcut(ctx, &store.Shortcut{
|
||||||
CreatorID: user.ID,
|
CreatorID: user.ID,
|
||||||
Name: "test",
|
Name: "test",
|
||||||
Link: "https://test.link",
|
Link: "https://test.link",
|
||||||
Description: "A test shortcut",
|
Description: "A test shortcut",
|
||||||
Visibility: store.VisibilityPrivate,
|
Visibility: store.VisibilityPrivate,
|
||||||
Tag: "test link",
|
Tag: "test link",
|
||||||
|
OpenGraphMetadata: &store.OpenGraphMetadata{},
|
||||||
})
|
})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
shortcuts, err := ts.ListShortcuts(ctx, &store.FindShortcut{
|
shortcuts, err := ts.ListShortcuts(ctx, &store.FindShortcut{
|
||||||
|
@ -11,7 +11,7 @@ interface Props {
|
|||||||
const AnalyticsDialog: React.FC<Props> = (props: Props) => {
|
const AnalyticsDialog: React.FC<Props> = (props: Props) => {
|
||||||
const { shortcutId, onClose } = props;
|
const { shortcutId, onClose } = props;
|
||||||
const [analytics, setAnalytics] = useState<AnalysisData | null>(null);
|
const [analytics, setAnalytics] = useState<AnalysisData | null>(null);
|
||||||
const [selectedDeviceTab, setSelectedDeviceTab] = useState<"os" | "browser">("os");
|
const [selectedDeviceTab, setSelectedDeviceTab] = useState<"os" | "browser">("browser");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
api.getShortcutAnalytics(shortcutId).then(({ data }) => {
|
api.getShortcutAnalytics(shortcutId).then(({ data }) => {
|
||||||
@ -32,22 +32,16 @@ const AnalyticsDialog: React.FC<Props> = (props: Props) => {
|
|||||||
{analytics ? (
|
{analytics ? (
|
||||||
<>
|
<>
|
||||||
<p className="w-full py-1 px-2">Top Sources</p>
|
<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">
|
<div className="w-full mt-1 overflow-hidden shadow ring-1 ring-black ring-opacity-5 rounded-lg">
|
||||||
<table className="min-w-full divide-y divide-gray-300">
|
<div className="w-full divide-y divide-gray-300">
|
||||||
<thead>
|
<div className="w-full flex flex-row justify-between items-center">
|
||||||
<tr>
|
<span className="py-1 px-2 text-left font-semibold text-sm text-gray-500">Source</span>
|
||||||
<th scope="col" className="py-1 px-2 text-left font-semibold text-sm text-gray-500">
|
<span className="py-1 pr-2 text-right font-semibold text-sm text-gray-500">Visitors</span>
|
||||||
Source
|
</div>
|
||||||
</th>
|
<div className="w-full divide-y divide-gray-200">
|
||||||
<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">
|
|
||||||
{analytics.referenceData.map((reference) => (
|
{analytics.referenceData.map((reference) => (
|
||||||
<tr key={reference.name}>
|
<div key={reference.name} className="w-full flex flex-row justify-between items-center">
|
||||||
<td className="whitespace-nowrap py-2 px-2 text-sm text-gray-900">
|
<span className="whitespace-nowrap py-2 px-2 text-sm truncate text-gray-900">
|
||||||
{reference.name ? (
|
{reference.name ? (
|
||||||
<a className="hover:underline hover:text-blue-600" href={reference.name} target="_blank">
|
<a className="hover:underline hover:text-blue-600" href={reference.name} target="_blank">
|
||||||
{reference.name}
|
{reference.name}
|
||||||
@ -55,28 +49,17 @@ const AnalyticsDialog: React.FC<Props> = (props: Props) => {
|
|||||||
) : (
|
) : (
|
||||||
"Direct"
|
"Direct"
|
||||||
)}
|
)}
|
||||||
</td>
|
</span>
|
||||||
<td className="whitespace-nowrap py-2 pr-2 text-sm text-gray-500 text-right">{reference.count}</td>
|
<span className="whitespace-nowrap py-2 pr-2 text-sm text-gray-500 text-right shrink-0">{reference.count}</span>
|
||||||
</tr>
|
</div>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</div>
|
||||||
</table>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="w-full mt-4 py-1 px-2 flex flex-row justify-between items-center">
|
<div className="w-full mt-4 py-1 px-2 flex flex-row justify-between items-center">
|
||||||
<span>Devices</span>
|
<span>Devices</span>
|
||||||
<div>
|
<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
|
<button
|
||||||
className={`whitespace-nowrap border-b-2 px-1 text-sm font-medium ${
|
className={`whitespace-nowrap border-b-2 px-1 text-sm font-medium ${
|
||||||
selectedDeviceTab === "browser"
|
selectedDeviceTab === "browser"
|
||||||
@ -87,56 +70,60 @@ const AnalyticsDialog: React.FC<Props> = (props: Props) => {
|
|||||||
>
|
>
|
||||||
Browser
|
Browser
|
||||||
</button>
|
</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>
|
</div>
|
||||||
|
|
||||||
<div className="mt-1 overflow-hidden shadow ring-1 ring-black ring-opacity-5 sm:rounded-lg">
|
<div className="w-full mt-1 overflow-hidden shadow ring-1 ring-black ring-opacity-5 rounded-lg">
|
||||||
{selectedDeviceTab === "os" ? (
|
{selectedDeviceTab === "browser" ? (
|
||||||
<table className="min-w-full divide-y divide-gray-300">
|
<div className="w-full divide-y divide-gray-300">
|
||||||
<thead>
|
<div className="w-full flex flex-row justify-between items-center">
|
||||||
<tr>
|
<span className="py-2 px-2 text-left text-sm font-semibold text-gray-500">Browsers</span>
|
||||||
<th scope="col" className="py-2 px-2 text-left text-sm font-semibold text-gray-500">
|
<span className="py-2 pr-2 text-right text-sm font-semibold text-gray-500">Visitors</span>
|
||||||
Operating system
|
</div>
|
||||||
</th>
|
<div className="w-full divide-y divide-gray-200">
|
||||||
<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">
|
|
||||||
{analytics.browserData.map((reference) => (
|
{analytics.browserData.map((reference) => (
|
||||||
<tr key={reference.name}>
|
<div key={reference.name} className="w-full flex flex-row justify-between items-center">
|
||||||
<td className="whitespace-nowrap py-2 px-2 text-sm text-gray-900">{reference.name || "Unknown"}</td>
|
<span className="whitespace-nowrap py-2 px-2 text-sm text-gray-900 truncate">{reference.name || "Unknown"}</span>
|
||||||
<td className="whitespace-nowrap py-2 pr-2 text-sm text-gray-500 text-right">{reference.count}</td>
|
<span className="whitespace-nowrap py-2 pr-2 text-sm text-gray-500 text-right shrink-0">{reference.count}</span>
|
||||||
</tr>
|
</div>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</div>
|
||||||
</table>
|
</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>
|
</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>
|
</div>
|
||||||
</ModalDialog>
|
</ModalDialog>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
@ -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 { isUndefined } from "lodash-es";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@ -29,26 +29,34 @@ const CreateShortcutDialog: React.FC<Props> = (props: Props) => {
|
|||||||
description: "",
|
description: "",
|
||||||
visibility: "PRIVATE",
|
visibility: "PRIVATE",
|
||||||
tags: [],
|
tags: [],
|
||||||
|
openGraphMetadata: {
|
||||||
|
title: "",
|
||||||
|
description: "",
|
||||||
|
image: "",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
const [showDescriptionAndTag, setShowDescriptionAndTag] = useState<boolean>(false);
|
||||||
|
const [showOpenGraphMetadata, setShowOpenGraphMetadata] = useState<boolean>(false);
|
||||||
const [tag, setTag] = useState<string>("");
|
const [tag, setTag] = useState<string>("");
|
||||||
const requestState = useLoading(false);
|
const requestState = useLoading(false);
|
||||||
const isCreating = isUndefined(shortcutId);
|
const isCreating = isUndefined(shortcutId);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (shortcutId) {
|
if (shortcutId) {
|
||||||
const shortcutTemp = shortcutService.getShortcutById(shortcutId);
|
const shortcut = shortcutService.getShortcutById(shortcutId);
|
||||||
if (shortcutTemp) {
|
if (shortcut) {
|
||||||
setState({
|
setState({
|
||||||
...state,
|
...state,
|
||||||
shortcutCreate: Object.assign(state.shortcutCreate, {
|
shortcutCreate: Object.assign(state.shortcutCreate, {
|
||||||
name: shortcutTemp.name,
|
name: shortcut.name,
|
||||||
link: shortcutTemp.link,
|
link: shortcut.link,
|
||||||
description: shortcutTemp.description,
|
description: shortcut.description,
|
||||||
visibility: shortcutTemp.visibility,
|
visibility: shortcut.visibility,
|
||||||
|
openGraphMetadata: shortcut.openGraphMetadata,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
setTag(shortcutTemp.tags.join(" "));
|
setTag(shortcut.tags.join(" "));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [shortcutId]);
|
}, [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>) => {
|
const handleDescriptionInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
setPartialState({
|
setPartialState({
|
||||||
shortcutCreate: Object.assign(state.shortcutCreate, {
|
shortcutCreate: Object.assign(state.shortcutCreate, {
|
||||||
@ -89,10 +105,35 @@ const CreateShortcutDialog: React.FC<Props> = (props: Props) => {
|
|||||||
setTag(text);
|
setTag(text);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleVisibilityInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleOpenGraphMetadataImageChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
setPartialState({
|
setPartialState({
|
||||||
shortcutCreate: Object.assign(state.shortcutCreate, {
|
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,
|
description: state.shortcutCreate.description,
|
||||||
visibility: state.shortcutCreate.visibility,
|
visibility: state.shortcutCreate.visibility,
|
||||||
tags: tag.split(" "),
|
tags: tag.split(" "),
|
||||||
|
openGraphMetadata: state.shortcutCreate.openGraphMetadata,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
await shortcutService.createShortcut({
|
await shortcutService.createShortcut({
|
||||||
@ -140,7 +182,7 @@ const CreateShortcutDialog: React.FC<Props> = (props: Props) => {
|
|||||||
<Icon.X className="w-5 h-auto text-gray-600" />
|
<Icon.X className="w-5 h-auto text-gray-600" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="overflow-y-auto">
|
||||||
<div className="w-full flex flex-col justify-start items-start mb-3">
|
<div className="w-full flex flex-col justify-start items-start mb-3">
|
||||||
<span className="mb-2">
|
<span className="mb-2">
|
||||||
Name <span className="text-red-600">*</span>
|
Name <span className="text-red-600">*</span>
|
||||||
@ -157,30 +199,16 @@ const CreateShortcutDialog: React.FC<Props> = (props: Props) => {
|
|||||||
</div>
|
</div>
|
||||||
<div className="w-full flex flex-col justify-start items-start mb-3">
|
<div className="w-full flex flex-col justify-start items-start mb-3">
|
||||||
<span className="mb-2">
|
<span className="mb-2">
|
||||||
Link <span className="text-red-600">*</span>
|
Destination URL <span className="text-red-600">*</span>
|
||||||
</span>
|
</span>
|
||||||
<Input
|
<Input
|
||||||
className="w-full"
|
className="w-full"
|
||||||
type="text"
|
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}
|
value={state.shortcutCreate.link}
|
||||||
onChange={handleLinkInputChange}
|
onChange={handleLinkInputChange}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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">
|
<div className="w-full flex flex-col justify-start items-start mb-3">
|
||||||
<span className="mb-2">
|
<span className="mb-2">
|
||||||
Visibility <span className="text-red-600">*</span>
|
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`)}
|
{t(`shortcut.visibility.${state.shortcutCreate.visibility.toLowerCase()}.description`)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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">
|
<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}>
|
<Button color="neutral" variant="plain" disabled={requestState.isLoading} loading={requestState.isLoading} onClick={onClose}>
|
||||||
Cancel
|
Cancel
|
||||||
|
54
web/src/components/OrderSetting.tsx
Normal file
54
web/src/components/OrderSetting.tsx
Normal 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;
|
@ -81,7 +81,7 @@ const ShortcutView = (props: Props) => {
|
|||||||
</a>
|
</a>
|
||||||
<Tooltip title="Copy" variant="solid" placement="top" arrow>
|
<Tooltip title="Copy" variant="solid" placement="top" arrow>
|
||||||
<button
|
<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()}
|
onClick={() => handleCopyButtonClick()}
|
||||||
>
|
>
|
||||||
<Icon.Clipboard className="w-4 h-auto mx-auto" />
|
<Icon.Clipboard className="w-4 h-auto mx-auto" />
|
||||||
@ -89,7 +89,7 @@ const ShortcutView = (props: Props) => {
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip title="QR Code" variant="solid" placement="top" arrow>
|
<Tooltip title="QR Code" variant="solid" placement="top" arrow>
|
||||||
<button
|
<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)}
|
onClick={() => setShowQRCodeDialog(true)}
|
||||||
>
|
>
|
||||||
<Icon.QrCode className="w-4 h-auto mx-auto" />
|
<Icon.QrCode className="w-4 h-auto mx-auto" />
|
||||||
|
@ -21,10 +21,15 @@ const Dropdown: React.FC<Props> = (props: Props) => {
|
|||||||
toggleDropdownStatus(false);
|
toggleDropdownStatus(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
window.addEventListener("click", handleClickOutside, {
|
window.addEventListener("click", handleClickOutside, {
|
||||||
capture: true,
|
capture: true,
|
||||||
once: true,
|
|
||||||
});
|
});
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("click", handleClickOutside, {
|
||||||
|
capture: true,
|
||||||
|
});
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}, [dropdownStatus]);
|
}, [dropdownStatus]);
|
||||||
|
|
||||||
|
@ -2,41 +2,19 @@ import { Button, Tab, TabList, Tabs } from "@mui/joy";
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { shortcutService } from "../services";
|
import { shortcutService } from "../services";
|
||||||
import { useAppSelector } from "../stores";
|
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 useUserStore from "../stores/v1/user";
|
||||||
import useLoading from "../hooks/useLoading";
|
import useLoading from "../hooks/useLoading";
|
||||||
import Icon from "../components/Icon";
|
import Icon from "../components/Icon";
|
||||||
import ShortcutListView from "../components/ShortcutListView";
|
import ShortcutListView from "../components/ShortcutListView";
|
||||||
import CreateShortcutDialog from "../components/CreateShortcutDialog";
|
import CreateShortcutDialog from "../components/CreateShortcutDialog";
|
||||||
import FilterView from "../components/FilterView";
|
import FilterView from "../components/FilterView";
|
||||||
|
import OrderSetting from "../components/OrderSetting";
|
||||||
|
|
||||||
interface State {
|
interface State {
|
||||||
showCreateShortcutDialog: boolean;
|
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 Home: React.FC = () => {
|
||||||
const loadingState = useLoading();
|
const loadingState = useLoading();
|
||||||
const currentUser = useUserStore().getCurrentUser();
|
const currentUser = useUserStore().getCurrentUser();
|
||||||
@ -47,6 +25,7 @@ const Home: React.FC = () => {
|
|||||||
});
|
});
|
||||||
const filter = viewStore.filter;
|
const filter = viewStore.filter;
|
||||||
const filteredShortcutList = getFilteredShortcutList(shortcutList, filter, currentUser);
|
const filteredShortcutList = getFilteredShortcutList(shortcutList, filter, currentUser);
|
||||||
|
const orderedShortcutList = getOrderedShortcutList(filteredShortcutList, viewStore.order);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
Promise.all([shortcutService.getMyAllShortcuts()]).finally(() => {
|
Promise.all([shortcutService.getMyAllShortcuts()]).finally(() => {
|
||||||
@ -69,6 +48,12 @@ const Home: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
<div className="w-full flex flex-row justify-between items-center mb-4">
|
<div className="w-full flex flex-row justify-between items-center mb-4">
|
||||||
<div className="flex flex-row justify-start items-center">
|
<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
|
<Tabs
|
||||||
value={filter.mineOnly ? "PRIVATE" : "ALL"}
|
value={filter.mineOnly ? "PRIVATE" : "ALL"}
|
||||||
size="sm"
|
size="sm"
|
||||||
@ -80,11 +65,6 @@ const Home: React.FC = () => {
|
|||||||
</TabList>
|
</TabList>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
|
||||||
<Button className="shadow" variant="soft" size="sm" onClick={() => setShowCreateShortcutDialog(true)}>
|
|
||||||
<Icon.Plus className="w-5 h-auto" /> New
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<FilterView />
|
<FilterView />
|
||||||
@ -94,13 +74,13 @@ const Home: React.FC = () => {
|
|||||||
<Icon.Loader className="mr-2 w-5 h-auto animate-spin" />
|
<Icon.Loader className="mr-2 w-5 h-auto animate-spin" />
|
||||||
loading
|
loading
|
||||||
</div>
|
</div>
|
||||||
) : filteredShortcutList.length === 0 ? (
|
) : orderedShortcutList.length === 0 ? (
|
||||||
<div className="py-16 w-full flex flex-col justify-center items-center">
|
<div className="py-16 w-full flex flex-col justify-center items-center">
|
||||||
<Icon.PackageOpen className="w-16 h-auto text-gray-400" />
|
<Icon.PackageOpen className="w-16 h-auto text-gray-400" />
|
||||||
<p className="mt-4">No shortcuts found.</p>
|
<p className="mt-4">No shortcuts found.</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<ShortcutListView shortcutList={filteredShortcutList} />
|
<ShortcutListView shortcutList={orderedShortcutList} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@ -7,18 +7,39 @@ export interface Filter {
|
|||||||
visibility?: Visibility;
|
visibility?: Visibility;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Order {
|
||||||
|
field: "name" | "createdTs" | "updatedTs" | "view";
|
||||||
|
direction: "asc" | "desc";
|
||||||
|
}
|
||||||
|
|
||||||
interface ViewState {
|
interface ViewState {
|
||||||
filter: Filter;
|
filter: Filter;
|
||||||
|
order: Order;
|
||||||
setFilter: (filter: Partial<Filter>) => void;
|
setFilter: (filter: Partial<Filter>) => void;
|
||||||
|
getOrder: () => Order;
|
||||||
|
setOrder: (order: Partial<Order>) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const useViewStore = create<ViewState>()(
|
const useViewStore = create<ViewState>()(
|
||||||
persist(
|
persist(
|
||||||
(set, get) => ({
|
(set, get) => ({
|
||||||
filter: {},
|
filter: {},
|
||||||
|
order: {
|
||||||
|
field: "name",
|
||||||
|
direction: "asc",
|
||||||
|
},
|
||||||
setFilter: (filter: Partial<Filter>) => {
|
setFilter: (filter: Partial<Filter>) => {
|
||||||
set({ filter: { ...get().filter, ...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",
|
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;
|
export default useViewStore;
|
||||||
|
9
web/src/types/modules/shortcut.d.ts
vendored
9
web/src/types/modules/shortcut.d.ts
vendored
@ -2,6 +2,12 @@ type ShortcutId = number;
|
|||||||
|
|
||||||
type Visibility = "PRIVATE" | "WORKSPACE" | "PUBLIC";
|
type Visibility = "PRIVATE" | "WORKSPACE" | "PUBLIC";
|
||||||
|
|
||||||
|
interface OpenGraphMetadata {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
image: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface Shortcut {
|
interface Shortcut {
|
||||||
id: ShortcutId;
|
id: ShortcutId;
|
||||||
|
|
||||||
@ -16,6 +22,7 @@ interface Shortcut {
|
|||||||
description: string;
|
description: string;
|
||||||
visibility: Visibility;
|
visibility: Visibility;
|
||||||
tags: string[];
|
tags: string[];
|
||||||
|
openGraphMetadata: OpenGraphMetadata;
|
||||||
view: number;
|
view: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -25,6 +32,7 @@ interface ShortcutCreate {
|
|||||||
description: string;
|
description: string;
|
||||||
visibility: Visibility;
|
visibility: Visibility;
|
||||||
tags: string[];
|
tags: string[];
|
||||||
|
openGraphMetadata: OpenGraphMetadata;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ShortcutPatch {
|
interface ShortcutPatch {
|
||||||
@ -35,6 +43,7 @@ interface ShortcutPatch {
|
|||||||
description?: string;
|
description?: string;
|
||||||
visibility?: Visibility;
|
visibility?: Visibility;
|
||||||
tags?: string[];
|
tags?: string[];
|
||||||
|
openGraphMetadata?: OpenGraphMetadata;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ShortcutFind {
|
interface ShortcutFind {
|
||||||
|
Reference in New Issue
Block a user