on master help sign in
adding color, loading states, state cleanup, migration
0ce16e4Hunter Miller
desk/app/sphinx.hoon+46−10
@@ -13,9 +13,10 @@
13 +$ card card:agent:gall
13 +$ card card:agent:gall
14 +$ edition
14 +$ edition
15 $% state-0
15 $% state-0
16+ state-1
16 ==
17 ==
17− +$ state-0
18+ +$ state-1
18− $: %0
19+ $: %1
19 =lookup:s
20 =lookup:s
20 =trigrams:s
21 =trigrams:s
21 =phonetics:s
22 =phonetics:s
@@ -23,8 +24,17 @@
23 =directory:s
24 =directory:s
24 published=directory:s
25 published=directory:s
25 ==
26 ==
27+ +$ state-0
28+ $: %0
29+ =lookup:s
30+ =trigrams:s
31+ =phonetics:s
32+ =trail:s
33+ =directory:directory-state-zero:s
34+ published=directory:directory-state-zero:s
35+ ==
26 --
36 --
27−=| state-0
37+=| state-1
28=* state -
38=* state -
29=<
39=<
30 %+ verb &
40 %+ verb &
@@ -47,14 +57,38 @@
47 [cards this]
57 [cards this]
48 ::
58 ::
49 ++ on-save !>(state)
59 ++ on-save !>(state)
50− ++ on-load
60+ ++ on-load
51− |= =vase
61+ |= =vase
52− ^- (quip card _this)
53− =/ old !<(edition vase)
62+ =/ old !<(edition vase)
63+ |^
54− ?- -.old
64+ ?- -.old
55− %0 `this(state old)
65+ %1 `this(state old(published (purge published.old)))
66+ %0
67+ %_ $
68+ -.old %1
69+ published.old (purge (directory-0-to-1 published.old))
70+ directory.old (directory-0-to-1 directory.old)
56 ==
71 ==
72+ ==
73+ ++ directory-0-to-1
74+ |= =directory:directory-state-zero:s
75+ ^- directory:s
76+ %- ~(run by directory)
77+ |= =listing:directory-state-zero:s
78+ ^- listing:s
79+ =/ post post.listing(+63 [+63.post.listing ''])
80+ listing(+2 post)
57 ::
81 ::
82+ ++ purge
83+ |= =directory:s
84+ ^- directory:s
85+ %- malt
86+ %+ skim
87+ ~(tap by directory)
88+ |= [=hash:s =listing:s]
89+ =(our.bowl source.listing)
90+ --
91+ ::
58 ++ on-poke
92 ++ on-poke
59 |= [=mark =vase]
93 |= [=mark =vase]
60 ^- (quip card _this)
94 ^- (quip card _this)
@@ -251,7 +285,9 @@
251 |= l=listing:s
285 |= l=listing:s
252 =. listing l
286 =. listing l
253 =. cor (index hash l)
287 =. cor (index hash l)
288+ =. published
289+ ?. =(src our):bowl published
254− =. published (~(put by directory) hash.listing listing)
290+ (~(put by directory) hash.listing listing)
255 =. cor (emit (invent:gossip %directory-listing !>(listing)))
291 =. cor (emit (invent:gossip %directory-listing !>(listing)))
256 di-core
292 di-core
257 --
293 --
desk/lib/sphinx-json.hoon+3−1
@@ -10,8 +10,9 @@
10 type/s/type.p
10 type/s/type.p
11 link/s/link.p
11 link/s/link.p
12 description/s/description.p
12 description/s/description.p
13− image/s/image.p
14 tags/a/(turn tags.p (lead %s))
13 tags/a/(turn tags.p (lead %s))
14+ image/s/image.p
15+ color/s/color.p
15 ==
16 ==
16 ++ listing
17 ++ listing
17 |= l=listing:s
18 |= l=listing:s
@@ -56,6 +57,7 @@
56 description/so
57 description/so
57 tags/(ar so)
58 tags/(ar so)
58 image/so
59 image/so
60+ color/so
59 ==
61 ==
60 ++ type (su (perk [%app %group %content %other ~]))
62 ++ type (su (perk [%app %group %content %other ~]))
61 --
63 --
desk/sur/sphinx.hoon+22
@@ -27,6 +27,7 @@
27 description=@t
27 description=@t
28 tags=(list @t)
28 tags=(list @t)
29 image=@t
29 image=@t
30+ color=@t
30 ==
31 ==
31::
32::
32+$ reach ?(%private %friends %public)
33+$ reach ?(%private %friends %public)
@@ -81,4 +82,25 @@
81 total=@ud
82 total=@ud
82 ==
83 ==
83::
84::
85+++ directory-state-zero
86+ |%
87+ +$ post
88+ $: title=@t
89+ type=post-type
90+ link=@t
91+ description=@t
92+ tags=(list @t)
93+ image=@t
94+ ==
95+ ::
96+ +$ listing
97+ $: =post
98+ =hash
99+ =reach
100+ source=@p
101+ time=@da
102+ ==
103+ ::
104+ +$ directory (map hash listing)
105+ --
84--
106--
ui/src/components/Listings.tsx+12−3
@@ -1,5 +1,5 @@
1import { UserRemoveIcon, UsersIcon } from '@heroicons/react/solid';
1import { UserRemoveIcon, UsersIcon } from '@heroicons/react/solid';
2−import { deSig } from '@urbit/api';
2+import { deSig, uxToHex } from '@urbit/api';
3import cn from 'classnames';
3import cn from 'classnames';
4import React from 'react';
4import React from 'react';
5import { usePals } from '../state/pals';
5import { usePals } from '../state/pals';
@@ -32,8 +32,17 @@ export const Listings = ({ listings, remove, className }: ListingsProps) => {
32 <li key={l.hash}>
32 <li key={l.hash}>
33 <article className='group-1 flex w-full'>
33 <article className='group-1 flex w-full'>
34 {l.post.image ? (
34 {l.post.image ? (
35+ <img
36+ src={l.post.image}
35− <img src={l.post.image} className="w-24 h-24 object-cover rounded-xl" />
37+ className="w-24 h-24 object-cover rounded-xl"
38+ style={{ backgroundColor: l.post.color ? `#${uxToHex(l.post.color)}` : undefined }}
39+ />
40+ ) : (
41+ <div
36− ) : <div className='w-24 h-24 bg-rosy/20 rounded-xl' />}
42+ className='w-24 h-24 bg-rosy/20 rounded-xl'
43+ style={{ backgroundColor: l.post.color ? `#${uxToHex(l.post.color)}` : undefined }}
44+ />
45+ )}
37 <div className='flex-1 ml-4'>
46 <div className='flex-1 ml-4'>
38 <a className='block w-full mb-2 hover:text-rosy transition-colors' href={l.post.link}>
47 <a className='block w-full mb-2 hover:text-rosy transition-colors' href={l.post.link}>
39 <h2 className='block font-semibold text-lg leading-none mb-1'>{l.post.title}</h2>
48 <h2 className='block font-semibold text-lg leading-none mb-1'>{l.post.title}</h2>
ui/src/components/PostOptions.tsx+13−12
@@ -1,4 +1,5 @@
1import { CheckIcon } from '@heroicons/react/solid';
1import { CheckIcon } from '@heroicons/react/solid';
2+import { uxToHex } from '@urbit/api';
2import cn from 'classnames';
3import cn from 'classnames';
3import React from 'react';
4import React from 'react';
4import { useFormContext } from 'react-hook-form';
5import { useFormContext } from 'react-hook-form';
@@ -6,21 +7,12 @@ import { PostOption, PostOptionsForm } from '../types/sphinx';
6
7
7interface PostOptionsProps {
8interface PostOptionsProps {
8 options: PostOption[];
9 options: PostOption[];
9− emptyMessage: string;
10 className?: string;
10 className?: string;
11}
11}
12
12
13−export const PostOptions = ({ options, emptyMessage, className }: PostOptionsProps) => {
13+export const PostOptions = ({ options, className }: PostOptionsProps) => {
14 const { register } = useFormContext<PostOptionsForm>();
14 const { register } = useFormContext<PostOptionsForm>();
15
15
16− if (options.length === 0) {
17− return (
18− <div>
19− <h2 className='text-lavender'>{emptyMessage}</h2>
20− </div>
21− )
22− }
23−
24 return (
16 return (
25 <ul className={cn('space-y-3', className)}>
17 <ul className={cn('space-y-3', className)}>
26 {options && options.map(o => (
18 {options && options.map(o => (
@@ -28,8 +20,17 @@ export const PostOptions = ({ options, emptyMessage, className }: PostOptionsPro
28 <input {...register('options')} id={o.key} value={o.key} type="checkbox" className='sr-only' />
20 <input {...register('options')} id={o.key} value={o.key} type="checkbox" className='sr-only' />
29 <label htmlFor={o.key} className='checked-bg-fawn group-1 flex w-full p-2 hover:bg-fawn cursor-pointer rounded-xl'>
21 <label htmlFor={o.key} className='checked-bg-fawn group-1 flex w-full p-2 hover:bg-fawn cursor-pointer rounded-xl'>
30 {o.post.image ? (
22 {o.post.image ? (
23+ <img
24+ src={o.post.image}
31− <img src={o.post.image} className="w-16 h-16 object-cover rounded-lg" />
25+ className="w-16 h-16 object-cover rounded-lg"
26+ style={{ backgroundColor: o.post.color ? `#${uxToHex(o.post.color)}` : undefined }}
27+ />
28+ ) : (
29+ <div
32− ) : <div className='w-16 h-16 bg-rosy/20 rounded-lg' />}
30+ className='w-16 h-16 bg-rosy/20 rounded-lg'
31+ style={{ backgroundColor: o.post.color ? `#${uxToHex(o.post.color)}` : undefined }}
32+ />
33+ )}
33 <div className='flex-1 ml-4'>
34 <div className='flex-1 ml-4'>
34 <span className='block font-semibold text-lg leading-none mb-1'>{o.post.title}</span>
35 <span className='block font-semibold text-lg leading-none mb-1'>{o.post.title}</span>
35 <div className='font-mono text-xs space-x-3'>
36 <div className='font-mono text-xs space-x-3'>
ui/src/components/Spinner.tsxnew file+11
@@ -0,0 +1,11 @@
1+import cn from 'classnames';
2+import React from 'react';
3+
4+export const Spinner = ({ className }: { className: string }) => {
5+ return (
6+ <svg className={cn('spinner', className)} xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
7+ <circle cx="12" cy="12" r="10" className="stroke-current" strokeOpacity="0.4" stroke-width="4"></circle>
8+ <path className="fill-current" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
9+ </svg>
10+ )
11+}
ui/src/index.css+4
@@ -13,4 +13,8 @@ input:checked + label .show-when-checked {
13input:checked + .checked-bg-fawn,
13input:checked + .checked-bg-fawn,
14input:checked + label .checked-bg-fawn {
14input:checked + label .checked-bg-fawn {
15 @apply bg-fawn;
15 @apply bg-fawn;
16+}
17+
18+.spinner {
19+ @apply inline-flex items-center w-6 h-6 animate-spin;
16}
20}
ui/src/manage-listings/Apps.tsx+25−6
@@ -1,8 +1,10 @@
1import React, { useCallback, useEffect, useState } from 'react';
1import React, { useCallback, useEffect, useState } from 'react';
2import { FormProvider, useForm } from 'react-hook-form';
2import { FormProvider, useForm } from 'react-hook-form';
3+import { useMutation, useQueryClient } from 'react-query';
3import { Link } from 'react-router-dom';
4import { Link } from 'react-router-dom';
4import api from '../api';
5import api from '../api';
5import { PostOptions } from '../components/PostOptions';
6import { PostOptions } from '../components/PostOptions';
7+import { Spinner } from '../components/Spinner';
6import { useApps } from '../state/apps';
8import { useApps } from '../state/apps';
7import { Declare, Listing, PostOption, PostOptionsForm } from '../types/sphinx';
9import { Declare, Listing, PostOption, PostOptionsForm } from '../types/sphinx';
8
10
@@ -11,7 +13,7 @@ function getAppKeys(apps: PostOption[]): string[] {
11}
13}
12
14
13export const Apps = () => {
15export const Apps = () => {
14− const apps = useApps();
16+ const { apps, loading } = useApps();
15 const form = useForm<PostOptionsForm>({
17 const form = useForm<PostOptionsForm>({
16 defaultValues: {
18 defaultValues: {
17 options: []
19 options: []
@@ -28,8 +30,9 @@ export const Apps = () => {
28 }
30 }
29 }, [apps, options, setValue]);
31 }, [apps, options, setValue]);
30
32
33+ const queryClient = useQueryClient();
31− const onSubmit = useCallback((values: PostOptionsForm) => {
34+ const { mutate } = useMutation(async (values: PostOptionsForm) => {
32− api.poke<Declare[]>({
35+ return api.poke<Declare[]>({
33 app: 'sphinx',
36 app: 'sphinx',
34 mark: 'declarations',
37 mark: 'declarations',
35 json: apps.filter(a => values.options.includes(a.key)).map(a => ({
38 json: apps.filter(a => values.options.includes(a.key)).map(a => ({
@@ -37,9 +40,16 @@ export const Apps = () => {
37 reach: 'friends'
40 reach: 'friends'
38 }))
41 }))
39 });
42 });
43+ }, {
44+ onSuccess: () => {
45+ queryClient.invalidateQueries('app-listings');
46+ }
47+ })
40
48
49+ const onSubmit = useCallback((values: PostOptionsForm) => {
50+ mutate(values)
41− reset();
51+ reset()
42− }, [apps, reset]);
52+ }, [reset, mutate]);
43
53
44 return (
54 return (
45 <>
55 <>
@@ -54,7 +64,16 @@ export const Apps = () => {
54 </header>
64 </header>
55 <FormProvider {...form}>
65 <FormProvider {...form}>
56 <form onSubmit={handleSubmit(onSubmit)}>
66 <form onSubmit={handleSubmit(onSubmit)}>
67+ {apps.length === 0 && loading ? (
68+ <div className='flex items-center space-x-2 text-lavender'>
69+ <Spinner className='w-6 h-6' />
70+ <span>Checking apps...</span>
71+ </div>
72+ ) : apps.length === 0 && !loading ? (
57− <PostOptions options={apps} emptyMessage="All apps already indexed" />
73+ <h2 className='text-lavender'>All apps already indexed</h2>
74+ ) : (
75+ <PostOptions options={apps} />
76+ )}
58 <div className='flex justify-between border-t border-zinc-300 py-3 mt-6'>
77 <div className='flex justify-between border-t border-zinc-300 py-3 mt-6'>
59 <Link to="/search" 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'>
78 <Link to="/search" 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'>
60 Back to Search
79 Back to Search
ui/src/manage-listings/Groups.tsx+25−6
@@ -1,13 +1,15 @@
1import React, { useCallback, useEffect, useState } from 'react';
1import React, { useCallback, useEffect, useState } from 'react';
2import { FormProvider, useForm } from 'react-hook-form';
2import { FormProvider, useForm } from 'react-hook-form';
3+import { useMutation, useQueryClient } from 'react-query';
3import { Link } from 'react-router-dom';
4import { Link } from 'react-router-dom';
4import api from '../api';
5import api from '../api';
5import { PostOptions } from '../components/PostOptions';
6import { PostOptions } from '../components/PostOptions';
7+import { Spinner } from '../components/Spinner';
6import { useGroups } from '../state/groups';
8import { useGroups } from '../state/groups';
7import { Declare, PostOption, PostOptionsForm } from '../types/sphinx';
9import { Declare, PostOption, PostOptionsForm } from '../types/sphinx';
8
10
9export const Groups = () => {
11export const Groups = () => {
10− const groups = useGroups();
12+ const { groups, loading } = useGroups();
11 const [firstLoad, setFirstLoad] = useState(true);
13 const [firstLoad, setFirstLoad] = useState(true);
12 const form = useForm<PostOptionsForm>({
14 const form = useForm<PostOptionsForm>({
13 defaultValues: {
15 defaultValues: {
@@ -35,8 +37,9 @@ export const Groups = () => {
35 }
37 }
36 }, [groups, options, setValue]);
38 }, [groups, options, setValue]);
37
39
40+ const queryClient = useQueryClient();
38− const onSubmit = useCallback((values: PostOptionsForm) => {
41+ const { mutate } = useMutation(async (values: PostOptionsForm) => {
39− api.poke<Declare[]>({
42+ return api.poke<Declare[]>({
40 app: 'sphinx',
43 app: 'sphinx',
41 mark: 'declarations',
44 mark: 'declarations',
42 json: groups.filter(a => values.options.includes(a.key)).map(a => ({
45 json: groups.filter(a => values.options.includes(a.key)).map(a => ({
@@ -44,9 +47,16 @@ export const Groups = () => {
44 reach: 'friends'
47 reach: 'friends'
45 }))
48 }))
46 });
49 });
47−
50+ }, {
51+ onSuccess: () => {
52+ queryClient.invalidateQueries('group-listings')
53+ }
54+ })
55+
56+ const onSubmit = useCallback((values: PostOptionsForm) => {
57+ mutate(values);
48 reset();
58 reset();
49− }, [reset]);
59+ }, [reset, mutate]);
50
60
51 return (
61 return (
52 <>
62 <>
@@ -61,7 +71,16 @@ export const Groups = () => {
61 </header>
71 </header>
62 <FormProvider {...form}>
72 <FormProvider {...form}>
63 <form onSubmit={handleSubmit(onSubmit)}>
73 <form onSubmit={handleSubmit(onSubmit)}>
74+ {groups.length === 0 && loading ? (
75+ <div className='flex items-center space-x-2 text-lavender'>
76+ <Spinner className='w-6 h-6' />
77+ <span>Checking groups...</span>
78+ </div>
79+ ) : groups.length === 0 && !loading ? (
64− <PostOptions options={groups} emptyMessage="All groups already indexed" />
80+ <h2 className='text-lavender'>All groups already indexed</h2>
81+ ) : (
82+ <PostOptions options={groups} />
83+ )}
65 <div className='flex justify-between border-t border-zinc-300 py-3 mt-6'>
84 <div className='flex justify-between border-t border-zinc-300 py-3 mt-6'>
66 <Link to="/search" 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'>
85 <Link to="/search" 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'>
67 Back to Search
86 Back to Search
ui/src/state/apps.ts+9−9
@@ -12,24 +12,24 @@ function getAppPost(app: string, vat: Vat, charge: Charge): Post {
12 link: `web+urbitgraph://${ship}/${app}`,
12 link: `web+urbitgraph://${ship}/${app}`,
13 description: charge.info || charge.title,
13 description: charge.info || charge.title,
14 image: charge.image || '',
14 image: charge.image || '',
15+ color: charge.color,
15 tags: ['app']
16 tags: ['app']
16 }
17 }
17}
18}
18
19
19export const useApps = () => {
20export const useApps = () => {
20− const { data: vats } = useQuery('vats', () => api.scry<Vats>(getVats));
21+ const { data: vats, isLoading: vatsLoading } = useQuery('vats', () => api.scry<Vats>(getVats));
21− const { data: charges } = useQuery('charges', () => api.scry<ChargeUpdateInitial>(scryCharges));
22+ const { data: charges, isLoading: chargesLoading } = useQuery('charges', () => api.scry<ChargeUpdateInitial>(scryCharges));
22− const { data } = useQuery('app-listings', () => api.scry<Search>({
23+ const { data, isLoading } = useQuery('app-listings', () => api.scry<Search>({
23 app: 'sphinx',
24 app: 'sphinx',
24 path: '/lookup/app/0/999'
25 path: '/lookup/app/0/999'
25 }));
26 }));
26
27
27− if (!vats || !charges || !data) {
28+ return {
28− return [];
29+ loading: vatsLoading || chargesLoading || isLoading,
29− }
30−
31− return Object.entries(charges.initial)
30+ apps: !vats || !charges || !data ? [] : Object.entries(charges.initial)
32 .filter(([k]) => k in vats)
31 .filter(([k]) => k in vats)
33 .map(([k,v]) => ({ key: k, post: getAppPost(k, vats[k], v) }))
32 .map(([k,v]) => ({ key: k, post: getAppPost(k, vats[k], v) }))
34− .filter(({ post }) => !data.listings.some(l => l.post.link === post.link || l.post.title === post.title));
33+ .filter(({ post }) => !data.listings.some(l => l.post.link === post.link || l.post.title === post.title))
34+ }
35}
35}
ui/src/state/groups.ts+10−10
@@ -5,27 +5,27 @@ import { PostOption, PostType, Search } from "../types/sphinx";
5
5
6
6
7
7
8−export const useGroups = (): PostOption[] => {
8+export const useGroups = () => {
9− const { data } = useQuery('metadata', () => api.subscribeOnce<{ 'metadata-update': MetadataUpdateInitial }>('metadata-store', '/all'));
9+ const { data, isLoading: metadataLoading } = useQuery('metadata', () => api.subscribeOnce<{ 'metadata-update': MetadataUpdateInitial }>('metadata-store', '/all'));
10− const { data: groupData } = useQuery('groups', () => api.subscribeOnce<{ groupUpdate: GroupUpdateInitial }>('group-store', '/groups'));
10+ const { data: groupData, isLoading: groupDataLoading } = useQuery('groups', () => api.subscribeOnce<{ groupUpdate: GroupUpdateInitial }>('group-store', '/groups'));
11− const { data: d } = useQuery('group-listings', () => api.scry<Search>({
11+ const { data: d, isLoading } = useQuery('group-listings', () => api.scry<Search>({
12 app: 'sphinx',
12 app: 'sphinx',
13 path: '/lookup/group/0/999'
13 path: '/lookup/group/0/999'
14 }));
14 }));
15
15
16− if (!data || !groupData || !d) {
16+ return {
17− return [];
17+ loading: metadataLoading || groupDataLoading || isLoading,
18− }
19−
20− return Object.entries(data['metadata-update'].associations)
18+ groups: !data || !groupData || !d ? [] : Object.entries(data['metadata-update'].associations)
21 .filter(([k,v]) => v['app-name'] === 'groups' && 'open' in (groupData.groupUpdate.initial[v.resource]?.policy || {}))
19 .filter(([k,v]) => v['app-name'] === 'groups' && 'open' in (groupData.groupUpdate.initial[v.resource]?.policy || {}))
22 .map(([k,v]) => ({ key: k, post: {
20 .map(([k,v]) => ({ key: k, post: {
23 type: 'group' as PostType,
21 type: 'group' as PostType,
24 title: v.metadata.title,
22 title: v.metadata.title,
25 link: v.group.replace('/ship/', 'web+urbitgraph://group/'),
23 link: v.group.replace('/ship/', 'web+urbitgraph://group/'),
26 image: v.metadata.picture,
24 image: v.metadata.picture,
25+ color: v.metadata.color,
27 description: v.metadata.description,
26 description: v.metadata.description,
28 tags: ['group']
27 tags: ['group']
29 }}))
28 }}))
30− .filter(({ post }) => !d.listings.some(l => l.post.link === post.link || l.post.title === post.title));
29+ .filter(({ post }) => !d.listings.some(l => l.post.link === post.link || l.post.title === post.title))
30+ }
31}
31}
ui/src/types/sphinx.ts+1
@@ -14,6 +14,7 @@ export interface Post {
14 type: PostType;
14 type: PostType;
15 description: string;
15 description: string;
16 image: string;
16 image: string;
17+ color: string;
17 tags: string[];
18 tags: string[];
18 link: string;
19 link: string;
19}
20}