mirror of
https://github.com/aljazceru/sendstr-web.git
synced 2025-12-17 06:24:24 +01:00
feat: init commit
This commit is contained in:
16
.eslintrc.cjs
Normal file
16
.eslintrc.cjs
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
module.exports = {
|
||||||
|
root: true,
|
||||||
|
parser: "@typescript-eslint/parser",
|
||||||
|
extends: [
|
||||||
|
"eslint:recommended",
|
||||||
|
"plugin:@typescript-eslint/recommended",
|
||||||
|
"plugin:@typescript-eslint/recommended-requiring-type-checking",
|
||||||
|
],
|
||||||
|
plugins: ["@typescript-eslint"],
|
||||||
|
parserOptions: {
|
||||||
|
project: ["./tsconfig.json"]
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
"@typescript-eslint/no-explicit-any": 1
|
||||||
|
}
|
||||||
|
}
|
||||||
25
.gitignore
vendored
Normal file
25
.gitignore
vendored
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||||
|
|
||||||
|
# dependencies
|
||||||
|
/node_modules
|
||||||
|
/.pnp
|
||||||
|
.pnp.js
|
||||||
|
|
||||||
|
# testing
|
||||||
|
/coverage
|
||||||
|
|
||||||
|
# next.js
|
||||||
|
/.next/
|
||||||
|
/out/
|
||||||
|
|
||||||
|
# production
|
||||||
|
/build
|
||||||
|
|
||||||
|
# misc
|
||||||
|
.DS_Store
|
||||||
|
.env*
|
||||||
|
|
||||||
|
# debug
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
7
.prettierrc
Normal file
7
.prettierrc
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"printWidth": 100,
|
||||||
|
"singleQuote": false,
|
||||||
|
"semi": false,
|
||||||
|
"trailingComma": "all",
|
||||||
|
"endOfLine": "lf"
|
||||||
|
}
|
||||||
16
README.md
Normal file
16
README.md
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
# Sendstr-web
|
||||||
|
|
||||||
|
Sendstr is an e2e encrypted shared clipboard web app powered by Nostr.
|
||||||
|
|
||||||
|
The main motivation to build Sendstr was to provide a quick and easy way to transfer text and files (coming soon) between devices. Sendstr defaults to a self-hosted Nostr relay but can easily be configured to point elsewhere.
|
||||||
|
|
||||||
|
## Build
|
||||||
|
`cd sendstr-web`
|
||||||
|
`npm i`
|
||||||
|
`npm run export`
|
||||||
|
|
||||||
|
Then copy the contents of `./out` to your favorite static content host.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
Distributed under the MIT License. See [LICENSE file](LICENSE).
|
||||||
65
global.d.ts
vendored
Normal file
65
global.d.ts
vendored
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
declare module "remark-html" {
|
||||||
|
const html: any
|
||||||
|
export default html
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module "nostr-tools" {
|
||||||
|
export const generatePrivateKey: () => string
|
||||||
|
export const getPublicKey: (priv: string) => string
|
||||||
|
export const relayPool: () => {
|
||||||
|
setPrivateKey: (priv: string) => void
|
||||||
|
addRelay: (url: string, { read: boolean, write: boolean }) => void
|
||||||
|
publish: ({pubkey, created_at, kind, tags, content}: {
|
||||||
|
pubkey: string,
|
||||||
|
created_at: number,
|
||||||
|
kind: number,
|
||||||
|
tags: [[string,string]],
|
||||||
|
content: string,
|
||||||
|
}) => Promise<void>,
|
||||||
|
sub: ({
|
||||||
|
cb,
|
||||||
|
filter,
|
||||||
|
}: {
|
||||||
|
cb: (event: {
|
||||||
|
content: string
|
||||||
|
created_at: number
|
||||||
|
id: string
|
||||||
|
kind: number
|
||||||
|
pubkey: string
|
||||||
|
message: string
|
||||||
|
sig: string
|
||||||
|
tags: [[string, string]]
|
||||||
|
}) => Promise<void>
|
||||||
|
filter: Record<string, string[]>[]
|
||||||
|
}) => {
|
||||||
|
unsub: () => void
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module "nostr-tools/nip04" {
|
||||||
|
export const decrypt: (priv: string, pub: string, message: string) => string
|
||||||
|
export const encrypt: (priv: string, pub: string, message: string) => string
|
||||||
|
}
|
||||||
|
declare module "toastify-js" {
|
||||||
|
const Toastify: ({
|
||||||
|
text,
|
||||||
|
duration,
|
||||||
|
close,
|
||||||
|
gravity,
|
||||||
|
position,
|
||||||
|
stopOnFocus,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
text: string
|
||||||
|
duration: number
|
||||||
|
close: boolean
|
||||||
|
gravity: string
|
||||||
|
position: string
|
||||||
|
stopOnFocus: boolean
|
||||||
|
className: string
|
||||||
|
}) => {
|
||||||
|
showToast: () => void
|
||||||
|
}
|
||||||
|
export = Toastify
|
||||||
|
}
|
||||||
5
next-env.d.ts
vendored
Normal file
5
next-env.d.ts
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
/// <reference types="next" />
|
||||||
|
/// <reference types="next/image-types/global" />
|
||||||
|
|
||||||
|
// NOTE: This file should not be edited
|
||||||
|
// see https://nextjs.org/docs/basic-features/typescript for more information.
|
||||||
10
next.config.js
Normal file
10
next.config.js
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
/** @type {import('next').NextConfig} */
|
||||||
|
const withPWA = require('next-pwa')
|
||||||
|
const runtimeCaching = require('next-pwa/cache')
|
||||||
|
|
||||||
|
module.exports = withPWA({
|
||||||
|
pwa: {
|
||||||
|
dest: 'public',
|
||||||
|
runtimeCaching,
|
||||||
|
},
|
||||||
|
})
|
||||||
16614
package-lock.json
generated
Normal file
16614
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
58
package.json
Normal file
58
package.json
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
{
|
||||||
|
"name": "sendstr-web",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "",
|
||||||
|
"main": "index.js",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev",
|
||||||
|
"build": "next build",
|
||||||
|
"start": "next start",
|
||||||
|
"export": "rm -rf out/ && next build && next export",
|
||||||
|
"serve": "browser-sync start --server out --no-open --https --no-snippet",
|
||||||
|
"lint": "npx eslint \"src/**/*.@(tsx|ts|jsx|js)\" --fix",
|
||||||
|
"format": "npx prettier --write \"src/**/*.@(tsx|ts|jsx|js)\""
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@zxing/browser": "^0.1.1",
|
||||||
|
"date-fns": "^2.11.1",
|
||||||
|
"gray-matter": "^4.0.2",
|
||||||
|
"next": "latest",
|
||||||
|
"next-pwa": "^5.5.4",
|
||||||
|
"nostr-tools": "^0.23.3",
|
||||||
|
"qrcode.react": "^3.0.2",
|
||||||
|
"react": "17.0.2",
|
||||||
|
"react-device-detect": "^2.2.2",
|
||||||
|
"react-dom": "17.0.2",
|
||||||
|
"react-icons": "^4.4.0",
|
||||||
|
"remark": "^14.0.1",
|
||||||
|
"remark-html": "^15.0.0",
|
||||||
|
"toastify-js": "^1.11.2"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^16.3.3",
|
||||||
|
"@types/react": "^17.0.14",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^5.29.0",
|
||||||
|
"@typescript-eslint/parser": "^5.29.0",
|
||||||
|
"autoprefixer": "^10.4.7",
|
||||||
|
"browser-sync": "^2.27.10",
|
||||||
|
"eslint": "^8.18.0",
|
||||||
|
"postcss": "^8.4.14",
|
||||||
|
"prettier": "^2.7.1",
|
||||||
|
"tailwindcss": "^3.1.3",
|
||||||
|
"typescript": "^4.3.5"
|
||||||
|
},
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "git+https://github.com/vilm3r/sendstr-web.git"
|
||||||
|
},
|
||||||
|
"keywords": [
|
||||||
|
"nostr",
|
||||||
|
"nextjs"
|
||||||
|
],
|
||||||
|
"author": "vilm3r",
|
||||||
|
"license": "MIT",
|
||||||
|
"bugs": {
|
||||||
|
"url": "https://github.com/vilm3r/sendstr-web/issues"
|
||||||
|
},
|
||||||
|
"homepage": "https://sendstr.com"
|
||||||
|
}
|
||||||
6
postcss.config.js
Normal file
6
postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
module.exports = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
BIN
public/android-chrome-192x192.png
Normal file
BIN
public/android-chrome-192x192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.3 KiB |
BIN
public/android-chrome-512x512.png
Normal file
BIN
public/android-chrome-512x512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 7.6 KiB |
BIN
public/apple-touch-icon.png
Normal file
BIN
public/apple-touch-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.1 KiB |
BIN
public/favicon-16x16.png
Normal file
BIN
public/favicon-16x16.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 349 B |
BIN
public/favicon-32x32.png
Normal file
BIN
public/favicon-32x32.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 607 B |
BIN
public/favicon.ico
Normal file
BIN
public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
22
public/manifest.json
Normal file
22
public/manifest.json
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"short_name": "Sendstr",
|
||||||
|
"name": "Sendstr: secure clipboard sharing",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/android-chrome-192x192.png",
|
||||||
|
"type": "image/png",
|
||||||
|
"sizes": "192x192"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/android-chrome-512x512.png",
|
||||||
|
"type": "image/png",
|
||||||
|
"sizes": "512x512"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"start_url": "/",
|
||||||
|
"background_color": "#3367D6",
|
||||||
|
"display": "standalone",
|
||||||
|
"scope": "/",
|
||||||
|
"theme_color": "#3367D6",
|
||||||
|
"description": "Sendstr is an open source end-to-end encrypted shared clipboard app built on top of Nostr. No login needed, new throwaway encryption keys are generated on page load."
|
||||||
|
}
|
||||||
3
public/robots.txt
Normal file
3
public/robots.txt
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
User-agent: *
|
||||||
|
Allow: /index.html
|
||||||
|
Sitemap: https://sendstr.com/sitemap.xml
|
||||||
9
public/sitemap.xml
Normal file
9
public/sitemap.xml
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||||
|
<url>
|
||||||
|
<loc>https://sendstr.com/</loc>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://sendstr.com/settings</loc>
|
||||||
|
</url>
|
||||||
|
</urlset>
|
||||||
1
public/sw.js
Normal file
1
public/sw.js
Normal file
File diff suppressed because one or more lines are too long
1
public/workbox-5f5b08d6.js
Normal file
1
public/workbox-5f5b08d6.js
Normal file
File diff suppressed because one or more lines are too long
20
src/components/button/index.tsx
Normal file
20
src/components/button/index.tsx
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
type ButtonProps = {
|
||||||
|
onClick: () => void
|
||||||
|
children: React.ReactNode
|
||||||
|
disabled?: boolean
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Button = ({ children, disabled, onClick, className }: ButtonProps) => {
|
||||||
|
return (
|
||||||
|
<div className={className}>
|
||||||
|
<button
|
||||||
|
className="bg-custom-black rounded-md px-6 py-3 w-full h-full shadow"
|
||||||
|
onClick={onClick}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
7
src/components/card/index.tsx
Normal file
7
src/components/card/index.tsx
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
interface CardProps {
|
||||||
|
children: React.ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Card = ({ children }: CardProps) => {
|
||||||
|
return <div className="bg-custom-green-light rounded-xl shadow-md">{children}</div>
|
||||||
|
}
|
||||||
31
src/components/header/index.tsx
Normal file
31
src/components/header/index.tsx
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import React from "react"
|
||||||
|
import { DiGithubBadge } from "react-icons/di"
|
||||||
|
import { MdSettings } from "react-icons/md"
|
||||||
|
import Link from "next/link"
|
||||||
|
|
||||||
|
export function Header() {
|
||||||
|
return (
|
||||||
|
<header className="bg-custom-green-light mb-5 mx-auto border-0 rounded-xl p-4 shadow-md">
|
||||||
|
<div className="container flex justify-between items-center">
|
||||||
|
<a href="/" className="py-1.5 mr-4 text-lg cursor-pointer">
|
||||||
|
<h1 className="text-xl">Sendstr</h1>
|
||||||
|
</a>
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
{/* <Link
|
||||||
|
href="/faq"
|
||||||
|
className="text-lg cursor-pointer"
|
||||||
|
>FAQ
|
||||||
|
</Link> */}
|
||||||
|
<a href="https://github.com/vilm3r/sendstr-web">
|
||||||
|
<DiGithubBadge className="inline text-3xl" title="Github" />
|
||||||
|
</a>
|
||||||
|
<Link href="/settings">
|
||||||
|
<div className="cursor-pointer">
|
||||||
|
<MdSettings className="inline text-2xl" title="Settings" />
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
)
|
||||||
|
}
|
||||||
26
src/components/icon-button/index.tsx
Normal file
26
src/components/icon-button/index.tsx
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { forwardRef, LegacyRef } from "react"
|
||||||
|
|
||||||
|
type IconButtonProps = {
|
||||||
|
onClick: () => void
|
||||||
|
children: React.ReactNode
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const IconButton = forwardRef(
|
||||||
|
(
|
||||||
|
{ children, onClick, className }: IconButtonProps,
|
||||||
|
ref: LegacyRef<HTMLButtonElement> | undefined,
|
||||||
|
) => {
|
||||||
|
return (
|
||||||
|
<div className={className}>
|
||||||
|
<button
|
||||||
|
ref={ref}
|
||||||
|
className="bg-custom-black w-full h-full rounded-lg flex justify-center items-center"
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
27
src/components/input/index.tsx
Normal file
27
src/components/input/index.tsx
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { ChangeEvent, forwardRef, LegacyRef } from "react"
|
||||||
|
|
||||||
|
export interface InputProps {
|
||||||
|
onChange?: (e: ChangeEvent<HTMLInputElement> | undefined) => void
|
||||||
|
value?: string
|
||||||
|
placeholder?: string
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Input = forwardRef(
|
||||||
|
(
|
||||||
|
{ onChange, value, placeholder, className }: InputProps,
|
||||||
|
ref: LegacyRef<HTMLInputElement> | undefined,
|
||||||
|
) => {
|
||||||
|
return (
|
||||||
|
<div className={className}>
|
||||||
|
<input
|
||||||
|
className="bg-custom-green-dark border-2 border-custom-black rounded w-full p-3"
|
||||||
|
ref={ref}
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
placeholder={placeholder}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
33
src/components/qr-reader/index.tsx
Normal file
33
src/components/qr-reader/index.tsx
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { BrowserQRCodeReader, IScannerControls } from "@zxing/browser"
|
||||||
|
import { useEffect, useRef } from "react"
|
||||||
|
|
||||||
|
export const QrReader = ({ onResult }: { onResult: (result: string) => void }) => {
|
||||||
|
const controlsRef = useRef<IScannerControls | null>(null)
|
||||||
|
|
||||||
|
const constraints = {
|
||||||
|
video: {
|
||||||
|
facingMode: {
|
||||||
|
ideal: "environment",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const reader = new BrowserQRCodeReader(undefined, {
|
||||||
|
delayBetweenScanAttempts: 300,
|
||||||
|
})
|
||||||
|
|
||||||
|
reader
|
||||||
|
.decodeFromConstraints(constraints, "video", (result, error, controls) => {
|
||||||
|
controlsRef.current = controls
|
||||||
|
if (result) {
|
||||||
|
controls.stop()
|
||||||
|
onResult(result.getText())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(console.warn)
|
||||||
|
return () => controlsRef?.current?.stop()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return <video id="video"></video>
|
||||||
|
}
|
||||||
24
src/components/toggle/index.tsx
Normal file
24
src/components/toggle/index.tsx
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
type ToggleProps = {
|
||||||
|
checked: boolean
|
||||||
|
onChange: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Toggle = ({ checked, onChange }: ToggleProps) => {
|
||||||
|
return (
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="absolute overflow-hidden whitespace-nowrap h-[1px] w-[1px]"
|
||||||
|
checked={checked}
|
||||||
|
onChange={onChange}
|
||||||
|
/>
|
||||||
|
<span className="bg-white border-2 border-custom-black rounded-3xl flex h-8 mr-[10px] relative w-16 cursor-pointer">
|
||||||
|
<span
|
||||||
|
className={`flex absolute left-[2px] bottom-[2px] justify-center h-6 w-6 rounded-full items-center transition ${
|
||||||
|
checked ? "bg-custom-black" : "translate-x-8 bg-custom-black/50"
|
||||||
|
}`}
|
||||||
|
></span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
)
|
||||||
|
}
|
||||||
42
src/lib/localStorage.ts
Normal file
42
src/lib/localStorage.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
export type SettingsRelay = {
|
||||||
|
url: string
|
||||||
|
enabled: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getRelays = (): SettingsRelay[] =>
|
||||||
|
JSON.parse(
|
||||||
|
window.localStorage.getItem("relays") ||
|
||||||
|
JSON.stringify([
|
||||||
|
{
|
||||||
|
url: "wss://relay.sendstr.com",
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
) as SettingsRelay[]
|
||||||
|
|
||||||
|
export const setRelays = (relays: SettingsRelay[]) =>
|
||||||
|
window.localStorage.setItem("relays", JSON.stringify(relays))
|
||||||
|
|
||||||
|
export const addRelay = (relay: SettingsRelay) =>
|
||||||
|
window.localStorage.setItem("relays", JSON.stringify([...getRelays(), relay]))
|
||||||
|
|
||||||
|
export const removeRelay = (relay: string) =>
|
||||||
|
window.localStorage.setItem("relays", JSON.stringify(getRelays().filter((x) => x.url !== relay)))
|
||||||
|
|
||||||
|
export const toggleRelay = (relay: string) =>
|
||||||
|
window.localStorage.setItem(
|
||||||
|
"relays",
|
||||||
|
JSON.stringify(
|
||||||
|
getRelays().reduce((acc, x) => {
|
||||||
|
if (x.url === relay)
|
||||||
|
return [
|
||||||
|
...acc,
|
||||||
|
{
|
||||||
|
url: x.url,
|
||||||
|
enabled: !x.enabled,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
return [...acc, x]
|
||||||
|
}, [] as SettingsRelay[]),
|
||||||
|
),
|
||||||
|
)
|
||||||
10
src/lib/utils.ts
Normal file
10
src/lib/utils.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
export const debounce = (callback: (...args: any[]) => void, wait: number) => {
|
||||||
|
let timeoutId: NodeJS.Timeout
|
||||||
|
return (...args: any) => {
|
||||||
|
clearTimeout(timeoutId)
|
||||||
|
timeoutId = setTimeout(() => {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
||||||
|
callback(...args)
|
||||||
|
}, wait)
|
||||||
|
}
|
||||||
|
}
|
||||||
70
src/pages/_app.tsx
Normal file
70
src/pages/_app.tsx
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import "../styles/global.css"
|
||||||
|
import { AppProps } from "next/app"
|
||||||
|
import { useEffect, useState } from "react"
|
||||||
|
import { getRelays } from "../lib/localStorage"
|
||||||
|
import { NostrEventType, NostrType } from "../types"
|
||||||
|
|
||||||
|
export default function App({ Component, pageProps }: AppProps) {
|
||||||
|
const [events, setEvents] = useState<{ [k: string]: NostrEventType }>({})
|
||||||
|
const [nostr, setNostr] = useState<NostrType>({
|
||||||
|
priv: "",
|
||||||
|
pub: "",
|
||||||
|
pool: null,
|
||||||
|
sub: null,
|
||||||
|
})
|
||||||
|
|
||||||
|
const updateEvents = async (pub: string, priv: string, event: NostrEventType) => {
|
||||||
|
const { decrypt } = await import("nostr-tools/nip04")
|
||||||
|
try {
|
||||||
|
const p = event.tags.find(([tag]) => tag === "p") || ["p", ""]
|
||||||
|
const pubkey = event.pubkey === pub ? p[1] : event.pubkey
|
||||||
|
const message = decrypt(priv, pubkey, event.content)
|
||||||
|
setEvents({
|
||||||
|
...events,
|
||||||
|
...{
|
||||||
|
[event.id]: {
|
||||||
|
...event,
|
||||||
|
message,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let sub: {
|
||||||
|
unsub: () => void
|
||||||
|
}
|
||||||
|
void (async () => {
|
||||||
|
const { generatePrivateKey, relayPool, getPublicKey } = await import("nostr-tools")
|
||||||
|
const priv = generatePrivateKey()
|
||||||
|
const pub = getPublicKey(priv)
|
||||||
|
const pool = relayPool()
|
||||||
|
pool.setPrivateKey(priv)
|
||||||
|
const relays = getRelays()
|
||||||
|
relays.forEach(
|
||||||
|
(relay) => relay.enabled && pool.addRelay(relay.url, { read: true, write: true }),
|
||||||
|
)
|
||||||
|
sub = pool.sub({
|
||||||
|
cb: (event: NostrEventType) => updateEvents(pub, priv, event),
|
||||||
|
filter: [{ "#p": [pub] }],
|
||||||
|
})
|
||||||
|
setNostr({ priv, pub, pool, sub })
|
||||||
|
})()
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
sub.unsub()
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const getLatestEvent = (events: Record<string, NostrEventType>) =>
|
||||||
|
Object.entries(events).reduce((acc, x) => {
|
||||||
|
if (acc === null) return x[1]
|
||||||
|
if (new Date(acc.created_at) < new Date(x[1].created_at)) return x[1]
|
||||||
|
return acc
|
||||||
|
}, null as NostrEventType | null)
|
||||||
|
|
||||||
|
return <Component {...{ ...pageProps, nostr, event: getLatestEvent(events) }} />
|
||||||
|
}
|
||||||
3
src/pages/faq/index.tsx
Normal file
3
src/pages/faq/index.tsx
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export default function FAQ() {
|
||||||
|
return <></>
|
||||||
|
}
|
||||||
82
src/pages/index.tsx
Normal file
82
src/pages/index.tsx
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import React, { useState } from "react"
|
||||||
|
import Head from "next/head"
|
||||||
|
|
||||||
|
import { Header } from "../components/header"
|
||||||
|
import { Button } from "../components/button"
|
||||||
|
import { Card } from "../components/card"
|
||||||
|
import { SendView } from "../views/send"
|
||||||
|
import { ReceiveView } from "../views/receive"
|
||||||
|
import { NostrEventType, NostrType } from "../types"
|
||||||
|
|
||||||
|
type HomeProps = {
|
||||||
|
nostr: NostrType
|
||||||
|
event: NostrEventType
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Home({ nostr, event }: HomeProps) {
|
||||||
|
const [clientType, setClientType] = useState("")
|
||||||
|
|
||||||
|
const LandingView = () => (
|
||||||
|
<div className="max-w-[64rem] m-auto">
|
||||||
|
<Card>
|
||||||
|
<div className="p-10">
|
||||||
|
<h1 className="text-2xl text-bold pb-5">Open source e2e encrypted shared clipboard</h1>
|
||||||
|
<p className="pb-10">
|
||||||
|
Sendstr is an open source end-to-end encrypted shared clipboard app built on top of{" "}
|
||||||
|
<a className="underline" href="https://github.com/nostr-protocol/nostr" target="_blank">
|
||||||
|
Nostr
|
||||||
|
</a>
|
||||||
|
. No login needed, new throwaway encryption keys are generated on page load, and the
|
||||||
|
default relay deletes messages after 1 hour. To get started open this page on another
|
||||||
|
device and choose one of the options below.
|
||||||
|
</p>
|
||||||
|
<div className="flex w-full">
|
||||||
|
<Button className="w-1/2 px-4" onClick={() => setClientType("send")}>
|
||||||
|
Send
|
||||||
|
</Button>
|
||||||
|
<Button className="w-1/2 px-4" onClick={() => setClientType("receive")}>
|
||||||
|
Receive
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
const Page = () => {
|
||||||
|
switch (true) {
|
||||||
|
case clientType === "send":
|
||||||
|
return <SendView nostr={nostr} />
|
||||||
|
case clientType === "receive":
|
||||||
|
return <ReceiveView nostr={nostr} event={event} />
|
||||||
|
default:
|
||||||
|
return <LandingView />
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Head>
|
||||||
|
<title>Sendstr</title>
|
||||||
|
<meta name="viewport" content="initial-scale=1.0, width=device-width" />
|
||||||
|
<meta
|
||||||
|
name="description"
|
||||||
|
content="Sendstr is an open source end-to-end encrypted shared clipboard app
|
||||||
|
built on top of Nostr. No login needed, new throwaway encryption keys are generated on
|
||||||
|
page load, and the default relay deletes messages after 1 hour."
|
||||||
|
/>
|
||||||
|
<meta name="mobile-web-app-capable" content="yes" />
|
||||||
|
</Head>
|
||||||
|
<div className="bg-custom-green-dark min-h-screen">
|
||||||
|
<div className="p-5">
|
||||||
|
<div className="max-w-[80rem] mx-auto">
|
||||||
|
<Header />
|
||||||
|
</div>
|
||||||
|
<main>
|
||||||
|
<Page />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
101
src/pages/settings/index.tsx
Normal file
101
src/pages/settings/index.tsx
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
import { createRef, useState } from "react"
|
||||||
|
import { Button } from "../../components/button"
|
||||||
|
import { Card } from "../../components/card"
|
||||||
|
import { Header } from "../../components/header"
|
||||||
|
import {
|
||||||
|
SettingsRelay,
|
||||||
|
getRelays,
|
||||||
|
addRelay,
|
||||||
|
toggleRelay,
|
||||||
|
removeRelay,
|
||||||
|
} from "../../lib/localStorage"
|
||||||
|
import { Input } from "../../components/input"
|
||||||
|
import { MdDelete } from "react-icons/md"
|
||||||
|
import { Toggle } from "../../components/toggle"
|
||||||
|
import Head from "next/head"
|
||||||
|
|
||||||
|
type SettingsState = {
|
||||||
|
relays: SettingsRelay[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Settings() {
|
||||||
|
const [settings, setSettings] = useState<SettingsState>({
|
||||||
|
relays: typeof window !== "undefined" ? getRelays() : [],
|
||||||
|
})
|
||||||
|
const newPool = createRef<HTMLInputElement>()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Head>
|
||||||
|
<title>Sendstr - Settings</title>
|
||||||
|
<meta name="viewport" content="initial-scale=1.0, width=device-width" />
|
||||||
|
<meta
|
||||||
|
name="description"
|
||||||
|
content="Sendstr is an open source end-to-end encrypted shared clipboard app
|
||||||
|
built on top of Nostr. No login needed, new throwaway encryption keys are generated on
|
||||||
|
page load, and the default relay deletes messages after 1 hour."
|
||||||
|
/>
|
||||||
|
</Head>
|
||||||
|
<div className="bg-custom-green-dark min-h-screen">
|
||||||
|
<div className="p-5 max-w-[80rem] m-auto">
|
||||||
|
<Header />
|
||||||
|
<main className="max-w-[64rem] m-auto">
|
||||||
|
<Card>
|
||||||
|
<div className="max-w-[30rem] m-auto p-10">
|
||||||
|
<h2 className="text-2xl pb-5">Relays</h2>
|
||||||
|
<ul>
|
||||||
|
{settings.relays.map((relay) => (
|
||||||
|
<li key={relay.url}>
|
||||||
|
<div className="m-auto flex items-center pb-3">
|
||||||
|
<Toggle
|
||||||
|
checked={relay.enabled}
|
||||||
|
onChange={() => {
|
||||||
|
toggleRelay(relay.url)
|
||||||
|
setSettings({
|
||||||
|
...settings,
|
||||||
|
relays: getRelays(),
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<label className="lg:text-lg flex-grow text-center p-2 truncate">
|
||||||
|
{relay.url}
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
removeRelay(relay.url)
|
||||||
|
setSettings({
|
||||||
|
...settings,
|
||||||
|
relays: getRelays(),
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MdDelete className="text-2xl" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<Input className="pt-5" ref={newPool} placeholder="Relay url" />
|
||||||
|
<Button
|
||||||
|
className="pt-5"
|
||||||
|
onClick={() => {
|
||||||
|
addRelay({
|
||||||
|
url: newPool?.current?.value || "",
|
||||||
|
enabled: true,
|
||||||
|
})
|
||||||
|
setSettings({
|
||||||
|
...settings,
|
||||||
|
relays: getRelays(),
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Add Relay
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
7
src/styles/global.css
Normal file
7
src/styles/global.css
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
body {
|
||||||
|
color: #FFFFFF
|
||||||
|
}
|
||||||
59
src/types.ts
Normal file
59
src/types.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
export type NostrSub = ({
|
||||||
|
cb,
|
||||||
|
filter,
|
||||||
|
}: {
|
||||||
|
cb: (event: {
|
||||||
|
content: string
|
||||||
|
created_at: number
|
||||||
|
id: string
|
||||||
|
kind: number
|
||||||
|
pubkey: string
|
||||||
|
message: string
|
||||||
|
sig: string
|
||||||
|
tags: [[string, string]]
|
||||||
|
}) => Promise<void>
|
||||||
|
filter: Record<string, string[]>[]
|
||||||
|
}) => {
|
||||||
|
unsub: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export type NostrPublish = ({
|
||||||
|
pubkey,
|
||||||
|
created_at,
|
||||||
|
kind,
|
||||||
|
tags,
|
||||||
|
content,
|
||||||
|
}: {
|
||||||
|
pubkey: string
|
||||||
|
created_at: number
|
||||||
|
kind: number
|
||||||
|
tags: [[string, string]]
|
||||||
|
content: string
|
||||||
|
}) => Promise<void>
|
||||||
|
|
||||||
|
export type NostrPool = {
|
||||||
|
setPrivateKey: (priv: string) => void
|
||||||
|
addRelay: (url: string, { read, write }: { read: boolean; write: boolean }) => void
|
||||||
|
sub: NostrSub
|
||||||
|
publish: NostrPublish
|
||||||
|
}
|
||||||
|
|
||||||
|
export type NostrType = {
|
||||||
|
priv: string
|
||||||
|
pub: string
|
||||||
|
pool: NostrPool | null
|
||||||
|
sub: {
|
||||||
|
unsub: () => void
|
||||||
|
} | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type NostrEventType = {
|
||||||
|
content: string
|
||||||
|
created_at: number
|
||||||
|
id: string
|
||||||
|
kind: number
|
||||||
|
pubkey: string
|
||||||
|
message: string
|
||||||
|
sig: string
|
||||||
|
tags: [[string, string]]
|
||||||
|
}
|
||||||
75
src/views/receive/index.tsx
Normal file
75
src/views/receive/index.tsx
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import { QRCodeSVG } from "qrcode.react"
|
||||||
|
import Toastify from "toastify-js"
|
||||||
|
import { Button } from "../../components/button"
|
||||||
|
import { Card } from "../../components/card"
|
||||||
|
import { NostrEventType, NostrType } from "../../types"
|
||||||
|
|
||||||
|
const Message = ({ event }: { event: NostrEventType }) => {
|
||||||
|
return (
|
||||||
|
<p className="bg-custom-green-dark border-2 border-custom-black rounded w-full p-3 whitespace-pre-wrap">
|
||||||
|
{event.message}
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type ReceiveViewProps = {
|
||||||
|
nostr: NostrType
|
||||||
|
event: NostrEventType
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ReceiveView = ({ nostr, event }: ReceiveViewProps) => {
|
||||||
|
return (
|
||||||
|
<div className="max-w-[64rem] m-auto">
|
||||||
|
<Card>
|
||||||
|
<div className="p-10">
|
||||||
|
<div className="flex flex-col lg:flex-row">
|
||||||
|
{!event && (
|
||||||
|
<div className="overflow-visible py-5 max-w-[20rem] mx-auto lg:pr-5">
|
||||||
|
<QRCodeSVG
|
||||||
|
value={nostr.pub}
|
||||||
|
level="H"
|
||||||
|
bgColor="transparent"
|
||||||
|
fgColor="#3C3744"
|
||||||
|
includeMargin={false}
|
||||||
|
width="100%"
|
||||||
|
height="100%"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex flex-col items-center justify-center w-full">
|
||||||
|
<div>
|
||||||
|
<label className="flex flex-grow text-left">My pubkey:</label>
|
||||||
|
<div
|
||||||
|
id="mypubkey"
|
||||||
|
className="border-2 border-custom-black rounded-md p-2 bg-custom-green-dark break-all"
|
||||||
|
>
|
||||||
|
{nostr.pub}
|
||||||
|
</div>
|
||||||
|
<div className="py-6 max-w-[20rem] m-auto">
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
navigator.clipboard.writeText(nostr.pub).catch(console.warn)
|
||||||
|
Toastify({
|
||||||
|
text: "Pubkey copied",
|
||||||
|
duration: 2000,
|
||||||
|
close: false,
|
||||||
|
gravity: "bottom",
|
||||||
|
position: "center",
|
||||||
|
stopOnFocus: false,
|
||||||
|
className:
|
||||||
|
"flex fixed bottom-0 bg-custom-black p-2 rounded left-[45%] z-50",
|
||||||
|
}).showToast()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Copy Pubkey
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>{event && <Message event={event} />}</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
20
src/views/scan/index.tsx
Normal file
20
src/views/scan/index.tsx
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { MdClose } from "react-icons/md"
|
||||||
|
import { QrReader } from "../../components/qr-reader"
|
||||||
|
|
||||||
|
interface ScanViewProps {
|
||||||
|
close: () => void
|
||||||
|
onScan: (x: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ScanView = ({ close, onScan }: ScanViewProps) => {
|
||||||
|
return (
|
||||||
|
<div className="bg-black h-screen w-screen fixed top-0 left-0 z-50 flex justify-center">
|
||||||
|
<QrReader onResult={onScan} />
|
||||||
|
<div className="absolute text-black top-0 right-0">
|
||||||
|
<button className="bg-white rounded-3xl p-2 m-2" onClick={close}>
|
||||||
|
<MdClose size="1rem" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
118
src/views/send/index.tsx
Normal file
118
src/views/send/index.tsx
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
import dynamic from "next/dynamic"
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from "react"
|
||||||
|
import { isMobile } from "react-device-detect"
|
||||||
|
import { Input } from "../../components/input"
|
||||||
|
import { MdQrCodeScanner } from "react-icons/md"
|
||||||
|
import { IconButton } from "../../components/icon-button"
|
||||||
|
import { Card } from "../../components/card"
|
||||||
|
import { debounce } from "../../lib/utils"
|
||||||
|
import { NostrType } from "../../types"
|
||||||
|
|
||||||
|
type MessageProps = {
|
||||||
|
message: string
|
||||||
|
onChange: (x: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const isValidPeerKey = (peerKey: string) => peerKey.length === 64
|
||||||
|
|
||||||
|
const Message = ({ message, onChange }: MessageProps) => {
|
||||||
|
return (
|
||||||
|
<section className="p-4">
|
||||||
|
<div className="border-0">
|
||||||
|
<textarea
|
||||||
|
className="bg-custom-green-dark border-2 border-custom-black rounded w-full min-h-[100px] max-h-[700px]"
|
||||||
|
value={message}
|
||||||
|
onChange={(e) => onChange(e.currentTarget.value || "")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type PeerInputProps = {
|
||||||
|
peerKey: string
|
||||||
|
onChange: (x: string) => void
|
||||||
|
setShowScan: (x: boolean) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const PeerInput = ({ peerKey, onChange, setShowScan }: PeerInputProps) => {
|
||||||
|
return (
|
||||||
|
<section className="mx-auto max-w-[40rem] p-4">
|
||||||
|
<label>Peer pubkey:</label>
|
||||||
|
<div className="relative">
|
||||||
|
<Input value={peerKey} onChange={(e) => onChange(e?.currentTarget?.value || "")} />
|
||||||
|
<div className="absolute right-0 top-0 h-full flex items-center">
|
||||||
|
<IconButton className="w-10 h-10 mr-2" onClick={() => setShowScan(true)}>
|
||||||
|
<div className="">
|
||||||
|
<MdQrCodeScanner width="100%" height="auto" />
|
||||||
|
</div>
|
||||||
|
</IconButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SendViewProps = {
|
||||||
|
nostr: NostrType
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SendView = ({ nostr }: SendViewProps) => {
|
||||||
|
const [showScan, setShowScan] = useState(false)
|
||||||
|
const [peerKey, setPeerKey] = useState("")
|
||||||
|
const [message, setMessage] = useState("")
|
||||||
|
|
||||||
|
const ScanView = dynamic(async () => (await import("../scan")).ScanView)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isMobile) {
|
||||||
|
setShowScan(true)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const sendNostrMessage = useRef(
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-misused-promises
|
||||||
|
debounce(async (m: string, p: string, n: NostrType) => {
|
||||||
|
try {
|
||||||
|
const { encrypt } = await import("nostr-tools/nip04")
|
||||||
|
await n.pool?.publish({
|
||||||
|
pubkey: n.pub,
|
||||||
|
created_at: Math.round(Date.now() / 1000),
|
||||||
|
kind: 4,
|
||||||
|
tags: [["p", p]],
|
||||||
|
content: encrypt(n.priv, p, m),
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(e)
|
||||||
|
}
|
||||||
|
}, 750),
|
||||||
|
).current
|
||||||
|
|
||||||
|
const sendMessage = (message: string) => {
|
||||||
|
setMessage(message)
|
||||||
|
sendNostrMessage(message, peerKey, nostr)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{showScan && (
|
||||||
|
<ScanView
|
||||||
|
close={() => setShowScan(false)}
|
||||||
|
onScan={(x: string) => {
|
||||||
|
setShowScan(false)
|
||||||
|
setPeerKey(x)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className="mx-auto max-w-[64rem] flex flex-col gap-5">
|
||||||
|
<Card>
|
||||||
|
<div className="p-4">
|
||||||
|
<PeerInput peerKey={peerKey} setShowScan={setShowScan} onChange={setPeerKey} />
|
||||||
|
{isValidPeerKey(peerKey) && <Message message={message} onChange={sendMessage} />}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
18
tailwind.config.js
Normal file
18
tailwind.config.js
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
module.exports = {
|
||||||
|
content: [
|
||||||
|
"./pages/**/*.{js,ts,jsx,tsx}",
|
||||||
|
"./components/**/*.{js,ts,jsx,tsx}",
|
||||||
|
"./views/**/*.{js,ts,jsx,tsx}",
|
||||||
|
],
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
'custom-green-light': '#6b9370',
|
||||||
|
'custom-green-dark': '#4D6A51',
|
||||||
|
'custom-black': '#3C3744'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
}
|
||||||
21
tsconfig.json
Normal file
21
tsconfig.json
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "es5",
|
||||||
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
|
"allowJs": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "node",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "preserve",
|
||||||
|
"incremental": true,
|
||||||
|
"noImplicitAny": true
|
||||||
|
},
|
||||||
|
"exclude": ["node_modules"],
|
||||||
|
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user