on master help sign in
posts: adding capabilities to mass add posts, cleaning up mobile
commitf9ed835a813d11dcea92cae67efaca80fdb7b361 authorHunter Miller <hunter@hmiller.dev> dateSat 6 Aug 2022, 16:51 (4 years ago) parente43c462
29 files changed+716−118
ui/package-lock.json+93 ui/package.json+3 ui/postcss.config.js+2 ui/src/app.tsx+22−13 ui/src/components/CenterColumn.tsx+6 ui/src/components/Layout.tsx+62−29 ui/src/components/Meta.tsx−2 ui/src/components/PostOptions.tsx+21−15 ui/src/components/SearchInput.tsx+4−1 ui/src/components/Spinner.tsx+1−1 ui/src/env.d.ts+4 ui/src/index.css−20 ui/src/logic/useMedia.ts+25 ui/src/main.tsx+1−1 ui/src/manage-listings/Notebooks.tsx+68 ui/src/manage-listings/Post.tsx+12−8 ui/src/manage-listings/Posts.tsx+121 ui/src/pages/Search.tsx+2−1 ui/src/pages/Tag.tsx+10−1 ui/src/pages/Tags.tsx−16 ui/src/routes.ts+16 ui/src/state/groups.ts+18−8 ui/src/state/posts.ts+195 ui/src/state/search.ts+2−1 ui/src/styles/base.css ui/src/styles/components.css+16 ui/src/styles/index.css+8 ui/src/styles/utilities.css+3 ui/vite.config.ts+1−1
ui/package-lock.json+93
@@ -17,12 +17,14 @@
1717 "big-integer": "^1.6.51",
1818 "classnames": "^2.3.1",
1919 "lodash.debounce": "^4.0.8",
20+ "query-string": "^7.1.1",
2021 "react": "^17.0.2",
2122 "react-dom": "^17.0.2",
2223 "react-hook-form": "^7.33.1",
2324 "react-query": "^3.39.1",
2425 "react-router-dom": "^6.3.0",
2526 "react-select": "^5.4.0",
27+ "remove-markdown-and-html": "^0.1.0",
2628 "zustand": "^4.0.0-rc.1"
2729 },
2830 "devDependencies": {
@@ -33,6 +35,7 @@
3335 "@vitejs/plugin-react-refresh": "^1.3.1",
3436 "autoprefixer": "^10.4.7",
3537 "postcss": "^8.4.14",
38+ "postcss-import": "^14.1.0",
3639 "tailwindcss": "^3.1.4",
3740 "tailwindcss-labeled-groups": "^0.0.2",
3841 "typescript": "^4.3.2",
@@ -1565,6 +1568,14 @@
15651568 }
15661569 }
15671570 },
1571+ "node_modules/decode-uri-component": {
1572+ "version": "0.2.0",
1573+ "resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.2.0.tgz",
1574+ "integrity": "sha512-hjf+xovcEn31w/EUYdTXQh/8smFL/dzYjohQGEIgjyNavaJfBY2p5F527Bo1VPATxv0VYTUC2bOcXvqFwk78Og==",
1575+ "engines": {
1576+ "node": ">=0.10"
1577+ }
1578+ },
15681579 "node_modules/defined": {
15691580 "version": "1.0.0",
15701581 "resolved": "https://registry.npmjs.org/defined/-/defined-1.0.0.tgz",
@@ -2058,6 +2069,14 @@
20582069 "node": ">=8"
20592070 }
20602071 },
2072+ "node_modules/filter-obj": {
2073+ "version": "1.1.0",
2074+ "resolved": "https://registry.npmjs.org/filter-obj/-/filter-obj-1.1.0.tgz",
2075+ "integrity": "sha512-8rXg1ZnX7xzy2NGDVkBVaAy+lSlPNwad13BtgSlLuxfIslyt5Vg64U7tFcCt4WS1R0hvtnQybT/IyCkGZ3DpXQ==",
2076+ "engines": {
2077+ "node": ">=0.10.0"
2078+ }
2079+ },
20612080 "node_modules/find-root": {
20622081 "version": "1.1.0",
20632082 "resolved": "https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz",
@@ -2719,6 +2738,23 @@
27192738 "react-is": "^16.13.1"
27202739 }
27212740 },
2741+ "node_modules/query-string": {
2742+ "version": "7.1.1",
2743+ "resolved": "https://registry.npmjs.org/query-string/-/query-string-7.1.1.tgz",
2744+ "integrity": "sha512-MplouLRDHBZSG9z7fpuAAcI7aAYjDLhtsiVZsevsfaHWDS2IDdORKbSd1kWUA+V4zyva/HZoSfpwnYMMQDhb0w==",
2745+ "dependencies": {
2746+ "decode-uri-component": "^0.2.0",
2747+ "filter-obj": "^1.1.0",
2748+ "split-on-first": "^1.0.0",
2749+ "strict-uri-encode": "^2.0.0"
2750+ },
2751+ "engines": {
2752+ "node": ">=6"
2753+ },
2754+ "funding": {
2755+ "url": "https://github.com/sponsors/sindresorhus"
2756+ }
2757+ },
27222758 "node_modules/queue-microtask": {
27232759 "version": "1.2.3",
27242760 "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
@@ -3000,6 +3036,11 @@
30003036 "resolved": "https://registry.npmjs.org/remove-accents/-/remove-accents-0.4.2.tgz",
30013037 "integrity": "sha512-7pXIJqJOq5tFgG1A2Zxti3Ht8jJF337m4sowbuHsW30ZnkQFnDzy9qBNhgzX8ZLW4+UBcXiiR7SwR6pokHsxiA=="
30023038 },
3039+ "node_modules/remove-markdown-and-html": {
3040+ "version": "0.1.0",
3041+ "resolved": "https://registry.npmjs.org/remove-markdown-and-html/-/remove-markdown-and-html-0.1.0.tgz",
3042+ "integrity": "sha512-Sy03RJ/R/FBbK6wutBKKIyEVMQS+TzSpGc8pBxwrh0LiqyVIaD/LkitUABIJJ59iryhvrPTmS8dzd3DoLdopHg=="
3043+ },
30033044 "node_modules/resolve": {
30043045 "version": "1.22.1",
30053046 "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.1.tgz",
@@ -3125,6 +3166,22 @@
31253166 "node": ">=0.10.0"
31263167 }
31273168 },
3169+ "node_modules/split-on-first": {
3170+ "version": "1.1.0",
3171+ "resolved": "https://registry.npmjs.org/split-on-first/-/split-on-first-1.1.0.tgz",
3172+ "integrity": "sha512-43ZssAJaMusuKWL8sKUBQXHWOpq8d6CfN/u1p4gUzfJkM05C8rxTmYrkIPTXapZpORA6LkkzcUulJ8FqA7Uudw==",
3173+ "engines": {
3174+ "node": ">=6"
3175+ }
3176+ },
3177+ "node_modules/strict-uri-encode": {
3178+ "version": "2.0.0",
3179+ "resolved": "https://registry.npmjs.org/strict-uri-encode/-/strict-uri-encode-2.0.0.tgz",
3180+ "integrity": "sha512-QwiXZgpRcKkhTj2Scnn++4PKtWsH0kpzZ62L2R6c/LUVYv7hVnZqcg2+sMuT6R7Jusu1vviK/MFsu6kNJfWlEQ==",
3181+ "engines": {
3182+ "node": ">=4"
3183+ }
3184+ },
31283185 "node_modules/stylis": {
31293186 "version": "4.0.13",
31303187 "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.0.13.tgz",
@@ -4635,6 +4692,11 @@
46354692 "ms": "2.1.2"
46364693 }
46374694 },
4695+ "decode-uri-component": {
4696+ "version": "0.2.0",
4697+ "resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.2.0.tgz",
4698+ "integrity": "sha512-hjf+xovcEn31w/EUYdTXQh/8smFL/dzYjohQGEIgjyNavaJfBY2p5F527Bo1VPATxv0VYTUC2bOcXvqFwk78Og=="
4699+ },
46384700 "defined": {
46394701 "version": "1.0.0",
46404702 "resolved": "https://registry.npmjs.org/defined/-/defined-1.0.0.tgz",
@@ -4922,6 +4984,11 @@
49224984 "to-regex-range": "^5.0.1"
49234985 }
49244986 },
4987+ "filter-obj": {
4988+ "version": "1.1.0",
4989+ "resolved": "https://registry.npmjs.org/filter-obj/-/filter-obj-1.1.0.tgz",
4990+ "integrity": "sha512-8rXg1ZnX7xzy2NGDVkBVaAy+lSlPNwad13BtgSlLuxfIslyt5Vg64U7tFcCt4WS1R0hvtnQybT/IyCkGZ3DpXQ=="
4991+ },
49254992 "find-root": {
49264993 "version": "1.1.0",
49274994 "resolved": "https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz",
@@ -5390,6 +5457,17 @@
53905457 "react-is": "^16.13.1"
53915458 }
53925459 },
5460+ "query-string": {
5461+ "version": "7.1.1",
5462+ "resolved": "https://registry.npmjs.org/query-string/-/query-string-7.1.1.tgz",
5463+ "integrity": "sha512-MplouLRDHBZSG9z7fpuAAcI7aAYjDLhtsiVZsevsfaHWDS2IDdORKbSd1kWUA+V4zyva/HZoSfpwnYMMQDhb0w==",
5464+ "requires": {
5465+ "decode-uri-component": "^0.2.0",
5466+ "filter-obj": "^1.1.0",
5467+ "split-on-first": "^1.0.0",
5468+ "strict-uri-encode": "^2.0.0"
5469+ }
5470+ },
53935471 "queue-microtask": {
53945472 "version": "1.2.3",
53955473 "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
@@ -5570,6 +5648,11 @@
55705648 "resolved": "https://registry.npmjs.org/remove-accents/-/remove-accents-0.4.2.tgz",
55715649 "integrity": "sha512-7pXIJqJOq5tFgG1A2Zxti3Ht8jJF337m4sowbuHsW30ZnkQFnDzy9qBNhgzX8ZLW4+UBcXiiR7SwR6pokHsxiA=="
55725650 },
5651+ "remove-markdown-and-html": {
5652+ "version": "0.1.0",
5653+ "resolved": "https://registry.npmjs.org/remove-markdown-and-html/-/remove-markdown-and-html-0.1.0.tgz",
5654+ "integrity": "sha512-Sy03RJ/R/FBbK6wutBKKIyEVMQS+TzSpGc8pBxwrh0LiqyVIaD/LkitUABIJJ59iryhvrPTmS8dzd3DoLdopHg=="
5655+ },
55735656 "resolve": {
55745657 "version": "1.22.1",
55755658 "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.1.tgz",
@@ -5647,6 +5730,16 @@
56475730 "integrity": "sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==",
56485731 "dev": true
56495732 },
5733+ "split-on-first": {
5734+ "version": "1.1.0",
5735+ "resolved": "https://registry.npmjs.org/split-on-first/-/split-on-first-1.1.0.tgz",
5736+ "integrity": "sha512-43ZssAJaMusuKWL8sKUBQXHWOpq8d6CfN/u1p4gUzfJkM05C8rxTmYrkIPTXapZpORA6LkkzcUulJ8FqA7Uudw=="
5737+ },
5738+ "strict-uri-encode": {
5739+ "version": "2.0.0",
5740+ "resolved": "https://registry.npmjs.org/strict-uri-encode/-/strict-uri-encode-2.0.0.tgz",
5741+ "integrity": "sha512-QwiXZgpRcKkhTj2Scnn++4PKtWsH0kpzZ62L2R6c/LUVYv7hVnZqcg2+sMuT6R7Jusu1vviK/MFsu6kNJfWlEQ=="
5742+ },
56505743 "stylis": {
56515744 "version": "4.0.13",
56525745 "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.0.13.tgz",
ui/package.json+3
@@ -19,12 +19,14 @@
1919 "big-integer": "^1.6.51",
2020 "classnames": "^2.3.1",
2121 "lodash.debounce": "^4.0.8",
22+ "query-string": "^7.1.1",
2223 "react": "^17.0.2",
2324 "react-dom": "^17.0.2",
2425 "react-hook-form": "^7.33.1",
2526 "react-query": "^3.39.1",
2627 "react-router-dom": "^6.3.0",
2728 "react-select": "^5.4.0",
29+ "remove-markdown-and-html": "^0.1.0",
2830 "zustand": "^4.0.0-rc.1"
2931 },
3032 "devDependencies": {
@@ -35,6 +37,7 @@
3537 "@vitejs/plugin-react-refresh": "^1.3.1",
3638 "autoprefixer": "^10.4.7",
3739 "postcss": "^8.4.14",
40+ "postcss-import": "^14.1.0",
3841 "tailwindcss": "^3.1.4",
3942 "tailwindcss-labeled-groups": "^0.0.2",
4043 "typescript": "^4.3.2",
ui/postcss.config.js+2
@@ -1,5 +1,7 @@
11module.exports = {
22 plugins: [
3+ require('postcss-import'),
4+ require('tailwindcss/nesting'),
35 require('tailwindcss'),
46 require('autoprefixer'),
57 ]
ui/src/app.tsx+22−13
@@ -1,12 +1,15 @@
11import React from 'react';
22import { QueryClient, QueryClientProvider } from 'react-query';
33import { BrowserRouter, Route, Routes } from 'react-router-dom';
4+import { CenterColumn } from './components/CenterColumn';
45import { Layout } from './components/Layout';
56import { AllListings } from './manage-listings/AllListings';
67import { Apps } from './manage-listings/Apps';
78import { Groups } from './manage-listings/Groups';
89import { MyListings } from './manage-listings/MyListings';
10+import { Notebooks } from './manage-listings/Notebooks';
911import { Post } from './manage-listings/Post';
12+import { Posts } from './manage-listings/Posts';
1013import { Search } from './pages/Search';
1114import { Tag } from './pages/Tag';
1215
@@ -16,20 +19,26 @@ function Main() {
1619 return (
1720 <Routes>
1821 <Route element={<Layout />}>
19− <Route path="/" element={<Search />} />
20− <Route path="/search" element={<Search />} />
21− <Route path="/search/:lookup" element={<Search />} />
22− <Route path="/search/:lookup/:limit/:page" element={<Search />} />
23− <Route path="/tags" element={<Tag />} />
24− <Route path="/tags/:tag" element={<Tag />} />
25− <Route path="/tags/:tag/:limit/:page" element={<Tag />} />
22+ <Route element={<CenterColumn />}>
23+ <Route path="/" element={<Search />} />
24+ <Route path="/search" element={<Search />} />
25+ <Route path="/search/:lookup" element={<Search />} />
26+ <Route path="/search/:lookup/:limit/:page" element={<Search />} />
27+ <Route path="/tags" element={<Tag />} />
28+ <Route path="/tags/:tag" element={<Tag />} />
29+ <Route path="/tags/:tag/:limit/:page" element={<Tag />} />
30+ </Route>
2631 <Route path="/manage-listings">
27− <Route index element={<MyListings />} />
28− <Route path="all" element={<AllListings />} />
29− <Route path="all/:limit/:page" element={<AllListings />} />
30− <Route path="post" element={<Post />} />
31− <Route path="apps" element={<Apps />} />
32− <Route path="groups" element={<Groups />} />
32+ <Route element={<CenterColumn />}>
33+ <Route index element={<MyListings />} />
34+ <Route path="all" element={<AllListings />} />
35+ <Route path="all/:limit/:page" element={<AllListings />} />
36+ <Route path="post" element={<Post />} />
37+ <Route path="apps" element={<Apps />} />
38+ <Route path="groups" element={<Groups />} />
39+ </Route>
40+ <Route path="posts" element={<Notebooks />} />
41+ <Route path="posts/:ship/:name" element={<Notebooks />} />
3342 </Route>
3443 </Route>
3544 </Routes>
ui/src/components/CenterColumn.tsxnew file+6
@@ -0,0 +1,6 @@
1+import React from 'react';
2+import { Outlet } from 'react-router-dom';
3+
4+export const CenterColumn = () => (
5+ <div className="flex-1 flex flex-col max-w-2xl h-full w-full p-4 sm:py-12 sm:px-8 space-y-6">{<Outlet />}</div>
6+)
ui/src/components/Layout.tsx+62−29
@@ -1,40 +1,73 @@
1+import { MenuIcon } from '@heroicons/react/solid';
2+import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
13import cn from 'classnames';
24import React from 'react';
3−import { NavLink, Outlet } from 'react-router-dom';
5+import { NavLink, NavLinkProps, Outlet } from 'react-router-dom';
6+import useMedia from '../logic/useMedia';
7+import { BrowsingRoutes, PostingRoutes } from '../routes';
48import { Meta } from './Meta';
59
10+const DesktopNavLink = ({ to, children }: NavLinkProps) => (
11+ <li>
12+ <NavLink to={to} className={({ isActive }) => cn('hover:text-rosy transition-colors', isActive && 'underline')}>
13+ { children }
14+ </NavLink>
15+ </li>
16+)
17+
18+interface WrappedNavLinkProps extends NavLinkProps {
19+ navClassName: (props: { isActive: boolean }) => string;
20+}
21+
22+const WrappedNavLink = React.forwardRef<HTMLAnchorElement, WrappedNavLinkProps>(({ className, navClassName, ...props }, forwardedRef) => {
23+ return (
24+ <NavLink
25+ {...props}
26+ className={(props) => `${className} ${navClassName(props)}`}
27+ ref={forwardedRef}
28+ />
29+ );
30+});
31+
32+const MobileNavLink = ({ to, children }: NavLinkProps) => (
33+ <DropdownMenu.Item asChild>
34+ <WrappedNavLink to={to} navClassName={({ isActive }) => cn('block hover:text-rosy transition-colors', isActive && 'underline')}>
35+ { children }
36+ </WrappedNavLink>
37+ </DropdownMenu.Item>
38+)
39+
640export const Layout = () => {
41+ const isMobile = useMedia('(max-width: 639px)');
42+
743 return (
844 <main className={cn("flex flex-col items-center h-full min-h-screen")}>
9− <div className="flex-1 flex flex-col max-w-2xl h-full w-full p-4 sm:py-12 sm:px-8 space-y-6">
10− <Outlet />
11− </div>
45+ <Outlet />
1246 <aside className='self-start mx-4 mb-6 mt-auto sm:m-0 sm:fixed left-4 bottom-4'>
13− <nav className='mb-10'>
14− <ul className='font-semibold text-sm space-y-4'>
15− <li>
16− <NavLink to="/search" className={({ isActive }) => cn('hover:text-rosy transition-colors', isActive && 'underline')}>search</NavLink>
17− </li>
18− <li>
19− <NavLink to="/tags" className={({ isActive }) => cn('hover:text-rosy transition-colors', isActive && 'underline')}>tags</NavLink>
20− </li>
21− <li>
22− <NavLink to="/manage-listings" end className={({ isActive }) => cn('hover:text-rosy transition-colors', isActive && 'underline')}>my listings</NavLink>
23− </li>
24− <li>
25− <NavLink to="/manage-listings/all" end className={({ isActive }) => cn('hover:text-rosy transition-colors', isActive && 'underline')}>all listings</NavLink>
26− </li>
27− <li>
28− <NavLink to="/manage-listings/post" className={({ isActive }) => cn('hover:text-rosy transition-colors', isActive && 'underline')}>post listing</NavLink>
29− </li>
30− <li>
31− <NavLink to="/manage-listings/apps" className={({ isActive }) => cn('hover:text-rosy transition-colors', isActive && 'underline')}>add apps</NavLink>
32− </li>
33− <li>
34− <NavLink to="/manage-listings/groups" className={({ isActive }) => cn('hover:text-rosy transition-colors', isActive && 'underline')}>add groups</NavLink>
35− </li>
36− </ul>
37− </nav>
47+ {isMobile ? (
48+ <DropdownMenu.Root>
49+ <DropdownMenu.Trigger className='button fixed bottom-6 right-4 p-2 rounded-full' aria-label='Open Menu'>
50+ <MenuIcon className='w-6 h-6' />
51+ </DropdownMenu.Trigger>
52+ <DropdownMenu.Content sideOffset={16} className='min-w-[200px] bg-linen shadow-lg rounded-xl'>
53+ <div className='p-4 space-y-3 text-right bg-fawn/30 rounded-xl'>
54+ {Object.entries(BrowsingRoutes).map(([k,v]) => <MobileNavLink key={v} to={v}>{k}</MobileNavLink>)}
55+ <DropdownMenu.Separator className='border-t-2 border-mauve'/>
56+ {Object.entries(PostingRoutes).map(([k,v]) => <MobileNavLink key={v} to={v}>{k}</MobileNavLink>)}
57+ </div>
58+ </DropdownMenu.Content>
59+ </DropdownMenu.Root>
60+ ) : (
61+ <nav className='w-24 mb-10'>
62+ <ul className='font-semibold text-sm space-y-2'>
63+ {Object.entries(BrowsingRoutes).map(([k,v]) => <DesktopNavLink key={v} to={v}>{k}</DesktopNavLink>)}
64+ </ul>
65+ <hr className='block my-4 border-mauve/60'/>
66+ <ul className='font-semibold text-sm space-y-2'>
67+ {Object.entries(PostingRoutes).map(([k,v]) => <DesktopNavLink key={v} to={v}>{k}</DesktopNavLink>)}
68+ </ul>
69+ </nav>
70+ )}
3871 <Meta className='text-sm'/>
3972 </aside>
4073 </main>
ui/src/components/Meta.tsx−2
@@ -1,7 +1,5 @@
1−import { PlusIcon } from '@heroicons/react/solid';
21import { ChargeUpdateInitial, scryCharges } from '@urbit/api';
32import React, { useEffect, useState } from 'react';
4−import { Link } from 'react-router-dom';
53import api from '../api';
64import { Logo } from './Logo';
75
ui/src/components/PostOptions.tsx+21−15
@@ -7,41 +7,47 @@ import { PostOption, PostOptionsForm } from '../types/sphinx';
77
88interface PostOptionsProps {
99 options: PostOption[];
10+ showTags?: boolean;
1011 className?: string;
1112}
1213
13−export const PostOptions = ({ options, className }: PostOptionsProps) => {
14+export const PostOptions = ({ options, showTags = false, className }: PostOptionsProps) => {
1415 const { register } = useFormContext<PostOptionsForm>();
1516
1617 return (
1718 <ul className={cn('space-y-3', className)}>
18− {options && options.map(o => (
19+ {options && options.map((o, index) => (
1920 <li key={o.key}>
2021 <input {...register('options')} id={o.key} value={o.key} type="checkbox" className='sr-only' />
21− <label htmlFor={o.key} className='checked-bg-fawn group-1 flex w-full p-2 hover:bg-fawn cursor-pointer rounded-xl'>
22+ <label htmlFor={o.key} className='checked-bg-fawn group-1 relative grid grid-rows-[auto,auto] sm:grid-rows-1 grid-cols-[64px,1fr] sm:grid-cols-[64px,1fr,24px] w-full p-2 hover:bg-fawn cursor-pointer rounded-xl'>
23+ <div className='absolute z-10 top-7 left-7 flex items-center justify-center h-6 w-6 border-2 border-solid border-mauve rounded bg-linen/80'>
24+ <CheckIcon className='show-when-checked h-4 w-4 opacity-0' />
25+ </div>
2226 {o.post.image ? (
2327 <img
2428 src={o.post.image}
25− className="w-16 h-16 object-cover rounded-lg"
29+ className="show-when-checked w-16 h-16 col-span-1 object-cover rounded-lg opacity-60"
30+ loading={index > 15 ? 'lazy' : 'eager'}
2631 style={{ backgroundColor: o.post.color ? `#${uxToHex(o.post.color)}` : undefined }}
2732 />
2833 ) : (
2934 <div
30− className='w-16 h-16 bg-rosy/20 rounded-lg'
35+ className='w-16 h-16 col-span-1 bg-rosy/20 rounded-lg'
3136 style={{ backgroundColor: o.post.color ? `#${uxToHex(o.post.color)}` : undefined }}
3237 />
3338 )}
34− <div className='flex-1 ml-4'>
35− <span className='block font-semibold text-lg leading-none mb-1'>{o.post.title}</span>
36− <div className='font-mono text-xs space-x-3'>
37− <strong>%{o.post.type}</strong>
38− <span>{o.post.link.replace('web+urbitgraph://', '')}</span>
39− </div>
40− <p className='text-sm'>{o.post.description}</p>
41− </div>
42− <div className='flex items-center justify-center m-5 h-6 w-6 border-2 border-solid border-mauve rounded'>
43− <CheckIcon className='show-when-checked h-4 w-4 opacity-0' />
39+ <div className='ml-4 min-w-0 sm:space-y-2'>
40+ <span className='block font-semibold text-lg leading-none mb-2 sm:mb-0'>{o.post.title}</span>
41+ <div className='flex flex-col sm:flex-row sm:items-center w-full text-xs space-y-2 sm:space-y-0 sm:space-x-3 truncate'>
42+ <strong className='font-mono'>%{o.post.type}</strong>
43+ {showTags && <p><strong>tags: </strong><em>{o.post.tags.join()}</em></p>}
44+ </div>
45+ <p className='hidden sm:block font-mono text-xs truncate'>{o.post.link.replace('web+urbitgraph://', '')}</p>
4446 </div>
47+ <div className='col-span-2 row-start-2 mt-2'>
48+ <p className='font-mono text-xs truncate sm:hidden'>{o.post.link.replace('web+urbitgraph://', '')}</p>
49+ <p className='text-sm'>{o.post.description}</p>
50+ </div>
4551 </label>
4652 </li>
4753 ))}
ui/src/components/SearchInput.tsx+4−1
@@ -1,14 +1,16 @@
11import { SearchIcon } from '@heroicons/react/solid';
22import cn from 'classnames';
33import React, { ChangeEvent, useCallback } from 'react';
4+import { Spinner } from './Spinner';
45
56interface SearchInputProps {
67 lookup: string;
8+ loading: boolean;
79 className?: string;
810 onChange: (value: string) => void;
911}
1012
11−export const SearchInput = ({ lookup, className, onChange }: SearchInputProps) => {
13+export const SearchInput = ({ lookup, loading, className, onChange }: SearchInputProps) => {
1214 const onInput = useCallback((event: ChangeEvent<HTMLInputElement>) => {
1315 const { value } = event.target;
1416 onChange(value);
@@ -24,6 +26,7 @@ export const SearchInput = ({ lookup, className, onChange }: SearchInputProps) =
2426 placeholder="Search"
2527 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')}
2628 />
29+ {loading && <Spinner className='absolute right-2 h-5 w-5' />}
2730 </div>
2831 )
2932}
ui/src/components/Spinner.tsx+1−1
@@ -4,7 +4,7 @@ import React from 'react';
44export const Spinner = ({ className }: { className: string }) => {
55 return (
66 <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>
7+ <circle cx="12" cy="12" r="10" className="stroke-current" strokeOpacity="0.4" strokeWidth="4"></circle>
88 <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>
99 </svg>
1010 )
ui/src/env.d.ts+4
@@ -5,3 +5,7 @@ interface ImportMetaEnv extends Readonly<Record<string, string | boolean | undef
55interface ImportMeta {
66 readonly env: ImportMetaEnv;
77}
8+
9+declare module 'remove-markdown-and-html' {
10+ export default function removeMd(input: string): string;
11+}
ui/src/index.cssdeleted−20
@@ -1,20 +0,0 @@
1−@tailwind base;
2−@tailwind components;
3−@tailwind utilities;
4−
5−.flip {
6− transform: rotateY(180deg);
7−}
8−
9−input:checked + label .show-when-checked {
10− @apply opacity-100;
11−}
12−
13−input:checked + .checked-bg-fawn,
14−input:checked + label .checked-bg-fawn {
15− @apply bg-fawn;
16−}
17−
18−.spinner {
19− @apply inline-flex items-center w-6 h-6 animate-spin;
20−}
ui/src/logic/useMedia.tsnew file+25
@@ -0,0 +1,25 @@
1+import { useCallback, useEffect, useState } from 'react';
2+
3+export default function useMedia(mediaQuery: string) {
4+ const [match, setMatch] = useState(false);
5+
6+ const update = useCallback((e: MediaQueryListEvent) => {
7+ setMatch(e.matches);
8+ }, []);
9+
10+ useEffect(() => {
11+ const query = window.matchMedia(mediaQuery);
12+
13+ query.addEventListener('change', update);
14+ update({ matches: query.matches } as MediaQueryListEvent);
15+ return () => {
16+ query.removeEventListener('change', update);
17+ };
18+ }, [update, mediaQuery]);
19+
20+ return match;
21+}
22+
23+export function useIsMobile() {
24+ return useMedia('(max-width: 639px)');
25+}
ui/src/main.tsx+1−1
@@ -1,7 +1,7 @@
11import React from 'react';
22import ReactDOM from 'react-dom';
33import { App } from './app';
4−import './index.css';
4+import './styles/index.css';
55
66ReactDOM.render(
77 <React.StrictMode>
ui/src/manage-listings/Notebooks.tsxnew file+68
@@ -0,0 +1,68 @@
1+import { uxToHex } from '@urbit/api';
2+import cn from 'classnames';
3+import React from 'react';
4+import { Link, NavLink, useParams } from 'react-router-dom';
5+import { Spinner } from '../components/Spinner';
6+import useMedia, { useIsMobile } from '../logic/useMedia';
7+import { useNotebooks } from '../state/posts';
8+import { Posts } from './Posts';
9+
10+export const Notebooks = () => {
11+ const { ship, name } = useParams<{ ship: string, name: string }>();
12+ const limited = useMedia('(max-width: 899px)');
13+ const { groups, notebooks, loading } = useNotebooks();
14+ const sortedNotebooks = Object.entries(notebooks)
15+ .sort(([,a], [,b]) => a.metadata.title.localeCompare(b.metadata.title));
16+
17+ return (
18+ <div className="flex-none flex flex-col max-w-5xl w-full p-4 sm:py-12 sm:px-8 space-y-6 sm:pl-32 xl:pl-8">
19+ <div className='flex items-start'>
20+ {((limited && !ship) || !limited) &&
21+ <aside className='sticky top-4 sm:top-12 flex-none flex flex-col sm:w-72 sm:h-[80vh] pt-14 sm:pt-0 mr-6 self-start'>
22+ <header className='flex-none fixed sm:static top-0 w-full p-4 pl-0 sm:p-0 bg-linen'>
23+ <h1 className='text-2xl font-semibold ml-2 sm:mb-6'>Add Posts</h1>
24+ </header>
25+ <ul className='flex-1 sm:overflow-y-auto'>
26+ {loading && <div className='flex items-center space-x-2 text-lavender mt-6'>
27+ <Spinner className='w-6 h-6' />
28+ <span>Loading notebooks...</span>
29+ </div>}
30+ {sortedNotebooks.map(([id, n], index) => {
31+ const [,group] = groups.find(([,v]) => v.group === n.group) || [];
32+
33+ return (
34+ <li key={id}>
35+ <NavLink to={`/manage-listings/posts/${id}`} className={({ isActive }) => cn('flex items-center p-2 space-x-2 leading-tight rounded-md hover:bg-fawn', isActive && 'bg-fawn')}>
36+ {group && group.metadata.picture ? (
37+ <img
38+ src={group.metadata.picture}
39+ className="flex-none w-10 h-10 object-cover rounded-md"
40+ loading={index > 15 ? 'lazy' : 'eager'}
41+ style={{ backgroundColor: group.metadata.color ? `#${uxToHex(group.metadata.color)}` : undefined }}
42+ />
43+ ) : group ? (
44+ <div
45+ className='flex-none w-10 h-10 bg-rosy/20 rounded-md'
46+ style={{ backgroundColor: group.metadata.color ? `#${uxToHex(group.metadata.color)}` : undefined }}
47+ />
48+ ) : null}
49+ <div className='flex flex-col min-w-0'>
50+ <strong className='w-full truncate'>{n.metadata.title}</strong>
51+ {group && <span className='text-sm'>{group.metadata.title}</span>}
52+ </div>
53+ </NavLink>
54+ </li>
55+ )}
56+ )}
57+ </ul>
58+ </aside>
59+ }
60+ {((limited && ship) || !limited) &&
61+ <section className='flex-1 min-w-0 space-y-6'>
62+ {!loading && notebooks && ship && name && <Posts ship={ship} name={name} />}
63+ </section>
64+ }
65+ </div>
66+ </div>
67+ )
68+}
ui/src/manage-listings/Post.tsx+12−8
@@ -1,13 +1,13 @@
11import debounce from 'lodash.debounce';
22import React, { useCallback, useEffect, useRef, useState } from 'react';
33import { FormProvider, useForm } from 'react-hook-form';
4−import { Link, useNavigate } from 'react-router-dom';
4+import { Link, useNavigate, useSearchParams } from 'react-router-dom';
55import { MultiValue } from 'react-select';
66import api from '../api';
77import { ErrorMessage } from '../components/ErrorMessage';
88import { Filter } from '../components/Filter';
99import { Option, TagField } from '../components/TagField';
10−import { Declare, Post as PostForm } from '../types/sphinx';
10+import { Declare, Post as PostForm, PostType } from '../types/sphinx';
1111
1212function errorMessages(length: number) {
1313 return {
@@ -17,15 +17,18 @@ function errorMessages(length: number) {
1717}
1818
1919export const Post = () => {
20− const [tags, setTags] = useState<MultiValue<Option>>([]);
20+ const [params] = useSearchParams();
21+ const defaultTags = params.getAll('tags').map(t => ({ label: t, value: t})) || [];
22+ const [tags, setTags] = useState<MultiValue<Option>>(defaultTags);
2123 const [image, setImage] = useState<string>('');
2224 const form = useForm<PostForm>({
2325 defaultValues: {
24− title: '',
25− type: 'other',
26− link: '',
27− image: '',
28− description: ''
26+ title: params.get('title') || '',
27+ type: params.get('type') as PostType || 'other',
28+ link: params.get('link') || '',
29+ image: params.get('image') || '',
30+ description: params.get('description') || '',
31+ color: params.get('color') || ''
2932 }
3033 });
3134
@@ -36,6 +39,7 @@ export const Post = () => {
3639 const type = watch('type');
3740
3841 const onSubmit = useCallback((values: Omit<PostForm, 'tags'>) => {
42+ debugger;
3943 api.poke<Declare>({
4044 app: 'sphinx',
4145 mark: 'declare',
ui/src/manage-listings/Posts.tsxnew file+121
@@ -0,0 +1,121 @@
1+import { ChevronLeftIcon } from '@heroicons/react/solid';
2+import React, { useCallback, useEffect, useState } from 'react';
3+import { FormProvider, useForm } from 'react-hook-form';
4+import { useMutation, useQueryClient } from 'react-query';
5+import { Link, useParams } from 'react-router-dom';
6+import { MultiValue } from 'react-select';
7+import api from '../api';
8+import { Filter } from '../components/Filter';
9+import { PostOptions } from '../components/PostOptions';
10+import { Spinner } from '../components/Spinner';
11+import { TagField, Option } from '../components/TagField';
12+import useMedia from '../logic/useMedia';
13+import { usePosts } from '../state/posts';
14+import { Declare, PostOptionsForm, PostType } from '../types/sphinx';
15+
16+interface PostsProps {
17+ ship: string;
18+ name: string;
19+}
20+
21+export const Posts = ({ ship, name }: PostsProps) => {
22+ const limited = useMedia('(max-width: 899px)');
23+ const [postType, setPostType] = useState<PostType>('content')
24+ const [tags, setTags] = useState<MultiValue<Option>>([{ label: 'post', value: 'post' }])
25+ const { loading, posts, notebook } = usePosts(ship, name, postType, tags.map(t => t.value));
26+ const [firstLoad, setFirstLoad] = useState(true);
27+ const form = useForm<PostOptionsForm>({
28+ defaultValues: {
29+ options: []
30+ }
31+ });
32+ const { handleSubmit, reset, watch, setValue } = form;
33+ const options = watch('options');
34+
35+ useEffect(() => {
36+ if (posts.length > 0 && firstLoad) {
37+ reset({
38+ options: []
39+ })
40+
41+ setFirstLoad(false);
42+ }
43+ }, [posts, firstLoad]);
44+
45+ const toggleAll = useCallback(() => {
46+ if (options.length !== posts.length) {
47+ setValue('options', posts.map(p => p.key));
48+ } else {
49+ setValue('options', []);
50+ }
51+ }, [posts, options, setValue]);
52+
53+ const queryClient = useQueryClient();
54+ const { mutate } = useMutation(async (values: PostOptionsForm) => {
55+ return api.poke<Declare[]>({
56+ app: 'sphinx',
57+ mark: 'declarations',
58+ json: posts.filter(a => values.options.includes(a.key)).map(a => ({
59+ post: a.post,
60+ reach: 'friends'
61+ }))
62+ });
63+ }, {
64+ onSuccess: () => {
65+ queryClient.invalidateQueries('post-listings')
66+ }
67+ })
68+
69+ const onSubmit = useCallback((values: PostOptionsForm) => {
70+ mutate(values);
71+ reset();
72+ }, [reset, mutate]);
73+
74+ return (
75+ <>
76+ <header className='flex items-center'>
77+ {limited && (
78+ <Link to="/manage-listings/posts" className='flex items-center'>
79+ <ChevronLeftIcon className='h-6 w-6 text-mauve/60' />
80+ <h1 className='text-2xl font-semibold'>{notebook?.metadata?.title}</h1>
81+ </Link>
82+ )}
83+ {!limited && <h1 className='text-2xl font-semibold'>{notebook?.metadata?.title}</h1>}
84+ {posts.length > 0 && (
85+ <button className='p-1.5 ml-auto text-sm underline font-semibold' onClick={toggleAll}>
86+ {options.length !== posts.length && 'select all'}
87+ {options.length === posts.length && 'deselect all'}
88+ </button>
89+ )}
90+ </header>
91+ <div className='flex flex-col items-end sm:flex-row sm:items-center space-y-2 sm:space-y-0 sm:space-x-2'>
92+ <TagField tags={tags} onTags={setTags} className="flex-1 w-full"/>
93+ <Filter className='flex-none' selected={postType} onSelect={(value) => setPostType(value)} />
94+ </div>
95+ <FormProvider {...form}>
96+ <form onSubmit={handleSubmit(onSubmit)}>
97+ {posts.length === 0 && loading ? (
98+ <div className='flex items-center space-x-2 text-lavender'>
99+ <Spinner className='w-6 h-6' />
100+ <span>Checking posts...</span>
101+ </div>
102+ ) : posts.length === 0 && !loading ? (
103+ <h2 className='text-lavender'>All posts already indexed</h2>
104+ ) : (
105+ <PostOptions options={posts} showTags />
106+ )}
107+ <div className='flex justify-between border-t border-zinc-300 py-3 mt-6'>
108+ <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'>
109+ Back to Search
110+ </Link>
111+ {posts.length > 0 && (
112+ <button type="submit" className='flex items-center rounded-lg text-base font-semibold text-linen bg-rosy disabled:bg-zinc-200 disabled:text-zinc-400 disabled:border-transparent border-2 border-transparent hover:border-linen/60 leading-none py-2 px-3 transition-colors' disabled={options.length === 0}>
113+ Publish
114+ </button>
115+ )}
116+ </div>
117+ </form>
118+ </FormProvider>
119+ </>
120+ );
121+}
ui/src/pages/Search.tsx+2−1
@@ -39,6 +39,7 @@ export const Search = () => {
3939 pageInt,
4040 pages,
4141 results,
42+ isLoading,
4243 linkBuild
4344 } = useSearch({
4445 key: (start, size) => `lookup-${selected}-${size}-${start}-${lookup}`,
@@ -81,7 +82,7 @@ export const Search = () => {
8182 return (
8283 <div className={cn('w-full space-y-6', !lookup && 'm-auto')}>
8384 <header className='flex items-center space-x-2'>
84− <SearchInput className='flex-1' lookup={rawSearch} onChange={onChange} />
85+ <SearchInput className='flex-1' lookup={rawSearch} loading={isLoading} onChange={onChange} />
8586 <Filter selected={selected} onSelect={setSelected} className="min-w-0 sm:w-20" />
8687 </header>
8788 {!palsInstalled && (
ui/src/pages/Tag.tsx+10−1
@@ -1,4 +1,6 @@
1+import { PlusIcon, PlusSmIcon } from '@heroicons/react/solid';
12import cn from 'classnames';
3+import qs from 'query-string';
24import React from 'react';
35import { useQueryClient, useMutation } from 'react-query';
46import { Link, useParams } from 'react-router-dom';
@@ -59,8 +61,15 @@ export const Tag = () => {
5961 <div className={cn('h-[136px] overflow-y-auto', tag && 'mb-12')}>
6062 <TagCloud tags={tags} />
6163 </div>
62− <header className='flex items-center'>
64+ <header className='flex items-center justify-between'>
6365 <h1 className='text-2xl font-semibold leading-none'>{tag}</h1>
66+ <Link
67+ to={{ pathname: '/manage-listings/post', search: qs.stringify({ tags: [tag] }) }}
68+ className="inline-flex items-center px-2 pr-3 py-1 font-semibold text-linen bg-lavender hover:bg-rosy transition-colors rounded-lg"
69+ >
70+ <PlusSmIcon className='w-5 h-5' />
71+ <span>Add</span>
72+ </Link>
6473 </header>
6574 {tag && results && <div className='flex justify-end border-t border-zinc-300'>
6675 <Paginator pages={pages} currentPage={pageInt} linkBuilder={linkBuild} />
ui/src/pages/Tags.tsxdeleted−16
@@ -1,16 +0,0 @@
1−import React from 'react';
2−import { TagCloud } from '../components/TagCloud';
3−import { useTags } from '../state/tags';
4−
5−export const Tags = () => {
6− const tags = useTags();
7−
8− return (
9− <>
10− <header className='flex items-center'>
11− <h1 className='text-2xl font-semibold'>Tags</h1>
12− </header>
13− <TagCloud tags={tags} />
14− </>
15− )
16−}
ui/src/routes.tsnew file+16
@@ -0,0 +1,16 @@
1+export const BrowsingRoutes = {
2+ search: "/search",
3+ tags: "/tags",
4+ apps: "/tags/app",
5+ groups: "/tags/group",
6+ jobs: "/tags/job",
7+ 'my listings': "/manage-listings",
8+ 'all listings': "/manage-listings/all"
9+}
10+
11+export const PostingRoutes = {
12+ 'post listing': "/manage-listings/post",
13+ 'add apps': "/manage-listings/apps",
14+ 'add groups': "/manage-listings/groups",
15+ 'add posts': "/manage-listings/posts"
16+}
ui/src/state/groups.ts+18−8
@@ -1,23 +1,33 @@
1−import { GroupUpdateInitial, MetadataUpdateInitial } from "@urbit/api";
1+import { Associations, GroupUpdateInitial, MetadataUpdateInitial } from "@urbit/api";
22import { useQuery } from "react-query"
33import api from "../api"
44import { PostOption, PostType, Search } from "../types/sphinx";
55
6+export type MetaDataUpdate = { 'metadata-update': MetadataUpdateInitial };
67
8+export const usePublicGroups = (metadata?: { 'metadata-update': MetadataUpdateInitial }) => {
9+ const { data, isLoading } = useQuery('metadata', () => api.subscribeOnce<MetaDataUpdate>('metadata-store', '/all'), {
10+ enabled: !metadata
11+ });
12+ const { data: groupData, isLoading: groupDataLoading } = useQuery('groups', () => api.subscribeOnce<{ groupUpdate: GroupUpdateInitial }>('group-store', '/groups'));
13+
14+ return {
15+ loading: isLoading || groupDataLoading,
16+ groups: !data || !groupData ? [] : Object.entries(data['metadata-update'].associations)
17+ .filter(([k,v]) => v['app-name'] === 'groups' && 'open' in (groupData.groupUpdate.initial[v.resource]?.policy || {}))
18+ }
19+}
720
821export const useGroups = () => {
9− const { data, isLoading: metadataLoading } = useQuery('metadata', () => api.subscribeOnce<{ 'metadata-update': MetadataUpdateInitial }>('metadata-store', '/all'));
10− const { data: groupData, isLoading: groupDataLoading } = useQuery('groups', () => api.subscribeOnce<{ groupUpdate: GroupUpdateInitial }>('group-store', '/groups'));
22+ const { groups, loading } = usePublicGroups();
1123 const { data: d, isLoading } = useQuery('group-listings', () => api.scry<Search>({
1224 app: 'sphinx',
1325 path: '/lookup/group/0/999'
1426 }));
1527
1628 return {
17− loading: metadataLoading || groupDataLoading || isLoading,
18− groups: !data || !groupData || !d ? [] : Object.entries(data['metadata-update'].associations)
19− .filter(([k,v]) => v['app-name'] === 'groups' && 'open' in (groupData.groupUpdate.initial[v.resource]?.policy || {}))
20− .map(([k,v]) => ({ key: k, post: {
29+ loading: loading || isLoading,
30+ groups: groups.map(([k,v]) => ({ key: k, post: {
2131 type: 'group' as PostType,
2232 title: v.metadata.title,
2333 link: v.group.replace('/ship/', 'web+urbitgraph://group/'),
@@ -26,6 +36,6 @@ export const useGroups = () => {
2636 description: v.metadata.description,
2737 tags: ['group']
2838 }}))
29− .filter(({ post }) => !d.listings.some(l => l.post.link === post.link || l.post.title === post.title))
39+ .filter(({ post }) => !d || d.listings.length === 0 || !d.listings.some(l => l.post.link === post.link || l.post.title === post.title))
3040 }
3141}
ui/src/state/posts.tsnew file+195
@@ -0,0 +1,195 @@
1+import { Association, BigIntOrderedMap, buntPost, Content, getGraph, getKeys, Graph, GraphNode, MetadataConfig, Post, resourceFromPath, TextContent, UrlContent } from "@urbit/api";
2+import bigInt, { BigInteger } from "big-integer";
3+import removeMd from 'remove-markdown-and-html';
4+import _ from "lodash";
5+import { useQuery } from "react-query"
6+import api from "../api"
7+import { PostType, Search } from "../types/sphinx";
8+import { MetaDataUpdate, usePublicGroups } from "./groups";
9+
10+interface Resource {
11+ ship: string;
12+ name: string;
13+}
14+
15+interface GraphKeyUpdate {
16+ 'graph-update': {
17+ keys: Resource[]
18+ }
19+}
20+
21+export const useNotebooks = () => {
22+ const { data: keys, isLoading: keysLoading } = useQuery('graph-keys', async () => {
23+ const json = await api.scry<GraphKeyUpdate>(getKeys())
24+ const update = _.get(json, 'graph-update', false);
25+ const data: Resource[] = _.get(update, 'keys', false) || [];
26+
27+ return new Set(data.map((res) => {
28+ const resource = res.ship + '/' + res.name;
29+ return resource;
30+ }));
31+ });
32+ const { data, isLoading: metadataLoading } = useQuery('metadata', () => api.subscribeOnce<MetaDataUpdate>('metadata-store', '/all'));
33+ const { groups, loading } = usePublicGroups(data || {} as MetaDataUpdate);
34+
35+ console.log(keys);
36+ const notebooks: Record<string, Association<MetadataConfig>> = _.fromPairs(!data || loading ? [] : Object.entries(data['metadata-update'].associations)
37+ .filter(([,v]) => v['app-name'] === 'graph'
38+ && 'graph' in v.metadata.config
39+ && v.metadata.config.graph === 'publish'
40+ && keys?.has(v.resource.replace('/ship/~', ''))
41+ && groups.find(([, gv]) => gv.group === v.group)
42+ ).map(([,v]) => [v.resource.replace('/ship/', ''), v]));
43+
44+ console.log(data)
45+
46+ return {
47+ loading: metadataLoading || loading,
48+ notebooks,
49+ groups
50+ }
51+}
52+
53+export const usePosts = (ship: string, name: string, type: PostType, tags: string[]) => {
54+ const { notebooks, loading } = useNotebooks();
55+ const association = notebooks[`${ship}/${name}`] || {};
56+ const { data, isLoading } = useQuery(`graph-${association?.resource}`, () => api.scry<Graph>(getGraph(ship, name)), {
57+ enabled: !!association?.resource
58+ });
59+ const { data: d, isLoading: postsLoading } = useQuery('post-listings', () => api.scry<Search>({
60+ app: 'sphinx',
61+ path: '/lookup/tag/0/999/~~post'
62+ }));
63+ const graph = addGraph(data);
64+ const items = data && graph ? Array.from(graph)
65+ .filter(([, node]) => node && typeof node?.post !== 'string')
66+ .map(([date, node]) => {
67+ const index = node.post.index.split('/');
68+ const noteId = bigInt(index[1]);
69+ const { title, body } = getLatestRevision(node);
70+ const snippet = getSnippet(body);
71+ const permalink = getPermalinkForGraph(
72+ association.group,
73+ association.resource,
74+ `/${noteId.toString()}`
75+ );
76+
77+ return { key: date.toString(), post: {
78+ type,
79+ title,
80+ link: permalink,
81+ image: snippet.image,
82+ color: '',
83+ description: snippet.content,
84+ tags
85+ }}
86+ }) : [];
87+
88+ const posts = items.filter(p => !d || d.listings.length === 0 || d.listings.find(l => l.post.link === p.post.link));
89+
90+ return {
91+ notebook: association,
92+ loading: loading || isLoading || postsLoading,
93+ posts
94+ }
95+}
96+
97+function getImage(snippet: string) {
98+ const pattern = /!\[.*\]\((.*)\)/
99+ const match = snippet.match(pattern);
100+
101+ if (!match) {
102+ return '';
103+ }
104+
105+ return match[1];
106+}
107+
108+function getLatestRevision(node: GraphNode): { id: number, title: string, body: Content[], post: Post } {
109+ const empty = { id: 1, title: '', body: [], post: buntPost() };
110+ const revs = node.children?.get(bigInt(1));
111+ if (!revs || revs.children === null) {
112+ return empty;
113+ }
114+
115+ const [revNum, rev] = [...revs.children][0];
116+ const title = rev.post.contents[0] as TextContent;
117+ const body = rev.post.contents.slice(1);
118+ return {
119+ id: revNum.toJSNumber(),
120+ title: title.text,
121+ body,
122+ post: rev.post
123+ };
124+}
125+
126+export function getSnippet(body: Content[]): { image: string, content: string } {
127+ const IMAGE_REGEX = new RegExp(
128+ /(\.jpg|\.img|\.png|\.gif|\.tiff|\.jpeg|\.webp|\.webm|\.svg)$/i
129+ );
130+
131+ let sum = 0;
132+ const firstContent = body.reduce((text, c) => {
133+ debugger;
134+ if ('text' in c && sum < 255) {
135+ sum += removeMd(c.text).length;
136+ return text + c.text + ' ';
137+ }
138+
139+ return text;
140+ }, '');
141+ const image = body.filter((c: Content): c is UrlContent => 'url' in c && IMAGE_REGEX.test(c.url)).map(c => c.url)[0] || '';
142+ const content = removeMd(firstContent.replace(/!\[.*\]\((.*)\)\s*/g, ''));
143+ const end = content.length > 256 ? 255 : content.length;
144+ const start = content.substring(0, end);
145+
146+ if (firstContent.match(/jiddu/i)) {
147+ debugger;
148+ }
149+ return {
150+ content: start === firstContent ? start : `${start}...`,
151+ image: getImage(firstContent) || image
152+ }
153+}
154+
155+export function getPermalinkForGraph(
156+ group: string,
157+ graph: string,
158+ index = ''
159+) {
160+ const groupLink = getPermalinkForAssociatedGroup(group);
161+ const { ship, name } = resourceFromPath(graph);
162+ return `${groupLink}/graph/${ship}/${name}${index}`;
163+}
164+
165+function getPermalinkForAssociatedGroup(group: string) {
166+ const { ship, name } = resourceFromPath(group);
167+ return `web+urbitgraph://group/${ship}/${name}`;
168+}
169+
170+const addGraph = (json: any): Graph => {
171+ const update = _.get(json, 'graph-update', false);
172+ const data = _.get(update, 'add-graph', false);
173+ let graph = new BigIntOrderedMap<GraphNode>();
174+ const items: [BigInteger, GraphNode][] = data.graph ? Object.entries(data.graph as any).map(([idx, value]) => [bigInt(idx), processNode(value as GraphNode)]) : [];
175+
176+ if (data) {
177+ graph = graph.gas(items);
178+ }
179+
180+ return graph;
181+};
182+
183+const processNode = (node: GraphNode): GraphNode => {
184+ const empty = new BigIntOrderedMap<GraphNode>();
185+ const items = _.map<GraphNode, [BigInteger, GraphNode]>((node.children || {}) as any, (item: GraphNode, idx: number) => {
186+ return [bigInt(idx), processNode(item)]
187+ })
188+
189+ const newNode = {
190+ ...node,
191+ children: !node.children ? empty : empty.gas(items)
192+ };
193+
194+ return newNode;
195+};
ui/src/state/search.ts+2−1
@@ -16,7 +16,7 @@ export const useSearch = ({ key, fetcher, enabled, limit, page, linkPrefix }: Us
1616 const pageInt = parseInt(page || '1', 10) - 1;
1717 const start = pageInt * size;
1818
19− const { data } = useQuery<unknown, unknown, Search>(key(start, size), () => fetcher(start, size), {
19+ const { data, ...query } = useQuery<unknown, unknown, Search>(key(start, size), () => fetcher(start, size), {
2020 enabled,
2121 keepPreviousData: true
2222 });
@@ -39,6 +39,7 @@ export const useSearch = ({ key, fetcher, enabled, limit, page, linkPrefix }: Us
3939 results: data || {
4040 listings: []
4141 },
42+ ...query,
4243 size,
4344 pageInt,
4445 start,
ui/src/styles/base.cssnew file

No changes to the content.

ui/src/styles/components.cssnew file+16
@@ -0,0 +1,16 @@
1+.button {
2+ @apply flex items-center rounded-lg text-base font-semibold text-linen bg-rosy disabled:bg-zinc-200 disabled:text-zinc-400 disabled:border-transparent border-2 border-transparent hover:border-linen/60 leading-none py-2 px-3 transition-colors;
3+}
4+
5+input:checked + label .show-when-checked {
6+ @apply opacity-100;
7+}
8+
9+input:checked + .checked-bg-fawn,
10+input:checked + label .checked-bg-fawn {
11+ @apply bg-fawn;
12+}
13+
14+.spinner {
15+ @apply inline-flex items-center w-6 h-6 animate-spin;
16+}
ui/src/styles/index.cssnew file+8
@@ -0,0 +1,8 @@
1+@import 'tailwindcss/base';
2+@import './base';
3+
4+@import 'tailwindcss/components';
5+@import './components';
6+
7+@import 'tailwindcss/utilities';
8+@import './utilities';
ui/src/styles/utilities.cssnew file+3
@@ -0,0 +1,3 @@
1+.flip {
2+ transform: rotateY(180deg);
3+}
ui/vite.config.ts+1−1
@@ -9,6 +9,6 @@ export default ({ mode }) => {
99 console.log(SHIP_URL);
1010
1111 return defineConfig({
12− plugins: [urbitPlugin({ base: 'sphinx', target: SHIP_URL, secure: false }), reactRefresh()]
12+ plugins: [urbitPlugin({ base: 'sphinx', target: SHIP_URL, changeOrigin: true, secure: false }), reactRefresh()]
1313 });
1414};