37 Commits

Author SHA1 Message Date
b2ce071ef0 chore: update readme 2023-07-14 21:41:34 +08:00
65545c78c6 chore: add tooltip to buttons 2023-07-14 20:45:06 +08:00
4279151238 chore: upgrade version to v0.2.0 2023-07-14 20:42:10 +08:00
3d3f55a931 chore: update air script 2023-07-14 20:42:00 +08:00
85569c032a chore: remove duplicated create button 2023-07-14 20:41:53 +08:00
bd9daddaef chore: add demo mode 2023-07-14 18:14:14 +08:00
af31875e6a chore: fix role handler 2023-07-14 13:36:32 +08:00
a0766159f2 chore: update profile 2023-07-14 13:07:01 +08:00
316617c396 chore: tweak description 2023-07-14 00:44:44 +08:00
402b766872 chore: update greeting words 2023-07-12 07:30:24 +08:00
8fade614d2 chore: fix signout 2023-07-12 07:14:23 +08:00
d8c980f56f chore: add indexes 2023-07-12 00:08:17 +08:00
b36572c5be chore: rename to slash 2023-07-11 23:51:17 +08:00
fcd72e1f98 chore: update auth checks 2023-07-11 23:39:19 +08:00
1cbab78989 feat: add chrome extension 2023-07-11 08:37:27 +08:00
28df6e35fb chore: tweak styles 2023-07-10 23:47:32 +08:00
12172f11c0 chore: update styles 2023-07-10 23:35:23 +08:00
00c7abc38d feat: add visibility filter 2023-07-10 23:19:03 +08:00
0cceed51f8 feat: implement shortcut view analytics 2023-07-10 22:57:29 +08:00
d866d5b53b chore: move auth to apiv1 2023-07-10 21:40:55 +08:00
05bc21b660 chore: update view store 2023-07-10 21:34:13 +08:00
9455824a2d feat: add tag filter 2023-07-09 11:36:26 +08:00
0b659ba124 chore: add vacuum functions 2023-07-09 01:37:20 +08:00
d82d3701dd chore: update dialog titles 2023-07-09 01:06:21 +08:00
5db3506cba feat: add member list in setting 2023-07-09 00:45:26 +08:00
c00f7d0852 chore: update request skipper 2023-07-05 00:35:24 +08:00
d900ca060a chore: remove unused scripts 2023-07-05 00:23:07 +08:00
b179f7b441 chore: update demo.gif 2023-07-04 23:08:55 +08:00
506e740438 chore: update demo gif 2023-07-04 23:02:48 +08:00
731ad57fd2 chore: update shortcut view style 2023-07-04 22:52:18 +08:00
9fd7d6bd34 chore: add demo video 2023-07-04 22:52:09 +08:00
7ca5c92769 feat: add generate qrcode dialog 2023-07-04 22:30:41 +08:00
96d44bd651 chore: update header style 2023-07-04 21:23:11 +08:00
ee9e092129 chore: code clean 2023-07-04 21:22:47 +08:00
f0334d5755 chore: update list shortcut api 2023-07-04 21:22:40 +08:00
1084381bbf chore: update jwt middleware 2023-07-04 21:07:12 +08:00
7d90b47875 chore: update shortcut view 2023-07-04 20:53:07 +08:00
90 changed files with 1740 additions and 553 deletions

View File

@ -5,7 +5,7 @@ body:
- type: markdown
attributes:
value: |
Thanks for taking the time to suggest an idea for Shortify!
Thanks for taking the time to suggest an idea for Slash!
- type: textarea
attributes:
label: Is your feature request related to a problem?

View File

@ -41,4 +41,4 @@ jobs:
file: ./Dockerfile
platforms: linux/amd64,linux/arm64
push: true
tags: stevenlgtm/shortify:latest, stevenlgtm/shortify:${{ env.VERSION }}
tags: stevenlgtm/slash:latest, stevenlgtm/slash:${{ env.VERSION }}

View File

@ -34,4 +34,4 @@ jobs:
file: ./Dockerfile
platforms: linux/amd64
push: true
tags: stevenlgtm/shortify:test
tags: stevenlgtm/slash:test

2
.gitignore vendored
View File

@ -11,5 +11,3 @@ web/dist
build
.DS_Store
extension

View File

@ -17,18 +17,24 @@ WORKDIR /backend-build
COPY . .
COPY --from=frontend /frontend-build/dist ./server/dist
RUN go build -o shortify ./cmd/shortify/main.go
RUN CGO_ENABLED=0 go build -o slash ./cmd/slash/main.go
# Make workspace with above generated files.
FROM alpine:3.16 AS monolithic
WORKDIR /usr/local/shortify
WORKDIR /usr/local/slash
RUN apk add --no-cache tzdata
ENV TZ="UTC"
COPY --from=backend /backend-build/shortify /usr/local/shortify/
COPY --from=backend /backend-build/slash /usr/local/slash/
EXPOSE 5231
# Directory to store the data, which can be referenced as the mounting point.
RUN mkdir -p /var/opt/shortify
RUN mkdir -p /var/opt/slash
VOLUME /var/opt/slash
ENTRYPOINT ["./shortify", "--mode", "prod", "--port", "5231"]
ENV SLASH_MODE="prod"
ENV SLASH_PORT="5231"
ENTRYPOINT ["./slash"]

View File

@ -1,17 +1,22 @@
# Shortify
# Slash
<img align="right" src="./resources/logo.png" height="64px" alt="logo">
**Shortify** is a bookmarking and short link service that allows you to save and share links easily. It lets you store and categorize links, generate short URLs for easy sharing, search and filter your saved links, and access them from any device. It simplifies link organization, management, and collaboration, making it effortless to navigate and share web resources.
**Slash** is a bookmarking and short link service that allows you to save and share links easily. It lets you store and categorize links, generate short URLs for easy sharing, search and filter your saved links, and access them from any device.
Let's Simplify, Share, and Save your links with **Shortify**.
Try it out on <a href="https://slash.stevenlgtm.com">Live Demo</a>.
## Features
- Create customizable `/s/` short links for any URL.
- Share short links privately or with others.
- View analytics on short link traffic and sources.
- Open source self-hosted solution.
## Deploy with Docker in seconds
> This project is under active development.
```bash
docker run -d --name shortify -p 5231:5231 -v ~/.shortify/:/var/opt/shortify stevenlgtm/shortify:latest
docker run -d --name slash -p 5231:5231 -v ~/.slash/:/var/opt/slash stevenlgtm/slash:latest
```
## Demo
![demo](./resources/demo.gif)

128
api/v1/analytics.go Normal file
View File

@ -0,0 +1,128 @@
package v1
import (
"encoding/json"
"fmt"
"net/http"
"strconv"
"github.com/boojack/slash/store"
"github.com/labstack/echo/v4"
"github.com/mssola/useragent"
"golang.org/x/exp/slices"
)
type ReferenceInfo struct {
Name string `json:"name"`
Count int `json:"count"`
}
type DeviceInfo struct {
Name string `json:"name"`
Count int `json:"count"`
}
type BrowserInfo struct {
Name string `json:"name"`
Count int `json:"count"`
}
type AnalysisData struct {
ReferenceData []ReferenceInfo `json:"referenceData"`
DeviceData []DeviceInfo `json:"deviceData"`
BrowserData []BrowserInfo `json:"browserData"`
}
func (s *APIV1Service) registerAnalyticsRoutes(g *echo.Group) {
g.GET("/shortcut/:shortcutId/analytics", func(c echo.Context) error {
ctx := c.Request().Context()
shortcutID, err := strconv.Atoi(c.Param("shortcutId"))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("shortcut id is not a number: %s", c.Param("shortcutId"))).SetInternal(err)
}
activities, err := s.Store.ListActivities(ctx, &store.FindActivity{
Type: store.ActivityShortcutView,
Where: []string{fmt.Sprintf("json_extract(payload, '$.shortcutId') = %d", shortcutID)},
})
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to get activities, err: %s", err)).SetInternal(err)
}
referenceMap := make(map[string]int)
deviceMap := make(map[string]int)
browserMap := make(map[string]int)
for _, activity := range activities {
payload := &ActivityShorcutViewPayload{}
if err := json.Unmarshal([]byte(activity.Payload), payload); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to unmarshal payload, err: %s", err)).SetInternal(err)
}
if _, ok := referenceMap[payload.Referer]; !ok {
referenceMap[payload.Referer] = 0
}
referenceMap[payload.Referer]++
ua := useragent.New(payload.UserAgent)
deviceName := ua.OSInfo().Name
browserName, _ := ua.Browser()
if _, ok := deviceMap[deviceName]; !ok {
deviceMap[deviceName] = 0
}
deviceMap[deviceName]++
if _, ok := browserMap[browserName]; !ok {
browserMap[browserName] = 0
}
browserMap[browserName]++
}
return c.JSON(http.StatusOK, &AnalysisData{
ReferenceData: mapToReferenceInfoSlice(referenceMap),
DeviceData: mapToDeviceInfoSlice(deviceMap),
BrowserData: mapToBrowserInfoSlice(browserMap),
})
})
}
func mapToReferenceInfoSlice(m map[string]int) []ReferenceInfo {
referenceInfoSlice := make([]ReferenceInfo, 0)
for key, value := range m {
referenceInfoSlice = append(referenceInfoSlice, ReferenceInfo{
Name: key,
Count: value,
})
}
slices.SortFunc(referenceInfoSlice, func(i, j ReferenceInfo) bool {
return i.Count > j.Count
})
return referenceInfoSlice
}
func mapToDeviceInfoSlice(m map[string]int) []DeviceInfo {
deviceInfoSlice := make([]DeviceInfo, 0)
for key, value := range m {
deviceInfoSlice = append(deviceInfoSlice, DeviceInfo{
Name: key,
Count: value,
})
}
slices.SortFunc(deviceInfoSlice, func(i, j DeviceInfo) bool {
return i.Count > j.Count
})
return deviceInfoSlice
}
func mapToBrowserInfoSlice(m map[string]int) []BrowserInfo {
browserInfoSlice := make([]BrowserInfo, 0)
for key, value := range m {
browserInfoSlice = append(browserInfoSlice, BrowserInfo{
Name: key,
Count: value,
})
}
slices.SortFunc(browserInfoSlice, func(i, j BrowserInfo) bool {
return i.Count > j.Count
})
return browserInfoSlice
}

View File

@ -5,8 +5,8 @@ import (
"fmt"
"net/http"
"github.com/boojack/shortify/server/auth"
"github.com/boojack/shortify/store"
"github.com/boojack/slash/api/v1/auth"
"github.com/boojack/slash/store"
"github.com/labstack/echo/v4"
"golang.org/x/crypto/bcrypt"

View File

@ -5,14 +5,14 @@ import (
"strconv"
"time"
"github.com/boojack/shortify/store"
"github.com/boojack/slash/store"
"github.com/golang-jwt/jwt/v4"
"github.com/labstack/echo/v4"
"github.com/pkg/errors"
)
const (
issuer = "shortify"
issuer = "slash"
// Signing key section. For now, this is only used for signing, not for verifying since we only
// have 1 version. But it will be used to maintain backward compatibility if we change the signing mechanism.
keyID = "v1"
@ -33,11 +33,9 @@ const (
// 2. The access token has already expired, we refresh the token so that the ongoing request can pass through.
CookieExpDuration = refreshTokenDuration - 1*time.Minute
// AccessTokenCookieName is the cookie name of access token.
AccessTokenCookieName = "access-token"
AccessTokenCookieName = "slash.access-token"
// RefreshTokenCookieName is the cookie name of refresh token.
RefreshTokenCookieName = "refresh-token"
// UserIDCookieName is the cookie name of user ID.
UserIDCookieName = "user"
RefreshTokenCookieName = "slash.refresh-token"
)
type claimsMessage struct {

View File

@ -7,9 +7,9 @@ import (
"strings"
"time"
"github.com/boojack/shortify/internal/util"
"github.com/boojack/shortify/server/auth"
"github.com/boojack/shortify/store"
"github.com/boojack/slash/api/v1/auth"
"github.com/boojack/slash/internal/util"
"github.com/boojack/slash/store"
"github.com/golang-jwt/jwt/v4"
"github.com/labstack/echo/v4"
"github.com/pkg/errors"
@ -77,14 +77,15 @@ func JWTMiddleware(server *APIV1Service, next echo.HandlerFunc, secret string) e
path := c.Path()
method := c.Request().Method
if defaultAuthSkipper(c) {
// Pass auth and profile endpoints.
if util.HasPrefixes(path, "/api/v1/auth", "/api/v1/workspace/profile") {
return next(c)
}
token := findAccessToken(c)
if token == "" {
// When the request is not authenticated, we allow the user to access the shortcut endpoints for those public shortcuts.
if util.HasPrefixes(path, "/api/v1/workspace/profile", "/s/*") && method == http.MethodGet {
if util.HasPrefixes(path, "/s/*") && method == http.MethodGet {
return next(c)
}
return echo.NewHTTPError(http.StatusUnauthorized, "Missing access token")
@ -195,8 +196,3 @@ func JWTMiddleware(server *APIV1Service, next echo.HandlerFunc, secret string) e
return next(c)
}
}
func defaultAuthSkipper(c echo.Context) bool {
path := c.Path()
return util.HasPrefixes(path, "/api/v1/auth")
}

View File

@ -6,7 +6,7 @@ import (
"net/http"
"net/url"
"github.com/boojack/shortify/store"
"github.com/boojack/slash/store"
"github.com/labstack/echo/v4"
"github.com/pkg/errors"
)

View File

@ -8,7 +8,7 @@ import (
"strconv"
"strings"
"github.com/boojack/shortify/store"
"github.com/boojack/slash/store"
"github.com/pkg/errors"
"github.com/labstack/echo/v4"
@ -83,7 +83,7 @@ func (s *APIV1Service) registerShortcutRoutes(g *echo.Group) {
Name: strings.ToLower(create.Name),
Link: create.Link,
Description: create.Description,
Visibility: convertVisibilityToStore(create.Visibility),
Visibility: store.Visibility(create.Visibility.String()),
Tag: strings.Join(create.Tags, " "),
})
if err != nil {
@ -176,13 +176,6 @@ func (s *APIV1Service) registerShortcutRoutes(g *echo.Group) {
}
find := &store.FindShortcut{}
if creatorIDStr := c.QueryParam("creatorId"); creatorIDStr != "" {
creatorID, err := strconv.Atoi(creatorIDStr)
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("unwanted creator id string: %s", creatorIDStr))
}
find.CreatorID = &creatorID
}
if tag := c.QueryParam("tag"); tag != "" {
find.Tag = &tag
}
@ -310,6 +303,9 @@ func (s *APIV1Service) composeShortcut(ctx context.Context, shortcut *Shortcut)
if err != nil {
return nil, errors.Wrap(err, "Failed to get creator")
}
if user == nil {
return nil, errors.New("Creator not found")
}
shortcut.Creator = convertUserFromStore(user)
activityList, err := s.Store.ListActivities(ctx, &store.FindActivity{
@ -325,19 +321,6 @@ func (s *APIV1Service) composeShortcut(ctx context.Context, shortcut *Shortcut)
return shortcut, nil
}
func convertVisibilityToStore(visibility Visibility) store.Visibility {
switch visibility {
case VisibilityPrivate:
return store.VisibilityPrivate
case VisibilityWorkspace:
return store.VisibilityWorkspace
case VisibilityPublic:
return store.VisibilityPublic
default:
return store.VisibilityPrivate
}
}
func convertShortcutFromStore(shortcut *store.Shortcut) *Shortcut {
tags := []string{}
if shortcut.Tag != "" {

View File

@ -9,6 +9,7 @@ import (
)
func (*APIV1Service) registerURLUtilRoutes(g *echo.Group) {
// GET /url/favicon?url=...
g.GET("/url/favicon", func(c echo.Context) error {
url := c.QueryParam("url")
icons, err := favicon.Find(url)

View File

@ -7,7 +7,7 @@ import (
"net/mail"
"strconv"
"github.com/boojack/shortify/store"
"github.com/boojack/slash/store"
"github.com/labstack/echo/v4"
"golang.org/x/crypto/bcrypt"
@ -56,7 +56,7 @@ type CreateUserRequest struct {
Email string `json:"email"`
Nickname string `json:"nickname"`
Password string `json:"password"`
Role Role `json:"-"`
Role Role `json:"role"`
}
func (create CreateUserRequest) Validate() error {
@ -78,13 +78,56 @@ type PatchUserRequest struct {
Email *string `json:"email"`
Nickname *string `json:"nickname"`
Password *string `json:"password"`
}
type UserDelete struct {
ID int
Role *Role `json:"role"`
}
func (s *APIV1Service) registerUserRoutes(g *echo.Group) {
g.POST("/user", func(c echo.Context) error {
ctx := c.Request().Context()
userID, ok := c.Get(getUserIDContextKey()).(int)
if !ok {
return echo.NewHTTPError(http.StatusUnauthorized, "Missing auth session")
}
currentUser, err := s.Store.GetUser(ctx, &store.FindUser{
ID: &userID,
})
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find user by id").SetInternal(err)
}
if currentUser == nil {
return echo.NewHTTPError(http.StatusUnauthorized, "Missing auth session")
}
if currentUser.Role != store.RoleAdmin {
return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized to create user")
}
userCreate := &CreateUserRequest{}
if err := json.NewDecoder(c.Request().Body).Decode(userCreate); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted post user request").SetInternal(err)
}
if err := userCreate.Validate(); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Invalid user create format").SetInternal(err)
}
passwordHash, err := bcrypt.GenerateFromPassword([]byte(userCreate.Password), bcrypt.DefaultCost)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to generate password hash").SetInternal(err)
}
user, err := s.Store.CreateUser(ctx, &store.User{
Role: store.Role(userCreate.Role),
Email: userCreate.Email,
Nickname: userCreate.Nickname,
PasswordHash: string(passwordHash),
})
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create user").SetInternal(err)
}
userMessage := convertUserFromStore(user)
return c.JSON(http.StatusOK, userMessage)
})
g.GET("/user", func(c echo.Context) error {
ctx := c.Request().Context()
list, err := s.Store.ListUsers(ctx, &store.FindUser{})
@ -144,7 +187,16 @@ func (s *APIV1Service) registerUserRoutes(g *echo.Group) {
if !ok {
return echo.NewHTTPError(http.StatusUnauthorized, "missing user in session")
}
if currentUserID != userID {
currentUser, err := s.Store.GetUser(ctx, &store.FindUser{
ID: &currentUserID,
})
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "failed to find current user").SetInternal(err)
}
if currentUser == nil {
return echo.NewHTTPError(http.StatusUnauthorized, "missing user in session")
}
if currentUser.ID != userID && currentUser.Role != store.RoleAdmin {
return echo.NewHTTPError(http.StatusForbidden, "access forbidden for current session user").SetInternal(err)
}
@ -154,13 +206,12 @@ func (s *APIV1Service) registerUserRoutes(g *echo.Group) {
}
updateUser := &store.UpdateUser{
ID: currentUserID,
ID: userID,
}
if userPatch.Email != nil {
if !validateEmail(*userPatch.Email) {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("invalid email format: %s", *userPatch.Email))
}
updateUser.Email = userPatch.Email
}
if userPatch.Nickname != nil {
@ -175,6 +226,14 @@ func (s *APIV1Service) registerUserRoutes(g *echo.Group) {
passwordHashStr := string(passwordHash)
updateUser.PasswordHash = &passwordHashStr
}
if userPatch.RowStatus != nil {
rowStatus := store.RowStatus(*userPatch.RowStatus)
updateUser.RowStatus = &rowStatus
}
if userPatch.Role != nil {
role := store.Role(*userPatch.Role)
updateUser.Role = &role
}
user, err := s.Store.UpdateUser(ctx, updateUser)
if err != nil {
@ -207,6 +266,18 @@ func (s *APIV1Service) registerUserRoutes(g *echo.Group) {
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("user id is not a number: %s", c.Param("id"))).SetInternal(err)
}
user, err := s.Store.GetUser(ctx, &store.FindUser{
ID: &userID,
})
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Failed to find user, err: %s", err)).SetInternal(err)
}
if user == nil {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("user not found with ID: %d", userID)).SetInternal(err)
}
if user.Role == store.RoleAdmin {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("cannot delete admin user with ID: %d", userID)).SetInternal(err)
}
if err := s.Store.DeleteUser(ctx, &store.DeleteUser{
ID: userID,

View File

@ -13,11 +13,8 @@ const (
)
// String returns the string format of UserSettingKey type.
func (key UserSettingKey) String() string {
if key == UserSettingLocaleKey {
return "locale"
}
return ""
func (k UserSettingKey) String() string {
return string(k)
}
var (
@ -27,7 +24,7 @@ var (
type UserSetting struct {
UserID int
Key UserSettingKey `json:"key"`
// Value is a JSON string with basic value
// Value is a JSON string with basic value.
Value string `json:"value"`
}

View File

@ -1,8 +1,8 @@
package v1
import (
"github.com/boojack/shortify/server/profile"
"github.com/boojack/shortify/store"
"github.com/boojack/slash/server/profile"
"github.com/boojack/slash/store"
"github.com/labstack/echo/v4"
)
@ -29,6 +29,7 @@ func (s *APIV1Service) Start(apiGroup *echo.Group, secret string) {
s.registerAuthRoutes(apiV1Group, secret)
s.registerUserRoutes(apiV1Group)
s.registerShortcutRoutes(apiV1Group)
s.registerAnalyticsRoutes(apiV1Group)
redirectorGroup := apiGroup.Group("/s")
redirectorGroup.Use(func(next echo.HandlerFunc) echo.HandlerFunc {

View File

@ -5,8 +5,8 @@ import (
"fmt"
"net/http"
"github.com/boojack/shortify/server/profile"
"github.com/boojack/shortify/store"
"github.com/boojack/slash/server/profile"
"github.com/boojack/slash/store"
"github.com/labstack/echo/v4"
)

View File

@ -12,21 +12,14 @@ import (
"github.com/spf13/viper"
_ "modernc.org/sqlite"
"github.com/boojack/shortify/server"
_profile "github.com/boojack/shortify/server/profile"
"github.com/boojack/shortify/store"
"github.com/boojack/shortify/store/db"
"github.com/boojack/slash/server"
_profile "github.com/boojack/slash/server/profile"
"github.com/boojack/slash/store"
"github.com/boojack/slash/store/db"
)
const (
greetingBanner = `
`
greetingBanner = `Welcome to Slash!`
)
var (
@ -36,8 +29,8 @@ var (
data string
rootCmd = &cobra.Command{
Use: "shortify",
Short: "",
Use: "slash",
Short: `A bookmarking and url shortener, save and share your links very easily.`,
Run: func(_cmd *cobra.Command, _args []string) {
ctx, cancel := context.WithCancel(context.Background())
db := db.NewDB(profile)
@ -89,7 +82,7 @@ func Execute() error {
func init() {
cobra.OnInitialize(initConfig)
rootCmd.PersistentFlags().StringVarP(&mode, "mode", "m", "dev", `mode of server, can be "prod" or "dev"`)
rootCmd.PersistentFlags().StringVarP(&mode, "mode", "m", "demo", `mode of server, can be "prod" or "dev" or "demo"`)
rootCmd.PersistentFlags().IntVarP(&port, "port", "p", 8082, "port of server")
rootCmd.PersistentFlags().StringVarP(&data, "data", "d", "", "data directory")
@ -106,9 +99,9 @@ func init() {
panic(err)
}
viper.SetDefault("mode", "dev")
viper.SetDefault("mode", "demo")
viper.SetDefault("port", 8082)
viper.SetEnvPrefix("shortify")
viper.SetEnvPrefix("slash")
}
func initConfig() {

21
extension/background.js Normal file
View File

@ -0,0 +1,21 @@
import { getSlashData } from "./common.js";
const urlRegex = /https?:\/\/s\/(.+)/;
chrome.tabs.onUpdated.addListener(async (tabId, changeInfo, tab) => {
if (typeof tab.url === "string") {
const matchResult = urlRegex.exec(tab.url);
if (matchResult) {
const slashData = await getSlashData();
const name = matchResult[1];
const url = `${slashData.domain}/s/${name}`;
return chrome.tabs.update(tab.id, { url });
}
}
});
chrome.omnibox.onInputEntered.addListener(async (text) => {
const slashData = await getSlashData();
const url = `${slashData.domain}/s/${text}`;
return chrome.tabs.update({ url });
});

11
extension/common.js Normal file
View File

@ -0,0 +1,11 @@
export const getSlashData = () => {
return new Promise((resolve, reject) => {
chrome.storage.local.get(["slash"], (data) => {
if (data?.slash) {
resolve(data.slash);
} else {
reject("slash data not found");
}
});
});
};

18
extension/manifest.json Normal file
View File

@ -0,0 +1,18 @@
{
"name": "Slash",
"description": "",
"version": "0.1.0",
"manifest_version": 3,
"omnibox": {
"keyword": "s/"
},
"action": {
"default_popup": "popup.html"
},
"background": {
"service_worker": "background.js",
"type": "module"
},
"permissions": ["tabs", "activeTab", "storage"],
"host_permissions": ["*://s/*"]
}

14
extension/popup.html Normal file
View File

@ -0,0 +1,14 @@
<!DOCTYPE html>
<html>
<body>
<h2>Slash extension</h2>
<div>
<span>Domain</span>
<input id="domain-input" type="text" />
</div>
<div>
<button id="save-button">Save</button>
</div>
<script type="module" src="popup.js"></script>
</body>
</html>

23
extension/popup.js Normal file
View File

@ -0,0 +1,23 @@
import { getSlashData } from "./common.js";
const saveButton = document.body.querySelector("#save-button");
const domainInput = document.body.querySelector("#domain-input");
saveButton.addEventListener("click", () => {
chrome.storage.local.set({
slash: {
domain: domainInput.value,
},
});
});
(async () => {
try {
const slashData = await getSlashData();
if (slashData) {
domainInput.value = slashData.domain;
}
} catch (error) {
// do nothing.
}
})();

6
go.mod
View File

@ -1,4 +1,4 @@
module github.com/boojack/shortify
module github.com/boojack/slash
go 1.19
@ -66,8 +66,10 @@ require (
require (
github.com/golang-jwt/jwt/v4 v4.5.0
github.com/mssola/useragent v1.0.0
github.com/pkg/errors v0.9.1
go.deanishe.net/favicon v0.1.0
golang.org/x/mod v0.8.0
golang.org/x/exp v0.0.0-20230626212559-97b1e661b5df
golang.org/x/mod v0.11.0
modernc.org/sqlite v1.23.1
)

8
go.sum
View File

@ -217,6 +217,8 @@ github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe/go.mod h1:wL8QJuTMNUDYhXwkmfOly8iTdp5TEcJFWZD2D7SIkUc=
github.com/mssola/useragent v1.0.0 h1:WRlDpXyxHDNfvZaPEut5Biveq86Ze4o4EMffyMxmH5o=
github.com/mssola/useragent v1.0.0/go.mod h1:hz9Cqz4RXusgg1EdI4Al0INR62kP7aPSRNHnpU+b85Y=
github.com/pelletier/go-toml v1.7.0/go.mod h1:vwGMzjaWMwyfHwgIBhI2YUM4fB6nL6lVAvS1LBMMhTE=
github.com/pelletier/go-toml/v2 v2.0.8 h1:0ctb6s9mE31h0/lhu+J6OPmVeDxJn+kYnJc2jZR9tGQ=
github.com/pelletier/go-toml/v2 v2.0.8/go.mod h1:vuYfssBdrU2XDZ9bYydBu6t+6a6PYNcZljzZR9VXg+4=
@ -316,6 +318,8 @@ golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u0
golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM=
golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU=
golang.org/x/exp v0.0.0-20230626212559-97b1e661b5df h1:UA2aFVmmsIlefxMk29Dp2juaUSth8Pyn3Tq5Y5mJGME=
golang.org/x/exp v0.0.0-20230626212559-97b1e661b5df/go.mod h1:FXUEEKJgO7OQYeo8N01OfiKP8RXMtf6e8aTskBGqWdc=
golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
@ -339,8 +343,8 @@ golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.4.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.8.0 h1:LUYupSeNrTNCGzR/hVBk2NHZO4hXcVaW1k4Qx7rjPx8=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.11.0 h1:bUO06HqtnRcc/7l71XBe4WcqTZ+3AH1J59zWDDwLKgU=
golang.org/x/mod v0.11.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/net v0.0.0-20180218175443-cbe0f9307d01/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 MiB

After

Width:  |  Height:  |  Size: 2.1 MiB

View File

@ -2,8 +2,8 @@ root = "."
tmp_dir = ".air"
[build]
bin = "./.air/shortify"
cmd = "go build -o ./.air/shortify ./cmd/shortify/main.go"
bin = "./.air/slash --mode dev"
cmd = "go build -o ./.air/slash ./cmd/slash/main.go"
delay = 1000
exclude_dir = [".air", "web", "build"]
exclude_file = []

View File

@ -1,13 +0,0 @@
#!/bin/bash
# Usage: ./scripts/build.sh
set -e
cd "$(dirname "$0")/../"
echo "Start building backend..."
go build -o ./build/shortify ./cmd/shortify/main.go
echo "Backend built!"

View File

@ -1,9 +0,0 @@
#!/bin/bash
# Usage: ./scripts/start.sh
set -e
cd "$(dirname "$0")/../"
air -c ./scripts/.air.toml

View File

@ -5,7 +5,7 @@ import (
"io/fs"
"net/http"
"github.com/boojack/shortify/internal/util"
"github.com/boojack/slash/internal/util"
"github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware"
)
@ -22,16 +22,16 @@ func getFileSystem(path string) http.FileSystem {
return http.FS(fs)
}
func defaultAPIRequestSkipper(c echo.Context) bool {
func defaultRequestSkipper(c echo.Context) bool {
path := c.Path()
return util.HasPrefixes(path, "/api")
return util.HasPrefixes(path, "/api/", "/s/*")
}
func embedFrontend(e *echo.Echo) {
// Use echo static middleware to serve the built dist folder
// refer: https://github.com/labstack/echo/blob/master/middleware/static.go
e.Use(middleware.StaticWithConfig(middleware.StaticConfig{
Skipper: defaultAPIRequestSkipper,
Skipper: defaultRequestSkipper,
HTML5: true,
Filesystem: getFileSystem("dist"),
}))
@ -44,7 +44,7 @@ func embedFrontend(e *echo.Echo) {
}
})
assetsGroup.Use(middleware.StaticWithConfig(middleware.StaticConfig{
Skipper: defaultAPIRequestSkipper,
Skipper: defaultRequestSkipper,
HTML5: true,
Filesystem: getFileSystem("dist/assets"),
}))

View File

@ -4,38 +4,44 @@ import (
"fmt"
"os"
"path/filepath"
"runtime"
"strings"
"github.com/boojack/shortify/server/version"
"github.com/boojack/slash/server/version"
"github.com/spf13/viper"
)
// Profile is the configuration to start main server.
type Profile struct {
// Data is the data directory
Data string `json:"-"`
// DSN points to where Shortify stores its own data
DSN string `json:"-"`
// Mode can be "prod" or "dev"
Mode string `json:"mode"`
// Port is the binding port for server
Port int `json:"port"`
Port int `json:"-"`
// Data is the data directory
Data string `json:"-"`
// DSN points to where slash stores its own data
DSN string `json:"-"`
// Version is the current version of server
Version string `json:"version"`
}
func (p *Profile) IsDev() bool {
return p.Mode != "prod"
}
func checkDSN(dataDir string) (string, error) {
// Convert to absolute path if relative path is supplied.
if !filepath.IsAbs(dataDir) {
absDir, err := filepath.Abs(filepath.Dir(os.Args[0]) + "/" + dataDir)
relativeDir := filepath.Join(filepath.Dir(os.Args[0]), dataDir)
absDir, err := filepath.Abs(relativeDir)
if err != nil {
return "", err
}
dataDir = absDir
}
// Trim trailing / in case user supplies
dataDir = strings.TrimRight(dataDir, "/")
// Trim trailing \ or / in case user supplies
dataDir = strings.TrimRight(dataDir, "\\/")
if _, err := os.Stat(dataDir); err != nil {
return "", fmt.Errorf("unable to access data folder %s, err %w", dataDir, err)
@ -44,7 +50,7 @@ func checkDSN(dataDir string) (string, error) {
return dataDir, nil
}
// GetDevProfile will return a profile for dev or prod.
// GetProfile will return a profile for dev or prod.
func GetProfile() (*Profile, error) {
profile := Profile{}
err := viper.Unmarshal(&profile)
@ -52,12 +58,23 @@ func GetProfile() (*Profile, error) {
return nil, err
}
if profile.Mode != "dev" && profile.Mode != "prod" {
profile.Mode = "dev"
if profile.Mode != "demo" && profile.Mode != "dev" && profile.Mode != "prod" {
profile.Mode = "demo"
}
if profile.Mode == "prod" && profile.Data == "" {
profile.Data = "/var/opt/shortify"
if runtime.GOOS == "windows" {
profile.Data = filepath.Join(os.Getenv("ProgramData"), "slash")
if _, err := os.Stat(profile.Data); os.IsNotExist(err) {
if err := os.MkdirAll(profile.Data, 0770); err != nil {
fmt.Printf("Failed to create data directory: %s, err: %+v\n", profile.Data, err)
return nil, err
}
}
} else {
profile.Data = "/var/opt/slash"
}
}
dataDir, err := checkDSN(profile.Data)
@ -67,7 +84,9 @@ func GetProfile() (*Profile, error) {
}
profile.Data = dataDir
profile.DSN = fmt.Sprintf("%s/shortify_%s.db", dataDir, profile.Mode)
dbFile := fmt.Sprintf("slash_%s.db", profile.Mode)
profile.DSN = filepath.Join(dataDir, dbFile)
profile.Version = version.GetCurrentVersion(profile.Mode)
return &profile, nil
}

View File

@ -5,9 +5,9 @@ import (
"fmt"
"time"
apiv1 "github.com/boojack/shortify/api/v1"
"github.com/boojack/shortify/server/profile"
"github.com/boojack/shortify/store"
apiv1 "github.com/boojack/slash/api/v1"
"github.com/boojack/slash/server/profile"
"github.com/boojack/slash/store"
"github.com/google/uuid"
"github.com/labstack/echo/v4"
@ -52,7 +52,7 @@ func NewServer(ctx context.Context, profile *profile.Profile, store *store.Store
embedFrontend(e)
// In dev mode, we'd like to set the const secret key to make signin session persistence.
secret := "shortify"
secret := "slash"
if profile.Mode == "prod" {
var err error
secret, err = s.getSystemSecretSessionName(ctx)
@ -61,10 +61,10 @@ func NewServer(ctx context.Context, profile *profile.Profile, store *store.Store
}
}
apiGroup := e.Group("")
rootGroup := e.Group("")
// Register API v1 routes.
apiV1Service := apiv1.NewAPIV1Service(profile, store)
apiV1Service.Start(apiGroup, secret)
apiV1Service.Start(rootGroup, secret)
return s, nil
}

View File

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

View File

@ -12,13 +12,16 @@ import (
"sort"
"time"
"github.com/boojack/shortify/server/profile"
"github.com/boojack/shortify/server/version"
"github.com/boojack/slash/server/profile"
"github.com/boojack/slash/server/version"
)
//go:embed migration
var migrationFS embed.FS
//go:embed seed
var seedFS embed.FS
type DB struct {
profile *profile.Profile
// sqlite db connection instance
@ -89,7 +92,7 @@ func (db *DB) Open(ctx context.Context) (err error) {
if err != nil {
return fmt.Errorf("failed to read raw database file, err: %w", err)
}
backupDBFilePath := fmt.Sprintf("%s/shortify_%s_%d_backup.db", db.profile.Data, db.profile.Version, time.Now().Unix())
backupDBFilePath := fmt.Sprintf("%s/slash_%s_%d_backup.db", db.profile.Data, db.profile.Version, time.Now().Unix())
if err := os.WriteFile(backupDBFilePath, rawBytes, 0644); err != nil {
return fmt.Errorf("failed to write raw database file, err: %w", err)
}
@ -119,6 +122,12 @@ func (db *DB) Open(ctx context.Context) (err error) {
if err := db.applyLatestSchema(ctx); err != nil {
return fmt.Errorf("failed to apply latest schema: %w", err)
}
// In demo mode, we should seed the database.
if db.profile.Mode == "demo" {
if err := db.seed(ctx); err != nil {
return fmt.Errorf("failed to seed: %w", err)
}
}
}
}
@ -185,6 +194,28 @@ func (db *DB) applyMigrationForMinorVersion(ctx context.Context, minorVersion st
return tx.Commit()
}
func (db *DB) seed(ctx context.Context) error {
filenames, err := fs.Glob(seedFS, fmt.Sprintf("%s/*.sql", "seed"))
if err != nil {
return fmt.Errorf("failed to read seed files, err: %w", err)
}
sort.Strings(filenames)
// Loop over all seed files and execute them in order.
for _, filename := range filenames {
buf, err := seedFS.ReadFile(filename)
if err != nil {
return fmt.Errorf("failed to read seed file, filename=%s err=%w", filename, err)
}
stmt := string(buf)
if err := db.execute(ctx, stmt); err != nil {
return fmt.Errorf("seed error: statement:%s err=%w", stmt, err)
}
}
return nil
}
// execute runs a single SQL statement within a transaction.
func (db *DB) execute(ctx context.Context, stmt string) error {
tx, err := db.DBInstance.Begin()

View File

@ -22,6 +22,8 @@ CREATE TABLE user (
role TEXT NOT NULL CHECK (role IN ('ADMIN', 'USER')) DEFAULT 'USER'
);
CREATE INDEX idx_user_email ON user(email);
-- user_setting
CREATE TABLE user_setting (
user_id INTEGER NOT NULL,
@ -44,6 +46,8 @@ CREATE TABLE shortcut (
tag TEXT NOT NULL DEFAULT ''
);
CREATE INDEX idx_shortcut_name ON shortcut(name);
-- activity
CREATE TABLE activity (
id INTEGER PRIMARY KEY AUTOINCREMENT,

View File

@ -0,0 +1,2 @@
CREATE INDEX IF NOT EXISTS idx_user_email ON user(email);
CREATE INDEX IF NOT EXISTS idx_shortcut_name ON shortcut(name);

View File

@ -22,6 +22,8 @@ CREATE TABLE user (
role TEXT NOT NULL CHECK (role IN ('ADMIN', 'USER')) DEFAULT 'USER'
);
CREATE INDEX idx_user_email ON user(email);
-- user_setting
CREATE TABLE user_setting (
user_id INTEGER NOT NULL,
@ -44,6 +46,8 @@ CREATE TABLE shortcut (
tag TEXT NOT NULL DEFAULT ''
);
CREATE INDEX idx_shortcut_name ON shortcut(name);
-- activity
CREATE TABLE activity (
id INTEGER PRIMARY KEY AUTOINCREMENT,

View File

@ -0,0 +1,9 @@
DELETE FROM activity;
DELETE FROM shortcut;
DELETE FROM user_setting;
DELETE FROM user;
DELETE FROM workspace_setting;

View File

@ -0,0 +1,35 @@
INSERT INTO
user (
`id`,
`role`,
`email`,
`nickname`,
`password_hash`
)
VALUES
(
101,
'ADMIN',
'slash@stevenlgtm.com',
'Slasher',
-- raw password: secret
'$2a$14$ajq8Q7fbtFRQvXpdCq7Jcuy.Rx1h/L4J60Otx.gyNLbAYctGMJ9tK'
);
INSERT INTO
user (
`id`,
`role`,
`email`,
`nickname`,
`password_hash`
)
VALUES
(
102,
'USER',
'steven@usememos.com',
'Steven',
-- raw password: secret
'$2a$14$ajq8Q7fbtFRQvXpdCq7Jcuy.Rx1h/L4J60Otx.gyNLbAYctGMJ9tK'
);

View File

@ -0,0 +1,67 @@
INSERT INTO
shortcut (
`id`,
`creator_id`,
`name`,
`link`,
`visibility`
)
VALUES
(
1,
101,
'memos',
'https://usememos.com',
'PUBLIC'
);
INSERT INTO
shortcut (
`id`,
`creator_id`,
`name`,
`link`,
`visibility`
)
VALUES
(
2,
101,
'sqlchat',
'https://www.sqlchat.ai',
'WORKSPACE'
);
INSERT INTO
shortcut (
`id`,
`creator_id`,
`name`,
`link`,
`visibility`
)
VALUES
(
3,
101,
'schema-change',
'https://www.bytebase.com/blog/how-to-handle-database-schema-change/#what-is-a-database-schema-change',
'PUBLIC'
);
INSERT INTO
shortcut (
`id`,
`creator_id`,
`name`,
`link`,
`visibility`
)
VALUES
(
4,
102,
'stevenlgtm',
'https://github.com/boojack',
'PUBLIC'
);

View File

@ -309,3 +309,22 @@ func listShortcuts(ctx context.Context, tx *sql.Tx, find *FindShortcut) ([]*Shor
return list, nil
}
func vacuumShortcut(ctx context.Context, tx *sql.Tx) error {
stmt := `
DELETE FROM
shortcut
WHERE
creator_id NOT IN (
SELECT
id
FROM
user
)`
_, err := tx.ExecContext(ctx, stmt)
if err != nil {
return err
}
return nil
}

View File

@ -4,7 +4,7 @@ import (
"database/sql"
"sync"
"github.com/boojack/shortify/server/profile"
"github.com/boojack/slash/server/profile"
)
// Store provides database access to all raw objects.

View File

@ -208,6 +208,14 @@ func (s *Store) DeleteUser(ctx context.Context, delete *DeleteUser) error {
return err
}
if err := vacuumUserSetting(ctx, tx); err != nil {
return err
}
if err := vacuumShortcut(ctx, tx); err != nil {
return err
}
if err := tx.Commit(); err != nil {
// do nothing here to prevent linter warning.
return err

View File

@ -132,3 +132,22 @@ func listUserSettings(ctx context.Context, tx *sql.Tx, find *FindUserSetting) ([
return userSettingMessageList, nil
}
func vacuumUserSetting(ctx context.Context, tx *sql.Tx) error {
stmt := `
DELETE FROM
user_setting
WHERE
user_id NOT IN (
SELECT
id
FROM
user
)`
_, err := tx.ExecContext(ctx, stmt)
if err != nil {
return err
}
return nil
}

View File

@ -4,7 +4,7 @@ import (
"context"
"testing"
"github.com/boojack/shortify/store"
"github.com/boojack/slash/store"
"github.com/stretchr/testify/require"
)

View File

@ -4,7 +4,7 @@ import (
"context"
"testing"
"github.com/boojack/shortify/store"
"github.com/boojack/slash/store"
"github.com/stretchr/testify/require"
)

View File

@ -5,9 +5,9 @@ import (
"fmt"
"testing"
"github.com/boojack/shortify/store"
"github.com/boojack/shortify/store/db"
test "github.com/boojack/shortify/test"
"github.com/boojack/slash/store"
"github.com/boojack/slash/store/db"
test "github.com/boojack/slash/test"
// sqlite driver.
_ "modernc.org/sqlite"

View File

@ -4,7 +4,7 @@ import (
"context"
"testing"
"github.com/boojack/shortify/store"
"github.com/boojack/slash/store"
"github.com/stretchr/testify/require"
"golang.org/x/crypto/bcrypt"
@ -26,6 +26,13 @@ func TestUserStore(t *testing.T) {
Nickname: &userPatchNickname,
})
require.NoError(t, err)
_, err = ts.CreateShortcut(ctx, &store.Shortcut{
CreatorID: user.ID,
Name: "test_shortcut",
Link: "https://www.google.com",
Visibility: store.VisibilityPublic,
})
require.NoError(t, err)
require.Equal(t, userPatchNickname, user.Nickname)
err = ts.DeleteUser(ctx, &store.DeleteUser{
ID: user.ID,
@ -34,6 +41,9 @@ func TestUserStore(t *testing.T) {
users, err = ts.ListUsers(ctx, &store.FindUser{})
require.NoError(t, err)
require.Equal(t, 0, len(users))
shortcuts, err := ts.ListShortcuts(ctx, &store.FindShortcut{})
require.NoError(t, err)
require.Equal(t, 0, len(shortcuts))
}
// createTestingAdminUser creates a testing admin user.

View File

@ -4,7 +4,7 @@ import (
"context"
"testing"
"github.com/boojack/shortify/store"
"github.com/boojack/slash/store"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
)

View File

@ -5,8 +5,8 @@ import (
"net"
"testing"
"github.com/boojack/shortify/server/profile"
"github.com/boojack/shortify/server/version"
"github.com/boojack/slash/server/profile"
"github.com/boojack/slash/server/version"
)
func getUnusedPort() int {
@ -31,7 +31,7 @@ func GetTestingProfile(t *testing.T) *profile.Profile {
Mode: mode,
Port: port,
Data: dir,
DSN: fmt.Sprintf("%s/shortify_%s.db", dir, mode),
DSN: fmt.Sprintf("%s/slash_%s.db", dir, mode),
Version: version.GetCurrentVersion(mode),
}
}

View File

@ -1 +1 @@
# Shortify
# Slash

View File

@ -5,7 +5,7 @@
<link rel="icon" href="/logo.png" type="image/*" />
<meta name="theme-color" content="#FFFFFF" />
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no" />
<title>Shortify</title>
<title>Slash</title>
</head>
<body>
<div id="root"></div>

View File

@ -1,5 +1,5 @@
{
"name": "shortify",
"name": "slash",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
@ -17,6 +17,7 @@
"i18next": "^23.2.3",
"lodash-es": "^4.17.21",
"lucide-react": "^0.252.0",
"qrcode.react": "^3.1.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-hot-toast": "^2.4.0",

11
web/pnpm-lock.yaml generated
View File

@ -35,6 +35,9 @@ dependencies:
lucide-react:
specifier: ^0.252.0
version: 0.252.0(react@18.2.0)
qrcode.react:
specifier: ^3.1.0
version: 3.1.0(react@18.2.0)
react:
specifier: ^18.2.0
version: 18.2.0
@ -2513,6 +2516,14 @@ packages:
engines: {node: '>=6'}
dev: true
/qrcode.react@3.1.0(react@18.2.0):
resolution: {integrity: sha512-oyF+Urr3oAMUG/OiOuONL3HXM+53wvuH3mtIWQrYmsXoAq0DkvZp2RYUWFSMFtbdOpuS++9v+WAkzNVkMlNW6Q==}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0
dependencies:
react: 18.2.0
dev: false
/queue-microtask@1.2.3:
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}

View File

@ -1,15 +1,33 @@
import { CssVarsProvider } from "@mui/joy/styles";
import { Toaster } from "react-hot-toast";
import { RouterProvider } from "react-router-dom";
import router from "./routers";
import { useEffect, useState } from "react";
import { Outlet } from "react-router-dom";
import { globalService } from "./services";
import useUserStore from "./stores/v1/user";
function App() {
return (
<CssVarsProvider>
<RouterProvider router={router} />
<Toaster position="top-center" />
</CssVarsProvider>
);
const userStore = useUserStore();
const [loading, setLoading] = useState(true);
useEffect(() => {
const initialState = async () => {
try {
await globalService.initialState();
} catch (error) {
// do nothing
}
try {
await userStore.fetchCurrentUser();
} catch (error) {
// do nothing.
}
setLoading(false);
};
initialState();
}, []);
return <>{!loading && <Outlet />}</>;
}
export default App;

View File

@ -19,12 +19,12 @@ const AboutDialog: React.FC<Props> = (props: Props) => {
</div>
<div className="max-w-full w-80 sm:w-96">
<p>
<span className="font-medium">Shortify</span> is a bookmarking and short link service that allows you to save and share links
<span className="font-medium">Slash</span> is a bookmarking and short link service that allows you to save and share links
easily.
</p>
<div className="mt-1">
<span className="mr-2">See more in:</span>
<Link variant="plain" href="https://github.com/boojack/shortify">
<Link variant="plain" href="https://github.com/boojack/slash">
GitHub
</Link>
</div>

View File

@ -0,0 +1,146 @@
import { Button, Modal, ModalDialog } from "@mui/joy";
import { useEffect, useState } from "react";
import * as api from "../helpers/api";
import Icon from "./Icon";
interface Props {
shortcutId: ShortcutId;
onClose: () => void;
}
const AnalyticsDialog: React.FC<Props> = (props: Props) => {
const { shortcutId, onClose } = props;
const [analytics, setAnalytics] = useState<AnalysisData | null>(null);
const [selectedDeviceTab, setSelectedDeviceTab] = useState<"os" | "browser">("os");
useEffect(() => {
api.getShortcutAnalytics(shortcutId).then(({ data }) => {
setAnalytics(data);
});
}, []);
return (
<Modal open={true}>
<ModalDialog>
<div className="w-full flex flex-row justify-between items-center">
<span className="text-lg font-medium">Analytics</span>
<Button variant="plain" onClick={onClose}>
<Icon.X className="w-5 h-auto text-gray-600" />
</Button>
</div>
<div className="max-w-full w-80 sm:w-96">
{analytics ? (
<>
<p className="w-full py-1 px-2">Top Sources</p>
<div className="mt-1 overflow-hidden shadow ring-1 ring-black ring-opacity-5 sm:rounded-lg">
<table className="min-w-full divide-y divide-gray-300">
<thead>
<tr>
<th scope="col" className="py-1 px-2 text-left font-semibold text-sm text-gray-500">
Source
</th>
<th scope="col" className="py-1 pr-2 text-right font-semibold text-sm text-gray-500">
Visitors
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200">
{analytics.referenceData.map((reference) => (
<tr key={reference.name}>
<td className="whitespace-nowrap py-2 px-2 text-sm text-gray-900">
{reference.name ? (
<a className="hover:underline hover:text-blue-600" href={reference.name} target="_blank">
{reference.name}
</a>
) : (
"Direct"
)}
</td>
<td className="whitespace-nowrap py-2 pr-2 text-sm text-gray-500 text-right">{reference.count}</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="w-full mt-4 py-1 px-2 flex flex-row justify-between items-center">
<span>Devices</span>
<div>
<button
className={`whitespace-nowrap border-b-2 px-1 text-sm font-medium ${
selectedDeviceTab === "os"
? "border-blue-600 text-blue-600"
: "border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700"
}`}
onClick={() => setSelectedDeviceTab("os")}
>
OS
</button>
<span className="text-gray-200 font-mono mx-1">/</span>
<button
className={`whitespace-nowrap border-b-2 px-1 text-sm font-medium ${
selectedDeviceTab === "browser"
? "border-blue-600 text-blue-600"
: "border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700"
}`}
onClick={() => setSelectedDeviceTab("browser")}
>
Browser
</button>
</div>
</div>
<div className="mt-1 overflow-hidden shadow ring-1 ring-black ring-opacity-5 sm:rounded-lg">
{selectedDeviceTab === "os" ? (
<table className="min-w-full divide-y divide-gray-300">
<thead>
<tr>
<th scope="col" className="py-2 px-2 text-left text-sm font-semibold text-gray-500">
Operating system
</th>
<th scope="col" className="py-2 pr-2 text-right text-sm font-semibold text-gray-500">
Visitors
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200">
{analytics.deviceData.map((reference) => (
<tr key={reference.name}>
<td className="whitespace-nowrap py-2 px-2 text-sm text-gray-900">{reference.name || "Unknown"}</td>
<td className="whitespace-nowrap py-2 pr-2 text-sm text-gray-500 text-right">{reference.count}</td>
</tr>
))}
</tbody>
</table>
) : (
<table className="min-w-full divide-y divide-gray-300">
<thead>
<tr>
<th scope="col" className="py-2 px-2 text-left text-sm font-semibold text-gray-500">
Browsers
</th>
<th scope="col" className="py-2 pr-2 text-right text-sm font-semibold text-gray-500">
Visitors
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200">
{analytics.browserData.map((reference) => (
<tr key={reference.name}>
<td className="whitespace-nowrap py-2 px-2 text-sm text-gray-900">{reference.name || "Unknown"}</td>
<td className="whitespace-nowrap py-2 pr-2 text-sm text-gray-500 text-right">{reference.count}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</>
) : null}
</div>
</ModalDialog>
</Modal>
);
};
export default AnalyticsDialog;

View File

@ -2,7 +2,7 @@ import { Button, Input, Modal, ModalDialog } from "@mui/joy";
import { useState } from "react";
import { toast } from "react-hot-toast";
import useLoading from "../hooks/useLoading";
import { userService } from "../services";
import useUserStore from "../stores/v1/user";
import Icon from "./Icon";
interface Props {
@ -11,6 +11,7 @@ interface Props {
const ChangePasswordDialog: React.FC<Props> = (props: Props) => {
const { onClose } = props;
const userStore = useUserStore();
const [newPassword, setNewPassword] = useState("");
const [newPasswordAgain, setNewPasswordAgain] = useState("");
const requestState = useLoading(false);
@ -43,9 +44,8 @@ const ChangePasswordDialog: React.FC<Props> = (props: Props) => {
requestState.setLoading();
try {
const user = userService.getState().user as User;
await userService.patchUser({
id: user.id,
userStore.patchUser({
id: userStore.getCurrentUser().id,
password: newPassword,
});
onClose();

View File

@ -1,10 +1,10 @@
import { Button, Input, Modal, ModalDialog, Radio, RadioGroup } from "@mui/joy";
import { isUndefined } from "lodash-es";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "react-hot-toast";
import { shortcutService } from "../services";
import useLoading from "../hooks/useLoading";
import { showCommonDialog } from "./Alert";
import Icon from "./Icon";
interface Props {
@ -33,7 +33,7 @@ const CreateShortcutDialog: React.FC<Props> = (props: Props) => {
});
const [tag, setTag] = useState<string>("");
const requestState = useLoading(false);
const isEditing = !!shortcutId;
const isCreating = isUndefined(shortcutId);
useEffect(() => {
if (shortcutId) {
@ -97,22 +97,6 @@ const CreateShortcutDialog: React.FC<Props> = (props: Props) => {
});
};
const handleDeleteShortcutButtonClick = () => {
if (!shortcutId) {
return;
}
showCommonDialog({
title: "Delete Shortcut",
content: `Are you sure to delete shortcut \`${state.shortcutCreate.name}\`? You can not undo this action.`,
style: "danger",
onConfirm: async () => {
await shortcutService.deleteShortcutById(shortcutId);
onClose();
},
});
};
const handleSaveBtnClick = async () => {
if (!state.shortcutCreate.name) {
toast.error("Name is required");
@ -151,7 +135,7 @@ const CreateShortcutDialog: React.FC<Props> = (props: Props) => {
<Modal open={true}>
<ModalDialog>
<div className="flex flex-row justify-between items-center w-80 sm:w-96 mb-4">
<span className="text-lg font-medium">{isEditing ? "Edit Shortcut" : "Create Shortcut"}</span>
<span className="text-lg font-medium">{isCreating ? "Create Shortcut" : "Edit Shortcut"}</span>
<Button variant="plain" onClick={onClose}>
<Icon.X className="w-5 h-auto text-gray-600" />
</Button>
@ -208,16 +192,11 @@ const CreateShortcutDialog: React.FC<Props> = (props: Props) => {
))}
</RadioGroup>
</div>
<p className="mt-3 text-sm text-gray-500 w-full bg-gray-100 border border-gray-200 px-2 py-1 rounded-md">
{t(`shortcut.visibility.${state.shortcutCreate.visibility.toLowerCase()}.description`)}
</p>
</div>
<div className="w-full flex flex-row justify-between items-center mt-8 space-x-2">
<div>
{isEditing && (
<Button color="danger" variant="plain" onClick={handleDeleteShortcutButtonClick}>
Delete
</Button>
)}
</div>
<div className="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}>
Cancel
</Button>
@ -226,7 +205,6 @@ const CreateShortcutDialog: React.FC<Props> = (props: Props) => {
</Button>
</div>
</div>
</div>
</ModalDialog>
</Modal>
);

View File

@ -0,0 +1,200 @@
import { Button, Input, Modal, ModalDialog, Radio, RadioGroup } from "@mui/joy";
import { isUndefined } from "lodash-es";
import { useEffect, useState } from "react";
import { toast } from "react-hot-toast";
import useLoading from "../hooks/useLoading";
import useUserStore from "../stores/v1/user";
import Icon from "./Icon";
interface Props {
user?: User;
onClose: () => void;
onConfirm?: () => void;
}
interface State {
userCreate: UserCreate;
}
const roles: Role[] = ["USER", "ADMIN"];
const CreateUserDialog: React.FC<Props> = (props: Props) => {
const { onClose, onConfirm, user } = props;
const userStore = useUserStore();
const [state, setState] = useState<State>({
userCreate: {
email: "",
nickname: "",
password: "",
role: "USER",
},
});
const requestState = useLoading(false);
const isCreating = isUndefined(user);
useEffect(() => {
if (user) {
setState({
...state,
userCreate: Object.assign(state.userCreate, {
email: user.email,
nickname: user.nickname,
password: "",
role: user.role,
}),
});
}
}, [user]);
const setPartialState = (partialState: Partial<State>) => {
setState({
...state,
...partialState,
});
};
const handleEmailInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setPartialState({
userCreate: Object.assign(state.userCreate, {
email: e.target.value.toLowerCase(),
}),
});
};
const handleNicknameInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setPartialState({
userCreate: Object.assign(state.userCreate, {
nickname: e.target.value,
}),
});
};
const handlePasswordInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setPartialState({
userCreate: Object.assign(state.userCreate, {
password: e.target.value,
}),
});
};
const handleRoleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setPartialState({
userCreate: Object.assign(state.userCreate, {
role: e.target.value,
}),
});
};
const handleSaveBtnClick = async () => {
if (isCreating && (!state.userCreate.email || !state.userCreate.nickname || !state.userCreate.password)) {
toast.error("Please fill all inputs");
return;
}
try {
if (user) {
const userPatch: UserPatch = {
id: user.id,
};
if (user.email !== state.userCreate.email) {
userPatch.email = state.userCreate.email;
}
if (user.nickname !== state.userCreate.nickname) {
userPatch.nickname = state.userCreate.nickname;
}
if (user.role !== state.userCreate.role) {
userPatch.role = state.userCreate.role;
}
await userStore.patchUser(userPatch);
} else {
await userStore.createUser(state.userCreate);
}
if (onConfirm) {
onConfirm();
} else {
onClose();
}
} catch (error: any) {
console.error(error);
toast.error(error.response.data.message);
}
};
return (
<Modal open={true}>
<ModalDialog>
<div className="flex flex-row justify-between items-center w-80 sm:w-96 mb-4">
<span className="text-lg font-medium">{isCreating ? "Create User" : "Edit User"}</span>
<Button variant="plain" onClick={onClose}>
<Icon.X className="w-5 h-auto text-gray-600" />
</Button>
</div>
<div>
<div className="w-full flex flex-col justify-start items-start mb-3">
<span className="mb-2">
Email <span className="text-red-600">*</span>
</span>
<div className="relative w-full">
<Input
className="w-full"
type="email"
placeholder="Unique user email"
value={state.userCreate.email}
onChange={handleEmailInputChange}
/>
</div>
</div>
<div className="w-full flex flex-col justify-start items-start mb-3">
<span className="mb-2">
Nickname <span className="text-red-600">*</span>
</span>
<Input
className="w-full"
type="text"
placeholder="Nickname"
value={state.userCreate.nickname}
onChange={handleNicknameInputChange}
/>
</div>
{isCreating && (
<div className="w-full flex flex-col justify-start items-start mb-3">
<span className="mb-2">
Password <span className="text-red-600">*</span>
</span>
<Input
className="w-full"
type="password"
placeholder=""
value={state.userCreate.password}
onChange={handlePasswordInputChange}
/>
</div>
)}
<div className="w-full flex flex-col justify-start items-start mb-3">
<span className="mb-2">
Role <span className="text-red-600">*</span>
</span>
<div className="w-full flex flex-row justify-start items-center text-base">
<RadioGroup orientation="horizontal" value={state.userCreate.role} onChange={handleRoleInputChange}>
{roles.map((role) => (
<Radio key={role} value={role} label={role} />
))}
</RadioGroup>
</div>
</div>
<div className="w-full flex flex-row justify-end items-center mt-4 space-x-2">
<Button color="neutral" variant="plain" disabled={requestState.isLoading} loading={requestState.isLoading} onClick={onClose}>
Cancel
</Button>
<Button color="primary" disabled={requestState.isLoading} loading={requestState.isLoading} onClick={handleSaveBtnClick}>
Save
</Button>
</div>
</div>
</ModalDialog>
</Modal>
);
};
export default CreateUserDialog;

View File

@ -0,0 +1,31 @@
import { globalService } from "../services";
import Icon from "./Icon";
const DemoBanner: React.FC = () => {
const {
workspaceProfile: {
profile: { mode },
},
} = globalService.getState();
const shouldShow = mode === "demo";
if (!shouldShow) return null;
return (
<div className="z-10 flex flex-row items-center justify-center w-full py-2 text-sm sm:text-lg font-medium dark:text-gray-300 bg-white dark:bg-zinc-700 shadow">
<div className="w-full max-w-4xl px-4 flex flex-row justify-between items-center gap-x-3">
<span>A bookmarking and url shortener, save and share your links very easily.</span>
<a
className="shadow flex flex-row justify-center items-center px-2 py-1 rounded-md text-sm sm:text-base text-white bg-blue-600 hover:bg-blue-700"
href="https://github.com/boojack/slash#deploy-with-docker-in-seconds"
target="_blank"
>
Install
<Icon.ExternalLink className="w-4 h-auto ml-1" />
</a>
</div>
</div>
);
};
export default DemoBanner;

View File

@ -2,8 +2,7 @@ import { Button, Input, Modal, ModalDialog } from "@mui/joy";
import { useState } from "react";
import { toast } from "react-hot-toast";
import useLoading from "../hooks/useLoading";
import { userService } from "../services";
import { useAppSelector } from "../stores";
import useUserStore from "../stores/v1/user";
import Icon from "./Icon";
interface Props {
@ -12,9 +11,10 @@ interface Props {
const EditUserinfoDialog: React.FC<Props> = (props: Props) => {
const { onClose } = props;
const user = useAppSelector((state) => state.user.user as User);
const [email, setEmail] = useState(user.email);
const [nickname, setNickname] = useState(user.nickname);
const userStore = useUserStore();
const currentUser = userStore.getCurrentUser();
const [email, setEmail] = useState(currentUser.email);
const [nickname, setNickname] = useState(currentUser.nickname);
const requestState = useLoading(false);
const handleCloseBtnClick = () => {
@ -39,14 +39,13 @@ const EditUserinfoDialog: React.FC<Props> = (props: Props) => {
requestState.setLoading();
try {
const user = userService.getState().user as User;
await userService.patchUser({
id: user.id,
await userStore.patchUser({
id: currentUser.id,
email,
nickname,
});
onClose();
toast("Password changed");
toast("User information updated");
} catch (error: any) {
console.error(error);
toast.error(error.response.data.message);

View File

@ -0,0 +1,42 @@
import { useTranslation } from "react-i18next";
import useViewStore from "../stores/v1/view";
import Icon from "./Icon";
import VisibilityIcon from "./VisibilityIcon";
const FilterView = () => {
const { t } = useTranslation();
const viewStore = useViewStore();
const filter = viewStore.filter;
const shouldShowFilters = filter.tag !== undefined || filter.visibility !== undefined;
if (!shouldShowFilters) {
return <></>;
}
return (
<div className="w-full flex flex-row justify-start items-center mb-4 pl-2">
<span className="text-gray-400">Filters:</span>
{filter.tag && (
<button
className="ml-2 px-2 py-1 flex flex-row justify-center items-center bg-gray-100 rounded-full text-gray-500 text-sm hover:line-through"
onClick={() => viewStore.setFilter({ tag: undefined })}
>
<Icon.Tag className="w-4 h-auto mr-1" />#{filter.tag}
<Icon.X className="w-4 h-auto ml-1" />
</button>
)}
{filter.visibility && (
<button
className="ml-2 px-2 py-1 flex flex-row justify-center items-center bg-gray-100 rounded-full text-gray-500 text-sm hover:line-through"
onClick={() => viewStore.setFilter({ visibility: undefined })}
>
<VisibilityIcon className="w-4 h-auto mr-1" visibility={filter.visibility} />
{t(`shortcut.visibility.${filter.visibility.toLowerCase()}.self`)}
<Icon.X className="w-4 h-auto ml-1" />
</button>
)}
</div>
);
};
export default FilterView;

View File

@ -0,0 +1,61 @@
import { Button, Modal, ModalDialog } from "@mui/joy";
import { useRef } from "react";
import { toast } from "react-hot-toast";
import { QRCodeCanvas } from "qrcode.react";
import { absolutifyLink } from "../helpers/utils";
import Icon from "./Icon";
interface Props {
shortcut: Shortcut;
onClose: () => void;
}
const GenerateQRCodeDialog: React.FC<Props> = (props: Props) => {
const { shortcut, onClose } = props;
const containerRef = useRef<HTMLDivElement | null>(null);
const shortcutLink = absolutifyLink(`/s/${shortcut.name}`);
const handleCloseBtnClick = () => {
onClose();
};
const handleDownloadQRCodeClick = () => {
const canvas = containerRef.current?.querySelector("canvas");
if (!canvas) {
toast.error("Failed to get qr code canvas");
return;
}
const link = document.createElement("a");
link.download = "filename.png";
link.href = canvas.toDataURL();
link.click();
handleCloseBtnClick();
};
return (
<Modal open={true}>
<ModalDialog>
<div className="flex flex-row justify-between items-center w-64 mb-4">
<span className="text-lg font-medium">QR Code</span>
<Button variant="plain" onClick={handleCloseBtnClick}>
<Icon.X className="w-5 h-auto text-gray-600" />
</Button>
</div>
<div>
<div ref={containerRef} className="w-full flex flex-row justify-center items-center mt-2 mb-6">
<QRCodeCanvas value={shortcutLink} size={128} bgColor={"#ffffff"} fgColor={"#000000"} includeMargin={false} level={"L"} />
</div>
<div className="w-full flex flex-row justify-center items-center px-4">
<Button className="w-full" color="neutral" onClick={handleDownloadQRCodeClick}>
<Icon.Download className="w-4 h-auto mr-1" />
Download
</Button>
</div>
</div>
</ModalDialog>
</Modal>
);
};
export default GenerateQRCodeDialog;

View File

@ -1,28 +1,29 @@
import { Avatar } from "@mui/joy";
import { useState } from "react";
import { Link, useNavigate } from "react-router-dom";
import { useAppSelector } from "../stores";
import { Link } from "react-router-dom";
import * as api from "../helpers/api";
import useUserStore from "../stores/v1/user";
import Icon from "./Icon";
import Dropdown from "./common/Dropdown";
import AboutDialog from "./AboutDialog";
const Header: React.FC = () => {
const navigate = useNavigate();
const user = useAppSelector((state) => state.user).user as User;
const currentUser = useUserStore().getCurrentUser();
const [showAboutDialog, setShowAboutDialog] = useState<boolean>(false);
const handleSignOutButtonClick = async () => {
navigate("/auth");
await api.signout();
window.location.href = "/auth";
};
return (
<>
<div className="w-full bg-amber-50">
<div className="w-full bg-gray-50 border-b border-b-gray-200">
<div className="w-full max-w-4xl mx-auto px-3 py-5 flex flex-row justify-between items-center">
<div className="flex flex-row justify-start items-center shrink mr-2">
<Link to="/" className="text-base font-mono font-medium cursor-pointer flex flex-row justify-start items-center">
<img src="/logo.png" className="w-8 h-auto mr-2" alt="" />
Shortify
Slash
</Link>
</div>
<div className="relative flex-shrink-0">
@ -30,7 +31,7 @@ const Header: React.FC = () => {
trigger={
<button className="flex flex-row justify-end items-center cursor-pointer">
<Avatar size="sm" variant="plain" />
<span>{user.nickname}</span>
<span>{currentUser.nickname}</span>
<Icon.ChevronDown className="ml-2 w-5 h-auto text-gray-600" />
</button>
}

View File

@ -18,6 +18,8 @@ const ShortcutListView: React.FC<Props> = (props: Props) => {
})}
</div>
<p className="w-full text-center text-gray-400 text-sm mt-2 mb-4 italic">Total {shortcutList.length} data</p>
{editingShortcutId && (
<CreateShortcutDialog
shortcutId={editingShortcutId}

View File

@ -4,13 +4,16 @@ import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import toast from "react-hot-toast";
import { shortcutService } from "../services";
import { useAppSelector } from "../stores";
import useFaviconStore from "../stores/v1/favicon";
import useViewStore from "../stores/v1/view";
import useUserStore from "../stores/v1/user";
import { absolutifyLink } from "../helpers/utils";
import { showCommonDialog } from "./Alert";
import Icon from "./Icon";
import Dropdown from "./common/Dropdown";
import VisibilityIcon from "./VisibilityIcon";
import GenerateQRCodeDialog from "./GenerateQRCodeDialog";
import AnalyticsDialog from "./AnalyticsDialog";
interface Props {
shortcut: Shortcut;
@ -20,11 +23,14 @@ interface Props {
const ShortcutView = (props: Props) => {
const { shortcut, handleEdit } = props;
const { t } = useTranslation();
const user = useAppSelector((state) => state.user.user as User);
const currentUser = useUserStore().getCurrentUser();
const viewStore = useViewStore();
const faviconStore = useFaviconStore();
const [favicon, setFavicon] = useState<string | undefined>(undefined);
const havePermission = user.role === "ADMIN" || shortcut.creatorId === user.id;
const shortifyLink = absolutifyLink(`/s/${shortcut.name}`);
const [showQRCodeDialog, setShowQRCodeDialog] = useState<boolean>(false);
const [showAnalyticsDialog, setShowAnalyticsDialog] = useState<boolean>(false);
const havePermission = currentUser.role === "ADMIN" || shortcut.creatorId === currentUser.id;
const shortcutLink = absolutifyLink(`/s/${shortcut.name}`);
useEffect(() => {
faviconStore.getOrFetchUrlFavicon(shortcut.link).then((url) => {
@ -35,14 +41,14 @@ const ShortcutView = (props: Props) => {
}, [shortcut.link]);
const handleCopyButtonClick = () => {
copy(shortifyLink);
copy(shortcutLink);
toast.success("Shortcut link copied to clipboard.");
};
const handleDeleteShortcutButtonClick = (shortcut: Shortcut) => {
showCommonDialog({
title: "Delete Shortcut",
content: `Are you sure to delete shortcut \`${shortcut.name}\`? You can not undo this action.`,
content: `Are you sure to delete shortcut \`${shortcut.name}\`? You cannot undo this action.`,
style: "danger",
onConfirm: async () => {
await shortcutService.deleteShortcutById(shortcut.id);
@ -51,28 +57,49 @@ const ShortcutView = (props: Props) => {
};
return (
<>
<div className="w-full flex flex-col justify-start items-start border px-4 py-3 mb-2 rounded-lg hover:shadow">
<div className="w-full flex flex-row justify-between items-center">
<div className="group flex flex-row justify-start items-center mr-1 shrink-0">
<div className="w-6 h-6 mr-1.5 flex justify-center items-center overflow-clip">
<div className="group flex flex-row justify-start items-center pr-2 mr-1 shrink-0">
<div className="w-6 h-6 mr-1 flex justify-center items-center overflow-clip">
{favicon ? (
<img className="w-[90%] h-auto rounded-full" src={favicon} decoding="async" loading="lazy" />
<img className="w-full h-auto rounded-full" src={favicon} decoding="async" loading="lazy" />
) : (
<Icon.Globe2 className="w-5 h-auto text-gray-500" />
<Icon.CircleSlash className="w-6 h-auto text-gray-400" />
)}
</div>
<button className="items-center cursor-pointer hover:opacity-80" onClick={() => handleCopyButtonClick()}>
<a
className="flex flex-row px-1 mr-1 justify-start items-center cursor-pointer rounded-md hover:bg-gray-100 hover:shadow"
target="_blank"
href={shortcutLink}
>
<span className="text-gray-400">s/</span>
{shortcut.name}
</button>
<a className="hidden group-hover:block ml-1 cursor-pointer hover:opacity-80" target="_blank" href={shortifyLink}>
<Icon.ExternalLink className="w-4 h-auto text-gray-500" />
<span className="hidden group-hover:block ml-1 cursor-pointer">
<Icon.ExternalLink className="w-4 h-auto text-gray-600" />
</span>
</a>
<Tooltip title="Copy" variant="solid" placement="top" arrow>
<button
className="hidden group-hover:block w-6 h-6 cursor-pointer rounded-full text-gray-500 hover:bg-gray-100 hover:shadow hover:text-blue-600"
onClick={() => handleCopyButtonClick()}
>
<Icon.Clipboard className="w-4 h-auto mx-auto" />
</button>
</Tooltip>
<Tooltip title="QR Code" variant="solid" placement="top" arrow>
<button
className="hidden group-hover:block ml-1 w-6 h-6 cursor-pointer rounded-full text-gray-500 hover:bg-gray-100 hover:shadow hover:text-blue-600"
onClick={() => setShowQRCodeDialog(true)}
>
<Icon.QrCode className="w-4 h-auto mx-auto" />
</button>
</Tooltip>
</div>
<div className="flex flex-row justify-end items-center space-x-2">
{havePermission && (
<Dropdown
actionsClassName="!w-24"
actionsClassName="!w-32"
actions={
<>
<button
@ -81,6 +108,12 @@ const ShortcutView = (props: Props) => {
>
<Icon.Edit className="w-4 h-auto mr-2" /> Edit
</button>
<button
className="w-full px-2 flex flex-row justify-start items-center text-left leading-8 cursor-pointer rounded hover:bg-gray-100 disabled:cursor-not-allowed disabled:bg-gray-100 disabled:opacity-60"
onClick={() => setShowAnalyticsDialog(true)}
>
<Icon.BarChart2 className="w-4 h-auto mr-2" /> Analytics
</button>
<button
className="w-full px-2 flex flex-row justify-start items-center text-left leading-8 cursor-pointer rounded text-red-600 hover:bg-gray-100 disabled:cursor-not-allowed disabled:bg-gray-100 disabled:opacity-60"
onClick={() => {
@ -97,11 +130,15 @@ const ShortcutView = (props: Props) => {
</div>
{shortcut.description && <p className="mt-1 text-gray-400 text-sm">{shortcut.description}</p>}
{shortcut.tags.length > 0 && (
<div className="mt-1 flex flex-row justify-start items-start gap-2">
<div className="mt-2 ml-1 flex flex-row justify-start items-start gap-2">
<Icon.Tag className="text-gray-400 w-4 h-auto" />
{shortcut.tags.map((tag) => {
return (
<span key={tag} className="text-gray-400 text-sm font-mono leading-4">
<span
key={tag}
className="text-gray-400 text-sm font-mono leading-4 cursor-pointer hover:text-gray-600"
onClick={() => viewStore.setFilter({ tag: tag })}
>
#{tag}
</span>
);
@ -116,19 +153,30 @@ const ShortcutView = (props: Props) => {
</div>
</Tooltip>
<Tooltip title={t(`shortcut.visibility.${shortcut.visibility.toLowerCase()}.description`)} variant="solid" placement="top" arrow>
<div className="w-auto px-2 leading-6 flex flex-row justify-start items-center border rounded-full text-gray-500 text-sm">
<div
className="w-auto px-2 leading-6 flex flex-row justify-start items-center border rounded-full cursor-pointer text-gray-500 text-sm"
onClick={() => viewStore.setFilter({ visibility: shortcut.visibility })}
>
<VisibilityIcon className="w-4 h-auto mr-1" visibility={shortcut.visibility} />
{t(`shortcut.visibility.${shortcut.visibility.toLowerCase()}.self`)}
</div>
</Tooltip>
<Tooltip title="View count" variant="solid" placement="top" arrow>
<div className="w-auto px-2 leading-6 flex flex-row justify-start items-center border rounded-full text-gray-500 text-sm">
<Icon.Eye className="w-4 h-auto mr-1" />
<div
className="w-auto px-2 leading-6 flex flex-row justify-start items-center border rounded-full cursor-pointer text-gray-500 text-sm"
onClick={() => setShowAnalyticsDialog(true)}
>
<Icon.BarChart2 className="w-4 h-auto mr-1" />
{shortcut.view} visits
</div>
</Tooltip>
</div>
</div>
{showQRCodeDialog && <GenerateQRCodeDialog shortcut={shortcut} onClose={() => setShowQRCodeDialog(false)} />}
{showAnalyticsDialog && <AnalyticsDialog shortcutId={shortcut.id} onClose={() => setShowAnalyticsDialog(false)} />}
</>
);
};

View File

@ -1,26 +1,26 @@
import { Button } from "@mui/joy";
import { useState } from "react";
import { useAppSelector } from "../../stores";
import useUserStore from "../../stores/v1/user";
import ChangePasswordDialog from "../ChangePasswordDialog";
import EditUserinfoDialog from "../EditUserinfoDialog";
const AccountSection: React.FC = () => {
const user = useAppSelector((state) => state.user).user as User;
const currentUser = useUserStore().getCurrentUser();
const [showEditUserinfoDialog, setShowEditUserinfoDialog] = useState<boolean>(false);
const [showChangePasswordDialog, setShowChangePasswordDialog] = useState<boolean>(false);
const isAdmin = user.role === "ADMIN";
const isAdmin = currentUser.role === "ADMIN";
return (
<>
<div className="mx-auto max-w-4xl w-full px-3 py-6 flex flex-col justify-start items-start gap-y-2">
<p className="text-gray-400">Account</p>
<p className="text-base font-semibold leading-6 text-gray-900">Account</p>
<p className="flex flex-row justify-start items-center mt-2">
<span className="text-xl font-medium">{user.nickname}</span>
<span className="text-xl">{currentUser.nickname}</span>
{isAdmin && <span className="ml-2 bg-blue-600 text-white px-2 leading-6 text-sm rounded-full">Admin</span>}
</p>
<p className="flex flex-row justify-start items-center">
<span className="mr-3 text-gray-500 font-mono">Email: </span>
{user.email}
{currentUser.email}
</p>
<div className="flex flex-row justify-start items-center gap-2 mt-2">
<Button variant="outlined" color="neutral" onClick={() => setShowEditUserinfoDialog(true)}>

View File

@ -0,0 +1,95 @@
import { useEffect, useState } from "react";
import { Button } from "@mui/joy";
import CreateUserDialog from "../CreateUserDialog";
import useUserStore from "../../stores/v1/user";
const MemberSection = () => {
const userStore = useUserStore();
const [showCreateUserDialog, setShowCreateUserDialog] = useState<boolean>(false);
const [currentEditingUser, setCurrentEditingUser] = useState<User | undefined>(undefined);
const userList = Object.values(userStore.userMap);
useEffect(() => {
userStore.fetchUserList();
}, []);
const handleCreateUserDialogClose = () => {
setShowCreateUserDialog(false);
setCurrentEditingUser(undefined);
};
return (
<>
<div className="mx-auto max-w-4xl w-full px-3 py-6 flex flex-col justify-start items-start space-y-4">
<div className="w-full">
<div className="sm:flex sm:items-center">
<div className="sm:flex-auto">
<p className="text-base font-semibold leading-6 text-gray-900">Users</p>
<p className="mt-2 text-sm text-gray-700">
A list of all the users in your workspace including their nickname, email and role.
</p>
</div>
<div className="mt-4 sm:ml-16 sm:mt-0 sm:flex-none">
<Button
onClick={() => {
setShowCreateUserDialog(true);
setCurrentEditingUser(undefined);
}}
>
Add user
</Button>
</div>
</div>
<div className="mt-2 flow-root">
<div className="overflow-x-auto">
<div className="inline-block min-w-full py-2 align-middle">
<table className="min-w-full divide-y divide-gray-300">
<thead>
<tr>
<th scope="col" className="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-gray-900">
Nickname
</th>
<th scope="col" className="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">
Email
</th>
<th scope="col" className="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">
Role
</th>
<th scope="col" className="relative py-3.5 pl-3 pr-4">
<span className="sr-only">Edit</span>
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200">
{userList.map((user) => (
<tr key={user.email}>
<td className="whitespace-nowrap py-4 pl-4 pr-3 text-sm text-gray-900">{user.nickname}</td>
<td className="whitespace-nowrap px-3 py-4 text-sm text-gray-500">{user.email}</td>
<td className="whitespace-nowrap px-3 py-4 text-sm text-gray-500">{user.role}</td>
<td className="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm font-medium">
<button
className="text-indigo-600 hover:text-indigo-900"
onClick={() => {
setCurrentEditingUser(user);
setShowCreateUserDialog(true);
}}
>
Edit
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
{showCreateUserDialog && <CreateUserDialog user={currentEditingUser} onClose={handleCreateUserDialogClose} />}
</>
);
};
export default MemberSection;

View File

@ -17,20 +17,17 @@ const WorkspaceSection: React.FC = () => {
};
return (
<>
<div className="mx-auto max-w-4xl w-full px-3 py-6 flex flex-col justify-start items-start space-y-4">
<p className="text-gray-400">Workspace settings</p>
<p className="text-base font-semibold leading-6 text-gray-900">Workspace settings</p>
<div className="w-full flex flex-col justify-start items-start">
<Checkbox
className="font-medium"
label="Disable self-service signup"
label="Disable user signup"
checked={disallowSignUp}
onChange={(event) => handleDisallowSignUpChange(event.target.checked)}
/>
<p className="mt-2 text-gray-500">Once disabled, other users cannot signup.</p>
</div>
</div>
</>
);
};

View File

@ -35,6 +35,10 @@ export function getUserById(id: number) {
return axios.get<User>(`/api/v1/user/${id}`);
}
export function createUser(userCreate: UserCreate) {
return axios.post<User>("/api/v1/user", userCreate);
}
export function patchUser(userPatch: UserPatch) {
return axios.patch<User>(`/api/v1/user/${userPatch.id}`, userPatch);
}
@ -45,9 +49,6 @@ export function deleteUser(userDelete: UserDelete) {
export function getShortcutList(shortcutFind?: ShortcutFind) {
const queryList = [];
if (shortcutFind?.creatorId) {
queryList.push(`creatorId=${shortcutFind.creatorId}`);
}
if (shortcutFind?.tag) {
queryList.push(`tag=${shortcutFind.tag}`);
}
@ -58,6 +59,10 @@ export function createShortcut(shortcutCreate: ShortcutCreate) {
return axios.post<Shortcut>("/api/v1/shortcut", shortcutCreate);
}
export function getShortcutAnalytics(shortcutId: ShortcutId) {
return axios.get<AnalysisData>(`/api/v1/shortcut/${shortcutId}/analytics`);
}
export function patchShortcut(shortcutPatch: ShortcutPatch) {
return axios.patch<Shortcut>(`/api/v1/shortcut/${shortcutPatch.id}`, shortcutPatch);
}

View File

@ -1,12 +1,31 @@
import { Outlet } from "react-router-dom";
import { useEffect } from "react";
import { Outlet, useNavigate } from "react-router-dom";
import useUserStore from "../stores/v1/user";
import Header from "../components/Header";
import DemoBanner from "../components/DemoBanner";
const Root: React.FC = () => {
const navigate = useNavigate();
const currentUser = useUserStore().getCurrentUser();
useEffect(() => {
if (!currentUser) {
navigate("/auth", {
replace: true,
});
}
}, []);
return (
<>
{currentUser && (
<div className="w-full h-full flex flex-col justify-start items-start">
<DemoBanner />
<Header />
<Outlet />
</div>
)}
</>
);
};

View File

@ -3,15 +3,15 @@
"visibility": {
"private": {
"self": "Private",
"description": "Only you can see this"
"description": "Only you can access"
},
"workspace": {
"self": "Workspace",
"description": "Only people in your workspace can see this"
"description": "Workspace members can access"
},
"public": {
"self": "Public",
"description": "Anyone can see this"
"description": "Available to Everyone on the Internet"
}
}
}

View File

@ -1,14 +1,21 @@
import { CssVarsProvider } from "@mui/joy";
import { createRoot } from "react-dom/client";
import { Toaster } from "react-hot-toast";
import { Provider } from "react-redux";
import { RouterProvider } from "react-router-dom";
import store from "./stores";
import App from "./App";
import router from "./routers";
import "./i18n";
import "./css/index.css";
const container = document.getElementById("root");
const root = createRoot(container as HTMLElement);
root.render(
<Provider store={store}>
<App />
<CssVarsProvider>
<RouterProvider router={router} />
<Toaster position="top-center" />
</CssVarsProvider>
</Provider>
);

View File

@ -1,21 +1,21 @@
import { Button } from "@mui/joy";
import { useState } from "react";
import { useAppSelector } from "../stores";
import useUserStore from "../stores/v1/user";
import ChangePasswordDialog from "../components/ChangePasswordDialog";
import EditUserinfoDialog from "../components/EditUserinfoDialog";
const Account: React.FC = () => {
const user = useAppSelector((state) => state.user).user as User;
const currentUser = useUserStore().getCurrentUser();
const [showEditUserinfoDialog, setShowEditUserinfoDialog] = useState<boolean>(false);
const [showChangePasswordDialog, setShowChangePasswordDialog] = useState<boolean>(false);
return (
<>
<div className="mx-auto max-w-4xl w-full px-3 py-6 flex flex-col justify-start items-start space-y-4">
<p className="text-3xl my-2">{user.nickname}</p>
<p className="text-3xl my-2">{currentUser.nickname}</p>
<p className="leading-8 flex flex-row justify-start items-center">
<span className="mr-3 text-gray-500 font-mono">Email: </span>
{user.email}
{currentUser.email}
</p>
<div className="flex flex-row justify-start items-center gap-2">
<Button variant="outlined" color="neutral" onClick={() => setShowEditUserinfoDialog(true)}>

View File

@ -2,24 +2,51 @@ import { Button, Tab, TabList, Tabs } from "@mui/joy";
import { useEffect, useState } from "react";
import { shortcutService } from "../services";
import { useAppSelector } from "../stores";
import useViewStore, { Filter } from "../stores/v1/view";
import useUserStore from "../stores/v1/user";
import useLoading from "../hooks/useLoading";
import Icon from "../components/Icon";
import ShortcutListView from "../components/ShortcutListView";
import CreateShortcutDialog from "../components/CreateShortcutDialog";
import FilterView from "../components/FilterView";
interface State {
showCreateShortcutDialog: boolean;
}
const getFilteredShortcutList = (shortcutList: Shortcut[], filter: Filter, currentUser: User) => {
const { tag, mineOnly, visibility } = filter;
const filteredShortcutList = shortcutList.filter((shortcut) => {
if (tag) {
if (!shortcut.tags.includes(tag)) {
return false;
}
}
if (mineOnly) {
if (shortcut.creatorId !== currentUser.id) {
return false;
}
}
if (visibility) {
if (shortcut.visibility !== visibility) {
return false;
}
}
return true;
});
return filteredShortcutList;
};
const Home: React.FC = () => {
const loadingState = useLoading();
const currentUser = useUserStore().getCurrentUser();
const viewStore = useViewStore();
const { shortcutList } = useAppSelector((state) => state.shortcut);
const user = useAppSelector((state) => state.user).user as User;
const [state, setState] = useState<State>({
showCreateShortcutDialog: false,
});
const [selectedFilter, setSelectFilter] = useState<"ALL" | "PRIVATE">("ALL");
const filteredShortcutList = selectedFilter === "ALL" ? shortcutList : shortcutList.filter((shortcut) => shortcut.creatorId === user.id);
const filter = viewStore.filter;
const filteredShortcutList = getFilteredShortcutList(shortcutList, filter, currentUser);
useEffect(() => {
Promise.all([shortcutService.getMyAllShortcuts()]).finally(() => {
@ -42,7 +69,11 @@ const Home: React.FC = () => {
</div>
<div className="w-full flex flex-row justify-between items-center mb-4">
<div className="flex flex-row justify-start items-center">
<Tabs defaultValue={"ALL"} size="sm" onChange={(_, value) => setSelectFilter(value as any)}>
<Tabs
value={filter.mineOnly ? "PRIVATE" : "ALL"}
size="sm"
onChange={(_, value) => viewStore.setFilter({ mineOnly: value !== "ALL" })}
>
<TabList>
<Tab value={"ALL"}>All</Tab>
<Tab value={"PRIVATE"}>Mine</Tab>
@ -50,23 +81,23 @@ const Home: React.FC = () => {
</Tabs>
</div>
<div>
<Button variant="soft" size="sm" onClick={() => setShowCreateShortcutDialog(true)}>
<Button className="shadow" variant="soft" size="sm" onClick={() => setShowCreateShortcutDialog(true)}>
<Icon.Plus className="w-5 h-auto" /> New
</Button>
</div>
</div>
<FilterView />
{loadingState.isLoading ? (
<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>
) : filteredShortcutList.length === 0 ? (
<div className="py-4 w-full flex flex-col justify-center items-center">
<Icon.PackageOpen className="w-12 h-auto text-gray-400" />
<p className="mt-4 mb-2">No shortcuts found.</p>
<Button size="sm" onClick={() => setShowCreateShortcutDialog(true)}>
Create one
</Button>
<div className="py-16 w-full flex flex-col justify-center items-center">
<Icon.PackageOpen className="w-16 h-auto text-gray-400" />
<p className="mt-4">No shortcuts found.</p>
</div>
) : (
<ShortcutListView shortcutList={filteredShortcutList} />

View File

@ -1,15 +1,21 @@
import { useAppSelector } from "../stores";
import useUserStore from "../stores/v1/user";
import AccountSection from "../components/setting/AccountSection";
import WorkspaceSection from "../components/setting/WorkspaceSection";
import UserSection from "../components/setting/UserSection";
const Setting: React.FC = () => {
const user = useAppSelector((state) => state.user).user as User;
const isAdmin = user.role === "ADMIN";
const currentUser = useUserStore().getCurrentUser();
const isAdmin = currentUser.role === "ADMIN";
return (
<div className="mx-auto max-w-4xl w-full px-3 py-6 flex flex-col justify-start items-start space-y-4">
<AccountSection />
{isAdmin && <WorkspaceSection />}
{isAdmin && (
<>
<UserSection />
<WorkspaceSection />
</>
)}
</div>
);
};

View File

@ -3,14 +3,18 @@ import React, { useEffect, useState } from "react";
import { Link, useNavigate } from "react-router-dom";
import { toast } from "react-hot-toast";
import * as api from "../helpers/api";
import { userService } from "../services";
import { useAppSelector } from "../stores";
import useLoading from "../hooks/useLoading";
import useUserStore from "../stores/v1/user";
const SignIn: React.FC = () => {
const navigate = useNavigate();
const userStore = useUserStore();
const {
workspaceProfile: { disallowSignUp },
workspaceProfile: {
disallowSignUp,
profile: { mode },
},
} = useAppSelector((state) => state.global);
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
@ -18,7 +22,16 @@ const SignIn: React.FC = () => {
const allowConfirm = email.length > 0 && password.length > 0;
useEffect(() => {
userService.doSignOut();
if (userStore.getCurrentUser()) {
return navigate("/", {
replace: true,
});
}
if (mode === "demo") {
setEmail("slash@stevenlgtm.com");
setPassword("secret");
}
}, []);
const handleEmailInputChanged = (e: React.ChangeEvent<HTMLInputElement>) => {
@ -39,7 +52,7 @@ const SignIn: React.FC = () => {
try {
actionBtnLoadingState.setLoading();
await api.signin(email, password);
const user = await userService.doSignIn();
const user = await userStore.fetchCurrentUser();
if (user) {
navigate("/", {
replace: true,
@ -60,7 +73,7 @@ const SignIn: React.FC = () => {
<div className="w-full py-4 grow flex flex-col justify-center items-center">
<div className="flex flex-col justify-start items-center w-full gap-y-2 mb-4">
<img src="/logo.png" className="w-16 h-auto" alt="logo" />
<span className="text-2xl font-medium font-mono opacity-80">Shortify</span>
<span className="text-2xl font-medium font-mono opacity-80">Slash</span>
</div>
<form className="w-full" onSubmit={handleSigninBtnClick}>
<div className={`flex flex-col justify-start items-start w-full ${actionBtnLoadingState.isLoading ? "opacity-80" : ""}`}>
@ -70,7 +83,7 @@ const SignIn: React.FC = () => {
className="w-full py-3"
type="email"
value={email}
placeholder="steven@shortify.com"
placeholder="steven@slash.com"
onChange={handleEmailInputChanged}
/>
</div>

View File

@ -3,11 +3,16 @@ import React, { useEffect, useState } from "react";
import { Link, useNavigate } from "react-router-dom";
import { toast } from "react-hot-toast";
import * as api from "../helpers/api";
import { userService } from "../services";
import { globalService } from "../services";
import useLoading from "../hooks/useLoading";
import useUserStore from "../stores/v1/user";
const SignUp: React.FC = () => {
const navigate = useNavigate();
const userStore = useUserStore();
const {
workspaceProfile: { disallowSignUp },
} = globalService.getState();
const [email, setEmail] = useState("");
const [nickname, setNickname] = useState("");
const [password, setPassword] = useState("");
@ -15,7 +20,17 @@ const SignUp: React.FC = () => {
const allowConfirm = email.length > 0 && nickname.length > 0 && password.length > 0;
useEffect(() => {
userService.doSignOut();
if (userStore.getCurrentUser()) {
return navigate("/", {
replace: true,
});
}
if (disallowSignUp) {
return navigate("/auth", {
replace: true,
});
}
}, []);
const handleEmailInputChanged = (e: React.ChangeEvent<HTMLInputElement>) => {
@ -41,7 +56,7 @@ const SignUp: React.FC = () => {
try {
actionBtnLoadingState.setLoading();
await api.signup(email, nickname, password);
const user = await userService.doSignIn();
const user = await userStore.fetchCurrentUser();
if (user) {
navigate("/", {
replace: true,
@ -62,7 +77,7 @@ const SignUp: React.FC = () => {
<div className="w-full py-4 grow flex flex-col justify-center items-center">
<div className="flex flex-col justify-start items-center w-full gap-y-2 mb-4">
<img src="/logo.png" className="w-16 h-auto" alt="logo" />
<span className="text-2xl font-medium font-mono opacity-80">Shortify</span>
<span className="text-2xl font-medium font-mono opacity-80">Slash</span>
</div>
<p className="w-full text-center mb-4 text-2xl">Create your account</p>
<form className="w-full" onSubmit={handleSignupBtnClick}>
@ -73,7 +88,7 @@ const SignUp: React.FC = () => {
className="w-full py-3"
type="email"
value={email}
placeholder="steven@shortify.com"
placeholder="steven@slash.com"
onChange={handleEmailInputChanged}
/>
</div>

View File

@ -1,83 +1,37 @@
import { createBrowserRouter, redirect } from "react-router-dom";
import { isNullorUndefined } from "../helpers/utils";
import { globalService, userService } from "../services";
import { createBrowserRouter } from "react-router-dom";
import Root from "../layouts/Root";
import SignIn from "../pages/SignIn";
import SignUp from "../pages/SignUp";
import Home from "../pages/Home";
import Setting from "../pages/Setting";
import App from "../App";
const router = createBrowserRouter([
{
path: "/auth",
element: <SignIn />,
loader: async () => {
try {
await globalService.initialState();
} catch (error) {
// do nth
}
return null;
},
},
{
path: "/auth/signup",
element: <SignUp />,
loader: async () => {
try {
await globalService.initialState();
} catch (error) {
// do nth
}
const {
workspaceProfile: { disallowSignUp },
} = globalService.getState();
if (disallowSignUp) {
return redirect("/auth");
}
return null;
},
},
{
path: "/",
element: <App />,
children: [
{
path: "auth",
element: <SignIn />,
},
{
path: "auth/signup",
element: <SignUp />,
},
{
path: "",
element: <Root />,
children: [
{
path: "",
element: <Home />,
loader: async () => {
try {
await userService.initialState();
} catch (error) {
// do nth
}
const { user } = userService.getState();
if (isNullorUndefined(user)) {
return redirect("/auth");
}
return null;
},
},
{
path: "/setting",
element: <Setting />,
loader: async () => {
try {
await userService.initialState();
} catch (error) {
// do nth
}
const { user } = userService.getState();
if (isNullorUndefined(user)) {
return redirect("/auth");
}
return null;
},
],
},
],
},

View File

@ -1,7 +1,6 @@
import * as api from "../helpers/api";
import store from "../stores";
import { setGlobalState } from "../stores/modules/global";
import userService from "./userService";
const globalService = {
getState: () => {
@ -15,12 +14,6 @@ const globalService = {
} catch (error) {
// do nth
}
try {
await userService.initialState();
} catch (error) {
// do nth
}
},
};

View File

@ -1,5 +1,4 @@
import globalService from "./globalService";
import shortcutService from "./shortcutService";
import userService from "./userService";
export { globalService, shortcutService, userService };
export { globalService, shortcutService };

View File

@ -1,66 +0,0 @@
import * as api from "../helpers/api";
import store from "../stores";
import { setUser, patchUser } from "../stores/modules/user";
export const convertResponseModelUser = (user: User): User => {
return {
...user,
createdTs: user.createdTs * 1000,
updatedTs: user.updatedTs * 1000,
};
};
const userService = {
getState: () => {
return store.getState().user;
},
initialState: async () => {
try {
const user = (await api.getMyselfUser()).data;
if (user) {
store.dispatch(setUser(convertResponseModelUser(user)));
}
} catch (error) {
// do nth
}
},
doSignIn: async () => {
const user = (await api.getMyselfUser()).data;
if (user) {
store.dispatch(setUser(convertResponseModelUser(user)));
} else {
userService.doSignOut();
}
return user;
},
doSignOut: async () => {
store.dispatch(setUser());
await api.signout();
},
getUserById: async (userId: UserId) => {
const user = (await api.getUserById(userId)).data;
if (user) {
return convertResponseModelUser(user);
} else {
return undefined;
}
},
patchUser: async (userPatch: UserPatch): Promise<void> => {
const data = (await api.patchUser(userPatch)).data;
if (userPatch.id === store.getState().user.user?.id) {
const user = convertResponseModelUser(data);
store.dispatch(patchUser(user));
}
},
deleteUser: async (userDelete: UserDelete) => {
await api.deleteUser(userDelete);
},
};
export default userService;

View File

@ -1,13 +1,11 @@
import { configureStore } from "@reduxjs/toolkit";
import { TypedUseSelectorHook, useSelector } from "react-redux";
import globalReducer from "./modules/global";
import userReducer from "./modules/user";
import shortcutReducer from "./modules/shortcut";
const store = configureStore({
reducer: {
global: globalReducer,
user: userReducer,
shortcut: shortcutReducer,
},
});

View File

@ -1,31 +0,0 @@
import { createSlice, PayloadAction } from "@reduxjs/toolkit";
interface State {
user?: User;
}
const userSlice = createSlice({
name: "user",
initialState: {} as State,
reducers: {
setUser: (state, action: PayloadAction<User | undefined>) => {
return {
...state,
user: action.payload,
};
},
patchUser: (state, action: PayloadAction<Partial<User>>) => {
return {
...state,
user: {
...state.user,
...action.payload,
} as User,
};
},
},
});
export const { setUser, patchUser } = userSlice.actions;
export default userSlice.reducer;

83
web/src/stores/v1/user.ts Normal file
View File

@ -0,0 +1,83 @@
import { create } from "zustand";
import * as api from "../../helpers/api";
const convertResponseModelUser = (user: User): User => {
return {
...user,
createdTs: user.createdTs * 1000,
updatedTs: user.updatedTs * 1000,
};
};
interface UserState {
userMap: {
[key: UserId]: User;
};
currentUserId?: UserId;
fetchUserList: () => Promise<User[]>;
fetchCurrentUser: () => Promise<User>;
getOrFetchUserById: (id: UserId) => Promise<User>;
getUserById: (id: UserId) => User;
getCurrentUser: () => User;
createUser: (userCreate: UserCreate) => Promise<User>;
patchUser: (userPatch: UserPatch) => Promise<void>;
}
const useUserStore = create<UserState>()((set, get) => ({
userMap: {},
fetchUserList: async () => {
const { data: userList } = await api.getUserList();
const userMap = get().userMap;
userList.forEach((user) => {
userMap[user.id] = convertResponseModelUser(user);
});
set(userMap);
return userList;
},
fetchCurrentUser: async () => {
const { data } = await api.getMyselfUser();
const user = convertResponseModelUser(data);
const userMap = get().userMap;
userMap[user.id] = user;
set({ userMap, currentUserId: user.id });
return user;
},
getOrFetchUserById: async (id: UserId) => {
const userMap = get().userMap;
if (userMap[id]) {
return userMap[id] as User;
}
const { data } = await api.getUserById(id);
const user = convertResponseModelUser(data);
userMap[id] = user;
set(userMap);
return user;
},
createUser: async (userCreate: UserCreate) => {
const { data } = await api.createUser(userCreate);
const user = convertResponseModelUser(data);
const userMap = get().userMap;
userMap[user.id] = user;
set(userMap);
return user;
},
patchUser: async (userPatch: UserPatch) => {
const { data } = await api.patchUser(userPatch);
const user = convertResponseModelUser(data);
const userMap = get().userMap;
userMap[user.id] = user;
set(userMap);
},
getUserById: (id: UserId) => {
const userMap = get().userMap;
return userMap[id] as User;
},
getCurrentUser: () => {
const userMap = get().userMap;
const currentUserId = get().currentUserId;
return userMap[currentUserId as UserId];
},
}));
export default useUserStore;

29
web/src/stores/v1/view.ts Normal file
View File

@ -0,0 +1,29 @@
import { create } from "zustand";
import { persist } from "zustand/middleware";
export interface Filter {
tag?: string;
mineOnly?: boolean;
visibility?: Visibility;
}
interface ViewState {
filter: Filter;
setFilter: (filter: Partial<Filter>) => void;
}
const useViewStore = create<ViewState>()(
persist(
(set, get) => ({
filter: {},
setFilter: (filter: Partial<Filter>) => {
set({ filter: { ...get().filter, ...filter } });
},
}),
{
name: "view",
}
)
);
export default useViewStore;

20
web/src/types/analytics.d.ts vendored Normal file
View File

@ -0,0 +1,20 @@
interface ReferenceInfo {
name: string;
count: number;
}
interface DeviceInfo {
name: string;
count: number;
}
interface BrowserInfo {
name: string;
count: number;
}
interface AnalysisData {
referenceData: ReferenceInfo[];
deviceData: DeviceInfo[];
browserData: BrowserInfo[];
}

View File

@ -38,6 +38,5 @@ interface ShortcutPatch {
}
interface ShortcutFind {
creatorId?: UserId;
tag?: string;
}

View File

@ -14,6 +14,13 @@ interface User {
role: Role;
}
interface UserCreate {
email: string;
nickname: string;
password: string;
role: Role;
}
interface UserPatch {
id: UserId;
@ -21,6 +28,7 @@ interface UserPatch {
email?: string;
nickname?: string;
password?: string;
role?: Role;
}
interface UserDelete {