├── .gitattributes ├── .github └── workflows │ ├── go.yaml │ └── publish.yaml ├── .gitignore ├── .slsa-goreleaser ├── darwin-amd64.yml ├── darwin-arm64.yml ├── linux-amd64.yml ├── linux-arm64.yml ├── windows-amd64.yml └── windows-arm64.yml ├── .vscode ├── launch.json └── settings.json ├── LICENSE ├── README.md ├── cli ├── cmd │ ├── auth.go │ ├── delete.go │ ├── pastes.go │ ├── root.go │ └── upload.go ├── config │ ├── config.go │ └── fileTypes.go ├── main.go └── supabase │ └── supabase.go ├── go.mod ├── go.sum ├── install.ps1 ├── install.sh ├── main.go ├── openbin.code-workspace ├── package-lock.json ├── package.json ├── pnpm-lock.yaml ├── supabase ├── .gitignore ├── config.toml ├── seed.sql └── types.ts └── web ├── .eslintrc.json ├── .gitignore ├── README.md ├── _eslintrc.cjs ├── components.json ├── next.config.js ├── package.json ├── pnpm-lock.yaml ├── postcss.config.js ├── prettier.config.cjs ├── public └── assets │ ├── favicon.png │ ├── favicon.svg │ ├── image.png │ ├── pfp-placeholder.png │ └── profile-bg.png ├── src ├── app │ ├── auth │ │ ├── callback │ │ │ ├── [redirect] │ │ │ │ └── route.ts │ │ │ └── route.ts │ │ ├── delete │ │ │ ├── confirm │ │ │ │ └── route.ts │ │ │ ├── layout.tsx │ │ │ └── page.tsx │ │ └── signout │ │ │ └── route.ts │ ├── avatar │ │ └── route.tsx │ ├── editor │ │ ├── loading.tsx │ │ └── page.tsx │ ├── globals.css │ ├── install.ps1 │ │ └── route.ts │ ├── install.sh │ │ └── route.ts │ ├── layout.tsx │ ├── login │ │ ├── loading.tsx │ │ └── page.tsx │ ├── me │ │ └── route.ts │ ├── page.tsx │ ├── pastes │ │ └── [id] │ │ │ ├── error.tsx │ │ │ ├── layout.tsx │ │ │ ├── loading.tsx │ │ │ ├── not-found.tsx │ │ │ └── page.tsx │ ├── privacy-policy │ │ └── page.tsx │ └── profiles │ │ └── [id] │ │ ├── error.tsx │ │ ├── layout.tsx │ │ ├── loading.tsx │ │ ├── not-found.tsx │ │ └── page.tsx ├── assets │ ├── bg.svg │ ├── gh-light.json │ ├── grid.svg │ └── languages.json ├── components │ ├── avatar.tsx │ ├── editor │ │ ├── actions.ts │ │ ├── delete-paste.tsx │ │ ├── index.tsx │ │ ├── navbar.tsx │ │ ├── publish-form.tsx │ │ ├── toggle-publish.tsx │ │ └── viewer.tsx │ ├── footer.tsx │ ├── loading-dots.module.css │ ├── loading-dots.tsx │ ├── loading-spinner.module.css │ ├── loading-spinner.tsx │ ├── login.tsx │ ├── logo.tsx │ ├── navbar.tsx │ ├── svg │ │ ├── circles.tsx │ │ └── supabase-logo.tsx │ ├── terminal.tsx │ └── ui │ │ ├── button.tsx │ │ ├── calendar.tsx │ │ ├── checkbox.tsx │ │ ├── command.tsx │ │ ├── dialog.tsx │ │ ├── dropdown-menu.tsx │ │ ├── form.tsx │ │ ├── input.tsx │ │ ├── label.tsx │ │ ├── popover.tsx │ │ ├── separator.tsx │ │ ├── textarea.tsx │ │ └── tooltip.tsx ├── middleware.ts └── utils │ ├── cn.ts │ ├── config.ts │ ├── os.ts │ └── supabase.ts ├── tailwind.config.js ├── tsconfig.json └── types ├── supabase.ts └── types.ts /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /.github/workflows/go.yaml: -------------------------------------------------------------------------------- 1 | name: Go 2 | 3 | on: 4 | push: 5 | branches: [ "main" ] 6 | pull_request: 7 | branches: [ "main" ] 8 | 9 | jobs: 10 | 11 | build: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v3 15 | 16 | - name: Set up Go 17 | uses: actions/setup-go@v4 18 | with: 19 | go-version: '1.20' 20 | 21 | - name: Build 22 | run: go build -v -o build/ . -------------------------------------------------------------------------------- /.github/workflows/publish.yaml: -------------------------------------------------------------------------------- 1 | # This workflow uses actions that are not certified by GitHub. 2 | # They are provided by a third-party and are governed by 3 | # separate terms of service, privacy policy, and support 4 | # documentation. 5 | 6 | # This workflow lets you compile your Go project using a SLSA3 compliant builder. 7 | # This workflow will generate a so-called "provenance" file describing the steps 8 | # that were performed to generate the final binary. 9 | # The project is an initiative of the OpenSSF (openssf.org) and is developed at 10 | # https://github.com/slsa-framework/slsa-github-generator. 11 | # The provenance file can be verified using https://github.com/slsa-framework/slsa-verifier. 12 | # For more information about SLSA and how it improves the supply-chain, visit slsa.dev. 13 | 14 | name: SLSA Go releaser 15 | on: 16 | workflow_dispatch: 17 | release: 18 | types: [created] 19 | 20 | permissions: read-all 21 | 22 | jobs: 23 | # ======================================================================================================================================== 24 | # Prerequesite: Create a .slsa-goreleaser.yml in the root directory of your project. 25 | # See format in https://github.com/slsa-framework/slsa-github-generator/blob/main/internal/builders/go/README.md#configuration-file 26 | #========================================================================================================================================= 27 | build: 28 | permissions: 29 | id-token: write # To sign. 30 | contents: write # To upload release assets. 31 | actions: read # To read workflow path. 32 | strategy: 33 | matrix: 34 | os: 35 | - linux 36 | - windows 37 | - darwin 38 | arch: 39 | - amd64 40 | - arm64 41 | uses: slsa-framework/slsa-github-generator/.github/workflows/builder_go_slsa3.yml@v2.1.0 42 | with: 43 | go-version: 1.19.4 44 | config-file: .slsa-goreleaser/${{ matrix.os }}-${{ matrix.arch }}.yml 45 | # ============================================================================================================= 46 | # Optional: For more options, see https://github.com/slsa-framework/slsa-github-generator#golang-projects 47 | # ============================================================================================================= 48 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | build/ 3 | web/.env 4 | -------------------------------------------------------------------------------- /.slsa-goreleaser/darwin-amd64.yml: -------------------------------------------------------------------------------- 1 | # Version for this file. 2 | version: 1 3 | 4 | # (Optional) List of env variables used during compilation. 5 | env: 6 | - GO111MODULE=on 7 | - CGO_ENABLED=0 8 | 9 | # (Optional) Flags for the compiler. 10 | flags: 11 | - -trimpath 12 | - -tags=netgo 13 | 14 | # The OS to compile for. `GOOS` env variable will be set to this value. 15 | goos: darwin 16 | 17 | # The architecture to compile for. `GOARCH` env variable will be set to this value. 18 | goarch: amd64 19 | 20 | # (Optional) Entrypoint to compile. 21 | # main: ./path/to/main.go 22 | 23 | # (Optional) Working directory. (default: root of the project) 24 | # dir: ./relative/path/to/dir 25 | 26 | # Binary output name. 27 | # {{ .Os }} will be replaced by goos field in the config file. 28 | # {{ .Arch }} will be replaced by goarch field in the config file. 29 | binary: openbin-{{ .Os }}-{{ .Arch }} -------------------------------------------------------------------------------- /.slsa-goreleaser/darwin-arm64.yml: -------------------------------------------------------------------------------- 1 | # Version for this file. 2 | version: 1 3 | 4 | # (Optional) List of env variables used during compilation. 5 | env: 6 | - GO111MODULE=on 7 | - CGO_ENABLED=0 8 | 9 | # (Optional) Flags for the compiler. 10 | flags: 11 | - -trimpath 12 | - -tags=netgo 13 | 14 | # The OS to compile for. `GOOS` env variable will be set to this value. 15 | goos: darwin 16 | 17 | # The architecture to compile for. `GOARCH` env variable will be set to this value. 18 | goarch: arm64 19 | 20 | # (Optional) Entrypoint to compile. 21 | # main: ./path/to/main.go 22 | 23 | # (Optional) Working directory. (default: root of the project) 24 | # dir: ./relative/path/to/dir 25 | 26 | # Binary output name. 27 | # {{ .Os }} will be replaced by goos field in the config file. 28 | # {{ .Arch }} will be replaced by goarch field in the config file. 29 | binary: openbin-{{ .Os }}-{{ .Arch }} -------------------------------------------------------------------------------- /.slsa-goreleaser/linux-amd64.yml: -------------------------------------------------------------------------------- 1 | # Version for this file. 2 | version: 1 3 | 4 | # (Optional) List of env variables used during compilation. 5 | env: 6 | - GO111MODULE=on 7 | - CGO_ENABLED=0 8 | 9 | # (Optional) Flags for the compiler. 10 | flags: 11 | - -trimpath 12 | - -tags=netgo 13 | 14 | # The OS to compile for. `GOOS` env variable will be set to this value. 15 | goos: linux 16 | 17 | # The architecture to compile for. `GOARCH` env variable will be set to this value. 18 | goarch: amd64 19 | 20 | # (Optional) Entrypoint to compile. 21 | # main: ./path/to/main.go 22 | 23 | # (Optional) Working directory. (default: root of the project) 24 | # dir: ./relative/path/to/dir 25 | 26 | # Binary output name. 27 | # {{ .Os }} will be replaced by goos field in the config file. 28 | # {{ .Arch }} will be replaced by goarch field in the config file. 29 | binary: openbin-{{ .Os }}-{{ .Arch }} 30 | -------------------------------------------------------------------------------- /.slsa-goreleaser/linux-arm64.yml: -------------------------------------------------------------------------------- 1 | # Version for this file. 2 | version: 1 3 | 4 | # (Optional) List of env variables used during compilation. 5 | env: 6 | - GO111MODULE=on 7 | - CGO_ENABLED=0 8 | 9 | # (Optional) Flags for the compiler. 10 | flags: 11 | - -trimpath 12 | - -tags=netgo 13 | 14 | # The OS to compile for. `GOOS` env variable will be set to this value. 15 | goos: linux 16 | 17 | # The architecture to compile for. `GOARCH` env variable will be set to this value. 18 | goarch: arm64 19 | 20 | # (Optional) Entrypoint to compile. 21 | # main: ./path/to/main.go 22 | 23 | # (Optional) Working directory. (default: root of the project) 24 | # dir: ./relative/path/to/dir 25 | 26 | # Binary output name. 27 | # {{ .Os }} will be replaced by goos field in the config file. 28 | # {{ .Arch }} will be replaced by goarch field in the config file. 29 | binary: openbin-{{ .Os }}-{{ .Arch }} -------------------------------------------------------------------------------- /.slsa-goreleaser/windows-amd64.yml: -------------------------------------------------------------------------------- 1 | # Version for this file. 2 | version: 1 3 | 4 | # (Optional) List of env variables used during compilation. 5 | env: 6 | - GO111MODULE=on 7 | - CGO_ENABLED=0 8 | 9 | # (Optional) Flags for the compiler. 10 | flags: 11 | - -trimpath 12 | - -tags=netgo 13 | 14 | # The OS to compile for. `GOOS` env variable will be set to this value. 15 | goos: windows 16 | 17 | # The architecture to compile for. `GOARCH` env variable will be set to this value. 18 | goarch: amd64 19 | 20 | # (Optional) Entrypoint to compile. 21 | # main: ./path/to/main.go 22 | 23 | # (Optional) Working directory. (default: root of the project) 24 | # dir: ./relative/path/to/dir 25 | 26 | # Binary output name. 27 | # {{ .Os }} will be replaced by goos field in the config file. 28 | # {{ .Arch }} will be replaced by goarch field in the config file. 29 | binary: openbin-{{ .Os }}-{{ .Arch }}.exe -------------------------------------------------------------------------------- /.slsa-goreleaser/windows-arm64.yml: -------------------------------------------------------------------------------- 1 | # Version for this file. 2 | version: 1 3 | 4 | # (Optional) List of env variables used during compilation. 5 | env: 6 | - GO111MODULE=on 7 | - CGO_ENABLED=0 8 | 9 | # (Optional) Flags for the compiler. 10 | flags: 11 | - -trimpath 12 | - -tags=netgo 13 | 14 | # The OS to compile for. `GOOS` env variable will be set to this value. 15 | goos: windows 16 | 17 | # The architecture to compile for. `GOARCH` env variable will be set to this value. 18 | goarch: arm64 19 | 20 | # (Optional) Entrypoint to compile. 21 | # main: ./path/to/main.go 22 | 23 | # (Optional) Working directory. (default: root of the project) 24 | # dir: ./relative/path/to/dir 25 | 26 | # Binary output name. 27 | # {{ .Os }} will be replaced by goos field in the config file. 28 | # {{ .Arch }} will be replaced by goarch field in the config file. 29 | binary: openbin-{{ .Os }}-{{ .Arch }}.exe -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": "Launch Package", 9 | "type": "go", 10 | "request": "launch", 11 | "mode": "auto", 12 | "program": "cli-go/main.go", 13 | "args": ["delete", "36468d53-525b-46d5-b9d3-973e5da362f7"] 14 | } 15 | ] 16 | } 17 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Unnamed Engineering 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Openbin 2 | 3 | Openbin is a Pastebin clone that takes notes & code sharing to the next level, by taking advantage of both a CLI and a web editor. We built it for the 8th Supabase Launch Week hackathon, attempting to solve issues we face often while trying to share snippets and code with friends and colleagues. The app is built using Go for the CLI and Next.js, Tailwind, TypeScript and `shadcn/ui` for the web editor. We take advantage of a whole lot of Supabase products, including the Database, Auth, Storage and the brand new Resend email integration. 4 | 5 | # Documentation 6 | The documentation is designed to help or refer you to the Openbin CLI. Occasionally, errors may occur, you may encounter bugs, or you simply have questions or need assistance in using it - if you are in one of these situations, [simply open an issue on the repo](https://github.com/ethndotsh/openbin/issues/new)! 7 | 8 | To view the complete list of options not mentioned in the documentation: `openbin up --help`. 9 | 10 | ## Installation 11 | Without guessing, this is the most important step in using CLI. You can install it on any operating system, whether Windows, Linux or macOS. 12 | 13 | ### Windows (Powershell) 14 | ```powershell 15 | irm https://openbin.ethn.sh/install.ps1 | iex 16 | ``` 17 | 18 | ### Linux and macOS 19 | ```shell 20 | curl -fsSL https://openbin.ethn.sh/install.sh | sh 21 | ``` 22 | 23 | To make sure that Openbin is installed, enter `openbin --version` in your terminal. If it gives you the version, this means that Openbin has been successfully installed and you're ready to start using it! 🎉 24 | 25 | ## Login to your account 26 | To get your pastes into your account, you should login to your Openbin account by entering this command: 27 | ``` 28 | openbin login 29 | ``` 30 | or with OAuth providers: 31 | ``` 32 | openbin login -p github/gitlab/bitbucket 33 | ``` 34 | 35 | Also, if you want to logout of your account, all you have to do is `openbin logout` - it's that simple! 36 | > Please note that you can't upload without being logged in, so this step is necessary. 37 | 38 | ## Upload a file to Openbin 39 | 40 | ``` 41 | openbin upload [file.extension] 42 | ``` 43 | ### Options: 44 | `--title [value]`\ 45 | `--description [value]`\ 46 | `--language [value]`\ 47 | `--expire [value]`\ 48 | `--editor [value]`\ 49 | `--draft` 50 | 51 | ## Manage your pastes 52 | The CLI lets you do everything just like with the web editor, and that means you can manage your pastes too! 53 | 54 | ### Get a list of the pastes you've created 55 | ``` 56 | openbin pastes 57 | ``` 58 | ### Delete a paste 59 | ``` 60 | openbin delete [uuid] 61 | ``` 62 | > The UUID is located directly after the pastes/. 63 | -------------------------------------------------------------------------------- /cli/cmd/auth.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "time" 7 | 8 | sb "github.com/jackmerrill/supabase-go" 9 | "github.com/urfave/cli/v2" 10 | ) 11 | 12 | var LoginCommand = cli.Command{ 13 | Name: "login", 14 | Aliases: []string{"auth", "signin"}, 15 | Usage: "Login to your Openbin account.", 16 | Flags: []cli.Flag{ 17 | &cli.StringFlag{ 18 | Name: "email", 19 | Aliases: []string{"e"}, 20 | Usage: "Your Openbin account email.", 21 | Required: false, 22 | }, 23 | &cli.StringFlag{ 24 | Name: "provider", 25 | Aliases: []string{"p"}, 26 | Usage: "Your Openbin account provider.", 27 | Required: false, 28 | DefaultText: "github", 29 | }, 30 | }, 31 | Action: func(cCtx *cli.Context) error { 32 | codeVerifier := "" 33 | if cCtx.String("email") != "" { 34 | d, err := supabase.Auth.SignInWithOtp(ctx, sb.OtpSignInOptions{ 35 | Email: cCtx.String("email"), 36 | RedirectTo: "http://localhost:8089/auth-callback", 37 | FlowType: sb.PKCE, 38 | }) 39 | 40 | if err != nil { 41 | return err 42 | } 43 | 44 | codeVerifier = d.CodeVerifier 45 | 46 | fmt.Println("Check your email! 📧") 47 | } else { 48 | provider := cCtx.String("provider") 49 | 50 | if provider == "" { 51 | provider = "github" 52 | } 53 | 54 | d, err := supabase.Auth.SignInWithProvider(sb.ProviderSignInOptions{ 55 | Provider: provider, 56 | RedirectTo: "http://localhost:8089/auth-callback", 57 | FlowType: sb.PKCE, 58 | }) 59 | 60 | if err != nil { 61 | return err 62 | } 63 | 64 | codeVerifier = d.CodeVerifier 65 | 66 | fmt.Printf("Please go to the following URL to login: %s\n", d.URL) 67 | } 68 | 69 | stopServer := make(chan bool) 70 | 71 | // start a server to listen for the redirect 72 | // 73 | // 1. create a server 74 | // 2. listen for the redirect 75 | // 3. get the token 76 | // 4. save the token to the config file 77 | // 5. return a success message 78 | http.HandleFunc("/auth-callback", func(w http.ResponseWriter, r *http.Request) { 79 | // get the code 80 | code := r.URL.Query().Get("code") 81 | // fmt.Printf("Code: %s\n", code) 82 | // fmt.Printf("Code verifier: %s\n", codeVerifier) 83 | // get the token 84 | token, err := supabase.Auth.ExchangeCode(ctx, sb.ExchangeCodeOpts{ 85 | AuthCode: code, 86 | CodeVerifier: codeVerifier, 87 | }) 88 | 89 | if err != nil { 90 | fmt.Println(err) 91 | } 92 | 93 | // get expiry date 94 | expiryDate := time.Now().Add(time.Second * time.Duration(token.ExpiresIn)) 95 | 96 | settings.SetAccessToken(token.AccessToken) 97 | settings.SetRefreshToken(token.RefreshToken) 98 | settings.SetTokenExpiry(expiryDate) 99 | 100 | fmt.Println("Successfully logged in! 🎉") 101 | 102 | // close the server 103 | w.Write([]byte("Successfully logged in! 🎉")) 104 | 105 | stopServer <- true 106 | }) 107 | 108 | server := &http.Server{Addr: ":8089"} 109 | 110 | go func() { 111 | if err := server.ListenAndServe(); err != nil { 112 | fmt.Println(err) 113 | } 114 | }() 115 | 116 | <-stopServer 117 | 118 | if err := server.Shutdown(ctx); err != nil { 119 | fmt.Println(err) 120 | } 121 | 122 | return nil 123 | }, 124 | } 125 | 126 | var LogoutCommand = cli.Command{ 127 | Name: "logout", 128 | Aliases: []string{"signout"}, 129 | Usage: "Logout from your Openbin account.", 130 | Action: func(cCtx *cli.Context) error { 131 | settings.SetAccessToken("") 132 | settings.SetRefreshToken("") 133 | 134 | fmt.Println("Successfully logged out! 👋") 135 | 136 | return nil 137 | }, 138 | } 139 | -------------------------------------------------------------------------------- /cli/cmd/delete.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | 6 | db "github.com/ethndotsh/openbin/cli/supabase" 7 | "github.com/urfave/cli/v2" 8 | ) 9 | 10 | var DeleteCommand = cli.Command{ 11 | Name: "delete", 12 | Aliases: []string{"del", "rm"}, 13 | Usage: "Delete a paste.", 14 | ArgsUsage: `PASTE_ID`, 15 | Action: func(cCtx *cli.Context) error { 16 | pasteId := cCtx.Args().First() 17 | 18 | if pasteId == "" { 19 | cli.Exit("Please provide a paste ID.", 1) 20 | } 21 | 22 | user, err := supabase.Auth.User(ctx, settings.AccessToken) 23 | 24 | if err != nil { 25 | cli.Exit("You don't seem to be signed in. Try running `openbin login` to sign in.", 1) 26 | return err 27 | } 28 | 29 | supabase.DB.AddHeader("Authorization", fmt.Sprintf("Bearer %s", settings.AccessToken)) 30 | 31 | var paste []Paste 32 | 33 | err = supabase.DB.From("pastes").Select("file").Eq("id", pasteId).Eq("author", user.ID).Execute(&paste) 34 | 35 | if err != nil { 36 | cli.Exit("Could not get the paste.", 1) 37 | return err 38 | } 39 | 40 | if len(paste) == 0 { 41 | cli.Exit("Could not find the paste.", 1) 42 | return err 43 | } 44 | 45 | err = supabase.DB.From("pastes").Delete().Eq("id", pasteId).Execute(nil) 46 | 47 | if err != nil { 48 | cli.Exit("Could not delete the paste.", 1) 49 | return err 50 | } 51 | 52 | sbAuth := db.NewAuth() 53 | 54 | sbAuth.Storage.From("pastes").Remove([]string{paste[0].File}) 55 | 56 | fmt.Println("Deleted the paste! 🗑️") 57 | 58 | return nil 59 | }, 60 | } 61 | -------------------------------------------------------------------------------- /cli/cmd/pastes.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | 7 | "github.com/urfave/cli/v2" 8 | ) 9 | 10 | type CustomTime struct { 11 | time.Time 12 | } 13 | 14 | const customLayout = "2006-01-02T15:04:05.999999" 15 | 16 | // Implement the UnmarshalJSON method for CustomTime 17 | func (ct *CustomTime) UnmarshalJSON(data []byte) error { 18 | // Remove the surrounding quotes from the JSON string 19 | if string(data) == "null" { 20 | return nil 21 | } 22 | trimmedData := data[1 : len(data)-1] 23 | parsedTime, err := time.Parse(customLayout, string(trimmedData)) 24 | if err != nil { 25 | return err 26 | } 27 | ct.Time = parsedTime 28 | return nil 29 | } 30 | 31 | type Paste struct { 32 | ID string `json:"id"` 33 | CreatedAt CustomTime `json:"created_at,omitempty"` 34 | Author string `json:"author"` 35 | File string `json:"file"` 36 | Draft bool `json:"draft"` 37 | ExpiresAt CustomTime `json:"expires_at,omitempty"` 38 | Title string `json:"title"` 39 | Description string `json:"description"` 40 | Language string `json:"language"` 41 | } 42 | 43 | var PastesCommand = cli.Command{ 44 | Name: "pastes", 45 | Aliases: []string{"ls", "list"}, 46 | Usage: "Manage your pastes.", 47 | Action: func(cCtx *cli.Context) error { 48 | user, err := supabase.Auth.User(ctx, settings.AccessToken) 49 | 50 | if err != nil { 51 | cli.Exit("You don't seem to be signed in. Try running `openbin login` to sign in.", 1) 52 | } 53 | 54 | if user == nil { 55 | cli.Exit("You don't seem to be signed in. Try running `openbin login` to sign in.", 1) 56 | } 57 | 58 | supabase.DB.AddHeader("Authorization", fmt.Sprintf("Bearer %s", settings.AccessToken)) 59 | 60 | var pastes []Paste 61 | err = supabase.DB.From("pastes").Select("*").Eq("author", user.ID).Execute(&pastes) 62 | 63 | if err != nil { 64 | cli.Exit("Could not get the pastes.", 1) 65 | return err 66 | } 67 | 68 | for _, paste := range pastes { 69 | visibility := "Public" 70 | 71 | if paste.Draft { 72 | visibility = "Draft" 73 | } 74 | 75 | title := paste.Title 76 | 77 | if title == "" { 78 | title = "Untitled Paste" 79 | } 80 | 81 | fmt.Printf("\n- %s: %s [%s]\n", title, fmt.Sprintf("https://openbin.ethn.sh/paste/%s", paste.ID), visibility) 82 | } 83 | 84 | return nil 85 | }, 86 | } 87 | -------------------------------------------------------------------------------- /cli/cmd/root.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "context" 5 | "time" 6 | 7 | "github.com/ethndotsh/openbin/cli/config" 8 | db "github.com/ethndotsh/openbin/cli/supabase" 9 | ) 10 | 11 | var ctx = context.Background() 12 | var supabase = db.New() 13 | var settings = config.New() 14 | 15 | func init() { 16 | now := time.Now() 17 | 18 | if settings.AccessToken != "" && settings.RefreshToken != "" { 19 | if settings.Expires.Before(now) { 20 | // refresh the token 21 | token, err := supabase.Auth.RefreshUser(ctx, settings.AccessToken, settings.RefreshToken) 22 | 23 | if err != nil { 24 | panic(err) 25 | } 26 | 27 | // get expiry date 28 | expiryDate := time.Now().Add(time.Second * time.Duration(token.ExpiresIn)) 29 | 30 | settings.SetAccessToken(token.AccessToken) 31 | settings.SetRefreshToken(token.RefreshToken) 32 | settings.SetTokenExpiry(expiryDate) 33 | 34 | supabase = db.New() 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /cli/cmd/upload.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "io/ioutil" 6 | "os" 7 | "os/exec" 8 | "strings" 9 | "time" 10 | 11 | "github.com/atotto/clipboard" 12 | uuid "github.com/doamatto/nobs-uuid" 13 | "github.com/ethndotsh/openbin/cli/config" 14 | db "github.com/ethndotsh/openbin/cli/supabase" 15 | "github.com/hairyhenderson/go-which" 16 | "github.com/pkg/browser" 17 | "github.com/urfave/cli/v2" 18 | ) 19 | 20 | type UploadOptions struct { 21 | File string 22 | Editor string 23 | Draft bool 24 | Expire string 25 | Title string 26 | Description string 27 | Language string 28 | Copy bool 29 | Open bool 30 | Quiet bool 31 | } 32 | 33 | var UploadCommand = cli.Command{ 34 | Name: "upload", 35 | Aliases: []string{"u", "up"}, 36 | Usage: "Upload a file to Openbin.", 37 | ArgsUsage: "[FILE]", 38 | Flags: []cli.Flag{ 39 | &cli.StringFlag{ 40 | Name: "editor", 41 | Aliases: []string{"E"}, 42 | Usage: "Set the editor to use to edit the paste. Must be the command executable (i.e. code, vim, nano, etc.)", 43 | Required: false, 44 | }, 45 | &cli.BoolFlag{ 46 | Name: "draft", 47 | Aliases: []string{"d"}, 48 | Usage: "Set the paste to draft (private).", 49 | Required: false, 50 | }, 51 | &cli.StringFlag{ 52 | Name: "expire", 53 | Aliases: []string{"e"}, 54 | Usage: "Set the paste to expire after a certain time.", 55 | Required: false, 56 | }, 57 | &cli.StringFlag{ 58 | Name: "title", 59 | Aliases: []string{"t"}, 60 | Usage: "Set the paste title. Use quotes if it has spaces.", 61 | Required: false, 62 | }, 63 | &cli.StringFlag{ 64 | Name: "description", 65 | Usage: "Set the paste description. Use quotes if it has spaces.", 66 | Required: false, 67 | }, 68 | &cli.StringFlag{ 69 | Name: "language", 70 | Aliases: []string{"l"}, 71 | Usage: "Set the paste language.", 72 | Required: false, 73 | }, 74 | &cli.BoolFlag{ 75 | Name: "copy", 76 | Aliases: []string{"c"}, 77 | Usage: "Copy the paste URL to the clipboard.", 78 | Required: false, 79 | }, 80 | &cli.BoolFlag{ 81 | Name: "open", 82 | Aliases: []string{"o"}, 83 | Usage: "Open the paste URL in the browser.", 84 | Required: false, 85 | }, 86 | &cli.BoolFlag{ 87 | Name: "quiet", 88 | Aliases: []string{"q"}, 89 | Usage: "Don't print anything to the console. Errors will still be printed.", 90 | Required: false, 91 | }, 92 | }, 93 | Action: func(cCtx *cli.Context) error { 94 | user, err := supabase.Auth.User(ctx, settings.AccessToken) 95 | 96 | if err != nil { 97 | cli.Exit("You don't seem to be signed in. Try running `openbin login` to sign in.", 1) 98 | } 99 | 100 | if user == nil { 101 | cli.Exit("You don't seem to be signed in. Try running `openbin login` to sign in.", 1) 102 | } 103 | 104 | args := UploadOptions{ 105 | File: cCtx.Args().First(), 106 | Editor: cCtx.String("editor"), 107 | Draft: cCtx.Bool("draft"), 108 | Expire: cCtx.String("expire"), 109 | Title: cCtx.String("title"), 110 | Description: cCtx.String("description"), 111 | Language: cCtx.String("language"), 112 | Copy: cCtx.Bool("copy"), 113 | Open: cCtx.Bool("open"), 114 | Quiet: cCtx.Bool("quiet"), 115 | } 116 | 117 | if args.File != "" { 118 | // Check if the file exists 119 | if _, err := os.Stat(args.File); os.IsNotExist(err) { 120 | return cli.Exit("The file you specified does not exist.", 1) 121 | } 122 | } 123 | 124 | if args.Language == "" { 125 | // Try to get the language from the file extension 126 | if args.File != "" { 127 | fileExt := config.GetFileExtension(args.File) 128 | if config.IsFileTypeAllowed(fileExt) { 129 | filetype := config.GetFileTypeByFilePath(args.File) 130 | args.Language = filetype.Value 131 | } else { 132 | return cli.Exit(fmt.Sprintf("The file type you specified is not allowed.\nYou specified: %s\nAllowed types: %s", config.GetFileExtension(args.File), strings.Join(config.GetAllowedTypes(), ", ")), 1) 133 | } 134 | } 135 | } else { 136 | // Check if the language is allowed 137 | if !config.IsFileTypeAllowedByValue(args.Language) { 138 | return cli.Exit(fmt.Sprintf("The file type you specified is not allowed.\nYou specified: %s\nAllowed types: %s", config.GetFileExtension(args.Language), strings.Join(config.GetAllowedTypes(), ", ")), 1) 139 | } 140 | } 141 | 142 | if args.Editor != "" { 143 | editorPath := which.Which(args.Editor) 144 | if editorPath == "" { 145 | return cli.Exit("The editor you specified could not be found.", 1) 146 | } 147 | 148 | path := "" 149 | 150 | if args.File == "" { 151 | // make a temporary file 152 | // open the editor 153 | 154 | f, err := ioutil.TempFile("", "openbin-*.txt") 155 | 156 | if err != nil { 157 | return cli.Exit("Could not create a temporary file.", 1) 158 | } 159 | 160 | path = f.Name() 161 | } else { 162 | path = args.File 163 | } 164 | 165 | if !args.Quiet { 166 | fmt.Println("Waiting for you to finish editing...") 167 | } 168 | 169 | if args.Editor == "code" { 170 | err := exec.Command(editorPath, "-r", "--wait", path).Run() 171 | 172 | if err != nil { 173 | return cli.Exit("Could not open the editor.", 1) 174 | } 175 | } else { 176 | err := exec.Command(editorPath, path).Run() 177 | 178 | if err != nil { 179 | return cli.Exit("Could not open the editor.", 1) 180 | } 181 | } 182 | 183 | // upload the file 184 | UploadFile(path, args) 185 | 186 | // delete the temporary file 187 | if args.File == "" { 188 | os.Remove(path) 189 | } 190 | 191 | if !args.Quiet { 192 | fmt.Println("Uploaded the file! 🎉") 193 | } 194 | return nil 195 | } 196 | 197 | if args.File == "" && args.Editor == "" { 198 | return cli.Exit("You must specify a file or an editor.", 1) 199 | } 200 | 201 | if args.File != "" { 202 | UploadFile(args.File, args) 203 | if !args.Quiet { 204 | fmt.Println("Uploaded the file! 🎉") 205 | } 206 | } 207 | 208 | return nil 209 | }, 210 | } 211 | 212 | func UploadFile(path string, opts UploadOptions) { 213 | _, id, err := uuid.GenUUID() 214 | 215 | if err != nil { 216 | cli.Exit("Could not generate a UUID.", 1) 217 | } 218 | 219 | data, err := os.Open(path) 220 | 221 | if err != nil { 222 | cli.Exit("Could not open the file.", 1) 223 | } 224 | 225 | sbAuth := db.NewAuth() 226 | 227 | sbAuth.Storage.From("pastes").Upload(fmt.Sprintf("pastes/openbin-%s.txt", id), data) 228 | 229 | // expires_at should be a UTC string from this format: "MM-dd-yyyy hh:mm" 230 | 231 | var expires_at *time.Time 232 | 233 | if opts.Expire != "" { 234 | e, err := time.Parse(opts.Expire, "01-02-2006 03:04") 235 | 236 | if err != nil { 237 | cli.Exit("Could not parse the expiration date.", 1) 238 | } 239 | 240 | expires_at = &e 241 | } 242 | 243 | user, err := supabase.Auth.User(ctx, settings.AccessToken) 244 | 245 | if err != nil { 246 | cli.Exit("You don't seem to be signed in. Try running `openbin login` to sign in.", 1) 247 | } 248 | 249 | if user == nil { 250 | cli.Exit("You don't seem to be signed in. Try running `openbin login` to sign in.", 1) 251 | } 252 | 253 | supabase.DB.AddHeader("Authorization", fmt.Sprintf("Bearer %s", settings.AccessToken)) 254 | 255 | err = supabase.DB.From("pastes").Insert(map[string]interface{}{ 256 | "id": id, 257 | "title": opts.Title, 258 | "description": opts.Description, 259 | "language": opts.Language, 260 | "draft": opts.Draft, 261 | "expires_at": expires_at, 262 | "file": fmt.Sprintf("pastes/openbin-%s.txt", id), 263 | "author": user.ID, 264 | }).ExecuteWithContext(ctx, nil) 265 | 266 | if err != nil { 267 | cli.Exit("Could not upload the file.", 1) 268 | } 269 | 270 | if !opts.Quiet { 271 | fmt.Printf("https://openbin.ethn.sh/pastes/%s\n", id) 272 | } 273 | 274 | if opts.Copy { 275 | // copy the URL to the clipboard 276 | err = clipboard.WriteAll(fmt.Sprintf("https://openbin.ethn.sh/pastes/%s", id)) 277 | 278 | if err != nil { 279 | fmt.Println(err) 280 | cli.Exit("Could not copy the URL to the clipboard.", 1) 281 | } 282 | } 283 | 284 | if opts.Open { 285 | // open the URL in the browser 286 | err = browser.OpenURL(fmt.Sprintf("https://openbin.ethn.sh/pastes/%s", id)) 287 | 288 | if err != nil { 289 | fmt.Println(err) 290 | cli.Exit("Could not open the URL in the browser.", 1) 291 | } 292 | } 293 | } 294 | -------------------------------------------------------------------------------- /cli/config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "encoding/json" 5 | "os" 6 | "path/filepath" 7 | "time" 8 | 9 | "github.com/kirsle/configdir" 10 | ) 11 | 12 | type AppSettings struct { 13 | AccessToken string 14 | RefreshToken string 15 | Expires time.Time 16 | } 17 | 18 | func New() AppSettings { 19 | configPath := configdir.LocalConfig("openbin") 20 | 21 | // create the config file if it doesn't exist 22 | if err := configdir.MakePath(configPath); err != nil { 23 | panic(err) 24 | } 25 | 26 | configFile := filepath.Join(configPath, "settings.json") 27 | 28 | var settings AppSettings 29 | 30 | // create the config file if it doesn't exist 31 | if _, err := os.Stat(configFile); os.IsNotExist(err) { 32 | // Create the new config file. 33 | settings = AppSettings{} 34 | fh, err := os.Create(configFile) 35 | if err != nil { 36 | panic(err) 37 | } 38 | defer fh.Close() 39 | 40 | encoder := json.NewEncoder(fh) 41 | encoder.Encode(&settings) 42 | } else { 43 | // Load the existing file. 44 | fh, err := os.Open(configFile) 45 | if err != nil { 46 | panic(err) 47 | } 48 | defer fh.Close() 49 | 50 | decoder := json.NewDecoder(fh) 51 | decoder.Decode(&settings) 52 | } 53 | 54 | return settings 55 | } 56 | 57 | func (s *AppSettings) Save() { 58 | configPath := configdir.LocalConfig("openbin") 59 | configFile := filepath.Join(configPath, "settings.json") 60 | 61 | fh, err := os.OpenFile(configFile, os.O_WRONLY|os.O_TRUNC, 0644) 62 | if err != nil { 63 | panic(err) 64 | } 65 | defer fh.Close() 66 | 67 | encoder := json.NewEncoder(fh) 68 | encoder.Encode(s) 69 | } 70 | 71 | func (s *AppSettings) SetAccessToken(token string) { 72 | s.AccessToken = token 73 | s.Save() 74 | } 75 | 76 | func (s *AppSettings) SetRefreshToken(token string) { 77 | s.RefreshToken = token 78 | s.Save() 79 | } 80 | 81 | func (s *AppSettings) GetAccessToken() string { 82 | return s.AccessToken 83 | } 84 | 85 | func (s *AppSettings) GetRefreshToken() string { 86 | return s.RefreshToken 87 | } 88 | 89 | func (s *AppSettings) SetTokenExpiry(expiry time.Time) { 90 | s.Expires = expiry 91 | s.Save() 92 | } 93 | 94 | func (s *AppSettings) GetTokenExpiry() time.Time { 95 | return s.Expires 96 | } 97 | -------------------------------------------------------------------------------- /cli/config/fileTypes.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "path/filepath" 5 | "strings" 6 | ) 7 | 8 | type FileType struct { 9 | Name string `json:"name"` 10 | Value string `json:"value"` 11 | Extensions []string `json:"extensions"` 12 | } 13 | 14 | var AcceptedTypes = []FileType{ 15 | { 16 | Name: "Plain Text", 17 | Value: "plaintext", 18 | Extensions: []string{"txt"}, 19 | }, 20 | { 21 | Name: "JavaScript", 22 | Value: "javascript", 23 | Extensions: []string{"js", "jsx", "mjs", "cjs"}, 24 | }, 25 | { 26 | Name: "TypeScript", 27 | Value: "typescript", 28 | Extensions: []string{"ts", "mts", "cts", "tsx"}, 29 | }, 30 | { 31 | Name: "Python", 32 | Value: "python", 33 | Extensions: []string{"py"}, 34 | }, 35 | { 36 | Name: "JSON", 37 | Value: "json", 38 | Extensions: []string{"json"}, 39 | }, 40 | { 41 | Name: "HTML", 42 | Value: "html", 43 | Extensions: []string{"html"}, 44 | }, 45 | { 46 | Name: "CSS", 47 | Value: "css", 48 | Extensions: []string{"css"}, 49 | }, 50 | { 51 | Name: "Markdown", 52 | Value: "markdown", 53 | Extensions: []string{"md"}, 54 | }, 55 | { 56 | Name: "Lua", 57 | Value: "lua", 58 | Extensions: []string{"lua"}, 59 | }, 60 | { 61 | Name: "Go", 62 | Value: "go", 63 | Extensions: []string{"go"}, 64 | }, 65 | { 66 | Name: "Rust", 67 | Value: "rust", 68 | Extensions: []string{"rs"}, 69 | }, 70 | { 71 | Name: "XML", 72 | Value: "xml", 73 | Extensions: []string{"xml"}, 74 | }, 75 | { 76 | Name: "YAML", 77 | Value: "yaml", 78 | Extensions: []string{"yaml", "yml"}, 79 | }, 80 | { 81 | Name: "MDX", 82 | Value: "mdx", 83 | Extensions: []string{"mdx"}, 84 | }, 85 | { 86 | Name: "SCSS", 87 | Value: "scss", 88 | Extensions: []string{"scss"}, 89 | }, 90 | { 91 | Name: "Less", 92 | Value: "less", 93 | Extensions: []string{"less"}, 94 | }, 95 | { 96 | Name: "Elixir", 97 | Value: "elixir", 98 | Extensions: []string{"ex", "exs"}, 99 | }, 100 | { 101 | Name: "C++", 102 | Value: "cpp", 103 | Extensions: []string{"cpp"}, 104 | }, 105 | { 106 | Name: "C#", 107 | Value: "csharp", 108 | Extensions: []string{"cs"}, 109 | }, 110 | { 111 | Name: "Java", 112 | Value: "java", 113 | Extensions: []string{"java"}, 114 | }, 115 | { 116 | Name: "Kotlin", 117 | Value: "kotlin", 118 | Extensions: []string{"kt"}, 119 | }, 120 | { 121 | Name: "Dart", 122 | Value: "dart", 123 | Extensions: []string{"dart"}, 124 | }, 125 | { 126 | Name: "Scala", 127 | Value: "scala", 128 | Extensions: []string{"scala"}, 129 | }, 130 | { 131 | Name: "Handlebars", 132 | Value: "handlebars", 133 | Extensions: []string{"hbs", "handlebars"}, 134 | }, 135 | { 136 | Name: "Pug", 137 | Value: "pug", 138 | Extensions: []string{"pug"}, 139 | }, 140 | { 141 | Name: "Ruby", 142 | Value: "ruby", 143 | Extensions: []string{"rb"}, 144 | }, 145 | { 146 | Name: "PHP", 147 | Value: "php", 148 | Extensions: []string{"php"}, 149 | }, 150 | { 151 | Name: "Swift", 152 | Value: "swift", 153 | Extensions: []string{"swift"}, 154 | }, 155 | { 156 | Name: "Solidity", 157 | Value: "solidity", 158 | Extensions: []string{"sol"}, 159 | }, 160 | { 161 | Name: "SQL", 162 | Value: "sql", 163 | Extensions: []string{"sql"}, 164 | }, 165 | { 166 | Name: "Dockerfile", 167 | Value: "dockerfile", 168 | Extensions: []string{"dockerfile"}, 169 | }, 170 | { 171 | Name: "Shell", 172 | Value: "shell", 173 | Extensions: []string{"sh"}, 174 | }, 175 | { 176 | Name: "PowerShell", 177 | Value: "powershell", 178 | Extensions: []string{"ps1"}, 179 | }, 180 | { 181 | Name: "R", 182 | Value: "r", 183 | Extensions: []string{"r"}, 184 | }, 185 | { 186 | Name: "Perl", 187 | Value: "perl", 188 | Extensions: []string{"pl"}, 189 | }, 190 | { 191 | Name: "GraphQL", 192 | Value: "graphql", 193 | Extensions: []string{"graphql", "gql"}, 194 | }, 195 | { 196 | Name: "Clojure", 197 | Value: "clojure", 198 | Extensions: []string{"clj"}, 199 | }, 200 | { 201 | Name: "Objective-C", 202 | Value: "objective-c", 203 | Extensions: []string{"m"}, 204 | }, 205 | } 206 | 207 | func GetFileTypeByExtension(extension string) FileType { 208 | for _, acceptedType := range AcceptedTypes { 209 | for _, ext := range acceptedType.Extensions { 210 | if ext == extension { 211 | return acceptedType 212 | } 213 | } 214 | } 215 | return FileType{} 216 | } 217 | 218 | func GetFileTypeByValue(value string) FileType { 219 | for _, acceptedType := range AcceptedTypes { 220 | if acceptedType.Value == value { 221 | return acceptedType 222 | } 223 | } 224 | return FileType{} 225 | } 226 | 227 | func GetFileTypeByName(name string) FileType { 228 | for _, acceptedType := range AcceptedTypes { 229 | if acceptedType.Name == name { 230 | return acceptedType 231 | } 232 | } 233 | return FileType{} 234 | } 235 | 236 | func GetFileTypeByFilePath(filePath string) FileType { 237 | extension := GetFileExtension(filePath) 238 | return GetFileTypeByExtension(extension) 239 | } 240 | 241 | func GetFileExtension(filePath string) string { 242 | // Get the file extension 243 | extension := filepath.Ext(filePath) 244 | // Remove the dot 245 | extension = strings.Replace(extension, ".", "", -1) 246 | return extension 247 | } 248 | 249 | func IsFileTypeAllowed(ext string) bool { 250 | for _, acceptedType := range AcceptedTypes { 251 | for _, extension := range acceptedType.Extensions { 252 | if extension == ext { 253 | return true 254 | } 255 | } 256 | } 257 | return false 258 | } 259 | 260 | func IsFileTypeAllowedByValue(value string) bool { 261 | for _, acceptedType := range AcceptedTypes { 262 | if acceptedType.Value == value { 263 | return true 264 | } 265 | } 266 | return false 267 | } 268 | 269 | func GetAllowedTypes() []string { 270 | var allowedTypes []string 271 | for _, acceptedType := range AcceptedTypes { 272 | allowedTypes = append(allowedTypes, acceptedType.Value) 273 | } 274 | return allowedTypes 275 | } 276 | -------------------------------------------------------------------------------- /cli/main.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io/ioutil" 7 | "log" 8 | "net/http" 9 | "os" 10 | "strings" 11 | 12 | "github.com/ethndotsh/openbin/cli/cmd" 13 | "github.com/urfave/cli/v2" 14 | ) 15 | 16 | const VERSION = "1.0.8" 17 | 18 | type GitHubResponse struct { 19 | TagName string `json:"tag_name"` 20 | } 21 | 22 | func Run() { 23 | app := &cli.App{ 24 | Name: "openbin", 25 | HelpName: "openbin", 26 | EnableBashCompletion: true, 27 | Description: "A CLI tool for Openbin, a free and open-source pastebin alternative built primarily for command-line warriors.", 28 | Flags: []cli.Flag{ 29 | &cli.BoolFlag{ 30 | Name: "version", 31 | Aliases: []string{ 32 | "v", 33 | }, 34 | Usage: "Print the version of openbin.", 35 | }, 36 | }, 37 | Action: func(cCtx *cli.Context) error { 38 | if cCtx.Bool("version") { 39 | fmt.Println("openbin version " + VERSION) 40 | return nil 41 | } 42 | fmt.Println("Welcome to openbin! Run `openbin help` to get started.") 43 | return nil 44 | }, 45 | After: func(cCtx *cli.Context) error { 46 | // Check for updates, and if there are any, notify the user. 47 | res, err := http.Get("https://api.github.com/repos/ethndotsh/openbin/releases/latest") 48 | if err != nil { 49 | // If there's an error, just return. 50 | return nil 51 | } 52 | 53 | if res.StatusCode != 200 { 54 | // If there's an error, just return. 55 | return nil 56 | } 57 | 58 | // Get the "tag_name" field from the JSON response. 59 | // This is the latest version of openbin. 60 | // If there is a newer version, notify the user. 61 | 62 | var response GitHubResponse 63 | 64 | defer res.Body.Close() 65 | 66 | body, err := ioutil.ReadAll(res.Body) 67 | 68 | if err != nil { 69 | return nil 70 | } 71 | 72 | err = json.Unmarshal(body, &response) 73 | 74 | if err != nil { 75 | return nil 76 | } 77 | 78 | if compareVersions(VERSION, response.TagName) == -1 { 79 | fmt.Printf("There is a new version of openbin available! You are running %s, and the latest version is %s.\n", VERSION, response.TagName) 80 | fmt.Println("You can update by running whatever command you used to install openbin.") 81 | } 82 | 83 | return nil 84 | }, 85 | Commands: []*cli.Command{ 86 | &cmd.LoginCommand, 87 | &cmd.LogoutCommand, 88 | &cmd.UploadCommand, 89 | &cmd.PastesCommand, 90 | &cmd.DeleteCommand, 91 | }, 92 | } 93 | 94 | if err := app.Run(os.Args); err != nil { 95 | log.Fatal(err) 96 | } 97 | } 98 | 99 | func compareVersions(a string, b string) int { 100 | // check the major, minor, and patch versions 101 | // if a > b, return 1 102 | // if a < b, return -1 103 | // if a == b, return 0 104 | 105 | // split the versions into arrays 106 | aArr := strings.Split(a, ".") 107 | bArr := strings.Split(b, ".") 108 | 109 | // compare the major versions 110 | if aArr[0] > bArr[0] { 111 | return 1 112 | } else if aArr[0] < bArr[0] { 113 | return -1 114 | } 115 | 116 | // compare the minor versions 117 | if aArr[1] > bArr[1] { 118 | return 1 119 | } else if aArr[1] < bArr[1] { 120 | return -1 121 | } 122 | 123 | // compare the patch versions 124 | if aArr[2] > bArr[2] { 125 | return 1 126 | } else if aArr[2] < bArr[2] { 127 | return -1 128 | } 129 | 130 | return 0 131 | } 132 | -------------------------------------------------------------------------------- /cli/supabase/supabase.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | import ( 4 | "github.com/ethndotsh/openbin/cli/config" 5 | "github.com/jackmerrill/supabase-go" 6 | ) 7 | 8 | const SUPABASE_URL = "https://yjcmvygieeqeptqmiykg.supabase.co" 9 | const SUPABASE_KEY = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InlqY212eWdpZWVxZXB0cW1peWtnIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NDI0NDE0NjgsImV4cCI6MjA1ODAxNzQ2OH0.9vyMbaYpYKrXC9WlTrEuFdyVglhaNxoUV4t9d9mfgJA" 10 | 11 | func New() *supabase.Client { 12 | supabaseClient := supabase.CreateClient(SUPABASE_URL, SUPABASE_KEY) 13 | return supabaseClient 14 | } 15 | 16 | func NewAuth() *supabase.Client { 17 | settings := config.New() 18 | 19 | if settings.AccessToken == "" { 20 | panic("No access token found. Please login with `openbin login`.") 21 | } 22 | 23 | return supabase.CreateClient(SUPABASE_URL, settings.AccessToken) 24 | } 25 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/ethndotsh/openbin 2 | 3 | go 1.19 4 | 5 | require ( 6 | github.com/atotto/clipboard v0.1.4 7 | github.com/doamatto/nobs-uuid v0.0.0-20230404013526-6ced46a9f4e8 8 | github.com/hairyhenderson/go-which v0.2.0 9 | github.com/jackmerrill/supabase-go v0.4.1 10 | github.com/kirsle/configdir v0.0.0-20170128060238-e45d2f54772f 11 | github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8 12 | github.com/urfave/cli/v2 v2.25.7 13 | ) 14 | 15 | require ( 16 | github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect 17 | github.com/google/go-querystring v1.1.0 // indirect 18 | github.com/nedpals/postgrest-go v0.1.3 // indirect 19 | github.com/russross/blackfriday/v2 v2.1.0 // indirect 20 | github.com/spf13/afero v1.3.3 // indirect 21 | github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect 22 | golang.org/x/sys v0.0.0-20210616045830-e2b7044e8c71 // indirect 23 | golang.org/x/text v0.3.0 // indirect 24 | ) 25 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= 2 | github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= 3 | github.com/cpuguy83/go-md2man/v2 v2.0.2 h1:p1EgwI/C7NhT0JmVkwCD2ZBK8j4aeHQX2pMHHBfMQ6w= 4 | github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= 5 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 6 | github.com/doamatto/nobs-uuid v0.0.0-20230404013526-6ced46a9f4e8 h1:MhLhwnEFV83k54BhlPLEAo2xUrWotX3O8xFPyThIlvI= 7 | github.com/doamatto/nobs-uuid v0.0.0-20230404013526-6ced46a9f4e8/go.mod h1:M4rKZKBTsXjSw7DpqJ+G469ALT5mdj36tkmGG9UyKpI= 8 | github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= 9 | github.com/google/go-cmp v0.5.2 h1:X2ev0eStA3AbceY54o37/0PQ/UWqKEiiO2dKL5OPaFM= 10 | github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 11 | github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= 12 | github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= 13 | github.com/hairyhenderson/go-which v0.2.0 h1:vxoCKdgYc6+MTBzkJYhWegksHjjxuXPNiqo5G2oBM+4= 14 | github.com/hairyhenderson/go-which v0.2.0/go.mod h1:U1BQQRCjxYHfOkXDyCgst7OZVknbqI7KuGKhGnmyIik= 15 | github.com/jackmerrill/supabase-go v0.4.1 h1:jbsbeFqnMOMiYvFd5tL7XSJvzxXqhRbBQ4FR6NEux00= 16 | github.com/jackmerrill/supabase-go v0.4.1/go.mod h1:vSyl/8/hCBcqmwv8t9bow9sDWR3CVnCnVPEUnKL2Q08= 17 | github.com/kirsle/configdir v0.0.0-20170128060238-e45d2f54772f h1:dKccXx7xA56UNqOcFIbuqFjAWPVtP688j5QMgmo6OHU= 18 | github.com/kirsle/configdir v0.0.0-20170128060238-e45d2f54772f/go.mod h1:4rEELDSfUAlBSyUjPG0JnaNGjf13JySHFeRdD/3dLP0= 19 | github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= 20 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 21 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 22 | github.com/nedpals/postgrest-go v0.1.3 h1:ZC3aPPx9rDTWQWzvnWI60lJWjAqgCCD/U6hcHp3NL0w= 23 | github.com/nedpals/postgrest-go v0.1.3/go.mod h1:RGinB2OXsnGLcZMu5avS0U+b9npyZmk+ecK74UDi/xY= 24 | github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= 25 | github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8 h1:KoWmjvw+nsYOo29YJK9vDA65RGE3NrOnUtO7a+RF9HU= 26 | github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8/go.mod h1:HKlIX3XHQyzLZPlr7++PzdhaXEj94dEiJgZDTsxEqUI= 27 | github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I= 28 | github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 29 | github.com/pkg/sftp v1.10.1/go.mod h1:lYOWFsE0bwd1+KfKJaKeuokY15vzFx25BLbzYYoAxZI= 30 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 31 | github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= 32 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 33 | github.com/spf13/afero v1.3.3 h1:p5gZEKLYoL7wh8VrJesMaYeNxdEd1v3cb4irOk9zB54= 34 | github.com/spf13/afero v1.3.3/go.mod h1:5KUK8ByomD5Ti5Artl0RtHeI5pTF7MIDuXL3yY520V4= 35 | github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= 36 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 37 | github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= 38 | github.com/urfave/cli/v2 v2.25.7 h1:VAzn5oq403l5pHjc4OhD54+XGO9cdKVL/7lDjF+iKUs= 39 | github.com/urfave/cli/v2 v2.25.7/go.mod h1:8qnjx1vcq5s2/wpsqoZFndg2CE5tNFyrTvS6SinrnYQ= 40 | github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 h1:bAn7/zixMGCfxrRTfdpNzjtPYqr8smhKouy9mxVdGPU= 41 | github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673/go.mod h1:N3UwUGtsrSj3ccvlPHLoLsHnpR27oXr4ZE984MbSER8= 42 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 43 | golang.org/x/crypto v0.0.0-20190820162420-60c769a6c586/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 44 | golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 45 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 46 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 47 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 48 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 49 | golang.org/x/sys v0.0.0-20210616045830-e2b7044e8c71 h1:X/2sJAybVknnUnV7AD2HdT6rm2p5BP6eH2j+igduWgk= 50 | golang.org/x/sys v0.0.0-20210616045830-e2b7044e8c71/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 51 | golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg= 52 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 53 | golang.org/x/tools v0.0.0-20190624222133-a101b041ded4/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= 54 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 55 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 56 | gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 57 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 58 | gotest.tools/v3 v3.0.2 h1:kG1BFyqVHuQoVQiR1bWGnfz/fmHvvuiSPIV7rvl360E= 59 | gotest.tools/v3 v3.0.2/go.mod h1:3SzNCllyD9/Y+b5r9JIKQ474KzkZyqLqEfYqMsX94Bk= 60 | -------------------------------------------------------------------------------- /install.ps1: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env pwsh 2 | # Inspired from the Deno install script: https://deno.land/x/install@v0.1.8/install.ps1?source= 3 | 4 | $ErrorActionPreference = "Stop" 5 | 6 | $BinDir = "${Home}\.openbin\bin" 7 | 8 | $DownloadUrl = "https://github.com/ethndotsh/openbin/releases/latest/download/openbin-windows-amd64.exe" 9 | 10 | $DownloadPath = "${BinDir}\openbin.exe" 11 | 12 | if (!(Test-Path $BinDir)) { 13 | New-Item -ItemType Directory -Force -Path $BinDir 14 | } 15 | 16 | curl.exe -Lo $DownloadPath $DownloadUrl 17 | 18 | $User = [System.EnvironmentVariableTarget]::User 19 | $Path = [System.Environment]::GetEnvironmentVariable("Path", $User) 20 | if (!(";${Path};".ToLower() -like "*;${BinDir}:*".ToLower())) { 21 | [System.Environment]::SetEnvironmentVariable("Path", "${Path};${BinDir}", $User) 22 | $Env:Path += ";${BinDir}" 23 | } 24 | 25 | # check for the "NoAlias" flag, if it's set, we'll skip aliasing. Alias to `ob` 26 | if ($args[0] -ne "--no-alias") { 27 | $Alias = "ob" 28 | if (Test-Path alias:\$Alias) { 29 | # if the alias already exists, don't remove it, but warn the user 30 | Write-Host "⚠️ The alias '$Alias' is already in your PowerShell profile. We won't change it." 31 | } else { 32 | # if the alias doesn't exist, add it 33 | New-Item -Path $PROFILE.CurrentUserAllHosts -ItemType File -Force 34 | Add-Content -Path $PROFILE.CurrentUserAllHosts -Value "Set-Alias -Name $Alias -Value $DownloadPath" 35 | Write-Host "Alias '$Alias' created!" 36 | } 37 | } 38 | 39 | Write-Host "🎉 Openbin Installed!" 40 | Write-Host "" 41 | Write-Host "Run 'openbin --help' to get started" -------------------------------------------------------------------------------- /install.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # Inspired from the Deno install script: https://deno.land/x/install@v0.1.8/install.sh?source= 3 | 4 | set -e 5 | 6 | if [ "$OS" = "Windows_NT" ]; then 7 | target="windows" 8 | ext=".exe" 9 | case $(uname -m) in 10 | x86_64) arch="amd64" ;; 11 | aarch64) arch="arm64" ;; 12 | *) 13 | echo "Unsupported architecture: $(uname -m)" 14 | exit 1 15 | ;; 16 | esac 17 | else 18 | ext="" 19 | case $(uname -sm) in 20 | "Darwin x86_64") 21 | target="darwin" 22 | arch="amd64" 23 | ;; 24 | "Darwin arm64") 25 | target="darwin" 26 | arch="arm64" 27 | ;; 28 | "Linux x86_64") 29 | target="linux" 30 | arch="amd64" 31 | ;; 32 | "Linux armv8") 33 | target="linux" 34 | arch="arm64" 35 | ;; 36 | *) 37 | echo "Unsupported platform: $(uname -sm)" 38 | exit 1 39 | ;; 40 | esac 41 | fi 42 | 43 | openbin_uri="https://github.com/ethndotsh/openbin/releases/latest/download/openbin-${target}-${arch}${ext}" 44 | 45 | bin_dir="${HOME}/.openbin/bin" 46 | 47 | if [ ! -d "$bin_dir" ]; then 48 | mkdir -p "$bin_dir" 49 | fi 50 | 51 | echo "Downloading binary..." 52 | 53 | curl --fail --location --progress-bar --output "$bin_dir/openbin${ext}" "$openbin_uri" 54 | chmod +x "$bin_dir/openbin${ext}" 55 | 56 | echo "Installing binary..." 57 | 58 | if command -v openbin >/dev/null; then 59 | echo "Run 'openbin --help' to get started" 60 | else 61 | case $SHELL in 62 | /bin/zsh) shell_profile=".zshrc" ;; 63 | *) shell_profile=".bashrc" ;; 64 | esac 65 | echo "Adding openbin to $HOME/$shell_profile" 66 | echo "" >>"$HOME/$shell_profile" 67 | echo "# openbin" >>"$HOME/$shell_profile" 68 | echo "export PATH=\"$bin_dir:\$PATH\"" >>"$HOME/$shell_profile" 69 | fi 70 | 71 | # check for the "--no-alias" flag, if not present, add alias (`ob`) 72 | if [ "$1" != "--no-alias" ]; then 73 | case $SHELL in 74 | /bin/zsh) shell_profile=".zshrc" ;; 75 | *) shell_profile=".bashrc" ;; 76 | esac 77 | echo "Adding openbin alias to $HOME/$shell_profile" 78 | echo "" >>"$HOME/$shell_profile" 79 | echo "# openbin alias" >>"$HOME/$shell_profile" 80 | echo "alias ob=openbin" >>"$HOME/$shell_profile" 81 | fi 82 | 83 | echo "🎉 Openbin Installed!" 84 | echo "" 85 | echo "Restart your shell or update your PATH:" 86 | echo "" 87 | echo " export PATH=\"$bin_dir:\$PATH\"" 88 | echo "" 89 | echo "Run 'openbin --help' to get started" 90 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/ethndotsh/openbin/cli" 5 | ) 6 | 7 | func main() { 8 | cli.Run() 9 | } 10 | -------------------------------------------------------------------------------- /openbin.code-workspace: -------------------------------------------------------------------------------- 1 | { 2 | "folders": [ 3 | { 4 | "name": "project-root", 5 | "path": "./" 6 | }, 7 | { 8 | "name": "supabase-functions", 9 | "path": "supabase/functions" 10 | } 11 | ], 12 | "settings": { 13 | "files.exclude": { 14 | "supabase/functions/": true 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "openbin", 3 | "version": "1.0.7", 4 | "description": "Pastebin clone that takes notes & code sharing to the next level.", 5 | "scripts": { 6 | "install": "go install github.com/ethndotsh/openbin@latest", 7 | "uninstall": "rm -rf $GOPATH/bin/openbin" 8 | }, 9 | "repository": { 10 | "type": "git", 11 | "url": "git+https://github.com/ethndotsh/openbin.git" 12 | }, 13 | "keywords": [ 14 | "pastebin", 15 | "openbin", 16 | "supabase" 17 | ], 18 | "author": "The Openbin Team", 19 | "license": "MIT", 20 | "bugs": { 21 | "url": "https://github.com/ethndotsh/openbin/issues" 22 | }, 23 | "homepage": "https://github.com/ethndotsh/openbin#readme" 24 | } 25 | -------------------------------------------------------------------------------- /pnpm-lock.yaml: -------------------------------------------------------------------------------- 1 | lockfileVersion: '9.0' 2 | 3 | settings: 4 | autoInstallPeers: true 5 | excludeLinksFromLockfile: false 6 | 7 | importers: 8 | 9 | .: {} 10 | -------------------------------------------------------------------------------- /supabase/.gitignore: -------------------------------------------------------------------------------- 1 | # Supabase 2 | .branches 3 | .temp 4 | -------------------------------------------------------------------------------- /supabase/config.toml: -------------------------------------------------------------------------------- 1 | # A string used to distinguish different Supabase projects on the same host. Defaults to the 2 | # working directory name when running `supabase init`. 3 | project_id = "openbin" 4 | 5 | [api] 6 | # Port to use for the API URL. 7 | port = 54321 8 | # Schemas to expose in your API. Tables, views and stored procedures in this schema will get API 9 | # endpoints. public and storage are always included. 10 | schemas = ["public", "storage", "graphql_public"] 11 | # Extra schemas to add to the search_path of every request. public is always included. 12 | extra_search_path = ["public", "extensions"] 13 | # The maximum number of rows returns from a view, table, or stored procedure. Limits payload size 14 | # for accidental or malicious requests. 15 | max_rows = 1000 16 | 17 | [db] 18 | # Port to use for the local database URL. 19 | port = 54322 20 | # Port used by db diff command to initialise the shadow database. 21 | shadow_port = 54320 22 | # The database major version to use. This has to be the same as your remote database's. Run `SHOW 23 | # server_version;` on the remote database to check. 24 | major_version = 15 25 | 26 | [studio] 27 | enabled = true 28 | # Port to use for Supabase Studio. 29 | port = 54323 30 | # External URL of the API server that frontend connects to. 31 | api_url = "http://localhost" 32 | 33 | # Email testing server. Emails sent with the local dev setup are not actually sent - rather, they 34 | # are monitored, and you can view the emails that would have been sent from the web interface. 35 | [inbucket] 36 | enabled = true 37 | # Port to use for the email testing server web interface. 38 | port = 54324 39 | # Uncomment to expose additional ports for testing user applications that send emails. 40 | # smtp_port = 54325 41 | # pop3_port = 54326 42 | 43 | [storage] 44 | # The maximum file size allowed (e.g. "5MB", "500KB"). 45 | file_size_limit = "50MiB" 46 | 47 | [auth] 48 | # The base URL of your website. Used as an allow-list for redirects and for constructing URLs used 49 | # in emails. 50 | site_url = "http://localhost:3000" 51 | # A list of *exact* URLs that auth providers are permitted to redirect to post authentication. 52 | additional_redirect_urls = ["https://localhost:3000"] 53 | # How long tokens are valid for, in seconds. Defaults to 3600 (1 hour), maximum 604,800 (1 week). 54 | jwt_expiry = 3600 55 | # If disabled, the refresh token will never expire. 56 | enable_refresh_token_rotation = true 57 | # Allows refresh tokens to be reused after expiry, up to the specified interval in seconds. 58 | # Requires enable_refresh_token_rotation = true. 59 | refresh_token_reuse_interval = 10 60 | # Allow/disallow new user signups to your project. 61 | enable_signup = true 62 | 63 | [auth.email] 64 | # Allow/disallow new user signups via email to your project. 65 | enable_signup = true 66 | # If enabled, a user will be required to confirm any email change on both the old, and new email 67 | # addresses. If disabled, only the new email is required to confirm. 68 | double_confirm_changes = true 69 | # If enabled, users need to confirm their email address before signing in. 70 | enable_confirmations = false 71 | 72 | [auth.sms] 73 | # Allow/disallow new user signups via SMS to your project. 74 | enable_signup = true 75 | # If enabled, users need to confirm their phone number before signing in. 76 | enable_confirmations = false 77 | 78 | # Configure one of the supported SMS providers: `twilio`, `messagebird`, `textlocal`, `vonage`. 79 | [auth.sms.twilio] 80 | enabled = false 81 | account_sid = "" 82 | message_service_sid = "" 83 | # DO NOT commit your Twilio auth token to git. Use environment variable substitution instead: 84 | auth_token = "env(SUPABASE_AUTH_SMS_TWILIO_AUTH_TOKEN)" 85 | 86 | # Use an external OAuth provider. The full list of providers are: `apple`, `azure`, `bitbucket`, 87 | # `discord`, `facebook`, `github`, `gitlab`, `google`, `keycloak`, `linkedin`, `notion`, `twitch`, 88 | # `twitter`, `slack`, `spotify`, `workos`, `zoom`. 89 | [auth.external.apple] 90 | enabled = false 91 | client_id = "" 92 | # DO NOT commit your OAuth provider secret to git. Use environment variable substitution instead: 93 | secret = "env(SUPABASE_AUTH_EXTERNAL_APPLE_SECRET)" 94 | # Overrides the default auth redirectUrl. 95 | redirect_uri = "" 96 | # Overrides the default auth provider URL. Used to support self-hosted gitlab, single-tenant Azure, 97 | # or any other third-party OIDC providers. 98 | url = "" 99 | 100 | [analytics] 101 | enabled = false 102 | port = 54327 103 | vector_port = 54328 104 | # Setup BigQuery project to enable log viewer on local development stack. 105 | # See: https://supabase.com/docs/guides/getting-started/local-development#enabling-local-logging 106 | gcp_project_id = "" 107 | gcp_project_number = "" 108 | gcp_jwt_path = "supabase/gcloud.json" 109 | -------------------------------------------------------------------------------- /supabase/seed.sql: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ethndotsh/openbin/3d61b6e5ec26e82da539338d17271ed1ed589d7c/supabase/seed.sql -------------------------------------------------------------------------------- /supabase/types.ts: -------------------------------------------------------------------------------- 1 | export type Json = 2 | | string 3 | | number 4 | | boolean 5 | | null 6 | | { [key: string]: Json | undefined } 7 | | Json[] 8 | 9 | export interface Database { 10 | public: { 11 | Tables: { 12 | pastes: { 13 | Row: { 14 | author: string | null 15 | created_at: string | null 16 | description: string | null 17 | expires_at: string | null 18 | file: string | null 19 | id: string 20 | private: boolean | null 21 | syntax: string | null 22 | title: string | null 23 | } 24 | Insert: { 25 | author?: string | null 26 | created_at?: string | null 27 | description?: string | null 28 | expires_at?: string | null 29 | file?: string | null 30 | id?: string 31 | private?: boolean | null 32 | syntax?: string | null 33 | title?: string | null 34 | } 35 | Update: { 36 | author?: string | null 37 | created_at?: string | null 38 | description?: string | null 39 | expires_at?: string | null 40 | file?: string | null 41 | id?: string 42 | private?: boolean | null 43 | syntax?: string | null 44 | title?: string | null 45 | } 46 | Relationships: [ 47 | { 48 | foreignKeyName: "pastes_author_fkey" 49 | columns: ["author"] 50 | referencedRelation: "users" 51 | referencedColumns: ["id"] 52 | } 53 | ] 54 | } 55 | } 56 | Views: { 57 | [_ in never]: never 58 | } 59 | Functions: { 60 | [_ in never]: never 61 | } 62 | Enums: { 63 | [_ in never]: never 64 | } 65 | CompositeTypes: { 66 | [_ in never]: never 67 | } 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /web/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /web/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | # i'll .gitignore your mom 3 | 4 | # dependencies 5 | /node_modules 6 | /.pnp 7 | .pnp.js 8 | 9 | # testing 10 | /coverage 11 | 12 | # next.js 13 | /.next/ 14 | /out/ 15 | 16 | # production 17 | /build 18 | 19 | # misc 20 | .DS_Store 21 | *.pem 22 | 23 | # debug 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | 28 | # local env files 29 | .env*.local 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | next-env.d.ts 37 | -------------------------------------------------------------------------------- /web/README.md: -------------------------------------------------------------------------------- 1 | This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app). 2 | 3 | ## Getting Started 4 | 5 | First, run the development server: 6 | 7 | ```bash 8 | npm run dev 9 | # or 10 | yarn dev 11 | # or 12 | pnpm dev 13 | ``` 14 | 15 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. 16 | 17 | You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. 18 | 19 | This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font. 20 | 21 | ## Learn More 22 | 23 | To learn more about Next.js, take a look at the following resources: 24 | 25 | - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. 26 | - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. 27 | 28 | You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome! 29 | 30 | ## Deploy on Vercel 31 | 32 | The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. 33 | 34 | Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details. 35 | -------------------------------------------------------------------------------- /web/_eslintrc.cjs: -------------------------------------------------------------------------------- 1 | // (e)slint-disable-next-line @typescript-eslint/no-var-requires 2 | const path = require("path"); 3 | 4 | /** @type {import("eslint").Linter.Config} */ 5 | const config = { 6 | overrides: [ 7 | { 8 | extends: [ 9 | "plugin:@typescript-eslint/recommended-requiring-type-checking", 10 | ], 11 | files: ["*.ts", "*.tsx"], 12 | parserOptions: { 13 | project: path.join(__dirname, "tsconfig.json"), 14 | }, 15 | }, 16 | ], 17 | parser: "@typescript-eslint/parser", 18 | parserOptions: { 19 | project: path.join(__dirname, "tsconfig.json"), 20 | }, 21 | plugins: ["@typescript-eslint"], 22 | extends: ["next/core-web-vitals", "plugin:@typescript-eslint/recommended"], 23 | rules: { 24 | "@typescript-eslint/consistent-type-imports": [ 25 | "warn", 26 | { 27 | prefer: "type-imports", 28 | fixStyle: "inline-type-imports", 29 | }, 30 | ], 31 | "@typescript-eslint/no-unused-vars": ["warn", { argsIgnorePattern: "^_" }], 32 | }, 33 | }; 34 | 35 | module.exports = config; 36 | -------------------------------------------------------------------------------- /web/components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "default", 4 | "rsc": true, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.js", 8 | "css": "src/app/globals.css", 9 | "baseColor": "neutral", 10 | "cssVariables": true 11 | }, 12 | "aliases": { 13 | "components": "@/components", 14 | "utils": "@/utils/cn" 15 | } 16 | } -------------------------------------------------------------------------------- /web/next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | experimental: { 4 | serverActions: true, 5 | }, 6 | images: { 7 | domains: ["avatars.githubusercontent.com", "api.dicebear.com"], 8 | }, 9 | }; 10 | 11 | module.exports = nextConfig; 12 | -------------------------------------------------------------------------------- /web/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "web", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint" 10 | }, 11 | "dependencies": { 12 | "@hookform/resolvers": "^3.2.0", 13 | "@monaco-editor/react": "^4.5.1", 14 | "@radix-ui/react-checkbox": "^1.0.4", 15 | "@radix-ui/react-dialog": "^1.0.4", 16 | "@radix-ui/react-dropdown-menu": "^2.0.5", 17 | "@radix-ui/react-label": "^2.0.2", 18 | "@radix-ui/react-popover": "^1.0.6", 19 | "@radix-ui/react-separator": "^1.0.3", 20 | "@radix-ui/react-slot": "^1.0.2", 21 | "@radix-ui/react-tooltip": "^1.0.6", 22 | "@supabase/auth-helpers-nextjs": "^0.7.4", 23 | "@supabase/supabase-js": "^2.32.0", 24 | "class-variance-authority": "^0.7.0", 25 | "clsx": "^2.0.0", 26 | "cmdk": "^0.2.0", 27 | "date-fns": "^2.30.0", 28 | "lucide-react": "^0.263.1", 29 | "monaco-editor": "^0.41.0", 30 | "murmurhash2": "^0.1.0", 31 | "next": "13.4.13", 32 | "react": "18.2.0", 33 | "react-day-picker": "^8.8.0", 34 | "react-dom": "18.2.0", 35 | "react-hook-form": "^7.45.4", 36 | "react-hotkeys-hook": "^4.4.1", 37 | "react-wrap-balancer": "^1.0.0", 38 | "sonner": "^0.6.2", 39 | "supabase": ">=1.8.1", 40 | "tailwind-merge": "^1.14.0", 41 | "tailwindcss-animate": "^1.0.6", 42 | "tinycolor2": "^1.6.0", 43 | "uuid": "^9.0.0", 44 | "zact": "^0.0.2", 45 | "zod": "^3.21.4" 46 | }, 47 | "devDependencies": { 48 | "@tailwindcss/typography": "^0.5.9", 49 | "@types/eslint": "^8.44.2", 50 | "@types/node": "20.4.8", 51 | "@types/react": "18.2.18", 52 | "@types/react-dom": "18.2.7", 53 | "@types/tinycolor2": "^1.4.3", 54 | "@types/uuid": "^9.0.2", 55 | "@typescript-eslint/eslint-plugin": "^6.3.0", 56 | "@typescript-eslint/parser": "^6.3.0", 57 | "autoprefixer": "10.4.14", 58 | "encoding": "^0.1.13", 59 | "eslint": "8.46.0", 60 | "eslint-config-next": "13.4.13", 61 | "postcss": "8.4.27", 62 | "prettier": "^3.0.1", 63 | "prettier-plugin-tailwindcss": "^0.4.1", 64 | "tailwindcss": "3.3.3", 65 | "typescript": "5.1.6" 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /web/postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /web/prettier.config.cjs: -------------------------------------------------------------------------------- 1 | /** @type {import("prettier").Config} */ 2 | const config = { 3 | plugins: [require.resolve("prettier-plugin-tailwindcss")], 4 | }; 5 | 6 | module.exports = config; 7 | -------------------------------------------------------------------------------- /web/public/assets/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ethndotsh/openbin/3d61b6e5ec26e82da539338d17271ed1ed589d7c/web/public/assets/favicon.png -------------------------------------------------------------------------------- /web/public/assets/favicon.svg: -------------------------------------------------------------------------------- 1 | 2 | 12 | 13 | -------------------------------------------------------------------------------- /web/public/assets/image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ethndotsh/openbin/3d61b6e5ec26e82da539338d17271ed1ed589d7c/web/public/assets/image.png -------------------------------------------------------------------------------- /web/public/assets/pfp-placeholder.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ethndotsh/openbin/3d61b6e5ec26e82da539338d17271ed1ed589d7c/web/public/assets/pfp-placeholder.png -------------------------------------------------------------------------------- /web/public/assets/profile-bg.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ethndotsh/openbin/3d61b6e5ec26e82da539338d17271ed1ed589d7c/web/public/assets/profile-bg.png -------------------------------------------------------------------------------- /web/src/app/auth/callback/[redirect]/route.ts: -------------------------------------------------------------------------------- 1 | import { BASE_URL } from "@/utils/config"; 2 | import { createRouteHandlerClient } from "@supabase/auth-helpers-nextjs"; 3 | import { cookies } from "next/headers"; 4 | import { NextRequest, NextResponse } from "next/server"; 5 | 6 | export async function GET( 7 | req: NextRequest, 8 | { params }: { params: { redirect: string } }, 9 | ) { 10 | const supabase = createRouteHandlerClient({ cookies }); 11 | const { searchParams } = new URL(req.url); 12 | const code = searchParams.get("code"); 13 | const redirect = params.redirect; 14 | 15 | if (code) { 16 | await supabase.auth.exchangeCodeForSession(code); 17 | } 18 | 19 | if (redirect) { 20 | return NextResponse.redirect( 21 | new URL(decodeURIComponent(redirect), BASE_URL), 22 | ); 23 | } 24 | 25 | return NextResponse.redirect(new URL(`/`, BASE_URL)); 26 | } 27 | -------------------------------------------------------------------------------- /web/src/app/auth/callback/route.ts: -------------------------------------------------------------------------------- 1 | import { BASE_URL } from "@/utils/config"; 2 | import { createRouteHandlerClient } from "@supabase/auth-helpers-nextjs"; 3 | import { cookies } from "next/headers"; 4 | import { NextRequest, NextResponse } from "next/server"; 5 | 6 | export async function GET(req: NextRequest) { 7 | const supabase = createRouteHandlerClient({ cookies }); 8 | const { searchParams } = new URL(req.url); 9 | const code = searchParams.get("code"); 10 | 11 | if (code) { 12 | await supabase.auth.exchangeCodeForSession(code); 13 | } 14 | 15 | return NextResponse.redirect(new URL(`/`, BASE_URL)); 16 | } 17 | -------------------------------------------------------------------------------- /web/src/app/auth/delete/confirm/route.ts: -------------------------------------------------------------------------------- 1 | import { BASE_URL } from "@/utils/config"; 2 | import { 3 | createRouteHandlerClient, 4 | createServerActionClient, 5 | createServerComponentClient, 6 | } from "@supabase/auth-helpers-nextjs"; 7 | import { createClient } from "@supabase/supabase-js"; 8 | import { cookies } from "next/headers"; 9 | import { type NextRequest, NextResponse } from "next/server"; 10 | 11 | export async function POST(req: NextRequest) { 12 | if (!process.env.NEXT_PUBLIC_SUPABASE_URL) { 13 | throw new Error("Missing env.NEXT_PUBLIC_SUPABASE_URL"); 14 | } 15 | 16 | if (!process.env.SUPABASE_SERVICE_KEY) { 17 | throw new Error("Missing env.SUPABASE_SERVICE_KEY"); 18 | } 19 | 20 | const supabase = createServerActionClient({ cookies: () => cookies() }); 21 | 22 | const supabaseServer = createClient( 23 | process.env.NEXT_PUBLIC_SUPABASE_URL, 24 | process.env.SUPABASE_SERVICE_KEY, 25 | { auth: { persistSession: false } }, 26 | ); 27 | 28 | // Check if we have a session 29 | const { 30 | data: { session }, 31 | error: sessionError, 32 | } = await supabase.auth.getSession(); 33 | 34 | if (sessionError) { 35 | console.error("Error:", sessionError); 36 | return; 37 | } 38 | 39 | if (session) { 40 | await supabase.auth.signOut(); // to be safe 41 | await supabaseServer.auth.admin.signOut(session.access_token); 42 | 43 | const { data, error } = await supabaseServer.auth.admin.deleteUser( 44 | session.user.id, 45 | ); 46 | 47 | if (error) { 48 | console.error("Error:", error); 49 | return; 50 | } 51 | 52 | return NextResponse.redirect(new URL("/", BASE_URL), { 53 | status: 302, 54 | }); 55 | } else { 56 | return NextResponse.redirect(new URL("/", BASE_URL), { 57 | status: 302, 58 | }); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /web/src/app/auth/delete/layout.tsx: -------------------------------------------------------------------------------- 1 | import { Navbar } from "@/components/navbar"; 2 | import { 3 | createServerComponentClient, 4 | getProfile, 5 | getSession, 6 | } from "@/utils/supabase"; 7 | import { redirect } from "next/navigation"; 8 | import { ReactNode } from "react"; 9 | 10 | export default async function AuthDeleteLayout({ 11 | children, 12 | }: { 13 | children: ReactNode; 14 | }) { 15 | const session = await getSession(); 16 | 17 | if (!session) { 18 | return redirect("/"); 19 | } 20 | 21 | const profile = await getProfile(session.user.id); 22 | return ( 23 | <> 24 | 25 |
26 | {children} 27 | 28 | ); 29 | } 30 | -------------------------------------------------------------------------------- /web/src/app/auth/delete/page.tsx: -------------------------------------------------------------------------------- 1 | import { Button, buttonVariants } from "@/components/ui/button"; 2 | import { cn } from "@/utils/cn"; 3 | import { getSession } from "@/utils/supabase"; 4 | import Link from "next/link"; 5 | 6 | export default async function DeletePage() { 7 | const session = await getSession(); 8 | return ( 9 |
10 |
11 |
12 |

Delete Account

13 |

14 | Are you sure you want to delete your account? 15 |

16 |

17 | This action is irreversible. All of your pastes will be deleted. 18 | Remixed pastes made by other users will be unaffected. 19 |

20 |
21 | 22 | 25 | 26 |
31 | 39 |
40 |
41 |
42 |
43 |
44 | ); 45 | } 46 | -------------------------------------------------------------------------------- /web/src/app/auth/signout/route.ts: -------------------------------------------------------------------------------- 1 | import { BASE_URL } from "@/utils/config"; 2 | import { createRouteHandlerClient } from "@supabase/auth-helpers-nextjs"; 3 | import { cookies } from "next/headers"; 4 | import { type NextRequest, NextResponse } from "next/server"; 5 | 6 | export async function POST(req: NextRequest) { 7 | const supabase = createRouteHandlerClient({ cookies }); 8 | 9 | // Check if we have a session 10 | const { 11 | data: { session }, 12 | } = await supabase.auth.getSession(); 13 | 14 | if (session) { 15 | await supabase.auth.signOut(); 16 | } 17 | 18 | return NextResponse.redirect(new URL("/", BASE_URL), { 19 | status: 302, 20 | }); 21 | } 22 | -------------------------------------------------------------------------------- /web/src/app/avatar/route.tsx: -------------------------------------------------------------------------------- 1 | import { ImageResponse, NextRequest, NextResponse } from "next/server"; 2 | import { murmur2 } from "murmurhash2"; 3 | import color from "tinycolor2"; 4 | 5 | export const runtime = "edge"; 6 | 7 | function generateGradient(id: string) { 8 | const c1 = color({ h: murmur2(id, 360) % 360, s: 0.95, l: 0.5 }); 9 | const second = c1.triad()[1].toHexString(); 10 | 11 | return { 12 | fromColor: c1.toHexString(), 13 | toColor: second, 14 | }; 15 | } 16 | 17 | export async function GET(req: NextRequest) { 18 | const { searchParams } = new URL(req.url); 19 | const name = searchParams.get("name"); 20 | 21 | const gradient = generateGradient(name ?? "default"); 22 | 23 | return new ImageResponse( 24 | ( 25 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | ), 43 | { 44 | width: 150, 45 | height: 150, 46 | }, 47 | ); 48 | } 49 | -------------------------------------------------------------------------------- /web/src/app/editor/loading.tsx: -------------------------------------------------------------------------------- 1 | import { LoadingSpinner } from "@/components/loading-spinner"; 2 | 3 | export default function Loading() { 4 | return ( 5 |
6 | 7 |
8 | ); 9 | } 10 | -------------------------------------------------------------------------------- /web/src/app/editor/page.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | createServerComponentClient, 3 | getProfile, 4 | getSession, 5 | } from "@/utils/supabase"; 6 | import { MonacoEditor } from "@/components/editor"; 7 | import { Paste } from "types/types"; 8 | import { redirect } from "next/navigation"; 9 | 10 | export default async function Page({ 11 | searchParams, 12 | }: { 13 | searchParams: { remix?: string }; 14 | }) { 15 | const session = await getSession(); 16 | 17 | const profile = await getProfile(session?.user.id); 18 | const supabase = createServerComponentClient(); 19 | 20 | let remixContent: string | undefined; 21 | let remixData: Paste | undefined; 22 | 23 | if (searchParams.remix) { 24 | const { data, error } = await supabase 25 | .from("pastes") 26 | .select("*, author(*)") 27 | .eq("id", searchParams.remix) 28 | .single(); 29 | 30 | if (!data) { 31 | throw new Error("Remix not found"); 32 | } 33 | 34 | if (error) { 35 | throw new Error(error); 36 | } 37 | 38 | const { data: fileData, error: fileError } = await supabase.storage 39 | .from("pastes") 40 | .download(data.file); 41 | 42 | if (fileError) { 43 | throw new Error(fileError.message); 44 | } 45 | 46 | if (!fileData) { 47 | throw new Error("Discrepancy between paste and file"); 48 | } 49 | 50 | const file = await fileData.text(); 51 | 52 | remixContent = file; 53 | remixData = data; 54 | } 55 | 56 | return ( 57 |
58 |
59 | 65 |
66 |
67 | ); 68 | } 69 | -------------------------------------------------------------------------------- /web/src/app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @layer base { 6 | :root { 7 | --background: 0 0% 100%; 8 | --foreground: 0 0% 3.9%; 9 | 10 | --muted: 0 0% 96.1%; 11 | --muted-foreground: 0 0% 45.1%; 12 | 13 | --popover: 0 0% 100%; 14 | --popover-foreground: 0 0% 3.9%; 15 | 16 | --card: 0 0% 100%; 17 | --card-foreground: 0 0% 3.9%; 18 | 19 | --border: 0 0% 89.8%; 20 | --input: 0 0% 89.8%; 21 | 22 | --primary: 0 0% 9%; 23 | --primary-foreground: 0 0% 98%; 24 | 25 | --secondary: 0 0% 96.1%; 26 | --secondary-foreground: 0 0% 9%; 27 | 28 | --accent: 0 0% 96.1%; 29 | --accent-foreground: 0 0% 9%; 30 | 31 | --destructive: 0 84.2% 60.2%; 32 | --destructive-foreground: 0 0% 98%; 33 | 34 | --ring: 0 0% 63.9%; 35 | 36 | --radius: 0.75rem; 37 | } 38 | 39 | .dark { 40 | --background: 0 0% 3.9%; 41 | --foreground: 0 0% 98%; 42 | 43 | --muted: 0 0% 14.9%; 44 | --muted-foreground: 0 0% 63.9%; 45 | 46 | --popover: 0 0% 3.9%; 47 | --popover-foreground: 0 0% 98%; 48 | 49 | --card: 0 0% 3.9%; 50 | --card-foreground: 0 0% 98%; 51 | 52 | --border: 0 0% 14.9%; 53 | --input: 0 0% 14.9%; 54 | 55 | --primary: 0 0% 98%; 56 | --primary-foreground: 0 0% 9%; 57 | 58 | --secondary: 0 0% 14.9%; 59 | --secondary-foreground: 0 0% 98%; 60 | 61 | --accent: 0 0% 14.9%; 62 | --accent-foreground: 0 0% 98%; 63 | 64 | --destructive: 0 62.8% 30.6%; 65 | --destructive-foreground: 0 85.7% 97.3%; 66 | 67 | --ring: 0 0% 14.9%; 68 | } 69 | } 70 | 71 | @layer base { 72 | * { 73 | @apply border-border; 74 | } 75 | body { 76 | @apply bg-background text-foreground; 77 | } 78 | } 79 | 80 | .cursor { 81 | display: inline-block; 82 | } 83 | @keyframes blink { 84 | 50% { 85 | opacity: 0; 86 | } 87 | } 88 | .blink { 89 | animation: blink 1s steps(1, start) infinite; 90 | } 91 | -------------------------------------------------------------------------------- /web/src/app/install.ps1/route.ts: -------------------------------------------------------------------------------- 1 | export const runtime = "edge"; 2 | 3 | export async function GET() { 4 | // Fetches the install script from GitHub 5 | const res = await fetch( 6 | "https://raw.githubusercontent.com/ethndotsh/openbin/main/install.ps1", 7 | ); 8 | // Returns the install script 9 | return new Response(res.body); 10 | } 11 | -------------------------------------------------------------------------------- /web/src/app/install.sh/route.ts: -------------------------------------------------------------------------------- 1 | export const runtime = "edge"; 2 | 3 | export async function GET() { 4 | // Fetches the install script from GitHub 5 | const res = await fetch( 6 | "https://raw.githubusercontent.com/ethndotsh/openbin/main/install.sh", 7 | ); 8 | // Returns the install script 9 | return new Response(res.body); 10 | } 11 | -------------------------------------------------------------------------------- /web/src/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import "./globals.css"; 2 | import type { Metadata } from "next"; 3 | import { Inter } from "next/font/google"; 4 | import Head from "next/head"; 5 | import Script from "next/script"; 6 | import { Toaster } from "sonner"; 7 | 8 | const inter = Inter({ subsets: ["latin"] }); 9 | 10 | export const metadata: Metadata = { 11 | title: "Openbin", 12 | description: 13 | "Openbin is a free and open source pastebin alternative built with command-line users and developers in mind.", 14 | icons: { 15 | icon: [ 16 | { 17 | url: "/assets/favicon.svg", 18 | type: "image/svg+xml", 19 | }, 20 | { 21 | url: "/assets/favicon.png", 22 | }, 23 | ], 24 | }, 25 | 26 | openGraph: { 27 | title: "Openbin", 28 | description: 29 | "Openbin is a free and open source pastebin alternative built with command-line users and developers in mind.", 30 | url: "https://openbin.ethn.sh/", 31 | images: [ 32 | { 33 | url: "/assets/image.png", 34 | width: 800, 35 | height: 600, 36 | }, 37 | ], 38 | }, 39 | }; 40 | 41 | export default function RootLayout({ 42 | children, 43 | }: { 44 | children: React.ReactNode; 45 | }) { 46 | return ( 47 | 48 | 53 | 54 | {children} 55 | 56 | 57 | 58 | ); 59 | } 60 | -------------------------------------------------------------------------------- /web/src/app/login/loading.tsx: -------------------------------------------------------------------------------- 1 | import { LoadingSpinner } from "@/components/loading-spinner"; 2 | 3 | export default function Loading() { 4 | return ( 5 |
6 | 7 |
8 | ); 9 | } 10 | -------------------------------------------------------------------------------- /web/src/app/login/page.tsx: -------------------------------------------------------------------------------- 1 | import { Footer } from "@/components/footer"; 2 | import { Logo } from "@/components/logo"; 3 | import { Circles } from "@/components/svg/circles"; 4 | import { LoginComponent } from "@/components/login"; 5 | 6 | export default function Login() { 7 | return ( 8 |
9 |
10 | 11 |
12 |
13 |
14 |
15 | 16 |
17 | 18 |
19 |

Login

20 | 21 |
22 | 23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 | ); 32 | } 33 | -------------------------------------------------------------------------------- /web/src/app/me/route.ts: -------------------------------------------------------------------------------- 1 | import { redirect } from "next/navigation"; 2 | import { cookies } from "next/headers"; 3 | import { NextRequest } from "next/server"; 4 | import { createRouteHandlerClient } from "@supabase/auth-helpers-nextjs"; 5 | 6 | export async function GET(req: NextRequest) { 7 | const supabase = createRouteHandlerClient({ cookies }); 8 | 9 | const { data } = await supabase.auth.getSession(); 10 | 11 | if (data.session && data.session.user) { 12 | return redirect(`/profiles/${data.session.user.id}`); 13 | } 14 | 15 | return redirect(`/login`); 16 | } 17 | -------------------------------------------------------------------------------- /web/src/app/page.tsx: -------------------------------------------------------------------------------- 1 | import { Logo } from "@/components/logo"; 2 | import { Footer } from "@/components/footer"; 3 | import { SupabaseLogo } from "@/components/svg/supabase-logo"; 4 | import { Button } from "@/components/ui/button"; 5 | import { Balancer } from "react-wrap-balancer"; 6 | import { Terminal } from "@/components/terminal"; 7 | import Link from "next/link"; 8 | import bg from "@/assets/bg.svg"; 9 | import grid from "@/assets/grid.svg"; 10 | import Image from "next/image"; 11 | import { Avatar } from "@/components/avatar"; 12 | import { getSession, getProfile } from "@/utils/supabase"; 13 | import { Profile } from "types/types"; 14 | 15 | export default async function Home() { 16 | const session = await getSession(); 17 | 18 | let profile: Profile | null = null; 19 | 20 | if (session && session.user) { 21 | profile = await getProfile(session.user.id); 22 | } 23 | 24 | return ( 25 |
26 | {session && profile && ( 27 |
28 | 29 |
30 | )} 31 |
32 |
33 | background 39 |
40 |
41 | background 47 |
48 | 49 |
50 |
51 | 52 | 53 |
54 | 55 | 56 | Supabase LW8 Hackathon Winner 57 | 58 |
59 |

60 | 61 | Code and notes sharing built for command line warriors. 62 | 63 |

64 |

65 | Openbin brings the ease of use of Pastebin and Gists to your 66 | terminal, allowing you to draft, publish and share text files in 67 | seconds. 68 |

69 |
70 | 71 | 74 | 75 | 79 | 93 | 94 |
95 |
96 |
97 | 102 |
103 |
104 |
105 | ); 106 | } 107 | -------------------------------------------------------------------------------- /web/src/app/pastes/[id]/error.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { AlertTriangle } from "lucide-react"; 4 | 5 | export default function Error({ 6 | error, 7 | }: { 8 | error: Error & { digest?: string }; 9 | }) { 10 | return ( 11 |
12 | 13 |

Error rendering paste

14 |
15 | ); 16 | } 17 | -------------------------------------------------------------------------------- /web/src/app/pastes/[id]/layout.tsx: -------------------------------------------------------------------------------- 1 | import { Navbar } from "@/components/navbar"; 2 | import { 3 | createServerComponentClient, 4 | getProfile, 5 | getSession, 6 | } from "@/utils/supabase"; 7 | import { ReactNode } from "react"; 8 | 9 | export default async function PasteLayout({ 10 | children, 11 | }: { 12 | children: ReactNode; 13 | }) { 14 | const session = await getSession(); 15 | 16 | const profile = await getProfile(session?.user.id); 17 | return ( 18 | <> 19 | 20 |
21 | {children} 22 | 23 | ); 24 | } 25 | -------------------------------------------------------------------------------- /web/src/app/pastes/[id]/loading.tsx: -------------------------------------------------------------------------------- 1 | import { LoadingSpinner } from "@/components/loading-spinner"; 2 | 3 | export default function Loading() { 4 | return ( 5 |
6 | 7 |
8 | ); 9 | } 10 | -------------------------------------------------------------------------------- /web/src/app/pastes/[id]/not-found.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { AlertTriangle } from "lucide-react"; 4 | 5 | export default function NotFound() { 6 | return ( 7 |
8 |

Paste does not exist

9 |
10 | ); 11 | } 12 | -------------------------------------------------------------------------------- /web/src/app/pastes/[id]/page.tsx: -------------------------------------------------------------------------------- 1 | import { createServerComponentClient, getSession } from "@/utils/supabase"; 2 | import { redirect, useParams } from "next/navigation"; 3 | import { PasteViewer } from "@/components/editor/viewer"; 4 | import { Profile } from "types/types"; 5 | import Link from "next/link"; 6 | import { Avatar } from "@/components/avatar"; 7 | import { Dialog, DialogTitle } from "@/components/ui/dialog"; 8 | import DeletePasteConfirmation from "@/components/editor/delete-paste"; 9 | import { FormEvent } from "react"; 10 | import { buttonVariants } from "@/components/ui/button"; 11 | import { Metadata } from "next"; 12 | import { createRouteHandlerClient } from "@supabase/auth-helpers-nextjs"; 13 | import { cookies } from "next/headers"; 14 | import { Database } from "types/supabase"; 15 | import { Disc3 } from "lucide-react"; 16 | import { Button } from "@/components/ui/button"; 17 | import { PublishUnpublishButton } from "@/components/editor/toggle-publish"; 18 | import { validate } from "uuid"; 19 | import { notFound } from "next/navigation"; 20 | 21 | type Props = { 22 | params: { id: string }; 23 | }; 24 | 25 | export async function generateMetadata({ params }: Props): Promise { 26 | const supabase = createServerComponentClient(); 27 | const session = await getSession(); 28 | // read route params 29 | const id = params.id; 30 | 31 | // fetch data 32 | const { data: paste, error } = await supabase 33 | .from("pastes") 34 | .select("*") 35 | .eq("id", id) 36 | .single(); 37 | 38 | if (error) { 39 | return { 40 | title: "Error", 41 | description: "Error retrieving paste", 42 | }; 43 | } 44 | 45 | if (paste.draft && paste.author !== session?.user?.id) { 46 | return { 47 | title: "Openbin", 48 | }; 49 | } 50 | 51 | return { 52 | title: `${paste?.title ?? "Untitled Paste"} - Openbin`, 53 | description: paste?.description ?? undefined, 54 | }; 55 | } 56 | 57 | export default async function Paste({ params }: { params: { id: string } }) { 58 | const { id } = params; 59 | const supabase = createServerComponentClient(); 60 | const session = await getSession(); 61 | 62 | if (!validate(id)) { 63 | notFound(); 64 | } 65 | 66 | const { data: pasteData, error: pasteError } = await supabase 67 | .from("pastes") 68 | .select("*, author(*)") 69 | .eq("id", id) 70 | .single(); 71 | 72 | if (!pasteData || pasteError) { 73 | notFound(); 74 | } 75 | 76 | if (pasteError) { 77 | throw new Error(pasteError); 78 | } 79 | 80 | if ( 81 | pasteData.draft && 82 | (pasteData.author as unknown as Profile).id !== session?.user.id 83 | ) { 84 | notFound(); 85 | } 86 | 87 | const { data: fileData, error: fileError } = await supabase.storage 88 | .from("pastes") 89 | .download(pasteData.file); 90 | 91 | if (fileError) { 92 | throw new Error(fileError.message); 93 | } 94 | 95 | if (!fileData) { 96 | throw new Error("Discrepancy between paste and file"); 97 | } 98 | 99 | const file = await fileData.text(); 100 | 101 | return ( 102 |
103 |
104 |
105 |
106 |

107 | 113 | 117 | {((pasteData.author as unknown as Profile).username || 118 | (pasteData.author as unknown as Profile).full_name) ?? 119 | "Untitled User"} 120 | 121 |

122 |

123 | {pasteData.title || "Untitled Paste"} 124 |

125 |

126 | 127 | {pasteData.language} 128 | {" "} 129 | 130 | {pasteData.draft && "(draft)"} 131 | 132 |

133 |

134 | {pasteData.created_at && ( 135 | 138 | )} 139 |

140 |

{pasteData.description}

141 |
142 | 143 |
144 | 151 |
152 | 156 | 160 | 161 | 162 | {(pasteData.author as unknown as Profile).id === 163 | session?.user?.id && ( 164 | 165 | )} 166 |
167 |
168 |
169 | 176 | 180 | 184 | 185 | 186 | {(pasteData.author as unknown as Profile).id === 187 | session?.user?.id && ( 188 | 189 | )} 190 |
191 |
192 |
193 | 194 |
195 |
196 | ); 197 | } 198 | -------------------------------------------------------------------------------- /web/src/app/privacy-policy/page.tsx: -------------------------------------------------------------------------------- 1 | import { Logo } from "@/components/logo"; 2 | import Link from "next/link"; 3 | 4 | export default function PrivacyPolicy() { 5 | return ( 6 |
7 |
8 |
9 |
10 | 11 | 12 | 13 |

14 | Privacy Policy 15 |

16 |

17 | Last updated: August 10, 2023 18 |

19 |
20 |
21 |
22 |
23 | ); 24 | } 25 | -------------------------------------------------------------------------------- /web/src/app/profiles/[id]/error.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { AlertTriangle } from "lucide-react"; 4 | 5 | export default function Error({ 6 | error, 7 | }: { 8 | error: Error & { digest?: string }; 9 | }) { 10 | return ( 11 |
12 | 13 |

Error retrieving profile

14 |
15 | ); 16 | } 17 | -------------------------------------------------------------------------------- /web/src/app/profiles/[id]/layout.tsx: -------------------------------------------------------------------------------- 1 | import { Navbar } from "@/components/navbar"; 2 | import { 3 | createServerComponentClient, 4 | getProfile, 5 | getSession, 6 | } from "@/utils/supabase"; 7 | import { ReactNode } from "react"; 8 | 9 | export default async function MeLayout({ 10 | children, 11 | params, 12 | }: { 13 | children: ReactNode; 14 | params: { id: string }; 15 | }) { 16 | const session = await getSession(); 17 | const userProfile = await getProfile(session?.user.id); 18 | return ( 19 | <> 20 | 21 |
22 | {children} 23 | 24 | ); 25 | } 26 | -------------------------------------------------------------------------------- /web/src/app/profiles/[id]/loading.tsx: -------------------------------------------------------------------------------- 1 | import { LoadingSpinner } from "@/components/loading-spinner"; 2 | 3 | export default function Loading() { 4 | return ( 5 |
6 | 7 |
8 | ); 9 | } 10 | -------------------------------------------------------------------------------- /web/src/app/profiles/[id]/not-found.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { AlertTriangle } from "lucide-react"; 4 | 5 | export default function NotFound() { 6 | return ( 7 |
8 |

Profile does not exist

9 |
10 | ); 11 | } 12 | -------------------------------------------------------------------------------- /web/src/app/profiles/[id]/page.tsx: -------------------------------------------------------------------------------- 1 | import { Calendar, Hash, Info, PersonStandingIcon } from "lucide-react"; 2 | import { Separator } from "@/components/ui/separator"; 3 | import { Button, buttonVariants } from "@/components/ui/button"; 4 | import { Metadata } from "next"; 5 | import Image from "next/image"; 6 | import { 7 | createServerComponentClient, 8 | getPastes, 9 | getProfile, 10 | getPublicPastes, 11 | getSession, 12 | } from "@/utils/supabase"; 13 | import Link from "next/link"; 14 | import { cn } from "@/utils/cn"; 15 | import { redirect } from "next/navigation"; 16 | import { Paste } from "types/types"; 17 | import { Cog, Trash2, PlusCircle, ClipboardX } from "lucide-react"; 18 | import { 19 | Tooltip, 20 | TooltipContent, 21 | TooltipProvider, 22 | TooltipTrigger, 23 | } from "@/components/ui/tooltip"; 24 | 25 | type Props = { 26 | params: { id: string }; 27 | }; 28 | 29 | import { notFound } from "next/navigation"; 30 | 31 | export async function generateMetadata({ params }: Props): Promise { 32 | // read route params 33 | const id = params.id; 34 | 35 | // fetch data 36 | const profile = await getProfile(id); 37 | 38 | return { 39 | title: `${profile?.username ?? "Unnamed User"} - Openbin`, 40 | }; 41 | } 42 | 43 | const Profile = async ({ params }: { params: { id: string } }) => { 44 | const session = await getSession(); 45 | 46 | const profile = await getProfile(params.id); 47 | 48 | if (!profile) { 49 | notFound(); 50 | } 51 | 52 | let pastes: Paste[] | null; 53 | 54 | if (session && profile.id === session.user.id) { 55 | pastes = await getPastes(profile.id); 56 | } else { 57 | pastes = await getPublicPastes(profile.id); 58 | } 59 | 60 | return ( 61 | 62 |
63 |
64 | avatar 75 |

76 | {profile?.username ?? "Untitled User"} 77 |

78 |
79 |
80 |
81 | 82 | 83 |
84 | {pastes?.length} 85 |
86 |
87 | Pastes Count 88 |
89 | 90 | 91 |
92 | 93 |
94 |
95 | 96 | Joined{" "} 97 | {new Date( 98 | profile!.created_at ?? new Date(), 99 | ).toLocaleDateString()} 100 | 101 |
102 | 103 | {session?.user.id === profile?.id && ( 104 | <> 105 | {/* 106 | 107 | 110 | 111 | Settings 112 | */} 113 | 114 | 115 | 119 | 120 | 121 | 122 | Delete Account 123 | 124 | 125 | )} 126 |
127 |
128 |
129 |
130 |
131 |
132 |
133 |
134 |
135 | 136 |

137 | {profile.id === session?.user.id && "Your "}Pastes 138 |

139 |
140 |
141 | {session?.user.id === profile?.id && ( 142 | 143 | 150 | 151 | )} 152 |
153 |
154 | 155 |
156 | {pastes?.map((paste) => ( 157 | 162 |

163 | {paste.title && paste.title.length 164 | ? paste.title 165 | : "Untitled Paste"} 166 | {paste.draft && ( 167 | DRAFT 168 | )} 169 |

170 |

{paste.language}

171 | 172 | 173 | 174 |

175 | {(paste.description && !!paste.description.length) ?? 176 | "No description"} 177 |

178 | 179 | ))} 180 |
181 | {pastes?.length === 0 && ( 182 |
183 | 184 |

No Pastes

185 |
186 | )} 187 |
188 |
189 | 190 | ); 191 | }; 192 | 193 | export default Profile; 194 | -------------------------------------------------------------------------------- /web/src/assets/bg.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /web/src/assets/gh-light.json: -------------------------------------------------------------------------------- 1 | { 2 | "base": "vs", 3 | "inherit": true, 4 | "rules": [ 5 | { 6 | "background": "ffffff", 7 | "token": "" 8 | }, 9 | { 10 | "foreground": "6a737d", 11 | "token": "comment" 12 | }, 13 | { 14 | "foreground": "6a737d", 15 | "token": "punctuation.definition.comment" 16 | }, 17 | { 18 | "foreground": "6a737d", 19 | "token": "string.comment" 20 | }, 21 | { 22 | "foreground": "005cc5", 23 | "token": "constant" 24 | }, 25 | { 26 | "foreground": "005cc5", 27 | "token": "entity.name.constant" 28 | }, 29 | { 30 | "foreground": "005cc5", 31 | "token": "variable.other.constant" 32 | }, 33 | { 34 | "foreground": "005cc5", 35 | "token": "variable.language" 36 | }, 37 | { 38 | "foreground": "6f42c1", 39 | "token": "entity" 40 | }, 41 | { 42 | "foreground": "6f42c1", 43 | "token": "entity.name" 44 | }, 45 | { 46 | "foreground": "24292e", 47 | "token": "variable.parameter.function" 48 | }, 49 | { 50 | "foreground": "22863a", 51 | "token": "entity.name.tag" 52 | }, 53 | { 54 | "foreground": "d73a49", 55 | "token": "keyword" 56 | }, 57 | { 58 | "foreground": "d73a49", 59 | "token": "storage" 60 | }, 61 | { 62 | "foreground": "d73a49", 63 | "token": "storage.type" 64 | }, 65 | { 66 | "foreground": "24292e", 67 | "token": "storage.modifier.package" 68 | }, 69 | { 70 | "foreground": "24292e", 71 | "token": "storage.modifier.import" 72 | }, 73 | { 74 | "foreground": "24292e", 75 | "token": "storage.type.java" 76 | }, 77 | { 78 | "foreground": "032f62", 79 | "token": "string" 80 | }, 81 | { 82 | "foreground": "032f62", 83 | "token": "punctuation.definition.string" 84 | }, 85 | { 86 | "foreground": "032f62", 87 | "token": "string punctuation.section.embedded source" 88 | }, 89 | { 90 | "foreground": "005cc5", 91 | "token": "support" 92 | }, 93 | { 94 | "foreground": "005cc5", 95 | "token": "meta.property-name" 96 | }, 97 | { 98 | "foreground": "e36209", 99 | "token": "variable" 100 | }, 101 | { 102 | "foreground": "24292e", 103 | "token": "variable.other" 104 | }, 105 | { 106 | "foreground": "b31d28", 107 | "fontStyle": "bold italic underline", 108 | "token": "invalid.broken" 109 | }, 110 | { 111 | "foreground": "b31d28", 112 | "fontStyle": "bold italic underline", 113 | "token": "invalid.deprecated" 114 | }, 115 | { 116 | "foreground": "fafbfc", 117 | "background": "b31d28", 118 | "fontStyle": "italic underline", 119 | "token": "invalid.illegal" 120 | }, 121 | { 122 | "foreground": "fafbfc", 123 | "background": "d73a49", 124 | "fontStyle": "italic underline", 125 | "token": "carriage-return" 126 | }, 127 | { 128 | "foreground": "b31d28", 129 | "fontStyle": "bold italic underline", 130 | "token": "invalid.unimplemented" 131 | }, 132 | { 133 | "foreground": "b31d28", 134 | "token": "message.error" 135 | }, 136 | { 137 | "foreground": "24292e", 138 | "token": "string source" 139 | }, 140 | { 141 | "foreground": "005cc5", 142 | "token": "string variable" 143 | }, 144 | { 145 | "foreground": "032f62", 146 | "token": "source.regexp" 147 | }, 148 | { 149 | "foreground": "032f62", 150 | "token": "string.regexp" 151 | }, 152 | { 153 | "foreground": "032f62", 154 | "token": "string.regexp.character-class" 155 | }, 156 | { 157 | "foreground": "032f62", 158 | "token": "string.regexp constant.character.escape" 159 | }, 160 | { 161 | "foreground": "032f62", 162 | "token": "string.regexp source.ruby.embedded" 163 | }, 164 | { 165 | "foreground": "032f62", 166 | "token": "string.regexp string.regexp.arbitrary-repitition" 167 | }, 168 | { 169 | "foreground": "22863a", 170 | "fontStyle": "bold", 171 | "token": "string.regexp constant.character.escape" 172 | }, 173 | { 174 | "foreground": "005cc5", 175 | "token": "support.constant" 176 | }, 177 | { 178 | "foreground": "005cc5", 179 | "token": "support.variable" 180 | }, 181 | { 182 | "foreground": "005cc5", 183 | "token": "meta.module-reference" 184 | }, 185 | { 186 | "foreground": "735c0f", 187 | "token": "markup.list" 188 | }, 189 | { 190 | "foreground": "005cc5", 191 | "fontStyle": "bold", 192 | "token": "markup.heading" 193 | }, 194 | { 195 | "foreground": "005cc5", 196 | "fontStyle": "bold", 197 | "token": "markup.heading entity.name" 198 | }, 199 | { 200 | "foreground": "22863a", 201 | "token": "markup.quote" 202 | }, 203 | { 204 | "foreground": "24292e", 205 | "fontStyle": "italic", 206 | "token": "markup.italic" 207 | }, 208 | { 209 | "foreground": "24292e", 210 | "fontStyle": "bold", 211 | "token": "markup.bold" 212 | }, 213 | { 214 | "foreground": "005cc5", 215 | "token": "markup.raw" 216 | }, 217 | { 218 | "foreground": "b31d28", 219 | "background": "ffeef0", 220 | "token": "markup.deleted" 221 | }, 222 | { 223 | "foreground": "b31d28", 224 | "background": "ffeef0", 225 | "token": "meta.diff.header.from-file" 226 | }, 227 | { 228 | "foreground": "b31d28", 229 | "background": "ffeef0", 230 | "token": "punctuation.definition.deleted" 231 | }, 232 | { 233 | "foreground": "22863a", 234 | "background": "f0fff4", 235 | "token": "markup.inserted" 236 | }, 237 | { 238 | "foreground": "22863a", 239 | "background": "f0fff4", 240 | "token": "meta.diff.header.to-file" 241 | }, 242 | { 243 | "foreground": "22863a", 244 | "background": "f0fff4", 245 | "token": "punctuation.definition.inserted" 246 | }, 247 | { 248 | "foreground": "e36209", 249 | "background": "ffebda", 250 | "token": "markup.changed" 251 | }, 252 | { 253 | "foreground": "e36209", 254 | "background": "ffebda", 255 | "token": "punctuation.definition.changed" 256 | }, 257 | { 258 | "foreground": "f6f8fa", 259 | "background": "005cc5", 260 | "token": "markup.ignored" 261 | }, 262 | { 263 | "foreground": "f6f8fa", 264 | "background": "005cc5", 265 | "token": "markup.untracked" 266 | }, 267 | { 268 | "foreground": "6f42c1", 269 | "fontStyle": "bold", 270 | "token": "meta.diff.range" 271 | }, 272 | { 273 | "foreground": "005cc5", 274 | "token": "meta.diff.header" 275 | }, 276 | { 277 | "foreground": "005cc5", 278 | "fontStyle": "bold", 279 | "token": "meta.separator" 280 | }, 281 | { 282 | "foreground": "005cc5", 283 | "token": "meta.output" 284 | }, 285 | { 286 | "foreground": "586069", 287 | "token": "brackethighlighter.tag" 288 | }, 289 | { 290 | "foreground": "586069", 291 | "token": "brackethighlighter.curly" 292 | }, 293 | { 294 | "foreground": "586069", 295 | "token": "brackethighlighter.round" 296 | }, 297 | { 298 | "foreground": "586069", 299 | "token": "brackethighlighter.square" 300 | }, 301 | { 302 | "foreground": "586069", 303 | "token": "brackethighlighter.angle" 304 | }, 305 | { 306 | "foreground": "586069", 307 | "token": "brackethighlighter.quote" 308 | }, 309 | { 310 | "foreground": "b31d28", 311 | "token": "brackethighlighter.unmatched" 312 | }, 313 | { 314 | "foreground": "b31d28", 315 | "token": "sublimelinter.mark.error" 316 | }, 317 | { 318 | "foreground": "e36209", 319 | "token": "sublimelinter.mark.warning" 320 | }, 321 | { 322 | "foreground": "959da5", 323 | "token": "sublimelinter.gutter-mark" 324 | }, 325 | { 326 | "foreground": "032f62", 327 | "fontStyle": "underline", 328 | "token": "constant.other.reference.link" 329 | }, 330 | { 331 | "foreground": "032f62", 332 | "fontStyle": "underline", 333 | "token": "string.other.link" 334 | } 335 | ], 336 | "colors": { 337 | "editor.foreground": "#24292e", 338 | "editor.background": "#ffffff", 339 | "editor.selectionBackground": "#c8c8fa", 340 | "editor.inactiveSelectionBackground": "#fafbfc", 341 | "editor.lineHighlightBackground": "#fafbfc", 342 | "editorCursor.foreground": "#24292e", 343 | "editorWhitespace.foreground": "#959da5", 344 | "editorIndentGuide.background": "#959da5", 345 | "editorIndentGuide.activeBackground": "#24292e", 346 | "editor.selectionHighlightBorder": "#fafbfc" 347 | } 348 | } 349 | -------------------------------------------------------------------------------- /web/src/assets/languages.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "name": "Plain Text", 4 | "value": "plaintext", 5 | "filetype": "txt" 6 | }, 7 | { 8 | "name": "JavaScript", 9 | "value": "javascript", 10 | "filetype": ["js", "jsx", "mjs", "cjs"] 11 | }, 12 | { 13 | "name": "TypeScript", 14 | "value": "typescript", 15 | "filetype": ["ts", "mts", "cts", "tsx"] 16 | }, 17 | { 18 | "name": "Python", 19 | "value": "python", 20 | "filetype": "py" 21 | }, 22 | { 23 | "name": "JSON", 24 | "value": "json", 25 | "filetype": "json" 26 | }, 27 | { 28 | "name": "HTML", 29 | "value": "html", 30 | "filetype": "html" 31 | }, 32 | { 33 | "name": "CSS", 34 | "value": "css", 35 | "filetype": "css" 36 | }, 37 | { 38 | "name": "Markdown", 39 | "value": "markdown", 40 | "filetype": "md" 41 | }, 42 | { 43 | "name": "Lua", 44 | "value": "lua", 45 | "filetype": "lua" 46 | }, 47 | { 48 | "name": "Go", 49 | "value": "go", 50 | "filetype": "go" 51 | }, 52 | { 53 | "name": "Rust", 54 | "value": "rust", 55 | "filetype": "rs" 56 | }, 57 | { 58 | "name": "XML", 59 | "value": "xml", 60 | "filetype": "xml" 61 | }, 62 | { 63 | "name": "YAML", 64 | "value": "yaml", 65 | "filetype": ["yaml", "yml"] 66 | }, 67 | { 68 | "name": "MDX", 69 | "value": "mdx", 70 | "filetype": "mdx" 71 | }, 72 | { 73 | "name": "SCSS", 74 | "value": "scss", 75 | "filetype": "scss" 76 | }, 77 | { 78 | "name": "Less", 79 | "value": "less", 80 | "filetype": "less" 81 | }, 82 | { 83 | "name": "Elixir", 84 | "value": "elixir", 85 | "filetype": ["ex", "exs"] 86 | }, 87 | { 88 | "name": "C++", 89 | "value": "cpp", 90 | "filetype": "cpp" 91 | }, 92 | { 93 | "name": "C#", 94 | "value": "csharp", 95 | "filetype": "cs" 96 | }, 97 | { 98 | "name": "Java", 99 | "value": "java", 100 | "filetype": "java" 101 | }, 102 | { 103 | "name": "Kotlin", 104 | "value": "kotlin", 105 | "filetype": "kt" 106 | }, 107 | { 108 | "name": "Dart", 109 | "value": "dart", 110 | "filetype": "dart" 111 | }, 112 | { 113 | "name": "Scala", 114 | "value": "scala", 115 | "filetype": "scala" 116 | }, 117 | { 118 | "name": "Handlebars", 119 | "value": "handlebars", 120 | "filetype": ["hbs", "handlebars"] 121 | }, 122 | { 123 | "name": "Pug", 124 | "value": "pug", 125 | "filetype": "pug" 126 | }, 127 | 128 | { 129 | "name": "Ruby", 130 | "value": "ruby", 131 | "filetype": "rb" 132 | }, 133 | { 134 | "name": "PHP", 135 | "value": "php", 136 | "filetype": "php" 137 | }, 138 | { 139 | "name": "Swift", 140 | "value": "swift", 141 | "filetype": "swift" 142 | }, 143 | { 144 | "name": "Solidity", 145 | "value": "solidity", 146 | "filetype": "sol" 147 | }, 148 | { 149 | "name": "SQL", 150 | "value": "sql", 151 | "filetype": "sql" 152 | }, 153 | { 154 | "name": "Dockerfile", 155 | "value": "dockerfile", 156 | "filetype": "dockerfile" 157 | }, 158 | { 159 | "name": "Shell", 160 | "value": "shell", 161 | "filetype": "sh" 162 | }, 163 | { 164 | "name": "PowerShell", 165 | "value": "powershell", 166 | "filetype": "ps1" 167 | }, 168 | { 169 | "name": "R", 170 | "value": "r", 171 | "filetype": "r" 172 | }, 173 | { 174 | "name": "Perl", 175 | "value": "perl", 176 | "filetype": "pl" 177 | }, 178 | { 179 | "name": "GraphQL", 180 | "value": "graphql", 181 | "filetype": ["graphql", "gql"] 182 | }, 183 | { 184 | "name": "Clojure", 185 | "value": "clojure", 186 | "filetype": "clj" 187 | }, 188 | { 189 | "name": "Objective-C", 190 | "value": "objective-c", 191 | "filetype": "m" 192 | } 193 | ] 194 | -------------------------------------------------------------------------------- /web/src/components/avatar.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | /* eslint-disable @next/next/no-img-element */ 4 | import clsx from "clsx"; 5 | import { murmur2 } from "murmurhash2"; 6 | import color from "tinycolor2"; 7 | import { Profile } from "types/types"; 8 | import { 9 | DropdownMenu, 10 | DropdownMenuTrigger, 11 | DropdownMenuContent, 12 | DropdownMenuItem, 13 | } from "./ui/dropdown-menu"; 14 | import Link from "next/link"; 15 | import { useRouter } from "next/navigation"; 16 | import { User2, LogOut } from "lucide-react"; 17 | 18 | function generateGradient(id: string) { 19 | const c1 = color({ h: murmur2(id, 360) % 360, s: 0.95, l: 0.5 }); 20 | const second = c1.triad()[1].toHexString(); 21 | 22 | return { 23 | fromColor: c1.toHexString(), 24 | toColor: second, 25 | }; 26 | } 27 | 28 | export function Avatar({ 29 | profile, 30 | dropdown = false, 31 | size = "sm", 32 | }: { 33 | profile: Profile | null; 34 | dropdown?: boolean; 35 | size?: string; 36 | }) { 37 | const gradient = generateGradient(profile?.id ?? ""); 38 | const { push, refresh } = useRouter(); 39 | 40 | const sizes = { 41 | xxs: "h-4 w-4", 42 | xs: "h-5 w-5", 43 | sm: "h-6 w-6", 44 | md: "h-7 w-7", 45 | }[size]; 46 | 47 | async function signOut() { 48 | await fetch("/auth/signout", { 49 | method: "POST", 50 | }).then((res) => { 51 | if (res.ok) { 52 | refresh(); 53 | } 54 | }); 55 | } 56 | 57 | return ( 58 | <> 59 | {dropdown ? ( 60 | 61 | 62 | {profile?.avatar_url ? ( 63 | {profile.username 68 | ) : ( 69 |
75 | )} 76 |
77 | 78 | 79 | 83 | 84 | Profile 85 | 86 | 87 | 88 | 95 | 96 | 97 |
98 | ) : ( 99 | <> 100 | {profile?.avatar_url ? ( 101 | {profile.username 106 | ) : ( 107 |
113 | )} 114 | 115 | )} 116 | 117 | ); 118 | } 119 | -------------------------------------------------------------------------------- /web/src/components/editor/actions.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { createServerActionClient } from "@supabase/auth-helpers-nextjs"; 4 | import { cookies } from "next/headers"; 5 | import { z } from "zod"; 6 | import { zact } from "zact/server"; 7 | import languages from "@/assets/languages.json"; 8 | import { Database } from "types/supabase"; 9 | import { v4 as uuid } from "uuid"; 10 | import { redirect } from "next/navigation"; 11 | 12 | export const publish = zact( 13 | z.object({ 14 | title: z 15 | .string() 16 | .trim() 17 | 18 | .max(30, { 19 | message: "Title must be less than 30 characters", 20 | }) 21 | .optional(), 22 | description: z 23 | .string() 24 | .trim() 25 | .max(300, { 26 | message: "Description must be less than 300 characters", 27 | }) 28 | .optional(), 29 | language: z.enum([ 30 | languages[0]?.value as string, 31 | ...languages.slice(1).map((language) => language.value), 32 | ]), 33 | value: z.string().optional(), 34 | draft: z.boolean().default(false), 35 | expiresAt: z.date().optional(), 36 | remixOf: z.string().optional(), 37 | }), 38 | )(async (input) => { 39 | const supabase = createServerActionClient({ cookies }); 40 | const { data: userData, error: userError } = await supabase.auth.getUser(); 41 | 42 | if (!userData || userError) { 43 | throw new Error("Not logged in"); 44 | } 45 | 46 | if (!input.value) { 47 | throw new Error("Paste is empty"); 48 | } 49 | 50 | if (Buffer.byteLength(input.value, "utf8") / Math.pow(1024, 2) > 1) { 51 | throw new Error("Paste is too large"); 52 | } 53 | 54 | const id = uuid(); 55 | 56 | const { error: fileError } = await supabase.storage 57 | .from("pastes") 58 | .upload(`pastes/openbin-${id}.txt`, input.value); 59 | 60 | if (fileError) { 61 | throw new Error(fileError.message); 62 | } 63 | 64 | const { data: pasteData, error: pasteError } = await supabase 65 | .from("pastes") 66 | .insert({ 67 | author: userData.user.id, 68 | title: input.title, 69 | description: 70 | input.description && input.description.length 71 | ? input.description 72 | : undefined, 73 | language: input.language, 74 | draft: input.draft, 75 | expires_at: input.expiresAt ? input.expiresAt.toDateString() : null, 76 | file: `pastes/openbin-${id}.txt`, 77 | id: id, 78 | remix_of: input.remixOf, 79 | }) 80 | .select("id"); 81 | 82 | if (pasteError) { 83 | throw new Error("Failed to create paste"); 84 | } 85 | 86 | return redirect(`/pastes/${pasteData[0]?.id}`); 87 | }); 88 | 89 | export const toggleDraft = zact( 90 | z.object({ 91 | id: z.string(), 92 | action: z.enum(["publish", "unpublish"]), 93 | }), 94 | )(async (input) => { 95 | const supabase = createServerActionClient({ cookies }); 96 | const { data: userData, error: userError } = await supabase.auth.getUser(); 97 | 98 | if (!userData || userError) { 99 | throw new Error("Not logged in"); 100 | } 101 | 102 | const { data: pasteData, error: pasteError } = await supabase 103 | .from("pastes") 104 | .update({ 105 | draft: input.action === "publish" ? false : true, 106 | }) 107 | .eq("id", input.id) 108 | .eq("author", userData.user.id) 109 | .select("draft") 110 | .single(); 111 | 112 | if (pasteError) { 113 | throw new Error("Failed toggle draft status"); 114 | } 115 | 116 | return pasteData?.draft; 117 | }); 118 | -------------------------------------------------------------------------------- /web/src/components/editor/delete-paste.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { FormEvent, useState } from "react"; 3 | import { LoginComponent } from "../login"; 4 | import { Button } from "../ui/button"; 5 | import { 6 | Dialog, 7 | DialogContent, 8 | DialogDescription, 9 | DialogFooter, 10 | DialogHeader, 11 | DialogTitle, 12 | DialogTrigger, 13 | } from "../ui/dialog"; 14 | import { PublishForm } from "./publish-form"; 15 | import { Paste } from "types/types"; 16 | import { createClientComponentClient } from "@supabase/auth-helpers-nextjs"; 17 | import { Database } from "types/supabase"; 18 | import { useRouter } from "next/navigation"; 19 | import { Trash2 } from "lucide-react"; 20 | 21 | export default function DeletePasteConfirmation({ paste }: { paste: Paste }) { 22 | const [open, setOpen] = useState(false); 23 | const [deleting, setDeleting] = useState(false); 24 | const router = useRouter(); 25 | 26 | const supabase = createClientComponentClient(); 27 | 28 | const deletePaste = async () => { 29 | setDeleting(true); 30 | await supabase.from("pastes").delete().eq("id", paste.id); 31 | await supabase.storage.from("pastes").remove([paste.file]); 32 | setOpen(false); 33 | setDeleting(false); 34 | const { 35 | data: { session }, 36 | error: sessionError, 37 | } = await supabase.auth.getSession(); 38 | if (sessionError) { 39 | throw new Error(sessionError.message); 40 | } 41 | if (!session) { 42 | throw new Error("Not logged in"); 43 | } 44 | router.push(session?.user.id); 45 | }; 46 | 47 | return ( 48 | 49 | 50 | 53 | 54 | 55 | 56 | Delete 57 | 58 | Are you sure you want to delete this paste? 59 | 60 | 61 | 62 | 70 | 71 | 79 | 80 | 81 | 82 | ); 83 | } 84 | -------------------------------------------------------------------------------- /web/src/components/editor/index.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useMonaco } from "@monaco-editor/react"; 4 | import { useEffect, useState } from "react"; 5 | import dynamic from "next/dynamic"; 6 | import theme from "@/assets/gh-light.json"; 7 | import type monacoTypes from "monaco-editor"; 8 | import { useRouter, useSearchParams } from "next/navigation"; 9 | import { LoadingSpinner } from "../loading-spinner"; 10 | import { Session } from "@supabase/supabase-js"; 11 | import { EditorNavbar } from "./navbar"; 12 | import { Paste, Profile } from "types/types"; 13 | import { getOS } from "@/utils/os"; 14 | import { useHotkeys } from "react-hotkeys-hook"; 15 | 16 | const Monaco = dynamic(() => import("@monaco-editor/react"), { ssr: false }); 17 | 18 | export function MonacoEditor({ 19 | session, 20 | profile, 21 | remixData, 22 | remixContent, 23 | }: { 24 | session: Session | null; 25 | profile: Profile | null; 26 | remixData?: Paste; 27 | remixContent?: string; 28 | }) { 29 | const monaco = useMonaco(); 30 | const [selectedLanguage, setLanguage] = useState("plaintext"); 31 | const [publishOpen, setPublishOpen] = useState(false); 32 | const [value, setValue] = useState( 33 | remixContent ?? "Welcome to Openbin!", 34 | ); 35 | const searchParams = useSearchParams(); 36 | const router = useRouter(); 37 | 38 | useEffect(() => { 39 | if (monaco) { 40 | monaco.editor.defineTheme( 41 | "gh-light", 42 | theme as monacoTypes.editor.IStandaloneThemeData, 43 | ); 44 | monaco.editor.setTheme("gh-light"); 45 | monaco.languages.typescript.typescriptDefaults.setDiagnosticsOptions({ 46 | noSemanticValidation: true, 47 | noSyntaxValidation: true, 48 | }); 49 | 50 | if (localStorage && searchParams.get("publish")) { 51 | const previousData = localStorage.getItem("editor-data"); 52 | if (previousData) { 53 | setValue(previousData); 54 | setPublishOpen(true); 55 | // prompt publish 56 | } 57 | } 58 | } 59 | }, [monaco, searchParams]); 60 | 61 | useHotkeys("ctrl+s,command+s", () => { 62 | setPublishOpen(true); 63 | }); 64 | 65 | return ( 66 |
67 | 77 |
78 | 85 | 86 |
87 | } 88 | options={{ 89 | padding: { 90 | top: 16, 91 | }, 92 | cursorSmoothCaretAnimation: "on", 93 | cursorBlinking: "smooth", 94 | fontSize: 14, 95 | formatOnType: true, 96 | formatOnPaste: true, 97 | automaticLayout: true, 98 | minimap: { 99 | enabled: false, 100 | }, 101 | }} 102 | language={selectedLanguage} 103 | defaultLanguage="plaintext" 104 | defaultValue="Welcome to Openbin!" 105 | /> 106 |
107 | ); 108 | } 109 | -------------------------------------------------------------------------------- /web/src/components/editor/navbar.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useState } from "react"; 4 | import languages from "@/assets/languages.json"; 5 | import { Logo } from "../logo"; 6 | import { Button } from "@/components/ui/button"; 7 | import { ChevronsUpDown, Check } from "lucide-react"; 8 | import Link from "next/link"; 9 | import { cn } from "@/utils/cn"; 10 | import { LoginComponent } from "../login"; 11 | import { publish } from "./actions"; 12 | import { zodResolver } from "@hookform/resolvers/zod"; 13 | import * as z from "zod"; 14 | import { Avatar } from "../avatar"; 15 | import { Navbar } from "../navbar"; 16 | 17 | import { PublishForm } from "./publish-form"; 18 | 19 | import { 20 | Dialog, 21 | DialogContent, 22 | DialogTrigger, 23 | DialogHeader, 24 | DialogDescription, 25 | DialogTitle, 26 | DialogFooter, 27 | } from "@/components/ui/dialog"; 28 | 29 | import { 30 | Command, 31 | CommandEmpty, 32 | CommandGroup, 33 | CommandInput, 34 | CommandItem, 35 | } from "@/components/ui/command"; 36 | import { 37 | Popover, 38 | PopoverContent, 39 | PopoverTrigger, 40 | } from "@/components/ui/popover"; 41 | import { Session } from "@supabase/supabase-js"; 42 | import { Input } from "../ui/input"; 43 | import { Paste, Profile } from "types/types"; 44 | 45 | const publishSchema = z.object({ 46 | title: z.string().trim().max(30), 47 | description: z.string().max(200), 48 | language: z.enum([ 49 | languages[0]?.value as string, 50 | ...languages.slice(1).map((language) => language.value), 51 | ]), 52 | draft: z.boolean().default(false), 53 | expiresAt: z.date().nullable().default(null), 54 | }); 55 | 56 | export function EditorNavbar({ 57 | session, 58 | profile, 59 | selectedLanguage, 60 | setLanguage, 61 | value, 62 | publishOpen, 63 | setPublishOpen, 64 | remixData, 65 | }: { 66 | session: Session | null; 67 | profile: Profile | null; 68 | selectedLanguage: string; 69 | value: string | undefined; 70 | setLanguage: (language: string) => void; 71 | publishOpen: boolean; 72 | setPublishOpen: (open: boolean) => void; 73 | remixData?: Paste; 74 | }) { 75 | const [languageSelectOpen, setLanguageSelectOpen] = useState(false); 76 | 77 | return ( 78 | <> 79 | 85 | 86 | 100 | 101 | 102 | 103 | 104 | No language found. 105 | 106 | {languages.map((language) => ( 107 | { 110 | setLanguage( 111 | currentValue === selectedLanguage ? "" : currentValue, 112 | ); 113 | setLanguageSelectOpen(false); 114 | }} 115 | > 116 | 124 | {language.name} 125 | 126 | ))} 127 | 128 | 129 | 130 | 131 | } 132 | rightActions={ 133 | 134 | 135 | 141 | 142 | 143 | {session?.user ? ( 144 | <> 145 | 146 | Publish 147 | 148 | Publish your paste to the world. 149 | 150 | 151 | 163 | 164 | ) : ( 165 | <> 166 | 167 | Sign in 168 | 169 | Sign in to publish your paste. 170 | 171 | 172 | { 175 | if (localStorage) { 176 | localStorage.setItem("editor-data", value ?? ""); 177 | } 178 | }} 179 | /> 180 | 181 | )} 182 | 183 | 184 | } 185 | session={session} 186 | profile={profile} 187 | /> 188 | 189 | ); 190 | } 191 | -------------------------------------------------------------------------------- /web/src/components/editor/toggle-publish.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Paste } from "types/types"; 4 | import { Button } from "@/components/ui/button"; 5 | import { useZact } from "zact/client"; 6 | import { toggleDraft } from "./actions"; 7 | import { useRouter } from "next/navigation"; 8 | import { toast } from "sonner"; 9 | 10 | export function PublishUnpublishButton({ 11 | paste, 12 | display, 13 | }: { 14 | paste: Paste; 15 | display: boolean; 16 | }) { 17 | const { mutate, error, isLoading } = useZact(toggleDraft); 18 | const router = useRouter(); 19 | 20 | function toggle() { 21 | mutate({ 22 | id: paste.id, 23 | action: paste.draft ? "publish" : "unpublish", 24 | }).then(() => { 25 | router.refresh(); 26 | 27 | if (error) { 28 | toast.error(error.message); 29 | } 30 | }); 31 | } 32 | 33 | return ( 34 | <> 35 | {display && paste.draft ? ( 36 | 43 | ) : display ? ( 44 | 52 | ) : ( 53 | <> 54 | )} 55 | 56 | ); 57 | } 58 | -------------------------------------------------------------------------------- /web/src/components/editor/viewer.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useMonaco } from "@monaco-editor/react"; 4 | import { useEffect, useState } from "react"; 5 | import { LoadingSpinner } from "../loading-spinner"; 6 | 7 | import dynamic from "next/dynamic"; 8 | 9 | import theme from "@/assets/gh-light.json"; 10 | 11 | import type monacoTypes from "monaco-editor"; 12 | import { Database } from "types/supabase"; 13 | import { Navbar } from "../navbar"; 14 | import { Session } from "@supabase/supabase-js"; 15 | 16 | const Monaco = dynamic(() => import("@monaco-editor/react"), { ssr: false }); 17 | 18 | export function PasteViewer({ 19 | paste, 20 | file, 21 | session, 22 | }: { 23 | paste: Database["public"]["Tables"]["pastes"]["Row"]; 24 | file: string; 25 | session: Session | null; 26 | }) { 27 | const monaco = useMonaco(); 28 | 29 | useEffect(() => { 30 | if (monaco) { 31 | monaco.editor.defineTheme( 32 | "gh-light", 33 | theme as monacoTypes.editor.IStandaloneThemeData, 34 | ); 35 | monaco.editor.setTheme("gh-light"); 36 | monaco.languages.typescript.typescriptDefaults.setDiagnosticsOptions({ 37 | noSemanticValidation: true, 38 | noSyntaxValidation: true, 39 | }); 40 | } 41 | }, [monaco]); 42 | 43 | return ( 44 |
45 | 51 | 52 |
53 | } 54 | options={{ 55 | padding: { 56 | top: 16, 57 | }, 58 | 59 | formatOnType: true, 60 | formatOnPaste: true, 61 | cursorSmoothCaretAnimation: "on", 62 | cursorBlinking: "smooth", 63 | 64 | domReadOnly: true, 65 | readOnly: true, 66 | readOnlyMessage: { 67 | value: "You cannot edit this paste.", 68 | }, 69 | 70 | fontSize: 14, 71 | 72 | automaticLayout: true, 73 | minimap: { 74 | enabled: false, 75 | }, 76 | }} 77 | language={paste.language || "plaintext"} 78 | defaultLanguage={paste.language || "plaintext"} 79 | defaultValue={file} 80 | /> 81 |
82 | ); 83 | } 84 | -------------------------------------------------------------------------------- /web/src/components/footer.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | 3 | const Footer = () => { 4 | return ( 5 |
6 |

7 | Made with 💚 by{" "} 8 | 9 | Unnamed Engineering 10 | 11 |

12 |
13 | ); 14 | }; 15 | 16 | export { Footer }; 17 | -------------------------------------------------------------------------------- /web/src/components/loading-dots.module.css: -------------------------------------------------------------------------------- 1 | .loading { 2 | display: inline-flex; 3 | align-items: center; 4 | } 5 | 6 | .loading .spacer { 7 | margin-right: 2px; 8 | } 9 | 10 | .loading span { 11 | animation-name: blink; 12 | animation-duration: 1.4s; 13 | animation-iteration-count: infinite; 14 | animation-fill-mode: both; 15 | width: 5px; 16 | height: 5px; 17 | border-radius: 50%; 18 | display: inline-block; 19 | margin: 0 1px; 20 | } 21 | 22 | .loading span:nth-of-type(2) { 23 | animation-delay: 0.2s; 24 | } 25 | 26 | .loading span:nth-of-type(3) { 27 | animation-delay: 0.4s; 28 | } 29 | 30 | @keyframes blink { 31 | 0% { 32 | opacity: 0.2; 33 | } 34 | 20% { 35 | opacity: 1; 36 | } 37 | 100% { 38 | opacity: 0.2; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /web/src/components/loading-dots.tsx: -------------------------------------------------------------------------------- 1 | import styles from "./loading-dots.module.css"; 2 | 3 | const LoadingDots = ({ color = "currentColor" }: { color?: string }) => { 4 | return ( 5 | 6 | 7 | 8 | 9 | 10 | ); 11 | }; 12 | 13 | export { LoadingDots }; 14 | -------------------------------------------------------------------------------- /web/src/components/loading-spinner.module.css: -------------------------------------------------------------------------------- 1 | .spinner { 2 | position: relative; 3 | top: 50%; 4 | left: 50%; 5 | } 6 | .spinner div { 7 | animation: spinner 1.2s linear infinite; 8 | background: gray; 9 | position: absolute; 10 | border-radius: 1rem; 11 | width: 30%; 12 | height: 8%; 13 | left: -10%; 14 | top: -4%; 15 | } 16 | .spinner div:nth-child(1) { 17 | animation-delay: -1.2s; 18 | transform: rotate(1deg) translate(120%); 19 | } 20 | .spinner div:nth-child(2) { 21 | animation-delay: -1.1s; 22 | transform: rotate(30deg) translate(120%); 23 | } 24 | .spinner div:nth-child(3) { 25 | animation-delay: -1s; 26 | transform: rotate(60deg) translate(120%); 27 | } 28 | .spinner div:nth-child(4) { 29 | animation-delay: -0.9s; 30 | transform: rotate(90deg) translate(120%); 31 | } 32 | .spinner div:nth-child(5) { 33 | animation-delay: -0.8s; 34 | transform: rotate(120deg) translate(120%); 35 | } 36 | .spinner div:nth-child(6) { 37 | animation-delay: -0.7s; 38 | transform: rotate(150deg) translate(120%); 39 | } 40 | .spinner div:nth-child(7) { 41 | animation-delay: -0.6s; 42 | transform: rotate(180deg) translate(120%); 43 | } 44 | .spinner div:nth-child(8) { 45 | animation-delay: -0.5s; 46 | transform: rotate(210deg) translate(120%); 47 | } 48 | .spinner div:nth-child(9) { 49 | animation-delay: -0.4s; 50 | transform: rotate(240deg) translate(120%); 51 | } 52 | .spinner div:nth-child(10) { 53 | animation-delay: -0.3s; 54 | transform: rotate(270deg) translate(120%); 55 | } 56 | .spinner div:nth-child(11) { 57 | animation-delay: -0.2s; 58 | transform: rotate(300deg) translate(120%); 59 | } 60 | .spinner div:nth-child(12) { 61 | animation-delay: -0.1s; 62 | transform: rotate(330deg) translate(120%); 63 | } 64 | 65 | @keyframes spinner { 66 | 0% { 67 | opacity: 1; 68 | } 69 | 100% { 70 | opacity: 0; 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /web/src/components/loading-spinner.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "@/utils/cn"; 2 | import styles from "./loading-spinner.module.css"; 3 | 4 | export function LoadingSpinner({ className }: { className?: string }) { 5 | return ( 6 |
7 |
8 | {[...Array(12)].map((_, i) => ( 9 |
10 | ))} 11 |
12 |
13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /web/src/components/logo.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "@/utils/cn"; 2 | 3 | export function Logo({ className }: { className?: string }) { 4 | return ( 5 |
6 | 12 | 16 | 17 |
18 | ); 19 | } 20 | -------------------------------------------------------------------------------- /web/src/components/navbar.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Session } from "@supabase/supabase-js"; 4 | import { Logo } from "./logo"; 5 | import Link from "next/link"; 6 | import { Avatar } from "./avatar"; 7 | import { Button } from "./ui/button"; 8 | import { Profile } from "types/types"; 9 | 10 | export function Navbar({ 11 | rightActions, 12 | leftActions, 13 | session, 14 | profile, 15 | }: { 16 | rightActions?: React.ReactNode | React.ReactNode[]; 17 | leftActions?: React.ReactNode | React.ReactNode[]; 18 | session: Session | null; 19 | profile: Profile | null; 20 | }) { 21 | return ( 22 | 44 | ); 45 | } 46 | -------------------------------------------------------------------------------- /web/src/components/svg/circles.tsx: -------------------------------------------------------------------------------- 1 | export function Circles() { 2 | return ( 3 |
4 | 10 | 19 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 46 | 47 | 48 | 49 | 50 | 51 |
52 | ); 53 | } 54 | -------------------------------------------------------------------------------- /web/src/components/svg/supabase-logo.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "@/utils/cn"; 2 | 3 | export function SupabaseLogo({ className }: { className?: string }) { 4 | return ( 5 |
6 | 12 | 16 | 21 | 25 | 26 | 34 | 35 | 36 | 37 | 45 | 46 | 47 | 48 | 49 | 50 |
51 | ); 52 | } 53 | -------------------------------------------------------------------------------- /web/src/components/terminal.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { v4 as uuidv4 } from "uuid"; 4 | import React, { useEffect, useState } from "react"; 5 | import { X } from "lucide-react"; 6 | import { getOS } from "@/utils/os"; 7 | 8 | interface Line { 9 | text: string; 10 | cmd: boolean; 11 | delay?: number; 12 | loading?: (remaining: number) => { text: string; delay: number }; 13 | } 14 | 15 | function getLines(os: string) { 16 | return [ 17 | { 18 | text: `${ 19 | os === "windows" 20 | ? "irm https://openbin.ethn.sh/install.ps1 | iex" 21 | : "curl -fsSL https://openbin.ethn.sh/install.sh | sh" 22 | }`, 23 | cmd: true, 24 | delay: 1000, 25 | }, 26 | { 27 | text: "Downloading binary...", 28 | cmd: false, 29 | delay: 400, 30 | }, 31 | { 32 | text: "Installing Openbin...", 33 | cmd: false, 34 | delay: 200, 35 | }, 36 | { 37 | text: "🎉 Openbin Installed", 38 | cmd: false, 39 | delay: 800, 40 | }, 41 | { 42 | text: "ob upload index.ts", 43 | cmd: true, 44 | }, 45 | { 46 | text: "Uploading file...", 47 | delay: 1000, 48 | cmd: false, 49 | }, 50 | { 51 | text: "", 52 | cmd: false, 53 | }, 54 | { 55 | text: `File uploaded successfully, accessible at https://openbin.ethn.sh/pastes/xxxxxxxx`, 56 | cmd: false, 57 | }, 58 | ] as Line[]; 59 | } 60 | 61 | const Terminal = () => { 62 | const [lines, setLines] = useState(); 63 | const [os, setOs] = useState(); 64 | const [currentLine, setCurrentLine] = useState(0); 65 | const [displayText, setDisplayText] = useState(""); 66 | const [rendering, setRendering] = useState(true); 67 | const [commandLine, setCommandLine] = useState(true); 68 | 69 | useEffect(() => { 70 | const operatingSystem = getOS(); 71 | setLines(getLines(operatingSystem)); 72 | setOs(operatingSystem); 73 | }, []); 74 | 75 | useEffect(() => { 76 | const typeLine = async (line: Line) => { 77 | for (let i = 0; i < line.text.length; i++) { 78 | await new Promise((resolve) => setTimeout(resolve, 110)); 79 | setDisplayText((prev) => prev + line.text[i]); 80 | } 81 | setDisplayText((prev) => prev + "\n"); 82 | if (line.delay) { 83 | await new Promise((resolve) => setTimeout(resolve, line.delay)); 84 | } 85 | setCurrentLine((prev) => prev + 1); 86 | if (line.cmd) { 87 | setCommandLine(false); 88 | } 89 | }; 90 | 91 | if (lines && currentLine < lines.length) { 92 | const line = lines[currentLine]; 93 | if (line && line.cmd) { 94 | setCommandLine(true); 95 | setDisplayText((prev) => prev + "kiwi ~ $ "); 96 | typeLine(line); 97 | } else if (line) { 98 | setDisplayText((prev) => prev + line.text + "\n"); 99 | if (line.delay) { 100 | new Promise((resolve) => setTimeout(resolve, line.delay)).then(() => { 101 | setRendering(true); 102 | setCurrentLine((prev) => prev + 1); 103 | }); 104 | } else { 105 | setCurrentLine((prev) => prev + 1); 106 | } 107 | } 108 | } else if (lines) { 109 | setRendering(false); 110 | } 111 | }, [currentLine, lines]); 112 | 113 | return ( 114 | <> 115 |
116 | {os === "windows" ? ( 117 |
118 | 119 |
120 | ) : os === "linux" || os === "macos" ? ( 121 |
122 |
123 |
124 |
125 |
126 | ) : ( 127 | <> 128 | )} 129 | 130 |
131 |

132 | kiwi@copple —{" "} 133 | {os === "macos" 134 | ? "zsh" 135 | : os === "windows" 136 | ? "powershell" 137 | : os === "linux" 138 | ? "terminal" 139 | : "..."}{" "} 140 |

141 |
142 |
143 |
144 |
145 |           {displayText}
146 |           {commandLine && (
147 |             
151 |               █
152 |             
153 |           )}
154 |         
155 | {!rendering && ( 156 |
157 | kiwi ~ ${" "} 158 | 162 | █ 163 | 164 |
165 | )} 166 |
167 | 168 | ); 169 | }; 170 | 171 | export { Terminal }; 172 | -------------------------------------------------------------------------------- /web/src/components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { Slot } from "@radix-ui/react-slot"; 3 | import { cva, type VariantProps } from "class-variance-authority"; 4 | import { LoadingDots } from "@/components/loading-dots"; 5 | 6 | import { cn } from "@/utils/cn"; 7 | 8 | const buttonVariants = cva( 9 | "inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50", 10 | { 11 | variants: { 12 | variant: { 13 | default: "bg-primary text-primary-foreground hover:bg-primary/90", 14 | destructive: 15 | "bg-destructive text-destructive-foreground hover:bg-destructive/90", 16 | outline: 17 | "border border-input bg-background hover:bg-accent hover:text-accent-foreground", 18 | secondary: 19 | "bg-secondary text-secondary-foreground hover:bg-secondary/80", 20 | ghost: "hover:bg-accent hover:text-accent-foreground", 21 | link: "text-primary underline-offset-4 hover:underline", 22 | }, 23 | size: { 24 | default: "h-10 px-4 py-2", 25 | sm: "h-9 rounded-md px-3", 26 | lg: "h-11 rounded-md px-8", 27 | icon: "h-10 w-10", 28 | }, 29 | }, 30 | defaultVariants: { 31 | variant: "default", 32 | size: "default", 33 | }, 34 | }, 35 | ); 36 | 37 | export interface ButtonProps 38 | extends React.ButtonHTMLAttributes, 39 | VariantProps { 40 | asChild?: boolean; 41 | loading?: boolean; 42 | loadingText?: string; 43 | } 44 | 45 | const Button = React.forwardRef( 46 | ( 47 | { 48 | className, 49 | variant, 50 | size, 51 | loading, 52 | loadingText, 53 | disabled, 54 | children, 55 | asChild = false, 56 | ...props 57 | }, 58 | ref, 59 | ) => { 60 | const Comp = asChild ? Slot : "button"; 61 | return ( 62 | 68 | {loading && ( 69 |
70 | 71 |
72 | )} 73 | {loading ? loadingText ?? children : children} 74 |
75 | ); 76 | }, 77 | ); 78 | Button.displayName = "Button"; 79 | 80 | export { Button, buttonVariants }; 81 | -------------------------------------------------------------------------------- /web/src/components/ui/calendar.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import { ChevronLeft, ChevronRight } from "lucide-react" 5 | import { DayPicker } from "react-day-picker" 6 | 7 | import { cn } from "@/utils/cn" 8 | import { buttonVariants } from "@/components/ui/button" 9 | 10 | export type CalendarProps = React.ComponentProps 11 | 12 | function Calendar({ 13 | className, 14 | classNames, 15 | showOutsideDays = true, 16 | ...props 17 | }: CalendarProps) { 18 | return ( 19 | , 56 | IconRight: ({ ...props }) => , 57 | }} 58 | {...props} 59 | /> 60 | ) 61 | } 62 | Calendar.displayName = "Calendar" 63 | 64 | export { Calendar } 65 | -------------------------------------------------------------------------------- /web/src/components/ui/checkbox.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as React from "react"; 4 | import * as CheckboxPrimitive from "@radix-ui/react-checkbox"; 5 | import { Check } from "lucide-react"; 6 | 7 | import { cn } from "@/utils/cn"; 8 | 9 | const Checkbox = React.forwardRef< 10 | React.ElementRef, 11 | React.ComponentPropsWithoutRef 12 | >(({ className, ...props }, ref) => ( 13 | 21 | 24 | 25 | 26 | 27 | )); 28 | Checkbox.displayName = CheckboxPrimitive.Root.displayName; 29 | 30 | export { Checkbox }; 31 | -------------------------------------------------------------------------------- /web/src/components/ui/command.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import { DialogProps } from "@radix-ui/react-dialog" 5 | import { Command as CommandPrimitive } from "cmdk" 6 | import { Search } from "lucide-react" 7 | 8 | import { cn } from "@/utils/cn" 9 | import { Dialog, DialogContent } from "@/components/ui/dialog" 10 | 11 | const Command = React.forwardRef< 12 | React.ElementRef, 13 | React.ComponentPropsWithoutRef 14 | >(({ className, ...props }, ref) => ( 15 | 23 | )) 24 | Command.displayName = CommandPrimitive.displayName 25 | 26 | interface CommandDialogProps extends DialogProps {} 27 | 28 | const CommandDialog = ({ children, ...props }: CommandDialogProps) => { 29 | return ( 30 | 31 | 32 | 33 | {children} 34 | 35 | 36 | 37 | ) 38 | } 39 | 40 | const CommandInput = React.forwardRef< 41 | React.ElementRef, 42 | React.ComponentPropsWithoutRef 43 | >(({ className, ...props }, ref) => ( 44 |
45 | 46 | 54 |
55 | )) 56 | 57 | CommandInput.displayName = CommandPrimitive.Input.displayName 58 | 59 | const CommandList = React.forwardRef< 60 | React.ElementRef, 61 | React.ComponentPropsWithoutRef 62 | >(({ className, ...props }, ref) => ( 63 | 68 | )) 69 | 70 | CommandList.displayName = CommandPrimitive.List.displayName 71 | 72 | const CommandEmpty = React.forwardRef< 73 | React.ElementRef, 74 | React.ComponentPropsWithoutRef 75 | >((props, ref) => ( 76 | 81 | )) 82 | 83 | CommandEmpty.displayName = CommandPrimitive.Empty.displayName 84 | 85 | const CommandGroup = React.forwardRef< 86 | React.ElementRef, 87 | React.ComponentPropsWithoutRef 88 | >(({ className, ...props }, ref) => ( 89 | 97 | )) 98 | 99 | CommandGroup.displayName = CommandPrimitive.Group.displayName 100 | 101 | const CommandSeparator = React.forwardRef< 102 | React.ElementRef, 103 | React.ComponentPropsWithoutRef 104 | >(({ className, ...props }, ref) => ( 105 | 110 | )) 111 | CommandSeparator.displayName = CommandPrimitive.Separator.displayName 112 | 113 | const CommandItem = React.forwardRef< 114 | React.ElementRef, 115 | React.ComponentPropsWithoutRef 116 | >(({ className, ...props }, ref) => ( 117 | 125 | )) 126 | 127 | CommandItem.displayName = CommandPrimitive.Item.displayName 128 | 129 | const CommandShortcut = ({ 130 | className, 131 | ...props 132 | }: React.HTMLAttributes) => { 133 | return ( 134 | 141 | ) 142 | } 143 | CommandShortcut.displayName = "CommandShortcut" 144 | 145 | export { 146 | Command, 147 | CommandDialog, 148 | CommandInput, 149 | CommandList, 150 | CommandEmpty, 151 | CommandGroup, 152 | CommandItem, 153 | CommandShortcut, 154 | CommandSeparator, 155 | } 156 | -------------------------------------------------------------------------------- /web/src/components/ui/dialog.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as DialogPrimitive from "@radix-ui/react-dialog" 5 | import { X } from "lucide-react" 6 | 7 | import { cn } from "@/utils/cn" 8 | 9 | const Dialog = DialogPrimitive.Root 10 | 11 | const DialogTrigger = DialogPrimitive.Trigger 12 | 13 | const DialogPortal = ({ 14 | className, 15 | ...props 16 | }: DialogPrimitive.DialogPortalProps) => ( 17 | 18 | ) 19 | DialogPortal.displayName = DialogPrimitive.Portal.displayName 20 | 21 | const DialogOverlay = React.forwardRef< 22 | React.ElementRef, 23 | React.ComponentPropsWithoutRef 24 | >(({ className, ...props }, ref) => ( 25 | 33 | )) 34 | DialogOverlay.displayName = DialogPrimitive.Overlay.displayName 35 | 36 | const DialogContent = React.forwardRef< 37 | React.ElementRef, 38 | React.ComponentPropsWithoutRef 39 | >(({ className, children, ...props }, ref) => ( 40 | 41 | 42 | 50 | {children} 51 | 52 | 53 | Close 54 | 55 | 56 | 57 | )) 58 | DialogContent.displayName = DialogPrimitive.Content.displayName 59 | 60 | const DialogHeader = ({ 61 | className, 62 | ...props 63 | }: React.HTMLAttributes) => ( 64 |
71 | ) 72 | DialogHeader.displayName = "DialogHeader" 73 | 74 | const DialogFooter = ({ 75 | className, 76 | ...props 77 | }: React.HTMLAttributes) => ( 78 |
85 | ) 86 | DialogFooter.displayName = "DialogFooter" 87 | 88 | const DialogTitle = React.forwardRef< 89 | React.ElementRef, 90 | React.ComponentPropsWithoutRef 91 | >(({ className, ...props }, ref) => ( 92 | 100 | )) 101 | DialogTitle.displayName = DialogPrimitive.Title.displayName 102 | 103 | const DialogDescription = React.forwardRef< 104 | React.ElementRef, 105 | React.ComponentPropsWithoutRef 106 | >(({ className, ...props }, ref) => ( 107 | 112 | )) 113 | DialogDescription.displayName = DialogPrimitive.Description.displayName 114 | 115 | export { 116 | Dialog, 117 | DialogTrigger, 118 | DialogContent, 119 | DialogHeader, 120 | DialogFooter, 121 | DialogTitle, 122 | DialogDescription, 123 | } 124 | -------------------------------------------------------------------------------- /web/src/components/ui/dropdown-menu.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu" 5 | import { Check, ChevronRight, Circle } from "lucide-react" 6 | 7 | import { cn } from "@/utils/cn" 8 | 9 | const DropdownMenu = DropdownMenuPrimitive.Root 10 | 11 | const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger 12 | 13 | const DropdownMenuGroup = DropdownMenuPrimitive.Group 14 | 15 | const DropdownMenuPortal = DropdownMenuPrimitive.Portal 16 | 17 | const DropdownMenuSub = DropdownMenuPrimitive.Sub 18 | 19 | const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup 20 | 21 | const DropdownMenuSubTrigger = React.forwardRef< 22 | React.ElementRef, 23 | React.ComponentPropsWithoutRef & { 24 | inset?: boolean 25 | } 26 | >(({ className, inset, children, ...props }, ref) => ( 27 | 36 | {children} 37 | 38 | 39 | )) 40 | DropdownMenuSubTrigger.displayName = 41 | DropdownMenuPrimitive.SubTrigger.displayName 42 | 43 | const DropdownMenuSubContent = React.forwardRef< 44 | React.ElementRef, 45 | React.ComponentPropsWithoutRef 46 | >(({ className, ...props }, ref) => ( 47 | 55 | )) 56 | DropdownMenuSubContent.displayName = 57 | DropdownMenuPrimitive.SubContent.displayName 58 | 59 | const DropdownMenuContent = React.forwardRef< 60 | React.ElementRef, 61 | React.ComponentPropsWithoutRef 62 | >(({ className, sideOffset = 4, ...props }, ref) => ( 63 | 64 | 73 | 74 | )) 75 | DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName 76 | 77 | const DropdownMenuItem = React.forwardRef< 78 | React.ElementRef, 79 | React.ComponentPropsWithoutRef & { 80 | inset?: boolean 81 | } 82 | >(({ className, inset, ...props }, ref) => ( 83 | 92 | )) 93 | DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName 94 | 95 | const DropdownMenuCheckboxItem = React.forwardRef< 96 | React.ElementRef, 97 | React.ComponentPropsWithoutRef 98 | >(({ className, children, checked, ...props }, ref) => ( 99 | 108 | 109 | 110 | 111 | 112 | 113 | {children} 114 | 115 | )) 116 | DropdownMenuCheckboxItem.displayName = 117 | DropdownMenuPrimitive.CheckboxItem.displayName 118 | 119 | const DropdownMenuRadioItem = React.forwardRef< 120 | React.ElementRef, 121 | React.ComponentPropsWithoutRef 122 | >(({ className, children, ...props }, ref) => ( 123 | 131 | 132 | 133 | 134 | 135 | 136 | {children} 137 | 138 | )) 139 | DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName 140 | 141 | const DropdownMenuLabel = React.forwardRef< 142 | React.ElementRef, 143 | React.ComponentPropsWithoutRef & { 144 | inset?: boolean 145 | } 146 | >(({ className, inset, ...props }, ref) => ( 147 | 156 | )) 157 | DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName 158 | 159 | const DropdownMenuSeparator = React.forwardRef< 160 | React.ElementRef, 161 | React.ComponentPropsWithoutRef 162 | >(({ className, ...props }, ref) => ( 163 | 168 | )) 169 | DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName 170 | 171 | const DropdownMenuShortcut = ({ 172 | className, 173 | ...props 174 | }: React.HTMLAttributes) => { 175 | return ( 176 | 180 | ) 181 | } 182 | DropdownMenuShortcut.displayName = "DropdownMenuShortcut" 183 | 184 | export { 185 | DropdownMenu, 186 | DropdownMenuTrigger, 187 | DropdownMenuContent, 188 | DropdownMenuItem, 189 | DropdownMenuCheckboxItem, 190 | DropdownMenuRadioItem, 191 | DropdownMenuLabel, 192 | DropdownMenuSeparator, 193 | DropdownMenuShortcut, 194 | DropdownMenuGroup, 195 | DropdownMenuPortal, 196 | DropdownMenuSub, 197 | DropdownMenuSubContent, 198 | DropdownMenuSubTrigger, 199 | DropdownMenuRadioGroup, 200 | } 201 | -------------------------------------------------------------------------------- /web/src/components/ui/form.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import * as LabelPrimitive from "@radix-ui/react-label" 3 | import { Slot } from "@radix-ui/react-slot" 4 | import { 5 | Controller, 6 | ControllerProps, 7 | FieldPath, 8 | FieldValues, 9 | FormProvider, 10 | useFormContext, 11 | } from "react-hook-form" 12 | 13 | import { cn } from "@/utils/cn" 14 | import { Label } from "@/components/ui/label" 15 | 16 | const Form = FormProvider 17 | 18 | type FormFieldContextValue< 19 | TFieldValues extends FieldValues = FieldValues, 20 | TName extends FieldPath = FieldPath 21 | > = { 22 | name: TName 23 | } 24 | 25 | const FormFieldContext = React.createContext( 26 | {} as FormFieldContextValue 27 | ) 28 | 29 | const FormField = < 30 | TFieldValues extends FieldValues = FieldValues, 31 | TName extends FieldPath = FieldPath 32 | >({ 33 | ...props 34 | }: ControllerProps) => { 35 | return ( 36 | 37 | 38 | 39 | ) 40 | } 41 | 42 | const useFormField = () => { 43 | const fieldContext = React.useContext(FormFieldContext) 44 | const itemContext = React.useContext(FormItemContext) 45 | const { getFieldState, formState } = useFormContext() 46 | 47 | const fieldState = getFieldState(fieldContext.name, formState) 48 | 49 | if (!fieldContext) { 50 | throw new Error("useFormField should be used within ") 51 | } 52 | 53 | const { id } = itemContext 54 | 55 | return { 56 | id, 57 | name: fieldContext.name, 58 | formItemId: `${id}-form-item`, 59 | formDescriptionId: `${id}-form-item-description`, 60 | formMessageId: `${id}-form-item-message`, 61 | ...fieldState, 62 | } 63 | } 64 | 65 | type FormItemContextValue = { 66 | id: string 67 | } 68 | 69 | const FormItemContext = React.createContext( 70 | {} as FormItemContextValue 71 | ) 72 | 73 | const FormItem = React.forwardRef< 74 | HTMLDivElement, 75 | React.HTMLAttributes 76 | >(({ className, ...props }, ref) => { 77 | const id = React.useId() 78 | 79 | return ( 80 | 81 |
82 | 83 | ) 84 | }) 85 | FormItem.displayName = "FormItem" 86 | 87 | const FormLabel = React.forwardRef< 88 | React.ElementRef, 89 | React.ComponentPropsWithoutRef 90 | >(({ className, ...props }, ref) => { 91 | const { error, formItemId } = useFormField() 92 | 93 | return ( 94 |