diff --git a/public/assets/modals/icons/share.svg b/public/assets/modals/icons/share.svg new file mode 100644 index 0000000..be470b2 --- /dev/null +++ b/public/assets/modals/icons/share.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/components/apps/file-explorer/FileExplorer.jsx b/src/components/apps/file-explorer/FileExplorer.jsx index a8bfb1e..348daf8 100644 --- a/src/components/apps/file-explorer/FileExplorer.jsx +++ b/src/components/apps/file-explorer/FileExplorer.jsx @@ -60,8 +60,7 @@ export function FileExplorer({ startPath, selectorMode, Footer, modalsManager, o title: `${file.id} ${TITLE_SEPARATOR} Properties`, iconUrl: file.getIconUrl(), size: new Vector2(400, 500), - Modal: (props) => - + Modal: (props) => }); }}/> diff --git a/src/components/apps/settings/Settings.jsx b/src/components/apps/settings/Settings.jsx index 21d2d78..c361b06 100644 --- a/src/components/apps/settings/Settings.jsx +++ b/src/components/apps/settings/Settings.jsx @@ -11,10 +11,10 @@ import { AppsSettings } from "./tabs/AppsSettings.jsx"; /** * @param {import("../../windows/WindowView.jsx").windowProps} props */ -export function Settings({ initialTabIndex, modalsManager }) { +export function Settings({ tab, modalsManager }) { return ( { const settingsManager = useSettingsManager(); @@ -30,6 +33,7 @@ export const Desktop = memo(() => { const virtualRoot = useVirtualRoot(); const [showIcons, setShowIcons] = useState(false); const [iconSize, setIconSize] = useState(FALLBACK_ICON_SIZE); + const { openWindowedModal } = useWindowedModal({ modalsManager }); const directory = virtualRoot.navigate("~/Desktop"); @@ -54,7 +58,7 @@ export const Desktop = memo(() => { reloadViewport(); }}/> { - windowsManager.open("settings", { initialTabIndex: 0 }); + windowsManager.open("settings", { tab: 0 }); }}/> { @@ -63,6 +67,13 @@ export const Desktop = memo(() => { { windowsManager.open(APPS.TERMINAL, { startPath: directory.path }); }}/> + + { + openWindowedModal({ + size: new Vector2(350, 350), + Modal: (props) => + }); + }}/> }); const { onContextMenu: onContextMenuFile } = useContextMenu({ modalsManager, Actions: (props) => diff --git a/src/components/modals/ModalView.jsx b/src/components/modals/ModalView.jsx index 5cb2daf..b6b9f1c 100644 --- a/src/components/modals/ModalView.jsx +++ b/src/components/modals/ModalView.jsx @@ -4,6 +4,14 @@ import OutsideClickListener from "../../hooks/_utils/outsideClick.js"; import styles from "./ModalView.module.css"; import { useEffect } from "react"; +/** + * @typedef {object} modalProps + * @param {object} props + * @param {ModalType} props.modal + * @param {*} props.params + * @param {Function} props.onFinish + */ + /** * @param {object} root * @param {ModalType} root.modal @@ -11,13 +19,11 @@ import { useEffect } from "react"; export const ModalView = memo(({ modal }) => { useEffect(() => { const onDismiss = (event) => { - if (event.key === "Escape" && modal.dismissible) + if (event.key === "Escape") modal.close(); }; - if (modal.dismissible) { - document.addEventListener("keydown", onDismiss); - } + document.addEventListener("keydown", onDismiss); return () => { document.removeEventListener("keydown", onDismiss); diff --git a/src/components/modals/file-selector/FileSelector.jsx b/src/components/modals/file-selector/FileSelector.jsx index 1e7fd41..1d06160 100644 --- a/src/components/modals/file-selector/FileSelector.jsx +++ b/src/components/modals/file-selector/FileSelector.jsx @@ -30,9 +30,6 @@ export function FileSelector({ modal, params, type, allowedFormats, onFinish, .. const [selection, setSelection] = useState(multi ? [] : null); const [directory, setDirectory] = useState(null); - params.title = multi ? "Select files" : "Select a file"; - params.iconUrl = APP_ICONS.FILE_EXPLORER; - const finish = (event) => { event?.preventDefault(); @@ -56,7 +53,11 @@ export function FileSelector({ modal, params, type, allowedFormats, onFinish, .. onFinish?.(multi ? files : files[0]); }; - return + return { + const newValue = event.target.value; + setValue(newValue); + setOption(name, newValue); + }; + + return ; +} \ No newline at end of file diff --git a/src/components/modals/share/Share.jsx b/src/components/modals/share/Share.jsx new file mode 100644 index 0000000..e555b8f --- /dev/null +++ b/src/components/modals/share/Share.jsx @@ -0,0 +1,122 @@ +import { useEffect, useState } from "react"; +import ModalsManager from "../../../features/modals/modalsManager.js"; +import { WindowedModal } from "../_utils/WindowedModal.jsx"; +import styles from "./Share.module.css"; +import { copyToClipboard, generateUrl } from "../../../features/_utils/browser.utils.js"; +import AppsManager from "../../../features/apps/appsManager.js"; +import utilStyles from "../../../styles/utils.module.css"; +import { Button } from "../../_utils/button/Button.jsx"; +import Option from "./Option.jsx"; + +const APP_OPTIONS = { + "terminal": [ + { + label: "Command", + name: "input" + }, + ], + "browser": [ + { + label: "Website", + name: "startUrl" + }, + ], + "file-explorer": [ + { + label: "Path", + name: "startPath" + } + ] +}; + +/** @type {import("../ModalView.jsx").modalProps} */ +export function Share({ modal, params, ...props }) { + const [appId, setAppId] = useState(params.appId ?? ""); + const [fullscreen, setFullscreen] = useState(params.fullscreen ?? false); + const [options, setOptions] = useState({}); + const [url, setUrl] = useState(null); + + useEffect(() => { + setUrl(generateUrl({ + appId: appId !== "" ? appId : null, + fullscreen, + ...options + })); + }, [appId, fullscreen, options]); + + const onAppIdChange = (event) => { + const newAppId = event.target.value; + + if (newAppId === appId) + return; + + setAppId(newAppId); + + const appOptions = APP_OPTIONS[appId]; + if (!appOptions) { + setOptions({}); + } else { + const newOptions = {}; + appOptions.forEach(({ key }) => { + newOptions[key] = ""; + }); + setOptions(newOptions); + } + }; + + const onFullscreenChange = (event) => { + const newFullscreen = event.target.checked; + setFullscreen(newFullscreen); + }; + + const setOption = (name, value) => { + setOptions((options = {}) => { + options = { ...options }; + options[name] = value; + return options; + }); + }; + + return +
+

Share options

+
+ + + {APP_OPTIONS[appId]?.map(({ label, name }) => +
+
+
+

{url}

+ +
+
; +} \ No newline at end of file diff --git a/src/components/modals/share/Share.module.css b/src/components/modals/share/Share.module.css new file mode 100644 index 0000000..3a181b6 --- /dev/null +++ b/src/components/modals/share/Share.module.css @@ -0,0 +1,93 @@ +.Container { + display: flex; + flex-direction: column; + justify-content: space-between; + width: 100%; + height: 100%; + padding: 1rem; + overflow-y: auto; +} + +.Container > div { + display: flex; + flex-direction: column; + justify-content: flex-start; + align-items: flex-start; +} + +.Container > div:last-child { + gap: 0.5rem; + flex-direction: row; + justify-content: space-between; + align-items: center; +} + +.Title { + font-size: 1.5rem; +} + +.Title:first-child { + margin-top: 0.5rem; +} + +.Form { + display: flex; + flex-direction: column; + width: 100%; +} + +.Label { + --gap: 0.5rem; + + display: flex; + gap: var(--gap); + align-items: center; + width: 100%; +} + +.Label > p { + width: calc(40% - var(--gap)); + min-width: 40%; + text-align: start; +} + +.Input { + width: auto; + max-width: calc(60% - var(--gap)); + padding: 0.5rem 1rem; + color: var(--text-color); + background-color: var(--background-color-b); + border: none; + border-radius: 0.5rem; + outline: none; + font-size: 0.875em; +} + +select.Input > * { + color: inherit; + background-color: inherit; + border: none; + border-radius: 0.5rem; + outline: none; + font-family: inherit; + font-size: inherit; +} + +.Input[type=checkbox] { + background-color: var(--background-color-b); + outline: none; +} + +.Url { + text-align: start; + word-break: break-all; +} + +.Button { + --normal-color: var(--background-color-a) !important; + --hover-color: var(--background-color-b) !important; + + padding: 0.5rem 1rem; + border-radius: 0.5rem; + white-space: nowrap; +} \ No newline at end of file diff --git a/src/components/windows/WindowView.jsx b/src/components/windows/WindowView.jsx index b6a2bb0..971a412 100644 --- a/src/components/windows/WindowView.jsx +++ b/src/components/windows/WindowView.jsx @@ -20,6 +20,10 @@ import { NAME } from "../../config/branding.config.js"; import { setViewportIcon, setViewportTitle } from "../../features/_utils/browser.utils.js"; import { ZIndexManager } from "../../features/z-index/zIndexManager.js"; import { useZIndex } from "../../hooks/z-index/zIndex.js"; +import { useWindowedModal } from "../../hooks/modals/windowedModal.js"; +import { Divider } from "../actions/actions/Divider.jsx"; +import ModalsManager from "../../features/modals/modalsManager.js"; +import { Share } from "../modals/share/Share.jsx"; /** * @typedef {object} windowProps @@ -45,14 +49,15 @@ import { useZIndex } from "../../hooks/z-index/zIndex.js"; * @param {boolean} props.minimized * @param {Function} props.toggleMinimized */ -export const WindowView = memo(({ id, app, size, position, onInteract, options, active, minimized, toggleMinimized, index }) => { +export const WindowView = memo(({ id, app, size, position, onInteract, options, active, fullscreen, minimized, toggleMinimized, index }) => { const windowsManager = useWindowsManager(); const nodeRef = useRef(null); const [modalsManager, modals] = useModals(); + const { openWindowedModal } = useWindowedModal({ modalsManager }); const [startSize, setStartSize] = useState(size); const [startPosition, setStartPosition] = useState(position); - const [maximized, setMaximized] = useState(false); + const [maximized, setMaximized] = useState(fullscreen ?? false); const [screenWidth, screenHeight] = useScreenDimensions(); const [title, setTitle] = useState(app.name); const [iconUrl, setIconUrl] = useState(AppsManager.getAppIconUrl(app.id)); @@ -67,6 +72,15 @@ export const WindowView = memo(({ id, app, size, position, onInteract, options, { close(); }}/> + + { + openWindowedModal({ + appId: app.id, + fullscreen: maximized, + size: new Vector2(350, 350), + Modal: (props) => + }); + }}/> }); diff --git a/src/components/windows/WindowsView.jsx b/src/components/windows/WindowsView.jsx index 8cfe2fb..4e8c69e 100644 --- a/src/components/windows/WindowsView.jsx +++ b/src/components/windows/WindowsView.jsx @@ -5,7 +5,8 @@ import { WindowView } from "./WindowView.jsx"; import { useSettingsManager } from "../../hooks/settings/settingsManagerContext.js"; import { SettingsManager } from "../../features/settings/settingsManager.js"; import { NAME, TAG_LINE } from "../../config/branding.config.js"; -import { setViewportIcon, setViewportTitle } from "../../features/_utils/browser.utils.js"; +import { getViewportParams, setViewportIcon, setViewportTitle } from "../../features/_utils/browser.utils.js"; +import { removeDuplicatesFromArray } from "../../features/_utils/array.utils.js"; export const WindowsView = memo(() => { const settingsManager = useSettingsManager(); @@ -38,18 +39,33 @@ export const WindowsView = memo(() => { // Launch startup apps useEffect(() => { + if (windowsManager.startupComplete) + return; + + let startupAppNames = []; + + // Get app name and params from URL query + const params = getViewportParams(); + const appName = params.app; + if (appName) + startupAppNames.push(appName); + delete params.app; + + // Get list of app names from settings file const settings = settingsManager.get(SettingsManager.VIRTUAL_PATHS.apps); settings.get("startup", (value) => { - if (value !== "") - windowsManager.startup(value?.split(",")); + if (value !== "") { + startupAppNames = value?.split(",").concat(startupAppNames); + startupAppNames = removeDuplicatesFromArray(startupAppNames); + } + + windowsManager.startup(startupAppNames, params); }); }, [settingsManager, windowsManager]); - // TO DO: prevent windows from being rerendered when order is changed - return (
{windows.map((window) => { - const { id, app, size, position, options, minimized } = window; + const { id, app, size, position, options, minimized, fullscreen } = window; const index = sortedWindows.indexOf(window); return { event.stopPropagation(); windowsManager.setMinimized(id, !minimized); }} + fullscreen={fullscreen} />; })}
); diff --git a/src/features/_utils/array.utils.js b/src/features/_utils/array.utils.js index 7af3bef..05d16a1 100644 --- a/src/features/_utils/array.utils.js +++ b/src/features/_utils/array.utils.js @@ -15,4 +15,12 @@ export function removeFromArray(item, array) { */ export function randomFromArray(array) { return array[Math.floor(Math.random() * array.length)]; +} + +/** + * @param {*[]} array + * @returns {*[]} + */ +export function removeDuplicatesFromArray(array) { + return array.filter((item, index) => array.indexOf(item) === index); } \ No newline at end of file diff --git a/src/features/_utils/browser.utils.js b/src/features/_utils/browser.utils.js index 8de67f5..6a58f19 100644 --- a/src/features/_utils/browser.utils.js +++ b/src/features/_utils/browser.utils.js @@ -51,4 +51,63 @@ export function setViewportIcon(url) { document.head.appendChild(link); } link.href = url; +} + +/** + * @returns {URLSearchParams} + */ +export function getViewportParams() { + const query = window.location.search.slice(1); + + const params = {}; + query.split("&").forEach((param) => { + // For some reason, URI components only decode when decoded twice + // Please find a fix, or create a custom function + const [key, value] = param.split("=").map((item) => decodeURIComponent(decodeURIComponent(item))); + params[key] = value; + }); + + return params; +} + +/** + * @param {object} options + * @param {string} options.appId + * @param {boolean} options.fullscreen + */ +export function generateUrl(options) { + const baseUrl = window.location.origin + "/"; + + if (!options || Object.keys(options).length === 0) + return baseUrl; + + const { appId, fullscreen, ...extraOptions } = options; + const params = new URLSearchParams(); + + if (appId) + params.set("app", appId); + if (fullscreen) + params.set("fullscreen", fullscreen.toString()); + + if (extraOptions && Object.keys(extraOptions).length > 0) { + Object.entries(extraOptions).forEach(([key, value]) => { + if (key && value) + params.set(key, encodeURIComponent(value)); + }); + } + + if (params.size === 0) + return baseUrl; + + const url = `${baseUrl}?${params.toString()}`; + return url; +} + +/** + * @param {string} string + * @param {Function} onSuccess + * @param {Function} onFail + */ +export function copyToClipboard(string, onSuccess, onFail) { + navigator.clipboard.writeText(string).then(onSuccess, onFail); } \ No newline at end of file diff --git a/src/features/modals/modalsManager.js b/src/features/modals/modalsManager.js index 7b29aa2..87b6a50 100644 --- a/src/features/modals/modalsManager.js +++ b/src/features/modals/modalsManager.js @@ -83,4 +83,12 @@ export default class ModalsManager { get modalIds() { return Object.keys(this.modals); } + + /** + * @param {string} name + * @returns {string} + */ + static getModalIconUrl(name) { + return `${process.env.PUBLIC_URL}/assets/modals/icons/${name}.svg`; + } } \ No newline at end of file diff --git a/src/features/windows/windowsManager.js b/src/features/windows/windowsManager.js index f92f403..3fe5c1a 100644 --- a/src/features/windows/windowsManager.js +++ b/src/features/windows/windowsManager.js @@ -30,6 +30,17 @@ export default class WindowsManager { const position = new Vector2(randomRange(SCREEN_MARGIN, window.innerWidth - size.x - SCREEN_MARGIN), randomRange(SCREEN_MARGIN, window.innerHeight - size.y - SCREEN_MARGIN - TASKBAR_HEIGHT)); + let fullscreen = false; + if (options?.fullscreen) { + if (typeof(options.fullscreen) == String) { + fullscreen = options.fullscreen.toLowerCase() === "true"; + } else { + fullscreen = options.fullscreen; + } + + delete options.fullscreen; + } + let id = 0; while (this.windowIds.includes(id.toString())) { id++; @@ -50,6 +61,7 @@ export default class WindowsManager { app, size, position, + fullscreen, options, }; @@ -192,12 +204,16 @@ export default class WindowsManager { this.updateWindows = updateWindows; } - startup(appIds) { + /** + * @param {string[]} appIds + * @param {{}} options + */ + startup(appIds, options) { if (appIds == null || this.startupComplete) return; appIds.forEach((appId) => { - this.open(appId); + this.open(appId, options); }); this.startupComplete = true; diff --git a/src/index.js b/src/index.js index 4624d3a..2e89953 100644 --- a/src/index.js +++ b/src/index.js @@ -24,4 +24,4 @@ console.info(ASCII_LOGO + space + welcomeMessage); // If you want to start measuring performance in your app, pass a function // to log results (for example: reportWebVitals(console.log)) // or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals -reportWebVitals(); +reportWebVitals(); \ No newline at end of file diff --git a/src/styles/global.css b/src/styles/global.css index 4d25143..4a9232c 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -29,7 +29,7 @@ body { text-rendering: optimizelegibility; } -p, a, button, input, h1, h2, h3, h4, h5, h6 { +p, a, button, input, select, h1, h2, h3, h4, h5, h6 { font-family: var(--body-font-family); letter-spacing: normal; }