Merge pull request #1 from arthyn/working-search-ui

Working search UI, gossip, posting
This commit is contained in:
Hunter Miller 2022-07-11 10:25:19 -05:00 committed by GitHub
commit a3adca576a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
40 changed files with 7873 additions and 172 deletions

View File

@ -1,7 +1,10 @@
/- s=seek
/+ default-agent, verb, dbug
/+ gossip, default-agent, verb, dbug
/+ *seek
/+ sift
/+ m=metaphone
/$ grab-listing %noun %directory-listing
/$ grab-directory %noun %directory
^- agent:gall
=>
|%
@ -9,6 +12,7 @@
+$ state-0
$: %0
=lookup:s
=phonetics:s
=trail:s
=directory:s
published=directory:s
@ -18,6 +22,13 @@
=* state -
=<
%+ verb &
%- %+ agent:gossip
[2 %mutuals %mutuals]
%- malt
^- (list [mark $-(* vase)])
:~ [%directory-listing |=(n=* !>((grab-listing n)))]
[%directory |=(n=* !>((grab-directory n)))]
==
%- agent:dbug
|_ =bowl:gall
+* this .
@ -88,26 +99,66 @@
|= [=mark =vase]
|^ ^+ cor
?+ mark ~|(bad-poke/mark !!)
%directory-notice
=+ !<(=notice:s vase)
(publish notice)
%declare
=+ !<(=declare:s vase)
di-abet:(publish declare di-core)
:: %declarations
:: =+ !<(declared=(list declare:s) vase)
:: =. di-core (roll declared publish)
:: cor
==
++ publish
|= =notice:s
^+ cor
?> =(src.bowl source.q.notice)
?> =(p.notice (digest q.notice))
di-abet:(di-init:(di-abed:di-core p.notice) q.notice)
|= [=declare:s core=_di-core]
^+ di-core
::
?> from-self
=/ listing
:* post=q.declare
hash=(digest q.declare)
reach=p.declare
source=our.bowl
time=now.bowl
==
?: (~(has by directory) hash.listing)
~& 'Listing already exists.'
di-core
=. published (~(put by directory) hash.listing listing)
(di-publish:(di-abed:core hash.listing) listing)
--
::
++ watch
|= =path
^+ cor
cor
?+ path ~|(bad-watch-path/path !!)
[%~.~ %gossip %source ~]
(give %fact ~ directory+!>(published))
==
++ agent
|= [=wire =sign:agent:gall]
^+ cor
cor
?+ wire ~|(bad-agent-wire/wire !!)
[%~.~ %gossip %gossip ~]
?+ -.sign ~|([%unexpected-gossip-sign -.sign] !!)
%fact
=* mark p.cage.sign
=* vase q.cage.sign
?+ mark
~& [dap.bowl %unexpected-mark-fact mark wire=wire]
cor
%directory-listing
=+ !<(=listing:s vase)
?> =(hash.listing (digest post.listing))
?: (~(has by directory) hash.listing)
~& 'Listing already exists.'
cor
di-abet:(di-publish:(di-abed:di-core hash.listing) listing)
::
%directory
=+ !<(d=directory:s vase)
cor(directory (~(uni by directory) d))
==
==
==
++ arvo
|= [=wire sign=sign-arvo]
^+ cor
@ -117,20 +168,92 @@
|= =path
^- (unit (unit cage))
?+ path [~ ~]
[%x %lookup @ ~]
=- ``listings+!>(-)
^- (list listing:s)
%+ turn
%+ turn
%+ sort (~(gut by lookup) i.t.t.path *(list entry:s))
|= [a=entry:s b=entry:s]
(gte rank.a rank.b)
|=(=entry:s hash.entry)
|= =hash:s
(~(got by directory) hash)
::
[%x %lookup @ @ @ @ ~]
=- ``search+!>(-)
^- search:s
=/ filter
%+ rash i.t.t.path
(perk [%app %group %content %other %all ~])
=/ term `@t`(slav %t i.t.t.t.path)
=/ start (slav %ud i.t.t.t.t.path)
=/ limit (slav %ud i.t.t.t.t.t.path)
:: expects encoded @t values)
=/ all
%+ skim
%- get-listings
%- get-hashes
%- sort-entries
(get-entries term)
|= =listing:s
|(=(filter %all) =(filter type.post.listing))
=/ listings (swag [start limit] all)
:* listings
start
limit
(lent listings)
(lent all)
==
==
::
++ get-entries
|= =key:s
^- (list entry:s)
=/ title (norm:sift key) :: full query
=/ parts (sift:sift key) :: split query
=/ title-entries
%- malt
%+ weld
(get-phonetics title)
(~(gut by lookup) title *(list entry:s))
%~ tap by
%-
%~ uni by :: union and prefer title entries (better rank)
%- malt
%- zing
%+ turn
parts
|= word=@t
%+ turn
%+ weld
(get-phonetics word)
(~(gut by lookup) word *(list entry:s))
|= =entry:s
(derank entry 10)
title-entries
++ get-phonetics
|= =key:s
^- (list entry:s)
=/ keys
%+ skim
%~ tap in
(~(gut by phonetics) (utter:m key) *(set key:s))
|= [word=key:s]
!=(key word)
%+ roll
keys
|= [word=key:s entries=(list entry:s)]
%+ weld
entries
%+ turn
(~(gut by lookup) word *(list entry:s))
|= =entry:s
(derank entry 10)
++ derank
|= [=entry:s offset=@ud]
[hash.entry (add rank.entry offset)] :: phonetically similar should be lower
++ sort-entries
|= entries=(list entry:s)
%+ sort entries
|= [a=entry:s b=entry:s]
(lth rank.a rank.b)
++ get-hashes
|= entries=(list entry:s)
%+ turn
entries
|=(=entry:s hash.entry)
++ get-listings
|= l=(list hash)
%+ turn l
|=(=hash:s (~(got by directory) hash))
++ from-self =(our src):bowl
++ di-core
|_ [=listing:s =hash:s]
@ -140,30 +263,45 @@
++ di-abed
|= h=hash:s
di-core(hash h, listing (~(gut by directory) h *listing:s))
++ di-init
++ di-publish
|= l=listing:s
=. listing l
=. cor (emit (invent:gossip %directory-listing !>(listing)))
=/ entries=lookup:s
%- malt
%- zing
:~ ~[[title.l ~[[hash rank=0]]]]
:~ ~[[(crip (cass (trip title.post.l))) ~[[hash rank=0]]]]
%+ turn
tags.l
|= tag=@t
[tag ~[[hash rank=1]]]
%+ turn
(sift:sift description.l)
(sift:sift title.post.l)
|= word=@t
[word ~[[hash rank=2]]]
[word ~[[hash rank=1]]]
%+ turn
tags.post.l
|= tag=@t
[(norm:sift tag) ~[[hash rank=2]]]
%+ turn
(sift:sift description.post.l)
|= word=@t
[word ~[[hash rank=3]]]
~[[type.post.l ~[[hash rank=4]]]]
==
~& -:!>(entries)
~& -:!>(lookup)
=/ keys ~(tap in ~(key by entries))
=. trail (~(put by trail) hash keys)
=. lookup
%- ~(uni by entries)
%- ~(rut by lookup)
|= [=key:s value=(list entry:s)]
?. (~(has by entries) key) value
(weld value (~(got by entries) key))
=. phonetics
%-
%- ~(uno by phonetics)
%- malt
%+ turn keys
|= =key:s
[(utter:m key) (silt ~[key])]
|= [k=key:s v=(set key:s) w=(set key:s)]
(~(uni in v) w)
di-core
--
--

6
desk/gen/metaphone.hoon Normal file
View File

@ -0,0 +1,6 @@
/+ *metaphone
:- %say
|= $: [now=@da eny=@uv =beak]
[[word=@t ~] ~]
==
noun+(utter word)

29
desk/gen/publish-all.hoon Normal file
View File

@ -0,0 +1,29 @@
/- s=seek
/+ *seek
:- %say
|= $: [now=@da eny=@uv =beak]
[ ~]
==
:- %declarations
^- (list declare:s)
:* :- %friends
['networked subject' %group 'web+urbitgraph://group/~matwet/networked-subject' 'https://subject.network | networked subject' 'https://urbits3.ams3.digitaloceanspaces.com/sitful-hatred/2022.7.07..17.15.52-ns.png' ~['hosting' 'networking' 'urbit' 'ops']]
:- %friends
['Hooniverse' %group 'web+urbitgraph://group/~hiddev-dannut/new-hooniverse' 'Community based Hoon learning for all levels. For discussion of Hoon specific to Uqbar, join ~hiddev-dannut/uhoon' 'https://i.imgur.com/ghThlz7.png' ~['hoon' 'dev' 'urbit' 'programming' 'education']]
:- %friends
['Hollow Mars Theory' %group 'web+urbitgraph://group/~rabsef-bicrym/hollow-mars-theory' 'Definitely NOT a Conspiracy Chat' '' ~['conspiracies' 'ufos' 'steel beams']]
:- %friends
['celestial systems' %group 'web+urbitgraph://group/~nocsyx-lassul/celestial-systems' 'A place for pilots who are building hosting providers' '' ~['hosting' 'ops' 'urbit']]
:- %friends
['Structure' %group 'web+urbitgraph://group/~fabled-faster/structure' 'Urbit Structural Design and Engineering Group. Always Thinking About Mechanics.' 'https://fabled-faster.nyc3.digitaloceanspaces.com/fabled-faster/2022.1.27..17.59.43-image.png' ~['design' 'ux' 'hci' 'urbit']]
:- %friends
['The Marketplace' %group 'web+urbitgraph://group/~tirrel/the-marketplace' 'Welcome to The Marketplace, featuring The Pit, Urbit\'s first open-outcry market!' 'https://snipboard.io/U0IYyi.jpg' ~['urbit' 'market' 'sell' 'buy']]
:- %friends
['The Forge' %group 'web+urbitgraph://group/~middev/the-forge' 'pale fire computing' 'https://nyc3.digitaloceanspaces.com/archiv/littel-wolfur/2021.5.06..21.01.58-the%20forge.png' ~['dev' 'programming' 'hoon' 'urbit']]
:- %friends
['UFORIA' %group 'web+urbitgraph://group/~tiplec-lacnyx/ufora' 'UFO Research, Investigations, and Analysis' 'https://tiplec-lacnyx.nyc3.digitaloceanspaces.com/tiplec-lacnyx/2021.12.27..06.09.47-change1.jpg' ~['UFO' 'conspiracies' 'research']]
:- %friends
['Urbit Community' %group 'web+urbitgraph://group/~bitbet-bolbel/urbit-community' 'World hub, help desk, meet and greet, etc.' 'https://fabled-faster.nyc3.digitaloceanspaces.com/fabled-faster/2021.4.02..21.52.41-UC.png' ~['general' 'urbit' 'community' 'help']]
:- %friends
['Urbytes' %group 'web+urbitgraph://group/~nartes-fasrum/urbytes' 'We feed and water Mars.' '' ~['cooking' 'food' 'beverages' 'peanut butter eggs']]
==

View File

@ -2,9 +2,8 @@
/+ *seek
:- %say
|= $: [now=@da eny=@uv =beak]
[[=listing:s ~] ~]
[[=post:s ~] ~]
==
=. time.listing now
:- %directory-notice
^- notice:s
[(digest listing) listing]
:- %declare
^- declare:s
[%friends post]

601
desk/lib/gossip.hoon Normal file
View File

@ -0,0 +1,601 @@
:: gossip: data sharing with pals
::
:: automates using /app/pals for peer & content discovery,
:: letting the underlying agent focus on handling the data.
::
:: at the time of writing, this library has not seen much real-world
:: use yet. it may be subject to behavioral or interface changes.
:: it tries its best to not generate obscene amounts of network load,
:: but has not yet proven itself in the real world.
:: for the time being, please tread carefully.
::
:: usage
::
:: to use this library, call its +agent arm with an initial
:: configuration and a map of, per mark, noun->vase convertors,
:: then call the resulting gate with the agent's door.
::
:: data from peers will come in through +on-agent, as %facts with
:: a /~/gossip wire.
:: the mark convertors ensure that this library can reconstitute the
:: appropriate vases from the (cask *)s it sends around internally.
:: if a mark conversion for a received datum isn't available, then
:: the library will inject a %fact with a %gossip-unknown mark instead,
:: containing a (cask *). the agent may either store it for later use,
:: or handle it directly.
::
:: for new incoming subscriptions, the underlying agent's +on-watch is
:: called, with /~/gossip/source, so that it may give initial results.
::TODO but this is somewhat wrong for multi-hop subscriptions, right?
:: /source is only intended to give _locally originating_ data.
:: if hops are >1, should we do both /source and /gossip?
:: doesn't this result in a lot of traffic, for know-a-lot cases?
::
:: when data originates locally and needs to be given to our peers,
:: simply produce a normal %fact on the /~/gossip/source path.
:: the +invent helper can be used to do this.
:: refrain from re-emitting received %facts manually. the library will
:: handle this for you, based on the current configuration.
::
:: to change the configuration after the agent has been started, emit
:: a %fact with the %gossip-config mark on the /~/gossip/config path.
:: the +configure helper can be used to do this.
:: the +read-config helper scries out the current configuration.
:: setting hops to 1 distributes the data to direct pals only.
:: setting hops to 0 prevents emission of any gossip data at all,
:: even during initial subscription setup.
::
:: (we introduce the /~/etc path prefix convention to indicate paths
:: that are for library-specific use only.
:: the advantage this has over the "mutated agent" pattern (for example,
:: /lib/shoe) is that the library consumes a normal $agent:gall, making
:: it theoretically easier to compose with other agent libraries.
:: the disavantage, of course, is that internal interaction with the
:: wrapper library isn't type-checked anymore. helper functions make
:: that less of a problem, but the developer must stay vigilant about
:: casting all the relevant outputs.)
::
:: when considering the types of data to be sent through gossip, keep
:: in mind that the library keeps track of (hashes of) all the individual
:: datums it has heard. as such, if your data is of the shape @t, and we
:: hear a 'hello' once, we will completely ignore any 'hello' that come
:: afterward, even if they originate from distinct events.
:: if this is a problem for your use case, consider including a timestamp
:: or other source of uniqueness with each distinct datum.
::
:: note that this library and its protocol are currently not in the
:: business of providing anonymized gossip. any slightly motivated actor
:: will have no problem modifying this library to detect and record
:: sources of data with good accuracy.
::
:: wip, libdev thoughts
::
:: we may want to include additional metadata alongside gossip facts,
:: such as a hop counter, set of informed peers, origin timestamp, etc.
:: we may want to use pokes exclusively, instead of watches/facts,
:: making it easier to exclude the src.bowl, include metadata, etc.
::
:: what if this was a userspace-infrastructure app instead of a wrapper?
:: how would ensuring installation of this app-dependency work?
:: it gains us... a higher chance of peers having this installed.
:: what if it was just part of pals?
:: would let us more-reliably poke mutuals and leeches, if we wanted to do
:: a proxy-broadcast thing.
::
:: when considering adding features like rumor signing, keep in mind that
:: this library is mostly in the business of ferrying casks. perhaps
:: features like signing should be left up to applications themselves.
::
:: internal logic
::
:: - on-init or during first on-load, watch pals for targets & leeches.
:: - if pals is not running, the watch will simply pend until it starts.
:: - if pals ever watch-nacks (it shouldn't), we try rewatching after ~m1.
::
:: - for facts produced on /~/gossip/source, we
:: - wrap them as %gossip-rumor to send them out on /~/gossip/gossip
:: - for new pals matching the hear mode, we watch their /~/gossip/gossip
:: - for gone pals, we leave that watch
:: - for facts on those watches
:: - ensure they're %gossip-rumors, ignoring otherwise
:: - unwrap them and +on-agent /~/gossip/gossip into the inner agent, and
:: - re-emit them as facts on /~/gossip/gossip if there are hops left
:: - for nacks on those watches, we retry after ~m30
::
/- pals
/+ lp=pals, dbug, verb
::
|%
+$ rumor
$: [kind=@ meta=*]
data=(cask *)
==
+$ meta-0 hops=_0
::
+$ whos
$? %anybody :: any ship discoverable through pals
%targets :: any ship we've added as a pal
%mutuals :: any mutual pal
==
::
+$ config
$: hops=_1 :: how far away gossip may travel
:: (1 hop is pals only, 0 stops exposing data at all)
hear=whos :: who to subscribe to
tell=whos :: who to allow subscriptions from
==
++ invent
|= =cage
^- card:agent:gall
[%give %fact [/~/gossip/source]~ cage]
::
++ configure
|= =config
^- card:agent:gall
[%give %fact [/~/gossip/config]~ %gossip-config !>(config)]
::
++ read-config
|= bowl:gall
^- config
.^(config %gx /(scot %p our)/[dap]/(scot %da now)/~/gossip/config/noun)
::
++ agent
|= $: init=config
grab=(map mark $-(* vase))
==
^- $-(agent:gall agent:gall)
|^ agent
::
+$ state-0
$: %0
manner=config :: latest config
memory=(set @uv) :: datums seen
future=(list rumor) :: rumors of unknown kinds
==
::
+$ card card:agent:gall
::
++ helper
|_ [=bowl:gall state-0]
+* state +<+
pals ~(. lp bowl)
++ en-cage
|= =(cask *)
^- cage
?^ to=(~(get by grab) p.cask)
[p.cask (u.to q.cask)]
~& [gossip+dap.bowl %no-mark p.cask]
[%gossip-unknown !>(cask)]
::
++ de-cage
|=(cage `(cask *)`[p q.q])
::
++ en-rumor ::NOTE assumes !=(0 hops.manner)
|= =cage
^- rumor
:_ (de-cage cage)
~| [%en-rumor initial-hops=hops.manner]
[%0 `meta-0`(dec hops.manner)]
::
++ play-card :: en-rumor relevant facts, handle config changes
|= =card
^- (quip ^card _state)
?. ?=([%give %fact *] card) [[card]~ state]
?: =(~ paths.p.card) [[card]~ state]
=/ [int=(list path) ext=(list path)]
%+ skid paths.p.card
|= =path
?=([%~.~ %gossip *] path)
=/ caz=(list ^card)
?: =(~ ext) ~
[card(paths.p ext)]~
?: ?=(~ int) [caz state]
=* path i.int
:: there may only be one gossip-internal path per card
::
?. =(~ t.int)
~& [gossip+dap.bowl %too-many-internal-targets int]
~|([%too-many-internal-targets int] !!)
?: =(/~/gossip/config path)
~| [%weird-fact-on-config p.cage.p.card]
?> =(%gossip-config p.cage.p.card)
=/ old=config manner
=. manner !<(config q.cage.p.card)
:_ state
;: weld
(hear-changed hear.old)
(tell-changed tell.old)
caz
==
~| [%strange-internal-target path]
?> =(/~/gossip/source path)
:: if hops is configured at 0, we don't broadcast at all.
::
=. memory (~(put in memory) (sham (de-cage cage.p.card)))
?: =(0 hops.manner) [caz state]
=- [[- caz] state]
=/ =rumor (en-rumor cage.p.card)
card(paths.p [/~/gossip/gossip]~, cage.p [%gossip-rumor !>(rumor)])
::
++ play-cards
|= cards=(list card)
^- (quip card _state)
=| out=(list card)
|-
?~ cards [out state]
=^ caz state (play-card i.cards)
$(out (weld out caz), cards t.cards)
::
++ play-first-cards
|= cards=(list card)
^- (quip card _state)
=| out=(list card)
|-
?~ cards [out state]
?. ?=([%give %fact ~ *] i.cards)
=^ caz state (play-card i.cards)
$(out (weld out caz), cards t.cards)
:: if hops is set to 0, we block even the initial response
::
?: =(0 hops.manner) $(cards t.cards)
=. cage.p.i.cards
[%gossip-rumor !>((en-rumor cage.p.i.cards))]
$(out (snoc out i.cards), cards t.cards)
::
++ resend-rumor
|= =rumor
^- (unit card)
?> =(%0 kind.rumor) ::NOTE should have been checked for already
?~ meta=((soft ,hops=@ud) meta.rumor) ~
=* hops hops.u.meta
?: =(0 hops) ~
=. meta.rumor (dec hops)
`[%give %fact [/~/gossip/gossip]~ %gossip-rumor !>(rumor)]
::
++ may-watch
|= who=ship
?- tell.manner
%anybody &
%targets (~(has in (targets:pals ~.)) who)
%mutuals (~(has in (mutuals:pals ~.)) who)
==
::
::
++ watch-pals
^- (list card)
=/ =gill:gall [our.bowl %pals]
:~ [%pass /~/gossip/pals/targets %agent gill %watch /targets]
[%pass /~/gossip/pals/leeches %agent gill %watch /leeches]
==
::
++ watching-target
|= s=ship
%- ~(has by wex.bowl)
[/~/gossip/gossip/(scot %p s) s dap.bowl]
::
++ want-target
%~ has in
?- hear.manner
%anybody (~(uni in leeches:pals) (targets:pals ~.))
%targets (targets:pals ~.)
%mutuals (mutuals:pals ~.)
==
::
++ retry-timer
|= [t=@dr p=path]
^- card
:+ %pass [%~.~ %gossip %retry p]
[%arvo %b %wait (add now.bowl t)]
::
++ watch-target
|= s=ship
^- (list card)
?: (watching-target s) ~
:_ ~
:+ %pass /~/gossip/gossip/(scot %p s)
[%agent [s dap.bowl] %watch /~/gossip/gossip]
::
++ leave-target
|= s=ship
^- card
:+ %pass /~/gossip/gossip/(scot %p s)
[%agent [s dap.bowl] %leave ~]
::
++ kick-target
|= s=ship
^- card
[%give %kick [/~/gossip/gossip]~ `s]
::
++ hear-changed
|= old=whos
^- (list card)
=* new hear.manner
?: =(old new) ~
=/ listen=(set ship)
?- new
%anybody (~(uni in leeches:pals) (targets:pals ~.))
%targets (targets:pals ~.)
%mutuals (mutuals:pals ~.)
==
=/ hearing=(set ship)
%- ~(gas in *(set ship))
%+ murn ~(tap by wex.bowl)
|= [[=wire =ship =term] [acked=? =path]]
^- (unit ^ship)
?. ?=([%~.~ %gossip %gossip @ ~] wire) ~
`ship
%+ weld
(turn ~(tap in (~(dif in hearing) listen)) leave-target)
^- (list card)
(zing (turn ~(tap in (~(dif in listen) hearing)) watch-target))
::
++ tell-changed
|= old=whos
^- (list card)
=* new tell.manner
?: =(old new) ~
?- [old new]
$? [* %anybody]
[%mutuals *]
==
:: perms got broader, we can just no-op
::
~
::
[* ?(%targets %mutuals)]
:: perms got tighter, we need to kick stragglers
::
=/ allowed=(set ship)
?- new
%anybody !!
%targets (targets:pals ~.)
%mutuals (mutuals:pals ~.)
==
%+ murn ~(val by sup.bowl)
|= [s=ship p=path]
^- (unit card)
=; kick=?
?.(kick ~ `(kick-target s))
?& ?=([%~.~ %gossip %gossip ~] p)
!(~(has in allowed) s)
==
==
--
::
++ agent
|= inner=agent:gall
=| state-0
=* state -
%+ verb |
%- agent:dbug
^- agent:gall
|_ =bowl:gall
+* this .
pals ~(. lp bowl)
def ~(. (default-agent this %|) bowl)
og ~(. inner bowl)
up ~(. helper bowl state)
++ on-init
^- (quip card _this)
=. manner init
=^ cards inner on-init:og
=^ cards state (play-cards:up cards)
[(weld watch-pals:up cards) this]
::
++ on-save !>([[%gossip state] on-save:og])
++ on-load
|= ole=vase
^- (quip card _this)
?. ?=([[%gossip *] *] q.ole)
=. manner init
=^ cards inner (on-load:og ole)
=^ cards state (play-cards:up cards)
[(weld watch-pals:up cards) this]
::
=+ !<([[%gossip old=state-0] ile=vase] ole)
=. state old
=^ cards inner (on-load:og ile)
=^ cards state (play-cards:up cards)
::TODO for later versions, add :future retry logic as needed
[cards this]
::
++ on-watch
|= =path
^- (quip card _this)
?. ?=([%~.~ %gossip *] path)
=^ cards inner (on-watch:og path)
=^ cards state (play-cards:up cards)
[cards this]
:: /~/gossip/gossip
?> =(/gossip t.t.path)
?. (may-watch:up src.bowl)
~|(%gossip-forbidden !!)
=^ cards inner (on-watch:og /~/gossip/source)
=^ cards state (play-first-cards:up cards)
[cards this]
::
++ on-agent
|= [=wire =sign:agent:gall]
^- (quip card _this)
?. ?=([%~.~ %gossip *] wire)
=^ cards inner (on-agent:og wire sign)
=^ cards state (play-cards:up cards)
[cards this]
::
?+ t.t.wire ~|([%gossip %unexpected-wire wire] !!)
[%gossip @ ~]
~| t.t.wire
?> =(src.bowl (slav %p i.t.t.t.wire))
?- -.sign
%fact
=* mark p.cage.sign
=* vase q.cage.sign
?. =(%gossip-rumor mark)
~& [gossip+dap.bowl %ignoring-unexpected-fact mark=mark]
[~ this]
=+ !<(=rumor vase)
=/ hash (sham data.rumor)
?: (~(has in memory) hash)
[~ this]
?. =(%0 kind.rumor)
~& [gossip+dap.bowl %delaying-unknown-rumor-kind kind.rumor]
[~ this(future [rumor future])]
=/ mage=cage (en-cage:up data.rumor)
=^ cards inner (on-agent:og /~/gossip/gossip sign(cage mage))
=^ cards state (play-cards:up cards)
:_ this(memory (~(put in memory) hash))
(weld (drop (resend-rumor:up rumor)) cards)
::
%watch-ack
:_ this
?~ p.sign ~
:: 30 minutes might cost us some responsiveness when the other
:: party changes their local config, but in return we save both
:: ourselves and others from a lot of needless retries.
:: (notably, "do we still care" check also lives in %wake logic.)
::
[(retry-timer:up ~m30 /watch/(scot %p src.bowl))]~
::
%kick
:_ this
:: to prevent pathological kicks from exploding, we always
:: wait a couple seconds before resubscribing.
:: perhaps this is overly careful, but we cannot tell the
:: difference between "clog" kicks and "missing mark" kicks,
:: so we cannot take more accurate/appropriate action here.
:: (notably, "do we still care" check also lives in %wake logic.)
::
[(retry-timer:up ~s15 /watch/(scot %p src.bowl))]~
::
%poke-ack
~& [gossip+dap.bowl %unexpected-poke-ack wire]
[~ this]
==
::
[%pals @ ~]
?- -.sign
%poke-ack ~&([gossip+dap.bowl %unexpected-poke-ack wire] [~ this])
%kick [watch-pals:up this]
::
%watch-ack
:_ this
?~ p.sign ~
%- (slog 'gossip: failed to subscribe on %pals!!' u.p.sign)
[(retry-timer:up ~m1 t.t.wire)]~
::
%fact
=* mark p.cage.sign
=* vase q.cage.sign
?. =(%pals-effect mark)
~& [gossip+dap.bowl %unexpected-fact-mark mark wire]
[~ this]
=+ !<(=effect:^pals vase)
:_ this
=* ship ship.effect
=* kick [(kick-target:up ship)]~
=* view (watch-target:up ship)
=* flee [(leave-target:up ship)]~
?- -.effect
%meet
?- hear.manner
%anybody view
%targets view
%mutuals ?:((mutual:pals ~. ship) view ~)
==
::
%part
%+ weld
?- tell.manner
%anybody ~
%targets kick
%mutuals kick
==
?- hear.manner
%anybody ?:((leeche:pals ship) ~ flee)
%targets flee
%mutuals flee
==
::
%near
?- hear.manner
%anybody view
%targets ~
%mutuals ?:((mutual:pals ~. ship) view ~)
==
::
%away
%+ weld
?- tell.manner
%anybody ~
%targets ~
%mutuals kick
==
?- hear.manner
%anybody ?:((target:pals ~. ship) ~ flee)
%targets ~
%mutuals flee
==
==
==
==
::
++ on-peek
|= =path
^- (unit (unit cage))
?: =(/x/dbug/state path)
``noun+(slop on-save:og !>(gossip=state))
?. ?=([@ %~.~ %gossip *] path)
(on-peek:og path)
?. ?=(%x i.path) [~ ~]
?+ t.t.t.path [~ ~]
[%config ~] ``noun+!>(manner)
==
::
++ on-leave
|= =path
^- (quip card _this)
?: ?=([%~.~ %gossip *] path)
[~ this]
=^ cards inner (on-leave:og path)
=^ cards state (play-cards:up cards)
[cards this]
::
++ on-poke
|= cage
^- (quip card _this)
::TODO gossip config pokes?
=^ cards inner (on-poke:og +<)
=^ cards state (play-cards:up cards)
[cards this]
::
++ on-arvo
|= [=wire sign=sign-arvo:agent:gall]
^- (quip card _this)
?. ?=([%~.~ %gossip *] wire)
=^ cards inner (on-arvo:og wire sign)
=^ cards state (play-cards:up cards)
[cards this]
?> ?=([%retry *] t.t.wire)
?> ?=(%wake +<.sign)
?+ t.t.t.wire ~|(wire !!)
[%pals *]
::NOTE this might result in subscription wire re-use,
:: but if we hit this path we should be loud anyway.
[watch-pals:up this]
::
[%watch @ ~]
:_ this
=/ target=ship (slav %p i.t.t.t.t.wire)
?. (want-target:up target) ~
(watch-target:up target)
==
::
++ on-fail
|= [term tang]
^- (quip card _this)
=^ cards inner (on-fail:og +<)
=^ cards state (play-cards:up cards)
[cards this]
--
--
--

190
desk/lib/metaphone.hoon Normal file
View File

@ -0,0 +1,190 @@
|%
++ utter
|= corpus=@t
^- @t
=/ norm (cuss (trip corpus))
~(phone core [norm *tape 0])
++ core
|_ [text=tape chars=tape index=@ud]
++ phone
?: =(current 'A')
?: =(next 'E') (jump2 'E')
(jump 'A')
?:
?& =(next 'N')
?| =(current 'G')
=(current 'K')
=(current 'P')
==
==
(jump2 'N')
?: =(current 'W')
?: =(next 'R') (jump2 'R')
?: =(next 'H') (jump2 current)
?: (vowel next) (jump2 'W')
recite
?: =(current 'X') (jump 'S')
?:
?| =(current 'E')
=(current 'I')
=(current 'O')
=(current 'U')
==
(jump current)
recite
++ recite
?: (gte index (lent text))
(crip chars)
:: B -> B unless in MB
?: &(=(current 'B') !=(prev 'M'))
(jump 'B')
:: 'sh' if -CIA- or -CH, but not SCH, except SCHW (SCHW is handled in S)
:: S if -CI-, -CE- or -CY- dropped if -SCI-, SCE-, -SCY- (handed in S)
:: else K
?: =(current 'C')
?: (soft next)
:: C[IEY]
?: &(=(next 'I') =((at 2 &) 'A'))
:: CIA
(jump sh)
?: !=(prev 'S')
(jump 'S')
advance
?: =(next 'H')
(jump2 sh)
(jump 'K')
:: J if in -DGE-, -DGI- or -DGY-, else T
?: =(current 'D')
?: &(=(next 'G') (soft (at 2 &)))
(jump2 'J')
(jump 'T')
:: F if in -GH and not B--GH, D--GH, -H--GH, -H---GH
:: else dropped if -GNED, -GN,
:: else dropped if -DGE-, -DGI- or -DGY- (handled in D)
:: else J if in -GE-, -GI, -GY and not GG
:: else K
?: =(current 'G')
?: =(next 'H')
?: |(!(no-gh-to-f (at 3 |)) =((at 4 |) 'H'))
(jump2 'F')
advance
?: =(next 'N')
?: !&(=((at 2 &) 'E') =((at 3 &) 'D'))
(jump 'K')
advance
?: &((soft next) !=(prev 'G'))
(jump 'J')
(jump 'K')
:: H if before a vowel and not after C,G,P,S,T
?: &(=(current 'H') (vowel next) !(dipthong-h prev))
(jump 'H')
:: Dropped if after C, else K
?: &(=(current 'K') !=(prev 'C'))
(jump 'K')
:: F if before H, else P
?: =(current 'P')
?: =(next 'H')
(jump 'F')
(jump 'P')
:: K
?: =(current 'Q')
(jump 'K')
:: 'sh' in -SH-, -SIO- or -SIA- or -SCHW-, else S
?: =(current 'S')
?: &(=(next 'I') |(=((at 2 &) 'O') =((at 2 &) 'A')))
(jump sh)
?: =(next 'H')
(jump2 sh)
(jump 'S')
:: sh' in -TIA- or -TIO-, else 'th' before H, else T
?: =(current 'T')
?: &(=(next 'I') |(=((at 2 &) 'O') =((at 2 &) 'A')))
(jump sh)
?: =(next 'H')
(jump2 th)
?: !&(=(next 'C') =((at 2 &) 'H'))
(jump 'T')
advance
:: F
?: =(current 'V')
(jump 'F')
?: &(=(current 'W') (vowel next))
(jump 'W')
:: KS
?: =(current 'X')
(jump 'KS')
:: Y if followed by a vowel
?: &(=(current 'Y') (vowel next))
(jump 'Y')
:: S
?: =(current 'Z')
(jump 'S')
?:
?| =(current 'F')
=(current 'J')
=(current 'L')
=(current 'M')
=(current 'N')
=(current 'R')
==
(jump current)
advance
++ advance
=. index +(index)
recite
++ jump
|= char=@t
=. chars (snoc chars char)
=. index +(index)
recite
++ jump2
|= char=@t
=. chars (snoc chars char)
=. index (add index 2)
recite
++ current (snag index text)
++ next
?: (gte index (dec (lent text))) ''
(snag +(index) text)
++ prev
?: (lte index 0) ''
(snag (dec index) text)
++ at
|= [offset=@ud forward=?]
?: forward
?: (gth (add index offset) (dec (lent text))) ''
(snag (add index offset) text)
?: (lth (sub index offset) 0) ''
(snag (sub index offset) text)
--
++ soft
|= char=@t
?| =(char 'E')
=(char 'I')
=(char 'Y')
==
++ vowel
|= char=@t
?| =(char 'A')
=(char 'E')
=(char 'I')
=(char 'O')
=(char 'U')
==
++ dipthong-h
|= char=@t
?| =(char 'C')
=(char 'G')
=(char 'P')
=(char 'S')
=(char 'T')
==
++ no-gh-to-f
|= char=@t
?| =(char 'B')
=(char 'D')
=(char 'H')
==
++ sh 'X'
++ th '0'
--

18
desk/lib/pals.hoon Normal file
View File

@ -0,0 +1,18 @@
:: pals: manual peer discovery
::
|_ bowl:gall
++ leeches (s (set ship) /leeches)
++ targets |= list=@ta (s (set ship) %targets ?~(list / /[list]))
++ mutuals |= list=@ta (s (set ship) %mutuals ?~(list / /[list]))
++ leeche |= =ship (s _| /leeches/(scot %p ship))
++ target |= [list=@ta =ship] (s _| /mutuals/[list]/(scot %p ship))
++ mutual |= [list=@ta =ship] (s _| /mutuals/[list]/(scot %p ship))
::
++ base ~+ /(scot %p our)/pals/(scot %da now)
++ running ~+ .^(? %gu base)
::
++ s
|* [=mold =path] ~+
?. running *mold
.^(mold %gx (weld base (snoc `^path`path %noun)))
--

View File

@ -3,24 +3,54 @@
++ enjs
=, enjs:format
|%
++ listings
|= l=(list listing:s)
a+(turn l listing)
++ post
|= p=post:s
%- pairs
:~ title/s/title.p
type/s/type.p
link/s/link.p
description/s/description.p
image/s/image.p
tags/a/(turn tags.p (lead %s))
==
++ listing
|= l=listing:s
%- pairs
:~ title/s/title.l
link/s/link.l
description/s/description.l
image/s/image.l
:~ post/(post post.l)
hash/s/(scot %uv hash.l)
source/s/(scot %p source.l)
time/(time time.l)
tags/a/(turn tags.l (lead %s))
==
++ search
|= =search:s
%- pairs
:~ listings/a/(turn listings.search listing)
start/n/(scot %ud start.search)
limit/n/(scot %ud limit.search)
size/n/(scot %ud size.search)
total/n/(scot %ud total.search)
==
--
++ dejs
=, dejs:format
|%
++ declarations (ar declare)
++ declare
^- $-(json declare:s)
%- ot
:~ reach/(su (perk [%friends %public %private ~]))
post/post
==
++ post
^- $-(json post:s)
%- ot
:~ title/so
type/type
link/so
description/so
tags/(ar so)
image/so
==
++ type (su (perk [%app %group %content %other ~]))
--
--

View File

@ -1,16 +1,26 @@
/- *seek
|%
++ iching
|= word=@t
^- (list @t)
=/ w (trip word)
=. w
?. (lth (lent w) 3) w
(weld w (reap (sub 3 (lent w)) ' '))
%+ turn
(gulf 0 (sub (lent w) 3))
|= index=@ud
(crip (swag [index 3] w))
++ digest
|= =listing
^- @uw
|= =post
^- @uv
%- shax
%- crip
:~ title.listing
link.listing
image.listing
description.listing
(crip tags.listing)
`@t`source.listing
`@t`time.listing
:~ title.post
`@t`type.post
link.post
image.post
description.post
(crip tags.post)
==
--

View File

@ -6,16 +6,31 @@
(split corpus)
|= word=@t
(~(has in words) word)
++ norm
|= corpus=@t
(crip (cass (trimall (crip (expunge corpus)))))
++ split
|= corpus=@t
^- (list @t)
=/ lines (crip (trimall corpus))
%+ rash lines
(more ace (cook crip (plus alf)))
%+ rash (norm corpus)
(more ace (cook crip (plus ;~(pose aln hep))))
++ allowed ;~(pose aln hep ace)
++ banned ;~(less allowed prn)
++ expunge
|= corpus=@t
^- (list @t)
%- zing
%+ rash corpus
%- plus
;~ pose
;~(pfix (plus banned) (star allowed))
;~(sfix (star allowed) (plus banned))
(plus allowed)
==
++ trimall
|= value=@t
|= corpus=@t
|^ ^- tape
%+ rash value
%+ rash corpus
%+ ifix [(star ws) (star ws)]
%- star
;~ less

View File

@ -0,0 +1,14 @@
/- s=seek
/+ j=seek-json
|_ d=(list declare:s)
++ grad %noun
++ grow
|%
++ noun d
--
++ grab
|%
++ noun (list declare:s)
++ json declarations:dejs:j
--
--

14
desk/mar/declare.hoon Normal file
View File

@ -0,0 +1,14 @@
/- s=seek
/+ j=seek-json
|_ =declare:s
++ grad %noun
++ grow
|%
++ noun declare
--
++ grab
|%
++ noun declare:s
++ json declare:dejs:j
--
--

View File

@ -1,14 +1,13 @@
/- s=seek
/+ j=seek-json
|_ =notice:s
|_ =listing:s
++ grad %noun
++ grow
|%
++ noun notice
++ noun listing
--
++ grab
|%
++ noun notice:s
++ json notice:dejs:j
++ noun listing:s
--
--

13
desk/mar/directory.hoon Normal file
View File

@ -0,0 +1,13 @@
/- s=seek
/+ j=seek-json
|_ =directory:s
++ grad %noun
++ grow
|%
++ noun directory
--
++ grab
|%
++ noun directory:s
--
--

View File

@ -0,0 +1,11 @@
|_ rum=[[@ *] (cask *)]
++ grad %noun
++ grow
|%
++ noun rum
--
++ grab
|%
++ noun ,[[@ *] (cask *)]
--
--

View File

@ -1,14 +0,0 @@
/- s=seek
/+ j=seek-json
|_ listings=(list listing:s)
++ grad %noun
++ grow
|%
++ noun listings
++ json (listings:enjs:j listings)
--
++ grab
|%
++ noun (list listing:s)
--
--

14
desk/mar/search.hoon Normal file
View File

@ -0,0 +1,14 @@
/- s=seek
/+ j=seek-json
|_ =search:s
++ grad %noun
++ grow
|%
++ noun search
++ json (search:enjs:j search)
--
++ grab
|%
++ noun search
--
--

41
desk/sur/pals.hoon Normal file
View File

@ -0,0 +1,41 @@
:: pals: manual neighboring
::
|%
+$ records :: local state
$: outgoing=(jug ship @ta)
incoming=(set ship)
::
:: receipts: for all outgoing, status
::
:: if ship not in receipts, poke awaiting ack
:: if ship present as true, poke acked positively
:: if ship present as false, poke acked negatively
::
receipts=(map ship ?)
==
::
+$ gesture :: to/from others
$% [%hey ~]
[%bye ~]
==
::
+$ command :: from ourselves
$% [%meet =ship in=(set @ta)] :: empty set allowed
[%part =ship in=(set @ta)] :: empty set implies un-targeting
==
::
+$ effect :: to ourselves
$% target-effect
leeche-effect
==
::
+$ target-effect
$% [%meet =ship] :: hey to target
[%part =ship] :: bye to target
==
::
+$ leeche-effect
$% [%near =ship] :: hey from leeche
[%away =ship] :: bye from leeche
==
--

View File

@ -1,7 +1,8 @@
|%
+$ key @t :: keyword that points to listings
+$ hash @uw :: @uw version of shax result
+$ hash @uv :: @uv version of shax result
+$ rank @ud :: @ud representing priority of listing
+$ phonetic @t :: @t consisting of phonetic characters from metaphone
::
:: an entry points to a listing hash and provides
:: a semblance of priority for the listing
@ -11,20 +12,36 @@
=rank :: based on which attribute keyword came from
==
::
:: a listing represents a search result which points to some
:: a post represents a search result which points to some
:: external resource through a link. this is a rich result
:: which gives a summary of the resource as well as an image
:: and related topics.
::
:: each listing has a source and time so that they can be updated
:: via the source requesting to replace the listing and hash
+$ post-type ?(%app %group %content %other)
::
+$ listing
+$ post
$: title=@t
type=post-type
link=@t
description=@t
tags=(list @t)
image=@t
==
::
+$ reach ?(%private %friends %public)
::
+$ declare (pair reach post)
::
:: a listing includes the content of the post as well as information
:: that would affect the hash so are left out, namely time, source and
:: hash. each post has a source and time so that they can be updated
:: via the source requesting to replace the listing and hash.
::
::
+$ listing
$: =post
=hash
=reach
source=@p
time=@da
==
@ -35,6 +52,11 @@
::
+$ lookup (map key (list entry))
::
:: phonetics act as a way to account for spelling errors. we keep a map
:: of phonetic to set of lookup keys. we take the query's phonetics and
:: use that to add other words to the results.
+$ phonetics (map phonetic (set key))
::
:: trail is a way for us to know all the keys required to update
:: a listing with the new hash of it's contents
::
@ -45,5 +67,12 @@
::
+$ directory (map hash listing)
::
+$ notice (pair hash listing)
+$ search
$: listings=(list listing)
start=@ud
limit=@ud
size=@ud
total=@ud
==
::
--

View File

@ -1 +1 @@
[%zuse 420]
[%zuse 418]

40
desk/ted/publish-all.hoon Normal file
View File

@ -0,0 +1,40 @@
/- spider
/- s=seek
/+ *strandio
=, strand=strand:spider
=/ posts
^- (list declare:s)
:~ :- %friends
['networked subject' %group 'web+urbitgraph://group/~matwet/networked-subject' 'https://subject.network | networked subject' ~['hosting' 'networking' 'urbit' 'ops'] 'https://urbits3.ams3.digitaloceanspaces.com/sitful-hatred/2022.7.07..17.15.52-ns.png']
:- %friends
['Hooniverse' %group 'web+urbitgraph://group/~hiddev-dannut/new-hooniverse' 'Community based Hoon learning for all levels. For discussion of Hoon specific to Uqbar, join ~hiddev-dannut/uhoon' ~['hoon' 'dev' 'urbit' 'programming' 'education'] 'https://i.imgur.com/ghThlz7.png']
:- %friends
['Hollow Mars Theory' %group 'web+urbitgraph://group/~rabsef-bicrym/hollow-mars-theory' 'Definitely NOT a Conspiracy Chat' ~['conspiracies' 'ufos' 'steel beams'] '']
:- %friends
['celestial systems' %group 'web+urbitgraph://group/~nocsyx-lassul/celestial-systems' 'A place for pilots who are building hosting providers' ~['hosting' 'ops' 'urbit'] '']
:- %friends
['Structure' %group 'web+urbitgraph://group/~fabled-faster/structure' 'Urbit Structural Design and Engineering Group. Always Thinking About Mechanics.' ~['design' 'ux' 'hci' 'urbit'] 'https://fabled-faster.nyc3.digitaloceanspaces.com/fabled-faster/2022.1.27..17.59.43-image.png']
:- %friends
['The Marketplace' %group 'web+urbitgraph://group/~tirrel/the-marketplace' 'Welcome to The Marketplace, featuring The Pit, Urbit\'s first open-outcry market!' ~['urbit' 'market' 'sell' 'buy'] 'https://snipboard.io/U0IYyi.jpg']
:- %friends
['The Forge' %group 'web+urbitgraph://group/~middev/the-forge' 'pale fire computing' ~['dev' 'programming' 'hoon' 'urbit'] 'https://nyc3.digitaloceanspaces.com/archiv/littel-wolfur/2021.5.06..21.01.58-the%20forge.png']
:- %friends
['UFORIA' %group 'web+urbitgraph://group/~tiplec-lacnyx/ufora' 'UFO Research, Investigations, and Analysis' ~['UFO' 'conspiracies' 'research'] 'https://tiplec-lacnyx.nyc3.digitaloceanspaces.com/tiplec-lacnyx/2021.12.27..06.09.47-change1.jpg']
:- %friends
['Urbit Community' %group 'web+urbitgraph://group/~bitbet-bolbel/urbit-community' 'World hub, help desk, meet and greet, etc.' ~['general' 'urbit' 'community' 'help'] 'https://fabled-faster.nyc3.digitaloceanspaces.com/fabled-faster/2021.4.02..21.52.41-UC.png']
:- %friends
['Urbytes' %group 'web+urbitgraph://group/~nartes-fasrum/urbytes' 'We feed and water Mars.' ~['cooking' 'food' 'beverages' 'peanut butter eggs'] '']
==
^- thread:spider
|= arg=vase
=/ m (strand ,vase)
^- form:m
;< our=@p bind:m get-our
;< ~ bind:m
%- send-raw-cards
^- (list card:agent:gall)
%+ turn
posts
|= =declare:s
[%pass /poke %agent [our %seek] %poke [%declare !>(declare)]]
(pure:m !>(~))

View File

@ -10,7 +10,7 @@
<link rel="apple-touch-icon" sizes="180x180" href="/src/assets/apple-touch-icon.png" />
<link rel="manifest" href="/src/assets/manifest.json" />
</head>
<body class="font-sans text-gray-900 bg-white antialiased">
<body class="font-sans text-mauve bg-linen antialiased">
<div id="app"></div>
<script type="module" src="/src/main.tsx"></script>
</body>

5800
ui/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -10,20 +10,32 @@
"tsc": "tsc --noEmit"
},
"dependencies": {
"@heroicons/react": "^1.0.6",
"@radix-ui/react-dropdown-menu": "^0.1.6",
"@radix-ui/react-select": "^0.1.1",
"@radix-ui/react-toggle-group": "^0.1.5",
"@urbit/api": "^2.0.0",
"@urbit/http-api": "^2.0.0",
"big-integer": "^1.6.51",
"classnames": "^2.3.1",
"lodash.debounce": "^4.0.8",
"react": "^17.0.2",
"react-dom": "^17.0.2"
"react-dom": "^17.0.2",
"react-hook-form": "^7.33.1",
"react-query": "^3.39.1",
"react-router-dom": "^6.3.0",
"react-select": "^5.4.0"
},
"devDependencies": {
"@types/lodash.debounce": "^4.0.7",
"@types/react": "^17.0.0",
"@types/react-dom": "^17.0.0",
"@urbit/vite-plugin-urbit": "^0.8.0",
"@vitejs/plugin-react-refresh": "^1.3.1",
"autoprefixer": "^10.3.7",
"postcss": "^8.3.9",
"tailwindcss": "^2.2.16",
"autoprefixer": "^10.4.7",
"postcss": "^8.4.14",
"tailwindcss": "^3.1.4",
"typescript": "^4.3.2",
"vite": "^2.6.7"
"vite": "^2.9.13"
}
}

6
ui/src/api.ts Normal file
View File

@ -0,0 +1,6 @@
import Urbit from "@urbit/http-api";
const api = new Urbit('', '', window.desk);
api.ship = window.ship;
export default api;

View File

@ -1,44 +1,31 @@
import React, { useEffect, useState } from 'react';
import Urbit from '@urbit/http-api';
import { Charges, ChargeUpdateInitial, scryCharges } from '@urbit/api';
import { AppTile } from './components/AppTile';
import React from 'react';
import { QueryClient, QueryClientProvider } from 'react-query';
import { BrowserRouter, Route, Routes } from 'react-router-dom';
import { Layout } from './components/Layout';
import { Post } from './pages/Post';
import { Search } from './pages/Search';
const api = new Urbit('', '', window.desk);
api.ship = window.ship;
export function App() {
const [apps, setApps] = useState<Charges>();
useEffect(() => {
async function init() {
const charges = (await api.scry<ChargeUpdateInitial>(scryCharges)).initial;
setApps(charges);
}
init();
}, []);
const queryClient = new QueryClient();
function Main() {
return (
<main className="flex items-center justify-center min-h-screen">
<div className="max-w-md space-y-6 py-20">
<h1 className="text-3xl font-bold">Welcome to seek</h1>
<p>Here&apos;s your urbit&apos;s installed apps:</p>
{apps && (
<ul className="space-y-4">
{Object.entries(apps).map(([desk, app]) => (
<li key={desk} className="flex items-center space-x-3 text-sm leading-tight">
<AppTile {...app} />
<div className="flex-1 text-black">
<p>
<strong>{app.title || desk}</strong>
</p>
{app.info && <p>{app.info}</p>}
</div>
</li>
))}
</ul>
)}
</div>
</main>
<Routes>
<Route element={<Layout />}>
<Route path="/" element={<Search />} />
<Route path="/search/:lookup" element={<Search />} />
<Route path="/search/:lookup/:limit/:page" element={<Search />} />
<Route path="/post" element={<Post />} />
</Route>
</Routes>
);
}
export function App() {
return (
<QueryClientProvider client={queryClient}>
<BrowserRouter basename='/apps/seek'>
<Main />
</BrowserRouter>
</QueryClientProvider>
)
}

View File

@ -1,29 +0,0 @@
import { Charge } from '@urbit/api';
import React, { useState } from 'react';
function normalizeUrbitColor(color: string): string {
if (color.startsWith('#')) {
return color;
}
return `#${color.slice(2).replace('.', '').toUpperCase()}`;
}
export const AppTile = ({ image, color }: Charge) => {
const [imageError, setImageError] = useState(false);
return (
<div
className="flex-none relative w-12 h-12 mr-3 rounded-lg bg-gray-200 overflow-hidden"
style={{ backgroundColor: normalizeUrbitColor(color) }}
>
{image && !imageError && (
<img
className="absolute top-0 left-0 h-full w-full object-cover"
src={image}
alt=""
onError={() => setImageError(true)}
/>
)}
</div>
);
};

View File

@ -0,0 +1,55 @@
import React from 'react';
import * as Select from '@radix-ui/react-select';
import { PostFilter, PostType } from '../types/seek';
interface FilterItemProps {
title: PostFilter;
}
const FilterItem = ({ title }: FilterItemProps) => {
return (
<Select.Item value={title} className="text-mauve cursor-default select-none relative py-2 pl-3 pr-9 focus:outline-none focus:ring-1 focus:ring-rosy font-semibold">
<Select.ItemText>%{title}</Select.ItemText>
<Select.ItemIndicator className="text-rosy absolute inset-y-0 right-0 flex items-center pr-4">
<svg className="h-5 w-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
</svg>
</Select.ItemIndicator>
</Select.Item>
)
}
const filters: PostFilter[] = ['all', 'app', 'group', 'content', 'other'];
interface FilterProps<T extends string> {
selected: T;
onSelect: (value: T) => void;
showAll?: boolean;
className?: string;
}
export function Filter<T extends string>({ selected, onSelect, className, showAll = true }: FilterProps<T>) {
const options = showAll ? filters : filters.filter(f => f === 'all');
return (
<div className={className}>
<Select.Root value={selected} onValueChange={onSelect}>
<span className="sr-only">Filter Type</span>
<Select.Trigger className="bg-linen flex justify-between w-32 font-semibold border border-fawn/30 rounded-lg shadow-sm pl-3 pr-2 py-1.5 text-left cursor-default focus:outline-none focus:ring-1 focus:ring-rosy focus:border-rosy sm:text-sm">
<Select.Value>%{selected}</Select.Value>
<Select.Icon asChild>
<svg className="h-5 w-5 text-rosy" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z" clipRule="evenodd" />
</svg>
</Select.Icon>
</Select.Trigger>
<Select.Content className="z-10 w-full bg-linen shadow-lg max-h-60 rounded-md py-1 text-base ring-1 ring-rosy ring-opacity-5 overflow-auto focus:outline-none sm:text-sm">
<Select.Viewport>
{options.map(f => (
<FilterItem key={f} title={f} />
))}
</Select.Viewport>
</Select.Content>
</Select.Root>
</div>
)
}

View File

@ -0,0 +1,17 @@
import cn from 'classnames';
import React from 'react';
import { Outlet, useParams } from 'react-router-dom';
import { Meta } from './Meta';
export const Layout = () => {
const params = useParams<{ lookup: string }>();
return (
<main className={cn("flex flex-col items-center min-h-screen", !params.lookup && 'justify-center')}>
<div className="max-w-2xl w-full p-4 sm:py-12 px-8 space-y-6">
<Outlet />
</div>
<Meta className='self-start mx-10 my-6 sm:m-0 sm:fixed left-4 bottom-4 text-sm'/>
</main>
)
}

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,45 @@
import { PlusIcon } from '@heroicons/react/solid';
import { ChargeUpdateInitial, scryCharges } from '@urbit/api';
import React, { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import api from '../api';
import { Logo } from './Logo';
interface MetaProps {
className?: string;
}
export const Meta = ({ className }: MetaProps) => {
const [version, setVersion] = useState('');
useEffect(() => {
if (!version) {
api.scry<ChargeUpdateInitial>(scryCharges).then(charges => {
const version = charges.initial.seek?.version;
if (version) {
setVersion(version);
}
})
}
}, []);
return (
<footer className={className}>
<div className='flex mb-2 space-x-3 items-end'>
<Logo className="w-24 text-rosy" />
{version && <span className='mb-[3px]'>v{version}</span>}
</div>
<a
className="inline-block font-mono default-ring underline rounded-md hover:text-rosy mb-4"
href="web+urbitgraph://group/~nocsyx-lassul/sphinx"
>
~nocsyx-lassul/sphinx
</a>
<Link to="/post" className="flex items-center rounded-lg text-base font-semibold text-rosy bg-rosy/30 border-2 border-transparent hover:border-rosy p-2 transition-colors">
<PlusIcon className='h-4 w-4 mr-1' />
Add a Listing
</Link>
</footer>
)
}

View File

@ -0,0 +1,129 @@
import { ArrowNarrowLeftIcon, ArrowNarrowRightIcon, DotsHorizontalIcon } from '@heroicons/react/solid';
import cn from 'classnames';
import React from 'react';
import { Link } from 'react-router-dom';
interface PaginatorProps {
linkBuilder: (page: number | null) => string | null;
currentPage: number;
pages: number;
showNextPrev?: boolean;
pagesShownLimit?: number;
className?: string;
}
interface Page {
number: number;
active?: boolean;
ellipsis?: boolean;
}
function calculateShownPages(currentPage: number, pages: number, pagesShownLimit: number): Page[] {
const first = 1;
const last = pages;
const gap = Math.floor(pagesShownLimit / 2);
const start = Math.max(1, Math.min(pages - pagesShownLimit, currentPage - gap));
const end = Math.min(last, Math.max(first + pagesShownLimit, currentPage + gap));
const firstEllipsis = (currentPage - first) > gap + 1 && start !== first;
const lastEllipsis = (last - currentPage) > gap + 1 && end !== last;
const pageSet: Page[] = [];
if (start !== first) {
pageSet.push({
number: first,
active: currentPage === first
});
}
if (firstEllipsis) {
pageSet.push({
number: 0,
ellipsis: true,
active: false
});
}
for (let number=start; number<=end; number++) {
pageSet.push({ number, active: currentPage === number });
}
if (lastEllipsis) {
pageSet.push({
number: 0,
ellipsis: true,
active: false
});
}
if (last !== end) {
pageSet.push({
number: last,
active: currentPage === last
})
}
return pageSet;
}
export const Paginator = ({ className, linkBuilder, currentPage, pages, showNextPrev = false, pagesShownLimit = 3 }: PaginatorProps) => {
const normCurrent = currentPage + 1; //shift for 0 based indexing
if (pages <= 1)
return null;
const pageSet = calculateShownPages(normCurrent, pages, pagesShownLimit);
const prevUrl = linkBuilder(currentPage - 1 >= 0 ? normCurrent - 1 : null);
const nextUrl = linkBuilder(currentPage + 1 <= pages - 1 ? normCurrent + 1 : null);
return (
<>
{showNextPrev && (
<div className="-mt-px w-0 flex-1 flex">
{!prevUrl && (
<span className='border-t-2 border-transparent pt-2 pl-1 inline-flex items-center text-sm font-medium text-zinc-300'>
<ArrowNarrowLeftIcon className="mr-3 h-5 w-5" />
Previous
</span>
)}
{prevUrl && <Link to={prevUrl} className="border-t-2 border-transparent pt-2 pr-1 inline-flex items-center text-sm font-medium text-zinc-500 hover:text-zinc-700 hover:border-zinc-300">
<ArrowNarrowLeftIcon className="mr-3 h-5 w-5 text-zinc-400" />
Previous
</Link>}
</div>
)}
{pageSet.map((page, index) => {
if (page.ellipsis)
return <span key={index} className="border-transparent text-zinc-500 border-t-2 pt-2 px-4 inline-flex items-center text-sm font-medium"><DotsHorizontalIcon className='w-3 h-3' /></span>
return (
<Link
key={index}
to={linkBuilder(page.number) || ''}
className={cn(
'border-t-2 pt-2 px-4 inline-flex items-center text-sm font-medium',
page.active && 'border-lavender/80 text-lavender',
!page.active && 'border-transparent text-zinc-500 hover:text-zinc-700 hover:border-zinc-300'
)}
aria-current={page.active ? 'page' : undefined}
>
{page.number}
</Link>
)
})}
{showNextPrev && (
<div className="-mt-px w-0 flex-1 flex justify-end">
{!nextUrl && (
<span className='border-t-2 border-transparent pt-2 pl-1 inline-flex items-center text-sm font-medium text-zinc-300'>
Next
<ArrowNarrowRightIcon className="ml-3 h-5 w-5" />
</span>
)}
{nextUrl && <Link to={nextUrl} className="border-t-2 border-transparent pt-2 pl-1 inline-flex items-center text-sm font-medium text-zinc-500 hover:text-zinc-700 hover:border-zinc-300">
Next
<ArrowNarrowRightIcon className="ml-3 h-5 w-5 text-zinc-400" />
</Link>}
</div>
)}
</>
);
};

View File

@ -0,0 +1,29 @@
import { SearchIcon } from '@heroicons/react/solid';
import cn from 'classnames';
import React, { ChangeEvent, useCallback } from 'react';
interface SearchInputProps {
lookup: string;
className?: string;
onChange: (value: string) => void;
}
export const SearchInput = ({ lookup, className, onChange }: SearchInputProps) => {
const onInput = useCallback((event: ChangeEvent<HTMLInputElement>) => {
const { value } = event.target;
onChange(value);
}, [onChange]);
return (
<div className={cn('relative flex items-center', className)}>
<SearchIcon className='flip absolute left-2 h-5 w-5' />
<input
type='text'
value={lookup}
onChange={onInput}
placeholder="Search"
className={cn('w-full py-1 pl-9 pr-2 bg-fawn/30 focus:outline-none focus:ring-2 ring-lavender rounded-lg border border-fawn/30')}
/>
</div>
)
}

View File

@ -0,0 +1,52 @@
import cn from 'classnames';
import React from 'react';
import { Listing } from '../types/seek';
interface SearchListProps {
listings: Listing[];
className?: string;
}
export const SearchList = ({ listings, className }: SearchListProps) => {
if (!listings) {
return null;
}
if (listings.length === 0) {
return (
<div>
<h2 className='text-xl font-semibold'>No Results</h2>
</div>
)
}
return (
<ul className={cn('px-2 space-y-6', className)}>
{listings && listings.map(l => (
<li key={l.hash}>
<article className='group flex w-full'>
{l.post.image ? (
<img src={l.post.image} className="w-24 h-24 object-cover rounded-xl" />
) : <div className='w-24 h-24 bg-rosy/20 rounded-xl' />}
<div className='flex-1 ml-4'>
<a className='block w-full mb-2 hover:text-rosy transition-colors' href={l.post.link}>
<h2 className='block font-semibold text-lg leading-none mb-1'>{l.post.title}</h2>
<div className='font-mono text-xs space-x-3'>
<strong>%{l.post.type}</strong>
<span>{l.post.link.replace('web+urbitgraph://', '')}</span>
</div>
</a>
<p className='mb-2 text-sm'>{l.post.description}</p>
<div className='flex font-mono text-xs text-zinc-500 space-x-3 opacity-0 group-hover:opacity-100 transition-opacity'>
<span><span className='font-semibold font-sans'>by:</span> {l.source}</span>
<time dateTime={(new Date(l.time)).toISOString()}>
{(new Date(l.time)).toLocaleString()}
</time>
</div>
</div>
</article>
</li>
))}
</ul>
)
}

View File

@ -0,0 +1,151 @@
import React, { Component, KeyboardEventHandler, useCallback, useState } from 'react';
import CreatableSelect from 'react-select/creatable';
import { ActionMeta, components, ControlProps, MultiValue, MultiValueGenericProps, MultiValueRemoveProps, OnChangeValue } from 'react-select';
import { XIcon } from '@heroicons/react/solid';
export interface Option {
readonly label: string;
readonly value: string;
}
const createOption = (label: string) => ({
label,
value: label,
});
function Control({ children, ...props }: ControlProps<Option, true>) {
return (
<components.Control
{...props}
className="flex items-center w-full py-1 px-2 bg-fawn/30 focus:outline-none focus:ring-2 ring-lavender rounded-lg border border-fawn/30 cursor-text text-mauve"
>
{children}
</components.Control>
);
}
function TagContainer({
children,
...props
}: MultiValueGenericProps<Option, true>) {
return (
<components.MultiValueContainer {...props}>
<div className="flex">{children}</div>
</components.MultiValueContainer>
);
}
function TagLabel({ data }: { data: Option }) {
const { value, label } = data;
return (
<div className="flex h-6 items-center rounded-l bg-lavender text-linen">
<span className="p-1 font-semibold">{label || value}</span>
</div>
);
}
function TagRemove(props: MultiValueRemoveProps<Option, true>) {
return (
<components.MultiValueRemove {...props}>
<div className="flex h-full items-center rounded-r bg-lavender pr-1">
<XIcon className="h-4 w-4 text-linen" />
</div>
</components.MultiValueRemove>
);
}
interface TagFieldProps {
tags: MultiValue<Option>;
onTags: (tags: MultiValue<Option>) => void;
className?: string;
}
export const TagField = ({ tags, onTags, className }: TagFieldProps) => {
const [input, setInput] = useState('');
const handleChange = useCallback((
value: OnChangeValue<Option, true>,
actionMeta: ActionMeta<Option>
) => {
console.group('Value Changed');
console.log(value);
console.log(`action: ${actionMeta.action}`);
console.groupEnd();
onTags(value);
}, []);
const handleInputChange = useCallback((value) => {
setInput(value);
}, []);
const handleKeyDown: KeyboardEventHandler<HTMLDivElement> = useCallback((event) => {
if (!input) return;
switch (event.key) {
case 'Enter':
case 'Tab':
console.group('Value Added');
console.log(tags);
console.groupEnd();
setInput('');
onTags([...tags, createOption(input)])
event.preventDefault();
}
}, [input, tags]);
return (
<CreatableSelect<Option, true>
className={className}
components={{
...components,
Control,
MultiValueContainer: TagContainer,
MultiValueLabel: TagLabel,
MultiValueRemove: TagRemove,
DropdownIndicator: null
}}
styles={{
control: (base) => ({}),
input: (base) => ({
...base,
padding: 0,
margin: 0,
}),
clearIndicator: (base) => ({
...base,
cursor: 'pointer',
padding: 0
}),
multiValue: (base) => ({
...base,
backgroundColor: '',
margin: '0 2px',
}),
multiValueRemove: (base) => ({
...base,
paddingRight: '',
paddingLeft: '',
'&:hover': {
color: 'inherit',
backgroundColor: 'inherit',
},
}),
valueContainer: (base) => ({
...base,
padding: 0
})
}}
inputValue={input}
isClearable
isMulti
menuIsOpen={false}
onChange={handleChange}
onInputChange={handleInputChange}
onKeyDown={handleKeyDown}
placeholder="Type something and press enter..."
value={tags}
/>
);
}

View File

@ -1,3 +1,7 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
.flip {
transform: rotateY(180deg);
}

106
ui/src/pages/Post.tsx Normal file
View File

@ -0,0 +1,106 @@
import debounce from 'lodash.debounce';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useForm } from 'react-hook-form';
import { Link, useNavigate } from 'react-router-dom';
import { MultiValue } from 'react-select';
import api from '../api';
import { Filter } from '../components/Filter';
import { Option, TagField } from '../components/TagField';
import { Declare, Post as PostForm } from '../types/seek';
export const Post = () => {
const navigate = useNavigate();
const [tags, setTags] = useState<MultiValue<Option>>([]);
const [image, setImage] = useState<string>('');
const { register, watch, reset, setValue, handleSubmit } = useForm<PostForm>({
defaultValues: {
title: '',
type: 'other',
link: '',
image: '',
description: ''
}
});
const updateImg = useRef(debounce(setImage));
const img = watch('image');
const type = watch('type');
const onSubmit = useCallback((values: Omit<PostForm, 'tags'>) => {
api.poke<Declare>({
app: 'seek',
mark: 'declare',
json: {
reach: 'friends',
post: {
...values,
tags: tags.map(t => t.value)
}
}
})
reset();
setTags([]);
}, [tags]);
useEffect(() => {
register('type')
}, []);
useEffect(() => {
if (img) {
updateImg.current(img)
}
}, [img]);
return (
<>
<header>
<h1 className='text-2xl font-semibold'>Add a Listing</h1>
</header>
<form onSubmit={handleSubmit(onSubmit)}>
<div className='flex w-full space-x-6'>
<div className='flex-1 space-y-3'>
<div>
<label htmlFor='title' className='text-sm font-semibold'>Title</label>
<div className='flex items-center space-x-2'>
<input {...register('title', { required: true, maxLength: 80 })} className='flex-1 w-full py-1 px-2 bg-fawn/30 focus:outline-none focus:ring-2 ring-lavender rounded-lg border border-fawn/30' placeholder='sphinx'/>
<Filter selected={type} onSelect={(value) => setValue('type', value)} />
</div>
</div>
<div className='flex items-center space-x-6'>
<div className='flex-1 space-y-3'>
<div>
<label htmlFor='image' className='text-sm font-semibold'>Image</label>
<input type="url" {...register('image')} className='flex-1 w-full py-1 px-2 bg-fawn/30 focus:outline-none focus:ring-2 ring-lavender rounded-lg border border-fawn/30' placeholder='https://nyc3.digitaloceanspaces.com/...' />
</div>
<div>
<label htmlFor='link' className='text-sm font-semibold'>Link</label>
<input type="url" {...register('link', { required: true })} className='flex-1 w-full py-1 px-2 bg-fawn/30 focus:outline-none focus:ring-2 ring-lavender rounded-lg border border-fawn/30' placeholder='web+urbitgraph://~nocsyx-lassul/sphinx' />
</div>
</div>
<img className='flex-none object-cover w-28 h-28 mt-4 border-2 border-dashed border-mauve/60 rounded-lg' src={image || undefined} />
</div>
<div>
<label htmlFor='description' className='text-sm font-semibold'>Description</label>
<textarea {...register('description', { required: true, maxLength: 256 })} rows={2} className='align-middle w-full py-1 px-2 bg-fawn/30 focus:outline-none focus:ring-2 ring-lavender rounded-lg border border-fawn/30' placeholder='An app for answering your riddles' />
</div>
<div>
<label className='text-sm font-semibold'>Tags</label>
<TagField tags={tags} onTags={setTags} />
</div>
<div className='pt-3'>
<div className='flex justify-between border-t border-zinc-300 py-3'>
<button type="button" className='flex items-center rounded-lg text-base font-semibold text-rosy bg-rosy/30 border-2 border-transparent hover:border-rosy leading-none py-2 px-3 transition-colors' onClick={() => navigate(-1)}>
Back to Search
</button>
<button type="submit" className='flex items-center rounded-lg text-base font-semibold text-linen bg-rosy border-2 border-transparent hover:border-linen/60 leading-none py-2 px-3 transition-colors'>
Publish
</button>
</div>
</div>
</div>
</div>
</form>
</>
)
}

92
ui/src/pages/Search.tsx Normal file
View File

@ -0,0 +1,92 @@
import cn from 'classnames';
import React, { useCallback, useRef, useState } from 'react';
import debounce from 'lodash.debounce';
import { SearchInput } from '../components/SearchInput';
import { SearchList } from '../components/SearchList';
import { useQuery } from 'react-query';
import { PostFilter, PostType, Search as SearchType } from '../types/seek';
import api from '../api';
import { Link, useNavigate, useParams } from 'react-router-dom';
import { Paginator } from '../components/Paginator';
import { stringToTa } from '@urbit/api';
import { Filter } from '../components/Filter';
import { Meta } from '../components/Meta';
import { DocumentAddIcon, PlusCircleIcon, PlusIcon } from '@heroicons/react/solid';
interface RouteParams extends Record<string, string | undefined> {
lookup?: string;
limit?: string;
page?: string;
}
function encodeLookup(value: string | undefined) {
if (!value) {
return '';
}
return stringToTa(value).replace('~.', '~~');
}
export const Search = () => {
const navigate = useNavigate();
const {
lookup,
limit,
page
} = useParams<RouteParams>();
const [selected, setSelected] = useState<PostFilter>('all')
const [rawSearch, setRawSearch] = useState(lookup || '');
const size = parseInt(limit || '10', 10);
const pageInt = parseInt(page || '1', 10) - 1;
const start = pageInt * size;
const { data } = useQuery<unknown, unknown, SearchType>(`lookup-${selected}-${lookup}-${size}-${start}`, () => api.scry<SearchType>({
app: 'seek',
path: `/lookup/${selected}/${encodeLookup(lookup)}/${start}/${size}`
}), {
enabled: !!lookup,
keepPreviousData: true
});
const total = data?.total || 0;
const pages =
total % size === 0
? total / size
: Math.floor(total / size) + 1;
const update = useRef(debounce((value: string) => {
if (!value) {
return;
}
navigate(`/search/${value}/${size}/${page || '1'}`)
}, 400))
const onChange = useCallback((value: string) => {
setRawSearch(value);
update.current(value);
}, []);
const linkBuild = useCallback((page) => {
if (!page) {
return null;
}
return `/${lookup}/${size}/${page || 1}`
}, [lookup, size]);
return (
<>
<header className='flex items-center space-x-2'>
<SearchInput className='flex-1' lookup={rawSearch} onChange={onChange} />
<Filter selected={selected} onSelect={setSelected} />
</header>
{lookup && data && <div className='flex justify-end border-t border-zinc-300'>
<Paginator pages={pages} currentPage={pageInt} linkBuilder={linkBuild} />
</div>}
{lookup && <SearchList listings={data?.listings || []} />}
{data && pages > 1 && <div className='flex justify-end border-t border-zinc-300'>
<Paginator pages={pages} currentPage={pageInt} linkBuilder={linkBuild} />
</div>}
</>
)
}

32
ui/src/types/seek.ts Normal file
View File

@ -0,0 +1,32 @@
export type PostType = 'app' | 'group' | 'content' | 'other';
export type PostFilter = 'all' | PostType;
export interface Declare {
reach: 'friends' | 'public' | 'private';
post: Post
}
export interface Post {
title: string;
type: PostType;
description: string;
image: string;
tags: string[];
link: string;
}
export interface Listing {
post: Post;
hash: string;
source: string;
time: number;
}
export type Search = {
listings: Listing[];
start: number;
limit: number;
size: number;
total: number;
}

View File

@ -1,13 +1,16 @@
module.exports = {
mode: 'jit',
purge: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
darkMode: 'media', // or 'media' or 'class'
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
theme: {
extend: {}
extend: {
colors: {
linen: '#FAEDE3',
fawn: '#E7AB79',
rosy: '#B25068',
lavender: '#774360',
mauve: '#4C3A51'
}
}
},
screens: {},
variants: {
extend: {}
},
plugins: []
};