made a start on data grid implementation

This commit is contained in:
Chris Kruining 2024-10-03 15:33:25 +02:00
parent a543259fe4
commit 70c15c4094
12 changed files with 1083 additions and 210 deletions

391
examples/emmer/en.json Normal file
View file

@ -0,0 +1,391 @@
{
"app": {
"login": "Get started",
"logout": "Logout",
"navigation": {
"dashboard": "Dashboard",
"events": "Events",
"organizations": "Organizations",
"reviews": "Reviews",
"speakers": "Speakers"
},
"server-down": {
"message": "Apparently, (one of our) server(s) is not responding properly. Please hold on while we try to fix this issue. One the connection is established, this message will disappear automatically.",
"title": "Oops, there is a problem"
},
"title": "SpreaView"
},
"button": {
"add-speaker": "Add speaker",
"cancel": "Cancel",
"create": "Create",
"delete": "Delete",
"download-qr": "Download QRs",
"invite": "Invite for {{name}}",
"join": "Join",
"new": "New",
"new-room": "New room",
"new-session": "New session",
"qr-code": "QR Code",
"register": "Register",
"save": "Save",
"submit": "Submit",
"synchronize": "Sync Sessionize",
"prize-draw": "Prize draw"
},
"dashboard": {
"new-organization": {
"create": "Enter a valid organization name to create a new organization",
"description": "You as a conference organizer do not work alone, you are part of an organization. Please create an organization that will host your events, or join an existing organization using an invitation code.",
"invitation-code": "Invitation code",
"join": "Enter a valid invitation code to join an existing organization",
"name": "Organization name",
"subtitle": "Join an existing organization or create a new one",
"title": "You're empty handed"
},
"new-organizer": {
"description": "Welcome to SpreaView. We're happy to have you here. Before we continue, please take a moment to check and verify your information. You are about to be registered as an organizer.",
"email": "Email address",
"name": "Display name",
"subtitle": "Please take a moment to check and verify your information before we continue",
"title": "Hi, I see you're new here"
},
"tiles": {
"planned-events": {
"link": "View all events",
"title": "Planned events",
"tooltip": "Shows the amount of upcoming events versus the total amount of events in the system"
},
"reviews-per-session": {
"link": "Go to reviews",
"title": "Reviews / Session",
"tooltip": "Shows the average amount of reviews per session"
},
"speaker-engagement": {
"title": "Spkr Engagement",
"tooltip": "Shows the engagement of speakers based on their interest in the system (did speakers visit the speaker dashboard, did they download their QR code etc.)"
}
},
"title": "Dashboard"
},
"dialogs": {
"delete-confirmation": {
"message": "Are you sure you want to delete {{name}}? This action cannot be undone.",
"title": "Are you sure?"
}
},
"errorcode": {
"events": {
"prizedrawwinnerunknown": "The prize draw did not take place just yet so the winner is still unknown. Please try again later.",
"prizewinner-load-failed": "Failed to load prize winner data, please try again later."
},
"organizer": {
"create-failed": "Failed to create a new organization due to an unknown error",
"invitation-failed": "Failed to invite member for organization due to an unknown error",
"join-failed": "Failed to join organization due to an unknown error",
"list-failed": "Failed to fetch list from server",
"registration-failed": "The organizer is new, but the registration process failed on the server.",
"resolve-failed": "Failed to resolve organizer, this usually means the organizer is not registered and needs to be registered first.",
"update-failed": "Failed to update organization due to an unknown error"
},
"review": {
"review-date-time-indication": "Sessions can only be reviewed during the review window, for this session, the review window is between {{startDateTime}} and {{endDateTime}}."
},
"reviews": {
"review-session-code-invalid": "The session code is invalid. Session information for this session could not be found.",
"session-details-failed": "Failed to load session information from the server. The session code is invalid or the session is not available for review."
}
},
"events": {
"create": {
"date-range": "Date range",
"default-review-duration": "Default review window (in hours)",
"default-session-duration": "Default session duration (in minutes)",
"name": "Event name",
"title": "Create a new event"
},
"details": {
"rooms-monitor-link": "Rooms monitor",
"event-information-link": "Event information",
"event-prize-draw": "Event prize draw",
"description": "Event description",
"duration-in-minutes": "Default session duration in minutes",
"ends-on": "Ends on",
"event-code": "Unique event code",
"free-comments": "Enable free comment field for attendee reviews",
"name": "Name",
"review-window-in-hours": "Review window in hours",
"rooms": {
"actions": "Actions",
"capacity": "Capacity",
"download": {
"tooltip": "Download the QR code to the Review URL for this room. This URL shows the review form for the session that is currently active in this room."
},
"external-identifier": {
"text": "Import",
"tooltip": "This room is imported from an external system and cannot be altered in SpreaView."
},
"name": "Name",
"read-only": "This room cannot be altered or deleted because it is imported from an external system.",
"subtitle": "Rooms are locations or stages where your event presentations are held. You can create rooms here, and assign sessions to them.",
"title": "Event Rooms",
"review-link": {
"text": "Room review link",
"tooltip": "This link will bring you to the review page of the session that started most recently in this room"
}
},
"sessionize-api": "Sessionize API ID",
"sessions": {
"abstract": "Session abstract",
"code": "Code",
"duration-in-hours": "Duration",
"duration-in-minutes": "Duration",
"end": "Ends",
"help": "Read more about sessions management in the SpreaView documentation online: https://docs.spreaview.com/docs/features/sessions/",
"read-only": "This session cannot be altered because it is imported from an external system. You can only delete this session.",
"review-start-date": "Review start date",
"review-start-time": "Review start time",
"room": "Room",
"session-title": "Session title",
"speakers": "Speaker(s)",
"start": "Starts",
"start-date": "Start date",
"start-time": "Start time",
"subtitle": "Your sessions are scheduled for your event and take place in a room",
"title": "Event sessions"
},
"starts-on": "Starts on",
"subtitle": "Change the properties of your event here to change the behavior of SpreaView for your event",
"swag-requirement": "Swag requirement threshold",
"theme-background": "Event theme background color",
"theme-foreground": "Event theme foreground color",
"event-logo-url": "Event logo URL",
"title": "Event details"
},
"list": {
"ends-on": "Ends on",
"name": "Name",
"organization": "Organization",
"starts-on": "Starts on"
},
"title": "Events"
},
"home": {
"about": {
"how": {
"steps": {
"five": {
"text": "Get comprehensive insights in the quality of the sessions and the popularity of the speakers. Share a personalized report with your speakers",
"title": "Step 5"
},
"four": {
"text": "Provide your speakers with a QR Code and use the Rooms view to allow attendees to rate the sessions",
"title": "Step 4"
},
"one": {
"text": "Register as an organizer, and create your first organization. Registration is free and takes only a couple of minutes",
"title": "Step 1"
},
"three": {
"text": "Import speakers and sessions from your conference management system, or create them manually",
"title": "Step 3"
},
"two": {
"text": "Create a new event, with a start- and end date. Provide more detailed properties when required to change SpreaView's behavior",
"title": "Step 2"
}
},
"text": "The SpreaView platform is easy to use. With just a couple of steps, you are ready to start measuring session quality and popularity.",
"title": "This is how it works"
},
"partners": {
"text": "SpreaView is used by many conference organizers and speakers. We have partnerships with many organizations to provide the best possible service to our users.",
"title": "Your are not alone"
},
"text": "SpreaView is a platform that helps conference organizers and speakers to gain more insights in the quality of their presentations. With SpreaView, speakers gain more insigths in how and where to improver their sessions, while organizers get insights in the popularity of topics and speakers.",
"title": "About SpreaView"
},
"landing": {
"cta": "Get started",
"documentation": "Documentation",
"more": "More information",
"subtitle": "SpreaView is the got-to platform for conference organizers and speakers to gain more insights in the quality of their presentations. With SpreaView, speakers gain more insigths in how and where to improver their sessions, while organizers get insights in the popularity of topics and speakers.",
"title": "Welcome to SpreaView"
},
"links": {
"about": "About",
"contact": "Contact",
"documentation": "Documentation",
"home": "Home",
"pricing": "Pricing",
"terms": "Terms"
}
},
"organizations": {
"details": {
"name": "Name",
"title": "Organization details"
},
"invite": {
"description": "Invite a new member to your organization by entering their e-mail address. The new member will receive an invitation to join your organization.",
"email-address": "E-mail address",
"title": "Invite member organization"
},
"join": {
"code": "Invitation code",
"title": "Join an organization"
},
"list": {
"members": "Members",
"name": "Name"
},
"selector": {
"text": "Select an organization",
"tooltip": "Select an organization"
},
"title": "Organizations"
},
"review": {
"session": {
"comment": "Comment",
"content": "Content",
"delivery": "Delivery",
"description": "Please take a moment to review the session '<strong>{{sessionName}}</strong>' you just attended. Your feedback is important to us and the speaker.",
"interaction": "Interaction",
"loading": "Hold on, we're loading the session information for you",
"loading-error": "Oops, something went wrong while loading the session information. Please try again later.",
"speaker": "Speaker",
"success": "Thanks!",
"title": "Your review is gold to us"
}
},
"event-info": {
"description": "To improve the quality of our conference and be able to fit the content to your needs, we would like you to review the sessions you visited. Select the room you visited your session.",
"no-sessions-started": "For this event, no sessions have started yet. Please come back later.",
"session": {
"title": "Title",
"speakers": "Speaker(s)",
"starts-on": "Started",
"ends-on": "Ended",
"actions": " "
}
},
"reviews": {
"dashboard": {
"best-session": {
"description": "For <strong>{{title}}</strong> by {{speakers}} based on {{reviews}} review(s)",
"title": "Best session",
"tooltip": "This tile shows the best rated session for this event"
},
"best-speaker": {
"description": "For <strong>{{displayName}}</strong> based on {{reviews}} review(s) of {{sessions}} session(s)",
"title": "Best speaker",
"tooltip": "This tile shows the best rated speaker for this event over all sessions"
},
"reviewed-sessions": {
"link": "View event sessions",
"title": "Reviewed sessions",
"tooltip": "This shows the amount of reviewed sessions, versus the total amount of sessions in the event"
},
"session-top": {
"rating": "Rating",
"reviews": "Reviews",
"speakers": "Speaker(s)",
"title": "Session"
},
"sessions-vs-speakers": {
"description": "You event has {{speakers}} speakers and {{sessions}} sessions",
"title": "Speakers",
"tooltip": "The number of speakers versus the number of sessions in this event"
},
"speaker-top": {
"display-name": "Name",
"rating": "Rating",
"reviews": "Reviews",
"sessions": "Sessions"
},
"total-reviews": {
"title": "Reviews",
"tooltip": "This shows the total amount of reviews given by attendees for this event"
}
},
"list": {
"average-score": "Speaker Avg.",
"ends-on": "Ends on",
"name": "Name",
"reviews-count": "Reviews",
"session-count": "Sessions",
"starts-on": "Starts on"
},
"session-list": {
"actions": "",
"actions-toolip": "Download the complete session report PDF file for this session.",
"average-score": "Average",
"content-score": "Content",
"delivery-score": "Delivery",
"interaction-score": "Interact.",
"speaker-score": "Speaker",
"speakers": "Speakers",
"title": "Sessions"
},
"title": "Reviews"
},
"rooms": {
"select": {
"enter": "Show room information",
"title": "Select a room"
}
},
"speaker-dashboard": {
"past": {
"title": "Past sessions"
},
"planned": {
"title": "Planned sessions"
},
"title": "Speaker Dashboard",
"welcome": {
"description": "This is SpreaView, the platform that helps you to gain more insights in the quality of your presentations. With SpreaView, you gain more insigths in how and where to improver your sessions. We do that by collecting session review information from attendees who attended your session. After you finished your session, you can come back to this speaker dashboad to get review your review information. Below are your sessions that have been scheduled in SpeaView, and sessions that have already been reviewed with SpreaView.",
"learn-more": "Learn more about SpreaView",
"speaker-error": "The system could not succesfully collect your personal speaker information. Please contact the event organizer for more information.",
"title": "Welcome on your speaker dashboard",
"pin-error": "Too many pincode attempts, please try again later",
"pin-create-error": "Creating your pincode failed, try again",
"pin-create": "Create pincode"
}
},
"speakers": {
"details": {
"description": "Change the details of the speaker. Speakers that were imported from an external system cannot be altered",
"name": "Name",
"profile-picture": "Profile picture",
"read-only": "This speaker cannot be altered or deleted because it is imported from an external system.",
"title": "Speaker details"
},
"title": "Speakers"
},
"pincode": {
"create": {
"title": "Create a pincode",
"description": "Choose a pincode that you can remember. This pincode is used to secure your performance data and to access your speaker dashboard.",
"pin": "6-character pincode",
"pin-confirm": "Confirm pincode",
"pin-mismatch": "Pincodes are not equal"
},
"validate": {
"title": "Enter your pincode",
"description": "Enter your pincode to access your speaker dashboard",
"pin-incorrect": "The pincode is incorrect, try again"
}
},
"prize-draw": {
"title": "Prize draw",
"winner": "You are a winner!",
"winner-description": "Congratulations, you are the winner of the prize draw. Please come forward to claim your prize.",
"loser": "Sorry, you're not the one",
"loser-description": "Unfortunately someone just clinched the prize. Better luck next time.",
"errors": {
"data-load-error": "Failed to load prize draw winner. Please refresh this page to try again."
}
}
}

View file

@ -0,0 +1,83 @@
{
"registration": {
"title": "Who are you?",
"description": "Please enter your name to play. Your email address is optional. Please note we have a code of conduct, so don't be too silly.",
"accept-terms": "My information is compliant with the Code of Conduct",
"display-name": "Name",
"email-address": "Email address",
"register": "Register",
"join": {
"voucher-valid": "Voucher is valid",
"title": "Join a game",
"description": "In order to join a game, enter the game code or scan the QR code.",
"game-code": "Game code",
"join": "Join"
}
},
"users": {
"exclusionreasons": {
"generalsystemmisuse": "You are locked out in suspect of general system misuse",
"codeofconductviolation": "You are locked out, you broke our code of conduct"
}
},
"game": {
"join": {
"scan": {
"title": "Scan QR Code",
"text": "Scan a valid game code QR code to join a game",
"device-select": "Select camera"
}
},
"waiting": {
"title": "Waiting for more players",
"description": "Please wait for more players to join the game. We will start this game shortly...",
"current-players": "At this time, {{numberOfPlayers}} player(s) have joined the game."
},
"finished": {
"title": "Game Finished",
"description": "Time's up! The game is over. Please wait for the results to be displayed."
},
"scoreboard": {
"title": "Scoreboard",
"player": "Player",
"score": "Score",
"rank": "Rank"
}
},
"button": {
"close": "Close",
"join-new-game": "Join a new game"
},
"wam": {
"errorcodes": {
"games": {
"invalidgamecode": {
"title": "Invalid game code",
"message": "The game code you entered is invalid. Please try again."
},
"gamenotfound": {
"title": "Invalid game code",
"message": "The game code you entered is invalid. Please try again."
},
"invalidstate": {
"title": "Cannot join this game",
"message": "You are trying to join a game, but this game has already been played."
},
"invalidgamevoucher": {
"title": "Invalid game voucher",
"message": "The voucher you scanned is invalid or has already been used."
}
},
"users": {
"usernotfound": {
"title": "Invalid user",
"message": "Tried to resolve your user ID, but it seems you are not registered. Please register first."
}
},
"unknown": {
"title": "Unknown server error",
"message": "Oops, something went wrong on the server side. Please try again later."
}
}
}
}

View file

@ -30,6 +30,9 @@ body {
block-size: 100%;
overflow: clip;
display: grid;
grid: auto 1fr / 100%;
background-color: var(--surface-1);
margin: 0;
@ -59,7 +62,13 @@ body {
padding: .75em;
margin-block-end: -1em;
background-color: inherit;
color: inherit;
border-radius: .25em;
& > svg {
inline-size: 100%;
block-size: 100%;
}
}
& > div {
@ -115,58 +124,20 @@ body {
background-color: var(--surface-2);
}
}
& > main {
block-size: 100%;
padding: 1em;
padding-block-start: 1.5em;
overflow: clip auto;
& details {
& > summary::marker {
content: url('data:image/svg+xml;charset=UTF-8,<svg color="currentColor" fill="currentColor" stroke-width="0" viewBox="0 0 1024 1024" width="1em" height="1em" xmlns="http://www.w3.org/2000/svg"><path d="M880 298.4H521L403.7 186.2a8.15 8.15 0 0 0-5.5-2.2H144c-17.7 0-32 14.3-32 32v592c0 17.7 14.3 32 32 32h736c17.7 0 32-14.3 32-32V330.4c0-17.7-14.3-32-32-32z"></path></svg>');
}
&[open] > summary::marker {
content: url('data:image/svg+xml;charset=UTF-8,<svg color="currentColor" fill="currentColor" stroke-width="0" viewBox="0 0 1024 1024" width="1em" height="1em" xmlns="http://www.w3.org/2000/svg"><path d="M928 444H820V330.4c0-17.7-14.3-32-32-32H473L355.7 186.2a8.15 8.15 0 0 0-5.5-2.2H96c-17.7 0-32 14.3-32 32v592c0 17.7 14.3 32 32 32h698c13 0 24.8-7.9 29.7-20l134-332c1.5-3.8 2.3-7.9 2.3-12 0-17.7-14.3-32-32-32zm-180 0H238c-13 0-24.8 7.9-29.7 20L136 643.2V256h188.5l119.6 114.4H748V444z"></path></svg>');
}
& span {
cursor: pointer;
}
}
}
}
a {
margin-right: 1rem;
color: var(--primary);
}
h1 {
color: #335d92;
color: var(--primary);
text-transform: uppercase;
font-size: 4rem;
font-weight: 100;
line-height: 1.1;
margin: 4rem auto;
max-width: 14rem;
}
p {
max-width: 14rem;
margin: 2rem auto;
line-height: 1.35;
}
@media (min-width: 480px) {
h1 {
max-width: none;
}
p {
max-width: none;
}
}

View file

@ -3,11 +3,11 @@ import { Portal, isServer } from "solid-js/web";
import { createStore } from "solid-js/store";
export interface MenuContextType {
ref: Accessor<JSX.Element|undefined>;
setRef: Setter<JSX.Element|undefined>;
ref: Accessor<JSX.Element | undefined>;
setRef: Setter<JSX.Element | undefined>;
addItems(items: (Item|ItemWithChildren)[]): void;
items: Accessor<(Item|ItemWithChildren)[]>;
addItems(items: (Item | ItemWithChildren)[]): void;
items: Accessor<(Item | ItemWithChildren)[]>;
commands(): Command[];
};
@ -41,7 +41,7 @@ export interface ItemWithChildren {
const MenuContext = createContext<MenuContextType>();
export const createCommand = (command: () => any, shortcut?: Command['shortcut']): Command => {
if(shortcut) {
if (shortcut) {
(command as Command).shortcut = { key: shortcut.key.toLowerCase(), modifier: shortcut.modifier };
}
@ -49,12 +49,12 @@ export const createCommand = (command: () => any, shortcut?: Command['shortcut']
};
export const MenuProvider: ParentComponent = (props) => {
const [ ref, setRef ] = createSignal<JSX.Element|undefined>();
const [ _items, setItems ] = createSignal<Map<string, Item&{ children?: Map<string, Item> }>>(new Map());
const [ref, setRef] = createSignal<JSX.Element | undefined>();
const [_items, setItems] = createSignal<Map<string, Item & { children?: Map<string, Item> }>>(new Map());
const [ store, setStore ] = createStore<{ items: Record<string, Item|ItemWithChildren> }>({ items: {} });
const [store, setStore] = createStore<{ items: Record<string, Item | ItemWithChildren> }>({ items: {} });
const addItems = (items: (Item|ItemWithChildren)[]) => setStore('items', values => {
const addItems = (items: (Item | ItemWithChildren)[]) => setStore('items', values => {
for (const item of items) {
values[item.id] = item;
}
@ -70,19 +70,19 @@ export const MenuProvider: ParentComponent = (props) => {
const useMenu = () => {
const context = useContext(MenuContext);
if(context === undefined) {
if (context === undefined) {
throw new Error(`MenuContext is called outside of a <MenuProvider />`);
}
return context;
}
type ItemProps = { label: string, children: JSX.Element }|{ label: string, command: Command };
type ItemProps = { label: string, children: JSX.Element } | { label: string, command: Command };
const Item: Component<ItemProps> = (props) => {
const id = createUniqueId();
if(props.command) {
if (props.command) {
return mergeProps(props, { id }) as unknown as JSX.Element;
}
@ -98,7 +98,7 @@ const Item: Component<ItemProps> = (props) => {
const Root: ParentComponent<{}> = (props) => {
const menu = useMenu();
const [ current, setCurrent ] = createSignal<HTMLElement>();
const [current, setCurrent] = createSignal<HTMLElement>();
const items = (isServer
? props.children
: props.children?.map(c => c())) ?? [];
@ -108,7 +108,7 @@ const Root: ParentComponent<{}> = (props) => {
const close = () => {
const el = current();
if(el) {
if (el) {
el.hidePopover();
setCurrent(undefined);
@ -123,17 +123,14 @@ const Root: ParentComponent<{}> = (props) => {
}
};
const Button: Component<{ label: string, command: Command }|{ [key: string]: any }> = (props) => {
const [ local, rest ] = splitProps(props, ['label', 'command']);
const Button: Component<{ label: string, command: Command } | { [key: string]: any }> = (props) => {
const [local, rest] = splitProps(props, ['label', 'command']);
return <button class="menu-item" type="button" on:pointerdown={onExecute(local.command)} {...rest}>{local.label}</button>;
};
return <Portal mount={menu.ref()}>
<For each={items}>{
item => {
const [] = createSignal();
return <>
item => <>
<Show when={item.children}>
<div
class="menu-child"
@ -141,7 +138,7 @@ const Root: ParentComponent<{}> = (props) => {
style={`position-anchor: --menu-${item.id};`}
popover
on:toggle={(e: ToggleEvent) => {
if(e.newState === 'open' && e.target !== null) {
if (e.newState === 'open' && e.target !== null) {
return setCurrent(e.target as HTMLElement);
}
}}
@ -155,23 +152,22 @@ const Root: ParentComponent<{}> = (props) => {
<Button
label={item.label}
on:pointerenter={(e) => {
if(!item.children) {
if (!item.children) {
return;
}
const el = current();
if(!el){
if (!el) {
return;
}
el.hidePopover();
}}
{...(item.children ? { popovertarget: `child-${item.id}`, style: `anchor-name: --menu-${item.id};`, command: item.command } : {})}
{...(item.children ? { popovertarget: `child-${item.id}`, style: `anchor-name: --menu-${item.id};` } : { command: item.command })}
/>
</>;
}
</>
}</For>
</Portal>
};
@ -179,7 +175,7 @@ const Root: ParentComponent<{}> = (props) => {
declare module "solid-js" {
namespace JSX {
interface HTMLAttributes<T> {
anchor?: string|undefined;
anchor?: string | undefined;
}
interface Directives {
@ -198,12 +194,12 @@ export const asMenuRoot = (element: Element) => {
(e.shiftKey ? 1 : 0) << 0 |
(e.ctrlKey ? 1 : 0) << 1 |
(e.metaKey ? 1 : 0) << 2 |
(e.altKey ? 1 : 0) << 3 ;
(e.altKey ? 1 : 0) << 3;
const commands = menu.commands();
const command = commands.find(c => c.shortcut?.key === key && (c.shortcut.modifier === undefined || c.shortcut.modifier === modifiers));
if(command === undefined) {
if (command === undefined) {
return;
}

7
src/global.d.ts vendored
View file

@ -1 +1,8 @@
/// <reference types="@solidjs/start/env" />
interface IterableIterator<T> {
map<U>(callbackfn: (value: T, index: number, array: T[]) => U, thisArg?: any): IterableIterator<U>;
filter<S extends T>(predicate: (value: T, index: number, array: T[]) => value is S, thisArg?: any): IterableIterator<S>;
toArray(): T[];
}

View file

@ -3,20 +3,22 @@ import { Show } from "solid-js";
import { BsTranslate } from "solid-icons/bs";
import { FilesProvider } from "~/features/file";
import { MenuProvider, asMenuRoot } from "~/features/menu";
import { isServer } from "solid-js/web";
import { A } from "@solidjs/router";
asMenuRoot // prevents removal of import
export default function Editor(props) {
const supported = typeof window.showDirectoryPicker === 'function';
const supported = isServer || typeof window.showDirectoryPicker === 'function';
return <MenuProvider>
<Title>Translation-Tool</Title>
<nav use:asMenuRoot>
<BsTranslate class="logo" />
<A class="logo" href="/"><BsTranslate /></A>
</nav>
<main>
<main style="padding: 1em; block-size: 100%; overflow: clip;">
<Show when={supported} fallback={<span>too bad, so sad. Your browser does not support the File Access API</span>}>
<FilesProvider>
{props.children}

View file

@ -0,0 +1,27 @@
.table {
display: grid;
overflow: auto;
grid-template-columns: 1em max-content repeat(var(--columns), auto);
gap: .5em;
block-size: 100%;
overflow: clip auto;
& > :is(header, main, footer) {
display: contents;
}
& details {
display: contents;
&::details-content {
grid-column: span calc(2 + var(--columns));
display: grid;
grid-template-columns: subgrid;
}
& > summary {
grid-column: 2 / span calc(1 + var(--columns));
}
}
}

View file

@ -0,0 +1,202 @@
import { createCommand, Menu, Modifier } from "~/features/menu";
import { Component, createEffect, createMemo, createResource, createSignal, For, onMount, Show } from "solid-js";
import { useFiles } from "~/features/file";
import "./edit.css";
interface Entry extends Record<string, Entry | string> { }
interface Leaf extends Record<string, string> { }
interface Entry2 extends Record<string, Entry2 | Leaf> { }
async function* walk(directory: FileSystemDirectoryHandle, path: string[] = []): AsyncGenerator<{ lang: string, entries: Entry }, void, never> {
for await (const handle of directory.values()) {
if (handle.kind === 'directory') {
yield* walk(handle, [...path, handle.name]);
continue;
}
if (!handle.name.endsWith('.json')) {
continue;
}
const file = await handle.getFile();
if (file.type !== 'application/json') {
continue;
}
const lang = file.name.split('.').at(0)!;
const text = await file.text();
const root: Entry = {};
let current: Entry = root;
for (const key of path) {
current[key] = {};
current = current[key];
}
Object.assign(current, JSON.parse(text));
yield { lang, entries: root };
}
};
export default function Edit(props) {
const files = useFiles();
const [root, { mutate, refetch }] = createResource(() => files.get('root'));
// Since the files are stored in indexedDb we need to refetch on the client in order to populate on page load
onMount(() => {
refetch();
});
createEffect(async () => {
const directory = root();
if (root.state === 'ready' && directory?.kind === 'directory') {
const contents = await Array.fromAsync(walk(directory));
const entries = Object.entries(
Object.groupBy(contents, e => e.lang)
).map(([lang, entries]) => ({
lang,
entries: entries!
.map(e => e.entries)
.reduce((o, e) => {
Object.assign(o, e);
return o;
}, {})
}));
const assign = (lang: string, entries: Entry) => {
return Object.entries(entries).reduce((aggregate, [key, value]) => {
const v = typeof value === 'string' ? { [lang]: value } : assign(lang, value);
Object.assign(aggregate, { [key]: v });
return aggregate;
}, {});
}
const unified = contents.reduce((aggregate, { lang, entries }) => {
Object.assign(aggregate, assign(lang, entries));
return aggregate;
}, {});
setColumns(['key', ...new Set(contents.map(c => c.lang))]);
setRows(unified);
}
});
const commands = {
open: createCommand(async () => {
const [fileHandle] = await window.showOpenFilePicker({
types: [
{
description: "JSON File(s)",
accept: {
"application/json": [".json", ".jsonp", ".jsonc"],
},
}
],
excludeAcceptAllOption: true,
multiple: true,
});
const file = await fileHandle.getFile();
const text = await file.text();
console.log(fileHandle, file, text);
}, { key: 'o', modifier: Modifier.Control }),
openFolder: createCommand(async () => {
const directory = await window.showDirectoryPicker({ mode: 'readwrite' });
files.set('root', directory);
mutate(directory);
}),
save: createCommand(() => {
console.log('save');
}, { key: 's', modifier: Modifier.Control }),
saveAll: createCommand(() => {
console.log('save all');
}, { key: 's', modifier: Modifier.Control | Modifier.Shift }),
edit: createCommand(() => {
}),
selection: createCommand(() => { }),
view: createCommand(() => { }),
} as const;
const [columns, setColumns] = createSignal([]);
const [rows, setRows] = createSignal<Entry2>({});
const Row: Component<{ entry: Entry2 }> = (props) => {
return <For each={Object.entries(props.entry)}>{
([key, value]) => {
const values = Object.values(value);
const isLeaf = values.some(v => typeof v === 'string');
return <Show when={isLeaf} fallback={<Group key={key} entry={value as Entry2} />}>
<input type="checkbox" />
<span>{key}</span>
<For each={values}>{
value => <input type="" value={value} />
}</For>
</Show>;
}
}</For>
};
const Group: Component<{ key: string, entry: Entry2 }> = (props) => {
return <details open>
<summary>{props.key}</summary>
<Row entry={props.entry} />
</details>;
};
const columnCount = createMemo(() => columns().length - 1);
return <>
<Menu.Root>
<Menu.Item label="file">
<Menu.Item label="open" command={commands.open} />
<Menu.Item label="open folder" command={commands.openFolder} />
<Menu.Item label="save" command={commands.save} />
<Menu.Item label="save all" command={commands.saveAll} />
</Menu.Item>
<Menu.Item label="edit" command={commands.edit} />
<Menu.Item label="selection" command={commands.selection} />
<Menu.Item label="view" command={commands.view} />
</Menu.Root>
<section class="table" style={{ '--columns': columnCount() }}>
<header>
<input type="checkbox" />
<For each={columns()}>{
column => <span>{column}</span>
}</For>
</header>
<main>
<Row entry={rows()} />
</main>
</section>
{/* <AgGridSolid
singleClickEdit
columnDefs={columnDefs()}
rowData={rowData()}
defaultColDef={defaultColDef} /> */}
</>
}

View file

@ -0,0 +1,45 @@
section.index {
display: grid;
grid: 100% / auto 1fr;
inline-size: 100%;
block-size: 100%;
& > aside {
overflow: clip auto;
resize: horizontal;
min-inline-size: 300px;
max-inline-size: 75vw;
block-size: 100%;
padding: 1em;
padding-block-start: 1.5em;
padding-inline-end: 1em;
& details {
& > summary::marker {
content: none;
color: var(--text) !important;
}
& span {
cursor: pointer;
}
}
& ul {
padding-inline-start: 0;
& ul {
padding-inline-start: 1.25em;
}
& span {
white-space: nowrap;
}
}
}
& > section {
padding-inline: 1em;
}
}

View file

@ -0,0 +1,174 @@
import { Component, createEffect, createMemo, createResource, createSignal, For, onMount, Show } from "solid-js";
import { useFiles } from "~/features/file";
import { createCommand, Menu, Modifier } from "~/features/menu";
import { AiFillFile, AiFillFolder, AiFillFolderOpen } from "solid-icons/ai";
import "./experimental.css";
interface FileEntry {
name: string;
kind: 'file';
meta: File;
}
interface FolderEntry {
name: string;
kind: 'folder';
entries: Entry[];
}
type Entry = FileEntry | FolderEntry;
async function* walk(directory: FileSystemDirectoryHandle, filters: RegExp[] = [], depth = 0): AsyncGenerator<Entry, void, never> {
if (depth === 10) {
return;
}
for await (const handle of directory.values()) {
if (filters.some(f => f.test(handle.name))) {
continue;
}
if (handle.kind === 'file') {
yield { name: handle.name, kind: 'file', meta: await handle.getFile() };
}
else {
yield { name: handle.name, kind: 'folder', entries: await Array.fromAsync(walk(handle, filters, depth + 1)) };
}
}
}
export default function Index() {
const files = useFiles();
const [tree, setTree] = createSignal<FolderEntry>();
const [content, setContent] = createSignal<string>('');
const [showHiddenFiles, setShowHiddenFiles] = createSignal<boolean>(false);
const filters = createMemo<RegExp[]>(() => showHiddenFiles() ? [/^node_modules$/] : [/^node_modules$/, /^\..+$/]);
const [root, { mutate, refetch }] = createResource(() => files.get('root'));
// Since the files are stored in indexedDb we need to refetch on the client in order to populate on page load
onMount(() => {
refetch();
});
createEffect(async () => {
const directory = root();
if (root.state === 'ready' && directory?.kind === 'directory') {
const entries = await Array.fromAsync(walk(directory, filters()));
setTree({ name: '', kind: 'folder', entries });
}
});
const open = async (file: File) => {
const text = await file.text();
console.log({ file, text });
return setContent(text);
};
const commands = {
open: createCommand(async () => {
const [fileHandle] = await window.showOpenFilePicker({
types: [
{
description: "JSON File(s)",
accept: {
"application/json": [".json", ".jsonp", ".jsonc"],
},
}
],
excludeAcceptAllOption: true,
multiple: true,
});
const file = await fileHandle.getFile();
const text = await file.text();
console.log(fileHandle, file, text);
}, { key: 'o', modifier: Modifier.Control }),
openFolder: createCommand(async () => {
const directory = await window.showDirectoryPicker({ mode: 'readwrite' });
const entries = await Array.fromAsync(walk(directory, filters()));
files.set('root', directory);
mutate(directory);
setTree({ name: '', kind: 'folder', entries });
}),
save: createCommand(() => {
console.log('save');
}, { key: 's', modifier: Modifier.Control }),
saveAll: createCommand(() => {
console.log('save all');
}, { key: 's', modifier: Modifier.Control | Modifier.Shift }),
edit: createCommand(() => { }),
selection: createCommand(() => { }),
view: createCommand(() => { }),
} as const;
const Tree: Component<{ entries: Entry[] }> = (props) => {
return <ul style="display: flex; flex-direction: column; list-style: none;">
<For each={props.entries}>{
(entry, index) => <li style={`order: ${(entry.kind === 'file' ? 200 : 100) + index()}`}>
<Show when={entry.kind === 'folder' ? entry : undefined}>{
folder => <Folder folder={folder()} />
}</Show>
<Show when={entry.kind === 'file' ? entry : undefined}>{
file => <span on:pointerdown={() => {
console.log(`lets open '${file().name}'`);
open(file().meta);
}}><AiFillFile /> {file().name}</span>
}</Show>
</li>
}</For>
</ul>
}
const Folder: Component<{ folder: FolderEntry }> = (props) => {
const [open, setOpen] = createSignal(false);
return <details open={open()} on:toggle={() => setOpen(o => !o)}>
<summary><Show when={open()} fallback={<AiFillFolder />}><AiFillFolderOpen /></Show> {props.folder.name}</summary>
<Tree entries={props.folder.entries} />
</details>;
};
return (
<>
<Menu.Root>
<Menu.Item label="file">
<Menu.Item label="open" command={commands.open} />
<Menu.Item label="open folder" command={commands.openFolder} />
<Menu.Item label="save" command={commands.save} />
<Menu.Item label="save all" command={commands.saveAll} />
</Menu.Item>
<Menu.Item label="edit" command={commands.edit} />
<Menu.Item label="selection" command={commands.selection} />
<Menu.Item label="view" command={commands.view} />
</Menu.Root>
<section class="index">
<aside>
<label><input type="checkbox" on:input={() => setShowHiddenFiles(v => !v)} />Show hidden files</label>
<Show when={tree()}>{
tree => <Tree entries={tree().entries} />
}</Show>
</aside>
<section>
<pre>{content()}</pre>
</section>
</section>
</>
);
}

View file

@ -1,8 +1,4 @@
section.index {
body > main {
display: grid;
grid: 100% / 20% 1fr;
& > aside {
overflow: clip auto;
}
place-content: center;
}

View file

@ -1,8 +1,9 @@
import { Component, createEffect, createMemo, createResource, createSignal, For, onMount, Show } from "solid-js";
import { useFiles } from "~/features/file";
import { createCommand, Menu, Modifier } from "~/features/menu";
import { AiFillFile } from "solid-icons/ai";
import { AiFillFile, AiFillFolder, AiFillFolderOpen } from "solid-icons/ai";
import "./index.css";
import { A } from "@solidjs/router";
interface FileEntry {
name: string;
@ -16,20 +17,20 @@ interface FolderEntry {
entries: Entry[];
}
type Entry = FileEntry|FolderEntry;
type Entry = FileEntry | FolderEntry;
async function* walk(directory: FileSystemDirectoryHandle, filters: RegExp[] = [], depth = 0): AsyncGenerator<Entry, void, never> {
if(depth === 5) {
if (depth === 10) {
return;
}
for await (const handle of directory.values()) {
if(filters.some(f => f.test(handle.name))){
if (filters.some(f => f.test(handle.name))) {
continue;
}
if(handle.kind === 'file') {
if (handle.kind === 'file') {
yield { name: handle.name, kind: 'file', meta: await handle.getFile() };
}
else {
@ -40,11 +41,11 @@ async function* walk(directory: FileSystemDirectoryHandle, filters: RegExp[] = [
export default function Index() {
const files = useFiles();
const [ tree, setTree ] = createSignal<FolderEntry>();
const [ content, setContent ] = createSignal<string>('');
const [ showHiddenFiles, setShowHiddenFiles ] = createSignal<boolean>(false);
const filters = createMemo<RegExp[]>(() => showHiddenFiles() ? [/^node_modules$/] : [ /^node_modules$/, /^\..+$/ ]);
const [ root, { mutate, refetch } ] = createResource(() => files.get('root'));
const [tree, setTree] = createSignal<FolderEntry>();
const [content, setContent] = createSignal<string>('');
const [showHiddenFiles, setShowHiddenFiles] = createSignal<boolean>(false);
const filters = createMemo<RegExp[]>(() => showHiddenFiles() ? [/^node_modules$/] : [/^node_modules$/, /^\..+$/]);
const [root, { mutate, refetch }] = createResource(() => files.get('root'));
// Since the files are stored in indexedDb we need to refetch on the client in order to populate on page load
onMount(() => {
@ -54,7 +55,7 @@ export default function Index() {
createEffect(async () => {
const directory = root();
if(root.state ==='ready' && directory?.kind === 'directory'){
if (root.state === 'ready' && directory?.kind === 'directory') {
const entries = await Array.fromAsync(walk(directory, filters()));
setTree({ name: '', kind: 'folder', entries });
@ -76,7 +77,7 @@ export default function Index() {
{
description: "JSON File(s)",
accept: {
"application/json": [ ".json", ".jsonp", ".jsonc" ],
"application/json": [".json", ".jsonp", ".jsonc"],
},
}
],
@ -87,7 +88,7 @@ export default function Index() {
const text = await file.text();
console.log(fileHandle, file, text);
}, { key: 'o', modifier: Modifier.Control}),
}, { key: 'o', modifier: Modifier.Control }),
openFolder: createCommand(async () => {
const directory = await window.showDirectoryPicker({ mode: 'readwrite' });
const entries = await Array.fromAsync(walk(directory, filters()));
@ -102,10 +103,10 @@ export default function Index() {
}, { key: 's', modifier: Modifier.Control }),
saveAll: createCommand(() => {
console.log('save all');
}, { key: 's', modifier: Modifier.Control|Modifier.Shift }),
edit: createCommand(() => {}),
selection: createCommand(() => {}),
view: createCommand(() => {}),
}, { key: 's', modifier: Modifier.Control | Modifier.Shift }),
edit: createCommand(() => { }),
selection: createCommand(() => { }),
view: createCommand(() => { }),
} as const;
const Tree: Component<{ entries: Entry[] }> = (props) => {
@ -121,52 +122,30 @@ export default function Index() {
console.log(`lets open '${file().name}'`);
open(file().meta);
}}><AiFillFile />{file().name}</span>
}}><AiFillFile /> {file().name}</span>
}</Show>
</li>
}</For>
</ul>
}
const Folder: Component<{ folder: FolderEntry, open?: true }> = (props) => {
return <details open={props.open ?? false}>
<summary>{props.folder.name}</summary>
const Folder: Component<{ folder: FolderEntry }> = (props) => {
const [open, setOpen] = createSignal(false);
return <details open={open()} on:toggle={() => setOpen(o => !o)}>
<summary><Show when={open()} fallback={<AiFillFolder />}><AiFillFolderOpen /></Show> {props.folder.name}</summary>
<Tree entries={props.folder.entries} />
</details>;
};
return (
<>
<Menu.Root>
<Menu.Item label="file">
<Menu.Item label="open" command={commands.open} />
<h1>Hi, welcome!</h1>
<b>Lets get started</b>
<Menu.Item label="open folder" command={commands.openFolder} />
<Menu.Item label="save" command={commands.save} />
<Menu.Item label="save all" command={commands.saveAll} />
</Menu.Item>
<Menu.Item label="edit" command={commands.edit} />
<Menu.Item label="selection" command={commands.selection} />
<Menu.Item label="view" command={commands.view} />
</Menu.Root>
<section class="index">
<aside>
<label><input type="checkbox" on:input={() => setShowHiddenFiles(v => !v)} />Show hidden files</label>
<Show when={tree()}>{
tree => <Tree entries={tree().entries} />
}</Show>
</aside>
<section>
<pre>{content()}</pre>
</section>
</section>
<ul>
<li><A href="/edit">Start editing</A></li>
</ul>
</>
);
}