mirror of
https://github.com/aljazceru/mutiny-web.git
synced 2026-02-01 12:34:36 +01:00
activity tab and contact viewer
This commit is contained in:
10
src/assets/icons/user-clock.svg
Normal file
10
src/assets/icons/user-clock.svg
Normal file
@@ -0,0 +1,10 @@
|
||||
<svg width="36" height="36" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#a)">
|
||||
<path d="M15.945 21.15a10.498 10.498 0 1 1 19.11 8.7A10.47 10.47 0 0 1 25.5 36c-4.05 0-7.755-2.34-9.495-6H1.5v-3c.09-1.71 1.26-3.105 3.51-4.23 2.25-1.125 5.07-1.71 8.49-1.77.855 0 1.665.075 2.445.15ZM13.5 6c1.68.045 3.09.63 4.215 1.755s1.68 2.535 1.68 4.245-.555 3.12-1.68 4.245-2.535 1.68-4.215 1.68c-1.68 0-3.09-.555-4.215-1.68S7.605 13.71 7.605 12s.555-3.12 1.68-4.245S11.82 6.045 13.5 6Zm12 27a7.5 7.5 0 1 0 0-15 7.5 7.5 0 0 0 0 15ZM24 21h2.25v4.23l3.66 2.115-1.125 1.95L24 26.535V21Z" fill="#fff"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="a">
|
||||
<path fill="#fff" d="M0 0h36v36H0z"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 725 B |
3
src/assets/icons/user.svg
Normal file
3
src/assets/icons/user.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg width="36" height="36" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M18 6a6 6 0 1 1 0 12 6 6 0 0 1 0-12Zm0 15c6.63 0 12 2.685 12 6v3H6v-3c0-3.315 5.37-6 12-6Z" fill="#fff"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 200 B |
@@ -16,7 +16,7 @@ type RadioGroupProps = {
|
||||
};
|
||||
type Color = "blue" | "green" | "red" | "gray"
|
||||
|
||||
const colorVariants = {
|
||||
export const colorVariants = {
|
||||
blue: "bg-m-blue",
|
||||
green: "bg-m-green",
|
||||
red: "bg-m-red",
|
||||
|
||||
@@ -1,56 +1,47 @@
|
||||
import { For, Show, createEffect, createMemo, createSignal } from 'solid-js';
|
||||
import { Button, LargeHeader, VStack } from '~/components/layout';
|
||||
import { useMegaStore } from '~/state/megaStore';
|
||||
import { satsToUsd } from '~/utils/conversions';
|
||||
import { Amount } from './Amount';
|
||||
import { Dialog, RadioGroup } from '@kobalte/core';
|
||||
import { Match, Switch, createSignal, createUniqueId } from 'solid-js';
|
||||
import { SmallHeader } from '~/components/layout';
|
||||
import { Dialog } from '@kobalte/core';
|
||||
import close from "~/assets/icons/close.svg";
|
||||
import { SubmitHandler, createForm, required, reset, setValue } from '@modular-forms/solid';
|
||||
import { TextField } from './layout/TextField';
|
||||
import { ColorRadioGroup } from './ColorRadioGroup';
|
||||
import { SubmitHandler } from '@modular-forms/solid';
|
||||
import { ContactItem } from '~/state/contacts';
|
||||
import { ContactForm } from './ContactForm';
|
||||
|
||||
type Color = "blue" | "green" | "red" | "gray"
|
||||
const INITIAL: ContactItem = { id: createUniqueId(), kind: "contact", name: "", color: "gray" }
|
||||
|
||||
type Contact = {
|
||||
name: string;
|
||||
npub?: string;
|
||||
isExchange: boolean;
|
||||
color: string;
|
||||
}
|
||||
|
||||
// const colorOptions = ["blue", "green", "red", "gray"]
|
||||
|
||||
const colorOptions = [{ label: "blue", value: "blue" }, { label: "green", value: "green" }, { label: "red", value: "red" }, { label: "gray", value: "gray" }]
|
||||
|
||||
const INITIAL: Contact = { name: "", isExchange: false, color: "gray" }
|
||||
|
||||
export function ContactEditor(props: { createContact: (name: string) => void }) {
|
||||
const [isOpen, setIsOpen] = createSignal(true);
|
||||
|
||||
const [contactForm, { Form, Field }] = createForm<Contact>({ initialValues: INITIAL });
|
||||
export function ContactEditor(props: { createContact: (contact: ContactItem) => void, list?: boolean }) {
|
||||
const [isOpen, setIsOpen] = createSignal(false);
|
||||
|
||||
// What we're all here for in the first place: returning a value
|
||||
const handleSubmit: SubmitHandler<Contact> = (c: Contact) => {
|
||||
// e.preventDefault()
|
||||
const handleSubmit: SubmitHandler<ContactItem> = (c: ContactItem) => {
|
||||
// TODO: why do the id and color disappear?
|
||||
|
||||
props.createContact(c.name)
|
||||
const odd = { id: createUniqueId(), kind: "contact" }
|
||||
|
||||
props.createContact({ ...odd, ...c })
|
||||
|
||||
setIsOpen(false);
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
// When isOpen changes we reset the form
|
||||
if (isOpen()) {
|
||||
reset(contactForm, { initialValues: INITIAL })
|
||||
}
|
||||
})
|
||||
|
||||
const DIALOG_POSITIONER = "fixed inset-0 safe-top safe-bottom z-50"
|
||||
const DIALOG_CONTENT = "h-full safe-bottom flex flex-col justify-between p-4 backdrop-blur-xl bg-neutral-800/70"
|
||||
|
||||
return (
|
||||
<Dialog.Root isOpen={isOpen()}>
|
||||
<button onClick={() => setIsOpen(true)} class="border border-l-white/50 border-r-white/50 border-t-white/75 border-b-white/25 bg-black px-1 py-[0.5] rounded cursor-pointer hover:outline-white hover:outline-1">+ Add Contact</button>
|
||||
<Switch>
|
||||
<Match when={props.list}>
|
||||
<button onClick={() => setIsOpen(true)} class="flex flex-col items-center gap-2">
|
||||
<div class="bg-neutral-500 flex-none h-16 w-16 rounded-full flex items-center justify-center text-4xl uppercase ">
|
||||
<span class="leading-[4rem]">+</span>
|
||||
</div>
|
||||
<SmallHeader class="overflow-ellipsis">
|
||||
new
|
||||
</SmallHeader>
|
||||
</button>
|
||||
</Match>
|
||||
<Match when={!props.list}>
|
||||
<button onClick={() => setIsOpen(true)} class="border border-l-white/50 border-r-white/50 border-t-white/75 border-b-white/25 bg-black px-1 py-[0.5] rounded cursor-pointer hover:outline-white hover:outline-1">+ Add Contact</button>
|
||||
</Match>
|
||||
</Switch>
|
||||
<Dialog.Portal>
|
||||
<div class={DIALOG_POSITIONER}>
|
||||
<Dialog.Content class={DIALOG_CONTENT} onEscapeKeyDown={() => setIsOpen(false)}>
|
||||
@@ -59,32 +50,7 @@ export function ContactEditor(props: { createContact: (name: string) => void })
|
||||
<img src={close} alt="Close" />
|
||||
</button>
|
||||
</div>
|
||||
<Form onSubmit={handleSubmit} class="flex flex-col flex-1 justify-around gap-4 max-w-[400px] mx-auto w-full">
|
||||
<div>
|
||||
|
||||
<LargeHeader>Create Contact</LargeHeader>
|
||||
<VStack>
|
||||
<Field name="name" validate={[required("We at least need a name")]}>
|
||||
{(field, props) => (
|
||||
<TextField {...props} placeholder='Satoshi' value={field.value} error={field.error} label="Name" />
|
||||
)}
|
||||
</Field>
|
||||
<Field name="npub" validate={[]}>
|
||||
{(field, props) => (
|
||||
<TextField {...props} placeholder='npub...' value={field.value} error={field.error} label="Nostr npub or NIP-05 (optional)" />
|
||||
)}
|
||||
</Field>
|
||||
<Field name="color">
|
||||
{(field, props) => (
|
||||
<ColorRadioGroup options={colorOptions} {...props} value={field.value} error={field.error} label="Color" />
|
||||
)}
|
||||
</Field>
|
||||
</VStack>
|
||||
</div>
|
||||
<Button type="submit" intent="blue" class="w-full flex-none">
|
||||
Create Contact
|
||||
</Button>
|
||||
</Form>
|
||||
<ContactForm title="New contact" cta="Create contact" handleSubmit={handleSubmit} initialValues={INITIAL} />
|
||||
</Dialog.Content>
|
||||
</div>
|
||||
</Dialog.Portal>
|
||||
|
||||
39
src/components/ContactForm.tsx
Normal file
39
src/components/ContactForm.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import { SubmitHandler, createForm, required } from "@modular-forms/solid";
|
||||
import { ContactItem } from "~/state/contacts";
|
||||
import { Button, LargeHeader, VStack } from "~/components/layout";
|
||||
import { TextField } from "~/components/layout/TextField";
|
||||
import { ColorRadioGroup } from "~/components/ColorRadioGroup";
|
||||
|
||||
const colorOptions = [{ label: "blue", value: "blue" }, { label: "green", value: "green" }, { label: "red", value: "red" }, { label: "gray", value: "gray" }]
|
||||
|
||||
export function ContactForm(props: { handleSubmit: SubmitHandler<ContactItem>, initialValues?: ContactItem, title: string, cta: string }) {
|
||||
const [_contactForm, { Form, Field }] = createForm<ContactItem>({ initialValues: props.initialValues });
|
||||
|
||||
return (
|
||||
<Form onSubmit={props.handleSubmit} class="flex flex-col flex-1 justify-around gap-4 max-w-[400px] mx-auto w-full">
|
||||
<div>
|
||||
<LargeHeader>{props.title}</LargeHeader>
|
||||
<VStack>
|
||||
<Field name="name" validate={[required("We at least need a name")]}>
|
||||
{(field, props) => (
|
||||
<TextField {...props} placeholder='Satoshi' value={field.value} error={field.error} label="Name" />
|
||||
)}
|
||||
</Field>
|
||||
<Field name="npub" validate={[]}>
|
||||
{(field, props) => (
|
||||
<TextField {...props} placeholder='npub...' value={field.value} error={field.error} label="Nostr npub or NIP-05 (optional)" />
|
||||
)}
|
||||
</Field>
|
||||
<Field name="color">
|
||||
{(field, props) => (
|
||||
<ColorRadioGroup options={colorOptions} {...props} value={field.value} error={field.error} label="Color" />
|
||||
)}
|
||||
</Field>
|
||||
</VStack>
|
||||
</div>
|
||||
<Button type="submit" intent="blue" class="w-full flex-none">
|
||||
{props.cta}
|
||||
</Button>
|
||||
</Form>
|
||||
)
|
||||
}
|
||||
71
src/components/ContactViewer.tsx
Normal file
71
src/components/ContactViewer.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import { Match, Switch, createSignal } from 'solid-js';
|
||||
import { Button, Card, NiceP, SmallHeader } from '~/components/layout';
|
||||
import { Dialog } from '@kobalte/core';
|
||||
import close from "~/assets/icons/close.svg";
|
||||
import { SubmitHandler } from '@modular-forms/solid';
|
||||
import { ContactItem } from '~/state/contacts';
|
||||
import { ContactForm } from './ContactForm';
|
||||
|
||||
export function ContactViewer(props: { contact: ContactItem, gradient: string, saveContact: (contact: ContactItem) => void }) {
|
||||
const [isOpen, setIsOpen] = createSignal(false);
|
||||
const [isEditing, setIsEditing] = createSignal(false);
|
||||
|
||||
const handleSubmit: SubmitHandler<ContactItem> = (c: ContactItem) => {
|
||||
props.saveContact({ ...props.contact, ...c })
|
||||
setIsEditing(false)
|
||||
}
|
||||
|
||||
const DIALOG_POSITIONER = "fixed inset-0 safe-top safe-bottom z-50"
|
||||
const DIALOG_CONTENT = "h-full safe-bottom flex flex-col justify-between p-4 backdrop-blur-xl bg-neutral-800/70"
|
||||
|
||||
return (
|
||||
<Dialog.Root isOpen={isOpen()}>
|
||||
<button onClick={() => setIsOpen(true)} class="flex flex-col items-center gap-2 w-16 flex-shrink-0 overflow-x-hidden">
|
||||
<div class="flex-none h-16 w-16 rounded-full flex items-center justify-center text-4xl uppercase border-t border-b border-t-white/50 border-b-white/10"
|
||||
style={{ background: props.gradient }}
|
||||
>
|
||||
{props.contact.name[0]}
|
||||
</div>
|
||||
<SmallHeader class="overflow-ellipsis w-16 text-center overflow-hidden h-4">
|
||||
{props.contact.name}
|
||||
</SmallHeader>
|
||||
</button>
|
||||
<Dialog.Portal>
|
||||
<div class={DIALOG_POSITIONER}>
|
||||
<Dialog.Content class={DIALOG_CONTENT} onEscapeKeyDown={() => setIsOpen(false)}>
|
||||
<div class="w-full flex justify-end">
|
||||
<button tabindex="-1" onClick={() => setIsOpen(false)} class="hover:bg-white/10 rounded-lg active:bg-m-blue">
|
||||
<img src={close} alt="Close" />
|
||||
</button>
|
||||
</div>
|
||||
<Switch>
|
||||
<Match when={isEditing()}>
|
||||
<ContactForm title="Edit contact" cta="Save contact" handleSubmit={handleSubmit} initialValues={props.contact} />
|
||||
</Match>
|
||||
<Match when={!isEditing()}>
|
||||
<div class="flex flex-col flex-1 justify-around items-center gap-4 max-w-[400px] mx-auto w-full">
|
||||
<div class="flex flex-col items-center w-full">
|
||||
<div class="flex-none h-32 w-32 rounded-full flex items-center justify-center text-8xl uppercase border-t border-b border-t-white/50 border-b-white/10"
|
||||
style={{ background: props.gradient }}
|
||||
>
|
||||
{props.contact.name[0]}
|
||||
</div>
|
||||
<h1 class="text-2xl font-semibold uppercase mt-2 mb-4">{props.contact.name}</h1>
|
||||
<Card title="Payment history">
|
||||
<NiceP>No payments yet with <span class="font-semibold">{props.contact.name}</span></NiceP>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div class="flex w-full gap-2">
|
||||
<Button layout="flex" intent="green" onClick={() => setIsEditing(true)}>Edit</Button>
|
||||
<Button intent="blue" onClick={() => { }}>Pay</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Match>
|
||||
</Switch>
|
||||
</Dialog.Content>
|
||||
</div>
|
||||
</Dialog.Portal>
|
||||
</Dialog.Root >
|
||||
);
|
||||
}
|
||||
@@ -2,10 +2,11 @@ import mutiny_m from '~/assets/icons/m.svg';
|
||||
import airplane from '~/assets/icons/airplane.svg';
|
||||
import settings from '~/assets/icons/settings.svg';
|
||||
import receive from '~/assets/icons/big-receive.svg';
|
||||
import userClock from '~/assets/icons/user-clock.svg';
|
||||
|
||||
import { A } from "solid-start";
|
||||
|
||||
type ActiveTab = 'home' | 'scan' | 'send' | 'receive' | 'settings' | 'none';
|
||||
type ActiveTab = 'home' | 'scan' | 'send' | 'receive' | 'settings' | 'activity' | 'none';
|
||||
|
||||
export default function NavBar(props: { activeTab: ActiveTab }) {
|
||||
const activeStyle = 'border-t-0 border-b-0 p-2 bg-black rounded-lg'
|
||||
@@ -28,6 +29,11 @@ export default function NavBar(props: { activeTab: ActiveTab }) {
|
||||
<img src={receive} alt="receive" />
|
||||
</A>
|
||||
</li>
|
||||
<li class={props.activeTab === "activity" ? activeStyle : inactiveStyle}>
|
||||
<A href="/activity">
|
||||
<img src={userClock} alt="activity" />
|
||||
</A>
|
||||
</li>
|
||||
<li class={props.activeTab === "settings" ? activeStyle : inactiveStyle}>
|
||||
<A href="/settings">
|
||||
<img src={settings} alt="settings" />
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { Select, createOptions } from "@thisbeyond/solid-select";
|
||||
import "~/styles/solid-select.css"
|
||||
import { SmallHeader } from "./layout";
|
||||
import { For } from "solid-js";
|
||||
import { For, createUniqueId } from "solid-js";
|
||||
import { ContactEditor } from "./ContactEditor";
|
||||
import { ContactItem, TagItem, TextItem, addContact } from "~/state/contacts";
|
||||
|
||||
// take two arrays, subtract the second from the first, then return the first
|
||||
function subtract<T>(a: T[], b: T[]) {
|
||||
@@ -10,20 +11,12 @@ function subtract<T>(a: T[], b: T[]) {
|
||||
return a.filter(x => !set.has(x));
|
||||
};
|
||||
|
||||
// simple math.random based id generator
|
||||
const createUniqueId = () => Math.random().toString(36).substr(2, 9);
|
||||
|
||||
export type TagItem = {
|
||||
id: string;
|
||||
name: string;
|
||||
kind: "text" | "contact";
|
||||
}
|
||||
|
||||
const createValue = (name: string) => {
|
||||
const createValue = (name: string): TextItem => {
|
||||
return { id: createUniqueId(), name, kind: "text" };
|
||||
};
|
||||
|
||||
export function TagEditor(props: { title: string, values: TagItem[], setValues: (values: TagItem[]) => void, selectedValues: TagItem[], setSelectedValues: (values: TagItem[]) => void }) {
|
||||
console.log(props.values);
|
||||
const onChange = (selected: TagItem[]) => {
|
||||
props.setSelectedValues(selected);
|
||||
|
||||
@@ -42,8 +35,8 @@ export function TagEditor(props: { title: string, values: TagItem[], setValues:
|
||||
createable: createValue,
|
||||
});
|
||||
|
||||
const newContact = (name: string) => {
|
||||
const contact: TagItem = { id: createUniqueId(), name, kind: "contact" };
|
||||
const newContact = async (contact: ContactItem) => {
|
||||
await addContact(contact)
|
||||
onChange([...props.selectedValues, contact])
|
||||
}
|
||||
|
||||
@@ -59,8 +52,13 @@ export function TagEditor(props: { title: string, values: TagItem[], setValues:
|
||||
/>
|
||||
<div class="flex gap-2 flex-wrap">
|
||||
<For each={subtract(props.values, props.selectedValues).slice(0, 3)}>
|
||||
{(contact) => (
|
||||
<div onClick={() => onChange([...props.selectedValues, contact])} class="border border-l-white/50 border-r-white/50 border-t-white/75 border-b-white/25 bg-m-blue px-1 py-[0.5] rounded cursor-pointer hover:outline-white hover:outline-1">{contact.name}</div>
|
||||
{(tag) => (
|
||||
<div onClick={() => onChange([...props.selectedValues, tag])}
|
||||
class="border border-l-white/50 border-r-white/50 border-t-white/75 border-b-white/25 px-1 py-[0.5] rounded cursor-pointer hover:outline-white hover:outline-1"
|
||||
classList={{ "bg-black": tag.kind === "text", "bg-m-blue": tag.kind === "contact" && tag.color === "blue", "bg-m-green": tag.kind === "contact" && tag.color === "green", "bg-m-red": tag.kind === "contact" && tag.color === "red", "bg-[#898989]": tag.kind === "contact" && tag.color === "gray" }}
|
||||
>
|
||||
{tag.name}
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
{/* <button class="border border-l-white/50 border-r-white/50 border-t-white/75 border-b-white/25 bg-black px-1 py-[0.5] rounded cursor-pointer hover:outline-white hover:outline-1">+ Add Contact</button> */}
|
||||
|
||||
@@ -16,7 +16,7 @@ export const SmallHeader: ParentComponent<{ class?: string }> = (props) => {
|
||||
|
||||
export const Card: ParentComponent<{ title?: string, titleElement?: JSX.Element }> = (props) => {
|
||||
return (
|
||||
<div class='rounded-xl p-4 flex flex-col gap-2 bg-neutral-950 overflow-x-hidden'>
|
||||
<div class='rounded-xl p-4 flex flex-col gap-2 bg-neutral-950 overflow-x-hidden w-full'>
|
||||
{props.title && <SmallHeader>{props.title}</SmallHeader>}
|
||||
{props.titleElement && props.titleElement}
|
||||
{props.children}
|
||||
@@ -94,8 +94,15 @@ export const LoadingSpinner = (props: { big?: boolean, wide?: boolean }) => {
|
||||
|
||||
export const Hr = () => <Separator.Root class="my-4 border-white/20" />
|
||||
|
||||
export const LargeHeader: ParentComponent = (props) => {
|
||||
return (<h1 class="text-4xl font-semibold uppercase border-b-2 border-b-white mt-2 mb-4">{props.children}</h1>)
|
||||
export const LargeHeader: ParentComponent<{ action?: JSX.Element }> = (props) => {
|
||||
return (
|
||||
<header class="w-full flex justify-between items-center mt-4 mb-2">
|
||||
<h1 class="text-4xl font-semibold">{props.children}</h1>
|
||||
<Show when={props.action}>
|
||||
{props.action}
|
||||
</Show>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
|
||||
export const VStack: ParentComponent<{ biggap?: boolean }> = (props) => {
|
||||
@@ -106,8 +113,8 @@ export const HStack: ParentComponent<{ biggap?: boolean }> = (props) => {
|
||||
return (<div class={`flex gap-${props.biggap ? "8" : "4"}`}>{props.children}</div>)
|
||||
}
|
||||
|
||||
export const SmallAmount: ParentComponent<{ amount: number | bigint }> = (props) => {
|
||||
return (<h2 class="font-light text-lg">{props.amount.toLocaleString()} <span class="text-sm">SATS</span></h2>)
|
||||
export const SmallAmount: ParentComponent<{ amount: number | bigint, sign?: string }> = (props) => {
|
||||
return (<h2 class="font-light text-lg">{props.sign ? `${props.sign} ` : ""}{props.amount.toLocaleString()} <span class="text-sm">SATS</span></h2>)
|
||||
}
|
||||
|
||||
export const NiceP: ParentComponent = (props) => {
|
||||
|
||||
@@ -1,108 +1,77 @@
|
||||
import { For, ParentComponent, createMemo, createSignal } from "solid-js";
|
||||
import { CENTER_COLUMN, MISSING_LABEL, OnChainTx, RIGHT_COLUMN, THREE_COLUMNS } from "~/components/Activity";
|
||||
import { DeleteEverything } from "~/components/DeleteEverything";
|
||||
import { JsonModal } from "~/components/JsonModal";
|
||||
import KitchenSink from "~/components/KitchenSink";
|
||||
import { For, Show, createResource } from "solid-js";
|
||||
import NavBar from "~/components/NavBar";
|
||||
import { Card, DefaultMain, Hr, LargeHeader, NodeManagerGuard, SafeArea, SmallAmount, SmallHeader, VStack } from "~/components/layout";
|
||||
import { BackButton } from "~/components/layout/BackButton";
|
||||
import { StyledRadioGroup } from "~/components/layout/Radio";
|
||||
import mempoolTxUrl from "~/utils/mempoolTxUrl";
|
||||
import send from '~/assets/icons/send.svg';
|
||||
import receive from '~/assets/icons/receive.svg';
|
||||
import { prettyPrintTime } from "~/utils/prettyPrintTime";
|
||||
|
||||
const NAMES = ["alice", "bob", "carol", "dave", "ethan", "frank", "graham", "hancock"]
|
||||
import { Button, Card, DefaultMain, LargeHeader, NiceP, NodeManagerGuard, SafeArea, SmallHeader, VStack } from "~/components/layout";
|
||||
import { BackLink } from "~/components/layout/BackLink";
|
||||
import { CombinedActivity, Activity as MutinyActivity } from "~/components/Activity";
|
||||
import { A } from "solid-start";
|
||||
import settings from '~/assets/icons/settings.svg';
|
||||
import { ContactItem, addContact, editContact, listContacts } from "~/state/contacts";
|
||||
import { Tabs } from "@kobalte/core";
|
||||
import { gradientsPerContact } from "~/utils/gradientHash";
|
||||
import { ContactEditor } from "~/components/ContactEditor";
|
||||
import { ContactViewer } from "~/components/ContactViewer";
|
||||
|
||||
function ContactRow() {
|
||||
const [contacts, { refetch }] = createResource(listContacts)
|
||||
const [gradients] = createResource(contacts, gradientsPerContact);
|
||||
|
||||
async function createContact(contact: ContactItem) {
|
||||
await addContact(contact)
|
||||
refetch();
|
||||
}
|
||||
|
||||
async function saveContact(contact: ContactItem) {
|
||||
await editContact(contact)
|
||||
refetch();
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="w-full overflow-x-scroll flex gap-2 disable-scrollbars">
|
||||
<div class="flex flex-col items-center">
|
||||
<div class="bg-neutral-500 flex-none h-16 w-16 rounded-full flex items-center justify-center text-4xl uppercase ">
|
||||
+
|
||||
</div>
|
||||
<div class="overflow-ellipsis">
|
||||
new
|
||||
</div>
|
||||
</div>
|
||||
<For each={NAMES}>
|
||||
{(name) => (
|
||||
<div class="flex flex-col items-center">
|
||||
<div class="bg-pink-500 flex-none h-16 w-16 rounded-full flex items-center justify-center text-4xl uppercase ">
|
||||
{name[0]}
|
||||
</div>
|
||||
<div class="overflow-ellipsis">
|
||||
{name}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
<div class="w-full overflow-x-scroll flex gap-4 disable-scrollbars">
|
||||
<ContactEditor list createContact={createContact} />
|
||||
<Show when={contacts() && gradients()}>
|
||||
<For each={contacts()}>
|
||||
{(contact) => (
|
||||
<ContactViewer contact={contact} gradient={gradients()?.get(contact.id)} saveContact={saveContact} />
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const CHOICES = [
|
||||
{ value: "mutiny", label: "Mutiny", caption: "Your wallet activity" },
|
||||
{ value: "nostr", label: "Zaps", caption: "Your friends on nostr" },
|
||||
]
|
||||
|
||||
const SubtleText: ParentComponent = (props) => {
|
||||
return <h3 class='text-xs text-gray-500 uppercase'>{props.children}</h3>
|
||||
}
|
||||
|
||||
function OnChainItem(props: { item: OnChainTx }) {
|
||||
const isReceive = createMemo(() => props.item.received > 0);
|
||||
|
||||
const [open, setOpen] = createSignal(false)
|
||||
|
||||
return (
|
||||
<>
|
||||
<JsonModal open={open()} data={props.item} title="On-Chain Transaction" setOpen={setOpen}>
|
||||
<a href={mempoolTxUrl(props.item.txid, "signet")} target="_blank" rel="noreferrer">
|
||||
Mempool Link
|
||||
</a>
|
||||
</JsonModal>
|
||||
<div class={THREE_COLUMNS} onClick={() => setOpen(!open())}>
|
||||
<div class="flex items-center">
|
||||
{isReceive() ? <img src={receive} alt="receive arrow" /> : <img src={send} alt="send arrow" />}
|
||||
</div>
|
||||
<div class={CENTER_COLUMN}>
|
||||
<h2 class={MISSING_LABEL}>Unknown</h2>
|
||||
{isReceive() ? <SmallAmount amount={props.item.received} /> : <SmallAmount amount={props.item.sent} />}
|
||||
{/* <h2 class="truncate">Txid: {props.item.txid}</h2> */}
|
||||
</div>
|
||||
<div class={RIGHT_COLUMN}>
|
||||
<SmallHeader class={isReceive() ? "text-m-green" : "text-m-red"}>
|
||||
{isReceive() ? "RECEIVE" : "SEND"}
|
||||
</SmallHeader>
|
||||
<SubtleText>{props.item.confirmation_time?.Confirmed ? prettyPrintTime(props.item.confirmation_time?.Confirmed?.time) : "Unconfirmed"}</SubtleText>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
const TAB = "flex-1 inline-block px-8 py-4 text-lg font-semibold rounded-lg ui-selected:bg-white/10 bg-neutral-950 hover:bg-white/10"
|
||||
|
||||
export default function Activity() {
|
||||
const [choice, setChoice] = createSignal(CHOICES[0].value)
|
||||
return (
|
||||
<NodeManagerGuard>
|
||||
<SafeArea>
|
||||
<DefaultMain>
|
||||
<BackButton />
|
||||
<LargeHeader>Activity</LargeHeader>
|
||||
<BackLink />
|
||||
<LargeHeader action={<A class="md:hidden p-2 hover:bg-white/5 rounded-lg active:bg-m-blue" href="/settings"><img src={settings} alt="Settings" /></A>}>Activity</LargeHeader>
|
||||
<ContactRow />
|
||||
<Hr />
|
||||
<StyledRadioGroup choices={CHOICES} value={choice()} onValueChange={setChoice} />
|
||||
<VStack>
|
||||
{/* <Card><p>If you know what you're doing you're in the right place!</p></Card>
|
||||
<KitchenSink />
|
||||
<div class='rounded-xl p-4 flex flex-col gap-2 bg-m-red overflow-x-hidden'>
|
||||
<SmallHeader>Danger zone</SmallHeader>
|
||||
<DeleteEverything />
|
||||
</div> */}
|
||||
</VStack>
|
||||
<Tabs.Root defaultValue="mutiny">
|
||||
<Tabs.List class="relative flex justify-around mt-4 mb-8 gap-1 bg-neutral-950 p-1 rounded-xl">
|
||||
<Tabs.Trigger value="mutiny" class={TAB}>Mutiny</Tabs.Trigger>
|
||||
<Tabs.Trigger value="nostr" class={TAB}>Nostr</Tabs.Trigger>
|
||||
{/* <Tabs.Indicator class="absolute bg-m-blue transition-all bottom-[-1px] h-[2px]" /> */}
|
||||
</Tabs.List>
|
||||
<Tabs.Content value="mutiny">
|
||||
{/* <MutinyActivity /> */}
|
||||
<Card title="Activity">
|
||||
<CombinedActivity />
|
||||
</Card>
|
||||
</Tabs.Content>
|
||||
<Tabs.Content value="nostr">
|
||||
<VStack>
|
||||
<div class="my-8 flex flex-col items-center gap-4 text-center max-w-[20rem] mx-auto">
|
||||
<NiceP>Import your contacts from nostr to see who they're zapping.</NiceP>
|
||||
<Button disabled intent="blue">Coming soon</Button>
|
||||
</div>
|
||||
</VStack>
|
||||
</Tabs.Content>
|
||||
</Tabs.Root>
|
||||
</DefaultMain>
|
||||
<NavBar activeTab="none" />
|
||||
<NavBar activeTab="activity" />
|
||||
</SafeArea>
|
||||
</NodeManagerGuard>
|
||||
)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { MutinyBip21RawMaterials, MutinyInvoice } from "@mutinywallet/mutiny-wasm";
|
||||
import { createEffect, createResource, createSignal, For, Match, onCleanup, Switch } from "solid-js";
|
||||
import { createEffect, createResource, createSignal, For, Match, onCleanup, onMount, Switch } from "solid-js";
|
||||
import { QRCodeSVG } from "solid-qr-code";
|
||||
import { AmountEditable } from "~/components/AmountEditable";
|
||||
import { Button, Card, LargeHeader, NodeManagerGuard, SafeArea, SmallHeader } from "~/components/layout";
|
||||
@@ -11,11 +11,12 @@ import mempoolTxUrl from "~/utils/mempoolTxUrl";
|
||||
import { Amount } from "~/components/Amount";
|
||||
import { FullscreenModal } from "~/components/layout/FullscreenModal";
|
||||
import { BackLink } from "~/components/layout/BackLink";
|
||||
import { TagEditor, TagItem } from "~/components/TagEditor";
|
||||
import { TagEditor } from "~/components/TagEditor";
|
||||
import { StyledRadioGroup } from "~/components/layout/Radio";
|
||||
import { showToast } from "~/components/Toaster";
|
||||
import { useNavigate } from "solid-start";
|
||||
import megacheck from "~/assets/icons/megacheck.png";
|
||||
import { TagItem, listTags } from "~/state/contacts";
|
||||
|
||||
type OnChainTx = {
|
||||
transaction: {
|
||||
@@ -45,9 +46,9 @@ const createUniqueId = () => Math.random().toString(36).substr(2, 9);
|
||||
|
||||
const fakeContacts: TagItem[] = [
|
||||
{ id: createUniqueId(), name: "Unknown", kind: "text" },
|
||||
{ id: createUniqueId(), name: "Alice", kind: "contact" },
|
||||
{ id: createUniqueId(), name: "Bob", kind: "contact" },
|
||||
{ id: createUniqueId(), name: "Carol", kind: "contact" },
|
||||
// { id: createUniqueId(), name: "Alice", kind: "contact" },
|
||||
// { id: createUniqueId(), name: "Bob", kind: "contact" },
|
||||
// { id: createUniqueId(), name: "Carol", kind: "contact" },
|
||||
]
|
||||
|
||||
const RECEIVE_FLAVORS = [{ value: "unified", label: "Unified", caption: "Sender decides" }, { value: "lightning", label: "Lightning", caption: "Fast and cool" }, { value: "onchain", label: "On-chain", caption: "Just like Satoshi did it" }]
|
||||
@@ -90,7 +91,16 @@ export default function Receive() {
|
||||
|
||||
// Tagging stuff
|
||||
const [selectedValues, setSelectedValues] = createSignal<TagItem[]>([]);
|
||||
const [values, setValues] = createSignal([...fakeContacts]);
|
||||
|
||||
// const [tagItems] = createResource(listTags);
|
||||
|
||||
const [values, setValues] = createSignal<TagItem[]>([{ id: createUniqueId(), name: "Unknown", kind: "text" }]);
|
||||
|
||||
onMount(() => {
|
||||
listTags().then((tags) => {
|
||||
setValues(prev => [...prev, ...tags || []])
|
||||
});
|
||||
})
|
||||
|
||||
// The data we get after a payment
|
||||
const [paymentTx, setPaymentTx] = createSignal<OnChainTx>();
|
||||
@@ -216,7 +226,6 @@ export default function Receive() {
|
||||
<dd>
|
||||
<TagEditor title="Tag the origin" values={values()} setValues={setValues} selectedValues={selectedValues()} setSelectedValues={setSelectedValues} />
|
||||
</dd>
|
||||
|
||||
</dl>
|
||||
<Button class="w-full" disabled={!amount() || !selectedValues().length} intent="green" onClick={onSubmit}>Create Invoice</Button>
|
||||
</Match>
|
||||
|
||||
@@ -42,6 +42,13 @@ export async function addContact(contact: ContactItem): Promise<void> {
|
||||
localStorage.setItem("contacts", JSON.stringify(contacts));
|
||||
}
|
||||
|
||||
export async function editContact(contact: ContactItem): Promise<void> {
|
||||
const contacts = await listContacts();
|
||||
const index = contacts.findIndex(c => c.id === contact.id);
|
||||
contacts[index] = contact;
|
||||
localStorage.setItem("contacts", JSON.stringify(contacts));
|
||||
}
|
||||
|
||||
export async function addTextTag(text: TextItem): Promise<void> {
|
||||
const texts = await listTexts();
|
||||
texts.push(text);
|
||||
|
||||
26
src/utils/gradientHash.ts
Normal file
26
src/utils/gradientHash.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { ContactItem } from "~/state/contacts";
|
||||
|
||||
async function generateGradientFromHashedString(str: string) {
|
||||
const encoder = new TextEncoder();
|
||||
const data = encoder.encode(str);
|
||||
const digestBuffer = await crypto.subtle.digest('SHA-256', data);
|
||||
const digestArray = new Uint8Array(digestBuffer);
|
||||
const h1 = digestArray[0] % 360;
|
||||
const h2 = (h1 + 180) % 360;
|
||||
|
||||
const gradient = `linear-gradient(135deg, hsl(${h1}, 50%, 50%) 0%, hsl(${h2}, 50%, 50%) 100%)`;
|
||||
|
||||
return gradient;
|
||||
}
|
||||
|
||||
export async function gradientsPerContact(contacts: ContactItem[]) {
|
||||
//
|
||||
// let gradients: { [key: string]: string } = {};
|
||||
let gradients = new Map();
|
||||
for (const contact of contacts) {
|
||||
const gradient = await generateGradientFromHashedString(contact.name);
|
||||
gradients.set(contact.id, gradient);
|
||||
}
|
||||
|
||||
return gradients;
|
||||
}
|
||||
Reference in New Issue
Block a user