From 0e1a7790b9934bb8d7a92276de7c5fa844ff96a7 Mon Sep 17 00:00:00 2001 From: Jag Talon Date: Fri, 6 Dec 2024 17:01:55 +0000 Subject: [PATCH] WIP: Improving webxdc.org/apps (#99) * initial commit for modal feature * add more information in the modal * add download and cancel buttons * better mobile support * add ghost button * hashing working * add comments * split search into its own module * add very basic dialog focus * basic filtering * add additional information * better design on mobile screens * increase min-width * make it dark * set a max-width on the app container * focus is buggy. remove it for now * change title when clicking on an app * title change now happens in the component * add size of the apps * add support for mb and kb * work on tabs * update results based on filter * show active buttons * add dark theme * empty results push footer too far * continue improving design of empty space * clear out id in the url * add floating header * better loading format * apps weren't clickable because of z-index * better fuzzy searching * add -webkit-backdrop-filter for webkit browsers * add more contrast to the buttons * move button to be closer to the rest of the content * truncate overflowing text --- .../apps/deps/dayjs/localizedFormat.min.js.js | 1 + website/apps/index.html | 16 +- website/apps/main.js | 251 +++++++++++--- website/apps/styles.css | 309 ++++++++++++++++-- 4 files changed, 492 insertions(+), 85 deletions(-) create mode 100644 website/apps/deps/dayjs/localizedFormat.min.js.js diff --git a/website/apps/deps/dayjs/localizedFormat.min.js.js b/website/apps/deps/dayjs/localizedFormat.min.js.js new file mode 100644 index 0000000..9f2fbac --- /dev/null +++ b/website/apps/deps/dayjs/localizedFormat.min.js.js @@ -0,0 +1 @@ +!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?module.exports=t():"function"==typeof define&&define.amd?define(t):(e="undefined"!=typeof globalThis?globalThis:e||self).dayjs_plugin_localizedFormat=t()}(this,function(){"use strict";var i={LTS:"h:mm:ss A",LT:"h:mm A",L:"MM/DD/YYYY",LL:"MMMM D, YYYY",LLL:"MMMM D, YYYY h:mm A",LLLL:"dddd, MMMM D, YYYY h:mm A"};return function(e,t,o){var t=t.prototype,n=t.format;o.en.formats=i,t.format=function(e){void 0===e&&(e="YYYY-MM-DDTHH:mm:ssZ");var r,t=this.$locale().formats,t=(r=void 0===t?{}:t,e.replace(/(\[[^\]]+])|(LTS?|l{1,4}|L{1,4})/g,function(e,t,o){var n=o&&o.toUpperCase();return t||r[o]||i[o]||r[n].replace(/(\[[^\]]+])|(MMMM|MM|DD|dddd)/g,function(e,t,o){return t||o.slice(1)})}));return n.call(this,t)}}}); \ No newline at end of file diff --git a/website/apps/index.html b/website/apps/index.html index 62884e8..b78e9fe 100644 --- a/website/apps/index.html +++ b/website/apps/index.html @@ -9,22 +9,8 @@ -
-

webxdc apps

- -

compatible with Delta Chat, Cheogram, and monocles chat

- -
- -
-
- - +
diff --git a/website/apps/main.js b/website/apps/main.js index a8786ae..4bdd9f6 100644 --- a/website/apps/main.js +++ b/website/apps/main.js @@ -2,61 +2,106 @@ import { html, render, - useReducer, useState, useEffect, useMemo, useRef, } from "./deps/preact_and_htm.js"; import Fuse from "./deps/fuse.basic.esm.min.js"; - import dayjs from "./deps/dayjs/dayjs_with_relative_time.min.js"; +import "./deps/dayjs/localizedFormat.min.js.js"; + //@ts-ignore dayjs.extend(dayjs_plugin_relativeTime); +//@ts-ignore +dayjs.extend(dayjs_plugin_localizedFormat); // without a trailing slash const xdcget_export = "https://apps.testrun.org"; -/** - * @param {{app: import('./app_list.d').AppEntry}} param0 - */ -const App = ({ app }) => { - const [subtitle, description] = [app.description.split('\n').shift(), app.description.split('\n').slice(1).join(' ')]; +/* +Each is implemented as a button that, when clicked, would show +more details about the webxdc app by showing a +*/ +const App = ({ app, toggleModal }) => { + const subtitle = app.description.split('\n').shift(); return html` - - + onClick=${() => toggleModal(app.app_id)}> + Icon for ${app.name} app
${app.name}
-
- ${subtitle}
-
Last updated ${dayjs(app.date).fromNow()}
+
${subtitle}
+
Updated ${dayjs(app.date).fromNow()}
-
+ `; }; -const MainScreen = () => { - /** @typedef {import('./app_list.d').AppList} AppList */ - /** @type {[AppList, (newState: AppList) => void]} */ - const [apps, setApps] = useState([]); - const [loading, setLoading] = useState(true); +/* + creates an overlay that shows the metadata of an app and a button of +downloading the actual webxdc file from the server. +*/ +const Dialog = ({app, modal, toggleModal}) => { + const [subtitle, description] = [app.description.split('\n').shift(), app.description.split('\n').slice(1).join(' ')]; - useEffect(() => { - (async () => { - console.log(";"); - setApps(await (await fetch(xdcget_export + "/xdcget-lock.json")).json()); - setLoading(false); - })(); - }, []); + // Change the title when a dialog is open + if(modal === app.app_id) { + document.title = `webxdc apps: ${app.name}`; + } + + // Display the size of the webxdc apps in a more human readable format + let size = `${(app.size/1000).toLocaleString(undefined, {maximumFractionDigits: 1})} kb`; + if(app.size > 1000000) { + size = `${(app.size/1000000).toLocaleString(undefined, {maximumFractionDigits: 1})} mb`; + } + + return html` + + + `; +} +/* + deals with searching and filtering webxdc apps +*/ +const Search = ({apps, setSearchResults, filterGroup}) => { const fuse = useMemo(() => { return new Fuse(apps, { includeScore: true, + threshold: 0.25, // Search in `author` and in `tags` array keys: [ { name: "name", weight: 2 }, @@ -64,7 +109,11 @@ const MainScreen = () => { ], }); }, [apps]); - const [searchResults, setSearchResults] = useState(); + + const filterResults = (result) => { + return filterGroup === "home" ? true : result.item.category === filterGroup; + } + const searchFieldRef = useRef(null); const updateSearch = useMemo(() => { return () => { @@ -72,7 +121,7 @@ const MainScreen = () => { const query = searchFieldRef.current.value; if (query) { const results = fuse.search(query) - setSearchResults(results); + setSearchResults(results.filter(filterResults)); // console.log("search result", {results}); return; } @@ -80,38 +129,150 @@ const MainScreen = () => { setSearchResults( apps .map((app) => ({ item: app })) + .filter(filterResults) .sort( (a, b) => new Date(b.item.date).getTime() - new Date(a.item.date).getTime() ) ); }; - }, [fuse, apps]); + }, [fuse, apps, filterGroup]); useEffect(() => { // do the initial update or when applist changes updateSearch(); + }, [apps, filterGroup]); + + return html` + + `; +}; + +/* + is responsible for implementing the search function, for fetching +the app data, and for actually rendering the page contents. +*/ +const MainScreen = () => { + /** @typedef {import('./app_list.d').AppList} AppList */ + /** @type {[AppList, (newState: AppList) => void]} */ + const [apps, setApps] = useState([]); + const [loading, setLoading] = useState(true); + const [modal, viewModal] = useState(false); + const [appIdMap, setIdMap] = useState({}); + const [searchResults, setSearchResults] = useState(); + const [filterGroup, setFilterGroup] = useState("home"); + + // Fetch the data that contains all of the apps we have available + // in the xstore. + useEffect(() => { + (async () => { + console.log("fetch"); + setApps(await (await fetch(xdcget_export + "/xdcget-lock.json")).json()); + setLoading(false); + })(); + }, []); + + // We need a map so that we can quickly verify later if + // an app ID is valid. We'll be using this for verifying + // valid app ID in window.location.hash + useEffect(() => { + setIdMap(apps.reduce((map, app) => { + map[app.app_id] = true; + return map; + }, {})); }, [apps]); - console.count('render') + // This allows us to set/unset the modal for a particular app. + // - Open the modal + // - Change the hash + const toggleModal = (appId) => { + if(appId) { + viewModal(appId); + window.location.hash = appId; + } else { + viewModal(false); + document.title = "webxdc apps"; + window.location.hash = 'home'; + } + }; + + const onHashChange = () => { + // Close any open modals when window.location.hash changes + // Doesn't matter if it's valid or not. + viewModal(false); + if(window.location.hash.substring(1) in appIdMap) { + toggleModal(window.location.hash.substring(1)); + } + }; + + // We set an event that triggers whenever window.location.hash changes + useEffect(() => { + // If the variable s already set, show the modal. + if (window.location.hash.length > 0) { + onHashChange(); + } + window.addEventListener('hashchange', onHashChange); + return () => window.removeEventListener('hashchange', onHashChange); + + }, [window.location.hash, appIdMap]); + + console.count('render'); + return html` -
- -
+ <${Search} apps=${apps} setSearchResults=${setSearchResults} filterGroup=${filterGroup} />
- ${loading && html`
Loading
`} + ${loading && html`
Loading ...
`} ${searchResults && - searchResults.map((result) => html`<${App} app=${result.item} />`)} + searchResults.map((result) => html`<${App} app=${result.item} toggleModal=${toggleModal} />`)}
+
+
+ ${searchResults && + searchResults.map((result) => html`<${Dialog} app=${result.item} modal=${modal} toggleModal=${toggleModal} />`)} +
+
+ + <${Tabs} setFilterGroup=${setFilterGroup} filterGroup=${filterGroup} /> `; }; +const Tabs = ({setFilterGroup, filterGroup}) => { + return html`
+ + + +
`; +} + window.onload = async () => { - render(html`<${MainScreen} />`, window.apps); + render(html`<${MainScreen} />`, document.getElementById('apps')); }; diff --git a/website/apps/styles.css b/website/apps/styles.css index ea09c67..d8ee972 100644 --- a/website/apps/styles.css +++ b/website/apps/styles.css @@ -1,68 +1,119 @@ +html { + height: 100%; +} + +.loading { + padding-top: 1rem; +} + +body { + display: flex; + flex-direction: column; + align-items: center; + height: 100%; +} + +.search { + width: 100%; + padding: 1rem; + display: flex; + justify-content: center; + font-size: 1rem; + position: sticky; + top: 0; + background: #FFFFFF90; + border-bottom: 1px solid #00000020; + -webkit-backdrop-filter: blur(1rem); + backdrop-filter: blur(1rem); + z-index: 1; +} + +.search input { + margin: 0; +} + #apps { --padding: 8px; - margin: 0 var(--padding); + --icon: 64px; - grid-column: 1/-1; + width: 100%; + margin: 0 1rem; + display: flex; + flex-direction: column; + align-items: center; + flex: 1; } #apps header { - margin: var(--padding); text-align: center; } +/* Search */ + #search_field { - margin: var(--padding); - min-width: 60%; - max-width: 80%; + border-radius: 50px; + min-width: 50%; + max-width: 100%; } #app_container { - max-width: 100vw; + max-width: 1300px; display: flex; flex-wrap: wrap; justify-content: center; + align-content: flex-start; + height: 100%; } .app { - width: 300px; - margin: var(--padding); + width: 100%; + padding: 1rem; + border: none; + border-radius: initial; + border-bottom: 1px solid #00000020; + height: fit-content; + + background: none; + color: var(--text); + text-align: left; + font-size: 1rem; + gap: 0.9rem; text-decoration: none; display: flex; - align-items: center; - + align-items: flex-start; flex-shrink: 0; } -.app img { - --image: 64px; - - width: var(--image); - min-width: var(--image); +#apps img { + width: var(--icon); + min-width: var(--icon); + height: var(--icon); margin-right: calc(var(--padding)); } -.app .title { - display: block; - - font-size: 90%; +#apps .title { font-weight: bold; line-height: 1.3; } -.app .description { - font-size: 70%; +#apps .description { max-height: 60px; line-height: 1.2; + font-size: 0.9rem; overflow: hidden; + white-space: nowrap; text-overflow: ellipsis; } -.app .date { - font-size: 60%; - opacity: 0.5; +#apps .props { + width: calc(300px - var(--icon) - var(--padding)); +} + +#apps .date { + opacity: 0.7; } #footer { @@ -73,4 +124,212 @@ #footer a { padding: .5em; +} + +/* + ------- + Buttons + ------- +*/ + +button, .button { + text-align: center; + margin: 0; + padding-left: 1rem; + padding-right: 1rem; +} + +button.ghost, .button.ghost { + border: 1px solid var(--accent); + background: var(--bg); + color: var(--accent); +} + +/* + ------- + Dialogs + ------- +*/ + +.dialog-backdrop { + background: rgb(0 0 0 / 30%); + display: none; + position: fixed; + overflow-y: auto; + top: 0; + right: 0; + bottom: 0; + left: 0; + z-index: 1; +} + +.dialog-backdrop.active { + display: block; +} + +[role=dialog] { + font-size: 1rem; + display: none; + padding: 2rem; + background: var(--bg); + min-height: 100vh; + gap: 1rem; +} + +[role=dialog] .app-container { + display: flex; + gap: var(--padding); +} + +[role=dialog] .button-container { + display: flex; + gap: var(--padding); + width: 100%; + flex-direction: column; +} + +[role=dialog].active { + display: flex; + flex-direction: column; +} + +[role=dialog] .metadata { + display: flex; + flex-direction: column; +} + +[role=dialog] .additional-info { + display: flex; + flex-direction: column; +} + +/* + ------- + Tabs + ------- +*/ + +#tabs { + position: sticky; + bottom: 0; + padding: 1rem; + background: #FFFFFF95; + border-top: 1px solid #00000020; + -webkit-backdrop-filter: blur(1rem); + backdrop-filter: blur(1rem); + display: flex; + gap: 1rem; + width: 100%; +} + +#tabs > button { + width: 100%; + background: transparent; + border: none; + color: black; + display: flex; + flex-direction: column; + align-items: center; + font-size: 1rem; + transition: all .3s; + border: 1px solid transparent; +} + +#tabs > button:hover { + filter: brightness(1.9); + background: #00000010; +} + +#tabs > button:active, #tabs > button.active { + background: #00000020; + border: 1px solid #00000050; +} + +#tabs > button > svg { + width: calc(var(--icon) / 2); + height: calc(var(--icon) / 2); +} + +@media screen and (min-width: 670px) { + #search_field { + max-width: 80%; + } + + .app { + width: 300px; + gap: .3rem; + border-radius: var(--padding); + border: none; + padding: .8rem; + margin: .5rem; + } + + [role=dialog] { + display: none; + position: absolute; + top: 2rem; + left: 50vw; + transform: translateX( -50% ); + min-width: calc(640px - (15px * 2)); + max-width: calc(640px - (15px * 2)); + min-height: auto; + box-shadow: 0 19px 38px rgb(0 0 0 / 12%); + border-radius: var(--padding); + } + + [role=dialog] .button-container { + display: flex; + gap: var(--padding); + width: fit-content; + flex-direction: row; + } +} + +@media (prefers-color-scheme: dark) { + .app { + border-color: #FFFFFF20; + } + + .dialog-backdrop { + background: rgb(255 255 255 / 30%); + } + + .search { + background: #00000095; + border-bottom: 1px solid #00000020; + } + + #tabs { + position: sticky; + bottom: 0; + padding: 1rem; + background: #00000095; + border-top: 1px solid #00000020; + backdrop-filter: blur(1rem); + display: flex; + gap: 1rem; + } + + #tabs > button { + width: 100%; + background: transparent; + border: none; + color: white; + display: flex; + flex-direction: column; + align-items: center; + font-size: 1rem; + transition: all .3s; + border: 1px solid transparent; + } + + #tabs > button:hover { + filter: brightness(1.9); + background: #FFFFFF10; + } + + #tabs > button:active, #tabs > button.active { + background: #FFFFFF30; + border: 1px solid #FFFFFF30; + } } \ No newline at end of file