mirror of
https://github.com/aykhans/slash-e.git
synced 2025-07-06 13:12:36 +00:00
Compare commits
5 Commits
Author | SHA1 | Date | |
---|---|---|---|
f815804f68 | |||
70f6f30d69 | |||
12cf0f8a8c | |||
3932cabeac | |||
f8d36ae1ef |
26
.github/dependabot.yml
vendored
26
.github/dependabot.yml
vendored
@ -1,26 +0,0 @@
|
|||||||
version: 2
|
|
||||||
updates:
|
|
||||||
- package-ecosystem: "github-actions"
|
|
||||||
commit-message:
|
|
||||||
prefix: "chore"
|
|
||||||
directory: "/"
|
|
||||||
schedule:
|
|
||||||
interval: "weekly"
|
|
||||||
- package-ecosystem: npm
|
|
||||||
commit-message:
|
|
||||||
prefix: "chore"
|
|
||||||
directory: "/frontend/web"
|
|
||||||
schedule:
|
|
||||||
interval: "weekly"
|
|
||||||
- package-ecosystem: npm
|
|
||||||
commit-message:
|
|
||||||
prefix: "chore"
|
|
||||||
directory: "/frontend/extension"
|
|
||||||
schedule:
|
|
||||||
interval: "weekly"
|
|
||||||
- package-ecosystem: "gomod"
|
|
||||||
commit-message:
|
|
||||||
prefix: "chore"
|
|
||||||
directory: "/"
|
|
||||||
schedule:
|
|
||||||
interval: "weekly"
|
|
14
.github/workflows/backend-tests.yml
vendored
14
.github/workflows/backend-tests.yml
vendored
@ -12,10 +12,10 @@ jobs:
|
|||||||
go-static-checks:
|
go-static-checks:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v3
|
||||||
- uses: actions/setup-go@v5
|
- uses: actions/setup-go@v3
|
||||||
with:
|
with:
|
||||||
go-version: 1.22
|
go-version: 1.21
|
||||||
check-latest: true
|
check-latest: true
|
||||||
cache: true
|
cache: true
|
||||||
- name: Verify go.mod is tidy
|
- name: Verify go.mod is tidy
|
||||||
@ -23,7 +23,7 @@ jobs:
|
|||||||
go mod tidy
|
go mod tidy
|
||||||
git diff --exit-code
|
git diff --exit-code
|
||||||
- name: golangci-lint
|
- name: golangci-lint
|
||||||
uses: golangci/golangci-lint-action@v4
|
uses: golangci/golangci-lint-action@v3
|
||||||
with:
|
with:
|
||||||
version: v1.54.1
|
version: v1.54.1
|
||||||
args: --verbose --timeout=3m
|
args: --verbose --timeout=3m
|
||||||
@ -32,10 +32,10 @@ jobs:
|
|||||||
go-tests:
|
go-tests:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v3
|
||||||
- uses: actions/setup-go@v5
|
- uses: actions/setup-go@v3
|
||||||
with:
|
with:
|
||||||
go-version: 1.22
|
go-version: 1.21
|
||||||
check-latest: true
|
check-latest: true
|
||||||
cache: true
|
cache: true
|
||||||
- name: Run all tests
|
- name: Run all tests
|
||||||
|
@ -10,10 +10,10 @@ jobs:
|
|||||||
build-and-push-release-image:
|
build-and-push-release-image:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v3
|
||||||
|
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@v3
|
uses: docker/setup-qemu-action@v2
|
||||||
|
|
||||||
- name: Extract build args
|
- name: Extract build args
|
||||||
# Extract version from branch name
|
# Extract version from branch name
|
||||||
@ -22,20 +22,20 @@ jobs:
|
|||||||
echo "VERSION=${GITHUB_REF_NAME#release/}" >> $GITHUB_ENV
|
echo "VERSION=${GITHUB_REF_NAME#release/}" >> $GITHUB_ENV
|
||||||
|
|
||||||
- name: Login to Docker Hub
|
- name: Login to Docker Hub
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v2
|
||||||
with:
|
with:
|
||||||
username: yourselfhosted
|
username: yourselfhosted
|
||||||
password: ${{ secrets.DOCKER_TOKEN }}
|
password: ${{ secrets.DOCKER_TOKEN }}
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
id: buildx
|
id: buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v2
|
||||||
with:
|
with:
|
||||||
install: true
|
install: true
|
||||||
|
|
||||||
- name: Build and Push
|
- name: Build and Push
|
||||||
id: docker_build
|
id: docker_build
|
||||||
uses: docker/build-push-action@v5
|
uses: docker/build-push-action@v3
|
||||||
with:
|
with:
|
||||||
context: ./
|
context: ./
|
||||||
file: ./Dockerfile
|
file: ./Dockerfile
|
||||||
|
10
.github/workflows/build-and-push-test-image.yml
vendored
10
.github/workflows/build-and-push-test-image.yml
vendored
@ -8,27 +8,27 @@ jobs:
|
|||||||
build-and-push-test-image:
|
build-and-push-test-image:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v3
|
||||||
|
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@v3
|
uses: docker/setup-qemu-action@v2
|
||||||
|
|
||||||
- name: Login to Docker Hub
|
- name: Login to Docker Hub
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v2
|
||||||
with:
|
with:
|
||||||
username: yourselfhosted
|
username: yourselfhosted
|
||||||
password: ${{ secrets.DOCKER_TOKEN }}
|
password: ${{ secrets.DOCKER_TOKEN }}
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
id: buildx
|
id: buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v2
|
||||||
with:
|
with:
|
||||||
install: true
|
install: true
|
||||||
version: v0.9.1
|
version: v0.9.1
|
||||||
|
|
||||||
- name: Build and Push
|
- name: Build and Push
|
||||||
id: docker_build
|
id: docker_build
|
||||||
uses: docker/build-push-action@v5
|
uses: docker/build-push-action@v3
|
||||||
with:
|
with:
|
||||||
context: ./
|
context: ./
|
||||||
file: ./Dockerfile
|
file: ./Dockerfile
|
||||||
|
33
.github/workflows/build-artifacts.yml
vendored
33
.github/workflows/build-artifacts.yml
vendored
@ -1,33 +0,0 @@
|
|||||||
name: Build artifacts
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
tags:
|
|
||||||
- "*"
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
goreleaser:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
- uses: actions/setup-go@v5
|
|
||||||
with:
|
|
||||||
go-version: 1.22
|
|
||||||
check-latest: true
|
|
||||||
cache: true
|
|
||||||
- name: Run GoReleaser
|
|
||||||
uses: goreleaser/goreleaser-action@v5
|
|
||||||
with:
|
|
||||||
# either 'goreleaser' (default) or 'goreleaser-pro'
|
|
||||||
distribution: goreleaser
|
|
||||||
# 'latest', 'nightly', or a semver
|
|
||||||
version: latest
|
|
||||||
args: release --clean
|
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
20
.github/workflows/extension-test.yml
vendored
20
.github/workflows/extension-test.yml
vendored
@ -14,17 +14,19 @@ jobs:
|
|||||||
eslint-checks:
|
eslint-checks:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v3
|
||||||
- uses: pnpm/action-setup@v3.0.0
|
- uses: pnpm/action-setup@v2.2.4
|
||||||
with:
|
with:
|
||||||
version: 9
|
version: 8
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v3
|
||||||
with:
|
with:
|
||||||
node-version: "18"
|
node-version: "18"
|
||||||
cache: pnpm
|
cache: pnpm
|
||||||
cache-dependency-path: "frontend/extension/pnpm-lock.yaml"
|
cache-dependency-path: "frontend/extension/pnpm-lock.yaml"
|
||||||
- run: pnpm install
|
- run: pnpm install
|
||||||
working-directory: frontend/extension
|
working-directory: frontend/extension
|
||||||
|
- run: pnpm type-gen
|
||||||
|
working-directory: frontend/extension
|
||||||
- name: Run eslint check
|
- name: Run eslint check
|
||||||
run: pnpm lint
|
run: pnpm lint
|
||||||
working-directory: frontend/extension
|
working-directory: frontend/extension
|
||||||
@ -32,17 +34,19 @@ jobs:
|
|||||||
extension-build:
|
extension-build:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v3
|
||||||
- uses: pnpm/action-setup@v3.0.0
|
- uses: pnpm/action-setup@v2.2.4
|
||||||
with:
|
with:
|
||||||
version: 9
|
version: 8
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v3
|
||||||
with:
|
with:
|
||||||
node-version: "18"
|
node-version: "18"
|
||||||
cache: pnpm
|
cache: pnpm
|
||||||
cache-dependency-path: "frontend/extension/pnpm-lock.yaml"
|
cache-dependency-path: "frontend/extension/pnpm-lock.yaml"
|
||||||
- run: pnpm install
|
- run: pnpm install
|
||||||
working-directory: frontend/extension
|
working-directory: frontend/extension
|
||||||
|
- run: pnpm type-gen
|
||||||
|
working-directory: frontend/extension
|
||||||
- name: Run extension build
|
- name: Run extension build
|
||||||
run: pnpm build
|
run: pnpm build
|
||||||
working-directory: frontend/extension
|
working-directory: frontend/extension
|
||||||
|
20
.github/workflows/frontend-test.yml
vendored
20
.github/workflows/frontend-test.yml
vendored
@ -14,17 +14,19 @@ jobs:
|
|||||||
eslint-checks:
|
eslint-checks:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v3
|
||||||
- uses: pnpm/action-setup@v3.0.0
|
- uses: pnpm/action-setup@v2.2.4
|
||||||
with:
|
with:
|
||||||
version: 9
|
version: 8
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v3
|
||||||
with:
|
with:
|
||||||
node-version: "18"
|
node-version: "18"
|
||||||
cache: pnpm
|
cache: pnpm
|
||||||
cache-dependency-path: "frontend/web/pnpm-lock.yaml"
|
cache-dependency-path: "frontend/web/pnpm-lock.yaml"
|
||||||
- run: pnpm install
|
- run: pnpm install
|
||||||
working-directory: frontend/web
|
working-directory: frontend/web
|
||||||
|
- run: pnpm type-gen
|
||||||
|
working-directory: frontend/web
|
||||||
- name: Run eslint check
|
- name: Run eslint check
|
||||||
run: pnpm lint
|
run: pnpm lint
|
||||||
working-directory: frontend/web
|
working-directory: frontend/web
|
||||||
@ -32,17 +34,19 @@ jobs:
|
|||||||
frontend-build:
|
frontend-build:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v3
|
||||||
- uses: pnpm/action-setup@v3.0.0
|
- uses: pnpm/action-setup@v2.2.4
|
||||||
with:
|
with:
|
||||||
version: 9
|
version: 8
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v3
|
||||||
with:
|
with:
|
||||||
node-version: "18"
|
node-version: "18"
|
||||||
cache: pnpm
|
cache: pnpm
|
||||||
cache-dependency-path: "frontend/web/pnpm-lock.yaml"
|
cache-dependency-path: "frontend/web/pnpm-lock.yaml"
|
||||||
- run: pnpm install
|
- run: pnpm install
|
||||||
working-directory: frontend/web
|
working-directory: frontend/web
|
||||||
|
- run: pnpm type-gen
|
||||||
|
working-directory: frontend/web
|
||||||
- name: Run frontend build
|
- name: Run frontend build
|
||||||
run: pnpm build
|
run: pnpm build
|
||||||
working-directory: frontend/web
|
working-directory: frontend/web
|
||||||
|
2
.github/workflows/proto-linter.yml
vendored
2
.github/workflows/proto-linter.yml
vendored
@ -17,7 +17,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v3
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
- name: Setup buf
|
- name: Setup buf
|
||||||
|
4
.gitignore
vendored
4
.gitignore
vendored
@ -10,7 +10,3 @@ build
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
||||||
node_modules
|
node_modules
|
||||||
|
|
||||||
.env
|
|
||||||
|
|
||||||
dist/
|
|
||||||
|
@ -29,7 +29,7 @@ issues:
|
|||||||
linters-settings:
|
linters-settings:
|
||||||
goimports:
|
goimports:
|
||||||
# Put imports beginning with prefix after 3rd-party packages.
|
# Put imports beginning with prefix after 3rd-party packages.
|
||||||
local-prefixes: github.com/yourselfhosted/slash
|
local-prefixes: github.com/boojack/slash
|
||||||
revive:
|
revive:
|
||||||
# Default to run all linters so that new rules in the future could automatically be added to the static check.
|
# Default to run all linters so that new rules in the future could automatically be added to the static check.
|
||||||
enable-all-rules: true
|
enable-all-rules: true
|
||||||
@ -65,10 +65,6 @@ linters-settings:
|
|||||||
disabled: true
|
disabled: true
|
||||||
- name: early-return
|
- name: early-return
|
||||||
disabled: true
|
disabled: true
|
||||||
- name: use-any
|
|
||||||
disabled: true
|
|
||||||
- name: var-naming
|
|
||||||
disabled: true
|
|
||||||
- name: exported
|
- name: exported
|
||||||
arguments:
|
arguments:
|
||||||
- "disableStutteringCheck"
|
- "disableStutteringCheck"
|
||||||
|
@ -1,38 +0,0 @@
|
|||||||
version: 1
|
|
||||||
|
|
||||||
before:
|
|
||||||
hooks:
|
|
||||||
# You may remove this if you don't use go modules.
|
|
||||||
- go mod tidy
|
|
||||||
|
|
||||||
builds:
|
|
||||||
- env:
|
|
||||||
- CGO_ENABLED=0
|
|
||||||
main: ./bin/slash
|
|
||||||
binary: slash
|
|
||||||
goos:
|
|
||||||
- linux
|
|
||||||
- darwin
|
|
||||||
|
|
||||||
archives:
|
|
||||||
- format: tar.gz
|
|
||||||
# this name template makes the OS and Arch compatible with the results of `uname`.
|
|
||||||
name_template: >-
|
|
||||||
{{ .ProjectName }}_{{ .Tag }}_{{ .Os }}_{{ .Arch }}
|
|
||||||
|
|
||||||
changelog:
|
|
||||||
sort: asc
|
|
||||||
filters:
|
|
||||||
exclude:
|
|
||||||
- "^docs:"
|
|
||||||
- "^test:"
|
|
||||||
|
|
||||||
checksum:
|
|
||||||
disable: true
|
|
||||||
|
|
||||||
release:
|
|
||||||
draft: true
|
|
||||||
replace_existing_draft: true
|
|
||||||
make_latest: true
|
|
||||||
mode: replace
|
|
||||||
skip_upload: false
|
|
@ -6,16 +6,16 @@ COPY . .
|
|||||||
|
|
||||||
WORKDIR /frontend-build/frontend/web
|
WORKDIR /frontend-build/frontend/web
|
||||||
|
|
||||||
RUN corepack enable && pnpm i --frozen-lockfile
|
RUN corepack enable && pnpm i --frozen-lockfile && pnpm type-gen
|
||||||
|
|
||||||
RUN pnpm build
|
RUN pnpm build
|
||||||
|
|
||||||
# Build backend exec file.
|
# Build backend exec file.
|
||||||
FROM golang:1.22-alpine AS backend
|
FROM golang:1.21-alpine AS backend
|
||||||
WORKDIR /backend-build
|
WORKDIR /backend-build
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
COPY --from=frontend /frontend-build/frontend/web/dist /backend-build/server/route/frontend/dist
|
COPY --from=frontend /frontend-build/frontend/web/dist ./server/dist
|
||||||
|
|
||||||
RUN CGO_ENABLED=0 go build -o slash ./bin/slash/main.go
|
RUN CGO_ENABLED=0 go build -o slash ./bin/slash/main.go
|
||||||
|
|
||||||
|
21
README.md
21
README.md
@ -1,19 +1,19 @@
|
|||||||
# Slash
|
# Slash
|
||||||
|
|
||||||
**Slash** is an open source, self-hosted links shortener and sharing platform. It allows you to organize your links with tags, and share them with custom shortened URLs. Slash also supports team sharing of link libraries for easy collaboration.
|
<img align="right" src="./resources/logo.png" height="64px" alt="logo">
|
||||||
|
|
||||||
|
**Slash** is an open source, self-hosted bookmarks and link sharing platform. It allows you to organize your links with tags, and share them with custom shortened URLs. Slash also supports team sharing of link libraries for easy collaboration.
|
||||||
|
|
||||||
🧩 Browser extension(v1.0.0) now available! - [Chrome Web Store](https://chrome.google.com/webstore/detail/slash/ebaiehmkammnacjadffpicipfckgeobg), [Firefox Add-on](https://addons.mozilla.org/firefox/addon/your-slash/)
|
🧩 Browser extension(v1.0.0) now available! - [Chrome Web Store](https://chrome.google.com/webstore/detail/slash/ebaiehmkammnacjadffpicipfckgeobg), [Firefox Add-on](https://addons.mozilla.org/firefox/addon/your-slash/)
|
||||||
|
|
||||||
Getting started with Slash's [Shortcuts](https://github.com/yourselfhosted/slash/blob/main/docs/getting-started/shortcuts.md) and [Collections](https://github.com/yourselfhosted/slash/blob/main/docs/getting-started/collections.md).
|
<a href="https://demo.slash.yourselfhosted.com">Live Demo</a> • <a href="https://discord.gg/QZqUuUAhDV">Discord</a>
|
||||||
|
|
||||||
[👉 Join our Discord 💬](https://discord.gg/QZqUuUAhDV)
|
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
<a href="https://hub.docker.com/r/yourselfhosted/slash"><img alt="Docker pull" src="https://img.shields.io/docker/pulls/yourselfhosted/slash.svg"/></a>
|
<a href="https://hub.docker.com/r/yourselfhosted/slash"><img alt="Docker pull" src="https://img.shields.io/docker/pulls/yourselfhosted/slash.svg"/></a>
|
||||||
<a href="https://discord.gg/QZqUuUAhDV"><img alt="Discord" src="https://img.shields.io/badge/discord-chat-5865f2?logo=discord&logoColor=f5f5f5" /></a>
|
<a href="https://github.com/boojack/slash/stargazers"><img alt="GitHub stars" src="https://img.shields.io/github/stars/boojack/slash?logo=github"/></a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
## Background
|
## Background
|
||||||
|
|
||||||
@ -23,11 +23,10 @@ That's why we developed Slash, a solution that transforms these links into easil
|
|||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- Create customizable `s/` short links for any URL.
|
- Create customizable `/s/` short links for any URL.
|
||||||
- Share short links public or only with your teammates.
|
- Share short links public or only with your teammates.
|
||||||
- View analytics on link traffic and sources.
|
- View analytics on link traffic and sources.
|
||||||
- Easy access to your shortcuts with browser extension.
|
- Easy access to your shortcuts with browser extension.
|
||||||
- Share your shortcuts with Collection to anyone, on any browser.
|
|
||||||
- Open source self-hosted solution.
|
- Open source self-hosted solution.
|
||||||
|
|
||||||
## Deploy with Docker in seconds
|
## Deploy with Docker in seconds
|
||||||
@ -36,15 +35,15 @@ That's why we developed Slash, a solution that transforms these links into easil
|
|||||||
docker run -d --name slash -p 5231:5231 -v ~/.slash/:/var/opt/slash yourselfhosted/slash:latest
|
docker run -d --name slash -p 5231:5231 -v ~/.slash/:/var/opt/slash yourselfhosted/slash:latest
|
||||||
```
|
```
|
||||||
|
|
||||||
Learn more in [Self-hosting Slash with Docker](https://github.com/yourselfhosted/slash/blob/main/docs/install.md).
|
Learn more in [Self-hosting Slash with Docker](https://github.com/boojack/slash/blob/main/docs/install.md).
|
||||||
|
|
||||||
## Browser Extension
|
## Browser Extension
|
||||||
|
|
||||||
Slash provides a browser extension to help you use your shortcuts in the search bar to go to the corresponding URL.
|
Slash provides a browser extension to help you use your shortcuts in the search bar to go to the corresponding URL.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
Learn more in [The Browser Extension of Slash](https://github.com/yourselfhosted/slash/blob/main/docs/install-browser-extension.md).
|
Learn more in [The Browser Extension of Slash](https://github.com/boojack/slash/blob/main/docs/install-browser-extension.md).
|
||||||
|
|
||||||
### Chromium based browsers
|
### Chromium based browsers
|
||||||
|
|
||||||
|
@ -4,7 +4,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/golang-jwt/jwt/v5"
|
"github.com/golang-jwt/jwt/v4"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
12
api/v1/activity.go
Normal file
12
api/v1/activity.go
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
package v1
|
||||||
|
|
||||||
|
type ActivityShorcutCreatePayload struct {
|
||||||
|
ShortcutID int32 `json:"shortcutId"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ActivityShorcutViewPayload struct {
|
||||||
|
ShortcutID int32 `json:"shortcutId"`
|
||||||
|
IP string `json:"ip"`
|
||||||
|
Referer string `json:"referer"`
|
||||||
|
UserAgent string `json:"userAgent"`
|
||||||
|
}
|
131
api/v1/analytics.go
Normal file
131
api/v1/analytics.go
Normal file
@ -0,0 +1,131 @@
|
|||||||
|
package v1
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/labstack/echo/v4"
|
||||||
|
"github.com/mssola/useragent"
|
||||||
|
"golang.org/x/exp/slices"
|
||||||
|
|
||||||
|
"github.com/boojack/slash/server/metric"
|
||||||
|
"github.com/boojack/slash/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
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]++
|
||||||
|
}
|
||||||
|
|
||||||
|
metric.Enqueue("shortcut analytics")
|
||||||
|
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) int {
|
||||||
|
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) int {
|
||||||
|
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) int {
|
||||||
|
return i.Count - j.Count
|
||||||
|
})
|
||||||
|
return browserInfoSlice
|
||||||
|
}
|
211
api/v1/auth.go
Normal file
211
api/v1/auth.go
Normal file
@ -0,0 +1,211 @@
|
|||||||
|
package v1
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/labstack/echo/v4"
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
|
||||||
|
"github.com/boojack/slash/api/auth"
|
||||||
|
storepb "github.com/boojack/slash/proto/gen/store"
|
||||||
|
"github.com/boojack/slash/server/metric"
|
||||||
|
"github.com/boojack/slash/server/service/license"
|
||||||
|
"github.com/boojack/slash/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
type SignInRequest struct {
|
||||||
|
Email string `json:"email"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SignUpRequest struct {
|
||||||
|
Nickname string `json:"nickname"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *APIV1Service) registerAuthRoutes(g *echo.Group, secret string) {
|
||||||
|
g.POST("/auth/signin", func(c echo.Context) error {
|
||||||
|
ctx := c.Request().Context()
|
||||||
|
signin := &SignInRequest{}
|
||||||
|
if err := json.NewDecoder(c.Request().Body).Decode(signin); err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("malformatted signin request, err: %s", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := s.Store.GetUser(ctx, &store.FindUser{
|
||||||
|
Email: &signin.Email,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to find user by email %s", signin.Email)).SetInternal(err)
|
||||||
|
}
|
||||||
|
if user == nil {
|
||||||
|
return echo.NewHTTPError(http.StatusUnauthorized, fmt.Sprintf("user not found with email %s", signin.Email))
|
||||||
|
} else if user.RowStatus == store.Archived {
|
||||||
|
return echo.NewHTTPError(http.StatusForbidden, fmt.Sprintf("user has been archived with email %s", signin.Email))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compare the stored hashed password, with the hashed version of the password that was received.
|
||||||
|
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(signin.Password)); err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusUnauthorized, "unmatched email and password")
|
||||||
|
}
|
||||||
|
|
||||||
|
accessToken, err := auth.GenerateAccessToken(user.Email, user.ID, time.Now().Add(auth.AccessTokenDuration), []byte(secret))
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to generate tokens, err: %s", err)).SetInternal(err)
|
||||||
|
}
|
||||||
|
if err := s.UpsertAccessTokenToStore(ctx, user, accessToken); err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to upsert access token, err: %s", err)).SetInternal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cookieExp := time.Now().Add(auth.CookieExpDuration)
|
||||||
|
setTokenCookie(c, auth.AccessTokenCookieName, accessToken, cookieExp)
|
||||||
|
metric.Enqueue("user sign in")
|
||||||
|
return c.JSON(http.StatusOK, convertUserFromStore(user))
|
||||||
|
})
|
||||||
|
|
||||||
|
g.POST("/auth/signup", func(c echo.Context) error {
|
||||||
|
ctx := c.Request().Context()
|
||||||
|
enableSignUpSetting, err := s.Store.GetWorkspaceSetting(ctx, &store.FindWorkspaceSetting{
|
||||||
|
Key: storepb.WorkspaceSettingKey_WORKSAPCE_SETTING_ENABLE_SIGNUP,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to get workspace setting, err: %s", err)).SetInternal(err)
|
||||||
|
}
|
||||||
|
if enableSignUpSetting != nil && !enableSignUpSetting.GetEnableSignup() {
|
||||||
|
return echo.NewHTTPError(http.StatusForbidden, "sign up has been disabled")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !s.LicenseService.IsFeatureEnabled(license.FeatureTypeUnlimitedAccounts) {
|
||||||
|
userList, err := s.Store.ListUsers(ctx, &store.FindUser{})
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to list users").SetInternal(err)
|
||||||
|
}
|
||||||
|
if len(userList) >= 5 {
|
||||||
|
return echo.NewHTTPError(http.StatusBadRequest, "Maximum number of users reached")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
signup := &SignUpRequest{}
|
||||||
|
if err := json.NewDecoder(c.Request().Body).Decode(signup); err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("malformatted signup request, err: %s", err)).SetInternal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
passwordHash, err := bcrypt.GenerateFromPassword([]byte(signup.Password), bcrypt.DefaultCost)
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, "failed to generate password hash").SetInternal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
create := &store.User{
|
||||||
|
Email: signup.Email,
|
||||||
|
Nickname: signup.Nickname,
|
||||||
|
PasswordHash: string(passwordHash),
|
||||||
|
}
|
||||||
|
existingUsers, err := s.Store.ListUsers(ctx, &store.FindUser{})
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to find existing users, err: %s", err)).SetInternal(err)
|
||||||
|
}
|
||||||
|
// The first user to sign up is an admin by default.
|
||||||
|
if len(existingUsers) == 0 {
|
||||||
|
create.Role = store.RoleAdmin
|
||||||
|
} else {
|
||||||
|
create.Role = store.RoleUser
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := s.Store.CreateUser(ctx, create)
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to create user, err: %s", err)).SetInternal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
accessToken, err := auth.GenerateAccessToken(user.Email, user.ID, time.Now().Add(auth.AccessTokenDuration), []byte(secret))
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to generate tokens, err: %s", err)).SetInternal(err)
|
||||||
|
}
|
||||||
|
if err := s.UpsertAccessTokenToStore(ctx, user, accessToken); err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to upsert access token, err: %s", err)).SetInternal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cookieExp := time.Now().Add(auth.CookieExpDuration)
|
||||||
|
setTokenCookie(c, auth.AccessTokenCookieName, accessToken, cookieExp)
|
||||||
|
metric.Enqueue("user sign up")
|
||||||
|
return c.JSON(http.StatusOK, convertUserFromStore(user))
|
||||||
|
})
|
||||||
|
|
||||||
|
g.POST("/auth/logout", func(c echo.Context) error {
|
||||||
|
ctx := c.Request().Context()
|
||||||
|
RemoveTokensAndCookies(c)
|
||||||
|
accessToken := findAccessToken(c)
|
||||||
|
userID, _ := getUserIDFromAccessToken(accessToken, secret)
|
||||||
|
userAccessTokens, err := s.Store.GetUserAccessTokens(ctx, userID)
|
||||||
|
// Auto remove the current access token from the user access tokens.
|
||||||
|
if err == nil && len(userAccessTokens) != 0 {
|
||||||
|
accessTokens := []*storepb.AccessTokensUserSetting_AccessToken{}
|
||||||
|
for _, userAccessToken := range userAccessTokens {
|
||||||
|
if accessToken != userAccessToken.AccessToken {
|
||||||
|
accessTokens = append(accessTokens, userAccessToken)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := s.Store.UpsertUserSetting(ctx, &storepb.UserSetting{
|
||||||
|
UserId: userID,
|
||||||
|
Key: storepb.UserSettingKey_USER_SETTING_ACCESS_TOKENS,
|
||||||
|
Value: &storepb.UserSetting_AccessTokens{
|
||||||
|
AccessTokens: &storepb.AccessTokensUserSetting{
|
||||||
|
AccessTokens: accessTokens,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}); err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to upsert user setting, err: %s", err)).SetInternal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c.Response().WriteHeader(http.StatusOK)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *APIV1Service) UpsertAccessTokenToStore(ctx context.Context, user *store.User, accessToken string) error {
|
||||||
|
userAccessTokens, err := s.Store.GetUserAccessTokens(ctx, user.ID)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "failed to get user access tokens")
|
||||||
|
}
|
||||||
|
userAccessToken := storepb.AccessTokensUserSetting_AccessToken{
|
||||||
|
AccessToken: accessToken,
|
||||||
|
Description: "Account sign in",
|
||||||
|
}
|
||||||
|
userAccessTokens = append(userAccessTokens, &userAccessToken)
|
||||||
|
if _, err := s.Store.UpsertUserSetting(ctx, &storepb.UserSetting{
|
||||||
|
UserId: user.ID,
|
||||||
|
Key: storepb.UserSettingKey_USER_SETTING_ACCESS_TOKENS,
|
||||||
|
Value: &storepb.UserSetting_AccessTokens{
|
||||||
|
AccessTokens: &storepb.AccessTokensUserSetting{
|
||||||
|
AccessTokens: userAccessTokens,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}); err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to upsert user setting, err: %s", err)).SetInternal(err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveTokensAndCookies removes the jwt token from the cookies.
|
||||||
|
func RemoveTokensAndCookies(c echo.Context) {
|
||||||
|
cookieExp := time.Now().Add(-1 * time.Hour)
|
||||||
|
setTokenCookie(c, auth.AccessTokenCookieName, "", cookieExp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// setTokenCookie sets the token to the cookie.
|
||||||
|
func setTokenCookie(c echo.Context, name, token string, expiration time.Time) {
|
||||||
|
cookie := new(http.Cookie)
|
||||||
|
cookie.Name = name
|
||||||
|
cookie.Value = token
|
||||||
|
cookie.Expires = expiration
|
||||||
|
cookie.Path = "/"
|
||||||
|
// Http-only helps mitigate the risk of client side script accessing the protected cookie.
|
||||||
|
cookie.HttpOnly = true
|
||||||
|
cookie.SameSite = http.SameSiteStrictMode
|
||||||
|
c.SetCookie(cookie)
|
||||||
|
}
|
15
api/v1/common.go
Normal file
15
api/v1/common.go
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
package v1
|
||||||
|
|
||||||
|
// RowStatus is the status for a row.
|
||||||
|
type RowStatus string
|
||||||
|
|
||||||
|
const (
|
||||||
|
// Normal is the status for a normal row.
|
||||||
|
Normal RowStatus = "NORMAL"
|
||||||
|
// Archived is the status for an archived row.
|
||||||
|
Archived RowStatus = "ARCHIVED"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s RowStatus) String() string {
|
||||||
|
return string(s)
|
||||||
|
}
|
133
api/v1/jwt.go
Normal file
133
api/v1/jwt.go
Normal file
@ -0,0 +1,133 @@
|
|||||||
|
package v1
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/golang-jwt/jwt/v4"
|
||||||
|
"github.com/labstack/echo/v4"
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
|
||||||
|
"github.com/boojack/slash/api/auth"
|
||||||
|
"github.com/boojack/slash/internal/util"
|
||||||
|
storepb "github.com/boojack/slash/proto/gen/store"
|
||||||
|
"github.com/boojack/slash/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// The key name used to store user id in the context
|
||||||
|
// user id is extracted from the jwt token subject field.
|
||||||
|
userIDContextKey = "user-id"
|
||||||
|
)
|
||||||
|
|
||||||
|
func extractTokenFromHeader(c echo.Context) (string, error) {
|
||||||
|
authHeader := c.Request().Header.Get("Authorization")
|
||||||
|
if authHeader == "" {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
authHeaderParts := strings.Fields(authHeader)
|
||||||
|
if len(authHeaderParts) != 2 || strings.ToLower(authHeaderParts[0]) != "bearer" {
|
||||||
|
return "", errors.New("Authorization header format must be Bearer {token}")
|
||||||
|
}
|
||||||
|
|
||||||
|
return authHeaderParts[1], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func findAccessToken(c echo.Context) string {
|
||||||
|
// Check the HTTP request header first.
|
||||||
|
accessToken, _ := extractTokenFromHeader(c)
|
||||||
|
if accessToken == "" {
|
||||||
|
// Check the cookie.
|
||||||
|
cookie, _ := c.Cookie(auth.AccessTokenCookieName)
|
||||||
|
if cookie != nil {
|
||||||
|
accessToken = cookie.Value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return accessToken
|
||||||
|
}
|
||||||
|
|
||||||
|
// JWTMiddleware validates the access token.
|
||||||
|
func JWTMiddleware(s *APIV1Service, next echo.HandlerFunc, secret string) echo.HandlerFunc {
|
||||||
|
return func(c echo.Context) error {
|
||||||
|
ctx := c.Request().Context()
|
||||||
|
path := c.Request().URL.Path
|
||||||
|
method := c.Request().Method
|
||||||
|
|
||||||
|
// Pass auth and profile endpoints.
|
||||||
|
if util.HasPrefixes(path, "/api/v1/auth", "/api/v1/workspace/profile") {
|
||||||
|
return next(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
accessToken := findAccessToken(c)
|
||||||
|
if accessToken == "" {
|
||||||
|
// When the request is not authenticated, we allow the user to access the shortcut endpoints for those public shortcuts.
|
||||||
|
if util.HasPrefixes(path, "/s/", "/api/v1/user/") && method == http.MethodGet {
|
||||||
|
return next(c)
|
||||||
|
}
|
||||||
|
return echo.NewHTTPError(http.StatusUnauthorized, "Missing access token")
|
||||||
|
}
|
||||||
|
|
||||||
|
userID, err := getUserIDFromAccessToken(accessToken, secret)
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusUnauthorized, "Invalid or expired access token")
|
||||||
|
}
|
||||||
|
|
||||||
|
accessTokens, err := s.Store.GetUserAccessTokens(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to get user access tokens.").WithInternal(err)
|
||||||
|
}
|
||||||
|
if !validateAccessToken(accessToken, accessTokens) {
|
||||||
|
return echo.NewHTTPError(http.StatusUnauthorized, "Invalid access token.")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Even if there is no error, we still need to make sure the user still exists.
|
||||||
|
user, err := s.Store.GetUser(ctx, &store.FindUser{
|
||||||
|
ID: &userID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Server error to find user ID: %d", userID)).SetInternal(err)
|
||||||
|
}
|
||||||
|
if user == nil {
|
||||||
|
return echo.NewHTTPError(http.StatusUnauthorized, fmt.Sprintf("Failed to find user ID: %d", userID))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stores userID into context.
|
||||||
|
c.Set(userIDContextKey, userID)
|
||||||
|
return next(c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func getUserIDFromAccessToken(accessToken, secret string) (int32, error) {
|
||||||
|
claims := &auth.ClaimsMessage{}
|
||||||
|
_, err := jwt.ParseWithClaims(accessToken, claims, func(t *jwt.Token) (any, error) {
|
||||||
|
if t.Method.Alg() != jwt.SigningMethodHS256.Name {
|
||||||
|
return nil, errors.Errorf("unexpected access token signing method=%v, expect %v", t.Header["alg"], jwt.SigningMethodHS256)
|
||||||
|
}
|
||||||
|
if kid, ok := t.Header["kid"].(string); ok {
|
||||||
|
if kid == "v1" {
|
||||||
|
return []byte(secret), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, errors.Errorf("unexpected access token kid=%v", t.Header["kid"])
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return 0, errors.Wrap(err, "Invalid or expired access token")
|
||||||
|
}
|
||||||
|
// We either have a valid access token or we will attempt to generate new access token.
|
||||||
|
userID, err := util.ConvertStringToInt32(claims.Subject)
|
||||||
|
if err != nil {
|
||||||
|
return 0, errors.Wrap(err, "Malformed ID in the token")
|
||||||
|
}
|
||||||
|
return userID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateAccessToken(accessTokenString string, userAccessTokens []*storepb.AccessTokensUserSetting_AccessToken) bool {
|
||||||
|
for _, userAccessToken := range userAccessTokens {
|
||||||
|
if accessTokenString == userAccessToken.AccessToken {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
118
api/v1/redirector.go
Normal file
118
api/v1/redirector.go
Normal file
@ -0,0 +1,118 @@
|
|||||||
|
package v1
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"html"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/labstack/echo/v4"
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
|
||||||
|
storepb "github.com/boojack/slash/proto/gen/store"
|
||||||
|
"github.com/boojack/slash/server/metric"
|
||||||
|
"github.com/boojack/slash/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *APIV1Service) registerRedirectorRoutes(g *echo.Group) {
|
||||||
|
g.GET("/*", func(c echo.Context) error {
|
||||||
|
ctx := c.Request().Context()
|
||||||
|
if len(c.ParamValues()) == 0 {
|
||||||
|
return echo.NewHTTPError(http.StatusBadRequest, "invalid shortcut name")
|
||||||
|
}
|
||||||
|
|
||||||
|
shortcutName := c.ParamValues()[0]
|
||||||
|
shortcut, err := s.Store.GetShortcut(ctx, &store.FindShortcut{
|
||||||
|
Name: &shortcutName,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to get shortcut, err: %s", err)).SetInternal(err)
|
||||||
|
}
|
||||||
|
if shortcut == nil {
|
||||||
|
return c.Redirect(http.StatusSeeOther, fmt.Sprintf("/404?shortcut=%s", shortcutName))
|
||||||
|
}
|
||||||
|
if shortcut.Visibility != storepb.Visibility_PUBLIC {
|
||||||
|
userID, ok := c.Get(userIDContextKey).(int32)
|
||||||
|
if !ok {
|
||||||
|
return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized")
|
||||||
|
}
|
||||||
|
if shortcut.Visibility == storepb.Visibility_PRIVATE && shortcut.CreatorId != userID {
|
||||||
|
return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.createShortcutViewActivity(c, shortcut); err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to create activity, err: %s", err)).SetInternal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
metric.Enqueue("shortcut redirect")
|
||||||
|
return redirectToShortcut(c, shortcut)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func redirectToShortcut(c echo.Context, shortcut *storepb.Shortcut) error {
|
||||||
|
isValidURL := isValidURLString(shortcut.Link)
|
||||||
|
if shortcut.OgMetadata == nil || (shortcut.OgMetadata.Title == "" && shortcut.OgMetadata.Description == "" && shortcut.OgMetadata.Image == "") {
|
||||||
|
if isValidURL {
|
||||||
|
return c.Redirect(http.StatusSeeOther, shortcut.Link)
|
||||||
|
}
|
||||||
|
return c.String(http.StatusOK, shortcut.Link)
|
||||||
|
}
|
||||||
|
|
||||||
|
htmlTemplate := `<html><head>%s</head><body>%s</body></html>`
|
||||||
|
metadataList := []string{
|
||||||
|
fmt.Sprintf(`<title>%s</title>`, shortcut.OgMetadata.Title),
|
||||||
|
fmt.Sprintf(`<meta name="description" content="%s" />`, shortcut.OgMetadata.Description),
|
||||||
|
fmt.Sprintf(`<meta property="og:title" content="%s" />`, shortcut.OgMetadata.Title),
|
||||||
|
fmt.Sprintf(`<meta property="og:description" content="%s" />`, shortcut.OgMetadata.Description),
|
||||||
|
fmt.Sprintf(`<meta property="og:image" content="%s" />`, shortcut.OgMetadata.Image),
|
||||||
|
`<meta property="og:type" content="website" />`,
|
||||||
|
// Twitter related metadata.
|
||||||
|
fmt.Sprintf(`<meta name="twitter:title" content="%s" />`, shortcut.OgMetadata.Title),
|
||||||
|
fmt.Sprintf(`<meta name="twitter:description" content="%s" />`, shortcut.OgMetadata.Description),
|
||||||
|
fmt.Sprintf(`<meta name="twitter:image" content="%s" />`, shortcut.OgMetadata.Image),
|
||||||
|
`<meta name="twitter:card" content="summary_large_image" />`,
|
||||||
|
}
|
||||||
|
if isValidURL {
|
||||||
|
metadataList = append(metadataList, fmt.Sprintf(`<meta property="og:url" content="%s" />`, shortcut.Link))
|
||||||
|
}
|
||||||
|
body := ""
|
||||||
|
if isValidURL {
|
||||||
|
body = fmt.Sprintf(`<script>window.location.href = "%s";</script>`, shortcut.Link)
|
||||||
|
} else {
|
||||||
|
body = html.EscapeString(shortcut.Link)
|
||||||
|
}
|
||||||
|
htmlString := fmt.Sprintf(htmlTemplate, strings.Join(metadataList, ""), body)
|
||||||
|
return c.HTML(http.StatusOK, htmlString)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *APIV1Service) createShortcutViewActivity(c echo.Context, shortcut *storepb.Shortcut) error {
|
||||||
|
payload := &ActivityShorcutViewPayload{
|
||||||
|
ShortcutID: shortcut.Id,
|
||||||
|
IP: c.RealIP(),
|
||||||
|
Referer: c.Request().Referer(),
|
||||||
|
UserAgent: c.Request().UserAgent(),
|
||||||
|
}
|
||||||
|
payloadStr, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "Failed to marshal activity payload")
|
||||||
|
}
|
||||||
|
activity := &store.Activity{
|
||||||
|
CreatorID: BotID,
|
||||||
|
Type: store.ActivityShortcutView,
|
||||||
|
Level: store.ActivityInfo,
|
||||||
|
Payload: string(payloadStr),
|
||||||
|
}
|
||||||
|
_, err = s.Store.CreateActivity(c.Request().Context(), activity)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "Failed to create activity")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func isValidURLString(s string) bool {
|
||||||
|
_, err := url.ParseRequestURI(s)
|
||||||
|
return err == nil
|
||||||
|
}
|
33
api/v1/redirector_test.go
Normal file
33
api/v1/redirector_test.go
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
package v1
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestIsValidURLString(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
link string
|
||||||
|
expected bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
link: "https://google.com",
|
||||||
|
expected: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
link: "http://google.com",
|
||||||
|
expected: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
link: "google.com",
|
||||||
|
expected: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
link: "mailto:email@example.com",
|
||||||
|
expected: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, test := range tests {
|
||||||
|
if isValidURLString(test.link) != test.expected {
|
||||||
|
t.Errorf("isValidURLString(%s) = %v, expected %v", test.link, !test.expected, test.expected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
383
api/v1/shortcut.go
Normal file
383
api/v1/shortcut.go
Normal file
@ -0,0 +1,383 @@
|
|||||||
|
package v1
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/labstack/echo/v4"
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
|
||||||
|
"github.com/boojack/slash/internal/util"
|
||||||
|
storepb "github.com/boojack/slash/proto/gen/store"
|
||||||
|
"github.com/boojack/slash/server/metric"
|
||||||
|
"github.com/boojack/slash/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Visibility is the type of a shortcut visibility.
|
||||||
|
type Visibility string
|
||||||
|
|
||||||
|
const (
|
||||||
|
// VisibilityPublic is the PUBLIC visibility.
|
||||||
|
VisibilityPublic Visibility = "PUBLIC"
|
||||||
|
// VisibilityWorkspace is the WORKSPACE visibility.
|
||||||
|
VisibilityWorkspace Visibility = "WORKSPACE"
|
||||||
|
// VisibilityPrivate is the PRIVATE visibility.
|
||||||
|
VisibilityPrivate Visibility = "PRIVATE"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (v Visibility) String() string {
|
||||||
|
return string(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
type OpenGraphMetadata struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Image string `json:"image"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Shortcut struct {
|
||||||
|
ID int32 `json:"id"`
|
||||||
|
|
||||||
|
// Standard fields
|
||||||
|
CreatorID int32 `json:"creatorId"`
|
||||||
|
Creator *User `json:"creator"`
|
||||||
|
CreatedTs int64 `json:"createdTs"`
|
||||||
|
UpdatedTs int64 `json:"updatedTs"`
|
||||||
|
RowStatus RowStatus `json:"rowStatus"`
|
||||||
|
|
||||||
|
// Domain specific fields
|
||||||
|
Name string `json:"name"`
|
||||||
|
Link string `json:"link"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Visibility Visibility `json:"visibility"`
|
||||||
|
Tags []string `json:"tags"`
|
||||||
|
View int `json:"view"`
|
||||||
|
OpenGraphMetadata *OpenGraphMetadata `json:"openGraphMetadata"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CreateShortcutRequest struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Link string `json:"link"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Visibility Visibility `json:"visibility"`
|
||||||
|
Tags []string `json:"tags"`
|
||||||
|
OpenGraphMetadata *OpenGraphMetadata `json:"openGraphMetadata"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PatchShortcutRequest struct {
|
||||||
|
RowStatus *RowStatus `json:"rowStatus"`
|
||||||
|
Name *string `json:"name"`
|
||||||
|
Link *string `json:"link"`
|
||||||
|
Title *string `json:"title"`
|
||||||
|
Description *string `json:"description"`
|
||||||
|
Visibility *Visibility `json:"visibility"`
|
||||||
|
Tags []string `json:"tags"`
|
||||||
|
OpenGraphMetadata *OpenGraphMetadata `json:"openGraphMetadata"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *APIV1Service) registerShortcutRoutes(g *echo.Group) {
|
||||||
|
g.POST("/shortcut", func(c echo.Context) error {
|
||||||
|
ctx := c.Request().Context()
|
||||||
|
userID, ok := c.Get(userIDContextKey).(int32)
|
||||||
|
if !ok {
|
||||||
|
return echo.NewHTTPError(http.StatusUnauthorized, "missing user in session")
|
||||||
|
}
|
||||||
|
create := &CreateShortcutRequest{}
|
||||||
|
if err := json.NewDecoder(c.Request().Body).Decode(create); err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("malformatted post shortcut request, err: %s", err)).SetInternal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
shortcut := &storepb.Shortcut{
|
||||||
|
CreatorId: userID,
|
||||||
|
Name: create.Name,
|
||||||
|
Link: create.Link,
|
||||||
|
Title: create.Title,
|
||||||
|
Description: create.Description,
|
||||||
|
Visibility: convertVisibilityToStorepb(create.Visibility),
|
||||||
|
Tags: create.Tags,
|
||||||
|
OgMetadata: &storepb.OpenGraphMetadata{},
|
||||||
|
}
|
||||||
|
if create.OpenGraphMetadata != nil {
|
||||||
|
shortcut.OgMetadata = &storepb.OpenGraphMetadata{
|
||||||
|
Title: create.OpenGraphMetadata.Title,
|
||||||
|
Description: create.OpenGraphMetadata.Description,
|
||||||
|
Image: create.OpenGraphMetadata.Image,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
shortcut, err := s.Store.CreateShortcut(ctx, shortcut)
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to create shortcut, err: %s", err)).SetInternal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.createShortcutCreateActivity(ctx, shortcut); err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to create shortcut activity, err: %s", err)).SetInternal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
shortcutMessage, err := s.composeShortcut(ctx, convertShortcutFromStorepb(shortcut))
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to compose shortcut, err: %s", err)).SetInternal(err)
|
||||||
|
}
|
||||||
|
metric.Enqueue("shortcut create")
|
||||||
|
return c.JSON(http.StatusOK, shortcutMessage)
|
||||||
|
})
|
||||||
|
|
||||||
|
g.PATCH("/shortcut/:shortcutId", func(c echo.Context) error {
|
||||||
|
ctx := c.Request().Context()
|
||||||
|
shortcutID, err := util.ConvertStringToInt32(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)
|
||||||
|
}
|
||||||
|
userID, ok := c.Get(userIDContextKey).(int32)
|
||||||
|
if !ok {
|
||||||
|
return echo.NewHTTPError(http.StatusUnauthorized, "missing user in session")
|
||||||
|
}
|
||||||
|
currentUser, 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
shortcut, err := s.Store.GetShortcut(ctx, &store.FindShortcut{
|
||||||
|
ID: &shortcutID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to find shortcut, err: %s", err)).SetInternal(err)
|
||||||
|
}
|
||||||
|
if shortcut == nil {
|
||||||
|
return echo.NewHTTPError(http.StatusNotFound, fmt.Sprintf("not found shortcut with id: %d", shortcutID))
|
||||||
|
}
|
||||||
|
if shortcut.CreatorId != userID && currentUser.Role != store.RoleAdmin {
|
||||||
|
return echo.NewHTTPError(http.StatusForbidden, "unauthorized to update shortcut")
|
||||||
|
}
|
||||||
|
|
||||||
|
patch := &PatchShortcutRequest{}
|
||||||
|
if err := json.NewDecoder(c.Request().Body).Decode(patch); err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("failed to decode patch shortcut request, err: %s", err)).SetInternal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
shortcutUpdate := &store.UpdateShortcut{
|
||||||
|
ID: shortcutID,
|
||||||
|
Name: patch.Name,
|
||||||
|
Link: patch.Link,
|
||||||
|
Title: patch.Title,
|
||||||
|
Description: patch.Description,
|
||||||
|
}
|
||||||
|
if patch.RowStatus != nil {
|
||||||
|
shortcutUpdate.RowStatus = (*store.RowStatus)(patch.RowStatus)
|
||||||
|
}
|
||||||
|
if patch.Visibility != nil {
|
||||||
|
shortcutUpdate.Visibility = (*store.Visibility)(patch.Visibility)
|
||||||
|
}
|
||||||
|
if patch.Tags != nil {
|
||||||
|
tag := strings.Join(patch.Tags, " ")
|
||||||
|
shortcutUpdate.Tag = &tag
|
||||||
|
}
|
||||||
|
if patch.OpenGraphMetadata != nil {
|
||||||
|
shortcutUpdate.OpenGraphMetadata = &store.OpenGraphMetadata{
|
||||||
|
Title: patch.OpenGraphMetadata.Title,
|
||||||
|
Description: patch.OpenGraphMetadata.Description,
|
||||||
|
Image: patch.OpenGraphMetadata.Image,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
shortcut, err = s.Store.UpdateShortcut(ctx, shortcutUpdate)
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to patch shortcut, err: %s", err)).SetInternal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
shortcutMessage, err := s.composeShortcut(ctx, convertShortcutFromStorepb(shortcut))
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to compose shortcut, err: %s", err)).SetInternal(err)
|
||||||
|
}
|
||||||
|
return c.JSON(http.StatusOK, shortcutMessage)
|
||||||
|
})
|
||||||
|
|
||||||
|
g.GET("/shortcut", func(c echo.Context) error {
|
||||||
|
ctx := c.Request().Context()
|
||||||
|
userID, ok := c.Get(userIDContextKey).(int32)
|
||||||
|
if !ok {
|
||||||
|
return echo.NewHTTPError(http.StatusUnauthorized, "missing user in session")
|
||||||
|
}
|
||||||
|
|
||||||
|
find := &store.FindShortcut{}
|
||||||
|
if tag := c.QueryParam("tag"); tag != "" {
|
||||||
|
find.Tag = &tag
|
||||||
|
}
|
||||||
|
|
||||||
|
list := []*storepb.Shortcut{}
|
||||||
|
find.VisibilityList = []store.Visibility{store.VisibilityWorkspace, store.VisibilityPublic}
|
||||||
|
visibleShortcutList, err := s.Store.ListShortcuts(ctx, find)
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to fetch shortcut list, err: %s", err)).SetInternal(err)
|
||||||
|
}
|
||||||
|
list = append(list, visibleShortcutList...)
|
||||||
|
|
||||||
|
find.VisibilityList = []store.Visibility{store.VisibilityPrivate}
|
||||||
|
find.CreatorID = &userID
|
||||||
|
privateShortcutList, err := s.Store.ListShortcuts(ctx, find)
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to fetch private shortcut list, err: %s", err)).SetInternal(err)
|
||||||
|
}
|
||||||
|
list = append(list, privateShortcutList...)
|
||||||
|
|
||||||
|
shortcutMessageList := []*Shortcut{}
|
||||||
|
for _, shortcut := range list {
|
||||||
|
shortcutMessage, err := s.composeShortcut(ctx, convertShortcutFromStorepb(shortcut))
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to compose shortcut, err: %s", err)).SetInternal(err)
|
||||||
|
}
|
||||||
|
shortcutMessageList = append(shortcutMessageList, shortcutMessage)
|
||||||
|
}
|
||||||
|
return c.JSON(http.StatusOK, shortcutMessageList)
|
||||||
|
})
|
||||||
|
|
||||||
|
g.GET("/shortcut/:id", func(c echo.Context) error {
|
||||||
|
ctx := c.Request().Context()
|
||||||
|
shortcutID, err := util.ConvertStringToInt32(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("shortcut id is not a number: %s", c.Param("id"))).SetInternal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
shortcut, err := s.Store.GetShortcut(ctx, &store.FindShortcut{
|
||||||
|
ID: &shortcutID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to fetch shortcut by id, err: %s", err)).SetInternal(err)
|
||||||
|
}
|
||||||
|
if shortcut == nil {
|
||||||
|
return echo.NewHTTPError(http.StatusNotFound, fmt.Sprintf("not found shortcut with id: %d", shortcutID))
|
||||||
|
}
|
||||||
|
|
||||||
|
shortcutMessage, err := s.composeShortcut(ctx, convertShortcutFromStorepb(shortcut))
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to compose shortcut, err: %s", err)).SetInternal(err)
|
||||||
|
}
|
||||||
|
return c.JSON(http.StatusOK, shortcutMessage)
|
||||||
|
})
|
||||||
|
|
||||||
|
g.DELETE("/shortcut/:id", func(c echo.Context) error {
|
||||||
|
ctx := c.Request().Context()
|
||||||
|
shortcutID, err := util.ConvertStringToInt32(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("shortcut id is not a number: %s", c.Param("id"))).SetInternal(err)
|
||||||
|
}
|
||||||
|
userID, ok := c.Get(userIDContextKey).(int32)
|
||||||
|
if !ok {
|
||||||
|
return echo.NewHTTPError(http.StatusUnauthorized, "missing user in session")
|
||||||
|
}
|
||||||
|
currentUser, 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
shortcut, err := s.Store.GetShortcut(ctx, &store.FindShortcut{
|
||||||
|
ID: &shortcutID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to fetch shortcut by id, err: %s", err)).SetInternal(err)
|
||||||
|
}
|
||||||
|
if shortcut == nil {
|
||||||
|
return echo.NewHTTPError(http.StatusNotFound, fmt.Sprintf("not found shortcut with id: %d", shortcutID))
|
||||||
|
}
|
||||||
|
if shortcut.CreatorId != userID && currentUser.Role != store.RoleAdmin {
|
||||||
|
return echo.NewHTTPError(http.StatusForbidden, "Unauthorized to delete shortcut")
|
||||||
|
}
|
||||||
|
|
||||||
|
err = s.Store.DeleteShortcut(ctx, &store.DeleteShortcut{ID: shortcutID})
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to delete shortcut, err: %s", err)).SetInternal(err)
|
||||||
|
}
|
||||||
|
return c.JSON(http.StatusOK, true)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *APIV1Service) composeShortcut(ctx context.Context, shortcut *Shortcut) (*Shortcut, error) {
|
||||||
|
if shortcut == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := s.Store.GetUser(ctx, &store.FindUser{
|
||||||
|
ID: &shortcut.CreatorID,
|
||||||
|
})
|
||||||
|
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{
|
||||||
|
Type: store.ActivityShortcutView,
|
||||||
|
Level: store.ActivityInfo,
|
||||||
|
Where: []string{fmt.Sprintf("json_extract(payload, '$.shortcutId') = %d", shortcut.ID)},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(err, "Failed to list activities")
|
||||||
|
}
|
||||||
|
shortcut.View = len(activityList)
|
||||||
|
|
||||||
|
return shortcut, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func convertShortcutFromStorepb(shortcut *storepb.Shortcut) *Shortcut {
|
||||||
|
return &Shortcut{
|
||||||
|
ID: shortcut.Id,
|
||||||
|
CreatedTs: shortcut.CreatedTs,
|
||||||
|
UpdatedTs: shortcut.UpdatedTs,
|
||||||
|
CreatorID: shortcut.CreatorId,
|
||||||
|
RowStatus: RowStatus(shortcut.RowStatus.String()),
|
||||||
|
Name: shortcut.Name,
|
||||||
|
Link: shortcut.Link,
|
||||||
|
Title: shortcut.Title,
|
||||||
|
Description: shortcut.Description,
|
||||||
|
Visibility: Visibility(shortcut.Visibility.String()),
|
||||||
|
Tags: shortcut.Tags,
|
||||||
|
OpenGraphMetadata: &OpenGraphMetadata{
|
||||||
|
Title: shortcut.OgMetadata.Title,
|
||||||
|
Description: shortcut.OgMetadata.Description,
|
||||||
|
Image: shortcut.OgMetadata.Image,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func convertVisibilityToStorepb(visibility Visibility) storepb.Visibility {
|
||||||
|
switch visibility {
|
||||||
|
case VisibilityPublic:
|
||||||
|
return storepb.Visibility_PUBLIC
|
||||||
|
case VisibilityWorkspace:
|
||||||
|
return storepb.Visibility_WORKSPACE
|
||||||
|
case VisibilityPrivate:
|
||||||
|
return storepb.Visibility_PRIVATE
|
||||||
|
default:
|
||||||
|
return storepb.Visibility_PUBLIC
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *APIV1Service) createShortcutCreateActivity(ctx context.Context, shortcut *storepb.Shortcut) error {
|
||||||
|
payload := &ActivityShorcutCreatePayload{
|
||||||
|
ShortcutID: shortcut.Id,
|
||||||
|
}
|
||||||
|
payloadStr, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "Failed to marshal activity payload")
|
||||||
|
}
|
||||||
|
activity := &store.Activity{
|
||||||
|
CreatorID: shortcut.CreatorId,
|
||||||
|
Type: store.ActivityShortcutCreate,
|
||||||
|
Level: store.ActivityInfo,
|
||||||
|
Payload: string(payloadStr),
|
||||||
|
}
|
||||||
|
_, err = s.Store.CreateActivity(ctx, activity)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "Failed to create activity")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
340
api/v1/user.go
Normal file
340
api/v1/user.go
Normal file
@ -0,0 +1,340 @@
|
|||||||
|
package v1
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/mail"
|
||||||
|
|
||||||
|
"github.com/labstack/echo/v4"
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
|
||||||
|
"github.com/boojack/slash/internal/util"
|
||||||
|
"github.com/boojack/slash/server/metric"
|
||||||
|
"github.com/boojack/slash/server/service/license"
|
||||||
|
"github.com/boojack/slash/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// BotID is the id of bot.
|
||||||
|
BotID = 0
|
||||||
|
)
|
||||||
|
|
||||||
|
// Role is the type of a role.
|
||||||
|
type Role string
|
||||||
|
|
||||||
|
const (
|
||||||
|
// RoleAdmin is the ADMIN role.
|
||||||
|
RoleAdmin Role = "ADMIN"
|
||||||
|
// RoleUser is the USER role.
|
||||||
|
RoleUser Role = "USER"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (r Role) String() string {
|
||||||
|
switch r {
|
||||||
|
case RoleAdmin:
|
||||||
|
return "ADMIN"
|
||||||
|
case RoleUser:
|
||||||
|
return "USER"
|
||||||
|
}
|
||||||
|
return "USER"
|
||||||
|
}
|
||||||
|
|
||||||
|
type User struct {
|
||||||
|
ID int32 `json:"id"`
|
||||||
|
|
||||||
|
// Standard fields
|
||||||
|
CreatedTs int64 `json:"createdTs"`
|
||||||
|
UpdatedTs int64 `json:"updatedTs"`
|
||||||
|
RowStatus RowStatus `json:"rowStatus"`
|
||||||
|
|
||||||
|
// Domain specific fields
|
||||||
|
Email string `json:"email"`
|
||||||
|
Nickname string `json:"nickname"`
|
||||||
|
Role Role `json:"role"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CreateUserRequest struct {
|
||||||
|
Email string `json:"email"`
|
||||||
|
Nickname string `json:"nickname"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
Role Role `json:"role"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (create CreateUserRequest) Validate() error {
|
||||||
|
if create.Email != "" && !validateEmail(create.Email) {
|
||||||
|
return errors.New("invalid email format")
|
||||||
|
}
|
||||||
|
if create.Nickname != "" && len(create.Nickname) < 3 {
|
||||||
|
return errors.New("nickname is too short, minimum length is 3")
|
||||||
|
}
|
||||||
|
if len(create.Password) < 3 {
|
||||||
|
return errors.New("password is too short, minimum length is 3")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type PatchUserRequest struct {
|
||||||
|
RowStatus *RowStatus `json:"rowStatus"`
|
||||||
|
Email *string `json:"email"`
|
||||||
|
Nickname *string `json:"nickname"`
|
||||||
|
Password *string `json:"password"`
|
||||||
|
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(userIDContextKey).(int32)
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !s.LicenseService.IsFeatureEnabled(license.FeatureTypeUnlimitedAccounts) {
|
||||||
|
userList, err := s.Store.ListUsers(ctx, &store.FindUser{})
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to list users").SetInternal(err)
|
||||||
|
}
|
||||||
|
if len(userList) >= 5 {
|
||||||
|
return echo.NewHTTPError(http.StatusBadRequest, "Maximum number of users reached")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
metric.Enqueue("user create")
|
||||||
|
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{})
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Failed to list users, err: %s", err)).SetInternal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
userList := []*User{}
|
||||||
|
for _, user := range list {
|
||||||
|
userList = append(userList, convertUserFromStore(user))
|
||||||
|
}
|
||||||
|
return c.JSON(http.StatusOK, userList)
|
||||||
|
})
|
||||||
|
|
||||||
|
// GET /api/user/me is used to check if the user is logged in.
|
||||||
|
g.GET("/user/me", func(c echo.Context) error {
|
||||||
|
ctx := c.Request().Context()
|
||||||
|
userID, ok := c.Get(userIDContextKey).(int32)
|
||||||
|
if !ok {
|
||||||
|
return echo.NewHTTPError(http.StatusUnauthorized, "missing auth session")
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(http.StatusOK, convertUserFromStore(user))
|
||||||
|
})
|
||||||
|
|
||||||
|
g.GET("/user/:id", func(c echo.Context) error {
|
||||||
|
ctx := c.Request().Context()
|
||||||
|
userID, err := util.ConvertStringToInt32(c.Param("id"))
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
userMessage := convertUserFromStore(user)
|
||||||
|
userID, ok := c.Get(userIDContextKey).(int32)
|
||||||
|
if !ok {
|
||||||
|
userMessage.Email = ""
|
||||||
|
}
|
||||||
|
return c.JSON(http.StatusOK, userMessage)
|
||||||
|
})
|
||||||
|
|
||||||
|
g.PATCH("/user/:id", func(c echo.Context) error {
|
||||||
|
ctx := c.Request().Context()
|
||||||
|
userID, err := util.ConvertStringToInt32(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("user id is not a number: %s", c.Param("id"))).SetInternal(err)
|
||||||
|
}
|
||||||
|
currentUserID, ok := c.Get(userIDContextKey).(int32)
|
||||||
|
if !ok {
|
||||||
|
return echo.NewHTTPError(http.StatusUnauthorized, "missing user in session")
|
||||||
|
}
|
||||||
|
currentUser, err := s.Store.GetUser(ctx, &store.FindUser{
|
||||||
|
ID: ¤tUserID,
|
||||||
|
})
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
userPatch := &PatchUserRequest{}
|
||||||
|
if err := json.NewDecoder(c.Request().Body).Decode(userPatch); err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("failed to decode request body, err: %s", err)).SetInternal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
updateUser := &store.UpdateUser{
|
||||||
|
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 {
|
||||||
|
updateUser.Nickname = userPatch.Nickname
|
||||||
|
}
|
||||||
|
if userPatch.Password != nil && *userPatch.Password != "" {
|
||||||
|
passwordHash, err := bcrypt.GenerateFromPassword([]byte(*userPatch.Password), bcrypt.DefaultCost)
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to hash password, err: %s", err)).SetInternal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
passwordHashStr := string(passwordHash)
|
||||||
|
updateUser.PasswordHash = &passwordHashStr
|
||||||
|
}
|
||||||
|
if userPatch.RowStatus != nil {
|
||||||
|
rowStatus := store.RowStatus(*userPatch.RowStatus)
|
||||||
|
updateUser.RowStatus = &rowStatus
|
||||||
|
}
|
||||||
|
if userPatch.Role != nil {
|
||||||
|
adminRole := store.RoleAdmin
|
||||||
|
adminUsers, err := s.Store.ListUsers(ctx, &store.FindUser{
|
||||||
|
Role: &adminRole,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to list admin users, err: %s", err)).SetInternal(err)
|
||||||
|
}
|
||||||
|
if len(adminUsers) == 1 && adminUsers[0].ID == userID && *userPatch.Role != RoleAdmin {
|
||||||
|
return echo.NewHTTPError(http.StatusBadRequest, "cannot remove admin role from the last admin user")
|
||||||
|
}
|
||||||
|
role := store.Role(*userPatch.Role)
|
||||||
|
updateUser.Role = &role
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := s.Store.UpdateUser(ctx, updateUser)
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Failed to update user, err: %s", err)).SetInternal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(http.StatusOK, convertUserFromStore(user))
|
||||||
|
})
|
||||||
|
|
||||||
|
g.DELETE("/user/:id", func(c echo.Context) error {
|
||||||
|
ctx := c.Request().Context()
|
||||||
|
currentUserID, ok := c.Get(userIDContextKey).(int32)
|
||||||
|
if !ok {
|
||||||
|
return echo.NewHTTPError(http.StatusUnauthorized, "missing user in session")
|
||||||
|
}
|
||||||
|
currentUser, err := s.Store.GetUser(ctx, &store.FindUser{
|
||||||
|
ID: ¤tUserID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to find current session user, err: %s", err)).SetInternal(err)
|
||||||
|
}
|
||||||
|
if currentUser == nil {
|
||||||
|
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("current session user not found with ID: %d", currentUserID)).SetInternal(err)
|
||||||
|
}
|
||||||
|
if currentUser.Role != store.RoleAdmin {
|
||||||
|
return echo.NewHTTPError(http.StatusForbidden, "access forbidden for current session user").SetInternal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
userID, err := util.ConvertStringToInt32(c.Param("id"))
|
||||||
|
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,
|
||||||
|
}); err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Failed to delete user, err: %s", err)).SetInternal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(http.StatusOK, true)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateEmail validates the email.
|
||||||
|
func validateEmail(email string) bool {
|
||||||
|
if _, err := mail.ParseAddress(email); err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// convertUserFromStore converts a store user to a user.
|
||||||
|
func convertUserFromStore(user *store.User) *User {
|
||||||
|
return &User{
|
||||||
|
ID: user.ID,
|
||||||
|
CreatedTs: user.CreatedTs,
|
||||||
|
UpdatedTs: user.UpdatedTs,
|
||||||
|
RowStatus: RowStatus(user.RowStatus),
|
||||||
|
Email: user.Email,
|
||||||
|
Nickname: user.Nickname,
|
||||||
|
Role: Role(user.Role),
|
||||||
|
}
|
||||||
|
}
|
67
api/v1/user_setting.go
Normal file
67
api/v1/user_setting.go
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
package v1
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
)
|
||||||
|
|
||||||
|
type UserSettingKey string
|
||||||
|
|
||||||
|
const (
|
||||||
|
// UserSettingLocaleKey is the key type for user locale.
|
||||||
|
UserSettingLocaleKey UserSettingKey = "locale"
|
||||||
|
)
|
||||||
|
|
||||||
|
// String returns the string format of UserSettingKey type.
|
||||||
|
func (k UserSettingKey) String() string {
|
||||||
|
return string(k)
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
UserSettingLocaleValue = []string{"en", "zh"}
|
||||||
|
)
|
||||||
|
|
||||||
|
type UserSetting struct {
|
||||||
|
UserID int
|
||||||
|
Key UserSettingKey `json:"key"`
|
||||||
|
// Value is a JSON string with basic value.
|
||||||
|
Value string `json:"value"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type UserSettingUpsert struct {
|
||||||
|
UserID int
|
||||||
|
Key UserSettingKey `json:"key"`
|
||||||
|
Value string `json:"value"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (upsert UserSettingUpsert) Validate() error {
|
||||||
|
if upsert.Key == UserSettingLocaleKey {
|
||||||
|
localeValue := "en"
|
||||||
|
err := json.Unmarshal([]byte(upsert.Value), &localeValue)
|
||||||
|
if err != nil {
|
||||||
|
return errors.New("failed to unmarshal user setting locale value")
|
||||||
|
}
|
||||||
|
|
||||||
|
invalid := true
|
||||||
|
for _, value := range UserSettingLocaleValue {
|
||||||
|
if localeValue == value {
|
||||||
|
invalid = false
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if invalid {
|
||||||
|
return errors.New("invalid user setting locale value")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return errors.New("invalid user setting key")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type UserSettingFind struct {
|
||||||
|
UserID int
|
||||||
|
|
||||||
|
Key *UserSettingKey `json:"key"`
|
||||||
|
}
|
41
api/v1/v1.go
Normal file
41
api/v1/v1.go
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
package v1
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/labstack/echo/v4"
|
||||||
|
|
||||||
|
"github.com/boojack/slash/server/profile"
|
||||||
|
"github.com/boojack/slash/server/service/license"
|
||||||
|
"github.com/boojack/slash/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
type APIV1Service struct {
|
||||||
|
Profile *profile.Profile
|
||||||
|
Store *store.Store
|
||||||
|
LicenseService *license.LicenseService
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewAPIV1Service(profile *profile.Profile, store *store.Store, licenseService *license.LicenseService) *APIV1Service {
|
||||||
|
return &APIV1Service{
|
||||||
|
Profile: profile,
|
||||||
|
Store: store,
|
||||||
|
LicenseService: licenseService,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *APIV1Service) Start(apiGroup *echo.Group, secret string) {
|
||||||
|
apiV1Group := apiGroup.Group("/api/v1")
|
||||||
|
apiV1Group.Use(func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||||
|
return JWTMiddleware(s, next, secret)
|
||||||
|
})
|
||||||
|
s.registerWorkspaceRoutes(apiV1Group)
|
||||||
|
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 {
|
||||||
|
return JWTMiddleware(s, next, secret)
|
||||||
|
})
|
||||||
|
s.registerRedirectorRoutes(redirectorGroup)
|
||||||
|
}
|
39
api/v1/workspace.go
Normal file
39
api/v1/workspace.go
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
package v1
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/labstack/echo/v4"
|
||||||
|
|
||||||
|
storepb "github.com/boojack/slash/proto/gen/store"
|
||||||
|
"github.com/boojack/slash/server/profile"
|
||||||
|
"github.com/boojack/slash/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
type WorkspaceProfile struct {
|
||||||
|
Profile *profile.Profile `json:"profile"`
|
||||||
|
DisallowSignUp bool `json:"disallowSignUp"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *APIV1Service) registerWorkspaceRoutes(g *echo.Group) {
|
||||||
|
g.GET("/workspace/profile", func(c echo.Context) error {
|
||||||
|
ctx := c.Request().Context()
|
||||||
|
workspaceProfile := WorkspaceProfile{
|
||||||
|
Profile: s.Profile,
|
||||||
|
DisallowSignUp: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
enableSignUpSetting, err := s.Store.GetWorkspaceSetting(ctx, &store.FindWorkspaceSetting{
|
||||||
|
Key: storepb.WorkspaceSettingKey_WORKSAPCE_SETTING_ENABLE_SIGNUP,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to find workspace setting, err: %s", err)).SetInternal(err)
|
||||||
|
}
|
||||||
|
if enableSignUpSetting != nil {
|
||||||
|
workspaceProfile.DisallowSignUp = !enableSignUpSetting.GetEnableSignup()
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(http.StatusOK, workspaceProfile)
|
||||||
|
})
|
||||||
|
}
|
@ -1,21 +1,21 @@
|
|||||||
package v1
|
package v2
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/golang-jwt/jwt/v5"
|
"github.com/golang-jwt/jwt/v4"
|
||||||
"github.com/pkg/errors"
|
"github.com/pkg/errors"
|
||||||
"google.golang.org/grpc"
|
"google.golang.org/grpc"
|
||||||
"google.golang.org/grpc/codes"
|
"google.golang.org/grpc/codes"
|
||||||
"google.golang.org/grpc/metadata"
|
"google.golang.org/grpc/metadata"
|
||||||
"google.golang.org/grpc/status"
|
"google.golang.org/grpc/status"
|
||||||
|
|
||||||
"github.com/yourselfhosted/slash/internal/util"
|
"github.com/boojack/slash/api/auth"
|
||||||
storepb "github.com/yourselfhosted/slash/proto/gen/store"
|
"github.com/boojack/slash/internal/util"
|
||||||
"github.com/yourselfhosted/slash/server/route/auth"
|
storepb "github.com/boojack/slash/proto/gen/store"
|
||||||
"github.com/yourselfhosted/slash/store"
|
"github.com/boojack/slash/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ContextKey is the key type of context value.
|
// ContextKey is the key type of context value.
|
29
api/v2/acl_config.go
Normal file
29
api/v2/acl_config.go
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
package v2
|
||||||
|
|
||||||
|
import "strings"
|
||||||
|
|
||||||
|
var allowedMethodsWhenUnauthorized = map[string]bool{
|
||||||
|
"/slash.api.v2.WorkspaceService/GetWorkspaceProfile": true,
|
||||||
|
"/slash.api.v2.WorkspaceService/GetWorkspaceSetting": true,
|
||||||
|
"/slash.api.v2.ShortcutService/GetShortcut": true,
|
||||||
|
"/slash.api.v2.CollectionService/GetCollectionByName": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// isUnauthorizeAllowedMethod returns true if the method is allowed to be called when the user is not authorized.
|
||||||
|
func isUnauthorizeAllowedMethod(methodName string) bool {
|
||||||
|
if strings.HasPrefix(methodName, "/grpc.reflection") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return allowedMethodsWhenUnauthorized[methodName]
|
||||||
|
}
|
||||||
|
|
||||||
|
var allowedMethodsOnlyForAdmin = map[string]bool{
|
||||||
|
"/slash.api.v2.UserService/CreateUser": true,
|
||||||
|
"/slash.api.v2.UserService/DeleteUser": true,
|
||||||
|
"/slash.api.v2.WorkspaceService/UpdateWorkspaceSetting": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// isOnlyForAdminAllowedMethod returns true if the method is allowed to be called only by admin.
|
||||||
|
func isOnlyForAdminAllowedMethod(methodName string) bool {
|
||||||
|
return allowedMethodsOnlyForAdmin[methodName]
|
||||||
|
}
|
@ -1,4 +1,4 @@
|
|||||||
package v1
|
package v2
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
@ -8,51 +8,33 @@ import (
|
|||||||
"google.golang.org/grpc/status"
|
"google.golang.org/grpc/status"
|
||||||
"google.golang.org/protobuf/types/known/timestamppb"
|
"google.golang.org/protobuf/types/known/timestamppb"
|
||||||
|
|
||||||
apiv1pb "github.com/yourselfhosted/slash/proto/gen/api/v1"
|
apiv2pb "github.com/boojack/slash/proto/gen/api/v2"
|
||||||
storepb "github.com/yourselfhosted/slash/proto/gen/store"
|
storepb "github.com/boojack/slash/proto/gen/store"
|
||||||
"github.com/yourselfhosted/slash/server/metric"
|
"github.com/boojack/slash/server/metric"
|
||||||
"github.com/yourselfhosted/slash/server/service/license"
|
"github.com/boojack/slash/store"
|
||||||
"github.com/yourselfhosted/slash/store"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func (s *APIV1Service) ListCollections(ctx context.Context, _ *apiv1pb.ListCollectionsRequest) (*apiv1pb.ListCollectionsResponse, error) {
|
func (s *APIV2Service) ListCollections(ctx context.Context, _ *apiv2pb.ListCollectionsRequest) (*apiv2pb.ListCollectionsResponse, error) {
|
||||||
user, err := getCurrentUser(ctx, s.Store)
|
userID := ctx.Value(userIDContextKey).(int32)
|
||||||
if err != nil {
|
find := &store.FindCollection{}
|
||||||
return nil, status.Errorf(codes.Unauthenticated, "failed to get current user: %v", err)
|
find.CreatorID = &userID
|
||||||
}
|
collections, err := s.Store.ListCollections(ctx, find)
|
||||||
collections, err := s.Store.ListCollections(ctx, &store.FindCollection{
|
|
||||||
CreatorID: &user.ID,
|
|
||||||
VisibilityList: []store.Visibility{
|
|
||||||
store.VisibilityPrivate,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, status.Errorf(codes.Internal, "failed to get collection list, err: %v", err)
|
return nil, status.Errorf(codes.Internal, "failed to get collection list, err: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
sharedCollections, err := s.Store.ListCollections(ctx, &store.FindCollection{
|
convertedCollections := []*apiv2pb.Collection{}
|
||||||
VisibilityList: []store.Visibility{
|
|
||||||
store.VisibilityWorkspace,
|
|
||||||
store.VisibilityPublic,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return nil, status.Errorf(codes.Internal, "failed to get collection list, err: %v", err)
|
|
||||||
}
|
|
||||||
collections = append(collections, sharedCollections...)
|
|
||||||
|
|
||||||
convertedCollections := []*apiv1pb.Collection{}
|
|
||||||
for _, collection := range collections {
|
for _, collection := range collections {
|
||||||
convertedCollections = append(convertedCollections, convertCollectionFromStore(collection))
|
convertedCollections = append(convertedCollections, convertCollectionFromStore(collection))
|
||||||
}
|
}
|
||||||
|
|
||||||
response := &apiv1pb.ListCollectionsResponse{
|
response := &apiv2pb.ListCollectionsResponse{
|
||||||
Collections: convertedCollections,
|
Collections: convertedCollections,
|
||||||
}
|
}
|
||||||
return response, nil
|
return response, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *APIV1Service) GetCollection(ctx context.Context, request *apiv1pb.GetCollectionRequest) (*apiv1pb.GetCollectionResponse, error) {
|
func (s *APIV2Service) GetCollection(ctx context.Context, request *apiv2pb.GetCollectionRequest) (*apiv2pb.GetCollectionResponse, error) {
|
||||||
collection, err := s.Store.GetCollection(ctx, &store.FindCollection{
|
collection, err := s.Store.GetCollection(ctx, &store.FindCollection{
|
||||||
ID: &request.Id,
|
ID: &request.Id,
|
||||||
})
|
})
|
||||||
@ -63,20 +45,17 @@ func (s *APIV1Service) GetCollection(ctx context.Context, request *apiv1pb.GetCo
|
|||||||
return nil, status.Errorf(codes.NotFound, "collection not found")
|
return nil, status.Errorf(codes.NotFound, "collection not found")
|
||||||
}
|
}
|
||||||
|
|
||||||
user, err := getCurrentUser(ctx, s.Store)
|
userID := ctx.Value(userIDContextKey).(int32)
|
||||||
if err != nil {
|
if collection.Visibility == storepb.Visibility_PRIVATE && collection.CreatorId != userID {
|
||||||
return nil, status.Errorf(codes.Unauthenticated, "failed to get current user: %v", err)
|
|
||||||
}
|
|
||||||
if collection.Visibility == storepb.Visibility_PRIVATE && collection.CreatorId != user.ID {
|
|
||||||
return nil, status.Errorf(codes.PermissionDenied, "Permission denied")
|
return nil, status.Errorf(codes.PermissionDenied, "Permission denied")
|
||||||
}
|
}
|
||||||
response := &apiv1pb.GetCollectionResponse{
|
response := &apiv2pb.GetCollectionResponse{
|
||||||
Collection: convertCollectionFromStore(collection),
|
Collection: convertCollectionFromStore(collection),
|
||||||
}
|
}
|
||||||
return response, nil
|
return response, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *APIV1Service) GetCollectionByName(ctx context.Context, request *apiv1pb.GetCollectionByNameRequest) (*apiv1pb.GetCollectionByNameResponse, error) {
|
func (s *APIV2Service) GetCollectionByName(ctx context.Context, request *apiv2pb.GetCollectionByNameRequest) (*apiv2pb.GetCollectionByNameResponse, error) {
|
||||||
collection, err := s.Store.GetCollection(ctx, &store.FindCollection{
|
collection, err := s.Store.GetCollection(ctx, &store.FindCollection{
|
||||||
Name: &request.Name,
|
Name: &request.Name,
|
||||||
})
|
})
|
||||||
@ -97,61 +76,46 @@ func (s *APIV1Service) GetCollectionByName(ctx context.Context, request *apiv1pb
|
|||||||
return nil, status.Errorf(codes.PermissionDenied, "Permission denied")
|
return nil, status.Errorf(codes.PermissionDenied, "Permission denied")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
response := &apiv1pb.GetCollectionByNameResponse{
|
response := &apiv2pb.GetCollectionByNameResponse{
|
||||||
Collection: convertCollectionFromStore(collection),
|
Collection: convertCollectionFromStore(collection),
|
||||||
}
|
}
|
||||||
|
metric.Enqueue("collection view")
|
||||||
return response, nil
|
return response, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *APIV1Service) CreateCollection(ctx context.Context, request *apiv1pb.CreateCollectionRequest) (*apiv1pb.CreateCollectionResponse, error) {
|
func (s *APIV2Service) CreateCollection(ctx context.Context, request *apiv2pb.CreateCollectionRequest) (*apiv2pb.CreateCollectionResponse, error) {
|
||||||
if request.Collection.Name == "" || request.Collection.Title == "" {
|
userID := ctx.Value(userIDContextKey).(int32)
|
||||||
return nil, status.Errorf(codes.InvalidArgument, "name and title are required")
|
collection := &storepb.Collection{
|
||||||
}
|
CreatorId: userID,
|
||||||
|
|
||||||
if !s.LicenseService.IsFeatureEnabled(license.FeatureTypeUnlimitedAccounts) {
|
|
||||||
collections, err := s.Store.ListCollections(ctx, &store.FindCollection{
|
|
||||||
VisibilityList: []store.Visibility{store.VisibilityWorkspace, store.VisibilityPublic},
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return nil, status.Errorf(codes.Internal, "failed to get collection list, err: %v", err)
|
|
||||||
}
|
|
||||||
if len(collections) >= 5 {
|
|
||||||
return nil, status.Errorf(codes.PermissionDenied, "Maximum number of collections reached")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
user, err := getCurrentUser(ctx, s.Store)
|
|
||||||
if err != nil {
|
|
||||||
return nil, status.Errorf(codes.Unauthenticated, "failed to get current user: %v", err)
|
|
||||||
}
|
|
||||||
collectionCreate := &storepb.Collection{
|
|
||||||
CreatorId: user.ID,
|
|
||||||
Name: request.Collection.Name,
|
Name: request.Collection.Name,
|
||||||
Title: request.Collection.Title,
|
Title: request.Collection.Title,
|
||||||
Description: request.Collection.Description,
|
Description: request.Collection.Description,
|
||||||
ShortcutIds: request.Collection.ShortcutIds,
|
ShortcutIds: request.Collection.ShortcutIds,
|
||||||
Visibility: storepb.Visibility(request.Collection.Visibility),
|
Visibility: storepb.Visibility(request.Collection.Visibility),
|
||||||
}
|
}
|
||||||
collection, err := s.Store.CreateCollection(ctx, collectionCreate)
|
collection, err := s.Store.CreateCollection(ctx, collection)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, status.Errorf(codes.Internal, "failed to create collection, err: %v", err)
|
return nil, status.Errorf(codes.Internal, "failed to create collection, err: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
response := &apiv1pb.CreateCollectionResponse{
|
response := &apiv2pb.CreateCollectionResponse{
|
||||||
Collection: convertCollectionFromStore(collection),
|
Collection: convertCollectionFromStore(collection),
|
||||||
}
|
}
|
||||||
metric.Enqueue("collection create")
|
metric.Enqueue("collection create")
|
||||||
return response, nil
|
return response, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *APIV1Service) UpdateCollection(ctx context.Context, request *apiv1pb.UpdateCollectionRequest) (*apiv1pb.UpdateCollectionResponse, error) {
|
func (s *APIV2Service) UpdateCollection(ctx context.Context, request *apiv2pb.UpdateCollectionRequest) (*apiv2pb.UpdateCollectionResponse, error) {
|
||||||
if request.UpdateMask == nil || len(request.UpdateMask.Paths) == 0 {
|
if request.UpdateMask == nil || len(request.UpdateMask.Paths) == 0 {
|
||||||
return nil, status.Errorf(codes.InvalidArgument, "updateMask is required")
|
return nil, status.Errorf(codes.InvalidArgument, "updateMask is required")
|
||||||
}
|
}
|
||||||
|
|
||||||
user, err := getCurrentUser(ctx, s.Store)
|
userID := ctx.Value(userIDContextKey).(int32)
|
||||||
|
currentUser, err := s.Store.GetUser(ctx, &store.FindUser{
|
||||||
|
ID: &userID,
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, status.Errorf(codes.Unauthenticated, "failed to get current user: %v", err)
|
return nil, status.Errorf(codes.Internal, "failed to get current user, err: %v", err)
|
||||||
}
|
}
|
||||||
collection, err := s.Store.GetCollection(ctx, &store.FindCollection{
|
collection, err := s.Store.GetCollection(ctx, &store.FindCollection{
|
||||||
ID: &request.Collection.Id,
|
ID: &request.Collection.Id,
|
||||||
@ -162,7 +126,7 @@ func (s *APIV1Service) UpdateCollection(ctx context.Context, request *apiv1pb.Up
|
|||||||
if collection == nil {
|
if collection == nil {
|
||||||
return nil, status.Errorf(codes.NotFound, "collection not found")
|
return nil, status.Errorf(codes.NotFound, "collection not found")
|
||||||
}
|
}
|
||||||
if collection.CreatorId != user.ID && user.Role != store.RoleAdmin {
|
if collection.CreatorId != userID && currentUser.Role != store.RoleAdmin {
|
||||||
return nil, status.Errorf(codes.PermissionDenied, "Permission denied")
|
return nil, status.Errorf(codes.PermissionDenied, "Permission denied")
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -189,16 +153,19 @@ func (s *APIV1Service) UpdateCollection(ctx context.Context, request *apiv1pb.Up
|
|||||||
return nil, status.Errorf(codes.Internal, "failed to update collection, err: %v", err)
|
return nil, status.Errorf(codes.Internal, "failed to update collection, err: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
response := &apiv1pb.UpdateCollectionResponse{
|
response := &apiv2pb.UpdateCollectionResponse{
|
||||||
Collection: convertCollectionFromStore(collection),
|
Collection: convertCollectionFromStore(collection),
|
||||||
}
|
}
|
||||||
return response, nil
|
return response, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *APIV1Service) DeleteCollection(ctx context.Context, request *apiv1pb.DeleteCollectionRequest) (*apiv1pb.DeleteCollectionResponse, error) {
|
func (s *APIV2Service) DeleteCollection(ctx context.Context, request *apiv2pb.DeleteCollectionRequest) (*apiv2pb.DeleteCollectionResponse, error) {
|
||||||
user, err := getCurrentUser(ctx, s.Store)
|
userID := ctx.Value(userIDContextKey).(int32)
|
||||||
|
currentUser, err := s.Store.GetUser(ctx, &store.FindUser{
|
||||||
|
ID: &userID,
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, status.Errorf(codes.Unauthenticated, "failed to get current user: %v", err)
|
return nil, status.Errorf(codes.Internal, "failed to get current user, err: %v", err)
|
||||||
}
|
}
|
||||||
collection, err := s.Store.GetCollection(ctx, &store.FindCollection{
|
collection, err := s.Store.GetCollection(ctx, &store.FindCollection{
|
||||||
ID: &request.Id,
|
ID: &request.Id,
|
||||||
@ -209,7 +176,7 @@ func (s *APIV1Service) DeleteCollection(ctx context.Context, request *apiv1pb.De
|
|||||||
if collection == nil {
|
if collection == nil {
|
||||||
return nil, status.Errorf(codes.NotFound, "collection not found")
|
return nil, status.Errorf(codes.NotFound, "collection not found")
|
||||||
}
|
}
|
||||||
if collection.CreatorId != user.ID && user.Role != store.RoleAdmin {
|
if collection.CreatorId != userID && currentUser.Role != store.RoleAdmin {
|
||||||
return nil, status.Errorf(codes.PermissionDenied, "Permission denied")
|
return nil, status.Errorf(codes.PermissionDenied, "Permission denied")
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -219,12 +186,12 @@ func (s *APIV1Service) DeleteCollection(ctx context.Context, request *apiv1pb.De
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, status.Errorf(codes.Internal, "failed to delete collection, err: %v", err)
|
return nil, status.Errorf(codes.Internal, "failed to delete collection, err: %v", err)
|
||||||
}
|
}
|
||||||
response := &apiv1pb.DeleteCollectionResponse{}
|
response := &apiv2pb.DeleteCollectionResponse{}
|
||||||
return response, nil
|
return response, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func convertCollectionFromStore(collection *storepb.Collection) *apiv1pb.Collection {
|
func convertCollectionFromStore(collection *storepb.Collection) *apiv2pb.Collection {
|
||||||
return &apiv1pb.Collection{
|
return &apiv2pb.Collection{
|
||||||
Id: collection.Id,
|
Id: collection.Id,
|
||||||
CreatorId: collection.CreatorId,
|
CreatorId: collection.CreatorId,
|
||||||
CreatedTime: timestamppb.New(time.Unix(collection.CreatedTs, 0)),
|
CreatedTime: timestamppb.New(time.Unix(collection.CreatedTs, 0)),
|
||||||
@ -233,6 +200,6 @@ func convertCollectionFromStore(collection *storepb.Collection) *apiv1pb.Collect
|
|||||||
Title: collection.Title,
|
Title: collection.Title,
|
||||||
Description: collection.Description,
|
Description: collection.Description,
|
||||||
ShortcutIds: collection.ShortcutIds,
|
ShortcutIds: collection.ShortcutIds,
|
||||||
Visibility: apiv1pb.Visibility(collection.Visibility),
|
Visibility: apiv2pb.Visibility(collection.Visibility),
|
||||||
}
|
}
|
||||||
}
|
}
|
17
api/v2/common.go
Normal file
17
api/v2/common.go
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
package v2
|
||||||
|
|
||||||
|
import (
|
||||||
|
apiv2pb "github.com/boojack/slash/proto/gen/api/v2"
|
||||||
|
"github.com/boojack/slash/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
func convertRowStatusFromStore(rowStatus store.RowStatus) apiv2pb.RowStatus {
|
||||||
|
switch rowStatus {
|
||||||
|
case store.Normal:
|
||||||
|
return apiv2pb.RowStatus_NORMAL
|
||||||
|
case store.Archived:
|
||||||
|
return apiv2pb.RowStatus_ARCHIVED
|
||||||
|
default:
|
||||||
|
return apiv2pb.RowStatus_ROW_STATUS_UNSPECIFIED
|
||||||
|
}
|
||||||
|
}
|
269
api/v2/shortcut_service.go
Normal file
269
api/v2/shortcut_service.go
Normal file
@ -0,0 +1,269 @@
|
|||||||
|
package v2
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
"google.golang.org/grpc/codes"
|
||||||
|
"google.golang.org/grpc/status"
|
||||||
|
"google.golang.org/protobuf/encoding/protojson"
|
||||||
|
"google.golang.org/protobuf/types/known/timestamppb"
|
||||||
|
|
||||||
|
apiv2pb "github.com/boojack/slash/proto/gen/api/v2"
|
||||||
|
storepb "github.com/boojack/slash/proto/gen/store"
|
||||||
|
"github.com/boojack/slash/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *APIV2Service) ListShortcuts(ctx context.Context, _ *apiv2pb.ListShortcutsRequest) (*apiv2pb.ListShortcutsResponse, error) {
|
||||||
|
userID := ctx.Value(userIDContextKey).(int32)
|
||||||
|
find := &store.FindShortcut{}
|
||||||
|
find.VisibilityList = []store.Visibility{store.VisibilityWorkspace, store.VisibilityPublic}
|
||||||
|
visibleShortcutList, err := s.Store.ListShortcuts(ctx, find)
|
||||||
|
if err != nil {
|
||||||
|
return nil, status.Errorf(codes.Internal, "failed to fetch visible shortcut list, err: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
find.VisibilityList = []store.Visibility{store.VisibilityPrivate}
|
||||||
|
find.CreatorID = &userID
|
||||||
|
shortcutList, err := s.Store.ListShortcuts(ctx, find)
|
||||||
|
if err != nil {
|
||||||
|
return nil, status.Errorf(codes.Internal, "failed to fetch private shortcut list, err: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
shortcutList = append(shortcutList, visibleShortcutList...)
|
||||||
|
shortcuts := []*apiv2pb.Shortcut{}
|
||||||
|
for _, shortcut := range shortcutList {
|
||||||
|
composedShortcut, err := s.convertShortcutFromStorepb(ctx, shortcut)
|
||||||
|
if err != nil {
|
||||||
|
return nil, status.Errorf(codes.Internal, "failed to convert shortcut, err: %v", err)
|
||||||
|
}
|
||||||
|
shortcuts = append(shortcuts, composedShortcut)
|
||||||
|
}
|
||||||
|
|
||||||
|
response := &apiv2pb.ListShortcutsResponse{
|
||||||
|
Shortcuts: shortcuts,
|
||||||
|
}
|
||||||
|
return response, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *APIV2Service) GetShortcut(ctx context.Context, request *apiv2pb.GetShortcutRequest) (*apiv2pb.GetShortcutResponse, error) {
|
||||||
|
shortcut, err := s.Store.GetShortcut(ctx, &store.FindShortcut{
|
||||||
|
ID: &request.Id,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, status.Errorf(codes.Internal, "failed to get shortcut by name: %v", err)
|
||||||
|
}
|
||||||
|
if shortcut == nil {
|
||||||
|
return nil, status.Errorf(codes.NotFound, "shortcut not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
userID, ok := ctx.Value(userIDContextKey).(int32)
|
||||||
|
if ok {
|
||||||
|
if shortcut.Visibility == storepb.Visibility_PRIVATE && shortcut.CreatorId != userID {
|
||||||
|
return nil, status.Errorf(codes.PermissionDenied, "Permission denied")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if shortcut.Visibility != storepb.Visibility_PUBLIC {
|
||||||
|
return nil, status.Errorf(codes.PermissionDenied, "Permission denied")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
composedShortcut, err := s.convertShortcutFromStorepb(ctx, shortcut)
|
||||||
|
if err != nil {
|
||||||
|
return nil, status.Errorf(codes.Internal, "failed to convert shortcut, err: %v", err)
|
||||||
|
}
|
||||||
|
response := &apiv2pb.GetShortcutResponse{
|
||||||
|
Shortcut: composedShortcut,
|
||||||
|
}
|
||||||
|
return response, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *APIV2Service) CreateShortcut(ctx context.Context, request *apiv2pb.CreateShortcutRequest) (*apiv2pb.CreateShortcutResponse, error) {
|
||||||
|
userID := ctx.Value(userIDContextKey).(int32)
|
||||||
|
shortcut := &storepb.Shortcut{
|
||||||
|
CreatorId: userID,
|
||||||
|
Name: request.Shortcut.Name,
|
||||||
|
Link: request.Shortcut.Link,
|
||||||
|
Title: request.Shortcut.Title,
|
||||||
|
Tags: request.Shortcut.Tags,
|
||||||
|
Description: request.Shortcut.Description,
|
||||||
|
Visibility: storepb.Visibility(request.Shortcut.Visibility),
|
||||||
|
OgMetadata: &storepb.OpenGraphMetadata{},
|
||||||
|
}
|
||||||
|
if request.Shortcut.OgMetadata != nil {
|
||||||
|
shortcut.OgMetadata = &storepb.OpenGraphMetadata{
|
||||||
|
Title: request.Shortcut.OgMetadata.Title,
|
||||||
|
Description: request.Shortcut.OgMetadata.Description,
|
||||||
|
Image: request.Shortcut.OgMetadata.Image,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
shortcut, err := s.Store.CreateShortcut(ctx, shortcut)
|
||||||
|
if err != nil {
|
||||||
|
return nil, status.Errorf(codes.Internal, "failed to create shortcut, err: %v", err)
|
||||||
|
}
|
||||||
|
if err := s.createShortcutCreateActivity(ctx, shortcut); err != nil {
|
||||||
|
return nil, status.Errorf(codes.Internal, "failed to create activity, err: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
composedShortcut, err := s.convertShortcutFromStorepb(ctx, shortcut)
|
||||||
|
if err != nil {
|
||||||
|
return nil, status.Errorf(codes.Internal, "failed to convert shortcut, err: %v", err)
|
||||||
|
}
|
||||||
|
response := &apiv2pb.CreateShortcutResponse{
|
||||||
|
Shortcut: composedShortcut,
|
||||||
|
}
|
||||||
|
return response, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *APIV2Service) UpdateShortcut(ctx context.Context, request *apiv2pb.UpdateShortcutRequest) (*apiv2pb.UpdateShortcutResponse, error) {
|
||||||
|
if request.UpdateMask == nil || len(request.UpdateMask.Paths) == 0 {
|
||||||
|
return nil, status.Errorf(codes.InvalidArgument, "updateMask is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
userID := ctx.Value(userIDContextKey).(int32)
|
||||||
|
currentUser, err := s.Store.GetUser(ctx, &store.FindUser{
|
||||||
|
ID: &userID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, status.Errorf(codes.Internal, "failed to get current user, err: %v", err)
|
||||||
|
}
|
||||||
|
shortcut, err := s.Store.GetShortcut(ctx, &store.FindShortcut{
|
||||||
|
ID: &request.Shortcut.Id,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, status.Errorf(codes.Internal, "failed to get shortcut by name: %v", err)
|
||||||
|
}
|
||||||
|
if shortcut == nil {
|
||||||
|
return nil, status.Errorf(codes.NotFound, "shortcut not found")
|
||||||
|
}
|
||||||
|
if shortcut.CreatorId != userID && currentUser.Role != store.RoleAdmin {
|
||||||
|
return nil, status.Errorf(codes.PermissionDenied, "Permission denied")
|
||||||
|
}
|
||||||
|
|
||||||
|
update := &store.UpdateShortcut{}
|
||||||
|
for _, path := range request.UpdateMask.Paths {
|
||||||
|
switch path {
|
||||||
|
case "link":
|
||||||
|
update.Link = &request.Shortcut.Link
|
||||||
|
case "title":
|
||||||
|
update.Title = &request.Shortcut.Title
|
||||||
|
case "tags":
|
||||||
|
tag := strings.Join(request.Shortcut.Tags, " ")
|
||||||
|
update.Tag = &tag
|
||||||
|
case "description":
|
||||||
|
update.Description = &request.Shortcut.Description
|
||||||
|
case "visibility":
|
||||||
|
visibility := store.Visibility(request.Shortcut.Visibility.String())
|
||||||
|
update.Visibility = &visibility
|
||||||
|
case "og_metadata":
|
||||||
|
if request.Shortcut.OgMetadata != nil {
|
||||||
|
update.OpenGraphMetadata = &store.OpenGraphMetadata{
|
||||||
|
Title: request.Shortcut.OgMetadata.Title,
|
||||||
|
Description: request.Shortcut.OgMetadata.Description,
|
||||||
|
Image: request.Shortcut.OgMetadata.Image,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
shortcut, err = s.Store.UpdateShortcut(ctx, update)
|
||||||
|
if err != nil {
|
||||||
|
return nil, status.Errorf(codes.Internal, "failed to update shortcut, err: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
composedShortcut, err := s.convertShortcutFromStorepb(ctx, shortcut)
|
||||||
|
if err != nil {
|
||||||
|
return nil, status.Errorf(codes.Internal, "failed to convert shortcut, err: %v", err)
|
||||||
|
}
|
||||||
|
response := &apiv2pb.UpdateShortcutResponse{
|
||||||
|
Shortcut: composedShortcut,
|
||||||
|
}
|
||||||
|
return response, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *APIV2Service) DeleteShortcut(ctx context.Context, request *apiv2pb.DeleteShortcutRequest) (*apiv2pb.DeleteShortcutResponse, error) {
|
||||||
|
userID := ctx.Value(userIDContextKey).(int32)
|
||||||
|
currentUser, err := s.Store.GetUser(ctx, &store.FindUser{
|
||||||
|
ID: &userID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, status.Errorf(codes.Internal, "failed to get current user, err: %v", err)
|
||||||
|
}
|
||||||
|
shortcut, err := s.Store.GetShortcut(ctx, &store.FindShortcut{
|
||||||
|
ID: &request.Id,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, status.Errorf(codes.Internal, "failed to get shortcut by name: %v", err)
|
||||||
|
}
|
||||||
|
if shortcut == nil {
|
||||||
|
return nil, status.Errorf(codes.NotFound, "shortcut not found")
|
||||||
|
}
|
||||||
|
if shortcut.CreatorId != userID && currentUser.Role != store.RoleAdmin {
|
||||||
|
return nil, status.Errorf(codes.PermissionDenied, "Permission denied")
|
||||||
|
}
|
||||||
|
|
||||||
|
err = s.Store.DeleteShortcut(ctx, &store.DeleteShortcut{
|
||||||
|
ID: shortcut.Id,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, status.Errorf(codes.Internal, "failed to delete shortcut, err: %v", err)
|
||||||
|
}
|
||||||
|
response := &apiv2pb.DeleteShortcutResponse{}
|
||||||
|
return response, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *APIV2Service) createShortcutCreateActivity(ctx context.Context, shortcut *storepb.Shortcut) error {
|
||||||
|
payload := &storepb.ActivityShorcutCreatePayload{
|
||||||
|
ShortcutId: shortcut.Id,
|
||||||
|
}
|
||||||
|
payloadStr, err := protojson.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "Failed to marshal activity payload")
|
||||||
|
}
|
||||||
|
activity := &store.Activity{
|
||||||
|
CreatorID: shortcut.CreatorId,
|
||||||
|
Type: store.ActivityShortcutCreate,
|
||||||
|
Level: store.ActivityInfo,
|
||||||
|
Payload: string(payloadStr),
|
||||||
|
}
|
||||||
|
_, err = s.Store.CreateActivity(ctx, activity)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "Failed to create activity")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *APIV2Service) convertShortcutFromStorepb(ctx context.Context, shortcut *storepb.Shortcut) (*apiv2pb.Shortcut, error) {
|
||||||
|
composedShortcut := &apiv2pb.Shortcut{
|
||||||
|
Id: shortcut.Id,
|
||||||
|
CreatorId: shortcut.CreatorId,
|
||||||
|
CreatedTime: timestamppb.New(time.Unix(shortcut.CreatedTs, 0)),
|
||||||
|
UpdatedTime: timestamppb.New(time.Unix(shortcut.UpdatedTs, 0)),
|
||||||
|
RowStatus: apiv2pb.RowStatus(shortcut.RowStatus),
|
||||||
|
Name: shortcut.Name,
|
||||||
|
Link: shortcut.Link,
|
||||||
|
Title: shortcut.Title,
|
||||||
|
Tags: shortcut.Tags,
|
||||||
|
Description: shortcut.Description,
|
||||||
|
Visibility: apiv2pb.Visibility(shortcut.Visibility),
|
||||||
|
OgMetadata: &apiv2pb.OpenGraphMetadata{
|
||||||
|
Title: shortcut.OgMetadata.Title,
|
||||||
|
Description: shortcut.OgMetadata.Description,
|
||||||
|
Image: shortcut.OgMetadata.Image,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
activityList, err := s.Store.ListActivities(ctx, &store.FindActivity{
|
||||||
|
Type: store.ActivityShortcutView,
|
||||||
|
Level: store.ActivityInfo,
|
||||||
|
Where: []string{fmt.Sprintf("json_extract(payload, '$.shortcutId') = %d", composedShortcut.Id)},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(err, "Failed to list activities")
|
||||||
|
}
|
||||||
|
composedShortcut.ViewCount = int32(len(activityList))
|
||||||
|
|
||||||
|
return composedShortcut, nil
|
||||||
|
}
|
@ -1,4 +1,4 @@
|
|||||||
package v1
|
package v2
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
@ -6,25 +6,25 @@ import (
|
|||||||
"google.golang.org/grpc/codes"
|
"google.golang.org/grpc/codes"
|
||||||
"google.golang.org/grpc/status"
|
"google.golang.org/grpc/status"
|
||||||
|
|
||||||
apiv1pb "github.com/yourselfhosted/slash/proto/gen/api/v1"
|
apiv2pb "github.com/boojack/slash/proto/gen/api/v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (s *APIV1Service) GetSubscription(ctx context.Context, _ *apiv1pb.GetSubscriptionRequest) (*apiv1pb.GetSubscriptionResponse, error) {
|
func (s *APIV2Service) GetSubscription(ctx context.Context, _ *apiv2pb.GetSubscriptionRequest) (*apiv2pb.GetSubscriptionResponse, error) {
|
||||||
subscription, err := s.LicenseService.LoadSubscription(ctx)
|
subscription, err := s.LicenseService.LoadSubscription(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, status.Errorf(codes.Internal, "failed to load subscription: %v", err)
|
return nil, status.Errorf(codes.Internal, "failed to load subscription: %v", err)
|
||||||
}
|
}
|
||||||
return &apiv1pb.GetSubscriptionResponse{
|
return &apiv2pb.GetSubscriptionResponse{
|
||||||
Subscription: subscription,
|
Subscription: subscription,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *APIV1Service) UpdateSubscription(ctx context.Context, request *apiv1pb.UpdateSubscriptionRequest) (*apiv1pb.UpdateSubscriptionResponse, error) {
|
func (s *APIV2Service) UpdateSubscription(ctx context.Context, request *apiv2pb.UpdateSubscriptionRequest) (*apiv2pb.UpdateSubscriptionResponse, error) {
|
||||||
subscription, err := s.LicenseService.UpdateSubscription(ctx, request.LicenseKey)
|
subscription, err := s.LicenseService.UpdateSubscription(ctx, request.LicenseKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, status.Errorf(codes.Internal, "failed to load subscription: %v", err)
|
return nil, status.Errorf(codes.Internal, "failed to load subscription: %v", err)
|
||||||
}
|
}
|
||||||
return &apiv1pb.UpdateSubscriptionResponse{
|
return &apiv2pb.UpdateSubscriptionResponse{
|
||||||
Subscription: subscription,
|
Subscription: subscription,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
@ -1,10 +1,10 @@
|
|||||||
package v1
|
package v2
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/golang-jwt/jwt/v5"
|
"github.com/golang-jwt/jwt/v4"
|
||||||
"github.com/pkg/errors"
|
"github.com/pkg/errors"
|
||||||
"golang.org/x/crypto/bcrypt"
|
"golang.org/x/crypto/bcrypt"
|
||||||
"golang.org/x/exp/slices"
|
"golang.org/x/exp/slices"
|
||||||
@ -12,35 +12,30 @@ import (
|
|||||||
"google.golang.org/grpc/status"
|
"google.golang.org/grpc/status"
|
||||||
"google.golang.org/protobuf/types/known/timestamppb"
|
"google.golang.org/protobuf/types/known/timestamppb"
|
||||||
|
|
||||||
apiv1pb "github.com/yourselfhosted/slash/proto/gen/api/v1"
|
"github.com/boojack/slash/api/auth"
|
||||||
storepb "github.com/yourselfhosted/slash/proto/gen/store"
|
apiv2pb "github.com/boojack/slash/proto/gen/api/v2"
|
||||||
"github.com/yourselfhosted/slash/server/route/auth"
|
storepb "github.com/boojack/slash/proto/gen/store"
|
||||||
"github.com/yourselfhosted/slash/server/service/license"
|
"github.com/boojack/slash/server/service/license"
|
||||||
"github.com/yourselfhosted/slash/store"
|
"github.com/boojack/slash/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
func (s *APIV2Service) ListUsers(ctx context.Context, _ *apiv2pb.ListUsersRequest) (*apiv2pb.ListUsersResponse, error) {
|
||||||
// BotID is the id of bot.
|
|
||||||
BotID = 0
|
|
||||||
)
|
|
||||||
|
|
||||||
func (s *APIV1Service) ListUsers(ctx context.Context, _ *apiv1pb.ListUsersRequest) (*apiv1pb.ListUsersResponse, error) {
|
|
||||||
users, err := s.Store.ListUsers(ctx, &store.FindUser{})
|
users, err := s.Store.ListUsers(ctx, &store.FindUser{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, status.Errorf(codes.Internal, "failed to list users: %v", err)
|
return nil, status.Errorf(codes.Internal, "failed to list users: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
userMessages := []*apiv1pb.User{}
|
userMessages := []*apiv2pb.User{}
|
||||||
for _, user := range users {
|
for _, user := range users {
|
||||||
userMessages = append(userMessages, convertUserFromStore(user))
|
userMessages = append(userMessages, convertUserFromStore(user))
|
||||||
}
|
}
|
||||||
response := &apiv1pb.ListUsersResponse{
|
response := &apiv2pb.ListUsersResponse{
|
||||||
Users: userMessages,
|
Users: userMessages,
|
||||||
}
|
}
|
||||||
return response, nil
|
return response, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *APIV1Service) GetUser(ctx context.Context, request *apiv1pb.GetUserRequest) (*apiv1pb.GetUserResponse, error) {
|
func (s *APIV2Service) GetUser(ctx context.Context, request *apiv2pb.GetUserRequest) (*apiv2pb.GetUserResponse, error) {
|
||||||
user, err := s.Store.GetUser(ctx, &store.FindUser{
|
user, err := s.Store.GetUser(ctx, &store.FindUser{
|
||||||
ID: &request.Id,
|
ID: &request.Id,
|
||||||
})
|
})
|
||||||
@ -52,13 +47,13 @@ func (s *APIV1Service) GetUser(ctx context.Context, request *apiv1pb.GetUserRequ
|
|||||||
}
|
}
|
||||||
|
|
||||||
userMessage := convertUserFromStore(user)
|
userMessage := convertUserFromStore(user)
|
||||||
response := &apiv1pb.GetUserResponse{
|
response := &apiv2pb.GetUserResponse{
|
||||||
User: userMessage,
|
User: userMessage,
|
||||||
}
|
}
|
||||||
return response, nil
|
return response, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *APIV1Service) CreateUser(ctx context.Context, request *apiv1pb.CreateUserRequest) (*apiv1pb.CreateUserResponse, error) {
|
func (s *APIV2Service) CreateUser(ctx context.Context, request *apiv2pb.CreateUserRequest) (*apiv2pb.CreateUserResponse, error) {
|
||||||
passwordHash, err := bcrypt.GenerateFromPassword([]byte(request.User.Password), bcrypt.DefaultCost)
|
passwordHash, err := bcrypt.GenerateFromPassword([]byte(request.User.Password), bcrypt.DefaultCost)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, status.Errorf(codes.Internal, "failed to hash password: %v", err)
|
return nil, status.Errorf(codes.Internal, "failed to hash password: %v", err)
|
||||||
@ -83,18 +78,15 @@ func (s *APIV1Service) CreateUser(ctx context.Context, request *apiv1pb.CreateUs
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, status.Errorf(codes.Internal, "failed to create user: %v", err)
|
return nil, status.Errorf(codes.Internal, "failed to create user: %v", err)
|
||||||
}
|
}
|
||||||
response := &apiv1pb.CreateUserResponse{
|
response := &apiv2pb.CreateUserResponse{
|
||||||
User: convertUserFromStore(user),
|
User: convertUserFromStore(user),
|
||||||
}
|
}
|
||||||
return response, nil
|
return response, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *APIV1Service) UpdateUser(ctx context.Context, request *apiv1pb.UpdateUserRequest) (*apiv1pb.UpdateUserResponse, error) {
|
func (s *APIV2Service) UpdateUser(ctx context.Context, request *apiv2pb.UpdateUserRequest) (*apiv2pb.UpdateUserResponse, error) {
|
||||||
user, err := getCurrentUser(ctx, s.Store)
|
userID := ctx.Value(userIDContextKey).(int32)
|
||||||
if err != nil {
|
if userID != request.User.Id {
|
||||||
return nil, status.Errorf(codes.Unauthenticated, "failed to get current user: %v", err)
|
|
||||||
}
|
|
||||||
if user.ID != request.User.Id {
|
|
||||||
return nil, status.Errorf(codes.PermissionDenied, "Permission denied")
|
return nil, status.Errorf(codes.PermissionDenied, "Permission denied")
|
||||||
}
|
}
|
||||||
if request.UpdateMask == nil || len(request.UpdateMask.Paths) == 0 {
|
if request.UpdateMask == nil || len(request.UpdateMask.Paths) == 0 {
|
||||||
@ -111,46 +103,43 @@ func (s *APIV1Service) UpdateUser(ctx context.Context, request *apiv1pb.UpdateUs
|
|||||||
userUpdate.Nickname = &request.User.Nickname
|
userUpdate.Nickname = &request.User.Nickname
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
user, err = s.Store.UpdateUser(ctx, userUpdate)
|
user, err := s.Store.UpdateUser(ctx, userUpdate)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, status.Errorf(codes.Internal, "failed to update user: %v", err)
|
return nil, status.Errorf(codes.Internal, "failed to update user: %v", err)
|
||||||
}
|
}
|
||||||
return &apiv1pb.UpdateUserResponse{
|
return &apiv2pb.UpdateUserResponse{
|
||||||
User: convertUserFromStore(user),
|
User: convertUserFromStore(user),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *APIV1Service) DeleteUser(ctx context.Context, request *apiv1pb.DeleteUserRequest) (*apiv1pb.DeleteUserResponse, error) {
|
func (s *APIV2Service) DeleteUser(ctx context.Context, request *apiv2pb.DeleteUserRequest) (*apiv2pb.DeleteUserResponse, error) {
|
||||||
user, err := getCurrentUser(ctx, s.Store)
|
userID := ctx.Value(userIDContextKey).(int32)
|
||||||
if err != nil {
|
if userID == request.Id {
|
||||||
return nil, status.Errorf(codes.Unauthenticated, "failed to get current user: %v", err)
|
|
||||||
}
|
|
||||||
if user.ID == request.Id {
|
|
||||||
return nil, status.Errorf(codes.InvalidArgument, "cannot delete yourself")
|
return nil, status.Errorf(codes.InvalidArgument, "cannot delete yourself")
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := s.Store.DeleteUser(ctx, &store.DeleteUser{ID: request.Id}); err != nil {
|
err := s.Store.DeleteUser(ctx, &store.DeleteUser{
|
||||||
|
ID: request.Id,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
return nil, status.Errorf(codes.Internal, "failed to delete user: %v", err)
|
return nil, status.Errorf(codes.Internal, "failed to delete user: %v", err)
|
||||||
}
|
}
|
||||||
response := &apiv1pb.DeleteUserResponse{}
|
response := &apiv2pb.DeleteUserResponse{}
|
||||||
return response, nil
|
return response, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *APIV1Service) ListUserAccessTokens(ctx context.Context, request *apiv1pb.ListUserAccessTokensRequest) (*apiv1pb.ListUserAccessTokensResponse, error) {
|
func (s *APIV2Service) ListUserAccessTokens(ctx context.Context, request *apiv2pb.ListUserAccessTokensRequest) (*apiv2pb.ListUserAccessTokensResponse, error) {
|
||||||
user, err := getCurrentUser(ctx, s.Store)
|
userID := ctx.Value(userIDContextKey).(int32)
|
||||||
if err != nil {
|
if userID != request.Id {
|
||||||
return nil, status.Errorf(codes.Unauthenticated, "failed to get current user: %v", err)
|
|
||||||
}
|
|
||||||
if user.ID != request.Id {
|
|
||||||
return nil, status.Errorf(codes.PermissionDenied, "Permission denied")
|
return nil, status.Errorf(codes.PermissionDenied, "Permission denied")
|
||||||
}
|
}
|
||||||
|
|
||||||
userAccessTokens, err := s.Store.GetUserAccessTokens(ctx, user.ID)
|
userAccessTokens, err := s.Store.GetUserAccessTokens(ctx, userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, status.Errorf(codes.Internal, "failed to list access tokens: %v", err)
|
return nil, status.Errorf(codes.Internal, "failed to list access tokens: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
accessTokens := []*apiv1pb.UserAccessToken{}
|
accessTokens := []*apiv2pb.UserAccessToken{}
|
||||||
for _, userAccessToken := range userAccessTokens {
|
for _, userAccessToken := range userAccessTokens {
|
||||||
claims := &auth.ClaimsMessage{}
|
claims := &auth.ClaimsMessage{}
|
||||||
_, err := jwt.ParseWithClaims(userAccessToken.AccessToken, claims, func(t *jwt.Token) (any, error) {
|
_, err := jwt.ParseWithClaims(userAccessToken.AccessToken, claims, func(t *jwt.Token) (any, error) {
|
||||||
@ -169,7 +158,7 @@ func (s *APIV1Service) ListUserAccessTokens(ctx context.Context, request *apiv1p
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
userAccessToken := &apiv1pb.UserAccessToken{
|
userAccessToken := &apiv2pb.UserAccessToken{
|
||||||
AccessToken: userAccessToken.AccessToken,
|
AccessToken: userAccessToken.AccessToken,
|
||||||
Description: userAccessToken.Description,
|
Description: userAccessToken.Description,
|
||||||
IssuedAt: timestamppb.New(claims.IssuedAt.Time),
|
IssuedAt: timestamppb.New(claims.IssuedAt.Time),
|
||||||
@ -181,24 +170,31 @@ func (s *APIV1Service) ListUserAccessTokens(ctx context.Context, request *apiv1p
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Sort by issued time in descending order.
|
// Sort by issued time in descending order.
|
||||||
slices.SortFunc(accessTokens, func(i, j *apiv1pb.UserAccessToken) int {
|
slices.SortFunc(accessTokens, func(i, j *apiv2pb.UserAccessToken) int {
|
||||||
return int(i.IssuedAt.Seconds - j.IssuedAt.Seconds)
|
return int(i.IssuedAt.Seconds - j.IssuedAt.Seconds)
|
||||||
})
|
})
|
||||||
response := &apiv1pb.ListUserAccessTokensResponse{
|
response := &apiv2pb.ListUserAccessTokensResponse{
|
||||||
AccessTokens: accessTokens,
|
AccessTokens: accessTokens,
|
||||||
}
|
}
|
||||||
return response, nil
|
return response, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *APIV1Service) CreateUserAccessToken(ctx context.Context, request *apiv1pb.CreateUserAccessTokenRequest) (*apiv1pb.CreateUserAccessTokenResponse, error) {
|
func (s *APIV2Service) CreateUserAccessToken(ctx context.Context, request *apiv2pb.CreateUserAccessTokenRequest) (*apiv2pb.CreateUserAccessTokenResponse, error) {
|
||||||
user, err := getCurrentUser(ctx, s.Store)
|
userID := ctx.Value(userIDContextKey).(int32)
|
||||||
if err != nil {
|
if userID != request.Id {
|
||||||
return nil, status.Errorf(codes.Unauthenticated, "failed to get current user: %v", err)
|
|
||||||
}
|
|
||||||
if user.ID != request.Id {
|
|
||||||
return nil, status.Errorf(codes.PermissionDenied, "Permission denied")
|
return nil, status.Errorf(codes.PermissionDenied, "Permission denied")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
user, err := s.Store.GetUser(ctx, &store.FindUser{
|
||||||
|
ID: &userID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, status.Errorf(codes.Internal, "failed to get user: %v", err)
|
||||||
|
}
|
||||||
|
if user == nil {
|
||||||
|
return nil, status.Errorf(codes.NotFound, "user not found")
|
||||||
|
}
|
||||||
|
|
||||||
expiresAt := time.Time{}
|
expiresAt := time.Time{}
|
||||||
if request.ExpiresAt != nil {
|
if request.ExpiresAt != nil {
|
||||||
expiresAt = request.ExpiresAt.AsTime()
|
expiresAt = request.ExpiresAt.AsTime()
|
||||||
@ -229,7 +225,7 @@ func (s *APIV1Service) CreateUserAccessToken(ctx context.Context, request *apiv1
|
|||||||
return nil, status.Errorf(codes.Internal, "failed to upsert access token to store: %v", err)
|
return nil, status.Errorf(codes.Internal, "failed to upsert access token to store: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
userAccessToken := &apiv1pb.UserAccessToken{
|
userAccessToken := &apiv2pb.UserAccessToken{
|
||||||
AccessToken: accessToken,
|
AccessToken: accessToken,
|
||||||
Description: request.Description,
|
Description: request.Description,
|
||||||
IssuedAt: timestamppb.New(claims.IssuedAt.Time),
|
IssuedAt: timestamppb.New(claims.IssuedAt.Time),
|
||||||
@ -237,18 +233,19 @@ func (s *APIV1Service) CreateUserAccessToken(ctx context.Context, request *apiv1
|
|||||||
if claims.ExpiresAt != nil {
|
if claims.ExpiresAt != nil {
|
||||||
userAccessToken.ExpiresAt = timestamppb.New(claims.ExpiresAt.Time)
|
userAccessToken.ExpiresAt = timestamppb.New(claims.ExpiresAt.Time)
|
||||||
}
|
}
|
||||||
response := &apiv1pb.CreateUserAccessTokenResponse{
|
response := &apiv2pb.CreateUserAccessTokenResponse{
|
||||||
AccessToken: userAccessToken,
|
AccessToken: userAccessToken,
|
||||||
}
|
}
|
||||||
return response, nil
|
return response, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *APIV1Service) DeleteUserAccessToken(ctx context.Context, request *apiv1pb.DeleteUserAccessTokenRequest) (*apiv1pb.DeleteUserAccessTokenResponse, error) {
|
func (s *APIV2Service) DeleteUserAccessToken(ctx context.Context, request *apiv2pb.DeleteUserAccessTokenRequest) (*apiv2pb.DeleteUserAccessTokenResponse, error) {
|
||||||
user, err := getCurrentUser(ctx, s.Store)
|
userID := ctx.Value(userIDContextKey).(int32)
|
||||||
if err != nil {
|
if userID != request.Id {
|
||||||
return nil, status.Errorf(codes.Unauthenticated, "failed to get current user: %v", err)
|
return nil, status.Errorf(codes.PermissionDenied, "Permission denied")
|
||||||
}
|
}
|
||||||
userAccessTokens, err := s.Store.GetUserAccessTokens(ctx, user.ID)
|
|
||||||
|
userAccessTokens, err := s.Store.GetUserAccessTokens(ctx, userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, status.Errorf(codes.Internal, "failed to list access tokens: %v", err)
|
return nil, status.Errorf(codes.Internal, "failed to list access tokens: %v", err)
|
||||||
}
|
}
|
||||||
@ -260,7 +257,7 @@ func (s *APIV1Service) DeleteUserAccessToken(ctx context.Context, request *apiv1
|
|||||||
updatedUserAccessTokens = append(updatedUserAccessTokens, userAccessToken)
|
updatedUserAccessTokens = append(updatedUserAccessTokens, userAccessToken)
|
||||||
}
|
}
|
||||||
if _, err := s.Store.UpsertUserSetting(ctx, &storepb.UserSetting{
|
if _, err := s.Store.UpsertUserSetting(ctx, &storepb.UserSetting{
|
||||||
UserId: user.ID,
|
UserId: userID,
|
||||||
Key: storepb.UserSettingKey_USER_SETTING_ACCESS_TOKENS,
|
Key: storepb.UserSettingKey_USER_SETTING_ACCESS_TOKENS,
|
||||||
Value: &storepb.UserSetting_AccessTokens{
|
Value: &storepb.UserSetting_AccessTokens{
|
||||||
AccessTokens: &storepb.AccessTokensUserSetting{
|
AccessTokens: &storepb.AccessTokensUserSetting{
|
||||||
@ -271,10 +268,10 @@ func (s *APIV1Service) DeleteUserAccessToken(ctx context.Context, request *apiv1
|
|||||||
return nil, status.Errorf(codes.Internal, "failed to upsert user setting: %v", err)
|
return nil, status.Errorf(codes.Internal, "failed to upsert user setting: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return &apiv1pb.DeleteUserAccessTokenResponse{}, nil
|
return &apiv2pb.DeleteUserAccessTokenResponse{}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *APIV1Service) UpsertAccessTokenToStore(ctx context.Context, user *store.User, accessToken, description string) error {
|
func (s *APIV2Service) UpsertAccessTokenToStore(ctx context.Context, user *store.User, accessToken, description string) error {
|
||||||
userAccessTokens, err := s.Store.GetUserAccessTokens(ctx, user.ID)
|
userAccessTokens, err := s.Store.GetUserAccessTokens(ctx, user.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errors.Wrap(err, "failed to get user access tokens")
|
return errors.Wrap(err, "failed to get user access tokens")
|
||||||
@ -298,8 +295,8 @@ func (s *APIV1Service) UpsertAccessTokenToStore(ctx context.Context, user *store
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func convertUserFromStore(user *store.User) *apiv1pb.User {
|
func convertUserFromStore(user *store.User) *apiv2pb.User {
|
||||||
return &apiv1pb.User{
|
return &apiv2pb.User{
|
||||||
Id: int32(user.ID),
|
Id: int32(user.ID),
|
||||||
RowStatus: convertRowStatusFromStore(user.RowStatus),
|
RowStatus: convertRowStatusFromStore(user.RowStatus),
|
||||||
CreatedTime: timestamppb.New(time.Unix(user.CreatedTs, 0)),
|
CreatedTime: timestamppb.New(time.Unix(user.CreatedTs, 0)),
|
||||||
@ -310,13 +307,13 @@ func convertUserFromStore(user *store.User) *apiv1pb.User {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func convertUserRoleFromStore(role store.Role) apiv1pb.Role {
|
func convertUserRoleFromStore(role store.Role) apiv2pb.Role {
|
||||||
switch role {
|
switch role {
|
||||||
case store.RoleAdmin:
|
case store.RoleAdmin:
|
||||||
return apiv1pb.Role_ADMIN
|
return apiv2pb.Role_ADMIN
|
||||||
case store.RoleUser:
|
case store.RoleUser:
|
||||||
return apiv1pb.Role_USER
|
return apiv2pb.Role_USER
|
||||||
default:
|
default:
|
||||||
return apiv1pb.Role_ROLE_UNSPECIFIED
|
return apiv2pb.Role_ROLE_UNSPECIFIED
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -1,4 +1,4 @@
|
|||||||
package v1
|
package v2
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
@ -7,34 +7,31 @@ import (
|
|||||||
"google.golang.org/grpc/codes"
|
"google.golang.org/grpc/codes"
|
||||||
"google.golang.org/grpc/status"
|
"google.golang.org/grpc/status"
|
||||||
|
|
||||||
apiv1pb "github.com/yourselfhosted/slash/proto/gen/api/v1"
|
apiv2pb "github.com/boojack/slash/proto/gen/api/v2"
|
||||||
storepb "github.com/yourselfhosted/slash/proto/gen/store"
|
storepb "github.com/boojack/slash/proto/gen/store"
|
||||||
"github.com/yourselfhosted/slash/store"
|
"github.com/boojack/slash/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (s *APIV1Service) GetUserSetting(ctx context.Context, request *apiv1pb.GetUserSettingRequest) (*apiv1pb.GetUserSettingResponse, error) {
|
func (s *APIV2Service) GetUserSetting(ctx context.Context, request *apiv2pb.GetUserSettingRequest) (*apiv2pb.GetUserSettingResponse, error) {
|
||||||
userSetting, err := getUserSetting(ctx, s.Store, request.Id)
|
userSetting, err := getUserSetting(ctx, s.Store, request.Id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, status.Errorf(codes.Internal, "failed to get user setting: %v", err)
|
return nil, status.Errorf(codes.Internal, "failed to get user setting: %v", err)
|
||||||
}
|
}
|
||||||
return &apiv1pb.GetUserSettingResponse{
|
return &apiv2pb.GetUserSettingResponse{
|
||||||
UserSetting: userSetting,
|
UserSetting: userSetting,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *APIV1Service) UpdateUserSetting(ctx context.Context, request *apiv1pb.UpdateUserSettingRequest) (*apiv1pb.UpdateUserSettingResponse, error) {
|
func (s *APIV2Service) UpdateUserSetting(ctx context.Context, request *apiv2pb.UpdateUserSettingRequest) (*apiv2pb.UpdateUserSettingResponse, error) {
|
||||||
if request.UpdateMask == nil || len(request.UpdateMask.Paths) == 0 {
|
if request.UpdateMask == nil || len(request.UpdateMask.Paths) == 0 {
|
||||||
return nil, status.Errorf(codes.InvalidArgument, "update mask is empty")
|
return nil, status.Errorf(codes.InvalidArgument, "update mask is empty")
|
||||||
}
|
}
|
||||||
|
|
||||||
user, err := getCurrentUser(ctx, s.Store)
|
userID := ctx.Value(userIDContextKey).(int32)
|
||||||
if err != nil {
|
|
||||||
return nil, status.Errorf(codes.Unauthenticated, "failed to get current user: %v", err)
|
|
||||||
}
|
|
||||||
for _, path := range request.UpdateMask.Paths {
|
for _, path := range request.UpdateMask.Paths {
|
||||||
if path == "locale" {
|
if path == "locale" {
|
||||||
if _, err := s.Store.UpsertUserSetting(ctx, &storepb.UserSetting{
|
if _, err := s.Store.UpsertUserSetting(ctx, &storepb.UserSetting{
|
||||||
UserId: user.ID,
|
UserId: userID,
|
||||||
Key: storepb.UserSettingKey_USER_SETTING_LOCALE,
|
Key: storepb.UserSettingKey_USER_SETTING_LOCALE,
|
||||||
Value: &storepb.UserSetting_Locale{
|
Value: &storepb.UserSetting_Locale{
|
||||||
Locale: convertUserSettingLocaleToStore(request.UserSetting.Locale),
|
Locale: convertUserSettingLocaleToStore(request.UserSetting.Locale),
|
||||||
@ -44,7 +41,7 @@ func (s *APIV1Service) UpdateUserSetting(ctx context.Context, request *apiv1pb.U
|
|||||||
}
|
}
|
||||||
} else if path == "color_theme" {
|
} else if path == "color_theme" {
|
||||||
if _, err := s.Store.UpsertUserSetting(ctx, &storepb.UserSetting{
|
if _, err := s.Store.UpsertUserSetting(ctx, &storepb.UserSetting{
|
||||||
UserId: user.ID,
|
UserId: userID,
|
||||||
Key: storepb.UserSettingKey_USER_SETTING_COLOR_THEME,
|
Key: storepb.UserSettingKey_USER_SETTING_COLOR_THEME,
|
||||||
Value: &storepb.UserSetting_ColorTheme{
|
Value: &storepb.UserSetting_ColorTheme{
|
||||||
ColorTheme: convertUserSettingColorThemeToStore(request.UserSetting.ColorTheme),
|
ColorTheme: convertUserSettingColorThemeToStore(request.UserSetting.ColorTheme),
|
||||||
@ -61,12 +58,12 @@ func (s *APIV1Service) UpdateUserSetting(ctx context.Context, request *apiv1pb.U
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, status.Errorf(codes.Internal, "failed to get user setting: %v", err)
|
return nil, status.Errorf(codes.Internal, "failed to get user setting: %v", err)
|
||||||
}
|
}
|
||||||
return &apiv1pb.UpdateUserSettingResponse{
|
return &apiv2pb.UpdateUserSettingResponse{
|
||||||
UserSetting: userSetting,
|
UserSetting: userSetting,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func getUserSetting(ctx context.Context, s *store.Store, userID int32) (*apiv1pb.UserSetting, error) {
|
func getUserSetting(ctx context.Context, s *store.Store, userID int32) (*apiv2pb.UserSetting, error) {
|
||||||
userSettings, err := s.ListUserSettings(ctx, &store.FindUserSetting{
|
userSettings, err := s.ListUserSettings(ctx, &store.FindUserSetting{
|
||||||
UserID: &userID,
|
UserID: &userID,
|
||||||
})
|
})
|
||||||
@ -74,10 +71,10 @@ func getUserSetting(ctx context.Context, s *store.Store, userID int32) (*apiv1pb
|
|||||||
return nil, errors.Wrap(err, "failed to find user setting")
|
return nil, errors.Wrap(err, "failed to find user setting")
|
||||||
}
|
}
|
||||||
|
|
||||||
userSetting := &apiv1pb.UserSetting{
|
userSetting := &apiv2pb.UserSetting{
|
||||||
Id: userID,
|
Id: userID,
|
||||||
Locale: apiv1pb.UserSetting_LOCALE_EN,
|
Locale: apiv2pb.UserSetting_LOCALE_EN,
|
||||||
ColorTheme: apiv1pb.UserSetting_COLOR_THEME_SYSTEM,
|
ColorTheme: apiv2pb.UserSetting_COLOR_THEME_SYSTEM,
|
||||||
}
|
}
|
||||||
for _, setting := range userSettings {
|
for _, setting := range userSettings {
|
||||||
if setting.Key == storepb.UserSettingKey_USER_SETTING_LOCALE {
|
if setting.Key == storepb.UserSettingKey_USER_SETTING_LOCALE {
|
||||||
@ -89,50 +86,50 @@ func getUserSetting(ctx context.Context, s *store.Store, userID int32) (*apiv1pb
|
|||||||
return userSetting, nil
|
return userSetting, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func convertUserSettingLocaleToStore(locale apiv1pb.UserSetting_Locale) storepb.LocaleUserSetting {
|
func convertUserSettingLocaleToStore(locale apiv2pb.UserSetting_Locale) storepb.LocaleUserSetting {
|
||||||
switch locale {
|
switch locale {
|
||||||
case apiv1pb.UserSetting_LOCALE_EN:
|
case apiv2pb.UserSetting_LOCALE_EN:
|
||||||
return storepb.LocaleUserSetting_LOCALE_USER_SETTING_EN
|
return storepb.LocaleUserSetting_LOCALE_USER_SETTING_EN
|
||||||
case apiv1pb.UserSetting_LOCALE_ZH:
|
case apiv2pb.UserSetting_LOCALE_ZH:
|
||||||
return storepb.LocaleUserSetting_LOCALE_USER_SETTING_ZH
|
return storepb.LocaleUserSetting_LOCALE_USER_SETTING_ZH
|
||||||
default:
|
default:
|
||||||
return storepb.LocaleUserSetting_LOCALE_USER_SETTING_UNSPECIFIED
|
return storepb.LocaleUserSetting_LOCALE_USER_SETTING_UNSPECIFIED
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func convertUserSettingLocaleFromStore(locale storepb.LocaleUserSetting) apiv1pb.UserSetting_Locale {
|
func convertUserSettingLocaleFromStore(locale storepb.LocaleUserSetting) apiv2pb.UserSetting_Locale {
|
||||||
switch locale {
|
switch locale {
|
||||||
case storepb.LocaleUserSetting_LOCALE_USER_SETTING_EN:
|
case storepb.LocaleUserSetting_LOCALE_USER_SETTING_EN:
|
||||||
return apiv1pb.UserSetting_LOCALE_EN
|
return apiv2pb.UserSetting_LOCALE_EN
|
||||||
case storepb.LocaleUserSetting_LOCALE_USER_SETTING_ZH:
|
case storepb.LocaleUserSetting_LOCALE_USER_SETTING_ZH:
|
||||||
return apiv1pb.UserSetting_LOCALE_ZH
|
return apiv2pb.UserSetting_LOCALE_ZH
|
||||||
default:
|
default:
|
||||||
return apiv1pb.UserSetting_LOCALE_UNSPECIFIED
|
return apiv2pb.UserSetting_LOCALE_UNSPECIFIED
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func convertUserSettingColorThemeToStore(colorTheme apiv1pb.UserSetting_ColorTheme) storepb.ColorThemeUserSetting {
|
func convertUserSettingColorThemeToStore(colorTheme apiv2pb.UserSetting_ColorTheme) storepb.ColorThemeUserSetting {
|
||||||
switch colorTheme {
|
switch colorTheme {
|
||||||
case apiv1pb.UserSetting_COLOR_THEME_SYSTEM:
|
case apiv2pb.UserSetting_COLOR_THEME_SYSTEM:
|
||||||
return storepb.ColorThemeUserSetting_COLOR_THEME_USER_SETTING_SYSTEM
|
return storepb.ColorThemeUserSetting_COLOR_THEME_USER_SETTING_SYSTEM
|
||||||
case apiv1pb.UserSetting_COLOR_THEME_LIGHT:
|
case apiv2pb.UserSetting_COLOR_THEME_LIGHT:
|
||||||
return storepb.ColorThemeUserSetting_COLOR_THEME_USER_SETTING_LIGHT
|
return storepb.ColorThemeUserSetting_COLOR_THEME_USER_SETTING_LIGHT
|
||||||
case apiv1pb.UserSetting_COLOR_THEME_DARK:
|
case apiv2pb.UserSetting_COLOR_THEME_DARK:
|
||||||
return storepb.ColorThemeUserSetting_COLOR_THEME_USER_SETTING_DARK
|
return storepb.ColorThemeUserSetting_COLOR_THEME_USER_SETTING_DARK
|
||||||
default:
|
default:
|
||||||
return storepb.ColorThemeUserSetting_COLOR_THEME_USER_SETTING_UNSPECIFIED
|
return storepb.ColorThemeUserSetting_COLOR_THEME_USER_SETTING_UNSPECIFIED
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func convertUserSettingColorThemeFromStore(colorTheme storepb.ColorThemeUserSetting) apiv1pb.UserSetting_ColorTheme {
|
func convertUserSettingColorThemeFromStore(colorTheme storepb.ColorThemeUserSetting) apiv2pb.UserSetting_ColorTheme {
|
||||||
switch colorTheme {
|
switch colorTheme {
|
||||||
case storepb.ColorThemeUserSetting_COLOR_THEME_USER_SETTING_SYSTEM:
|
case storepb.ColorThemeUserSetting_COLOR_THEME_USER_SETTING_SYSTEM:
|
||||||
return apiv1pb.UserSetting_COLOR_THEME_SYSTEM
|
return apiv2pb.UserSetting_COLOR_THEME_SYSTEM
|
||||||
case storepb.ColorThemeUserSetting_COLOR_THEME_USER_SETTING_LIGHT:
|
case storepb.ColorThemeUserSetting_COLOR_THEME_USER_SETTING_LIGHT:
|
||||||
return apiv1pb.UserSetting_COLOR_THEME_LIGHT
|
return apiv2pb.UserSetting_COLOR_THEME_LIGHT
|
||||||
case storepb.ColorThemeUserSetting_COLOR_THEME_USER_SETTING_DARK:
|
case storepb.ColorThemeUserSetting_COLOR_THEME_USER_SETTING_DARK:
|
||||||
return apiv1pb.UserSetting_COLOR_THEME_DARK
|
return apiv2pb.UserSetting_COLOR_THEME_DARK
|
||||||
default:
|
default:
|
||||||
return apiv1pb.UserSetting_COLOR_THEME_UNSPECIFIED
|
return apiv2pb.UserSetting_COLOR_THEME_UNSPECIFIED
|
||||||
}
|
}
|
||||||
}
|
}
|
113
api/v2/v2.go
Normal file
113
api/v2/v2.go
Normal file
@ -0,0 +1,113 @@
|
|||||||
|
package v2
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/grpc-ecosystem/grpc-gateway/v2/runtime"
|
||||||
|
"github.com/improbable-eng/grpc-web/go/grpcweb"
|
||||||
|
"github.com/labstack/echo/v4"
|
||||||
|
"google.golang.org/grpc"
|
||||||
|
"google.golang.org/grpc/credentials/insecure"
|
||||||
|
"google.golang.org/grpc/reflection"
|
||||||
|
|
||||||
|
apiv2pb "github.com/boojack/slash/proto/gen/api/v2"
|
||||||
|
"github.com/boojack/slash/server/profile"
|
||||||
|
"github.com/boojack/slash/server/service/license"
|
||||||
|
"github.com/boojack/slash/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
type APIV2Service struct {
|
||||||
|
apiv2pb.UnimplementedWorkspaceServiceServer
|
||||||
|
apiv2pb.UnimplementedSubscriptionServiceServer
|
||||||
|
apiv2pb.UnimplementedUserServiceServer
|
||||||
|
apiv2pb.UnimplementedUserSettingServiceServer
|
||||||
|
apiv2pb.UnimplementedShortcutServiceServer
|
||||||
|
apiv2pb.UnimplementedCollectionServiceServer
|
||||||
|
|
||||||
|
Secret string
|
||||||
|
Profile *profile.Profile
|
||||||
|
Store *store.Store
|
||||||
|
LicenseService *license.LicenseService
|
||||||
|
|
||||||
|
grpcServer *grpc.Server
|
||||||
|
grpcServerPort int
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewAPIV2Service(secret string, profile *profile.Profile, store *store.Store, licenseService *license.LicenseService, grpcServerPort int) *APIV2Service {
|
||||||
|
authProvider := NewGRPCAuthInterceptor(store, secret)
|
||||||
|
grpcServer := grpc.NewServer(
|
||||||
|
grpc.ChainUnaryInterceptor(
|
||||||
|
authProvider.AuthenticationInterceptor,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
apiV2Service := &APIV2Service{
|
||||||
|
Secret: secret,
|
||||||
|
Profile: profile,
|
||||||
|
Store: store,
|
||||||
|
LicenseService: licenseService,
|
||||||
|
grpcServer: grpcServer,
|
||||||
|
grpcServerPort: grpcServerPort,
|
||||||
|
}
|
||||||
|
|
||||||
|
apiv2pb.RegisterSubscriptionServiceServer(grpcServer, apiV2Service)
|
||||||
|
apiv2pb.RegisterWorkspaceServiceServer(grpcServer, apiV2Service)
|
||||||
|
apiv2pb.RegisterUserServiceServer(grpcServer, apiV2Service)
|
||||||
|
apiv2pb.RegisterUserSettingServiceServer(grpcServer, apiV2Service)
|
||||||
|
apiv2pb.RegisterShortcutServiceServer(grpcServer, apiV2Service)
|
||||||
|
apiv2pb.RegisterCollectionServiceServer(grpcServer, apiV2Service)
|
||||||
|
reflection.Register(grpcServer)
|
||||||
|
|
||||||
|
return apiV2Service
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *APIV2Service) GetGRPCServer() *grpc.Server {
|
||||||
|
return s.grpcServer
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegisterGateway registers the gRPC-Gateway with the given Echo instance.
|
||||||
|
func (s *APIV2Service) RegisterGateway(ctx context.Context, e *echo.Echo) error {
|
||||||
|
// Create a client connection to the gRPC Server we just started.
|
||||||
|
// This is where the gRPC-Gateway proxies the requests.
|
||||||
|
conn, err := grpc.DialContext(
|
||||||
|
ctx,
|
||||||
|
fmt.Sprintf(":%d", s.grpcServerPort),
|
||||||
|
grpc.WithTransportCredentials(insecure.NewCredentials()),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
gwMux := runtime.NewServeMux()
|
||||||
|
if err := apiv2pb.RegisterSubscriptionServiceHandler(context.Background(), gwMux, conn); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := apiv2pb.RegisterWorkspaceServiceHandler(context.Background(), gwMux, conn); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := apiv2pb.RegisterUserServiceHandler(context.Background(), gwMux, conn); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := apiv2pb.RegisterUserSettingServiceHandler(context.Background(), gwMux, conn); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := apiv2pb.RegisterShortcutServiceHandler(context.Background(), gwMux, conn); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := apiv2pb.RegisterCollectionServiceHandler(context.Background(), gwMux, conn); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
e.Any("/api/v2/*", echo.WrapHandler(gwMux))
|
||||||
|
|
||||||
|
// GRPC web proxy.
|
||||||
|
options := []grpcweb.Option{
|
||||||
|
grpcweb.WithCorsForRegisteredEndpointsOnly(false),
|
||||||
|
grpcweb.WithOriginFunc(func(origin string) bool {
|
||||||
|
return true
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
wrappedGrpc := grpcweb.WrapServer(s.grpcServer, options...)
|
||||||
|
e.Any("/slash.api.v2.*", echo.WrapHandler(wrappedGrpc))
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
@ -1,4 +1,4 @@
|
|||||||
package v1
|
package v2
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
@ -6,16 +6,15 @@ import (
|
|||||||
"google.golang.org/grpc/codes"
|
"google.golang.org/grpc/codes"
|
||||||
"google.golang.org/grpc/status"
|
"google.golang.org/grpc/status"
|
||||||
|
|
||||||
apiv1pb "github.com/yourselfhosted/slash/proto/gen/api/v1"
|
apiv2pb "github.com/boojack/slash/proto/gen/api/v2"
|
||||||
storepb "github.com/yourselfhosted/slash/proto/gen/store"
|
storepb "github.com/boojack/slash/proto/gen/store"
|
||||||
"github.com/yourselfhosted/slash/store"
|
"github.com/boojack/slash/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (s *APIV1Service) GetWorkspaceProfile(ctx context.Context, _ *apiv1pb.GetWorkspaceProfileRequest) (*apiv1pb.GetWorkspaceProfileResponse, error) {
|
func (s *APIV2Service) GetWorkspaceProfile(ctx context.Context, _ *apiv2pb.GetWorkspaceProfileRequest) (*apiv2pb.GetWorkspaceProfileResponse, error) {
|
||||||
profile := &apiv1pb.WorkspaceProfile{
|
profile := &apiv2pb.WorkspaceProfile{
|
||||||
Mode: s.Profile.Mode,
|
Mode: s.Profile.Mode,
|
||||||
Version: s.Profile.Version,
|
Plan: apiv2pb.PlanType_FREE,
|
||||||
Plan: apiv1pb.PlanType_FREE,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load subscription plan from license service.
|
// Load subscription plan from license service.
|
||||||
@ -25,7 +24,7 @@ func (s *APIV1Service) GetWorkspaceProfile(ctx context.Context, _ *apiv1pb.GetWo
|
|||||||
}
|
}
|
||||||
profile.Plan = subscription.Plan
|
profile.Plan = subscription.Plan
|
||||||
|
|
||||||
workspaceSetting, err := s.GetWorkspaceSetting(ctx, &apiv1pb.GetWorkspaceSettingRequest{})
|
workspaceSetting, err := s.GetWorkspaceSetting(ctx, &apiv2pb.GetWorkspaceSettingRequest{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, status.Errorf(codes.Internal, "failed to get workspace setting: %v", err)
|
return nil, status.Errorf(codes.Internal, "failed to get workspace setting: %v", err)
|
||||||
}
|
}
|
||||||
@ -34,14 +33,13 @@ func (s *APIV1Service) GetWorkspaceProfile(ctx context.Context, _ *apiv1pb.GetWo
|
|||||||
profile.EnableSignup = setting.GetEnableSignup()
|
profile.EnableSignup = setting.GetEnableSignup()
|
||||||
profile.CustomStyle = setting.GetCustomStyle()
|
profile.CustomStyle = setting.GetCustomStyle()
|
||||||
profile.CustomScript = setting.GetCustomScript()
|
profile.CustomScript = setting.GetCustomScript()
|
||||||
profile.FaviconProvider = setting.GetFaviconProvider()
|
|
||||||
}
|
}
|
||||||
return &apiv1pb.GetWorkspaceProfileResponse{
|
return &apiv2pb.GetWorkspaceProfileResponse{
|
||||||
Profile: profile,
|
Profile: profile,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *APIV1Service) GetWorkspaceSetting(ctx context.Context, _ *apiv1pb.GetWorkspaceSettingRequest) (*apiv1pb.GetWorkspaceSettingResponse, error) {
|
func (s *APIV2Service) GetWorkspaceSetting(ctx context.Context, _ *apiv2pb.GetWorkspaceSettingRequest) (*apiv2pb.GetWorkspaceSettingResponse, error) {
|
||||||
isAdmin := false
|
isAdmin := false
|
||||||
userID, ok := ctx.Value(userIDContextKey).(int32)
|
userID, ok := ctx.Value(userIDContextKey).(int32)
|
||||||
if ok {
|
if ok {
|
||||||
@ -57,22 +55,16 @@ func (s *APIV1Service) GetWorkspaceSetting(ctx context.Context, _ *apiv1pb.GetWo
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, status.Errorf(codes.Internal, "failed to list workspace settings: %v", err)
|
return nil, status.Errorf(codes.Internal, "failed to list workspace settings: %v", err)
|
||||||
}
|
}
|
||||||
workspaceSetting := &apiv1pb.WorkspaceSetting{
|
workspaceSetting := &apiv2pb.WorkspaceSetting{
|
||||||
EnableSignup: true,
|
EnableSignup: true,
|
||||||
}
|
}
|
||||||
for _, v := range workspaceSettings {
|
for _, v := range workspaceSettings {
|
||||||
if v.Key == storepb.WorkspaceSettingKey_WORKSAPCE_SETTING_ENABLE_SIGNUP {
|
if v.Key == storepb.WorkspaceSettingKey_WORKSAPCE_SETTING_ENABLE_SIGNUP {
|
||||||
workspaceSetting.EnableSignup = v.GetEnableSignup()
|
workspaceSetting.EnableSignup = v.GetEnableSignup()
|
||||||
} else if v.Key == storepb.WorkspaceSettingKey_WORKSPACE_SETTING_INSTANCE_URL {
|
|
||||||
workspaceSetting.InstanceUrl = v.GetInstanceUrl()
|
|
||||||
} else if v.Key == storepb.WorkspaceSettingKey_WORKSPACE_SETTING_CUSTOM_STYLE {
|
} else if v.Key == storepb.WorkspaceSettingKey_WORKSPACE_SETTING_CUSTOM_STYLE {
|
||||||
workspaceSetting.CustomStyle = v.GetCustomStyle()
|
workspaceSetting.CustomStyle = v.GetCustomStyle()
|
||||||
} else if v.Key == storepb.WorkspaceSettingKey_WORKSPACE_SETTING_CUSTOM_SCRIPT {
|
} else if v.Key == storepb.WorkspaceSettingKey_WORKSPACE_SETTING_CUSTOM_SCRIPT {
|
||||||
workspaceSetting.CustomScript = v.GetCustomScript()
|
workspaceSetting.CustomScript = v.GetCustomScript()
|
||||||
} else if v.Key == storepb.WorkspaceSettingKey_WORKSPACE_SETTING_DEFAULT_VISIBILITY {
|
|
||||||
workspaceSetting.DefaultVisibility = apiv1pb.Visibility(v.GetDefaultVisibility())
|
|
||||||
} else if v.Key == storepb.WorkspaceSettingKey_WORKSPACE_SETTING_FAVICON_PROVIDER {
|
|
||||||
workspaceSetting.FaviconProvider = v.GetFaviconProvider()
|
|
||||||
} else if isAdmin {
|
} else if isAdmin {
|
||||||
// For some settings, only admin can get the value.
|
// For some settings, only admin can get the value.
|
||||||
if v.Key == storepb.WorkspaceSettingKey_WORKSPACE_SETTING_LICENSE_KEY {
|
if v.Key == storepb.WorkspaceSettingKey_WORKSPACE_SETTING_LICENSE_KEY {
|
||||||
@ -80,12 +72,12 @@ func (s *APIV1Service) GetWorkspaceSetting(ctx context.Context, _ *apiv1pb.GetWo
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return &apiv1pb.GetWorkspaceSettingResponse{
|
return &apiv2pb.GetWorkspaceSettingResponse{
|
||||||
Setting: workspaceSetting,
|
Setting: workspaceSetting,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *APIV1Service) UpdateWorkspaceSetting(ctx context.Context, request *apiv1pb.UpdateWorkspaceSettingRequest) (*apiv1pb.UpdateWorkspaceSettingResponse, error) {
|
func (s *APIV2Service) UpdateWorkspaceSetting(ctx context.Context, request *apiv2pb.UpdateWorkspaceSettingRequest) (*apiv2pb.UpdateWorkspaceSettingResponse, error) {
|
||||||
if request.UpdateMask == nil || len(request.UpdateMask.Paths) == 0 {
|
if request.UpdateMask == nil || len(request.UpdateMask.Paths) == 0 {
|
||||||
return nil, status.Errorf(codes.InvalidArgument, "update mask is empty")
|
return nil, status.Errorf(codes.InvalidArgument, "update mask is empty")
|
||||||
}
|
}
|
||||||
@ -109,15 +101,6 @@ func (s *APIV1Service) UpdateWorkspaceSetting(ctx context.Context, request *apiv
|
|||||||
}); err != nil {
|
}); err != nil {
|
||||||
return nil, status.Errorf(codes.Internal, "failed to update workspace setting: %v", err)
|
return nil, status.Errorf(codes.Internal, "failed to update workspace setting: %v", err)
|
||||||
}
|
}
|
||||||
} else if path == "instance_url" {
|
|
||||||
if _, err := s.Store.UpsertWorkspaceSetting(ctx, &storepb.WorkspaceSetting{
|
|
||||||
Key: storepb.WorkspaceSettingKey_WORKSPACE_SETTING_INSTANCE_URL,
|
|
||||||
Value: &storepb.WorkspaceSetting_InstanceUrl{
|
|
||||||
InstanceUrl: request.Setting.InstanceUrl,
|
|
||||||
},
|
|
||||||
}); err != nil {
|
|
||||||
return nil, status.Errorf(codes.Internal, "failed to update workspace setting: %v", err)
|
|
||||||
}
|
|
||||||
} else if path == "custom_style" {
|
} else if path == "custom_style" {
|
||||||
if _, err := s.Store.UpsertWorkspaceSetting(ctx, &storepb.WorkspaceSetting{
|
if _, err := s.Store.UpsertWorkspaceSetting(ctx, &storepb.WorkspaceSetting{
|
||||||
Key: storepb.WorkspaceSettingKey_WORKSPACE_SETTING_CUSTOM_STYLE,
|
Key: storepb.WorkspaceSettingKey_WORKSPACE_SETTING_CUSTOM_STYLE,
|
||||||
@ -136,34 +119,16 @@ func (s *APIV1Service) UpdateWorkspaceSetting(ctx context.Context, request *apiv
|
|||||||
}); err != nil {
|
}); err != nil {
|
||||||
return nil, status.Errorf(codes.Internal, "failed to update workspace setting: %v", err)
|
return nil, status.Errorf(codes.Internal, "failed to update workspace setting: %v", err)
|
||||||
}
|
}
|
||||||
} else if path == "default_visibility" {
|
|
||||||
if _, err := s.Store.UpsertWorkspaceSetting(ctx, &storepb.WorkspaceSetting{
|
|
||||||
Key: storepb.WorkspaceSettingKey_WORKSPACE_SETTING_DEFAULT_VISIBILITY,
|
|
||||||
Value: &storepb.WorkspaceSetting_DefaultVisibility{
|
|
||||||
DefaultVisibility: storepb.Visibility(request.Setting.DefaultVisibility),
|
|
||||||
},
|
|
||||||
}); err != nil {
|
|
||||||
return nil, status.Errorf(codes.Internal, "failed to update workspace setting: %v", err)
|
|
||||||
}
|
|
||||||
} else if path == "favicon_provider" {
|
|
||||||
if _, err := s.Store.UpsertWorkspaceSetting(ctx, &storepb.WorkspaceSetting{
|
|
||||||
Key: storepb.WorkspaceSettingKey_WORKSPACE_SETTING_FAVICON_PROVIDER,
|
|
||||||
Value: &storepb.WorkspaceSetting_FaviconProvider{
|
|
||||||
FaviconProvider: request.Setting.FaviconProvider,
|
|
||||||
},
|
|
||||||
}); err != nil {
|
|
||||||
return nil, status.Errorf(codes.Internal, "failed to update workspace setting: %v", err)
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
return nil, status.Errorf(codes.InvalidArgument, "invalid path: %s", path)
|
return nil, status.Errorf(codes.InvalidArgument, "invalid path: %s", path)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
getWorkspaceSettingResponse, err := s.GetWorkspaceSetting(ctx, &apiv1pb.GetWorkspaceSettingRequest{})
|
getWorkspaceSettingResponse, err := s.GetWorkspaceSetting(ctx, &apiv2pb.GetWorkspaceSettingRequest{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, status.Errorf(codes.Internal, "failed to get workspace setting: %v", err)
|
return nil, status.Errorf(codes.Internal, "failed to get workspace setting: %v", err)
|
||||||
}
|
}
|
||||||
return &apiv1pb.UpdateWorkspaceSettingResponse{
|
return &apiv2pb.UpdateWorkspaceSettingResponse{
|
||||||
Setting: getWorkspaceSettingResponse.Setting,
|
Setting: getWorkspaceSettingResponse.Setting,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
@ -3,7 +3,6 @@ package main
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
@ -11,12 +10,15 @@ import (
|
|||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"github.com/spf13/viper"
|
"github.com/spf13/viper"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
_ "modernc.org/sqlite"
|
||||||
|
|
||||||
"github.com/yourselfhosted/slash/server"
|
"github.com/boojack/slash/internal/log"
|
||||||
"github.com/yourselfhosted/slash/server/metric"
|
"github.com/boojack/slash/server"
|
||||||
"github.com/yourselfhosted/slash/server/profile"
|
"github.com/boojack/slash/server/metric"
|
||||||
"github.com/yourselfhosted/slash/store"
|
"github.com/boojack/slash/server/profile"
|
||||||
"github.com/yourselfhosted/slash/store/db"
|
"github.com/boojack/slash/store"
|
||||||
|
"github.com/boojack/slash/store/db"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@ -28,39 +30,29 @@ var (
|
|||||||
mode string
|
mode string
|
||||||
port int
|
port int
|
||||||
data string
|
data string
|
||||||
driver string
|
|
||||||
dsn string
|
|
||||||
enableMetric bool
|
|
||||||
|
|
||||||
rootCmd = &cobra.Command{
|
rootCmd = &cobra.Command{
|
||||||
Use: "slash",
|
Use: "slash",
|
||||||
Short: `An open source, self-hosted links shortener and sharing platform.`,
|
Short: `An open source, self-hosted bookmarks and link sharing platform.`,
|
||||||
Run: func(_ *cobra.Command, _ []string) {
|
Run: func(_cmd *cobra.Command, _args []string) {
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
dbDriver, err := db.NewDBDriver(serverProfile)
|
db := db.NewDB(serverProfile)
|
||||||
if err != nil {
|
if err := db.Open(ctx); err != nil {
|
||||||
cancel()
|
cancel()
|
||||||
slog.Error("failed to create db driver", err)
|
log.Error("failed to open database", zap.Error(err))
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := dbDriver.Migrate(ctx); err != nil {
|
|
||||||
cancel()
|
|
||||||
slog.Error("failed to migrate db", err)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
storeInstance := store.New(dbDriver, serverProfile)
|
storeInstance := store.New(db.DBInstance, serverProfile)
|
||||||
s, err := server.NewServer(ctx, serverProfile, storeInstance)
|
s, err := server.NewServer(ctx, serverProfile, storeInstance)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
cancel()
|
cancel()
|
||||||
slog.Error("failed to create server", err)
|
log.Error("failed to create server", zap.Error(err))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if serverProfile.Metric {
|
// nolint
|
||||||
// nolint
|
metric.NewMetricClient(s.Secret, *serverProfile)
|
||||||
metric.NewMetricClient(s.Secret, *serverProfile)
|
|
||||||
}
|
|
||||||
|
|
||||||
c := make(chan os.Signal, 1)
|
c := make(chan os.Signal, 1)
|
||||||
// Trigger graceful shutdown on SIGINT or SIGTERM.
|
// Trigger graceful shutdown on SIGINT or SIGTERM.
|
||||||
@ -69,7 +61,7 @@ var (
|
|||||||
signal.Notify(c, os.Interrupt, syscall.SIGTERM)
|
signal.Notify(c, os.Interrupt, syscall.SIGTERM)
|
||||||
go func() {
|
go func() {
|
||||||
sig := <-c
|
sig := <-c
|
||||||
slog.Info(fmt.Sprintf("%s received.\n", sig.String()))
|
log.Info(fmt.Sprintf("%s received.\n", sig.String()))
|
||||||
s.Shutdown(ctx)
|
s.Shutdown(ctx)
|
||||||
cancel()
|
cancel()
|
||||||
}()
|
}()
|
||||||
@ -78,7 +70,7 @@ var (
|
|||||||
|
|
||||||
if err := s.Start(ctx); err != nil {
|
if err := s.Start(ctx); err != nil {
|
||||||
if err != http.ErrServerClosed {
|
if err != http.ErrServerClosed {
|
||||||
slog.Error("failed to start server", err)
|
log.Error("failed to start server", zap.Error(err))
|
||||||
cancel()
|
cancel()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -90,6 +82,7 @@ var (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func Execute() error {
|
func Execute() error {
|
||||||
|
defer log.Sync()
|
||||||
return rootCmd.Execute()
|
return rootCmd.Execute()
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -99,9 +92,6 @@ func init() {
|
|||||||
rootCmd.PersistentFlags().StringVarP(&mode, "mode", "m", "demo", `mode of server, can be "prod" or "dev" or "demo"`)
|
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().IntVarP(&port, "port", "p", 8082, "port of server")
|
||||||
rootCmd.PersistentFlags().StringVarP(&data, "data", "d", "", "data directory")
|
rootCmd.PersistentFlags().StringVarP(&data, "data", "d", "", "data directory")
|
||||||
rootCmd.PersistentFlags().StringVarP(&driver, "driver", "", "", "database driver")
|
|
||||||
rootCmd.PersistentFlags().StringVarP(&dsn, "dsn", "", "", "database source name(aka. DSN)")
|
|
||||||
rootCmd.PersistentFlags().BoolVarP(&enableMetric, "metric", "", true, "allow metric collection")
|
|
||||||
|
|
||||||
err := viper.BindPFlag("mode", rootCmd.PersistentFlags().Lookup("mode"))
|
err := viper.BindPFlag("mode", rootCmd.PersistentFlags().Lookup("mode"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -115,23 +105,9 @@ func init() {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
panic(err)
|
panic(err)
|
||||||
}
|
}
|
||||||
err = viper.BindPFlag("driver", rootCmd.PersistentFlags().Lookup("driver"))
|
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
err = viper.BindPFlag("dsn", rootCmd.PersistentFlags().Lookup("dsn"))
|
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
err = viper.BindPFlag("metric", rootCmd.PersistentFlags().Lookup("metric"))
|
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
viper.SetDefault("mode", "demo")
|
viper.SetDefault("mode", "demo")
|
||||||
viper.SetDefault("port", 8082)
|
viper.SetDefault("port", 8082)
|
||||||
viper.SetDefault("driver", "sqlite")
|
|
||||||
viper.SetDefault("metric", true)
|
|
||||||
viper.SetEnvPrefix("slash")
|
viper.SetEnvPrefix("slash")
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -140,7 +116,7 @@ func initConfig() {
|
|||||||
var err error
|
var err error
|
||||||
serverProfile, err = profile.GetProfile()
|
serverProfile, err = profile.GetProfile()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("failed to get profile", err)
|
log.Error("failed to get profile", zap.Error(err))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -158,7 +134,7 @@ func printGreetings() {
|
|||||||
fmt.Printf("Version %s has been started on port %d\n", serverProfile.Version, serverProfile.Port)
|
fmt.Printf("Version %s has been started on port %d\n", serverProfile.Version, serverProfile.Port)
|
||||||
println("---")
|
println("---")
|
||||||
println("See more in:")
|
println("See more in:")
|
||||||
fmt.Printf("👉GitHub: %s\n", "https://github.com/yourselfhosted/slash")
|
fmt.Printf("👉GitHub: %s\n", "https://github.com/boojack/slash")
|
||||||
println("---")
|
println("---")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Binary file not shown.
Before Width: | Height: | Size: 836 KiB |
@ -1,6 +1,6 @@
|
|||||||
# Slash Collections
|
# Slash Collections
|
||||||
|
|
||||||
**Slash Collections** introduces a feature to help you better organize and manage related Shortcuts.
|
**Slash Collections** introduces a feature to help you better organize and manage related Shortcuts within the Slash Shortcuts platform.
|
||||||
|
|
||||||
## What is a Collection?
|
## What is a Collection?
|
||||||
|
|
||||||
|
@ -44,7 +44,7 @@ Assume that docker compose is deployed in the `/opt/slash` directory.
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
mkdir -p /opt/slash && cd /opt/slash
|
mkdir -p /opt/slash && cd /opt/slash
|
||||||
curl -#LO https://github.com/yourselfhosted/slash/raw/main/docker-compose.yml
|
curl -#LO https://github.com/boojack/slash/raw/main/docker-compose.yml
|
||||||
docker compose up -d
|
docker compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Binary file not shown.
Before Width: | Height: | Size: 852 B After Width: | Height: | Size: 257 KiB |
@ -1,54 +1,57 @@
|
|||||||
{
|
{
|
||||||
"name": "slash-extension",
|
"name": "slash-extension",
|
||||||
"displayName": "Slash",
|
"displayName": "Slash",
|
||||||
"version": "1.0.8",
|
"version": "1.0.1",
|
||||||
"description": "An open source, self-hosted links shortener and sharing platform. Save and share your links very easily.",
|
"description": "An open source, self-hosted bookmarks and link sharing platform. Save and share your links very easily.",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "plasmo dev",
|
"dev": "plasmo dev",
|
||||||
"build": "plasmo build",
|
"build": "plasmo build",
|
||||||
"package": "plasmo package",
|
"package": "plasmo package",
|
||||||
"lint": "eslint --ext .js,.ts,.tsx, src",
|
"lint": "eslint --ext .js,.ts,.tsx, src",
|
||||||
"lint-fix": "eslint --ext .js,.ts,.tsx, src --fix",
|
"lint-fix": "eslint --ext .js,.ts,.tsx, src --fix",
|
||||||
"postinstall": "cd ../../proto && buf generate"
|
"type-gen": "cd ../../proto && buf generate"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@emotion/react": "^11.11.4",
|
"@emotion/react": "^11.11.1",
|
||||||
"@emotion/styled": "^11.11.5",
|
"@emotion/styled": "^11.11.0",
|
||||||
"@mui/joy": "5.0.0-beta.32",
|
"@mui/joy": "5.0.0-beta.14",
|
||||||
"@plasmohq/storage": "^1.10.0",
|
"@plasmohq/storage": "^1.8.1",
|
||||||
"axios": "^1.6.8",
|
"axios": "^1.6.1",
|
||||||
"classnames": "^2.5.1",
|
"classnames": "^2.3.2",
|
||||||
"lodash-es": "^4.17.21",
|
"lodash-es": "^4.17.21",
|
||||||
"lucide-react": "^0.312.0",
|
"lucide-react": "^0.264.0",
|
||||||
"plasmo": "^0.85.2",
|
"plasmo": "^0.83.0",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"react-hot-toast": "^2.4.1",
|
"react-hot-toast": "^2.4.1",
|
||||||
"zustand": "^4.5.2"
|
"zustand": "^4.4.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@bufbuild/buf": "^1.30.1",
|
"@bufbuild/buf": "^1.27.2",
|
||||||
"@trivago/prettier-plugin-sort-imports": "^4.3.0",
|
"@trivago/prettier-plugin-sort-imports": "^4.2.1",
|
||||||
"@types/chrome": "^0.0.266",
|
"@types/chrome": "^0.0.241",
|
||||||
"@types/lodash-es": "^4.17.12",
|
"@types/lodash-es": "^4.17.11",
|
||||||
"@types/node": "^20.12.7",
|
"@types/node": "^20.9.0",
|
||||||
"@types/react": "^18.2.79",
|
"@types/react": "^18.2.37",
|
||||||
"@types/react-dom": "^18.2.25",
|
"@types/react-dom": "^18.2.15",
|
||||||
"@typescript-eslint/eslint-plugin": "^6.21.0",
|
"@typescript-eslint/eslint-plugin": "^6.10.0",
|
||||||
"@typescript-eslint/parser": "^6.21.0",
|
"@typescript-eslint/parser": "^6.10.0",
|
||||||
"autoprefixer": "^10.4.19",
|
"autoprefixer": "^10.4.16",
|
||||||
"eslint": "^8.57.0",
|
"eslint": "^8.53.0",
|
||||||
"eslint-config-prettier": "^8.10.0",
|
"eslint-config-prettier": "^8.10.0",
|
||||||
"eslint-plugin-prettier": "^4.2.1",
|
"eslint-plugin-prettier": "^4.2.1",
|
||||||
"eslint-plugin-react": "^7.34.1",
|
"eslint-plugin-react": "^7.33.2",
|
||||||
"long": "^5.2.3",
|
"long": "^5.2.3",
|
||||||
"postcss": "^8.4.38",
|
"postcss": "^8.4.31",
|
||||||
"prettier": "^2.8.8",
|
"prettier": "^2.8.8",
|
||||||
"protobufjs": "^7.2.6",
|
"protobufjs": "^7.2.5",
|
||||||
"tailwindcss": "^3.4.3",
|
"tailwindcss": "^3.3.5",
|
||||||
"typescript": "^5.4.5"
|
"typescript": "^5.2.2"
|
||||||
},
|
},
|
||||||
"manifest": {
|
"manifest": {
|
||||||
|
"omnibox": {
|
||||||
|
"keyword": "s/"
|
||||||
|
},
|
||||||
"permissions": [
|
"permissions": [
|
||||||
"activeTab",
|
"activeTab",
|
||||||
"storage",
|
"storage",
|
||||||
|
10877
frontend/extension/pnpm-lock.yaml
generated
10877
frontend/extension/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@ -1,4 +1,5 @@
|
|||||||
import { Storage } from "@plasmohq/storage";
|
import { Storage } from "@plasmohq/storage";
|
||||||
|
import type { Shortcut } from "@/types/proto/api/v2/shortcut_service";
|
||||||
|
|
||||||
const storage = new Storage();
|
const storage = new Storage();
|
||||||
const urlRegex = /https?:\/\/s\/(.+)/;
|
const urlRegex = /https?:\/\/s\/(.+)/;
|
||||||
@ -12,14 +13,33 @@ chrome.webRequest.onBeforeRequest.addListener(
|
|||||||
|
|
||||||
const shortcutName = getShortcutNameFromUrl(param.url);
|
const shortcutName = getShortcutNameFromUrl(param.url);
|
||||||
if (shortcutName) {
|
if (shortcutName) {
|
||||||
const instanceUrl = (await storage.getItem<string>("domain")) || "";
|
const shortcuts = (await storage.getItem<Shortcut[]>("shortcuts")) || [];
|
||||||
return chrome.tabs.update({ url: `${instanceUrl}/s/${shortcutName}` });
|
const shortcut = shortcuts.find((shortcut) => shortcut.name === shortcutName);
|
||||||
|
if (!shortcut) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return chrome.tabs.update({ url: shortcut.link });
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
},
|
},
|
||||||
{ urls: ["*://s/*", "*://*/search*"] }
|
{ urls: ["*://s/*", "*://*/search*"] }
|
||||||
);
|
);
|
||||||
|
|
||||||
|
chrome.omnibox.onInputEntered.addListener(async (text, disposition) => {
|
||||||
|
const shortcuts = (await storage.getItem<Shortcut[]>("shortcuts")) || [];
|
||||||
|
const shortcut = shortcuts.find((shortcut) => shortcut.name === text);
|
||||||
|
if (!shortcut) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (disposition === "currentTab") {
|
||||||
|
chrome.tabs.update({ url: shortcut.link });
|
||||||
|
} else if (disposition === "newForegroundTab") {
|
||||||
|
chrome.tabs.create({ url: shortcut.link });
|
||||||
|
} else if (disposition === "newBackgroundTab") {
|
||||||
|
chrome.tabs.create({ url: shortcut.link, active: false });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const getShortcutNameFromUrl = (urlString: string) => {
|
const getShortcutNameFromUrl = (urlString: string) => {
|
||||||
const matchResult = urlRegex.exec(urlString);
|
const matchResult = urlRegex.exec(urlString);
|
||||||
if (matchResult === null) {
|
if (matchResult === null) {
|
||||||
|
@ -1,27 +1,39 @@
|
|||||||
import { Button, IconButton, Input, Modal, ModalDialog } from "@mui/joy";
|
import { Button, IconButton, Input, Modal, ModalDialog } from "@mui/joy";
|
||||||
|
import { useStorage } from "@plasmohq/storage/hook";
|
||||||
|
import axios from "axios";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { toast } from "react-hot-toast";
|
import { toast } from "react-hot-toast";
|
||||||
import { useStorageContext } from "@/context";
|
import { Visibility } from "@/types/proto/api/v2/common";
|
||||||
import { useShortcutStore } from "@/stores";
|
import { CreateShortcutResponse, OpenGraphMetadata } from "@/types/proto/api/v2/shortcut_service";
|
||||||
import type { Visibility } from "@/types/proto/api/v1/common";
|
|
||||||
import { Shortcut } from "@/types/proto/api/v1/shortcut_service";
|
|
||||||
import Icon from "./Icon";
|
import Icon from "./Icon";
|
||||||
|
|
||||||
|
const generateTempName = (length = 6) => {
|
||||||
|
let result = "";
|
||||||
|
const characters = "abcdefghijklmnopqrstuvwxyz0123456789";
|
||||||
|
const charactersLength = characters.length;
|
||||||
|
let counter = 0;
|
||||||
|
while (counter < length) {
|
||||||
|
result += characters.charAt(Math.floor(Math.random() * charactersLength));
|
||||||
|
counter += 1;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
interface State {
|
interface State {
|
||||||
name: string;
|
name: string;
|
||||||
title: string;
|
title: string;
|
||||||
link: string;
|
link: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const CreateShortcutButton = () => {
|
const CreateShortcutsButton = () => {
|
||||||
const context = useStorageContext();
|
const [domain] = useStorage("domain");
|
||||||
const shortcutStore = useShortcutStore();
|
const [accessToken] = useStorage("access_token");
|
||||||
|
const [shortcuts, setShortcuts] = useStorage("shortcuts");
|
||||||
const [state, setState] = useState<State>({
|
const [state, setState] = useState<State>({
|
||||||
name: "",
|
name: "",
|
||||||
title: "",
|
title: "",
|
||||||
link: "",
|
link: "",
|
||||||
});
|
});
|
||||||
const [tag, setTag] = useState<string>("");
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [showModal, setShowModal] = useState(false);
|
const [showModal, setShowModal] = useState(false);
|
||||||
|
|
||||||
@ -42,7 +54,7 @@ const CreateShortcutButton = () => {
|
|||||||
const tab = tabs[0];
|
const tab = tabs[0];
|
||||||
setState((state) => ({
|
setState((state) => ({
|
||||||
...state,
|
...state,
|
||||||
name: "",
|
name: generateTempName() + "-temp",
|
||||||
title: tab.title || "",
|
title: tab.title || "",
|
||||||
link: tab.url || "",
|
link: tab.url || "",
|
||||||
}));
|
}));
|
||||||
@ -50,18 +62,6 @@ const CreateShortcutButton = () => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const generateRandomName = () => {
|
|
||||||
const chars = "abcdefghijklmnopqrstuvwxyz0123456789";
|
|
||||||
let name = "";
|
|
||||||
for (let i = 0; i < 8; i++) {
|
|
||||||
name += chars.charAt(Math.floor(Math.random() * chars.length));
|
|
||||||
}
|
|
||||||
setState((state) => ({
|
|
||||||
...state,
|
|
||||||
name,
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleNameInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleNameInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
setState((state) => ({
|
setState((state) => ({
|
||||||
...state,
|
...state,
|
||||||
@ -83,11 +83,6 @@ const CreateShortcutButton = () => {
|
|||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleTagsInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
const text = e.target.value as string;
|
|
||||||
setTag(text);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSaveBtnClick = async () => {
|
const handleSaveBtnClick = async () => {
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return;
|
return;
|
||||||
@ -99,18 +94,25 @@ const CreateShortcutButton = () => {
|
|||||||
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
try {
|
try {
|
||||||
const tags = tag.split(" ").filter(Boolean);
|
const {
|
||||||
await shortcutStore.createShortcut(
|
data: { shortcut },
|
||||||
context.instanceUrl,
|
} = await axios.post<CreateShortcutResponse>(
|
||||||
context.accessToken,
|
`${domain}/api/v2/shortcuts`,
|
||||||
Shortcut.fromPartial({
|
{
|
||||||
name: state.name,
|
name: state.name,
|
||||||
title: state.title,
|
title: state.title,
|
||||||
link: state.link,
|
link: state.link,
|
||||||
tags,
|
visibility: Visibility.PRIVATE,
|
||||||
visibility: context.defaultVisibility as Visibility,
|
ogMetadata: OpenGraphMetadata.fromPartial({}),
|
||||||
})
|
},
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
},
|
||||||
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
setShortcuts([shortcut, ...shortcuts]);
|
||||||
toast.success("Shortcut created successfully");
|
toast.success("Shortcut created successfully");
|
||||||
setShowModal(false);
|
setShowModal(false);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
@ -137,18 +139,7 @@ const CreateShortcutButton = () => {
|
|||||||
<div className="overflow-x-hidden w-full flex flex-col justify-start items-center">
|
<div className="overflow-x-hidden w-full flex flex-col justify-start items-center">
|
||||||
<div className="w-full flex flex-row justify-start items-center mb-2">
|
<div className="w-full flex flex-row justify-start items-center mb-2">
|
||||||
<span className="block w-12 mr-2 shrink-0">Name</span>
|
<span className="block w-12 mr-2 shrink-0">Name</span>
|
||||||
<Input
|
<Input className="grow" type="text" placeholder="Unique shortcut name" value={state.name} onChange={handleNameInputChange} />
|
||||||
className="grow"
|
|
||||||
type="text"
|
|
||||||
placeholder="Unique shortcut name"
|
|
||||||
value={state.name}
|
|
||||||
onChange={handleNameInputChange}
|
|
||||||
endDecorator={
|
|
||||||
<IconButton size="sm" onClick={generateRandomName}>
|
|
||||||
<Icon.RefreshCcw className="w-4 h-auto cursor-pointer" />
|
|
||||||
</IconButton>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="w-full flex flex-row justify-start items-center mb-2">
|
<div className="w-full flex flex-row justify-start items-center mb-2">
|
||||||
<span className="block w-12 mr-2 shrink-0">Title</span>
|
<span className="block w-12 mr-2 shrink-0">Title</span>
|
||||||
@ -159,15 +150,11 @@ const CreateShortcutButton = () => {
|
|||||||
<Input
|
<Input
|
||||||
className="grow"
|
className="grow"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="e.g., https://github.com/yourselfhosted/slash"
|
placeholder="https://github.com/boojack/slash"
|
||||||
value={state.link}
|
value={state.link}
|
||||||
onChange={handleLinkInputChange}
|
onChange={handleLinkInputChange}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-full flex flex-row justify-start items-center mb-2">
|
|
||||||
<span className="block w-12 mr-2 shrink-0">Tags</span>
|
|
||||||
<Input className="grow" type="text" placeholder="The tags of shortcut" value={tag} onChange={handleTagsInputChange} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="w-full flex flex-row justify-end items-center mt-2 space-x-2">
|
<div className="w-full flex flex-row justify-end items-center mt-2 space-x-2">
|
||||||
<Button color="neutral" variant="plain" onClick={() => setShowModal(false)}>
|
<Button color="neutral" variant="plain" onClick={() => setShowModal(false)}>
|
||||||
@ -184,4 +171,4 @@ const CreateShortcutButton = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default CreateShortcutButton;
|
export default CreateShortcutsButton;
|
@ -1,12 +1,12 @@
|
|||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
import Icon from "./Icon";
|
import LogoBase64 from "data-base64:../..//assets/icon.png";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Logo = ({ className }: Props) => {
|
const Logo = ({ className }: Props) => {
|
||||||
return <Icon.CircleSlash className={classNames("dark:text-gray-500", className)} strokeWidth={1.5} />;
|
return <img className={classNames(className)} src={LogoBase64} alt="" />;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Logo;
|
export default Logo;
|
||||||
|
@ -1,23 +1,32 @@
|
|||||||
import { IconButton } from "@mui/joy";
|
import { IconButton } from "@mui/joy";
|
||||||
|
import { useStorage } from "@plasmohq/storage/hook";
|
||||||
|
import axios from "axios";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { toast } from "react-hot-toast";
|
import { toast } from "react-hot-toast";
|
||||||
import { useStorageContext } from "@/context";
|
import { ListShortcutsResponse } from "@/types/proto/api/v2/shortcut_service";
|
||||||
import { useShortcutStore } from "@/stores";
|
|
||||||
import Icon from "./Icon";
|
import Icon from "./Icon";
|
||||||
|
|
||||||
const PullShortcutsButton = () => {
|
const PullShortcutsButton = () => {
|
||||||
const context = useStorageContext();
|
const [domain] = useStorage("domain");
|
||||||
const shortcutStore = useShortcutStore();
|
const [accessToken] = useStorage("access_token");
|
||||||
|
const [, setShortcuts] = useStorage("shortcuts");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (context.instanceUrl && context.accessToken) {
|
if (domain && accessToken) {
|
||||||
handlePullShortcuts(true);
|
handlePullShortcuts(true);
|
||||||
}
|
}
|
||||||
}, [context]);
|
}, [domain, accessToken]);
|
||||||
|
|
||||||
const handlePullShortcuts = async (silence = false) => {
|
const handlePullShortcuts = async (silence = false) => {
|
||||||
try {
|
try {
|
||||||
await shortcutStore.fetchShortcutList(context.instanceUrl, context.accessToken);
|
const {
|
||||||
|
data: { shortcuts },
|
||||||
|
} = await axios.get<ListShortcutsResponse>(`${domain}/api/v2/shortcuts`, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
setShortcuts(shortcuts);
|
||||||
if (!silence) {
|
if (!silence) {
|
||||||
toast.success("Shortcuts pulled");
|
toast.success("Shortcuts pulled");
|
||||||
}
|
}
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import { useStorage } from "@plasmohq/storage/hook";
|
import { useStorage } from "@plasmohq/storage/hook";
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
import { getFaviconWithGoogleS2 } from "@/helpers/utils";
|
import { getFaviconWithGoogleS2 } from "@/helpers/utils";
|
||||||
import type { Shortcut } from "@/types/proto/api/v1/shortcut_service";
|
import type { Shortcut } from "@/types/proto/api/v2/shortcut_service";
|
||||||
import Icon from "./Icon";
|
import Icon from "./Icon";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@ -22,13 +22,13 @@ const ShortcutView = (props: Props) => {
|
|||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
className={classNames(
|
className={classNames(
|
||||||
"group w-auto px-3 py-2 flex flex-col justify-start items-start border rounded-lg hover:bg-gray-100 hover:shadow dark:border-zinc-800 dark:hover:bg-zinc-800"
|
"group w-full px-3 py-2 flex flex-col justify-start items-start border rounded-lg hover:bg-gray-100 hover:shadow dark:border-zinc-800 dark:hover:bg-zinc-800"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="w-full flex flex-row justify-start items-center">
|
<div className="w-full flex flex-row justify-start items-center">
|
||||||
<span className={classNames("w-5 h-5 flex justify-center items-center overflow-clip shrink-0")}>
|
<span className={classNames("w-5 h-5 flex justify-center items-center overflow-clip shrink-0")}>
|
||||||
{favicon ? (
|
{favicon ? (
|
||||||
<img className="w-full h-auto rounded" src={favicon} decoding="async" loading="lazy" />
|
<img className="w-full h-auto rounded-lg" src={favicon} decoding="async" loading="lazy" />
|
||||||
) : (
|
) : (
|
||||||
<Icon.CircleSlash className="w-full h-auto text-gray-400" />
|
<Icon.CircleSlash className="w-full h-auto text-gray-400" />
|
||||||
)}
|
)}
|
||||||
@ -44,14 +44,15 @@ const ShortcutView = (props: Props) => {
|
|||||||
<div className="truncate">
|
<div className="truncate">
|
||||||
<span className="dark:text-gray-400">{shortcut.title}</span>
|
<span className="dark:text-gray-400">{shortcut.title}</span>
|
||||||
{shortcut.title ? (
|
{shortcut.title ? (
|
||||||
<span className="text-gray-500">({shortcut.name})</span>
|
<span className="text-gray-500">(s/{shortcut.name})</span>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
<span className="text-gray-400 dark:text-gray-500">s/</span>
|
||||||
<span className="truncate dark:text-gray-400">{shortcut.name}</span>
|
<span className="truncate dark:text-gray-400">{shortcut.name}</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<span className="ml-1 cursor-pointer shrink-0 opacity-80">
|
<span className="hidden group-hover:block ml-1 cursor-pointer shrink-0">
|
||||||
<Icon.ExternalLink className="w-4 h-auto text-gray-600" />
|
<Icon.ExternalLink className="w-4 h-auto text-gray-600" />
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
@ -1,24 +1,16 @@
|
|||||||
|
import { useStorage } from "@plasmohq/storage/hook";
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
import { useShortcutStore } from "@/stores";
|
import type { Shortcut } from "@/types/proto/api/v2/shortcut_service";
|
||||||
import Icon from "./Icon";
|
|
||||||
import ShortcutView from "./ShortcutView";
|
import ShortcutView from "./ShortcutView";
|
||||||
|
|
||||||
const ShortcutsContainer = () => {
|
const ShortcutsContainer = () => {
|
||||||
const shortcuts = useShortcutStore().getShortcutList();
|
const [shortcuts] = useStorage<Shortcut[]>("shortcuts", (v) => (v ? v : []));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className={classNames("w-full grid grid-cols-2 gap-2")}>
|
||||||
<div className="w-full flex flex-row justify-start items-center mb-4">
|
{shortcuts.map((shortcut) => {
|
||||||
<a className="bg-blue-100 dark:bg-blue-500 dark:opacity-70 py-2 px-3 rounded-full border dark:border-blue-600 flex flex-row justify-start items-center cursor-pointer shadow">
|
return <ShortcutView key={shortcut.id} shortcut={shortcut} />;
|
||||||
<Icon.AlertCircle className="w-4 h-auto" />
|
})}
|
||||||
<span className="mx-1 text-sm">Please make sure you have signed in your instance.</span>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<div className={classNames("w-full flex flex-row justify-start items-start flex-wrap gap-2")}>
|
|
||||||
{shortcuts.map((shortcut) => {
|
|
||||||
return <ShortcutView key={shortcut.id} shortcut={shortcut} />;
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -1,27 +0,0 @@
|
|||||||
import { createContext, useContext } from "react";
|
|
||||||
import { Visibility } from "@/types/proto/api/v1/common";
|
|
||||||
|
|
||||||
interface Context {
|
|
||||||
instanceUrl?: string;
|
|
||||||
accessToken?: string;
|
|
||||||
defaultVisibility: string;
|
|
||||||
setInstanceUrl: (instanceUrl: string) => void;
|
|
||||||
setAccessToken: (accessToken: string) => void;
|
|
||||||
setDefaultVisibility: (visibility: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const StorageContext = createContext<Context>({
|
|
||||||
instanceUrl: undefined,
|
|
||||||
accessToken: undefined,
|
|
||||||
defaultVisibility: Visibility.PRIVATE,
|
|
||||||
setInstanceUrl: () => {},
|
|
||||||
setAccessToken: () => {},
|
|
||||||
setDefaultVisibility: () => {},
|
|
||||||
});
|
|
||||||
|
|
||||||
const useStorageContext = () => {
|
|
||||||
const context = useContext(StorageContext);
|
|
||||||
return context;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default useStorageContext;
|
|
@ -1,4 +0,0 @@
|
|||||||
import useStorageContext from "./context";
|
|
||||||
import StorageContextProvider from "./provider";
|
|
||||||
|
|
||||||
export { useStorageContext, StorageContextProvider };
|
|
@ -1,66 +0,0 @@
|
|||||||
import { Storage } from "@plasmohq/storage";
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { Visibility } from "@/types/proto/api/v1/common";
|
|
||||||
import { StorageContext } from "./context";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
children: React.ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
const StorageContextProvider = ({ children }: Props) => {
|
|
||||||
const storage = new Storage();
|
|
||||||
const [instanceUrl, setInstanceUrl] = useState<string | undefined>(undefined);
|
|
||||||
const [accessToken, setAccessToken] = useState<string | undefined>(undefined);
|
|
||||||
const [defaultVisibility, setDefaultVisibility] = useState<Visibility>(Visibility.PRIVATE);
|
|
||||||
const [isInitialized, setIsInitialized] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
(async () => {
|
|
||||||
let instanceUrl = await storage.get("instance_url");
|
|
||||||
const accessToken = await storage.get("access_token");
|
|
||||||
const defaultVisibility = (await storage.get("default_visibility")) as Visibility;
|
|
||||||
|
|
||||||
// Migrate domain to instance_url.
|
|
||||||
const domain = await storage.get("domain");
|
|
||||||
if (domain) {
|
|
||||||
instanceUrl = domain;
|
|
||||||
await storage.remove("domain");
|
|
||||||
await storage.set("instance_url", instanceUrl);
|
|
||||||
}
|
|
||||||
|
|
||||||
setInstanceUrl(instanceUrl);
|
|
||||||
setAccessToken(accessToken);
|
|
||||||
setDefaultVisibility(defaultVisibility);
|
|
||||||
setIsInitialized(true);
|
|
||||||
})();
|
|
||||||
|
|
||||||
storage.watch({
|
|
||||||
instance_url: (c) => {
|
|
||||||
setInstanceUrl(c.newValue);
|
|
||||||
},
|
|
||||||
access_token: (c) => {
|
|
||||||
setAccessToken(c.newValue);
|
|
||||||
},
|
|
||||||
default_visibility: (c) => {
|
|
||||||
setDefaultVisibility(c.newValue as Visibility);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<StorageContext.Provider
|
|
||||||
value={{
|
|
||||||
instanceUrl,
|
|
||||||
accessToken,
|
|
||||||
defaultVisibility,
|
|
||||||
setInstanceUrl: (instanceUrl: string) => storage.set("instance_url", instanceUrl),
|
|
||||||
setAccessToken: (accessToken: string) => storage.set("access_token", accessToken),
|
|
||||||
setDefaultVisibility: (visibility: Visibility) => storage.set("default_visibility", visibility),
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{isInitialized && children}
|
|
||||||
</StorageContext.Provider>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default StorageContextProvider;
|
|
@ -1,3 +1,9 @@
|
|||||||
|
import { isNull, isUndefined } from "lodash-es";
|
||||||
|
|
||||||
|
export const isNullorUndefined = (value: any) => {
|
||||||
|
return isNull(value) || isUndefined(value);
|
||||||
|
};
|
||||||
|
|
||||||
export const getFaviconWithGoogleS2 = (url: string) => {
|
export const getFaviconWithGoogleS2 = (url: string) => {
|
||||||
try {
|
try {
|
||||||
const urlObject = new URL(url);
|
const urlObject = new URL(url);
|
||||||
|
@ -1,15 +1,14 @@
|
|||||||
import { Button, CssVarsProvider, Divider, Input, Select, Option } from "@mui/joy";
|
import { Button, CssVarsProvider, Divider, Input, Select, Option } from "@mui/joy";
|
||||||
|
import { useStorage } from "@plasmohq/storage/hook";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Toaster, toast } from "react-hot-toast";
|
import { Toaster, toast } from "react-hot-toast";
|
||||||
import { useShortcutStore } from "@/stores";
|
import type { Shortcut } from "@/types/proto/api/v2/shortcut_service";
|
||||||
import Icon from "./components/Icon";
|
import Icon from "./components/Icon";
|
||||||
import Logo from "./components/Logo";
|
import Logo from "./components/Logo";
|
||||||
import PullShortcutsButton from "./components/PullShortcutsButton";
|
import PullShortcutsButton from "./components/PullShortcutsButton";
|
||||||
import ShortcutsContainer from "./components/ShortcutsContainer";
|
import ShortcutsContainer from "./components/ShortcutsContainer";
|
||||||
import { StorageContextProvider, useStorageContext } from "./context";
|
|
||||||
import useColorTheme from "./hooks/useColorTheme";
|
import useColorTheme from "./hooks/useColorTheme";
|
||||||
import "./style.css";
|
import "./style.css";
|
||||||
import { Visibility } from "./types/proto/api/v1/common";
|
|
||||||
|
|
||||||
interface SettingState {
|
interface SettingState {
|
||||||
domain: string;
|
domain: string;
|
||||||
@ -33,21 +32,21 @@ const colorThemeOptions = [
|
|||||||
|
|
||||||
const IndexOptions = () => {
|
const IndexOptions = () => {
|
||||||
const { colorTheme, setColorTheme } = useColorTheme();
|
const { colorTheme, setColorTheme } = useColorTheme();
|
||||||
const context = useStorageContext();
|
const [domain, setDomain] = useStorage<string>("domain", (v) => (v ? v : ""));
|
||||||
|
const [accessToken, setAccessToken] = useStorage<string>("access_token", (v) => (v ? v : ""));
|
||||||
const [settingState, setSettingState] = useState<SettingState>({
|
const [settingState, setSettingState] = useState<SettingState>({
|
||||||
domain: context.instanceUrl || "",
|
domain,
|
||||||
accessToken: context.accessToken || "",
|
accessToken,
|
||||||
});
|
});
|
||||||
const shortcutStore = useShortcutStore();
|
const [shortcuts] = useStorage<Shortcut[]>("shortcuts", []);
|
||||||
const shortcuts = shortcutStore.getShortcutList();
|
const isInitialized = domain && accessToken;
|
||||||
const isInitialized = context.instanceUrl && context.accessToken;
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setSettingState({
|
setSettingState({
|
||||||
domain: context.instanceUrl || "",
|
domain,
|
||||||
accessToken: context.accessToken || "",
|
accessToken,
|
||||||
});
|
});
|
||||||
}, [context]);
|
}, [domain, accessToken]);
|
||||||
|
|
||||||
const setPartialSettingState = (partialSettingState: Partial<SettingState>) => {
|
const setPartialSettingState = (partialSettingState: Partial<SettingState>) => {
|
||||||
setSettingState((prevState) => ({
|
setSettingState((prevState) => ({
|
||||||
@ -57,8 +56,8 @@ const IndexOptions = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleSaveSetting = () => {
|
const handleSaveSetting = () => {
|
||||||
context.setInstanceUrl(settingState.domain);
|
setDomain(settingState.domain);
|
||||||
context.setAccessToken(settingState.accessToken);
|
setAccessToken(settingState.accessToken);
|
||||||
toast.success("Setting saved");
|
toast.success("Setting saved");
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -66,16 +65,12 @@ const IndexOptions = () => {
|
|||||||
setColorTheme(colorTheme as any);
|
setColorTheme(colorTheme as any);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDefaultVisibilitySelect = (value: Visibility) => {
|
|
||||||
context.setDefaultVisibility(value);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full px-4">
|
<div className="w-full">
|
||||||
<div className="w-full flex flex-row justify-center items-center">
|
<div className="w-full flex flex-row justify-center items-center">
|
||||||
<a
|
<a
|
||||||
className="bg-yellow-100 dark:bg-yellow-500 dark:opacity-70 mt-12 py-2 px-3 rounded-full border dark:border-yellow-600 flex flex-row justify-start items-center cursor-pointer shadow hover:underline hover:text-blue-600"
|
className="bg-yellow-100 dark:bg-yellow-500 dark:opacity-70 mt-12 py-2 px-3 rounded-full border dark:border-yellow-600 flex flex-row justify-start items-center cursor-pointer shadow hover:underline hover:text-blue-600"
|
||||||
href="https://github.com/yourselfhosted/slash#browser-extension"
|
href="https://github.com/boojack/slash#browser-extension"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
>
|
>
|
||||||
<Icon.HelpCircle className="w-4 h-auto" />
|
<Icon.HelpCircle className="w-4 h-auto" />
|
||||||
@ -84,7 +79,7 @@ const IndexOptions = () => {
|
|||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="w-full max-w-lg mx-auto flex flex-col justify-start items-start py-12">
|
<div className="w-full max-w-lg mx-auto flex flex-col justify-start items-start mt-12">
|
||||||
<h2 className="flex flex-row justify-start items-center mb-6 text-2xl dark:text-gray-400">
|
<h2 className="flex flex-row justify-start items-center mb-6 text-2xl dark:text-gray-400">
|
||||||
<Logo className="w-10 h-auto mr-2" />
|
<Logo className="w-10 h-auto mr-2" />
|
||||||
<span>Slash</span>
|
<span>Slash</span>
|
||||||
@ -95,11 +90,11 @@ const IndexOptions = () => {
|
|||||||
<div className="w-full flex flex-col justify-start items-start">
|
<div className="w-full flex flex-col justify-start items-start">
|
||||||
<div className="w-full flex flex-col justify-start items-start mb-4">
|
<div className="w-full flex flex-col justify-start items-start mb-4">
|
||||||
<div className="mb-2 text-base w-full flex flex-row justify-between items-center">
|
<div className="mb-2 text-base w-full flex flex-row justify-between items-center">
|
||||||
<span className="dark:text-gray-400">Instance URL</span>
|
<span className="dark:text-gray-400">Domain</span>
|
||||||
{context.instanceUrl !== "" && (
|
{domain !== "" && (
|
||||||
<a
|
<a
|
||||||
className="text-sm flex flex-row justify-start items-center underline text-blue-600 hover:opacity-80"
|
className="text-sm flex flex-row justify-start items-center dark:text-gray-400 hover:underline hover:text-blue-600"
|
||||||
href={context.instanceUrl}
|
href={domain}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
>
|
>
|
||||||
<span className="mr-1">Go to my Slash</span>
|
<span className="mr-1">Go to my Slash</span>
|
||||||
@ -111,7 +106,7 @@ const IndexOptions = () => {
|
|||||||
<Input
|
<Input
|
||||||
className="w-full"
|
className="w-full"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="The url of your Slash instance. e.g., https://slash.example.com"
|
placeholder="The domain of your Slash instance"
|
||||||
value={settingState.domain}
|
value={settingState.domain}
|
||||||
onChange={(e) => setPartialSettingState({ domain: e.target.value })}
|
onChange={(e) => setPartialSettingState({ domain: e.target.value })}
|
||||||
/>
|
/>
|
||||||
@ -124,7 +119,7 @@ const IndexOptions = () => {
|
|||||||
<Input
|
<Input
|
||||||
className="w-full"
|
className="w-full"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="An available access token of your account."
|
placeholder="The access token of your Slash instance"
|
||||||
value={settingState.accessToken}
|
value={settingState.accessToken}
|
||||||
onChange={(e) => setPartialSettingState({ accessToken: e.target.value })}
|
onChange={(e) => setPartialSettingState({ accessToken: e.target.value })}
|
||||||
/>
|
/>
|
||||||
@ -137,9 +132,9 @@ const IndexOptions = () => {
|
|||||||
|
|
||||||
<Divider className="!my-6" />
|
<Divider className="!my-6" />
|
||||||
|
|
||||||
<p className="text-base font-semibold leading-6 mb-2 text-gray-900 dark:text-gray-500">Preference</p>
|
<p className="text-base font-semibold leading-6 text-gray-900 dark:text-gray-500">Preference</p>
|
||||||
|
|
||||||
<div className="w-full flex flex-row justify-between items-center mb-2">
|
<div className="w-full flex flex-row justify-between items-center">
|
||||||
<div className="flex flex-row justify-start items-center gap-x-1">
|
<div className="flex flex-row justify-start items-center gap-x-1">
|
||||||
<span className="dark:text-gray-400">Color Theme</span>
|
<span className="dark:text-gray-400">Color Theme</span>
|
||||||
</div>
|
</div>
|
||||||
@ -153,17 +148,6 @@ const IndexOptions = () => {
|
|||||||
})}
|
})}
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="w-full flex flex-row justify-between items-center">
|
|
||||||
<div className="flex flex-row justify-start items-center gap-x-1">
|
|
||||||
<span className="dark:text-gray-400">Default Visibility</span>
|
|
||||||
</div>
|
|
||||||
<Select defaultValue={context.defaultVisibility} onChange={(_, value) => handleDefaultVisibilitySelect(value as Visibility)}>
|
|
||||||
<Option value={Visibility.PRIVATE}>Private</Option>
|
|
||||||
<Option value={Visibility.WORKSPACE}>Workspace</Option>
|
|
||||||
<Option value={Visibility.PUBLIC}>Public</Option>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isInitialized && (
|
{isInitialized && (
|
||||||
@ -185,12 +169,10 @@ const IndexOptions = () => {
|
|||||||
|
|
||||||
const Options = () => {
|
const Options = () => {
|
||||||
return (
|
return (
|
||||||
<StorageContextProvider>
|
<CssVarsProvider>
|
||||||
<CssVarsProvider>
|
<IndexOptions />
|
||||||
<IndexOptions />
|
<Toaster position="top-right" />
|
||||||
<Toaster position="top-center" />
|
</CssVarsProvider>
|
||||||
</CssVarsProvider>
|
|
||||||
</StorageContextProvider>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -1,30 +1,21 @@
|
|||||||
import { Button, CssVarsProvider, Divider, IconButton } from "@mui/joy";
|
import { Button, CssVarsProvider, Divider, IconButton } from "@mui/joy";
|
||||||
import { useEffect } from "react";
|
import { useStorage } from "@plasmohq/storage/hook";
|
||||||
import { Toaster } from "react-hot-toast";
|
import { Toaster } from "react-hot-toast";
|
||||||
import CreateShortcutButton from "@/components/CreateShortcutButton";
|
import CreateShortcutsButton from "@/components/CreateShortcutsButton";
|
||||||
import Icon from "@/components/Icon";
|
import Icon from "@/components/Icon";
|
||||||
import Logo from "@/components/Logo";
|
import Logo from "@/components/Logo";
|
||||||
import PullShortcutsButton from "@/components/PullShortcutsButton";
|
import PullShortcutsButton from "@/components/PullShortcutsButton";
|
||||||
import ShortcutsContainer from "@/components/ShortcutsContainer";
|
import ShortcutsContainer from "@/components/ShortcutsContainer";
|
||||||
import { useShortcutStore } from "@/stores";
|
import type { Shortcut } from "@/types/proto/api/v2/shortcut_service";
|
||||||
import { StorageContextProvider, useStorageContext } from "./context";
|
|
||||||
import useColorTheme from "./hooks/useColorTheme";
|
import useColorTheme from "./hooks/useColorTheme";
|
||||||
import "./style.css";
|
import "./style.css";
|
||||||
|
|
||||||
const IndexPopup = () => {
|
const IndexPopup = () => {
|
||||||
useColorTheme();
|
useColorTheme();
|
||||||
const context = useStorageContext();
|
const [domain] = useStorage<string>("domain", "");
|
||||||
const shortcutStore = useShortcutStore();
|
const [accessToken] = useStorage<string>("access_token", "");
|
||||||
const shortcuts = shortcutStore.getShortcutList();
|
const [shortcuts] = useStorage<Shortcut[]>("shortcuts", []);
|
||||||
const isInitialized = context.instanceUrl && context.accessToken;
|
const isInitialized = domain && accessToken;
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isInitialized) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
shortcutStore.fetchShortcutList(context.instanceUrl, context.accessToken);
|
|
||||||
}, [isInitialized]);
|
|
||||||
|
|
||||||
const handleSettingButtonClick = () => {
|
const handleSettingButtonClick = () => {
|
||||||
chrome.runtime.openOptionsPage();
|
chrome.runtime.openOptionsPage();
|
||||||
@ -39,7 +30,7 @@ const IndexPopup = () => {
|
|||||||
<div className="w-full min-w-[512px] px-4 pt-4">
|
<div className="w-full min-w-[512px] px-4 pt-4">
|
||||||
<div className="w-full flex flex-row justify-between items-center">
|
<div className="w-full flex flex-row justify-between items-center">
|
||||||
<div className="flex flex-row justify-start items-center dark:text-gray-400">
|
<div className="flex flex-row justify-start items-center dark:text-gray-400">
|
||||||
<Logo className="w-6 h-auto mr-1" />
|
<Logo className="w-6 h-auto mr-2" />
|
||||||
<span className="">Slash</span>
|
<span className="">Slash</span>
|
||||||
{isInitialized && (
|
{isInitialized && (
|
||||||
<>
|
<>
|
||||||
@ -50,7 +41,7 @@ const IndexPopup = () => {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div>{isInitialized && <CreateShortcutButton />}</div>
|
<div>{isInitialized && <CreateShortcutsButton />}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="w-full mt-4">
|
<div className="w-full mt-4">
|
||||||
@ -71,21 +62,14 @@ const IndexPopup = () => {
|
|||||||
<IconButton size="sm" variant="plain" color="neutral" onClick={handleSettingButtonClick}>
|
<IconButton size="sm" variant="plain" color="neutral" onClick={handleSettingButtonClick}>
|
||||||
<Icon.Settings className="w-5 h-auto text-gray-500 dark:text-gray-400" />
|
<Icon.Settings className="w-5 h-auto text-gray-500 dark:text-gray-400" />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
<IconButton
|
<IconButton size="sm" variant="plain" color="neutral" component="a" href="https://github.com/boojack/slash" target="_blank">
|
||||||
size="sm"
|
|
||||||
variant="plain"
|
|
||||||
color="neutral"
|
|
||||||
component="a"
|
|
||||||
href="https://github.com/yourselfhosted/slash"
|
|
||||||
target="_blank"
|
|
||||||
>
|
|
||||||
<Icon.Github className="w-5 h-auto text-gray-500 dark:text-gray-400" />
|
<Icon.Github className="w-5 h-auto text-gray-500 dark:text-gray-400" />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-row justify-end items-center">
|
<div className="flex flex-row justify-end items-center">
|
||||||
<a
|
<a
|
||||||
className="text-sm flex flex-row justify-start items-center underline text-blue-600 hover:opacity-80"
|
className="text-sm flex flex-row justify-start items-center text-gray-500 dark:text-gray-400 hover:underline hover:text-blue-600"
|
||||||
href={context.instanceUrl}
|
href={domain}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
>
|
>
|
||||||
<span className="mr-1">Go to my Slash</span>
|
<span className="mr-1">Go to my Slash</span>
|
||||||
@ -97,10 +81,10 @@ const IndexPopup = () => {
|
|||||||
) : (
|
) : (
|
||||||
<div className="w-full flex flex-col justify-start items-center">
|
<div className="w-full flex flex-col justify-start items-center">
|
||||||
<Icon.Cookie strokeWidth={1} className="w-20 h-auto mb-4 text-gray-400" />
|
<Icon.Cookie strokeWidth={1} className="w-20 h-auto mb-4 text-gray-400" />
|
||||||
<p className="dark:text-gray-400">Please set your instance URL and access token first.</p>
|
<p className="dark:text-gray-400">Please set your domain and access token first.</p>
|
||||||
<div className="w-full flex flex-row justify-center items-center py-4">
|
<div className="w-full flex flex-row justify-center items-center py-4">
|
||||||
<Button size="sm" color="primary" onClick={handleSettingButtonClick}>
|
<Button size="sm" color="primary" onClick={handleSettingButtonClick}>
|
||||||
<Icon.Settings className="w-5 h-auto mr-1" /> Go to Setting
|
<Icon.Settings className="w-5 h-auto mr-1" /> Setting
|
||||||
</Button>
|
</Button>
|
||||||
<span className="mx-2 dark:text-gray-400">Or</span>
|
<span className="mx-2 dark:text-gray-400">Or</span>
|
||||||
<Button size="sm" variant="outlined" color="neutral" onClick={handleRefreshButtonClick}>
|
<Button size="sm" variant="outlined" color="neutral" onClick={handleRefreshButtonClick}>
|
||||||
@ -116,12 +100,10 @@ const IndexPopup = () => {
|
|||||||
|
|
||||||
const Popup = () => {
|
const Popup = () => {
|
||||||
return (
|
return (
|
||||||
<StorageContextProvider>
|
<CssVarsProvider>
|
||||||
<CssVarsProvider>
|
<IndexPopup />
|
||||||
<IndexPopup />
|
<Toaster position="top-right" />
|
||||||
<Toaster position="top-center" />
|
</CssVarsProvider>
|
||||||
</CssVarsProvider>
|
|
||||||
</StorageContextProvider>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -1,3 +0,0 @@
|
|||||||
import useShortcutStore from "./shortcut";
|
|
||||||
|
|
||||||
export { useShortcutStore };
|
|
@ -1,55 +0,0 @@
|
|||||||
import axios from "axios";
|
|
||||||
import { create } from "zustand";
|
|
||||||
import { combine } from "zustand/middleware";
|
|
||||||
import { CreateShortcutResponse, ListShortcutsResponse, Shortcut } from "@/types/proto/api/v1/shortcut_service";
|
|
||||||
|
|
||||||
interface State {
|
|
||||||
shortcutMapById: Record<number, Shortcut>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const getDefaultState = (): State => {
|
|
||||||
return {
|
|
||||||
shortcutMapById: {},
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const useShortcutStore = create(
|
|
||||||
combine(getDefaultState(), (set, get) => ({
|
|
||||||
fetchShortcutList: async (instanceUrl: string, accessToken: string) => {
|
|
||||||
const {
|
|
||||||
data: { shortcuts },
|
|
||||||
} = await axios.get<ListShortcutsResponse>(`${instanceUrl}/api/v1/shortcuts`, {
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${accessToken}`,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const shortcutMap = get().shortcutMapById;
|
|
||||||
shortcuts.forEach((shortcut) => {
|
|
||||||
shortcutMap[shortcut.id] = shortcut;
|
|
||||||
});
|
|
||||||
set({ shortcutMapById: shortcutMap });
|
|
||||||
return shortcuts;
|
|
||||||
},
|
|
||||||
getShortcutList: () => {
|
|
||||||
return Object.values(get().shortcutMapById);
|
|
||||||
},
|
|
||||||
createShortcut: async (instanceUrl: string, accessToken: string, create: Shortcut) => {
|
|
||||||
const {
|
|
||||||
data: { shortcut },
|
|
||||||
} = await axios.post<CreateShortcutResponse>(`${instanceUrl}/api/v1/shortcuts`, create, {
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${accessToken}`,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (!shortcut) {
|
|
||||||
throw new Error(`Failed to create shortcut`);
|
|
||||||
}
|
|
||||||
const shortcutMap = get().shortcutMapById;
|
|
||||||
shortcutMap[shortcut.id] = shortcut;
|
|
||||||
set({ shortcutMapById: shortcutMap });
|
|
||||||
return shortcut;
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
);
|
|
||||||
|
|
||||||
export default useShortcutStore;
|
|
@ -76,8 +76,7 @@
|
|||||||
"enable-user-signup": {
|
"enable-user-signup": {
|
||||||
"self": "Enable user signup",
|
"self": "Enable user signup",
|
||||||
"description": "Once enabled, other users can signup."
|
"description": "Once enabled, other users can signup."
|
||||||
},
|
}
|
||||||
"default-visibility": "Default visibility"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -76,8 +76,7 @@
|
|||||||
"enable-user-signup": {
|
"enable-user-signup": {
|
||||||
"self": "启用用户注册",
|
"self": "启用用户注册",
|
||||||
"description": "允许其他用户注册新账号"
|
"description": "允许其他用户注册新账号"
|
||||||
},
|
}
|
||||||
"default-visibility": "默认可见性"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -2,10 +2,9 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" href="/logo.svg" type="image/*" />
|
<link rel="icon" href="/logo.png" type="image/*" />
|
||||||
<meta name="theme-color" content="#FFFFFF" />
|
<meta name="theme-color" content="#FFFFFF" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no" />
|
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no" />
|
||||||
<!-- slash.metadata -->
|
|
||||||
<title>Slash</title>
|
<title>Slash</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
@ -6,52 +6,51 @@
|
|||||||
"serve": "vite preview",
|
"serve": "vite preview",
|
||||||
"lint": "eslint --ext .js,.ts,.tsx, src",
|
"lint": "eslint --ext .js,.ts,.tsx, src",
|
||||||
"lint-fix": "eslint --ext .js,.ts,.tsx, src --fix",
|
"lint-fix": "eslint --ext .js,.ts,.tsx, src --fix",
|
||||||
"postinstall": "cd ../../proto && buf generate"
|
"type-gen": "cd ../../proto && buf generate"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@emotion/react": "^11.11.4",
|
"@emotion/react": "^11.11.1",
|
||||||
"@emotion/styled": "^11.11.5",
|
"@emotion/styled": "^11.11.0",
|
||||||
"@mui/joy": "5.0.0-beta.30",
|
"@mui/joy": "5.0.0-beta.14",
|
||||||
"@reduxjs/toolkit": "^1.9.7",
|
"@reduxjs/toolkit": "^1.9.7",
|
||||||
"classnames": "^2.5.1",
|
"axios": "^1.6.0",
|
||||||
|
"classnames": "^2.3.2",
|
||||||
"copy-to-clipboard": "^3.3.3",
|
"copy-to-clipboard": "^3.3.3",
|
||||||
"dayjs": "^1.11.10",
|
"dayjs": "^1.11.10",
|
||||||
"i18next": "^23.11.2",
|
"i18next": "^23.6.0",
|
||||||
"lodash-es": "^4.17.21",
|
"lodash-es": "^4.17.21",
|
||||||
"lucide-react": "^0.312.0",
|
"lucide-react": "^0.292.0",
|
||||||
"nice-grpc-web": "^3.3.3",
|
"nice-grpc-web": "^3.3.2",
|
||||||
"qrcode.react": "^3.1.0",
|
"qrcode.react": "^3.1.0",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"react-hot-toast": "^2.4.1",
|
"react-hot-toast": "^2.4.1",
|
||||||
"react-i18next": "^14.1.0",
|
"react-i18next": "^13.3.1",
|
||||||
"react-router-dom": "^6.22.3",
|
"react-redux": "^8.1.3",
|
||||||
"react-use": "^17.5.0",
|
"react-router-dom": "^6.18.0",
|
||||||
"tailwindcss": "^3.4.3",
|
"react-use": "^17.4.0",
|
||||||
"zustand": "^4.5.2"
|
"tailwindcss": "^3.3.5",
|
||||||
|
"zustand": "^4.4.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@bufbuild/buf": "^1.30.1",
|
"@bufbuild/buf": "^1.27.2",
|
||||||
"@trivago/prettier-plugin-sort-imports": "^4.3.0",
|
"@trivago/prettier-plugin-sort-imports": "^4.2.1",
|
||||||
"@types/lodash-es": "^4.17.12",
|
"@types/lodash-es": "^4.17.11",
|
||||||
"@types/react": "^18.2.79",
|
"@types/react": "^18.2.37",
|
||||||
"@types/react-dom": "^18.2.25",
|
"@types/react-dom": "^18.2.15",
|
||||||
"@typescript-eslint/eslint-plugin": "^6.21.0",
|
"@typescript-eslint/eslint-plugin": "^6.10.0",
|
||||||
"@typescript-eslint/parser": "^6.21.0",
|
"@typescript-eslint/parser": "^6.10.0",
|
||||||
"@vitejs/plugin-react-swc": "^3.6.0",
|
"@vitejs/plugin-react-swc": "^3.4.1",
|
||||||
"autoprefixer": "^10.4.19",
|
"autoprefixer": "^10.4.16",
|
||||||
"eslint": "^8.57.0",
|
"eslint": "^8.53.0",
|
||||||
"eslint-config-prettier": "^9.1.0",
|
"eslint-config-prettier": "^8.10.0",
|
||||||
"eslint-plugin-prettier": "^4.2.1",
|
"eslint-plugin-prettier": "^4.2.1",
|
||||||
"eslint-plugin-react": "^7.34.1",
|
"eslint-plugin-react": "^7.33.2",
|
||||||
"long": "^5.2.3",
|
"long": "^5.2.3",
|
||||||
"postcss": "^8.4.38",
|
"postcss": "^8.4.31",
|
||||||
"prettier": "2.6.2",
|
"prettier": "2.6.2",
|
||||||
"protobufjs": "^7.2.6",
|
"protobufjs": "^7.2.5",
|
||||||
"typescript": "^5.4.5",
|
"typescript": "^5.2.2",
|
||||||
"vite": "^5.2.9"
|
"vite": "^4.5.0"
|
||||||
},
|
|
||||||
"resolutions": {
|
|
||||||
"csstype": "3.1.2"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
6043
frontend/web/pnpm-lock.yaml
generated
6043
frontend/web/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
BIN
frontend/web/public/logo.png
Normal file
BIN
frontend/web/public/logo.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 257 KiB |
@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-circle-slash"><line x1="9" x2="15" y1="15" y2="9"/><circle cx="12" cy="12" r="10"/></svg>frontend/web/public/logo.svg
|
|
Before Width: | Height: | Size: 319 B |
@ -1,8 +1,9 @@
|
|||||||
import { useColorScheme } from "@mui/joy";
|
import { useColorScheme } from "@mui/joy";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Outlet } from "react-router-dom";
|
import { Outlet } from "react-router-dom";
|
||||||
import DemoBanner from "@/components/DemoBanner";
|
import DemoBanner from "./components/DemoBanner";
|
||||||
import { useUserStore, useWorkspaceStore } from "@/stores";
|
import useUserStore from "./stores/v1/user";
|
||||||
|
import useWorkspaceStore from "./stores/v1/workspace";
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const { mode: colorScheme } = useColorScheme();
|
const { mode: colorScheme } = useColorScheme();
|
||||||
@ -15,7 +16,7 @@ function App() {
|
|||||||
try {
|
try {
|
||||||
await Promise.all([workspaceStore.fetchWorkspaceProfile(), workspaceStore.fetchWorkspaceSetting(), userStore.fetchCurrentUser()]);
|
await Promise.all([workspaceStore.fetchWorkspaceProfile(), workspaceStore.fetchWorkspaceSetting(), userStore.fetchCurrentUser()]);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Do nothing.
|
// do nth
|
||||||
}
|
}
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
})();
|
})();
|
||||||
|
@ -21,11 +21,11 @@ const AboutDialog: React.FC<Props> = (props: Props) => {
|
|||||||
</div>
|
</div>
|
||||||
<div className="max-w-full w-80 sm:w-96">
|
<div className="max-w-full w-80 sm:w-96">
|
||||||
<p>
|
<p>
|
||||||
<span className="font-medium">Slash</span>: An open source, self-hosted links shortener and sharing platform.
|
<span className="font-medium">Slash</span>: An open source, self-hosted bookmarks and link sharing platform.
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-1">
|
<div className="mt-1">
|
||||||
<span className="mr-2">See more in</span>
|
<span className="mr-2">See more in</span>
|
||||||
<Link variant="plain" href="https://github.com/yourselfhosted/slash" target="_blank">
|
<Link variant="plain" href="https://github.com/boojack/slash" target="_blank">
|
||||||
GitHub
|
GitHub
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
@ -45,7 +45,7 @@ const Alert: React.FC<Props> = (props: Props) => {
|
|||||||
return (
|
return (
|
||||||
<Modal open={true}>
|
<Modal open={true}>
|
||||||
<ModalDialog>
|
<ModalDialog>
|
||||||
<div className="flex flex-row justify-between items-center w-80">
|
<div className="flex flex-row justify-between items-center w-80 mb-4">
|
||||||
<span className="text-lg font-medium">{title}</span>
|
<span className="text-lg font-medium">{title}</span>
|
||||||
<Button variant="plain" onClick={handleCloseBtnClick}>
|
<Button variant="plain" onClick={handleCloseBtnClick}>
|
||||||
<Icon.X className="w-5 h-auto text-gray-600" />
|
<Icon.X className="w-5 h-auto text-gray-600" />
|
||||||
|
@ -1,29 +1,28 @@
|
|||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { shortcutServiceClient } from "@/grpcweb";
|
import * as api from "../helpers/api";
|
||||||
import { GetShortcutAnalyticsResponse } from "@/types/proto/api/v1/shortcut_service";
|
|
||||||
import Icon from "./Icon";
|
import Icon from "./Icon";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
shortcutId: number;
|
shortcutId: ShortcutId;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const AnalyticsView: React.FC<Props> = (props: Props) => {
|
const AnalyticsView: React.FC<Props> = (props: Props) => {
|
||||||
const { shortcutId, className } = props;
|
const { shortcutId, className } = props;
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [analytics, setAnalytics] = useState<GetShortcutAnalyticsResponse | null>(null);
|
const [analytics, setAnalytics] = useState<AnalysisData | null>(null);
|
||||||
const [selectedDeviceTab, setSelectedDeviceTab] = useState<"os" | "browser">("browser");
|
const [selectedDeviceTab, setSelectedDeviceTab] = useState<"os" | "browser">("browser");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
shortcutServiceClient.getShortcutAnalytics({ id: shortcutId }).then((response) => {
|
api.getShortcutAnalytics(shortcutId).then(({ data }) => {
|
||||||
setAnalytics(response);
|
setAnalytics(data);
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={classNames("relative w-full", className)}>
|
<div className={classNames("w-full", className)}>
|
||||||
{analytics ? (
|
{analytics ? (
|
||||||
<>
|
<>
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
@ -35,13 +34,13 @@ const AnalyticsView: React.FC<Props> = (props: Props) => {
|
|||||||
<span className="py-2 pr-2 text-right font-semibold text-sm text-gray-500">{t("analytics.visitors")}</span>
|
<span className="py-2 pr-2 text-right font-semibold text-sm text-gray-500">{t("analytics.visitors")}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-full divide-y divide-gray-200 dark:divide-zinc-800">
|
<div className="w-full divide-y divide-gray-200 dark:divide-zinc-800">
|
||||||
{analytics.references.length === 0 && (
|
{analytics.referenceData.length === 0 && (
|
||||||
<div className="w-full flex flex-row justify-center items-center py-6 text-gray-400">
|
<div className="w-full flex flex-row justify-center items-center py-6 text-gray-400">
|
||||||
<Icon.PackageOpen className="w-6 h-auto" />
|
<Icon.PackageOpen className="w-6 h-auto" />
|
||||||
<p className="ml-2">No data found.</p>
|
<p className="ml-2">No data found.</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{analytics.references.map((reference) => (
|
{analytics.referenceData.map((reference) => (
|
||||||
<div key={reference.name} className="w-full flex flex-row justify-between items-center">
|
<div key={reference.name} className="w-full flex flex-row justify-between items-center">
|
||||||
<span className="whitespace-nowrap py-2 px-2 text-sm truncate text-gray-900 dark:text-gray-500">
|
<span className="whitespace-nowrap py-2 px-2 text-sm truncate text-gray-900 dark:text-gray-500">
|
||||||
{reference.name ? (
|
{reference.name ? (
|
||||||
@ -96,13 +95,13 @@ const AnalyticsView: React.FC<Props> = (props: Props) => {
|
|||||||
<span className="py-2 pr-2 text-right text-sm font-semibold text-gray-500">{t("analytics.visitors")}</span>
|
<span className="py-2 pr-2 text-right text-sm font-semibold text-gray-500">{t("analytics.visitors")}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-full divide-y divide-gray-200 dark:divide-zinc-800">
|
<div className="w-full divide-y divide-gray-200 dark:divide-zinc-800">
|
||||||
{analytics.browsers.length === 0 && (
|
{analytics.browserData.length === 0 && (
|
||||||
<div className="w-full flex flex-row justify-center items-center py-6 text-gray-400">
|
<div className="w-full flex flex-row justify-center items-center py-6 text-gray-400">
|
||||||
<Icon.PackageOpen className="w-6 h-auto" />
|
<Icon.PackageOpen className="w-6 h-auto" />
|
||||||
<p className="ml-2">No data found.</p>
|
<p className="ml-2">No data found.</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{analytics.browsers.map((reference) => (
|
{analytics.browserData.map((reference) => (
|
||||||
<div key={reference.name} className="w-full flex flex-row justify-between items-center">
|
<div key={reference.name} className="w-full flex flex-row justify-between items-center">
|
||||||
<span className="whitespace-nowrap py-2 px-2 text-sm text-gray-900 truncate dark:text-gray-500">
|
<span className="whitespace-nowrap py-2 px-2 text-sm text-gray-900 truncate dark:text-gray-500">
|
||||||
{reference.name || "Unknown"}
|
{reference.name || "Unknown"}
|
||||||
@ -119,13 +118,13 @@ const AnalyticsView: React.FC<Props> = (props: Props) => {
|
|||||||
<span className="py-2 pr-2 text-right text-sm font-semibold text-gray-500">{t("analytics.visitors")}</span>
|
<span className="py-2 pr-2 text-right text-sm font-semibold text-gray-500">{t("analytics.visitors")}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-full divide-y divide-gray-200">
|
<div className="w-full divide-y divide-gray-200">
|
||||||
{analytics.devices.length === 0 && (
|
{analytics.deviceData.length === 0 && (
|
||||||
<div className="w-full flex flex-row justify-center items-center py-6 text-gray-400">
|
<div className="w-full flex flex-row justify-center items-center py-6 text-gray-400">
|
||||||
<Icon.PackageOpen className="w-6 h-auto" />
|
<Icon.PackageOpen className="w-6 h-auto" />
|
||||||
<p className="ml-2">No data found.</p>
|
<p className="ml-2">No data found.</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{analytics.devices.map((device) => (
|
{analytics.deviceData.map((device) => (
|
||||||
<div key={device.name} className="w-full flex flex-row justify-between items-center">
|
<div key={device.name} className="w-full flex flex-row justify-between items-center">
|
||||||
<span className="whitespace-nowrap py-2 px-2 text-sm text-gray-900 truncate">{device.name || "Unknown"}</span>
|
<span className="whitespace-nowrap py-2 px-2 text-sm text-gray-900 truncate">{device.name || "Unknown"}</span>
|
||||||
<span className="whitespace-nowrap py-2 pr-2 text-sm text-gray-500 text-right shrink-0">{device.count}</span>
|
<span className="whitespace-nowrap py-2 pr-2 text-sm text-gray-500 text-right shrink-0">{device.count}</span>
|
||||||
@ -138,7 +137,7 @@ const AnalyticsView: React.FC<Props> = (props: Props) => {
|
|||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div className="absolute py-12 w-full flex flex-row justify-center items-center opacity-80">
|
<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" />
|
<Icon.Loader className="mr-2 w-5 h-auto animate-spin" />
|
||||||
{t("common.loading")}
|
{t("common.loading")}
|
||||||
</div>
|
</div>
|
||||||
|
@ -2,8 +2,8 @@ import { Button, Input, Modal, ModalDialog } from "@mui/joy";
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { toast } from "react-hot-toast";
|
import { toast } from "react-hot-toast";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import useLoading from "@/hooks/useLoading";
|
import useLoading from "../hooks/useLoading";
|
||||||
import { useUserStore } from "@/stores";
|
import useUserStore from "../stores/v1/user";
|
||||||
import Icon from "./Icon";
|
import Icon from "./Icon";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@ -54,7 +54,7 @@ const ChangePasswordDialog: React.FC<Props> = (props: Props) => {
|
|||||||
toast("Password changed");
|
toast("Password changed");
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
toast.error(error.details);
|
toast.error(error.response.data.message);
|
||||||
}
|
}
|
||||||
requestState.setFinish();
|
requestState.setFinish();
|
||||||
};
|
};
|
||||||
@ -62,7 +62,7 @@ const ChangePasswordDialog: React.FC<Props> = (props: Props) => {
|
|||||||
return (
|
return (
|
||||||
<Modal open={true}>
|
<Modal open={true}>
|
||||||
<ModalDialog>
|
<ModalDialog>
|
||||||
<div className="flex flex-row justify-between items-center w-80">
|
<div className="flex flex-row justify-between items-center w-80 mb-4">
|
||||||
<span className="text-lg font-medium">Change Password</span>
|
<span className="text-lg font-medium">Change Password</span>
|
||||||
<Button variant="plain" onClick={handleCloseBtnClick}>
|
<Button variant="plain" onClick={handleCloseBtnClick}>
|
||||||
<Icon.X className="w-5 h-auto text-gray-600" />
|
<Icon.X className="w-5 h-auto text-gray-600" />
|
||||||
|
@ -7,11 +7,11 @@ import { Link } from "react-router-dom";
|
|||||||
import { absolutifyLink } from "@/helpers/utils";
|
import { absolutifyLink } from "@/helpers/utils";
|
||||||
import useNavigateTo from "@/hooks/useNavigateTo";
|
import useNavigateTo from "@/hooks/useNavigateTo";
|
||||||
import useResponsiveWidth from "@/hooks/useResponsiveWidth";
|
import useResponsiveWidth from "@/hooks/useResponsiveWidth";
|
||||||
import { useCollectionStore, useShortcutStore, useUserStore } from "@/stores";
|
import { useAppSelector } from "@/stores";
|
||||||
import { Collection } from "@/types/proto/api/v1/collection_service";
|
import useCollectionStore from "@/stores/v1/collection";
|
||||||
import { Shortcut } from "@/types/proto/api/v1/shortcut_service";
|
import { Collection } from "@/types/proto/api/v2/collection_service";
|
||||||
import { showCommonDialog } from "./Alert";
|
import { showCommonDialog } from "./Alert";
|
||||||
import CreateCollectionDialog from "./CreateCollectionDrawer";
|
import CreateCollectionDialog from "./CreateCollectionDialog";
|
||||||
import Icon from "./Icon";
|
import Icon from "./Icon";
|
||||||
import ShortcutView from "./ShortcutView";
|
import ShortcutView from "./ShortcutView";
|
||||||
import Dropdown from "./common/Dropdown";
|
import Dropdown from "./common/Dropdown";
|
||||||
@ -25,15 +25,12 @@ const CollectionView = (props: Props) => {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { sm } = useResponsiveWidth();
|
const { sm } = useResponsiveWidth();
|
||||||
const navigateTo = useNavigateTo();
|
const navigateTo = useNavigateTo();
|
||||||
const userStore = useUserStore();
|
|
||||||
const currentUser = userStore.getCurrentUser();
|
|
||||||
const collectionStore = useCollectionStore();
|
const collectionStore = useCollectionStore();
|
||||||
const shortcutList = useShortcutStore().getShortcutList();
|
const { shortcutList } = useAppSelector((state) => state.shortcut);
|
||||||
const [showEditDialog, setShowEditDialog] = useState<boolean>(false);
|
const [showEditDialog, setShowEditDialog] = useState<boolean>(false);
|
||||||
const shortcuts = collection.shortcutIds
|
const shortcuts = collection.shortcutIds
|
||||||
.map((shortcutId) => shortcutList.find((shortcut) => shortcut?.id === shortcutId))
|
.map((shortcutId) => shortcutList.find((shortcut) => shortcut?.id === shortcutId))
|
||||||
.filter(Boolean) as any as Shortcut[];
|
.filter(Boolean) as any as Shortcut[];
|
||||||
const showAdminActions = currentUser.id === collection.creatorId;
|
|
||||||
|
|
||||||
const handleCopyCollectionLink = () => {
|
const handleCopyCollectionLink = () => {
|
||||||
copy(absolutifyLink(`/c/${collection.name}`));
|
copy(absolutifyLink(`/c/${collection.name}`));
|
||||||
@ -60,48 +57,37 @@ const CollectionView = (props: Props) => {
|
|||||||
<div className={classNames("w-full flex flex-col justify-start items-start border rounded-lg hover:shadow dark:border-zinc-800")}>
|
<div className={classNames("w-full flex flex-col justify-start items-start border rounded-lg hover:shadow dark:border-zinc-800")}>
|
||||||
<div className="bg-gray-100 dark:bg-zinc-800 px-3 py-2 w-full flex flex-row justify-between items-center rounded-t-lg">
|
<div className="bg-gray-100 dark:bg-zinc-800 px-3 py-2 w-full flex flex-row justify-between items-center rounded-t-lg">
|
||||||
<div className="w-auto flex flex-col justify-start items-start mr-2">
|
<div className="w-auto flex flex-col justify-start items-start mr-2">
|
||||||
<div className="w-full truncate">
|
<div className="w-full truncate" onClick={handleCopyCollectionLink}>
|
||||||
<Link className="leading-6 font-medium dark:text-gray-400" to={`/c/${collection.name}`} unstable_viewTransition>
|
<span className="leading-6 font-medium dark:text-gray-400">{collection.title}</span>
|
||||||
{collection.title}
|
<span className="ml-1 leading-6 text-gray-500 dark:text-gray-400">(c/{collection.name})</span>
|
||||||
</Link>
|
|
||||||
<span className="ml-1 leading-6 text-gray-500 dark:text-gray-400" onClick={handleCopyCollectionLink}>
|
|
||||||
(c/{collection.name})
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-gray-500">{collection.description}</p>
|
<p className="text-sm text-gray-500">{collection.description}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-row justify-end items-center shrink-0 gap-2">
|
<div className="flex flex-row justify-end items-center shrink-0">
|
||||||
<Link className="w-full text-gray-400 cursor-pointer hover:text-gray-500" to={`/c/${collection.name}`} target="_blank">
|
<Link className="w-full text-gray-400 cursor-pointer hover:text-gray-500" to={`/c/${collection.name}`}>
|
||||||
<Icon.Share className="w-4 h-auto" />
|
<Icon.Share className="w-4 h-auto mr-2" />
|
||||||
</Link>
|
</Link>
|
||||||
{showAdminActions && (
|
<Dropdown
|
||||||
<Dropdown
|
actionsClassName="!w-28 dark:text-gray-500"
|
||||||
trigger={
|
actions={
|
||||||
<button className="flex flex-row justify-center items-center rounded text-gray-400 cursor-pointer hover:text-gray-500">
|
<>
|
||||||
<Icon.MoreVertical className="w-4 h-auto" />
|
<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 dark:hover:bg-zinc-800"
|
||||||
|
onClick={() => setShowEditDialog(true)}
|
||||||
|
>
|
||||||
|
<Icon.Edit className="w-4 h-auto mr-2" /> {t("common.edit")}
|
||||||
</button>
|
</button>
|
||||||
}
|
<button
|
||||||
actionsClassName="!w-28 text-sm"
|
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 dark:hover:bg-zinc-800"
|
||||||
actions={
|
onClick={() => {
|
||||||
<>
|
handleDeleteCollectionButtonClick();
|
||||||
<button
|
}}
|
||||||
className="w-full px-2 flex flex-row justify-start items-center text-left dark:text-gray-400 leading-8 cursor-pointer rounded hover:bg-gray-100 dark:hover:bg-zinc-800 disabled:cursor-not-allowed disabled:bg-gray-100 disabled:opacity-60"
|
>
|
||||||
onClick={() => setShowEditDialog(true)}
|
<Icon.Trash className="w-4 h-auto mr-2" /> {t("common.delete")}
|
||||||
>
|
</button>
|
||||||
<Icon.Edit className="w-4 h-auto mr-2" /> {t("common.edit")}
|
</>
|
||||||
</button>
|
}
|
||||||
<button
|
></Dropdown>
|
||||||
className="w-full px-2 flex flex-row justify-start items-center text-left text-red-600 dark:text-gray-400 leading-8 cursor-pointer rounded hover:bg-gray-100 dark:hover:bg-zinc-800 disabled:cursor-not-allowed disabled:bg-gray-100 disabled:opacity-60"
|
|
||||||
onClick={() => {
|
|
||||||
handleDeleteCollectionButtonClick();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Icon.Trash className="w-4 h-auto mr-2" /> {t("common.delete")}
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
></Dropdown>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-full p-3 flex flex-row justify-start items-start flex-wrap gap-3">
|
<div className="w-full p-3 flex flex-row justify-start items-start flex-wrap gap-3">
|
||||||
|
@ -3,8 +3,8 @@ import { useState } from "react";
|
|||||||
import { toast } from "react-hot-toast";
|
import { toast } from "react-hot-toast";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { userServiceClient } from "@/grpcweb";
|
import { userServiceClient } from "@/grpcweb";
|
||||||
import useLoading from "@/hooks/useLoading";
|
import useLoading from "../hooks/useLoading";
|
||||||
import { useUserStore } from "@/stores";
|
import useUserStore from "../stores/v1/user";
|
||||||
import Icon from "./Icon";
|
import Icon from "./Icon";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@ -80,14 +80,14 @@ const CreateAccessTokenDialog: React.FC<Props> = (props: Props) => {
|
|||||||
onClose();
|
onClose();
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
toast.error(error.details);
|
toast.error(error.response.data.message);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal open={true}>
|
<Modal open={true}>
|
||||||
<ModalDialog>
|
<ModalDialog>
|
||||||
<div className="flex flex-row justify-between items-center w-80">
|
<div className="flex flex-row justify-between items-center w-80 sm:w-96 mb-4">
|
||||||
<span className="text-lg font-medium">Create Access Token</span>
|
<span className="text-lg font-medium">Create Access Token</span>
|
||||||
<Button variant="plain" onClick={onClose}>
|
<Button variant="plain" onClick={onClose}>
|
||||||
<Icon.X className="w-5 h-auto text-gray-600" />
|
<Icon.X className="w-5 h-auto text-gray-600" />
|
||||||
|
@ -1,14 +1,14 @@
|
|||||||
import { Button, DialogActions, DialogContent, DialogTitle, Drawer, Input, ModalClose, Radio, RadioGroup } from "@mui/joy";
|
import { Button, Input, Modal, ModalDialog, Radio, RadioGroup } from "@mui/joy";
|
||||||
import { isUndefined } from "lodash-es";
|
import { isUndefined } from "lodash-es";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { toast } from "react-hot-toast";
|
import { toast } from "react-hot-toast";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import useLoading from "@/hooks/useLoading";
|
import { useAppSelector } from "@/stores";
|
||||||
import { useCollectionStore, useShortcutStore, useWorkspaceStore } from "@/stores";
|
import useCollectionStore from "@/stores/v1/collection";
|
||||||
import { Collection } from "@/types/proto/api/v1/collection_service";
|
import { Collection } from "@/types/proto/api/v2/collection_service";
|
||||||
import { Visibility } from "@/types/proto/api/v1/common";
|
import { Visibility } from "@/types/proto/api/v2/common";
|
||||||
import { Shortcut } from "@/types/proto/api/v1/shortcut_service";
|
|
||||||
import { convertVisibilityFromPb } from "@/utils/visibility";
|
import { convertVisibilityFromPb } from "@/utils/visibility";
|
||||||
|
import useLoading from "../hooks/useLoading";
|
||||||
import Icon from "./Icon";
|
import Icon from "./Icon";
|
||||||
import ShortcutView from "./ShortcutView";
|
import ShortcutView from "./ShortcutView";
|
||||||
|
|
||||||
@ -22,50 +22,23 @@ interface State {
|
|||||||
collectionCreate: Collection;
|
collectionCreate: Collection;
|
||||||
}
|
}
|
||||||
|
|
||||||
const CreateCollectionDrawer: React.FC<Props> = (props: Props) => {
|
const CreateCollectionDialog: React.FC<Props> = (props: Props) => {
|
||||||
const { onClose, onConfirm, collectionId } = props;
|
const { onClose, onConfirm, collectionId } = props;
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const workspaceStore = useWorkspaceStore();
|
|
||||||
const collectionStore = useCollectionStore();
|
const collectionStore = useCollectionStore();
|
||||||
const shortcutList = useShortcutStore().getShortcutList();
|
const { shortcutList } = useAppSelector((state) => state.shortcut);
|
||||||
const [state, setState] = useState<State>({
|
const [state, setState] = useState<State>({
|
||||||
collectionCreate: Collection.fromPartial({
|
collectionCreate: Collection.fromPartial({
|
||||||
visibility: Visibility.PRIVATE,
|
visibility: Visibility.PRIVATE,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
const [selectedShortcuts, setSelectedShortcuts] = useState<Shortcut[]>([]);
|
const [selectedShortcuts, setSelectedShortcuts] = useState<Shortcut[]>([]);
|
||||||
const isCreating = isUndefined(collectionId);
|
|
||||||
const loadingState = useLoading(!isCreating);
|
|
||||||
const requestState = useLoading(false);
|
const requestState = useLoading(false);
|
||||||
|
const isCreating = isUndefined(collectionId);
|
||||||
const unselectedShortcuts = shortcutList
|
const unselectedShortcuts = shortcutList
|
||||||
.filter((shortcut) => {
|
.filter((shortcut) => (state.collectionCreate.visibility === Visibility.PUBLIC ? shortcut.visibility === "PUBLIC" : true))
|
||||||
if (state.collectionCreate.visibility === Visibility.PUBLIC) {
|
|
||||||
return shortcut.visibility === Visibility.PUBLIC;
|
|
||||||
} else if (state.collectionCreate.visibility === Visibility.WORKSPACE) {
|
|
||||||
return shortcut.visibility === Visibility.PUBLIC || shortcut.visibility === Visibility.WORKSPACE;
|
|
||||||
} else {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.filter((shortcut) => !selectedShortcuts.find((selectedShortcut) => selectedShortcut.id === shortcut.id));
|
.filter((shortcut) => !selectedShortcuts.find((selectedShortcut) => selectedShortcut.id === shortcut.id));
|
||||||
|
|
||||||
const setPartialState = (partialState: Partial<State>) => {
|
|
||||||
setState({
|
|
||||||
...state,
|
|
||||||
...partialState,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (workspaceStore.setting.defaultVisibility !== Visibility.VISIBILITY_UNSPECIFIED) {
|
|
||||||
setPartialState({
|
|
||||||
collectionCreate: Object.assign(state.collectionCreate, {
|
|
||||||
visibility: workspaceStore.setting.defaultVisibility,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
(async () => {
|
(async () => {
|
||||||
if (collectionId) {
|
if (collectionId) {
|
||||||
@ -82,15 +55,17 @@ const CreateCollectionDrawer: React.FC<Props> = (props: Props) => {
|
|||||||
.map((shortcutId) => shortcutList.find((shortcut) => shortcut.id === shortcutId))
|
.map((shortcutId) => shortcutList.find((shortcut) => shortcut.id === shortcutId))
|
||||||
.filter(Boolean) as Shortcut[]
|
.filter(Boolean) as Shortcut[]
|
||||||
);
|
);
|
||||||
loadingState.setFinish();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
}, [collectionId]);
|
}, [collectionId]);
|
||||||
|
|
||||||
if (loadingState.isLoading) {
|
const setPartialState = (partialState: Partial<State>) => {
|
||||||
return null;
|
setState({
|
||||||
}
|
...state,
|
||||||
|
...partialState,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const handleNameInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleNameInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
setPartialState({
|
setPartialState({
|
||||||
@ -111,7 +86,7 @@ const CreateCollectionDrawer: React.FC<Props> = (props: Props) => {
|
|||||||
const handleVisibilityInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleVisibilityInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
setPartialState({
|
setPartialState({
|
||||||
collectionCreate: Object.assign(state.collectionCreate, {
|
collectionCreate: Object.assign(state.collectionCreate, {
|
||||||
visibility: e.target.value,
|
visibility: Number(e.target.value),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@ -166,22 +141,28 @@ const CreateCollectionDrawer: React.FC<Props> = (props: Props) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Drawer anchor="right" open={true} onClose={onClose}>
|
<Modal open={true}>
|
||||||
<DialogTitle>{isCreating ? "Create Collection" : "Edit Collection"}</DialogTitle>
|
<ModalDialog>
|
||||||
<ModalClose />
|
<div className="w-full flex flex-row justify-between items-center">
|
||||||
<DialogContent className="w-full max-w-full">
|
<span className="text-lg font-medium">{isCreating ? "Create Collection" : "Edit Collection"}</span>
|
||||||
<div className="overflow-y-auto w-full mt-2 px-4 pb-4 sm:w-[24rem]">
|
<Button variant="plain" onClick={onClose}>
|
||||||
|
<Icon.X className="w-5 h-auto text-gray-600" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="overflow-y-auto overflow-x-hidden w-80 sm:w-96 max-w-full">
|
||||||
<div className="w-full flex flex-col justify-start items-start mb-3">
|
<div className="w-full flex flex-col justify-start items-start mb-3">
|
||||||
<span className="mb-2">
|
<span className="mb-2">
|
||||||
Name <span className="text-red-600">*</span>
|
Name <span className="text-red-600">*</span>
|
||||||
</span>
|
</span>
|
||||||
<Input
|
<div className="relative w-full">
|
||||||
className="w-full"
|
<Input
|
||||||
type="text"
|
className="w-full"
|
||||||
placeholder="The memorable name of the collection"
|
type="text"
|
||||||
value={state.collectionCreate.name}
|
placeholder="Should be an unique name and will be put in url"
|
||||||
onChange={handleNameInputChange}
|
value={state.collectionCreate.name}
|
||||||
/>
|
onChange={handleNameInputChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-full flex flex-col justify-start items-start mb-3">
|
<div className="w-full flex flex-col justify-start items-start mb-3">
|
||||||
<span className="mb-2">
|
<span className="mb-2">
|
||||||
@ -214,7 +195,6 @@ const CreateCollectionDrawer: React.FC<Props> = (props: Props) => {
|
|||||||
<div className="w-full flex flex-row justify-start items-center text-base">
|
<div className="w-full flex flex-row justify-start items-center text-base">
|
||||||
<RadioGroup orientation="horizontal" value={state.collectionCreate.visibility} onChange={handleVisibilityInputChange}>
|
<RadioGroup orientation="horizontal" value={state.collectionCreate.visibility} onChange={handleVisibilityInputChange}>
|
||||||
<Radio value={Visibility.PRIVATE} label={t(`shortcut.visibility.private.self`)} />
|
<Radio value={Visibility.PRIVATE} label={t(`shortcut.visibility.private.self`)} />
|
||||||
<Radio value={Visibility.WORKSPACE} label={t(`shortcut.visibility.workspace.self`)} />
|
|
||||||
<Radio value={Visibility.PUBLIC} label={t(`shortcut.visibility.public.self`)} />
|
<Radio value={Visibility.PUBLIC} label={t(`shortcut.visibility.public.self`)} />
|
||||||
</RadioGroup>
|
</RadioGroup>
|
||||||
</div>
|
</div>
|
||||||
@ -226,7 +206,7 @@ const CreateCollectionDrawer: React.FC<Props> = (props: Props) => {
|
|||||||
<p className="mb-2">
|
<p className="mb-2">
|
||||||
<span>Shortcuts</span>
|
<span>Shortcuts</span>
|
||||||
<span className="opacity-60">({selectedShortcuts.length})</span>
|
<span className="opacity-60">({selectedShortcuts.length})</span>
|
||||||
{selectedShortcuts.length === 0 && <span className="ml-2 italic opacity-80 text-sm">(Select a shortcut first)</span>}
|
{selectedShortcuts.length === 0 && <span className="ml-2 italic opacity-80 text-sm">Select a shortcut first</span>}
|
||||||
</p>
|
</p>
|
||||||
<div className="w-full py-1 px-px flex flex-row justify-start items-start flex-wrap overflow-hidden gap-2">
|
<div className="w-full py-1 px-px flex flex-row justify-start items-start flex-wrap overflow-hidden gap-2">
|
||||||
{selectedShortcuts.map((shortcut) => {
|
{selectedShortcuts.map((shortcut) => {
|
||||||
@ -261,20 +241,19 @@ const CreateCollectionDrawer: React.FC<Props> = (props: Props) => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="w-full flex flex-row justify-end items-center mt-4 space-x-2">
|
||||||
|
<Button color="neutral" variant="plain" disabled={requestState.isLoading} loading={requestState.isLoading} onClick={onClose}>
|
||||||
|
{t("common.cancel")}
|
||||||
|
</Button>
|
||||||
|
<Button color="primary" disabled={requestState.isLoading} loading={requestState.isLoading} onClick={handleSaveBtnClick}>
|
||||||
|
{t("common.save")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</ModalDialog>
|
||||||
<DialogActions>
|
</Modal>
|
||||||
<div className="w-full flex flex-row justify-end items-center px-3 py-4 space-x-2">
|
|
||||||
<Button color="neutral" variant="plain" disabled={requestState.isLoading} loading={requestState.isLoading} onClick={onClose}>
|
|
||||||
{t("common.cancel")}
|
|
||||||
</Button>
|
|
||||||
<Button color="primary" disabled={requestState.isLoading} loading={requestState.isLoading} onClick={handleSaveBtnClick}>
|
|
||||||
{t("common.save")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</DialogActions>
|
|
||||||
</Drawer>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default CreateCollectionDrawer;
|
export default CreateCollectionDialog;
|
@ -1,84 +1,57 @@
|
|||||||
import {
|
import { Button, Divider, Input, Modal, ModalDialog, Radio, RadioGroup, Textarea } from "@mui/joy";
|
||||||
Button,
|
|
||||||
DialogActions,
|
|
||||||
DialogContent,
|
|
||||||
DialogTitle,
|
|
||||||
Divider,
|
|
||||||
Drawer,
|
|
||||||
Input,
|
|
||||||
ModalClose,
|
|
||||||
Radio,
|
|
||||||
RadioGroup,
|
|
||||||
Textarea,
|
|
||||||
} from "@mui/joy";
|
|
||||||
import classnames from "classnames";
|
import classnames from "classnames";
|
||||||
import { isUndefined, uniq } from "lodash-es";
|
import { isUndefined, uniq } from "lodash-es";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { toast } from "react-hot-toast";
|
import { toast } from "react-hot-toast";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import useLoading from "@/hooks/useLoading";
|
import { useAppSelector } from "@/stores";
|
||||||
import { useWorkspaceStore, useShortcutStore } from "@/stores";
|
import useLoading from "../hooks/useLoading";
|
||||||
import { getShortcutUpdateMask } from "@/stores/shortcut";
|
import { shortcutService } from "../services";
|
||||||
import { Visibility } from "@/types/proto/api/v1/common";
|
|
||||||
import { Shortcut } from "@/types/proto/api/v1/shortcut_service";
|
|
||||||
import { convertVisibilityFromPb } from "@/utils/visibility";
|
|
||||||
import Icon from "./Icon";
|
import Icon from "./Icon";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
shortcutId?: number;
|
shortcutId?: ShortcutId;
|
||||||
initialShortcut?: Partial<Shortcut>;
|
initialShortcut?: Partial<Shortcut>;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onConfirm?: () => void;
|
onConfirm?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface State {
|
interface State {
|
||||||
shortcutCreate: Shortcut;
|
shortcutCreate: ShortcutCreate;
|
||||||
}
|
}
|
||||||
|
|
||||||
const CreateShortcutDrawer: React.FC<Props> = (props: Props) => {
|
const visibilities: Visibility[] = ["PRIVATE", "WORKSPACE", "PUBLIC"];
|
||||||
|
|
||||||
|
const CreateShortcutDialog: React.FC<Props> = (props: Props) => {
|
||||||
const { onClose, onConfirm, shortcutId, initialShortcut } = props;
|
const { onClose, onConfirm, shortcutId, initialShortcut } = props;
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const { shortcutList } = useAppSelector((state) => state.shortcut);
|
||||||
const [state, setState] = useState<State>({
|
const [state, setState] = useState<State>({
|
||||||
shortcutCreate: Shortcut.fromPartial({
|
shortcutCreate: {
|
||||||
visibility: Visibility.PUBLIC,
|
name: "",
|
||||||
ogMetadata: {
|
link: "",
|
||||||
|
title: "",
|
||||||
|
description: "",
|
||||||
|
visibility: "PRIVATE",
|
||||||
|
tags: [],
|
||||||
|
openGraphMetadata: {
|
||||||
title: "",
|
title: "",
|
||||||
description: "",
|
description: "",
|
||||||
image: "",
|
image: "",
|
||||||
},
|
},
|
||||||
...initialShortcut,
|
...initialShortcut,
|
||||||
}),
|
},
|
||||||
});
|
});
|
||||||
const shortcutStore = useShortcutStore();
|
const [showAdditionalFields, setShowAdditionalFields] = useState<boolean>(false);
|
||||||
const workspaceStore = useWorkspaceStore();
|
|
||||||
const [showOpenGraphMetadata, setShowOpenGraphMetadata] = useState<boolean>(false);
|
const [showOpenGraphMetadata, setShowOpenGraphMetadata] = useState<boolean>(false);
|
||||||
const shortcutList = shortcutStore.getShortcutList();
|
|
||||||
const [tag, setTag] = useState<string>("");
|
const [tag, setTag] = useState<string>("");
|
||||||
const tagSuggestions = uniq(shortcutList.map((shortcut) => shortcut.tags).flat());
|
const tagSuggestions = uniq(shortcutList.map((shortcut) => shortcut.tags).flat());
|
||||||
const isCreating = isUndefined(shortcutId);
|
|
||||||
const loadingState = useLoading(!isCreating);
|
|
||||||
const requestState = useLoading(false);
|
const requestState = useLoading(false);
|
||||||
|
const isCreating = isUndefined(shortcutId);
|
||||||
const setPartialState = (partialState: Partial<State>) => {
|
|
||||||
setState({
|
|
||||||
...state,
|
|
||||||
...partialState,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (workspaceStore.setting.defaultVisibility !== Visibility.VISIBILITY_UNSPECIFIED) {
|
|
||||||
setPartialState({
|
|
||||||
shortcutCreate: Object.assign(state.shortcutCreate, {
|
|
||||||
visibility: workspaceStore.setting.defaultVisibility,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (shortcutId) {
|
if (shortcutId) {
|
||||||
const shortcut = shortcutStore.getShortcutById(shortcutId);
|
const shortcut = shortcutService.getShortcutById(shortcutId);
|
||||||
if (shortcut) {
|
if (shortcut) {
|
||||||
setState({
|
setState({
|
||||||
...state,
|
...state,
|
||||||
@ -88,18 +61,20 @@ const CreateShortcutDrawer: React.FC<Props> = (props: Props) => {
|
|||||||
title: shortcut.title,
|
title: shortcut.title,
|
||||||
description: shortcut.description,
|
description: shortcut.description,
|
||||||
visibility: shortcut.visibility,
|
visibility: shortcut.visibility,
|
||||||
ogMetadata: shortcut.ogMetadata,
|
openGraphMetadata: shortcut.openGraphMetadata,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
setTag(shortcut.tags.join(" "));
|
setTag(shortcut.tags.join(" "));
|
||||||
loadingState.setFinish();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [shortcutId]);
|
}, [shortcutId]);
|
||||||
|
|
||||||
if (loadingState.isLoading) {
|
const setPartialState = (partialState: Partial<State>) => {
|
||||||
return null;
|
setState({
|
||||||
}
|
...state,
|
||||||
|
...partialState,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const handleNameInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleNameInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
setPartialState({
|
setPartialState({
|
||||||
@ -149,8 +124,8 @@ const CreateShortcutDrawer: React.FC<Props> = (props: Props) => {
|
|||||||
const handleOpenGraphMetadataImageChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleOpenGraphMetadataImageChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
setPartialState({
|
setPartialState({
|
||||||
shortcutCreate: Object.assign(state.shortcutCreate, {
|
shortcutCreate: Object.assign(state.shortcutCreate, {
|
||||||
ogMetadata: {
|
openGraphMetadata: {
|
||||||
...state.shortcutCreate.ogMetadata,
|
...state.shortcutCreate.openGraphMetadata,
|
||||||
image: e.target.value,
|
image: e.target.value,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
@ -160,8 +135,8 @@ const CreateShortcutDrawer: React.FC<Props> = (props: Props) => {
|
|||||||
const handleOpenGraphMetadataTitleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleOpenGraphMetadataTitleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
setPartialState({
|
setPartialState({
|
||||||
shortcutCreate: Object.assign(state.shortcutCreate, {
|
shortcutCreate: Object.assign(state.shortcutCreate, {
|
||||||
ogMetadata: {
|
openGraphMetadata: {
|
||||||
...state.shortcutCreate.ogMetadata,
|
...state.shortcutCreate.openGraphMetadata,
|
||||||
title: e.target.value,
|
title: e.target.value,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
@ -171,8 +146,8 @@ const CreateShortcutDrawer: React.FC<Props> = (props: Props) => {
|
|||||||
const handleOpenGraphMetadataDescriptionChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
const handleOpenGraphMetadataDescriptionChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||||
setPartialState({
|
setPartialState({
|
||||||
shortcutCreate: Object.assign(state.shortcutCreate, {
|
shortcutCreate: Object.assign(state.shortcutCreate, {
|
||||||
ogMetadata: {
|
openGraphMetadata: {
|
||||||
...state.shortcutCreate.ogMetadata,
|
...state.shortcutCreate.openGraphMetadata,
|
||||||
description: e.target.value,
|
description: e.target.value,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
@ -194,19 +169,21 @@ const CreateShortcutDrawer: React.FC<Props> = (props: Props) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const tags = tag.split(" ").filter(Boolean);
|
|
||||||
if (shortcutId) {
|
if (shortcutId) {
|
||||||
const originShortcut = shortcutStore.getShortcutById(shortcutId);
|
await shortcutService.patchShortcut({
|
||||||
const updatingShortcut = {
|
|
||||||
...state.shortcutCreate,
|
|
||||||
id: shortcutId,
|
id: shortcutId,
|
||||||
tags,
|
name: state.shortcutCreate.name,
|
||||||
};
|
link: state.shortcutCreate.link,
|
||||||
await shortcutStore.updateShortcut(updatingShortcut, getShortcutUpdateMask(originShortcut, updatingShortcut));
|
title: state.shortcutCreate.title,
|
||||||
|
description: state.shortcutCreate.description,
|
||||||
|
visibility: state.shortcutCreate.visibility,
|
||||||
|
tags: tag.split(" ").filter(Boolean),
|
||||||
|
openGraphMetadata: state.shortcutCreate.openGraphMetadata,
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
await shortcutStore.createShortcut({
|
await shortcutService.createShortcut({
|
||||||
...state.shortcutCreate,
|
...state.shortcutCreate,
|
||||||
tags,
|
tags: tag.split(" ").filter(Boolean),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -217,63 +194,49 @@ const CreateShortcutDrawer: React.FC<Props> = (props: Props) => {
|
|||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
toast.error(error.details);
|
toast.error(error.response.data.message);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Drawer anchor="right" open={true} onClose={onClose}>
|
<Modal open={true}>
|
||||||
<DialogTitle>{isCreating ? "Create Shortcut" : "Edit Shortcut"}</DialogTitle>
|
<ModalDialog>
|
||||||
<ModalClose />
|
<div className="flex flex-row justify-between items-center w-80 sm:w-96">
|
||||||
<DialogContent className="w-full max-w-full">
|
<span className="text-lg font-medium">{isCreating ? "Create Shortcut" : "Edit Shortcut"}</span>
|
||||||
<div className="overflow-y-auto w-full mt-2 px-4 pb-4 sm:w-[24rem]">
|
<Button variant="plain" onClick={onClose}>
|
||||||
|
<Icon.X className="w-5 h-auto text-gray-600" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="overflow-y-auto overflow-x-hidden">
|
||||||
<div className="w-full flex flex-col justify-start items-start mb-3">
|
<div className="w-full flex flex-col justify-start items-start mb-3">
|
||||||
<span className="mb-2">
|
<span className="mb-2">
|
||||||
Name <span className="text-red-600">*</span>
|
Name <span className="text-red-600">*</span>
|
||||||
</span>
|
</span>
|
||||||
<Input
|
<div className="relative w-full">
|
||||||
className="w-full"
|
<Input
|
||||||
type="text"
|
className="w-full"
|
||||||
placeholder="The memorable name of the shortcut"
|
type="text"
|
||||||
value={state.shortcutCreate.name}
|
placeholder="Should be an unique name and will be put in url"
|
||||||
onChange={handleNameInputChange}
|
value={state.shortcutCreate.name}
|
||||||
/>
|
onChange={handleNameInputChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-full flex flex-col justify-start items-start mb-3">
|
<div className="w-full flex flex-col justify-start items-start mb-3">
|
||||||
<span className="mb-2">
|
<span className="mb-2">
|
||||||
Link <span className="text-red-600">*</span>
|
Destination URL <span className="text-red-600">*</span>
|
||||||
</span>
|
</span>
|
||||||
<Input
|
<Input
|
||||||
className="w-full"
|
className="w-full"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="The destination link of the shortcut"
|
placeholder="https://github.com/boojack/slash"
|
||||||
value={state.shortcutCreate.link}
|
value={state.shortcutCreate.link}
|
||||||
onChange={handleLinkInputChange}
|
onChange={handleLinkInputChange}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-full flex flex-col justify-start items-start mb-3">
|
|
||||||
<span className="mb-2">Title</span>
|
|
||||||
<Input
|
|
||||||
className="w-full"
|
|
||||||
type="text"
|
|
||||||
placeholder="The title of the shortcut"
|
|
||||||
value={state.shortcutCreate.title}
|
|
||||||
onChange={handleTitleInputChange}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="w-full flex flex-col justify-start items-start mb-3">
|
|
||||||
<span className="mb-2">Description</span>
|
|
||||||
<Input
|
|
||||||
className="w-full"
|
|
||||||
type="text"
|
|
||||||
placeholder="A short description of the shortcut"
|
|
||||||
value={state.shortcutCreate.description}
|
|
||||||
onChange={handleDescriptionInputChange}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="w-full flex flex-col justify-start items-start mb-3">
|
<div className="w-full flex flex-col justify-start items-start mb-3">
|
||||||
<span className="mb-2">Tags</span>
|
<span className="mb-2">Tags</span>
|
||||||
<Input className="w-full" type="text" placeholder="The tags of shortcut" value={tag} onChange={handleTagsInputChange} />
|
<Input className="w-full" type="text" placeholder="github slash" value={tag} onChange={handleTagsInputChange} />
|
||||||
{tagSuggestions.length > 0 && (
|
{tagSuggestions.length > 0 && (
|
||||||
<div className="w-full flex flex-row justify-start items-start mt-2">
|
<div className="w-full flex flex-row justify-start items-start mt-2">
|
||||||
<Icon.Asterisk className="w-4 h-auto shrink-0 mx-1 text-gray-400 dark:text-gray-500" />
|
<Icon.Asterisk className="w-4 h-auto shrink-0 mx-1 text-gray-400 dark:text-gray-500" />
|
||||||
@ -295,17 +258,57 @@ const CreateShortcutDrawer: React.FC<Props> = (props: Props) => {
|
|||||||
<span className="mb-2">Visibility</span>
|
<span className="mb-2">Visibility</span>
|
||||||
<div className="w-full flex flex-row justify-start items-center text-base">
|
<div className="w-full flex flex-row justify-start items-center text-base">
|
||||||
<RadioGroup orientation="horizontal" value={state.shortcutCreate.visibility} onChange={handleVisibilityInputChange}>
|
<RadioGroup orientation="horizontal" value={state.shortcutCreate.visibility} onChange={handleVisibilityInputChange}>
|
||||||
<Radio value={Visibility.PRIVATE} label={t(`shortcut.visibility.private.self`)} />
|
{visibilities.map((visibility) => (
|
||||||
<Radio value={Visibility.WORKSPACE} label={t(`shortcut.visibility.workspace.self`)} />
|
<Radio key={visibility} value={visibility} label={t(`shortcut.visibility.${visibility.toLowerCase()}.self`)} />
|
||||||
<Radio value={Visibility.PUBLIC} label={t(`shortcut.visibility.public.self`)} />
|
))}
|
||||||
</RadioGroup>
|
</RadioGroup>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-3 text-sm text-gray-500 w-full bg-gray-100 border border-gray-200 dark:bg-zinc-800 dark:border-zinc-700 dark:text-gray-400 px-2 py-1 rounded-md">
|
<p className="mt-3 text-sm text-gray-500 w-full bg-gray-100 border border-gray-200 dark:bg-zinc-800 dark:border-zinc-700 dark:text-gray-400 px-2 py-1 rounded-md">
|
||||||
{t(`shortcut.visibility.${convertVisibilityFromPb(state.shortcutCreate.visibility).toLowerCase()}.description`)}
|
{t(`shortcut.visibility.${state.shortcutCreate.visibility.toLowerCase()}.description`)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Divider className="text-gray-500">More</Divider>
|
<Divider className="text-gray-500">More</Divider>
|
||||||
<div className="w-full flex flex-col justify-start items-start border rounded-md mt-3 overflow-hidden dark:border-zinc-800">
|
<div className="w-full flex flex-col justify-start items-start border rounded-md overflow-hidden my-3 dark:border-zinc-800">
|
||||||
|
<div
|
||||||
|
className={classnames(
|
||||||
|
"w-full flex flex-row justify-between items-center px-2 py-1 cursor-pointer hover:bg-gray-100 dark:hover:bg-zinc-800",
|
||||||
|
showAdditionalFields ? "bg-gray-100 border-b dark:bg-zinc-800 dark:border-b-zinc-700" : ""
|
||||||
|
)}
|
||||||
|
onClick={() => setShowAdditionalFields(!showAdditionalFields)}
|
||||||
|
>
|
||||||
|
<span className="text-sm">Additional fields</span>
|
||||||
|
<button className="w-7 h-7 p-1 rounded-md">
|
||||||
|
<Icon.ChevronDown className={classnames("w-4 h-auto text-gray-500", showAdditionalFields ? "transform rotate-180" : "")} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{showAdditionalFields && (
|
||||||
|
<div className="w-full px-2 py-1">
|
||||||
|
<div className="w-full flex flex-col justify-start items-start mb-3">
|
||||||
|
<span className="mb-2 text-sm">Title</span>
|
||||||
|
<Input
|
||||||
|
className="w-full"
|
||||||
|
type="text"
|
||||||
|
placeholder="Title"
|
||||||
|
size="sm"
|
||||||
|
value={state.shortcutCreate.title}
|
||||||
|
onChange={handleTitleInputChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="w-full flex flex-col justify-start items-start mb-3">
|
||||||
|
<span className="mb-2 text-sm">Description</span>
|
||||||
|
<Input
|
||||||
|
className="w-full"
|
||||||
|
type="text"
|
||||||
|
placeholder="Github repo for slash"
|
||||||
|
size="sm"
|
||||||
|
value={state.shortcutCreate.description}
|
||||||
|
onChange={handleDescriptionInputChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="w-full flex flex-col justify-start items-start border rounded-md overflow-hidden dark:border-zinc-800">
|
||||||
<div
|
<div
|
||||||
className={classnames(
|
className={classnames(
|
||||||
"w-full flex flex-row justify-between items-center px-2 py-1 cursor-pointer hover:bg-gray-100 dark:hover:bg-zinc-800",
|
"w-full flex flex-row justify-between items-center px-2 py-1 cursor-pointer hover:bg-gray-100 dark:hover:bg-zinc-800",
|
||||||
@ -330,7 +333,7 @@ const CreateShortcutDrawer: React.FC<Props> = (props: Props) => {
|
|||||||
type="text"
|
type="text"
|
||||||
placeholder="https://the.link.to/the/image.png"
|
placeholder="https://the.link.to/the/image.png"
|
||||||
size="sm"
|
size="sm"
|
||||||
value={state.shortcutCreate.ogMetadata?.image}
|
value={state.shortcutCreate.openGraphMetadata.image}
|
||||||
onChange={handleOpenGraphMetadataImageChange}
|
onChange={handleOpenGraphMetadataImageChange}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -339,9 +342,9 @@ const CreateShortcutDrawer: React.FC<Props> = (props: Props) => {
|
|||||||
<Input
|
<Input
|
||||||
className="w-full"
|
className="w-full"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Slash - An open source, self-hosted links shortener and sharing platform"
|
placeholder="Slash - An open source, self-hosted bookmarks and link sharing platform"
|
||||||
size="sm"
|
size="sm"
|
||||||
value={state.shortcutCreate.ogMetadata?.title}
|
value={state.shortcutCreate.openGraphMetadata.title}
|
||||||
onChange={handleOpenGraphMetadataTitleChange}
|
onChange={handleOpenGraphMetadataTitleChange}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -349,30 +352,29 @@ const CreateShortcutDrawer: React.FC<Props> = (props: Props) => {
|
|||||||
<span className="mb-2 text-sm">Description</span>
|
<span className="mb-2 text-sm">Description</span>
|
||||||
<Textarea
|
<Textarea
|
||||||
className="w-full"
|
className="w-full"
|
||||||
placeholder="An open source, self-hosted links shortener and sharing platform."
|
placeholder="An open source, self-hosted bookmarks and link sharing platform."
|
||||||
size="sm"
|
size="sm"
|
||||||
maxRows={3}
|
maxRows={3}
|
||||||
value={state.shortcutCreate.ogMetadata?.description}
|
value={state.shortcutCreate.openGraphMetadata.description}
|
||||||
onChange={handleOpenGraphMetadataDescriptionChange}
|
onChange={handleOpenGraphMetadataDescriptionChange}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="w-full flex flex-row justify-end items-center mt-4 space-x-2">
|
||||||
|
<Button color="neutral" variant="plain" disabled={requestState.isLoading} loading={requestState.isLoading} onClick={onClose}>
|
||||||
|
{t("common.cancel")}
|
||||||
|
</Button>
|
||||||
|
<Button color="primary" disabled={requestState.isLoading} loading={requestState.isLoading} onClick={handleSaveBtnClick}>
|
||||||
|
{t("common.save")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</ModalDialog>
|
||||||
<DialogActions>
|
</Modal>
|
||||||
<div className="w-full flex flex-row justify-end items-center px-3 py-4 space-x-2">
|
|
||||||
<Button color="neutral" variant="plain" disabled={requestState.isLoading} loading={requestState.isLoading} onClick={onClose}>
|
|
||||||
{t("common.cancel")}
|
|
||||||
</Button>
|
|
||||||
<Button color="primary" disabled={requestState.isLoading} loading={requestState.isLoading} onClick={handleSaveBtnClick}>
|
|
||||||
{t("common.save")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</DialogActions>
|
|
||||||
</Drawer>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default CreateShortcutDrawer;
|
export default CreateShortcutDialog;
|
@ -3,9 +3,8 @@ import { isUndefined } from "lodash-es";
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { toast } from "react-hot-toast";
|
import { toast } from "react-hot-toast";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import useLoading from "@/hooks/useLoading";
|
import useLoading from "../hooks/useLoading";
|
||||||
import { useUserStore } from "@/stores";
|
import useUserStore from "../stores/v1/user";
|
||||||
import { Role, User } from "@/types/proto/api/v1/user_service";
|
|
||||||
import Icon from "./Icon";
|
import Icon from "./Icon";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@ -15,9 +14,11 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface State {
|
interface State {
|
||||||
userCreate: Pick<User, "email" | "nickname" | "password" | "role">;
|
userCreate: UserCreate;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const roles: Role[] = ["USER", "ADMIN"];
|
||||||
|
|
||||||
const CreateUserDialog: React.FC<Props> = (props: Props) => {
|
const CreateUserDialog: React.FC<Props> = (props: Props) => {
|
||||||
const { onClose, onConfirm, user } = props;
|
const { onClose, onConfirm, user } = props;
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@ -27,7 +28,7 @@ const CreateUserDialog: React.FC<Props> = (props: Props) => {
|
|||||||
email: "",
|
email: "",
|
||||||
nickname: "",
|
nickname: "",
|
||||||
password: "",
|
password: "",
|
||||||
role: Role.USER,
|
role: "USER",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const requestState = useLoading(false);
|
const requestState = useLoading(false);
|
||||||
@ -94,7 +95,7 @@ const CreateUserDialog: React.FC<Props> = (props: Props) => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
if (user) {
|
if (user) {
|
||||||
const userPatch: Partial<User> = {
|
const userPatch: UserPatch = {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
};
|
};
|
||||||
if (user.email !== state.userCreate.email) {
|
if (user.email !== state.userCreate.email) {
|
||||||
@ -118,14 +119,14 @@ const CreateUserDialog: React.FC<Props> = (props: Props) => {
|
|||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
toast.error(error.details);
|
toast.error(error.response.data.message);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal open={true}>
|
<Modal open={true}>
|
||||||
<ModalDialog>
|
<ModalDialog>
|
||||||
<div className="flex flex-row justify-between items-center w-80 sm:w-96">
|
<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>
|
<span className="text-lg font-medium">{isCreating ? "Create User" : "Edit User"}</span>
|
||||||
<Button variant="plain" onClick={onClose}>
|
<Button variant="plain" onClick={onClose}>
|
||||||
<Icon.X className="w-5 h-auto text-gray-600" />
|
<Icon.X className="w-5 h-auto text-gray-600" />
|
||||||
@ -178,8 +179,9 @@ const CreateUserDialog: React.FC<Props> = (props: Props) => {
|
|||||||
</span>
|
</span>
|
||||||
<div className="w-full flex flex-row justify-start items-center text-base">
|
<div className="w-full flex flex-row justify-start items-center text-base">
|
||||||
<RadioGroup orientation="horizontal" value={state.userCreate.role} onChange={handleRoleInputChange}>
|
<RadioGroup orientation="horizontal" value={state.userCreate.role} onChange={handleRoleInputChange}>
|
||||||
<Radio value={Role.USER} label={"User"} />
|
{roles.map((role) => (
|
||||||
<Radio value={Role.ADMIN} label={"Admin"} />
|
<Radio key={role} value={role} label={role} />
|
||||||
|
))}
|
||||||
</RadioGroup>
|
</RadioGroup>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { useWorkspaceStore } from "@/stores";
|
import useWorkspaceStore from "@/stores/v1/workspace";
|
||||||
import Icon from "./Icon";
|
import Icon from "./Icon";
|
||||||
|
|
||||||
const DemoBanner: React.FC = () => {
|
const DemoBanner: React.FC = () => {
|
||||||
@ -10,10 +10,10 @@ const DemoBanner: React.FC = () => {
|
|||||||
return (
|
return (
|
||||||
<div className="z-10 relative 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="z-10 relative 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-8xl px-4 md:px-12 flex flex-row justify-between items-center gap-x-3">
|
<div className="w-full max-w-8xl px-4 md:px-12 flex flex-row justify-between items-center gap-x-3">
|
||||||
<span>✨🔗 Slash - An open source, self-hosted links shortener and sharing platform</span>
|
<span>✨🔗 Slash - An open source, self-hosted bookmarks and link sharing platform</span>
|
||||||
<a
|
<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"
|
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/yourselfhosted/slash#deploy-with-docker-in-seconds"
|
href="https://github.com/boojack/slash#deploy-with-docker-in-seconds"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
>
|
>
|
||||||
Install
|
Install
|
||||||
|
@ -2,8 +2,8 @@ import { Button, Input, Modal, ModalDialog } from "@mui/joy";
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { toast } from "react-hot-toast";
|
import { toast } from "react-hot-toast";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import useLoading from "@/hooks/useLoading";
|
import useLoading from "../hooks/useLoading";
|
||||||
import { useUserStore } from "@/stores";
|
import useUserStore from "../stores/v1/user";
|
||||||
import Icon from "./Icon";
|
import Icon from "./Icon";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@ -50,7 +50,7 @@ const EditUserinfoDialog: React.FC<Props> = (props: Props) => {
|
|||||||
toast("User information updated");
|
toast("User information updated");
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
toast.error(error.details);
|
toast.error(error.response.data.message);
|
||||||
}
|
}
|
||||||
requestState.setFinish();
|
requestState.setFinish();
|
||||||
};
|
};
|
||||||
@ -58,7 +58,7 @@ const EditUserinfoDialog: React.FC<Props> = (props: Props) => {
|
|||||||
return (
|
return (
|
||||||
<Modal open={true}>
|
<Modal open={true}>
|
||||||
<ModalDialog>
|
<ModalDialog>
|
||||||
<div className="flex flex-row justify-between items-center w-80">
|
<div className="flex flex-row justify-between items-center w-80 mb-4">
|
||||||
<span className="text-lg font-medium">Edit Userinfo</span>
|
<span className="text-lg font-medium">Edit Userinfo</span>
|
||||||
<Button variant="plain" onClick={handleCloseBtnClick}>
|
<Button variant="plain" onClick={handleCloseBtnClick}>
|
||||||
<Icon.X className="w-5 h-auto text-gray-600" />
|
<Icon.X className="w-5 h-auto text-gray-600" />
|
||||||
|
@ -1,6 +1,5 @@
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useViewStore } from "@/stores";
|
import useViewStore from "../stores/v1/view";
|
||||||
import { convertVisibilityFromPb } from "@/utils/visibility";
|
|
||||||
import Icon from "./Icon";
|
import Icon from "./Icon";
|
||||||
import VisibilityIcon from "./VisibilityIcon";
|
import VisibilityIcon from "./VisibilityIcon";
|
||||||
|
|
||||||
@ -33,7 +32,7 @@ const FilterView = () => {
|
|||||||
onClick={() => viewStore.setFilter({ visibility: undefined })}
|
onClick={() => viewStore.setFilter({ visibility: undefined })}
|
||||||
>
|
>
|
||||||
<VisibilityIcon className="w-4 h-auto mr-1" visibility={filter.visibility} />
|
<VisibilityIcon className="w-4 h-auto mr-1" visibility={filter.visibility} />
|
||||||
{t(`shortcut.visibility.${convertVisibilityFromPb(filter.visibility).toLowerCase()}.self`)}
|
{t(`shortcut.visibility.${filter.visibility.toLowerCase()}.self`)}
|
||||||
<Icon.X className="w-4 h-auto ml-1" />
|
<Icon.X className="w-4 h-auto ml-1" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
@ -3,8 +3,7 @@ import { QRCodeCanvas } from "qrcode.react";
|
|||||||
import { useRef } from "react";
|
import { useRef } from "react";
|
||||||
import { toast } from "react-hot-toast";
|
import { toast } from "react-hot-toast";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { absolutifyLink } from "@/helpers/utils";
|
import { absolutifyLink } from "../helpers/utils";
|
||||||
import { Shortcut } from "@/types/proto/api/v1/shortcut_service";
|
|
||||||
import Icon from "./Icon";
|
import Icon from "./Icon";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@ -25,12 +24,12 @@ const GenerateQRCodeDialog: React.FC<Props> = (props: Props) => {
|
|||||||
const handleDownloadQRCodeClick = () => {
|
const handleDownloadQRCodeClick = () => {
|
||||||
const canvas = containerRef.current?.querySelector("canvas");
|
const canvas = containerRef.current?.querySelector("canvas");
|
||||||
if (!canvas) {
|
if (!canvas) {
|
||||||
toast.error("Failed to get QR code canvas");
|
toast.error("Failed to get qr code canvas");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const link = document.createElement("a");
|
const link = document.createElement("a");
|
||||||
link.download = `${shortcut.title || shortcut.name}-qrcode.png`;
|
link.download = "filename.png";
|
||||||
link.href = canvas.toDataURL();
|
link.href = canvas.toDataURL();
|
||||||
link.click();
|
link.click();
|
||||||
handleCloseBtnClick();
|
handleCloseBtnClick();
|
||||||
@ -39,7 +38,7 @@ const GenerateQRCodeDialog: React.FC<Props> = (props: Props) => {
|
|||||||
return (
|
return (
|
||||||
<Modal open={true}>
|
<Modal open={true}>
|
||||||
<ModalDialog>
|
<ModalDialog>
|
||||||
<div className="flex flex-row justify-between items-center w-64">
|
<div className="flex flex-row justify-between items-center w-64 mb-4">
|
||||||
<span className="text-lg font-medium">QR Code</span>
|
<span className="text-lg font-medium">QR Code</span>
|
||||||
<Button variant="plain" onClick={handleCloseBtnClick}>
|
<Button variant="plain" onClick={handleCloseBtnClick}>
|
||||||
<Icon.X className="w-5 h-auto text-gray-600" />
|
<Icon.X className="w-5 h-auto text-gray-600" />
|
||||||
@ -47,7 +46,7 @@ const GenerateQRCodeDialog: React.FC<Props> = (props: Props) => {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div ref={containerRef} className="w-full flex flex-row justify-center items-center mt-2 mb-6">
|
<div ref={containerRef} className="w-full flex flex-row justify-center items-center mt-2 mb-6">
|
||||||
<QRCodeCanvas value={shortcutLink} size={180} bgColor={"#ffffff"} fgColor={"#000000"} includeMargin={false} level={"L"} />
|
<QRCodeCanvas value={shortcutLink} size={128} bgColor={"#ffffff"} fgColor={"#000000"} includeMargin={false} level={"L"} />
|
||||||
</div>
|
</div>
|
||||||
<div className="w-full flex flex-row justify-center items-center px-4">
|
<div className="w-full flex flex-row justify-center items-center px-4">
|
||||||
<Button className="w-full" color="neutral" onClick={handleDownloadQRCodeClick}>
|
<Button className="w-full" color="neutral" onClick={handleDownloadQRCodeClick}>
|
||||||
|
@ -2,10 +2,10 @@ import { Avatar } from "@mui/joy";
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Link, useLocation } from "react-router-dom";
|
import { Link, useLocation } from "react-router-dom";
|
||||||
import { authServiceClient } from "@/grpcweb";
|
import useWorkspaceStore from "@/stores/v1/workspace";
|
||||||
import { useWorkspaceStore, useUserStore } from "@/stores";
|
import { PlanType } from "@/types/proto/api/v2/subscription_service";
|
||||||
import { PlanType } from "@/types/proto/api/v1/subscription_service";
|
import * as api from "../helpers/api";
|
||||||
import { Role } from "@/types/proto/api/v1/user_service";
|
import useUserStore from "../stores/v1/user";
|
||||||
import AboutDialog from "./AboutDialog";
|
import AboutDialog from "./AboutDialog";
|
||||||
import Icon from "./Icon";
|
import Icon from "./Icon";
|
||||||
import Dropdown from "./common/Dropdown";
|
import Dropdown from "./common/Dropdown";
|
||||||
@ -17,22 +17,21 @@ const Header: React.FC = () => {
|
|||||||
const currentUser = useUserStore().getCurrentUser();
|
const currentUser = useUserStore().getCurrentUser();
|
||||||
const [showAboutDialog, setShowAboutDialog] = useState<boolean>(false);
|
const [showAboutDialog, setShowAboutDialog] = useState<boolean>(false);
|
||||||
const profile = workspaceStore.profile;
|
const profile = workspaceStore.profile;
|
||||||
const isAdmin = currentUser.role === Role.ADMIN;
|
const isAdmin = currentUser.role === "ADMIN";
|
||||||
const shouldShowRouterSwitch = location.pathname === "/shortcuts" || location.pathname === "/collections";
|
const shouldShowRouterSwitch = location.pathname === "/" || location.pathname === "/collections";
|
||||||
const selectedSection = location.pathname === "/shortcuts" ? "Shortcuts" : location.pathname === "/collections" ? "Collections" : "";
|
|
||||||
|
|
||||||
const handleSignOutButtonClick = async () => {
|
const handleSignOutButtonClick = async () => {
|
||||||
await authServiceClient.signOut({});
|
await api.signout();
|
||||||
window.location.href = "/auth";
|
window.location.href = "/auth";
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="w-full bg-gray-50 dark:bg-zinc-800 border-b border-b-gray-200 dark:border-b-zinc-800">
|
<div className="w-full bg-gray-50 dark:bg-zinc-800 border-b border-b-gray-200 dark:border-b-zinc-800">
|
||||||
<div className="w-full max-w-8xl mx-auto px-4 sm:px-6 md:px-12 py-3 flex flex-row justify-between items-center">
|
<div className="w-full max-w-8xl mx-auto px-3 md:px-12 py-3 flex flex-row justify-between items-center">
|
||||||
<div className="flex flex-row justify-start items-center shrink mr-2">
|
<div className="flex flex-row justify-start items-center shrink mr-2">
|
||||||
<Link to="/" className="cursor-pointer flex flex-row justify-start items-center dark:text-gray-400" unstable_viewTransition>
|
<Link to="/" className="cursor-pointer flex flex-row justify-start items-center dark:text-gray-400">
|
||||||
<Icon.CircleSlash className="w-7 h-auto dark:text-gray-500 mr-2" strokeWidth={1.5} />
|
<img id="logo-img" src="/logo.png" className="w-7 h-auto mr-2 -mt-0.5 dark:opacity-80 rounded-full shadow" alt="" />
|
||||||
Slash
|
Slash
|
||||||
</Link>
|
</Link>
|
||||||
{profile.plan === PlanType.PRO && (
|
{profile.plan === PlanType.PRO && (
|
||||||
@ -42,11 +41,11 @@ const Header: React.FC = () => {
|
|||||||
)}
|
)}
|
||||||
{shouldShowRouterSwitch && (
|
{shouldShowRouterSwitch && (
|
||||||
<>
|
<>
|
||||||
<span className="font-mono opacity-60 mx-1 dark:text-gray-400">/</span>
|
<span className="font-mono opacity-60 mx-1">/</span>
|
||||||
<Dropdown
|
<Dropdown
|
||||||
trigger={
|
trigger={
|
||||||
<button className="flex flex-row justify-end items-center cursor-pointer">
|
<button className="flex flex-row justify-end items-center cursor-pointer">
|
||||||
<span className="dark:text-gray-400">{selectedSection}</span>
|
<span className="dark:text-gray-400">{location.pathname === "/" ? "Shortcuts" : "Collections"}</span>
|
||||||
<Icon.ChevronsUpDown className="ml-1 w-4 h-auto text-gray-600 dark:text-gray-400" />
|
<Icon.ChevronsUpDown className="ml-1 w-4 h-auto text-gray-600 dark:text-gray-400" />
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
@ -54,16 +53,14 @@ const Header: React.FC = () => {
|
|||||||
actions={
|
actions={
|
||||||
<>
|
<>
|
||||||
<Link
|
<Link
|
||||||
|
to="/"
|
||||||
className="w-full px-2 flex flex-row justify-start items-center text-left dark:text-gray-400 leading-8 cursor-pointer rounded hover:bg-gray-100 dark:hover:bg-zinc-800 disabled:cursor-not-allowed disabled:bg-gray-100 disabled:opacity-60"
|
className="w-full px-2 flex flex-row justify-start items-center text-left dark:text-gray-400 leading-8 cursor-pointer rounded hover:bg-gray-100 dark:hover:bg-zinc-800 disabled:cursor-not-allowed disabled:bg-gray-100 disabled:opacity-60"
|
||||||
to="/shortcuts"
|
|
||||||
unstable_viewTransition
|
|
||||||
>
|
>
|
||||||
<Icon.SquareSlash className="w-5 h-auto mr-2 opacity-70" /> Shortcuts
|
<Icon.SquareSlash className="w-5 h-auto mr-2 opacity-70" /> Shortcuts
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
className="w-full px-2 flex flex-row justify-start items-center text-left dark:text-gray-400 leading-8 cursor-pointer rounded hover:bg-gray-100 dark:hover:bg-zinc-800 disabled:cursor-not-allowed disabled:bg-gray-100 disabled:opacity-60"
|
|
||||||
to="/collections"
|
to="/collections"
|
||||||
unstable_viewTransition
|
className="w-full px-2 flex flex-row justify-start items-center text-left dark:text-gray-400 leading-8 cursor-pointer rounded hover:bg-gray-100 dark:hover:bg-zinc-800 disabled:cursor-not-allowed disabled:bg-gray-100 disabled:opacity-60"
|
||||||
>
|
>
|
||||||
<Icon.LibrarySquare className="w-5 h-auto mr-2 opacity-70" /> Collections
|
<Icon.LibrarySquare className="w-5 h-auto mr-2 opacity-70" /> Collections
|
||||||
</Link>
|
</Link>
|
||||||
@ -86,32 +83,30 @@ const Header: React.FC = () => {
|
|||||||
actions={
|
actions={
|
||||||
<>
|
<>
|
||||||
<Link
|
<Link
|
||||||
className="w-full px-2 flex flex-row justify-start items-center text-left dark:text-gray-400 leading-8 cursor-pointer rounded hover:bg-gray-100 dark:hover:bg-zinc-800 disabled:cursor-not-allowed disabled:bg-gray-100 disabled:opacity-60"
|
|
||||||
to="/setting/general"
|
to="/setting/general"
|
||||||
unstable_viewTransition
|
className="w-full px-2 flex flex-row justify-start items-center text-left dark:text-gray-400 leading-8 cursor-pointer rounded hover:bg-gray-100 dark:hover:bg-zinc-800 disabled:cursor-not-allowed disabled:bg-gray-100 disabled:opacity-60"
|
||||||
>
|
>
|
||||||
<Icon.User className="w-5 h-auto mr-2 opacity-70" /> {t("user.profile")}
|
<Icon.User className="w-4 h-auto mr-2" /> {t("user.profile")}
|
||||||
</Link>
|
</Link>
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<Link
|
<Link
|
||||||
className="w-full px-2 flex flex-row justify-start items-center text-left dark:text-gray-400 leading-8 cursor-pointer rounded hover:bg-gray-100 dark:hover:bg-zinc-800 disabled:cursor-not-allowed disabled:bg-gray-100 disabled:opacity-60"
|
|
||||||
to="/setting/workspace"
|
to="/setting/workspace"
|
||||||
unstable_viewTransition
|
className="w-full px-2 flex flex-row justify-start items-center text-left dark:text-gray-400 leading-8 cursor-pointer rounded hover:bg-gray-100 dark:hover:bg-zinc-800 disabled:cursor-not-allowed disabled:bg-gray-100 disabled:opacity-60"
|
||||||
>
|
>
|
||||||
<Icon.Settings className="w-5 h-auto mr-2 opacity-70" /> {t("settings.self")}
|
<Icon.Settings className="w-4 h-auto mr-2" /> {t("settings.self")}
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
className="w-full px-2 flex flex-row justify-start items-center text-left dark:text-gray-400 leading-8 cursor-pointer rounded hover:bg-gray-100 dark:hover:bg-zinc-800 disabled:cursor-not-allowed disabled:bg-gray-100 disabled:opacity-60"
|
className="w-full px-2 flex flex-row justify-start items-center text-left dark:text-gray-400 leading-8 cursor-pointer rounded hover:bg-gray-100 dark:hover:bg-zinc-800 disabled:cursor-not-allowed disabled:bg-gray-100 disabled:opacity-60"
|
||||||
onClick={() => setShowAboutDialog(true)}
|
onClick={() => setShowAboutDialog(true)}
|
||||||
>
|
>
|
||||||
<Icon.Info className="w-5 h-auto mr-2 opacity-70" /> {t("common.about")}
|
<Icon.Info className="w-4 h-auto mr-2" /> {t("common.about")}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="w-full px-2 flex flex-row justify-start items-center text-left dark:text-gray-400 leading-8 cursor-pointer rounded hover:bg-gray-100 dark:hover:bg-zinc-800 disabled:cursor-not-allowed disabled:bg-gray-100 disabled:opacity-60"
|
className="w-full px-2 flex flex-row justify-start items-center text-left dark:text-gray-400 leading-8 cursor-pointer rounded hover:bg-gray-100 dark:hover:bg-zinc-800 disabled:cursor-not-allowed disabled:bg-gray-100 disabled:opacity-60"
|
||||||
onClick={() => handleSignOutButtonClick()}
|
onClick={() => handleSignOutButtonClick()}
|
||||||
>
|
>
|
||||||
<Icon.LogOut className="w-5 h-auto mr-2 opacity-70" /> {t("auth.sign-out")}
|
<Icon.LogOut className="w-4 h-auto mr-2" /> {t("auth.sign-out")}
|
||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
|
@ -1,36 +0,0 @@
|
|||||||
import { useState } from "react";
|
|
||||||
import { useWorkspaceStore } from "@/stores";
|
|
||||||
import Icon from "./Icon";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
url: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const getFaviconUrlWithProvider = (url: string, provider: string) => {
|
|
||||||
try {
|
|
||||||
const searchParams = new URLSearchParams();
|
|
||||||
searchParams.set("domain", new URL(url).hostname);
|
|
||||||
return new URL(`?${searchParams.toString()}`, provider).toString();
|
|
||||||
} catch (error) {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const LinkFavicon = (props: Props) => {
|
|
||||||
const { url } = props;
|
|
||||||
const workspaceStore = useWorkspaceStore();
|
|
||||||
const faviconProvider = workspaceStore.profile.faviconProvider || "https://www.google.com/s2/favicons";
|
|
||||||
const [faviconUrl, setFaviconUrl] = useState<string>(getFaviconUrlWithProvider(url, faviconProvider));
|
|
||||||
|
|
||||||
const handleImgError = () => {
|
|
||||||
setFaviconUrl("");
|
|
||||||
};
|
|
||||||
|
|
||||||
return faviconUrl ? (
|
|
||||||
<img className="w-full h-auto rounded" src={faviconUrl} decoding="async" loading="lazy" onError={handleImgError} />
|
|
||||||
) : (
|
|
||||||
<Icon.CircleSlash className="w-full h-auto text-gray-400" strokeWidth={1.5} />
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default LinkFavicon;
|
|
@ -1,59 +0,0 @@
|
|||||||
import { IconButton, Input } from "@mui/joy";
|
|
||||||
import classNames from "classnames";
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { generateRandomString } from "@/helpers/utils";
|
|
||||||
import Icon from "./Icon";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
name: string;
|
|
||||||
onChange: (name: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ResourceNameInput = (props: Props) => {
|
|
||||||
const { name, onChange } = props;
|
|
||||||
const [modified, setModified] = useState(false);
|
|
||||||
const [editingName, setEditingName] = useState(name || generateRandomString().toLowerCase());
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
onChange(editingName);
|
|
||||||
}, [editingName]);
|
|
||||||
|
|
||||||
const handleNameInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
if (!modified) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setEditingName(e.target.value);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="w-full flex flex-col justify-start items-start mb-3">
|
|
||||||
<div className={classNames("", modified ? "mb-2" : "flex flex-row justify-start items-center")}>
|
|
||||||
<span>Name</span>
|
|
||||||
{modified ? (
|
|
||||||
<span className="text-red-600"> *</span>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<span>:</span>
|
|
||||||
<span className="ml-1 font-mono font-medium">{editingName}</span>
|
|
||||||
<div className="ml-1 flex flex-row justify-start items-center">
|
|
||||||
<IconButton size="sm" variant="plain" color="neutral" onClick={() => setModified(true)}>
|
|
||||||
<Icon.Edit className="w-4 h-auto text-gray-500 dark:text-gray-400" />
|
|
||||||
</IconButton>
|
|
||||||
<IconButton size="sm" variant="plain" color="neutral" onClick={() => setEditingName(generateRandomString().toLowerCase())}>
|
|
||||||
<Icon.RefreshCcw className="w-4 h-auto text-gray-500 dark:text-gray-400" />
|
|
||||||
</IconButton>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{modified && (
|
|
||||||
<div className="relative w-full">
|
|
||||||
<Input className="w-full" type="text" placeholder="An unique name" value={editingName} onChange={handleNameInputChange} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ResourceNameInput;
|
|
@ -1,11 +1,10 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import useNavigateTo from "@/hooks/useNavigateTo";
|
import useNavigateTo from "@/hooks/useNavigateTo";
|
||||||
import { useShortcutStore, useUserStore } from "@/stores";
|
import { shortcutService } from "../services";
|
||||||
import { Shortcut } from "@/types/proto/api/v1/shortcut_service";
|
import useUserStore from "../stores/v1/user";
|
||||||
import { Role } from "@/types/proto/api/v1/user_service";
|
|
||||||
import { showCommonDialog } from "./Alert";
|
import { showCommonDialog } from "./Alert";
|
||||||
import CreateShortcutDrawer from "./CreateShortcutDrawer";
|
import CreateShortcutDialog from "./CreateShortcutDialog";
|
||||||
import GenerateQRCodeDialog from "./GenerateQRCodeDialog";
|
import GenerateQRCodeDialog from "./GenerateQRCodeDialog";
|
||||||
import Icon from "./Icon";
|
import Icon from "./Icon";
|
||||||
import Dropdown from "./common/Dropdown";
|
import Dropdown from "./common/Dropdown";
|
||||||
@ -18,11 +17,10 @@ const ShortcutActionsDropdown = (props: Props) => {
|
|||||||
const { shortcut } = props;
|
const { shortcut } = props;
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const navigateTo = useNavigateTo();
|
const navigateTo = useNavigateTo();
|
||||||
const shortcutStore = useShortcutStore();
|
|
||||||
const currentUser = useUserStore().getCurrentUser();
|
const currentUser = useUserStore().getCurrentUser();
|
||||||
const [showEditDrawer, setShowEditDrawer] = useState<boolean>(false);
|
const [showEditDialog, setShowEditDialog] = useState<boolean>(false);
|
||||||
const [showQRCodeDialog, setShowQRCodeDialog] = useState<boolean>(false);
|
const [showQRCodeDialog, setShowQRCodeDialog] = useState<boolean>(false);
|
||||||
const havePermission = currentUser.role === Role.ADMIN || shortcut.creatorId === currentUser.id;
|
const havePermission = currentUser.role === "ADMIN" || shortcut.creatorId === currentUser.id;
|
||||||
|
|
||||||
const handleDeleteShortcutButtonClick = (shortcut: Shortcut) => {
|
const handleDeleteShortcutButtonClick = (shortcut: Shortcut) => {
|
||||||
showCommonDialog({
|
showCommonDialog({
|
||||||
@ -30,7 +28,7 @@ const ShortcutActionsDropdown = (props: Props) => {
|
|||||||
content: `Are you sure to delete shortcut \`${shortcut.name}\`? You cannot undo this action.`,
|
content: `Are you sure to delete shortcut \`${shortcut.name}\`? You cannot undo this action.`,
|
||||||
style: "danger",
|
style: "danger",
|
||||||
onConfirm: async () => {
|
onConfirm: async () => {
|
||||||
await shortcutStore.deleteShortcut(shortcut.id);
|
await shortcutService.deleteShortcutById(shortcut.id);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@ -42,28 +40,28 @@ const ShortcutActionsDropdown = (props: Props) => {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Dropdown
|
<Dropdown
|
||||||
actionsClassName="!w-32 dark:text-gray-500 text-sm"
|
actionsClassName="!w-32 dark:text-gray-500"
|
||||||
actions={
|
actions={
|
||||||
<>
|
<>
|
||||||
{havePermission && (
|
{havePermission && (
|
||||||
<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 dark:hover:bg-zinc-800"
|
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 dark:hover:bg-zinc-800"
|
||||||
onClick={() => setShowEditDrawer(true)}
|
onClick={() => setShowEditDialog(true)}
|
||||||
>
|
>
|
||||||
<Icon.Edit className="w-4 h-auto mr-2 opacity-70" /> {t("common.edit")}
|
<Icon.Edit className="w-4 h-auto mr-2" /> {t("common.edit")}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<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 dark:hover:bg-zinc-800"
|
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 dark:hover:bg-zinc-800"
|
||||||
onClick={() => setShowQRCodeDialog(true)}
|
onClick={() => setShowQRCodeDialog(true)}
|
||||||
>
|
>
|
||||||
<Icon.QrCode className="w-4 h-auto mr-2 opacity-70" /> QR Code
|
<Icon.QrCode className="w-4 h-auto mr-2" /> QR Code
|
||||||
</button>
|
</button>
|
||||||
<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 dark:hover:bg-zinc-800"
|
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 dark:hover:bg-zinc-800"
|
||||||
onClick={gotoAnalytics}
|
onClick={gotoAnalytics}
|
||||||
>
|
>
|
||||||
<Icon.BarChart2 className="w-4 h-auto mr-2 opacity-70" /> {t("analytics.self")}
|
<Icon.BarChart2 className="w-4 h-auto mr-2" /> {t("analytics.self")}
|
||||||
</button>
|
</button>
|
||||||
{havePermission && (
|
{havePermission && (
|
||||||
<button
|
<button
|
||||||
@ -72,18 +70,18 @@ const ShortcutActionsDropdown = (props: Props) => {
|
|||||||
handleDeleteShortcutButtonClick(shortcut);
|
handleDeleteShortcutButtonClick(shortcut);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Icon.Trash className="w-4 h-auto mr-2 opacity-70" /> {t("common.delete")}
|
<Icon.Trash className="w-4 h-auto mr-2" /> {t("common.delete")}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
></Dropdown>
|
></Dropdown>
|
||||||
|
|
||||||
{showEditDrawer && (
|
{showEditDialog && (
|
||||||
<CreateShortcutDrawer
|
<CreateShortcutDialog
|
||||||
shortcutId={shortcut.id}
|
shortcutId={shortcut.id}
|
||||||
onClose={() => setShowEditDrawer(false)}
|
onClose={() => setShowEditDialog(false)}
|
||||||
onConfirm={() => setShowEditDrawer(false)}
|
onConfirm={() => setShowEditDialog(false)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
@ -1,16 +1,12 @@
|
|||||||
import { Avatar, Tooltip } from "@mui/joy";
|
import { Tooltip } from "@mui/joy";
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
import copy from "copy-to-clipboard";
|
import copy from "copy-to-clipboard";
|
||||||
import { useEffect } from "react";
|
|
||||||
import toast from "react-hot-toast";
|
import toast from "react-hot-toast";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { absolutifyLink } from "@/helpers/utils";
|
import { absolutifyLink, getFaviconWithGoogleS2 } from "../helpers/utils";
|
||||||
import { useUserStore, useViewStore } from "@/stores";
|
import useViewStore from "../stores/v1/view";
|
||||||
import { Shortcut } from "@/types/proto/api/v1/shortcut_service";
|
|
||||||
import { convertVisibilityFromPb } from "@/utils/visibility";
|
|
||||||
import Icon from "./Icon";
|
import Icon from "./Icon";
|
||||||
import LinkFavicon from "./LinkFavicon";
|
|
||||||
import ShortcutActionsDropdown from "./ShortcutActionsDropdown";
|
import ShortcutActionsDropdown from "./ShortcutActionsDropdown";
|
||||||
import VisibilityIcon from "./VisibilityIcon";
|
import VisibilityIcon from "./VisibilityIcon";
|
||||||
|
|
||||||
@ -21,14 +17,9 @@ interface Props {
|
|||||||
const ShortcutCard = (props: Props) => {
|
const ShortcutCard = (props: Props) => {
|
||||||
const { shortcut } = props;
|
const { shortcut } = props;
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const userStore = useUserStore();
|
|
||||||
const viewStore = useViewStore();
|
const viewStore = useViewStore();
|
||||||
const creator = userStore.getUserById(shortcut.creatorId);
|
|
||||||
const shortcutLink = absolutifyLink(`/s/${shortcut.name}`);
|
const shortcutLink = absolutifyLink(`/s/${shortcut.name}`);
|
||||||
|
const favicon = getFaviconWithGoogleS2(shortcut.link);
|
||||||
useEffect(() => {
|
|
||||||
userStore.getOrFetchUserById(shortcut.creatorId);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleCopyButtonClick = () => {
|
const handleCopyButtonClick = () => {
|
||||||
copy(shortcutLink);
|
copy(shortcutLink);
|
||||||
@ -36,113 +27,103 @@ const ShortcutCard = (props: Props) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<>
|
||||||
className={classNames(
|
<div
|
||||||
"group px-4 py-3 w-full flex flex-col justify-start items-start border rounded-lg hover:shadow dark:border-zinc-700"
|
className={classNames(
|
||||||
)}
|
"group px-4 py-3 w-full flex flex-col justify-start items-start border rounded-lg hover:shadow dark:border-zinc-700"
|
||||||
>
|
)}
|
||||||
<div className="w-full flex flex-row justify-between items-center">
|
>
|
||||||
<div className="w-[calc(100%-16px)] flex flex-row justify-start items-center mr-1 shrink-0">
|
<div className="w-full flex flex-row justify-between items-center">
|
||||||
<Link
|
<div className="w-[calc(100%-16px)] flex flex-row justify-start items-center mr-1 shrink-0">
|
||||||
className={classNames("w-8 h-8 flex justify-center items-center overflow-clip shrink-0")}
|
<Link to={`/shortcut/${shortcut.id}`} className={classNames("w-8 h-8 flex justify-center items-center overflow-clip shrink-0")}>
|
||||||
to={`/shortcut/${shortcut.id}`}
|
{favicon ? (
|
||||||
unstable_viewTransition
|
<img className="w-full h-auto rounded-lg" src={favicon} decoding="async" loading="lazy" />
|
||||||
>
|
) : (
|
||||||
<LinkFavicon url={shortcut.link} />
|
<Icon.CircleSlash className="w-full h-auto text-gray-400" />
|
||||||
</Link>
|
)}
|
||||||
<div className="ml-2 w-[calc(100%-24px)] flex flex-col justify-start items-start">
|
</Link>
|
||||||
<div className="w-full flex flex-row justify-start items-center leading-tight">
|
<div className="ml-1 w-[calc(100%-24px)] flex flex-col justify-start items-start">
|
||||||
<a
|
<div className="w-full flex flex-row justify-start items-center">
|
||||||
className={classNames(
|
<a
|
||||||
"max-w-[calc(100%-36px)] flex flex-row justify-start items-center mr-1 cursor-pointer hover:opacity-80 hover:underline"
|
className={classNames(
|
||||||
)}
|
"max-w-[calc(100%-36px)] flex flex-row px-1 mr-1 justify-start items-center cursor-pointer rounded-md hover:bg-gray-100 hover:shadow dark:hover:bg-zinc-800"
|
||||||
target="_blank"
|
|
||||||
href={shortcutLink}
|
|
||||||
>
|
|
||||||
<div className="truncate">
|
|
||||||
<span className="dark:text-gray-400">{shortcut.title}</span>
|
|
||||||
{shortcut.title ? (
|
|
||||||
<span className="text-gray-500">({shortcut.name})</span>
|
|
||||||
) : (
|
|
||||||
<span className="truncate dark:text-gray-400">{shortcut.name}</span>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
target="_blank"
|
||||||
<span className="hidden group-hover:block ml-1 cursor-pointer shrink-0">
|
href={shortcutLink}
|
||||||
<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 cursor-pointer text-gray-500 hover:opacity-80"
|
|
||||||
onClick={() => handleCopyButtonClick()}
|
|
||||||
>
|
>
|
||||||
<Icon.Clipboard className="w-4 h-auto mx-auto" />
|
<div className="truncate">
|
||||||
</button>
|
<span className="dark:text-gray-400">{shortcut.title}</span>
|
||||||
</Tooltip>
|
{shortcut.title ? (
|
||||||
|
<span className="text-gray-500">(s/{shortcut.name})</span>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span className="text-gray-400 dark:text-gray-500">s/</span>
|
||||||
|
<span className="truncate dark:text-gray-400">{shortcut.name}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className="hidden group-hover:block ml-1 cursor-pointer shrink-0">
|
||||||
|
<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-md text-gray-500 hover:bg-gray-100 hover:shadow dark:hover:bg-zinc-800"
|
||||||
|
onClick={() => handleCopyButtonClick()}
|
||||||
|
>
|
||||||
|
<Icon.Clipboard className="w-4 h-auto mx-auto" />
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
<a
|
||||||
|
className="pl-1 pr-4 w-full text-sm truncate text-gray-400 dark:text-gray-500 hover:underline"
|
||||||
|
href={shortcut.link}
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
{shortcut.link}
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<a
|
</div>
|
||||||
className="pr-4 leading-tight w-full text-sm truncate text-gray-400 dark:text-gray-500 hover:underline"
|
<div className="h-full pt-2 flex flex-row justify-end items-start">
|
||||||
href={shortcut.link}
|
<ShortcutActionsDropdown shortcut={shortcut} />
|
||||||
target="_blank"
|
|
||||||
>
|
|
||||||
{shortcut.link}
|
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="h-full pt-2 flex flex-row justify-end items-start">
|
<div className="mt-2 w-full flex flex-row justify-start items-start gap-2 truncate">
|
||||||
<ShortcutActionsDropdown shortcut={shortcut} />
|
{shortcut.tags.map((tag) => {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
key={tag}
|
||||||
|
className="max-w-[8rem] truncate text-gray-400 dark:text-gray-500 text-sm leading-4 cursor-pointer hover:opacity-80"
|
||||||
|
onClick={() => viewStore.setFilter({ tag: tag })}
|
||||||
|
>
|
||||||
|
#{tag}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{shortcut.tags.length === 0 && <span className="text-gray-400 text-sm leading-4 italic">No tags</span>}
|
||||||
|
</div>
|
||||||
|
<div className="w-full flex mt-2 gap-2 overflow-x-auto">
|
||||||
|
<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 flex-nowrap whitespace-nowrap border rounded-full cursor-pointer text-gray-500 dark:text-gray-400 text-sm dark:border-zinc-700"
|
||||||
|
onClick={() => viewStore.setFilter({ visibility: shortcut.visibility })}
|
||||||
|
>
|
||||||
|
<VisibilityIcon className="w-4 h-auto mr-1 opacity-60" visibility={shortcut.visibility} />
|
||||||
|
{t(`shortcut.visibility.${shortcut.visibility.toLowerCase()}.self`)}
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title="View count" variant="solid" placement="top" arrow>
|
||||||
|
<Link
|
||||||
|
to={`/shortcut/${shortcut.id}#analytics`}
|
||||||
|
className="w-auto px-2 leading-6 flex flex-row justify-start items-center flex-nowrap whitespace-nowrap border rounded-full cursor-pointer text-gray-500 dark:text-gray-400 text-sm dark:border-zinc-700"
|
||||||
|
>
|
||||||
|
<Icon.BarChart2 className="w-4 h-auto mr-1 opacity-80" />
|
||||||
|
{t("shortcut.visits", { count: shortcut.view })}
|
||||||
|
</Link>
|
||||||
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-2 w-full flex flex-row justify-start items-start gap-2 truncate">
|
</>
|
||||||
{shortcut.tags.map((tag) => {
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
key={tag}
|
|
||||||
className="max-w-[8rem] truncate text-gray-400 dark:text-gray-500 text-sm leading-4 cursor-pointer hover:opacity-80"
|
|
||||||
onClick={() => viewStore.setFilter({ tag: tag })}
|
|
||||||
>
|
|
||||||
#{tag}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
{shortcut.tags.length === 0 && <span className="text-gray-400 text-sm leading-4 italic">No tags</span>}
|
|
||||||
</div>
|
|
||||||
<div className="w-full mt-2 flex gap-2 overflow-x-auto">
|
|
||||||
<Tooltip title={creator.nickname} variant="solid" placement="top" arrow>
|
|
||||||
<Avatar
|
|
||||||
className="dark:bg-zinc-800"
|
|
||||||
sx={{
|
|
||||||
"--Avatar-size": "24px",
|
|
||||||
}}
|
|
||||||
alt={creator.nickname.toUpperCase()}
|
|
||||||
></Avatar>
|
|
||||||
</Tooltip>
|
|
||||||
<Tooltip
|
|
||||||
title={t(`shortcut.visibility.${convertVisibilityFromPb(shortcut.visibility).toLowerCase()}.description`)}
|
|
||||||
variant="solid"
|
|
||||||
placement="top"
|
|
||||||
arrow
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="w-auto leading-5 flex flex-row justify-start items-center flex-nowrap whitespace-nowrap cursor-pointer text-gray-400 text-sm"
|
|
||||||
onClick={() => viewStore.setFilter({ visibility: shortcut.visibility })}
|
|
||||||
>
|
|
||||||
<VisibilityIcon className="w-4 h-auto mr-1 opacity-70" visibility={shortcut.visibility} />
|
|
||||||
{t(`shortcut.visibility.${convertVisibilityFromPb(shortcut.visibility).toLowerCase()}.self`)}
|
|
||||||
</div>
|
|
||||||
</Tooltip>
|
|
||||||
<Tooltip title="View count" variant="solid" placement="top" arrow>
|
|
||||||
<Link
|
|
||||||
className="w-auto leading-5 flex flex-row justify-start items-center flex-nowrap whitespace-nowrap cursor-pointer text-gray-400 text-sm"
|
|
||||||
to={`/shortcut/${shortcut.id}#analytics`}
|
|
||||||
unstable_viewTransition
|
|
||||||
>
|
|
||||||
<Icon.BarChart2 className="w-4 h-auto mr-1 opacity-70" />
|
|
||||||
{t("shortcut.visits", { count: shortcut.viewCount })}
|
|
||||||
</Link>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -1,35 +0,0 @@
|
|||||||
import { Divider } from "@mui/joy";
|
|
||||||
import classNames from "classnames";
|
|
||||||
import { Link } from "react-router-dom";
|
|
||||||
import { Shortcut } from "@/types/proto/api/v1/shortcut_service";
|
|
||||||
import Icon from "./Icon";
|
|
||||||
import LinkFavicon from "./LinkFavicon";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
shortcut: Shortcut;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ShortcutFrame = ({ shortcut }: Props) => {
|
|
||||||
return (
|
|
||||||
<div className="w-full h-full flex flex-col justify-center items-center p-8">
|
|
||||||
<Link
|
|
||||||
className="w-72 max-w-full border dark:border-zinc-900 dark:bg-zinc-900 p-6 pb-4 rounded-2xl shadow-xl dark:text-gray-400 hover:opacity-80"
|
|
||||||
to={`/s/${shortcut.name}`}
|
|
||||||
target="_blank"
|
|
||||||
>
|
|
||||||
<div className={classNames("w-12 h-12 flex justify-center items-center overflow-clip rounded-lg shrink-0")}>
|
|
||||||
<LinkFavicon url={shortcut.link} />
|
|
||||||
</div>
|
|
||||||
<p className="text-lg font-medium leading-8 mt-2 truncate">{shortcut.title || shortcut.name}</p>
|
|
||||||
<p className="text-gray-500 truncate">{shortcut.description}</p>
|
|
||||||
<Divider className="!my-2" />
|
|
||||||
<p className="text-gray-400 dark:text-gray-600 text-sm mt-2">
|
|
||||||
<span className="leading-4">Open this site in a new tab</span>
|
|
||||||
<Icon.ArrowUpRight className="inline-block ml-1 -mt-0.5 w-4 h-auto" />
|
|
||||||
</p>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ShortcutFrame;
|
|
@ -1,8 +1,7 @@
|
|||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { Shortcut } from "@/types/proto/api/v1/shortcut_service";
|
import { getFaviconWithGoogleS2 } from "../helpers/utils";
|
||||||
import Icon from "./Icon";
|
import Icon from "./Icon";
|
||||||
import LinkFavicon from "./LinkFavicon";
|
|
||||||
import ShortcutActionsDropdown from "./ShortcutActionsDropdown";
|
import ShortcutActionsDropdown from "./ShortcutActionsDropdown";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@ -15,6 +14,7 @@ interface Props {
|
|||||||
|
|
||||||
const ShortcutView = (props: Props) => {
|
const ShortcutView = (props: Props) => {
|
||||||
const { shortcut, className, showActions, alwaysShowLink, onClick } = props;
|
const { shortcut, className, showActions, alwaysShowLink, onClick } = props;
|
||||||
|
const favicon = getFaviconWithGoogleS2(shortcut.link);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@ -25,16 +25,21 @@ const ShortcutView = (props: Props) => {
|
|||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
>
|
>
|
||||||
<div className={classNames("w-5 h-5 flex justify-center items-center overflow-clip shrink-0")}>
|
<div className={classNames("w-5 h-5 flex justify-center items-center overflow-clip shrink-0")}>
|
||||||
<LinkFavicon url={shortcut.link} />
|
{favicon ? (
|
||||||
|
<img className="w-full h-auto rounded-lg" src={favicon} decoding="async" loading="lazy" />
|
||||||
|
) : (
|
||||||
|
<Icon.CircleSlash className="w-full h-auto text-gray-400" />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="ml-2 w-full truncate">
|
<div className="ml-2 w-full truncate">
|
||||||
{shortcut.title ? (
|
{shortcut.title ? (
|
||||||
<>
|
<>
|
||||||
<span className="dark:text-gray-400">{shortcut.title}</span>
|
<span className="dark:text-gray-400">{shortcut.title}</span>
|
||||||
<span className="text-gray-500">({shortcut.name})</span>
|
<span className="text-gray-500">(s/{shortcut.name})</span>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
<span className="text-gray-400 dark:text-gray-500">s/</span>
|
||||||
<span className="dark:text-gray-400">{shortcut.name}</span>
|
<span className="dark:text-gray-400">{shortcut.name}</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
@ -1,7 +1,6 @@
|
|||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
import useNavigateTo from "@/hooks/useNavigateTo";
|
import { absolutifyLink } from "@/helpers/utils";
|
||||||
import { useViewStore } from "@/stores";
|
import useViewStore from "../stores/v1/view";
|
||||||
import { Shortcut } from "@/types/proto/api/v1/shortcut_service";
|
|
||||||
import ShortcutCard from "./ShortcutCard";
|
import ShortcutCard from "./ShortcutCard";
|
||||||
import ShortcutView from "./ShortcutView";
|
import ShortcutView from "./ShortcutView";
|
||||||
|
|
||||||
@ -11,13 +10,12 @@ interface Props {
|
|||||||
|
|
||||||
const ShortcutsContainer: React.FC<Props> = (props: Props) => {
|
const ShortcutsContainer: React.FC<Props> = (props: Props) => {
|
||||||
const { shortcutList } = props;
|
const { shortcutList } = props;
|
||||||
const navigateTo = useNavigateTo();
|
|
||||||
const viewStore = useViewStore();
|
const viewStore = useViewStore();
|
||||||
const displayStyle = viewStore.displayStyle || "full";
|
const displayStyle = viewStore.displayStyle || "full";
|
||||||
const ShortcutItemView = viewStore.displayStyle === "compact" ? ShortcutView : ShortcutCard;
|
const ShortcutItemView = viewStore.displayStyle === "compact" ? ShortcutView : ShortcutCard;
|
||||||
|
|
||||||
const handleShortcutClick = (shortcut: Shortcut) => {
|
const handleShortcutClick = (shortcut: Shortcut) => {
|
||||||
navigateTo(`/shortcut/${shortcut.id}`);
|
window.open(absolutifyLink(`/s/${shortcut.name}`));
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -1,12 +1,13 @@
|
|||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useShortcutStore, useViewStore } from "@/stores";
|
import { useAppSelector } from "../stores";
|
||||||
|
import useViewStore from "../stores/v1/view";
|
||||||
import Icon from "./Icon";
|
import Icon from "./Icon";
|
||||||
|
|
||||||
const ShortcutsNavigator = () => {
|
const ShortcutsNavigator = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const viewStore = useViewStore();
|
const viewStore = useViewStore();
|
||||||
const shortcutList = useShortcutStore().getShortcutList();
|
const { shortcutList } = useAppSelector((state) => state.shortcut);
|
||||||
const tags = shortcutList.map((shortcut) => shortcut.tags).flat();
|
const tags = shortcutList.map((shortcut) => shortcut.tags).flat();
|
||||||
const currentTab = viewStore.filter.tab || `tab:all`;
|
const currentTab = viewStore.filter.tab || `tab:all`;
|
||||||
const sortedTagMap = sortTags(tags);
|
const sortedTagMap = sortTags(tags);
|
||||||
@ -17,7 +18,7 @@ const ShortcutsNavigator = () => {
|
|||||||
className={classNames(
|
className={classNames(
|
||||||
"flex flex-row justify-center items-center px-2 leading-7 text-sm dark:text-gray-400 rounded-md",
|
"flex flex-row justify-center items-center px-2 leading-7 text-sm dark:text-gray-400 rounded-md",
|
||||||
currentTab === "tab:all"
|
currentTab === "tab:all"
|
||||||
? "bg-blue-700 dark:bg-blue-800 text-white dark:text-gray-400 shadow"
|
? "bg-gray-600 dark:bg-zinc-700 text-white dark:text-gray-400 shadow"
|
||||||
: "hover:bg-gray-200 dark:hover:bg-zinc-700"
|
: "hover:bg-gray-200 dark:hover:bg-zinc-700"
|
||||||
)}
|
)}
|
||||||
onClick={() => viewStore.setFilter({ tab: "tab:all" })}
|
onClick={() => viewStore.setFilter({ tab: "tab:all" })}
|
||||||
@ -29,7 +30,7 @@ const ShortcutsNavigator = () => {
|
|||||||
className={classNames(
|
className={classNames(
|
||||||
"flex flex-row justify-center items-center px-2 leading-7 text-sm dark:text-gray-400 rounded-md",
|
"flex flex-row justify-center items-center px-2 leading-7 text-sm dark:text-gray-400 rounded-md",
|
||||||
currentTab === "tab:mine"
|
currentTab === "tab:mine"
|
||||||
? "bg-blue-700 dark:bg-blue-800 text-white dark:text-gray-400 shadow"
|
? "bg-gray-600 dark:bg-zinc-700 text-white dark:text-gray-400 shadow"
|
||||||
: "hover:bg-gray-200 dark:hover:bg-zinc-700"
|
: "hover:bg-gray-200 dark:hover:bg-zinc-700"
|
||||||
)}
|
)}
|
||||||
onClick={() => viewStore.setFilter({ tab: "tab:mine" })}
|
onClick={() => viewStore.setFilter({ tab: "tab:mine" })}
|
||||||
@ -43,7 +44,7 @@ const ShortcutsNavigator = () => {
|
|||||||
className={classNames(
|
className={classNames(
|
||||||
"flex flex-row justify-center items-center px-2 leading-7 text-sm dark:text-gray-400 rounded-md",
|
"flex flex-row justify-center items-center px-2 leading-7 text-sm dark:text-gray-400 rounded-md",
|
||||||
currentTab === `tag:${tag}`
|
currentTab === `tag:${tag}`
|
||||||
? "bg-blue-700 dark:bg-blue-800 text-white dark:text-gray-400 shadow"
|
? "bg-gray-600 dark:bg-zinc-700 text-white dark:text-gray-400 shadow"
|
||||||
: "hover:bg-gray-200 dark:hover:bg-zinc-700"
|
: "hover:bg-gray-200 dark:hover:bg-zinc-700"
|
||||||
)}
|
)}
|
||||||
onClick={() => viewStore.setFilter({ tab: `tag:${tag}`, tag: undefined })}
|
onClick={() => viewStore.setFilter({ tab: `tag:${tag}`, tag: undefined })}
|
||||||
|
@ -11,25 +11,18 @@ const SubscriptionFAQ = () => {
|
|||||||
<Accordion>
|
<Accordion>
|
||||||
<AccordionSummary>Can I use the Free plan in my team?</AccordionSummary>
|
<AccordionSummary>Can I use the Free plan in my team?</AccordionSummary>
|
||||||
<AccordionDetails>
|
<AccordionDetails>
|
||||||
Of course you can. In the free plan, you can invite up to 5 members to your team. If you need more, you should upgrade to the
|
Of course you can. In the free plan, you can invite up to 5 members to your team. If you need more, you can upgrade to the Pro
|
||||||
Pro plan.
|
plan.
|
||||||
</AccordionDetails>
|
</AccordionDetails>
|
||||||
</Accordion>
|
</Accordion>
|
||||||
<Accordion>
|
<Accordion>
|
||||||
<AccordionSummary>How many devices can the license key be used on?</AccordionSummary>
|
<AccordionSummary>How many devices can the license key be used on?</AccordionSummary>
|
||||||
<AccordionDetails>{`It's unlimited for now, but please do not abuse it.`}</AccordionDetails>
|
<AccordionDetails>{`It's unlimited for now, but please don't abuse it.`}</AccordionDetails>
|
||||||
</Accordion>
|
</Accordion>
|
||||||
<Accordion>
|
<Accordion>
|
||||||
<AccordionSummary>{`Can I get a refund if Slash doesn't meet my needs?`}</AccordionSummary>
|
<AccordionSummary>{`Can I get a refund if Slash doesn't meet my needs?`}</AccordionSummary>
|
||||||
<AccordionDetails>
|
<AccordionDetails>
|
||||||
Yes, absolutely! You can contact us with `yourselfhosted@gmail.com`. I will refund you as soon as possible.
|
Yes, absolutely! You can send a email to me at `yourselfhosted@gmail.com`. I will refund you as soon as possible.
|
||||||
</AccordionDetails>
|
|
||||||
</Accordion>
|
|
||||||
<Accordion>
|
|
||||||
<AccordionSummary>Is there a Lifetime license?</AccordionSummary>
|
|
||||||
<AccordionDetails>
|
|
||||||
{`As software requires someone to maintain it, so we won't sell a lifetime service, since humans are not immortal yet. But if you
|
|
||||||
really want it, please contact us "yourselfhosted@gmail.com".`}
|
|
||||||
</AccordionDetails>
|
</AccordionDetails>
|
||||||
</Accordion>
|
</Accordion>
|
||||||
</AccordionGroup>
|
</AccordionGroup>
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import { Divider, Option, Select, Switch } from "@mui/joy";
|
import { Divider, Option, Select, Switch } from "@mui/joy";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useViewStore } from "@/stores";
|
import useViewStore from "../stores/v1/view";
|
||||||
import Icon from "./Icon";
|
import Icon from "./Icon";
|
||||||
import Dropdown from "./common/Dropdown";
|
import Dropdown from "./common/Dropdown";
|
||||||
|
|
||||||
@ -18,7 +18,7 @@ const ViewSetting = () => {
|
|||||||
<Icon.Settings2 className="w-4 h-auto text-gray-500" />
|
<Icon.Settings2 className="w-4 h-auto text-gray-500" />
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
actionsClassName="!mt-3 !right-[unset] -left-24 -ml-2"
|
actionsClassName="!mt-3 !-right-2"
|
||||||
actions={
|
actions={
|
||||||
<div className="w-52 p-2 gap-2 flex flex-col justify-start items-start" onClick={(e) => e.stopPropagation()}>
|
<div className="w-52 p-2 gap-2 flex flex-col justify-start items-start" onClick={(e) => e.stopPropagation()}>
|
||||||
<div className="w-full flex flex-row justify-between items-center">
|
<div className="w-full flex flex-row justify-between items-center">
|
||||||
|
@ -1,4 +1,3 @@
|
|||||||
import { Visibility } from "@/types/proto/api/v1/common";
|
|
||||||
import Icon from "./Icon";
|
import Icon from "./Icon";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@ -8,11 +7,11 @@ interface Props {
|
|||||||
|
|
||||||
const VisibilityIcon = (props: Props) => {
|
const VisibilityIcon = (props: Props) => {
|
||||||
const { visibility, className } = props;
|
const { visibility, className } = props;
|
||||||
if (visibility === Visibility.PRIVATE) {
|
if (visibility === "PRIVATE") {
|
||||||
return <Icon.Lock className={className || ""} />;
|
return <Icon.Lock className={className || ""} />;
|
||||||
} else if (visibility === Visibility.WORKSPACE) {
|
} else if (visibility === "WORKSPACE") {
|
||||||
return <Icon.Building2 className={className || ""} />;
|
return <Icon.Building2 className={className || ""} />;
|
||||||
} else if (visibility === Visibility.PUBLIC) {
|
} else if (visibility === "PUBLIC") {
|
||||||
return <Icon.Globe2 className={className || ""} />;
|
return <Icon.Globe2 className={className || ""} />;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import { ReactNode, useEffect, useRef, useState } from "react";
|
import { ReactNode, useEffect, useRef, useState } from "react";
|
||||||
import Icon from "@/components/Icon";
|
import Icon from "../Icon";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
trigger?: ReactNode;
|
trigger?: ReactNode;
|
||||||
|
@ -3,12 +3,12 @@ import copy from "copy-to-clipboard";
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { toast } from "react-hot-toast";
|
import { toast } from "react-hot-toast";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { showCommonDialog } from "@/components/Alert";
|
|
||||||
import CreateAccessTokenDialog from "@/components/CreateAccessTokenDialog";
|
|
||||||
import Icon from "@/components/Icon";
|
|
||||||
import { userServiceClient } from "@/grpcweb";
|
import { userServiceClient } from "@/grpcweb";
|
||||||
import { useUserStore } from "@/stores";
|
import { UserAccessToken } from "@/types/proto/api/v2/user_service";
|
||||||
import { UserAccessToken } from "@/types/proto/api/v1/user_service";
|
import useUserStore from "../../stores/v1/user";
|
||||||
|
import { showCommonDialog } from "../Alert";
|
||||||
|
import CreateAccessTokenDialog from "../CreateAccessTokenDialog";
|
||||||
|
import Icon from "../Icon";
|
||||||
|
|
||||||
const listAccessTokens = async (userId: number) => {
|
const listAccessTokens = async (userId: number) => {
|
||||||
const { accessTokens } = await userServiceClient.listUserAccessTokens({
|
const { accessTokens } = await userServiceClient.listUserAccessTokens({
|
||||||
@ -64,7 +64,7 @@ const AccessTokenSection = () => {
|
|||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
<div className="sm:flex sm:items-center">
|
<div className="sm:flex sm:items-center">
|
||||||
<div className="sm:flex-auto">
|
<div className="sm:flex-auto">
|
||||||
<p className="text-2xl shrink-0 font-semibold text-gray-900 dark:text-gray-500">Access Tokens</p>
|
<p className="text-base font-semibold leading-6 text-gray-900 dark:text-gray-500">Access Tokens</p>
|
||||||
<p className="mt-2 text-sm text-gray-700 dark:text-gray-600">A list of all access tokens for your account.</p>
|
<p className="mt-2 text-sm text-gray-700 dark:text-gray-600">A list of all access tokens for your account.</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-4 sm:ml-16 sm:mt-0 sm:flex-none">
|
<div className="mt-4 sm:ml-16 sm:mt-0 sm:flex-none">
|
||||||
|
@ -1,22 +1,21 @@
|
|||||||
import { Button } from "@mui/joy";
|
import { Button } from "@mui/joy";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import ChangePasswordDialog from "@/components/ChangePasswordDialog";
|
import useUserStore from "../../stores/v1/user";
|
||||||
import EditUserinfoDialog from "@/components/EditUserinfoDialog";
|
import ChangePasswordDialog from "../ChangePasswordDialog";
|
||||||
import { useUserStore } from "@/stores";
|
import EditUserinfoDialog from "../EditUserinfoDialog";
|
||||||
import { Role } from "@/types/proto/api/v1/user_service";
|
|
||||||
|
|
||||||
const AccountSection: React.FC = () => {
|
const AccountSection: React.FC = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const currentUser = useUserStore().getCurrentUser();
|
const currentUser = useUserStore().getCurrentUser();
|
||||||
const [showEditUserinfoDialog, setShowEditUserinfoDialog] = useState<boolean>(false);
|
const [showEditUserinfoDialog, setShowEditUserinfoDialog] = useState<boolean>(false);
|
||||||
const [showChangePasswordDialog, setShowChangePasswordDialog] = useState<boolean>(false);
|
const [showChangePasswordDialog, setShowChangePasswordDialog] = useState<boolean>(false);
|
||||||
const isAdmin = currentUser.role === Role.ADMIN;
|
const isAdmin = currentUser.role === "ADMIN";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="w-full flex flex-col justify-start items-start gap-y-2">
|
<div className="w-full flex flex-col justify-start items-start gap-y-2">
|
||||||
<p className="text-2xl shrink-0 font-semibold text-gray-900 dark:text-gray-500">{t("common.account")}</p>
|
<p className="text-base font-semibold leading-6 text-gray-900 dark:text-gray-500">{t("common.account")}</p>
|
||||||
<p className="flex flex-row justify-start items-center mt-2 dark:text-gray-400">
|
<p className="flex flex-row justify-start items-center mt-2 dark:text-gray-400">
|
||||||
<span className="text-xl">{currentUser.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>}
|
{isAdmin && <span className="ml-2 bg-blue-600 text-white px-2 leading-6 text-sm rounded-full">Admin</span>}
|
||||||
|
@ -2,12 +2,10 @@ import { Button, IconButton } from "@mui/joy";
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import toast from "react-hot-toast";
|
import toast from "react-hot-toast";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { showCommonDialog } from "@/components/Alert";
|
import useUserStore from "../../stores/v1/user";
|
||||||
import CreateUserDialog from "@/components/CreateUserDialog";
|
import { showCommonDialog } from "../Alert";
|
||||||
import Icon from "@/components/Icon";
|
import CreateUserDialog from "../CreateUserDialog";
|
||||||
import { useUserStore } from "@/stores";
|
import Icon from "../Icon";
|
||||||
import { User } from "@/types/proto/api/v1/user_service";
|
|
||||||
import { convertRoleFromPb } from "@/utils/user";
|
|
||||||
|
|
||||||
const MemberSection = () => {
|
const MemberSection = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@ -35,7 +33,7 @@ const MemberSection = () => {
|
|||||||
await userStore.deleteUser(user.id);
|
await userStore.deleteUser(user.id);
|
||||||
toast.success(`User \`${user.nickname}\` deleted successfully`);
|
toast.success(`User \`${user.nickname}\` deleted successfully`);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
toast.error(`Failed to delete user \`${user.nickname}\`: ${error.details}`);
|
toast.error(`Failed to delete user \`${user.nickname}\`: ${error.response.data.message}`);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@ -47,7 +45,7 @@ const MemberSection = () => {
|
|||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
<div className="sm:flex sm:items-center">
|
<div className="sm:flex sm:items-center">
|
||||||
<div className="sm:flex-auto">
|
<div className="sm:flex-auto">
|
||||||
<p className="text-2xl shrink-0 font-semibold text-gray-900 dark:text-gray-500">{t("user.self")}</p>
|
<p className="text-base font-semibold leading-6 text-gray-900 dark:text-gray-500">{t("user.self")}</p>
|
||||||
<p className="mt-2 text-sm text-gray-700 dark:text-gray-600">
|
<p className="mt-2 text-sm text-gray-700 dark:text-gray-600">
|
||||||
A list of all the users in your workspace including their nickname, email and role.
|
A list of all the users in your workspace including their nickname, email and role.
|
||||||
</p>
|
</p>
|
||||||
@ -90,7 +88,7 @@ const MemberSection = () => {
|
|||||||
<tr key={user.email}>
|
<tr key={user.email}>
|
||||||
<td className="whitespace-nowrap py-4 pl-4 pr-3 text-sm text-gray-900 dark:text-gray-500">{user.nickname}</td>
|
<td className="whitespace-nowrap py-4 pl-4 pr-3 text-sm text-gray-900 dark:text-gray-500">{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.email}</td>
|
||||||
<td className="whitespace-nowrap px-3 py-4 text-sm text-gray-500">{convertRoleFromPb(user.role)}</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">
|
<td className="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm">
|
||||||
<IconButton
|
<IconButton
|
||||||
size="sm"
|
size="sm"
|
||||||
|
@ -1,8 +1,8 @@
|
|||||||
import { Option, Select } from "@mui/joy";
|
import { Option, Select } from "@mui/joy";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import BetaBadge from "@/components/BetaBadge";
|
import { UserSetting, UserSetting_ColorTheme, UserSetting_Locale } from "@/types/proto/api/v2/user_setting_service";
|
||||||
import { useUserStore } from "@/stores";
|
import useUserStore from "../../stores/v1/user";
|
||||||
import { UserSetting, UserSetting_ColorTheme, UserSetting_Locale } from "@/types/proto/api/v1/user_setting_service";
|
import BetaBadge from "../BetaBadge";
|
||||||
|
|
||||||
const PreferenceSection: React.FC = () => {
|
const PreferenceSection: React.FC = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@ -58,9 +58,9 @@ const PreferenceSection: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full flex flex-col sm:flex-row justify-start items-start gap-4 sm:gap-x-16">
|
<>
|
||||||
<p className="sm:w-1/4 text-2xl shrink-0 font-semibold text-gray-900 dark:text-gray-500">{t("settings.preference.self")}</p>
|
<div className="w-full flex flex-col justify-start items-start gap-y-2">
|
||||||
<div className="w-full sm:w-auto grow flex flex-col justify-start items-start gap-4">
|
<p className="text-base font-semibold leading-6 text-gray-900 dark:text-gray-500">{t("settings.preference.self")}</p>
|
||||||
<div className="w-full flex flex-row justify-between items-center">
|
<div className="w-full flex flex-row justify-between items-center">
|
||||||
<div className="flex flex-row justify-start items-center gap-x-1">
|
<div className="flex flex-row justify-start items-center gap-x-1">
|
||||||
<span className="dark:text-gray-400">{t("settings.preference.color-theme")}</span>
|
<span className="dark:text-gray-400">{t("settings.preference.color-theme")}</span>
|
||||||
@ -91,7 +91,7 @@ const PreferenceSection: React.FC = () => {
|
|||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -1,14 +1,13 @@
|
|||||||
import { Button, Input, Select, Textarea, Option, Switch, Link } from "@mui/joy";
|
import { Button, Checkbox, Textarea } from "@mui/joy";
|
||||||
import { isEqual } from "lodash-es";
|
import { isEqual } from "lodash-es";
|
||||||
import { useRef, useState } from "react";
|
import { useRef, useState } from "react";
|
||||||
import toast from "react-hot-toast";
|
import toast from "react-hot-toast";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { workspaceServiceClient } from "@/grpcweb";
|
import { workspaceServiceClient } from "@/grpcweb";
|
||||||
import { useWorkspaceStore } from "@/stores";
|
import useWorkspaceStore from "@/stores/v1/workspace";
|
||||||
import { Visibility } from "@/types/proto/api/v1/common";
|
import { WorkspaceSetting } from "@/types/proto/api/v2/workspace_service";
|
||||||
import { WorkspaceSetting } from "@/types/proto/api/v1/workspace_service";
|
|
||||||
|
|
||||||
const WorkspaceSection = () => {
|
const WorkspaceSection: React.FC = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const workspaceStore = useWorkspaceStore();
|
const workspaceStore = useWorkspaceStore();
|
||||||
const [workspaceSetting, setWorkspaceSetting] = useState<WorkspaceSetting>(workspaceStore.setting);
|
const [workspaceSetting, setWorkspaceSetting] = useState<WorkspaceSetting>(workspaceStore.setting);
|
||||||
@ -22,20 +21,6 @@ const WorkspaceSection = () => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleInstanceUrlChange = async (value: string) => {
|
|
||||||
setWorkspaceSetting({
|
|
||||||
...workspaceSetting,
|
|
||||||
instanceUrl: value,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleFaviconProvierChange = async (value: string) => {
|
|
||||||
setWorkspaceSetting({
|
|
||||||
...workspaceSetting,
|
|
||||||
faviconProvider: value,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCustomStyleChange = async (value: string) => {
|
const handleCustomStyleChange = async (value: string) => {
|
||||||
setWorkspaceSetting({
|
setWorkspaceSetting({
|
||||||
...workspaceSetting,
|
...workspaceSetting,
|
||||||
@ -43,30 +28,14 @@ const WorkspaceSection = () => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDefaultVisibilityChange = async (value: Visibility) => {
|
|
||||||
setWorkspaceSetting({
|
|
||||||
...workspaceSetting,
|
|
||||||
defaultVisibility: value,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSaveWorkspaceSetting = async () => {
|
const handleSaveWorkspaceSetting = async () => {
|
||||||
const updateMask: string[] = [];
|
const updateMask: string[] = [];
|
||||||
if (!isEqual(originalWorkspaceSetting.current.enableSignup, workspaceSetting.enableSignup)) {
|
if (!isEqual(originalWorkspaceSetting.current.enableSignup, workspaceSetting.enableSignup)) {
|
||||||
updateMask.push("enable_signup");
|
updateMask.push("enable_signup");
|
||||||
}
|
}
|
||||||
if (!isEqual(originalWorkspaceSetting.current.instanceUrl, workspaceSetting.instanceUrl)) {
|
|
||||||
updateMask.push("instance_url");
|
|
||||||
}
|
|
||||||
if (!isEqual(originalWorkspaceSetting.current.customStyle, workspaceSetting.customStyle)) {
|
if (!isEqual(originalWorkspaceSetting.current.customStyle, workspaceSetting.customStyle)) {
|
||||||
updateMask.push("custom_style");
|
updateMask.push("custom_style");
|
||||||
}
|
}
|
||||||
if (!isEqual(originalWorkspaceSetting.current.defaultVisibility, workspaceSetting.defaultVisibility)) {
|
|
||||||
updateMask.push("default_visibility");
|
|
||||||
}
|
|
||||||
if (!isEqual(originalWorkspaceSetting.current.faviconProvider, workspaceSetting.faviconProvider)) {
|
|
||||||
updateMask.push("favicon_provider");
|
|
||||||
}
|
|
||||||
if (updateMask.length === 0) {
|
if (updateMask.length === 0) {
|
||||||
toast.error("No changes made");
|
toast.error("No changes made");
|
||||||
return;
|
return;
|
||||||
@ -80,7 +49,6 @@ const WorkspaceSection = () => {
|
|||||||
})
|
})
|
||||||
).setting as WorkspaceSetting;
|
).setting as WorkspaceSetting;
|
||||||
setWorkspaceSetting(setting);
|
setWorkspaceSetting(setting);
|
||||||
await workspaceStore.fetchWorkspaceSetting();
|
|
||||||
originalWorkspaceSetting.current = setting;
|
originalWorkspaceSetting.current = setting;
|
||||||
toast.success("Workspace setting saved successfully");
|
toast.success("Workspace setting saved successfully");
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
@ -89,75 +57,31 @@ const WorkspaceSection = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full flex flex-col sm:flex-row justify-start items-start gap-4 sm:gap-x-16">
|
<div className="w-full flex flex-col justify-start items-start space-y-4">
|
||||||
<p className="sm:w-1/4 text-2xl shrink-0 font-semibold text-gray-900 dark:text-gray-500">{t("settings.workspace.self")}</p>
|
<p className="text-base font-semibold leading-6 text-gray-900 dark:text-gray-500">{t("settings.workspace.self")}</p>
|
||||||
<div className="w-full sm:w-auto grow flex flex-col justify-start items-start gap-4">
|
<div className="w-full flex flex-col justify-start items-start">
|
||||||
<div className="w-full flex flex-col justify-start items-start">
|
<p className="mt-2 dark:text-gray-400">{t("settings.workspace.custom-style")}</p>
|
||||||
<p className="font-medium dark:text-gray-400">Instance URL</p>
|
<Textarea
|
||||||
<Input
|
className="w-full mt-2"
|
||||||
className="w-full mt-2"
|
placeholder="* {font-family: ui-monospace Monaco Consolas;}"
|
||||||
placeholder="Your instance URL. Using for website SEO. Leave it empty if you don't want cawler to index your website."
|
minRows={2}
|
||||||
value={workspaceSetting.instanceUrl}
|
maxRows={5}
|
||||||
onChange={(event) => handleInstanceUrlChange(event.target.value)}
|
value={workspaceSetting.customStyle}
|
||||||
/>
|
onChange={(event) => handleCustomStyleChange(event.target.value)}
|
||||||
</div>
|
/>
|
||||||
<div className="w-full flex flex-col justify-start items-start">
|
</div>
|
||||||
<p className="font-medium dark:text-gray-400">Favicon Provider</p>
|
<div className="w-full flex flex-col justify-start items-start">
|
||||||
<p className="text-sm text-gray-500">
|
<Checkbox
|
||||||
e.g.{" "}
|
label={t("settings.workspace.enable-user-signup.self")}
|
||||||
<Link className="!text-sm" href="https://github.com/yourselfhosted/favicons" target="_blank">
|
checked={workspaceSetting.enableSignup}
|
||||||
yourselfhosted/favicons
|
onChange={(event) => handleEnableSignUpChange(event.target.checked)}
|
||||||
</Link>
|
/>
|
||||||
</p>
|
<p className="mt-2 text-gray-500">{t("settings.workspace.enable-user-signup.description")}</p>
|
||||||
<Input
|
</div>
|
||||||
className="w-full mt-2"
|
<div>
|
||||||
placeholder="The provider of favicon. Empty for default Google S2."
|
<Button variant="outlined" color="neutral" disabled={!allowSave} onClick={handleSaveWorkspaceSetting}>
|
||||||
value={workspaceSetting.faviconProvider}
|
{t("common.save")}
|
||||||
onChange={(event) => handleFaviconProvierChange(event.target.value)}
|
</Button>
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="w-full flex flex-col justify-start items-start">
|
|
||||||
<p className="mt-2 font-medium dark:text-gray-400">{t("settings.workspace.custom-style")}</p>
|
|
||||||
<Textarea
|
|
||||||
className="w-full mt-2"
|
|
||||||
placeholder="* {font-family: ui-monospace Monaco Consolas;}"
|
|
||||||
minRows={2}
|
|
||||||
maxRows={5}
|
|
||||||
value={workspaceSetting.customStyle}
|
|
||||||
onChange={(event) => handleCustomStyleChange(event.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="w-full flex flex-row justify-between items-center">
|
|
||||||
<div className="w-full flex flex-col justify-start items-start">
|
|
||||||
<p className="font-medium">{t("settings.workspace.enable-user-signup.self")}</p>
|
|
||||||
<p className="text-gray-500">{t("settings.workspace.enable-user-signup.description")}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Switch
|
|
||||||
size="lg"
|
|
||||||
checked={workspaceSetting.enableSignup}
|
|
||||||
onChange={(event) => handleEnableSignUpChange(event.target.checked)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="w-full flex flex-row justify-between items-center">
|
|
||||||
<div className="flex flex-row justify-start items-center gap-x-1">
|
|
||||||
<span className="font-medium dark:text-gray-400">{t("settings.workspace.default-visibility")}</span>
|
|
||||||
</div>
|
|
||||||
<Select
|
|
||||||
defaultValue={workspaceSetting.defaultVisibility || Visibility.PRIVATE}
|
|
||||||
onChange={(_, value) => handleDefaultVisibilityChange(value as Visibility)}
|
|
||||||
>
|
|
||||||
<Option value={Visibility.PRIVATE}>{t(`shortcut.visibility.private.self`)}</Option>
|
|
||||||
<Option value={Visibility.WORKSPACE}>{t(`shortcut.visibility.workspace.self`)}</Option>
|
|
||||||
<Option value={Visibility.PUBLIC}>{t(`shortcut.visibility.public.self`)}</Option>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Button variant="outlined" color="neutral" disabled={!allowSave} onClick={handleSaveWorkspaceSetting}>
|
|
||||||
{t("common.save")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
@ -1,3 +0,0 @@
|
|||||||
.MuiDrawer-content {
|
|
||||||
@apply !w-auto;
|
|
||||||
}
|
|
@ -1,11 +1,10 @@
|
|||||||
import { createChannel, createClientFactory, FetchTransport } from "nice-grpc-web";
|
import { createChannel, createClientFactory, FetchTransport } from "nice-grpc-web";
|
||||||
import { AuthServiceDefinition } from "./types/proto/api/v1/auth_service";
|
import { CollectionServiceDefinition } from "./types/proto/api/v2/collection_service";
|
||||||
import { CollectionServiceDefinition } from "./types/proto/api/v1/collection_service";
|
import { ShortcutServiceDefinition } from "./types/proto/api/v2/shortcut_service";
|
||||||
import { ShortcutServiceDefinition } from "./types/proto/api/v1/shortcut_service";
|
import { SubscriptionServiceDefinition } from "./types/proto/api/v2/subscription_service";
|
||||||
import { SubscriptionServiceDefinition } from "./types/proto/api/v1/subscription_service";
|
import { UserServiceDefinition } from "./types/proto/api/v2/user_service";
|
||||||
import { UserServiceDefinition } from "./types/proto/api/v1/user_service";
|
import { UserSettingServiceDefinition } from "./types/proto/api/v2/user_setting_service";
|
||||||
import { UserSettingServiceDefinition } from "./types/proto/api/v1/user_setting_service";
|
import { WorkspaceServiceDefinition } from "./types/proto/api/v2/workspace_service";
|
||||||
import { WorkspaceServiceDefinition } from "./types/proto/api/v1/workspace_service";
|
|
||||||
|
|
||||||
const address = import.meta.env.MODE === "development" ? "http://localhost:8082" : window.location.origin;
|
const address = import.meta.env.MODE === "development" ? "http://localhost:8082" : window.location.origin;
|
||||||
|
|
||||||
@ -18,11 +17,9 @@ const channel = createChannel(
|
|||||||
|
|
||||||
const clientFactory = createClientFactory();
|
const clientFactory = createClientFactory();
|
||||||
|
|
||||||
export const workspaceServiceClient = clientFactory.create(WorkspaceServiceDefinition, channel);
|
|
||||||
|
|
||||||
export const subscriptionServiceClient = clientFactory.create(SubscriptionServiceDefinition, channel);
|
export const subscriptionServiceClient = clientFactory.create(SubscriptionServiceDefinition, channel);
|
||||||
|
|
||||||
export const authServiceClient = clientFactory.create(AuthServiceDefinition, channel);
|
export const workspaceServiceClient = clientFactory.create(WorkspaceServiceDefinition, channel);
|
||||||
|
|
||||||
export const userServiceClient = clientFactory.create(UserServiceDefinition, channel);
|
export const userServiceClient = clientFactory.create(UserServiceDefinition, channel);
|
||||||
|
|
||||||
|
73
frontend/web/src/helpers/api.ts
Normal file
73
frontend/web/src/helpers/api.ts
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
import axios from "axios";
|
||||||
|
import { userServiceClient } from "@/grpcweb";
|
||||||
|
|
||||||
|
export function signin(email: string, password: string) {
|
||||||
|
return axios.post<User>("/api/v1/auth/signin", {
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function signup(email: string, nickname: string, password: string) {
|
||||||
|
return axios.post<User>("/api/v1/auth/signup", {
|
||||||
|
email,
|
||||||
|
nickname,
|
||||||
|
password,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function signout() {
|
||||||
|
return axios.post("/api/v1/auth/logout");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMyselfUser() {
|
||||||
|
return axios.get<User>("/api/v1/user/me");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getUserList() {
|
||||||
|
return axios.get<User[]>("/api/v1/user");
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteUser(userId: UserId) {
|
||||||
|
return userServiceClient.deleteUser({ id: userId });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getShortcutList(shortcutFind?: ShortcutFind) {
|
||||||
|
const queryList = [];
|
||||||
|
if (shortcutFind?.tag) {
|
||||||
|
queryList.push(`tag=${shortcutFind.tag}`);
|
||||||
|
}
|
||||||
|
return axios.get<Shortcut[]>(`/api/v1/shortcut?${queryList.join("&")}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getShortcutById(id: number) {
|
||||||
|
return axios.get<Shortcut>(`/api/v1/shortcut/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteShortcutById(shortcutId: ShortcutId) {
|
||||||
|
return axios.delete(`/api/v1/shortcut/${shortcutId}`);
|
||||||
|
}
|
@ -1,20 +1,24 @@
|
|||||||
|
import { isNull, isUndefined } from "lodash-es";
|
||||||
|
|
||||||
|
export const isNullorUndefined = (value: any) => {
|
||||||
|
return isNull(value) || isUndefined(value);
|
||||||
|
};
|
||||||
|
|
||||||
export const absolutifyLink = (rel: string): string => {
|
export const absolutifyLink = (rel: string): string => {
|
||||||
const anchor = document.createElement("a");
|
const anchor = document.createElement("a");
|
||||||
anchor.setAttribute("href", rel);
|
anchor.setAttribute("href", rel);
|
||||||
return anchor.href;
|
return anchor.href;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const isURL = (str: string): boolean => {
|
export const releaseGuard = () => {
|
||||||
const urlRegex = /^(https?|ftp):\/\/[^\s/$.?#].[^\s]*$/i;
|
return import.meta.env.MODE === "development";
|
||||||
return urlRegex.test(str);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const generateRandomString = () => {
|
export const getFaviconWithGoogleS2 = (url: string) => {
|
||||||
const characters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
|
try {
|
||||||
let randomString = "";
|
const urlObject = new URL(url);
|
||||||
for (let i = 0; i < 6; i++) {
|
return `https://www.google.com/s2/favicons?sz=128&domain=${urlObject.hostname}`;
|
||||||
const randomIndex = Math.floor(Math.random() * characters.length);
|
} catch (error) {
|
||||||
randomString += characters.charAt(randomIndex);
|
return undefined;
|
||||||
}
|
}
|
||||||
return randomString;
|
|
||||||
};
|
};
|
||||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user