Merge pull request #9 from MutinyWallet/solid-switch

switch to solid
This commit is contained in:
Paul Miller
2023-04-04 11:19:46 -05:00
committed by GitHub
48 changed files with 6063 additions and 6565 deletions

23
.eslintrc.cjs Normal file
View File

@@ -0,0 +1,23 @@
module.exports = {
"env": {
"browser": true,
"es2021": true
},
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/recommended"
],
"overrides": [
],
"parser": "@typescript-eslint/parser",
"parserOptions": {
"ecmaVersion": "latest",
"sourceType": "module"
},
"plugins": [
"@typescript-eslint"
],
"rules": {
"@typescript-eslint/no-unused-vars": ["warn", { argsIgnorePattern: "^_", destructuredArrayIgnorePattern: "^_", varsIgnorePattern: "^_" }],
}
}

3
.gitignore vendored
View File

@@ -24,4 +24,5 @@ dist-ssr
*.sw?
# PWA dev stuff
dev-dist
dev-dist
.solid

View File

@@ -1,6 +1,30 @@
# mutiny-web
# SolidStart
Everything you need to build a Solid project, powered by [`solid-start`](https://start.solidjs.com);
## Creating a project
```bash
# create a new project in the current directory
npm init solid@latest
# create a new project in my-app
npm init solid@latest my-app
```
pnpm install
pnpm run dev
## Developing
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
```bash
npm run dev
# or start the server and open the app in a new browser tab
npm run dev -- --open
```
## Building
Solid apps are built with _adapters_, which optimise your project for deployment to different environments.
By default, `npm run build` will generate a Node app that you can run with `npm start`. To use a different adapter, add it to the `devDependencies` in `package.json` and specify in your `vite.config.js`.

View File

@@ -1,21 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta
name="viewport"
content="width=device-width, initial-scale=1.0 height=device-height viewport-fit=cover user-scalable=no"
/>
<meta name="theme-color" content="#000000" />
<meta name="description" content="Lightning wallet for the web" />
<link rel="icon" href="/favicon.ico" />
<link rel="apple-touch-icon" href="/180.png" sizes="180x180" />
<link rel="mask-icon" href="/mask-icon.svg" color="#000" />
<title>Mutiny Wallet</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

5964
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,36 +1,40 @@
{
"name": "next-frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"name": "mws",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
"dev": "solid-start dev",
"build": "solid-start build",
"start": "solid-start start",
"lint": "eslint . --ext .ts,.tsx,.js"
},
"type": "module",
"devDependencies": {
"@types/node": "^18.15.11",
"@typescript-eslint/eslint-plugin": "^5.57.1",
"@typescript-eslint/parser": "^5.57.1",
"autoprefixer": "^10.4.14",
"esbuild": "^0.14.54",
"eslint": "^8.37.0",
"postcss": "^8.4.21",
"solid-start-node": "^0.2.24",
"tailwindcss": "^3.3.1",
"typescript": "^4.9.5",
"vite": "^4.2.1",
"vite-plugin-pwa": "^0.14.7",
"workbox-window": "^6.5.4"
},
"dependencies": {
"@motionone/solid": "^10.16.0",
"@nostr-dev-kit/ndk": "^0.0.13",
"@solidjs/meta": "^0.28.4",
"@solidjs/router": "^0.8.2",
"class-variance-authority": "^0.4.0",
"framer-motion": "^8.5.4",
"nostr-react": "^0.6.4",
"nostr-tools": "^1.3.2",
"nostr-tools": "^1.8.1",
"qr-scanner": "^1.4.2",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-linkify": "1.0.0-alpha",
"react-modal-sheet": "^1.10.0",
"react-router-dom": "^6.8.0"
"solid-js": "^1.7.1",
"solid-start": "^0.2.24",
"undici": "^5.21.0"
},
"devDependencies": {
"@types/node": "^18.14.0",
"@types/react": "^18.0.26",
"@types/react-dom": "^18.0.9",
"@types/react-linkify": "^1.0.1",
"@vitejs/plugin-react": "^3.0.0",
"autoprefixer": "^10.4.13",
"postcss": "^8.4.21",
"tailwindcss": "^3.2.4",
"typescript": "^4.9.3",
"vite": "^4.0.0",
"vite-plugin-pwa": "^0.14.1"
"engines": {
"node": ">=16.8"
}
}

5253
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

BIN
public/maskable_icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.8 KiB

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -1,17 +0,0 @@
import { useLoaderData } from "react-router-dom";
import Join from "@/routes/Join";
import Home from "@/routes/Home";
import { WaitlistItem } from "./types";
function App() {
const data = useLoaderData() as WaitlistItem | null;
return (
<>
{data?.approval_date ? <Home /> : <Join />}
</>
)
}
export default App;

View File

Before

Width:  |  Height:  |  Size: 709 B

After

Width:  |  Height:  |  Size: 709 B

View File

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

View File

Before

Width:  |  Height:  |  Size: 526 B

After

Width:  |  Height:  |  Size: 526 B

View File

Before

Width:  |  Height:  |  Size: 557 B

After

Width:  |  Height:  |  Size: 557 B

View File

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

109
src/components/App.tsx Normal file
View File

@@ -0,0 +1,109 @@
import { createSignal, For } from "solid-js";
import { A } from "solid-start";
import { Motion, Presence } from "@motionone/solid";
import logo from '~/assets/icons/mutiny-logo.svg';
import mutiny_m from '~/assets/icons/m.svg';
import scan from '~/assets/icons/scan.svg';
import settings from '~/assets/icons/settings.svg';
import send from '~/assets/icons/send.svg';
// TODO: use this reload prompt for real
// import ReloadPrompt from "./Reload";
function ActivityItem() {
return (
<div class="flex flex-row border-b border-gray-500 gap-4 py-2">
<img src={send} class="App-logo" alt="logo" />
<div class='flex flex-col flex-1'>
<h1>Bitcoin Beefsteak</h1>
<h2>-1,441,851 SAT</h2>
<h3 class='text-sm text-gray-500'>Jul 24</h3>
</div>
<div class='text-sm font-semibold uppercase text-[#E23A5E]'>SEND</div>
</div>
)
}
export default function App() {
const [_isOpen, setOpen] = createSignal(false);
return (
<div class="safe-top safe-left safe-right safe-bottom">
<div class="disable-scrollbars max-h-screen h-full overflow-y-scroll">
<main class='flex flex-col gap-4 py-8 px-4'>
<header>
<img src={logo} class="App-logo" alt="logo" />
</header>
{/* <ReloadPrompt /> */}
<Presence>
<Motion
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.5, easing: [0.87, 0, 0.13, 1] }}
>
<div class='border border-white rounded-xl border-b-4 p-4 flex flex-col gap-2'>
<header class='text-sm font-semibold uppercase'>
Balance
</header>
<div>
<h1 class='text-4xl font-light'>
69,420 <span class='text-xl'>SAT</span>
</h1>
</div>
<div class="flex gap-2 py-4">
<button onClick={() => setOpen(true)} class='bg-[#1EA67F] p-4 flex-1 rounded-xl text-xl font-semibold '><span class="drop-shadow-sm shadow-black">Send</span></button>
<button class='bg-[#3B6CCC] p-4 flex-1 rounded-xl text-xl font-semibold '><span class="drop-shadow-sm shadow-black">Receive</span></button>
</div>
</div>
</Motion>
</Presence>
<div class='rounded-xl p-4 flex flex-col gap-2 bg-[rgba(0,0,0,0.5)]'>
<header class='text-sm font-semibold uppercase'>
Activity
</header>
<For each={[1, 2, 3, 4, 5, 6, 7, 8, 9, 10]}>
{() =>
<Presence>
<Motion
initial={{ opacity: 0, scaleY: 0 }}
animate={{ opacity: 1, scaleY: 1 }}
exit={{ opacity: 0, scaleY: 0 }}
transition={{ duration: 0.3 }}
>
<ActivityItem />
</Motion>
</Presence>
}
</For>
<div class='flex justify-end py-4'>
<a href="#" class='underline text-sm'>
MORE
</a>
</div>
</div>
{/* safety div */}
<div class="h-32" />
</main>
<nav class='bg-black fixed bottom-0 shadow-lg z-40 w-full safe-bottom'>
<ul class='h-16 flex justify-between px-16 items-center'>
<li class='h-full border-t-2 border-b-2 border-b-black flex flex-col justify-center'>
<img src={mutiny_m} alt="home" />
</li>
<li>
<A href="/scanner">
<img src={scan} alt="scan" />
</A>
</li>
<li>
<img src={settings} alt="settings" />
</li>
</ul>
</nav>
</div>
</div >
);
}

46
src/components/Button.tsx Normal file
View File

@@ -0,0 +1,46 @@
import { cva, VariantProps } from "class-variance-authority";
import { children, JSX, ParentComponent, splitProps } from "solid-js";
const button = cva(["p-4", "rounded-xl", "text-xl", "font-semibold"], {
variants: {
intent: {
active: "bg-white text-black",
inactive: "bg-black text-white border border-white",
blue: "bg-[#3B6CCC] text-white",
red: "bg-[#F61D5B] text-white"
},
layout: {
flex: "flex-1",
pad: "px-8"
},
},
defaultVariants: {
intent: "inactive",
layout: "flex"
},
});
// Help from https://github.com/arpadgabor/credee/blob/main/packages/www/src/components/ui/button.tsx
type StyleProps = VariantProps<typeof button>
interface ButtonProps extends JSX.ButtonHTMLAttributes<HTMLButtonElement>, StyleProps { }
export const Button: ParentComponent<ButtonProps> = props => {
const slot = children(() => props.children)
const [local, attrs] = splitProps(props, ['children', 'intent', 'layout', 'class'])
return (
<button
{...attrs}
class={button({
class: local.class || "",
intent: local.intent,
layout: local.layout,
})}
>
{slot()}
</button>
)
}

View File

@@ -0,0 +1,20 @@
.increment {
font-family: inherit;
font-size: inherit;
padding: 1em 2em;
color: #335d92;
background-color: rgba(68, 107, 158, 0.1);
border-radius: 2em;
border: 2px solid rgba(68, 107, 158, 0);
outline: none;
width: 200px;
font-variant-numeric: tabular-nums;
}
.increment:focus {
border: 2px solid #335d92;
}
.increment:active {
background-color: rgba(68, 107, 158, 0.2);
}

View File

@@ -0,0 +1,12 @@
import { createSignal } from "solid-js";
import { Button } from "./Button";
import "./Counter.css";
export default function Counter() {
const [count, setCount] = createSignal(0);
return (
<Button onClick={() => setCount(count() + 1)}>
Clicks: {count()}
</Button>
);
}

View File

@@ -1,9 +0,0 @@
import { Outlet } from "react-router-dom";
export default function Layout() {
return (<div className="safe-top safe-left safe-right safe-bottom">
<div className="disable-scrollbars max-h-screen h-full overflow-y-scroll">
<Outlet />
</div>
</div >)
}

View File

@@ -0,0 +1,35 @@
import { JSX } from 'solid-js';
interface LinkifyProps {
text: string;
}
// chat gpt wrote this lol
export default function Linkify(props: LinkifyProps): JSX.Element {
const { text } = props;
const links: (string | JSX.Element)[] = [];
const pattern = /((https?:\/\/|www\.)\S+)/gi;
let lastIndex = 0;
let match;
while ((match = pattern.exec(text)) !== null) {
const link = match[1];
const href = link.startsWith('http') ? link : `https://${link}`;
const beforeLink = text.slice(lastIndex, match.index);
lastIndex = pattern.lastIndex;
if (beforeLink) {
links.push(beforeLink);
}
links.push(<a href={href} target="_blank" rel="noopener noreferrer">{link}</a>);
}
const remainingText = text.slice(lastIndex);
if (remainingText) {
links.push(remainingText);
}
return <>{links}</>;
}

View File

@@ -0,0 +1,9 @@
export default function LoadingSpinner() {
return (<div role="status" class="w-full h-full grid" >
<svg aria-hidden="true" class="w-8 h-8 mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-m-red place-self-center" viewBox="0 0 100 101" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z" fill="currentColor" />
<path d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z" fill="currentFill" />
</svg>
<span class="sr-only">Loading...</span>
</div>);
}

View File

@@ -1,56 +0,0 @@
import { useNostrEvents } from "nostr-react";
import { nip19 } from 'nostr-tools'
import Linkify from 'react-linkify';
type NostrEvent = {
"content": string, "created_at": number, id?: string
}
function Note({ e }: { e: NostrEvent }) {
const date = new Date(e.created_at * 1000);
const linkRoot = "https://snort.social/e/";
let noteId;
if (e.id) {
noteId = nip19.noteEncode(e.id)
}
return (
<div className="flex gap-4 border-b border-faint-white py-6 items-start w-full">
<img className="bg-black rounded-xl flex-0" src="../180.png" width={45} height={45} />
<div className="flex flex-col gap-2 flex-1">
{/* <p>{JSON.stringify(e, null, 2)}</p> */}
<p className="break-words">
<Linkify>
{e.content ?? e.content}
</Linkify>
</p>
<a className="no-underline hover:underline hover:decoration-light-text" href={`${linkRoot}${noteId}`}>
<small className="text-light-text">{date.toLocaleString()}</small>
</a>
</div>
</div>
)
}
export default function Notes() {
const { events } = useNostrEvents({
filter: {
authors: [
"df173277182f3155d37b330211ba1de4a81500c02d195e964f91be774ec96708"
],
since: 0,
kinds: [1],
},
});
return (
<ul className="flex flex-col">
{events.filter((event) => !event.tags.length).map((event) => (
<li className="w-full" key={event.id}><Note e={event as NostrEvent} /></li>
))}
</ul>
)
}

View File

@@ -1,36 +1,38 @@
import QrScanner from 'qr-scanner';
import { useEffect, useRef } from "react";
import { createSignal, onCleanup, onMount } from 'solid-js';
export default function Scanner({ onResult }: { onResult: (result: string) => void }) {
const container = useRef<HTMLVideoElement | null>(null);
let container: HTMLVideoElement | null;
useEffect(() => {
let scanner: QrScanner | null;
if (container.current) {
scanner = new QrScanner(
container.current,
(result) => {
// TODO: not sure it's appropriate to use a signal for this but it works!
const [scanner, setScanner] = createSignal<QrScanner | null>(null);
onMount(() => {
if (container) {
const newScanner = new QrScanner(
container,
(result: { data: string }) => {
onResult(result.data);
},
{
returnDetailedScanResult: true,
}
);
scanner.start();
newScanner.start();
setScanner(newScanner)
}
});
return () => {
scanner?.destroy();
scanner = null;
}
}, [onResult]);
onCleanup(() => {
scanner()?.destroy();
setScanner(null);
container = null;
});
return (
<>
<div id="video-container">
<video ref={container} className="w-full h-full fixed object-cover bg-gray"></video>
<video ref={el => container = el} class="w-full h-full fixed object-cover bg-gray"></video>
</div>
</>
);

49
src/components/Reload.tsx Normal file
View File

@@ -0,0 +1,49 @@
import type { Component } from 'solid-js'
import { Show } from 'solid-js'
import { useRegisterSW } from 'virtual:pwa-register/solid'
const ReloadPrompt: Component = () => {
const {
offlineReady: [offlineReady, setOfflineReady],
needRefresh: [needRefresh, setNeedRefresh],
updateServiceWorker,
} = useRegisterSW({
onRegistered(r: ServiceWorkerRegistration) {
console.log('SW Registered: ' + r.scope)
},
onRegisterError(error: Error) {
console.log('SW registration error', error)
},
})
const close = () => {
setOfflineReady(false)
setNeedRefresh(false)
}
// TODO: for now we're just going to have it be invisible
return (<></>)
// return (
// <div>
// <Show when={offlineReady() || needRefresh()}>
// <div>
// <div>
// <Show
// fallback={<span>New content available, click on reload button to update.</span>}
// when={offlineReady()}
// >
// <span>App ready to work offline</span>
// </Show>
// </div>
// <Show when={needRefresh()}>
// <button onClick={() => updateServiceWorker(true)}>Reload</button>
// </Show>
// <button onClick={() => close()}>Close</button>
// </div>
// </Show>
// </div>
// )
}
export default ReloadPrompt

View File

@@ -1,29 +0,0 @@
import Notes from "./Notes";
import { NostrProvider } from "nostr-react";
const relayUrls = [
"wss://nostr.zebedee.cloud",
"wss://relay.snort.social",
"wss://nos.lol",
"wss://brb.io",
"wss://nostr.fmt.wiz.biz",
"wss://relay.damus.io",
"wss://eden.nostr.land"
]
export function WaitlistAlreadyIn() {
return (
<main className='flex flex-col gap-2 sm:gap-4 py-8 px-4 max-w-xl mx-auto items-center drop-shadow-blue-glow'>
<h1 className="text-4xl font-bold">You're on a list!</h1>
<h2 className="text-xl">
We'll message you when Mutiny Wallet is ready.
</h2>
<div className="px-4 sm:px-8 py-8 rounded-xl bg-half-black">
<h2 className="text-sm font-semibold uppercase">Recent Updates</h2>
<NostrProvider relayUrls={relayUrls} debug={true}>
<Notes />
</NostrProvider>
</div>
</main>
);
}

View File

@@ -1,123 +0,0 @@
import { useState } from "react";
import button from "@/styles/button";
const INPUT = "w-full mb-4 p-2 rounded-lg text-black"
const WAITLIST_ENDPOINT = "https://waitlist.mutiny-waitlist.workers.dev/waitlist";
// const WAITLIST_ENDPOINT = "http://localhost:8787/waitlist";
export default function WaitlistForm() {
let [nostr, setNostr] = useState(true);
let [error, setError] = useState<string | undefined>(undefined);
let [loading, setLoading] = useState(false);
// Form submission function that takes the form data and sends it to the backend
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setError(undefined);
setLoading(true);
const form = e.currentTarget;
const data = new FormData(form);
const value = Object.fromEntries(data.entries());
console.log(value);
let payload: null | { user_type: string, id: string, comment: string } = null;
if (nostr) {
payload = {
user_type: "nostr",
id: value.pubkey as string,
comment: value.comments as string
}
} else {
payload = {
user_type: "email",
id: value.email as string,
comment: value.comments as string
}
};
console.log(payload);
try {
if (!payload || !payload.id) {
throw new Error("nope");
}
let res = await fetch(WAITLIST_ENDPOINT, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(payload)
})
if (res.status !== 200) {
throw new Error("nope");
} else {
// On success set the id in local storage and reload the page
localStorage.setItem('waitlist_id', payload!.id);
window.location.reload();
}
} catch (e) {
if (nostr) {
setError("Something went wrong. Are you sure that's a valid npub?");
} else {
setError("Something went wrong. Are you sure that's a valid email?");
}
return
}
}
return (
<main className='flex flex-col gap-4 py-8 px-4 max-w-xl mx-auto drop-shadow-blue-glow'>
<h1 className='text-4xl font-bold'>Join Waitlist</h1>
{/* HTML form with three inputs: nostr pubkey (text), email (text), and a textarea for comments */}
<h2 className="text-xl">
Sign up for our waitlist and we'll send a message when Mutiny Wallet is ready for you.
</h2>
<div className="p-8 rounded-xl bg-half-black">
<div className="flex gap-4 mb-6">
<button className={button({ intent: nostr ? "active" : "inactive" })} onClick={() => setNostr(true)}><span className="drop-shadow-sm shadow-black">Nostr</span></button>
<button className={button({ intent: nostr ? "inactive" : "active" })} onClick={() => setNostr(false)}><span className="drop-shadow-sm shadow-black">Email</span></button>
</div>
{error &&
<div className="mb-6">
<p className="text-m-red">Error: {error}</p>
</div>
}
<form className="flex flex-col items-start gap-2" onSubmit={handleSubmit}>
{nostr &&
<>
<label className="font-semibold" htmlFor="pubkey">Nostr npub or NIP-05</label>
<input className={INPUT} type="text" id="pubkey" name="pubkey" placeholder="npub..." />
</>
}
{
!nostr &&
<>
<label className="font-semibold" htmlFor="email">Email</label>
<input className={INPUT} type="text" id="email" name="email" placeholder="email@mutinywallet.com" />
</>
}
<label className="font-semibold" htmlFor="comments">Comments</label>
<textarea className={INPUT} id="comments" name="comments" rows={4} placeholder="I want a lightning wallet that does..." />
{loading &&
<div role="status">
<svg aria-hidden="true" className="w-8 h-8 mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-m-red" viewBox="0 0 100 101" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z" fill="currentColor" />
<path d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z" fill="currentFill" />
</svg>
<span className="sr-only">Loading...</span>
</div>}
{!loading &&
<button className={button({ intent: "red", layout: "pad" })}><span className="drop-shadow-sm shadow-black">Submit</span></button>
}
</form>
</div>
</main>
)
}

View File

@@ -0,0 +1,48 @@
import { Component, For } from "solid-js";
import { Event, nip19 } from "nostr-tools"
import Linkify from "../Linkify";
type NostrEvent = {
"content": string, "created_at": number, id?: string
}
const Note: Component<{ e: NostrEvent }> = (props) => {
const e = props.e;
const date = new Date(e.created_at * 1000);
const linkRoot = "https://snort.social/e/";
let noteId;
if (e.id) {
noteId = nip19.noteEncode(e.id)
}
return (
<div class="flex gap-4 border-b border-faint-white py-6 items-start w-full">
<img class="bg-black rounded-xl flex-0" src="../180.png" width={45} height={45} />
<div class="flex flex-col gap-2 flex-1">
<p class="break-words">
<Linkify text={e.content} />
</p>
<a class="no-underline hover:underline hover:decoration-light-text" href={`${linkRoot}${noteId}`}>
<small class="text-light-text">{date.toLocaleString()}</small>
</a>
</div>
</div>
)
}
const Notes: Component<{ notes: Event[] }> = (props) => {
return (<ul class="flex flex-col">
<For each={props.notes.filter((event) => !event.tags.length).sort((a, b) => b.created_at - a.created_at)}>
{(item) =>
<li class="w-full"><Note e={item as NostrEvent} /></li>
}
</For>
</ul>)
}
export default Notes

View File

@@ -0,0 +1,50 @@
import { createResource, Show } from "solid-js";
const relayUrls = [
"wss://nostr.zebedee.cloud",
"wss://relay.snort.social",
"wss://nos.lol",
"wss://nostr.fmt.wiz.biz",
"wss://relay.damus.io",
"wss://eden.nostr.land"
]
import { SimplePool } from 'nostr-tools'
import LoadingSpinner from "~/components/LoadingSpinner";
import Notes from "~/components/waitlist/Notes";
const pool = new SimplePool()
const postsFetcher = async () => {
const filter = {
authors: [
"df173277182f3155d37b330211ba1de4a81500c02d195e964f91be774ec96708"
],
since: 0,
kinds: [1]
};
const events = await pool.list(relayUrls, [filter])
return events;
}
export function WaitlistAlreadyIn() {
const [posts] = createResource("", postsFetcher);
return (
<main class='flex flex-col gap-2 sm:gap-4 py-8 px-4 max-w-xl mx-auto items-center drop-shadow-blue-glow'>
<h1 class="text-4xl font-bold">You're on a list!</h1>
<h2 class="text-xl">
We'll message you when Mutiny Wallet is ready.
</h2>
<div class="px-4 sm:px-8 py-8 rounded-xl bg-half-black">
<h2 class="text-sm font-semibold uppercase">Recent Updates</h2>
<Show when={!posts.loading} fallback={<div class="h-[10rem]"><LoadingSpinner /></div>}>
<Notes notes={posts() && posts() || []} />
</Show>
</div>
</main>
);
}

View File

@@ -0,0 +1,117 @@
import { createSignal } from "solid-js";
import { Button } from "~/components/Button";
import LoadingSpinner from "../LoadingSpinner";
const INPUT = "w-full mb-4 p-2 rounded-lg text-black"
const WAITLIST_ENDPOINT = "https://waitlist.mutiny-waitlist.workers.dev/waitlist";
export default function WaitlistForm() {
const [nostr, setNostr] = createSignal(true);
const [error, setError] = createSignal<string | undefined>(undefined);
const [loading, setLoading] = createSignal(false);
// Form submission function that takes the form data and sends it to the backend
const handleSubmit = async (e: Event) => {
e.preventDefault();
setError(undefined);
setLoading(true);
const form = e.currentTarget;
const data = new FormData(form as HTMLFormElement);
const value = Object.fromEntries(data.entries());
console.log(value);
let payload: null | { user_type: string, id: string, comment: string } = null;
if (nostr()) {
payload = {
user_type: "nostr",
id: value.pubkey as string,
comment: value.comments as string
}
} else {
payload = {
user_type: "email",
id: value.email as string,
comment: value.comments as string
}
}
console.log(payload);
try {
if (!payload || !payload.id) {
throw new Error("nope");
}
const res = await fetch(WAITLIST_ENDPOINT, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(payload)
})
if (res.status !== 200) {
throw new Error("nope");
} else {
// On success set the id in local storage and reload the page
localStorage.setItem('waitlist_id', payload.id);
window.location.reload();
}
} catch (e) {
if (nostr()) {
setError("Something went wrong. Are you sure that's a valid npub?");
} else {
setError("Something went wrong. Are you sure that's a valid email?");
}
setTimeout(() => setLoading(false), 1000);
return
}
}
return (
<main class='flex flex-col gap-4 py-8 px-4 max-w-xl mx-auto drop-shadow-blue-glow'>
<h1 class='text-4xl font-bold'>Join Waitlist</h1>
{/* HTML form with three inputs: nostr pubkey (text), email (text), and a textarea for comments */}
<h2 class="text-xl">
Sign up for our waitlist and we'll send a message when Mutiny Wallet is ready for you.
</h2>
<div class="p-8 rounded-xl bg-half-black">
<div class="flex gap-4 mb-6">
<Button intent={nostr() ? "active" : "inactive"} onClick={() => setNostr(true)}>Nostr</Button>
<Button intent={nostr() ? "inactive" : "active"} onClick={() => setNostr(false)}> Email</Button>
</div>
{error() &&
<div class="mb-6">
<p class="text-m-red">Error: {error()}</p>
</div>
}
<form class="flex flex-col items-start gap-2" onSubmit={handleSubmit}>
{nostr() &&
<>
<label class="font-semibold" for="pubkey">Nostr npub or NIP-05</label>
<input class={INPUT} type="text" id="pubkey" name="pubkey" placeholder="npub..." />
</>
}
{
!nostr() &&
<>
<label class="font-semibold" for="email">Email</label>
<input class={INPUT} type="text" id="email" name="email" placeholder="email@mutinywallet.com" />
</>
}
<label class="font-semibold" for="comments">Comments</label>
<textarea class={INPUT} id="comments" name="comments" rows={4} placeholder="I want a lightning wallet that does..." />
{loading() &&
<LoadingSpinner />
}
{!loading() &&
<Button intent="red" layout="pad" >Submit</Button>
}
</form>
</div>
</main>
)
}

1
src/decs.d.ts vendored
View File

@@ -1 +0,0 @@
declare module "react-linkify"

3
src/entry-client.tsx Normal file
View File

@@ -0,0 +1,3 @@
import { mount, StartClient } from "solid-start/entry-client";
mount(() => <StartClient />, document);

9
src/entry-server.tsx Normal file
View File

@@ -0,0 +1,9 @@
import {
createHandler,
renderAsync,
StartServer,
} from "solid-start/entry-server";
export default createHandler(
renderAsync((event) => <StartServer event={event} />)
);

View File

@@ -1,31 +0,0 @@
import ReactDOM from 'react-dom/client'
import { createBrowserRouter, RouterProvider } from 'react-router-dom'
import App from '@/App'
import Scanner from '@/routes/Scanner';
import './index.css'
import { fetchApprovedStatus } from './utils/fetchApprovedLoader';
import Layout from './components/Layout';
let waitlist_id = localStorage.getItem('waitlist_id')
const router = createBrowserRouter([
{
path: "/",
element: <Layout />,
children: [
{
loader: async () => { return fetchApprovedStatus(waitlist_id || "") },
index: true,
element: <App />,
},
{
path: "scanner",
element: <Scanner />,
},
],
},
]);
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
<RouterProvider router={router} />
)

46
src/root.tsx Normal file
View File

@@ -0,0 +1,46 @@
// @refresh reload
import { Suspense } from "solid-js";
import {
Body,
ErrorBoundary,
FileRoutes,
Head,
Html,
Link,
Meta,
Routes,
Scripts,
Title,
} from "solid-start";
import "./root.css";
export default function Root() {
return (
<Html lang="en">
<Head>
<Title>Mutiny Wallet</Title>
<Meta charset="utf-8" />
<Meta
name="viewport"
content="width=device-width, initial-scale=1.0 height=device-height viewport-fit=cover user-scalable=no"
/>
<Link rel="manifest" href="/manifest.webmanifest" />
<Meta name="theme-color" content="#000000" />
<Meta name="description" content="Lightning wallet for the web" />
<Link rel="icon" href="/favicon.ico" />
<Link rel="apple-touch-icon" href="/180.png" sizes="180x180" />
<Link rel="mask-icon" href="/mutiny_logo_mask.svg" color="#000" />
</Head>
<Body>
<Suspense>
<ErrorBoundary>
<Routes>
<FileRoutes />
</Routes>
</ErrorBoundary>
</Suspense>
<Scripts />
</Body>
</Html>
);
}

View File

@@ -1,112 +0,0 @@
import Sheet from 'react-modal-sheet';
import { useState } from 'react';
import logo from '@/assets/mutiny-logo.svg';
import mutiny_m from '@/assets/m.svg';
import scan from '@/assets/scan.svg';
import settings from '@/assets/settings.svg';
import send from '@/assets/send.svg';
import { Link } from 'react-router-dom';
function ActivityItem() {
return (
<div className="flex flex-row border-b border-gray-500 gap-4 py-2">
<img src={send} className="App-logo" alt="logo" />
<div className='flex flex-col flex-1'>
<h1>Bitcoin Beefsteak</h1>
<h2>-1,441,851 SAT</h2>
<h3 className='text-sm text-gray-500'>Jul 24</h3>
</div>
<div className='text-sm font-semibold uppercase text-[#E23A5E]'>SEND</div>
</div>
)
}
function App() {
const [isOpen, setOpen] = useState(false);
return (
<>
<main className='flex flex-col gap-4 py-8 px-4'>
<header>
<img src={logo} className="App-logo" alt="logo" />
</header>
<div className='border border-white rounded-xl border-b-4 p-4 flex flex-col gap-2'>
<header className='text-sm font-semibold uppercase'>
Balance
</header>
<div>
<h1 className='text-4xl font-light'>
69,420 <span className='text-xl'>SAT</span>
</h1>
</div>
<div className="flex gap-2 py-4">
<button onClick={() => setOpen(true)} className='bg-[#1EA67F] p-4 flex-1 rounded-xl text-xl font-semibold '><span className="drop-shadow-sm shadow-black">Send</span></button>
<button className='bg-[#3B6CCC] p-4 flex-1 rounded-xl text-xl font-semibold '><span className="drop-shadow-sm shadow-black">Receive</span></button>
</div>
</div>
<div className='rounded-xl p-4 flex flex-col gap-2 bg-[rgba(0,0,0,0.5)]'>
<header className='text-sm font-semibold uppercase'>
Activity
</header>
<ActivityItem />
<ActivityItem />
<ActivityItem />
<ActivityItem />
<ActivityItem />
<ActivityItem />
<ActivityItem />
<div className='flex justify-end py-4'>
<a href="#" className='underline text-sm'>
MORE
</a>
</div>
</div>
{/* safety div */}
<div className="h-32" />
</main>
<Sheet isOpen={isOpen} onClose={() => setOpen(false)}>
<Sheet.Container>
<Sheet.Header />
<Sheet.Content>
<div className='p-4 flex flex-col gap-2'>
<header className='text-sm font-semibold uppercase'>
Activity
</header>
<ActivityItem />
<h1 className='text-4xl font-light'>
It's a sheet! Like a modal, but a sheet.
</h1>
</div>
</Sheet.Content>
</Sheet.Container>
<Sheet.Backdrop />
</Sheet>
<nav className='bg-black fixed bottom-0 shadow-lg z-40 w-full safe-bottom'>
<ul className='h-16 flex justify-between px-16 items-center'>
<li className='h-full border-t-2 border-b-2 border-b-black flex flex-col justify-center'>
<img src={mutiny_m} alt="home" />
</li>
<li>
<Link to="/scanner">
<img src={scan} alt="scan" />
</Link>
</li>
<li>
<img src={settings} alt="settings" />
</li>
</ul>
</nav>
</>
)
}
export default App

View File

@@ -1,31 +0,0 @@
import { useEffect, useState } from "react";
import WaitlistForm from "@/components/WaitlistForm";
import { WaitlistAlreadyIn } from "@/components/WaitlistAlreadyIn";
export default function Join() {
// On load, check if the user is already on the waitlist
const [waitlisted, setWaitlisted] = useState(false);
const [waitlistId] = useState(localStorage.getItem('waitlist_id') || "");
const [loading, setLoading] = useState(true);
// Fetch the waitlist status from the backend
useEffect(() => {
if (waitlistId) {
fetch(`https://waitlist.mutiny-waitlist.workers.dev/waitlist/${waitlistId}`).then(res => {
if (res.status === 200) {
setWaitlisted(true);
}
setLoading(false);
})
} else {
setLoading(false);
}
}, [waitlistId]);
return (
<>
{loading ? null : waitlisted ? <WaitlistAlreadyIn /> : <WaitlistForm />}
</>
)
}

View File

@@ -1,47 +1,45 @@
import { useNavigate } from "react-router-dom";
import button from "@/styles/button";
import { useState } from "react";
import Reader from "@/components/Reader";
import Reader from "~/components/Reader";
import { createSignal, Show } from "solid-js";
import { useNavigate } from "solid-start";
import { Button } from "~/components/Button";
export default function Scanner() {
const [scanResult, setScanResult] = createSignal<string | null>(null);
const navigate = useNavigate();
const [scanResult, setScanResult] = useState<string | null>(null);
function onResult(result: string) {
setScanResult(result);
}
function exit() {
navigate("/")
navigate("/", { replace: true })
}
return (
<>
{scanResult ?
<div className="w-full p-8">
<div className="mt-[20vw] rounded-xl p-4 flex flex-col gap-2 bg-[rgba(0,0,0,0.5)]">
<header className='text-sm font-semibold uppercase'>
<Show when={scanResult()} fallback={<Reader onResult={onResult} />}>
<div class="w-full p-8">
<div class="mt-[20vw] rounded-xl p-4 flex flex-col gap-2 bg-[rgba(0,0,0,0.5)]">
<header class='text-sm font-semibold uppercase'>
Scan Result
</header>
<code className="break-all">{scanResult}</code>
<code class="break-all">{scanResult()}</code>
</div>
</div> : <Reader onResult={onResult} />
}
<div className="w-full flex flex-col fixed bottom-[2rem] gap-8 px-8">
{!scanResult &&
<>
<button className={button({ intent: "blue" })} onClick={exit}>Paste Something</button>
<button className={button()} onClick={exit}>Cancel</button>
</>
}
{scanResult &&
<>
<button className={button({ intent: "red" })} onClick={() => setScanResult(null)}>Try Again</button>
<button className={button()} onClick={exit}>Cancel</button>
</>
}
</div>
</Show>
<div class="w-full flex flex-col fixed bottom-[2rem] gap-8 px-8">
<Show when={scanResult()}
fallback={
<>
<Button intent="blue" onClick={exit}>Paste Something</Button>
<Button onClick={exit}>Cancel</Button>
</>
}>
<Button intent="red" onClick={() => setScanResult(null)}>Try Again</Button>
<Button onClick={exit}>Cancel</Button>
</Show>
</div>
</>
);

19
src/routes/[...404].tsx Normal file
View File

@@ -0,0 +1,19 @@
import { Title } from "solid-start";
import { HttpStatusCode } from "solid-start/server";
export default function NotFound() {
return (
<main>
<Title>Not Found</Title>
<HttpStatusCode code={404} />
<h1>Page Not Found</h1>
<p>
Visit{" "}
<a href="https://start.solidjs.com" target="_blank">
start.solidjs.com
</a>{" "}
to learn how to build SolidStart apps.
</p>
</main>
);
}

49
src/routes/index.tsx Normal file
View File

@@ -0,0 +1,49 @@
import App from "~/components/App";
import { Accessor, createEffect, createResource, Setter, createSignal, Switch, Match } from "solid-js";
import { WaitlistAlreadyIn } from "~/components/waitlist/WaitlistAlreadyIn";
import WaitlistForm from "~/components/waitlist/WaitlistForm";
import ReloadPrompt from "~/components/Reload";
function createWaitListSignal(): [Accessor<string>, Setter<string>] {
const [state, setState] = createSignal("");
const originalState = localStorage.getItem("waitlist_id")
if (originalState) {
setState(localStorage.getItem("waitlist_id") || "");
}
createEffect(() => localStorage.setItem("waitlist_id", state()));
return [state, setState];
}
async function fetchData(source: string) {
if (source) {
const data = await fetch(`https://waitlist.mutiny-waitlist.workers.dev/waitlist/${source}`);
return data.json();
} else {
return null
}
}
export default function Home() {
// On load, check if the user is already on the waitlist
const [waitlistId] = createWaitListSignal();
const [waitlistData] = createResource(waitlistId, fetchData);
return (
<>
<ReloadPrompt />
<Switch fallback={<>Loading...</>} >
<Match when={waitlistData() && waitlistData().approval_date}>
<App />
</Match>
<Match when={waitlistData() && waitlistData().date}>
<WaitlistAlreadyIn />
</Match>
<Match when={!waitlistData.loading && !waitlistData()}>
<WaitlistForm />
</Match>
</Switch>
</>
);
}

10
src/routes/routes.tsx Normal file
View File

@@ -0,0 +1,10 @@
import { Outlet } from "solid-start";
export default function UsersLayout() {
return (
<div>
<h1>Users</h1>
<Outlet />
</div>
);
}

View File

@@ -1,23 +0,0 @@
import { cva } from "class-variance-authority";
const button = cva(["p-4", "rounded-xl", "text-xl", "font-semibold"], {
variants: {
intent: {
active: "bg-white text-black",
inactive: "bg-black text-white border border-white",
blue: "bg-[#3B6CCC] text-white",
red: "bg-[#F61D5B] text-white"
},
layout: {
flex: "flex-1",
pad: "px-8"
},
},
defaultVariants: {
intent: "inactive",
layout: "flex"
},
});
export default button

View File

@@ -1,7 +0,0 @@
export type WaitlistItem = {
user_type: "nostr" | "email"
id: string
comment: string
date: string
approval_date: string
}

View File

@@ -1,14 +0,0 @@
import { WaitlistItem } from "@/types";
export async function fetchApprovedStatus(waitlistId: string): Promise<WaitlistItem | null> {
// Fetch the waitlist status from the backend
// Will error if it doesn't exist so we just return undefined
try {
let res = await fetch(`https://waitlist.mutiny-waitlist.workers.dev/waitlist/${waitlistId}`)
let data = await res.json();
return data
} catch (e) {
console.error(e)
return null
}
}

1
src/vite-env.d.ts vendored
View File

@@ -1 +0,0 @@
/// <reference types="vite/client" />

View File

@@ -1,25 +1,17 @@
{
"compilerOptions": {
"target": "ESNext",
"useDefineForClassFields": true,
"lib": ["DOM", "DOM.Iterable", "ESNext"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": false,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"esModuleInterop": true,
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "Node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"types": ["node"],
"moduleResolution": "node",
"jsxImportSource": "solid-js",
"jsx": "preserve",
"strict": true,
"types": ["solid-start/env"],
"baseUrl": "./",
"paths": {
"@/*": ["./src/*"]
"~/*": ["./src/*"]
}
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}
}

View File

@@ -1,37 +1,47 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import { VitePWA } from 'vite-plugin-pwa'
import solid from "solid-start/vite";
import { defineConfig } from "vite";
import { VitePWA, VitePWAOptions } from 'vite-plugin-pwa'
import * as path from 'path'
// https://vitejs.dev/config/
export default defineConfig({
const pwaOptions: Partial<VitePWAOptions> = {
registerType: "autoUpdate",
devOptions: {
enabled: true
},
includeAssets: ['favicon.ico', 'robots.txt'],
manifest: {
name: 'Mutiny Wallet',
short_name: 'Mutiny',
description: 'A lightning wallet',
theme_color: '#000',
icons: [
{
src: '192.png',
sizes: '192x192',
type: 'image/png'
},
{
src: '512.png',
sizes: '512x512',
type: 'image/png'
},
{
src: 'maskable_icon.png',
sizes: '512x512',
type: 'image/png',
purpose: 'any maskable'
}
]
},
}
plugins: [react(), VitePWA({
includeAssets: ['favicon.ico', 'apple-touch-icon.png', 'masked-icon.svg'],
manifest: {
name: 'Mutiny Wallet',
short_name: 'Mutiny',
description: 'A lightning wallet',
theme_color: '#000',
icons: [
{
src: '192.png',
sizes: '192x192',
type: 'image/png'
},
{
src: '512.png',
sizes: '512x512',
type: 'image/png'
}
]
},
registerType: 'autoUpdate', devOptions: {
enabled: true
}
})],
export default defineConfig({
server: {
port: 3420,
},
plugins: [solid({ ssr: false }), VitePWA(pwaOptions)],
resolve: {
alias: [{ find: '@', replacement: path.resolve(__dirname, './src') }]
alias: [{ find: '~', replacement: path.resolve(__dirname, './src') }]
}
})
});