diff --git a/base.css b/base.css new file mode 100644 index 00000000..4882f97f --- /dev/null +++ b/base.css @@ -0,0 +1,29 @@ +html, +body { + padding: 0; + font-family: sans-serif; +} + +*, +*::after, +*::before { + margin: 0; + box-sizing: border-box +} + +@media (prefers-reduced-motion: no-preference) { + html { + interpolate-size: allow-keywords; + } +} + +input, +button, +textarea, +select { + font: inherit; +} + +img { + font-size: 12px; +} diff --git a/docs/Conversion flowchart.drawio b/docs/Conversion flowchart.drawio new file mode 100644 index 00000000..887512b6 --- /dev/null +++ b/docs/Conversion flowchart.drawio @@ -0,0 +1,360 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/Conversion flowchart.png b/docs/Conversion flowchart.png new file mode 100644 index 00000000..34716e60 Binary files /dev/null and b/docs/Conversion flowchart.png differ diff --git a/docs/wireframe.png b/docs/wireframe.png new file mode 100644 index 00000000..fef65515 Binary files /dev/null and b/docs/wireframe.png differ diff --git a/index.html b/index.html index 5bf122e9..3434ad7a 100644 --- a/index.html +++ b/index.html @@ -1,54 +1,20 @@ + + + Convert to it! - - - - - - -
-

Click to add your file

-

or drag and drop it here

-
-
- -
- -
- -
-

Convert from:

- -
- -
-
-
-

Convert to:

- -
- -
-
- -
- - - - - - - + - \ No newline at end of file + + + + diff --git a/package.json b/package.json index c5a81ef6..8665f76c 100644 --- a/package.json +++ b/package.json @@ -43,6 +43,7 @@ } }, "devDependencies": { + "@preact/preset-vite": "^2.10.3", "@types/jszip": "^3.4.0", "@types/opentype.js": "^1.3.9", "electron": "^40.6.0", @@ -59,6 +60,7 @@ "@ffmpeg/util": "^0.12.2", "@flo-audio/reflo": "^0.1.2", "@imagemagick/magick-wasm": "^0.0.37", + "@preact/signals": "^2.8.1", "@sqlite.org/sqlite-wasm": "^3.51.2-build6", "@stringsync/vexml": "^0.1.8", "@toon-format/toon": "^2.1.0", @@ -80,11 +82,13 @@ "papaparse": "^5.5.3", "pdftoimg-js": "^0.2.5", "pe-library": "^2.0.1", + "preact": "^10.28.3", "svg-pathdata": "^8.0.0", "three": "^0.182.0", "three-bvh-csg": "^0.0.17", "three-mesh-bvh": "^0.9.8", "ts-flp": "^1.0.3", + "use-debounce": "^10.1.0", "verovio": "^6.0.1", "vexflow": "^5.0.0", "vite-plugin-static-copy": "^3.1.6", diff --git a/src/main.new.ts b/src/main.new.ts new file mode 100644 index 00000000..ffdf5800 --- /dev/null +++ b/src/main.new.ts @@ -0,0 +1,163 @@ +import type { FileFormat, FileData, FormatHandler, ConvertPathNode } from "./FormatHandler.js"; +import normalizeMimeType from "./normalizeMimeType.js"; +import handlers from "./handlers"; +import { TraversionGraph } from "./TraversionGraph.js"; +import { PopupData } from "./ui/index.js"; +import { closePopup, openPopup } from "./ui/PopupStore.js"; +import { signal } from "@preact/signals"; +import { Mode, ModeEnum } from "./ui/ModeStore.js"; + +/** KV pairs of files */ +type FileRecord = Record<`${string}-${string}`, File> + +/** Map of available formats and its handler */ +export type ConversionOptionsMap = Map; +/** A single conversion option, derived from `ConversionOptionsMap` */ +export type ConversionOption = ConversionOptionsMap extends Map ? [K, V] : never; + +export const ConversionOptions: ConversionOptionsMap = new Map(); + +/** + * Files currently selected for conversion + */ +export const SelectedFiles = signal({}); + +/** + * Handlers that support conversion from any formats + */ +export const ConversionsFromAnyInput: ConvertPathNode[] = + handlers + .filter(h => h.supportAnyInput && h.supportedFormats) + .flatMap(h => h.supportedFormats! + .filter(f => f.to) + .map(f => ({ handler: h, format: f }))); + +window.supportedFormatCache = new Map(); +window.traversionGraph = new TraversionGraph(); + +window.printSupportedFormatCache = () => { + const entries = []; + for (const entry of window.supportedFormatCache) + entries.push(entry); + return JSON.stringify(entries, null, 2); +} + +async function buildOptionList() { + ConversionOptions.clear(); + + for (const handler of handlers) { + if (!window.supportedFormatCache.has(handler.name)) { + console.warn(`Cache miss for formats of handler "${handler.name}"`); + + try { + await handler.init(); + } catch (_) { continue } + + if (handler.supportedFormats) { + window.supportedFormatCache.set(handler.name, handler.supportedFormats); + console.info(`Updated supported format cache for "${handler.name}"`); + } + } + + const supportedFormats = window.supportedFormatCache.get(handler.name); + + if (!supportedFormats) { + console.warn(`Handler "${handler.name}" doesn't support any formats`); + continue + } + + for (const format of supportedFormats) { + if (!format.mime) continue; + ConversionOptions.set(format, handler); + } + } + + closePopup(); +} + +async function attemptConvertPath(files: FileData[], path: ConvertPathNode[]) { + PopupData.value = { + title: "Finding conversion route...", + text: `Trying ${path.map(c => c.format.format).join(" → ")}` + } + openPopup(); + + for (let i = 0; i < path.length - 1; i++) { + const handler = path[i + 1].handler; + + try { + let supportedFormats = window.supportedFormatCache.get(handler.name); + + if (!handler.ready) { + try { + await handler.init(); + } catch (_) { return null; } + + if (handler.supportedFormats) { + window.supportedFormatCache.set(handler.name, handler.supportedFormats); + supportedFormats = handler.supportedFormats; + } + } + + if (!supportedFormats) throw `Handler "${handler.name}" doesn't support any formats.`; + + const inputFormat = supportedFormats.find(c => c.mime === path[i].format.mime && c.from)!; + + files = ( + await Promise.all([ + handler.doConvert(files, inputFormat, path[i + 1].format), + // Ensure that we wait long enough for the UI to update + new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))) + ]) + )[0]; + + if (files.some(c => !c.bytes.length)) throw "Output is empty."; + } catch (e) { + console.log(path.map(c => c.format.format)); + console.error(handler.name, `${path[i].format.format} → ${path[i + 1].format.format}`, e); + + await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))); + return null; + } + } +} + +window.tryConvertByTraversing = async function ( + files: FileData[], + from: ConvertPathNode, + to: ConvertPathNode +) { + for await (const path of window.traversionGraph.searchPath(from, to, Mode.value === ModeEnum.Simple)) { + // Use exact output format if the target handler supports it + if (path.at(-1)?.handler === to.handler) { + path[path.length - 1] = to; + } + const attempt = await attemptConvertPath(files, path); + if (attempt) return attempt; + } + return null; +} + +function downloadFile(bytes: Uint8Array, name: string, mime: string) { + const blob = new Blob([bytes as BlobPart], { type: mime }); + const link = document.createElement("a"); + link.href = URL.createObjectURL(blob); + link.download = name; + link.click(); +} + +try { + const cacheJSON = await fetch("cache.json") + .then(r => r.json()); + window.supportedFormatCache = new Map(cacheJSON); +} catch (error) { + console.warn( + "Missing supported format precache.\n\n" + + "Consider saving the output of printSupportedFormatCache() to cache.json." + ); +} finally { + await buildOptionList(); + console.log("Built initial format list."); +} + +console.debug(ConversionOptions); diff --git a/src/main.ts b/src/main.ts index 3521bd10..09c38247 100644 --- a/src/main.ts +++ b/src/main.ts @@ -4,7 +4,7 @@ import handlers from "./handlers"; import { TraversionGraph } from "./TraversionGraph.js"; /** Files currently selected for conversion */ -let selectedFiles: File[] = []; +export let selectedFiles: File[] = []; /** * Whether to use "simple" mode. * - In **simple** mode, the input/output lists are grouped by file format. @@ -316,10 +316,10 @@ ui.modeToggleButton.addEventListener("click", () => { simpleMode = !simpleMode; if (simpleMode) { ui.modeToggleButton.textContent = "Advanced mode"; - document.body.style.setProperty("--highlight-color", "#1C77FF"); + document.body.style.setProperty("--primary", "#1C77FF"); } else { ui.modeToggleButton.textContent = "Simple mode"; - document.body.style.setProperty("--highlight-color", "#FF6F1C"); + document.body.style.setProperty("--primary", "#FF6F1C"); } buildOptionList(); }); diff --git a/src/ui/ModeStore.ts b/src/ui/ModeStore.ts new file mode 100644 index 00000000..707ddbe3 --- /dev/null +++ b/src/ui/ModeStore.ts @@ -0,0 +1,40 @@ +import { signal } from "@preact/signals"; + +const STORAGE_KEY = "mode"; + +export enum ModeEnum { + Simple, + Advanced +} + +export const enum ModeText { + Simple = "Simple mode", + Advanced = "Advanced mode" +} + +function getInitialMode(): ModeEnum { + const stored = localStorage.getItem(STORAGE_KEY); + return (!!stored) ? parseInt(stored, 10) : ModeEnum.Simple; +} + +export const Mode = signal(getInitialMode()); + +function applyMode(value: ModeEnum) { + if (value === ModeEnum.Simple) document.documentElement.style.setProperty("--primary", "#1C77FF"); + if (value === ModeEnum.Advanced) document.documentElement.style.setProperty("--primary", "#FF6F1C"); +} + +Mode.subscribe((value) => { + localStorage.setItem(STORAGE_KEY, value.toString()); + applyMode(value); +}) + +export function toggleMode() { + Mode.value = Mode.value === ModeEnum.Advanced + ? ModeEnum.Simple + : ModeEnum.Advanced; +} + +export function initMode() { + applyMode(Mode.value); +} diff --git a/src/ui/PopupStore.ts b/src/ui/PopupStore.ts new file mode 100644 index 00000000..c66d9c10 --- /dev/null +++ b/src/ui/PopupStore.ts @@ -0,0 +1,40 @@ +import { signal } from '@preact/signals' + +export interface PopupDataContainer { + /** Title of the popup */ + title?: string + /** The description text of the popup */ + text?: string + /** + * Is the popup soft-dismissible? + * + * If this is false, the modal must be closed programmatically, or else it'll be stuck blocking input + * + * @see https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/dialog#popover_api_html_attributes + */ + dismissible?: boolean + /** Text for the button. If this is undefined, the popup will hide the button */ + buttonText?: string + /** The event handler for the button. If this is just `true`, clicking the button will close the modal */ + buttonOnClick?: preact.MouseEventHandler | true + /** + * Raw contents of the popup. Can be any arbitrary JSX data. + * + * If this is declared, properties `title` and `text` are ignored + */ + contents?: preact.JSX.Element +} + +export const popupOpen = signal(false); + +export const openPopup = () => (popupOpen.value = true); +export const closePopup = () => (popupOpen.value = false); +export const togglePopup = () => (popupOpen.value = !popupOpen.value); + +// Manual overrides +// @ts-expect-error +window.openPopup = openPopup; +// @ts-expect-error +window.closePopup = closePopup; +// @ts-expect-error +window.togglePopup = togglePopup; diff --git a/src/ui/ThemeStore.ts b/src/ui/ThemeStore.ts new file mode 100644 index 00000000..2579547a --- /dev/null +++ b/src/ui/ThemeStore.ts @@ -0,0 +1,35 @@ +import { signal } from "@preact/signals"; + +const STORAGE_KEY = "theme"; + +export type Theme = "light" | "dark"; + +function getSystemTheme(): Theme { + return window.matchMedia("(prefers-color-scheme: dark)").matches + ? "dark" + : "light"; +} + +function getInitialTheme(): Theme { + const stored = localStorage.getItem(STORAGE_KEY) as Theme | null; + return stored ?? getSystemTheme(); +} + +export const theme = signal(getInitialTheme()); + +function applyTheme(value: Theme) { + document.documentElement.dataset.theme = value; +} + +theme.subscribe((value) => { + localStorage.setItem(STORAGE_KEY, value); + applyTheme(value); +}); + +export function toggleTheme() { + theme.value = theme.value === "dark" ? "light" : "dark"; +} + +export function initTheme() { + applyTheme(theme.value); +} \ No newline at end of file diff --git a/src/ui/components/AdvancedModeToggle.tsx b/src/ui/components/AdvancedModeToggle.tsx new file mode 100644 index 00000000..da1e4e9d --- /dev/null +++ b/src/ui/components/AdvancedModeToggle.tsx @@ -0,0 +1,26 @@ +import { Mode, ModeEnum, ModeText, toggleMode } from "../ModeStore"; +import StyledButton from "./StyledButton"; + +interface AdvancedModeToggleComponentProps { + compact: boolean +} + +export default function AdvancedModeToggle({ compact }: AdvancedModeToggleComponentProps) { + const onAdvancedModeClick = (ev: preact.TargetedMouseEvent) => { + toggleMode(); + } + + return ( + + { + (compact) + ? Mode.value === ModeEnum.Advanced ? "S" : "A" + : Mode.value === ModeEnum.Advanced ? ModeText.Simple : ModeText.Advanced + } + + ) +} diff --git a/src/ui/components/Conversion/ConversionHeader.css b/src/ui/components/Conversion/ConversionHeader.css new file mode 100644 index 00000000..43366f38 --- /dev/null +++ b/src/ui/components/Conversion/ConversionHeader.css @@ -0,0 +1,31 @@ +.conversion-header { + height: 64px; + background: var(--bg-card); + border-block-end: 1px solid var(--border-primary); + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 1.5rem; + z-index: 20; + flex-shrink: 0; + position: sticky; + top: 0; + + .header-left { + display: flex; + align-items: center; + gap: 0.75rem; + + .conversion-title { + font-size: 1.25rem; + font-weight: 900; + color: var(--text-primary); + } + } + + .header-right { + display: flex; + align-items: center; + gap: 1rem; + } +} diff --git a/src/ui/components/Conversion/ConversionHeader.tsx b/src/ui/components/Conversion/ConversionHeader.tsx new file mode 100644 index 00000000..849b512d --- /dev/null +++ b/src/ui/components/Conversion/ConversionHeader.tsx @@ -0,0 +1,29 @@ +import logoImage from '../../img/logo.svg'; +import { Icon } from "../Icon"; +import DarkModeToggle from '../DarkModeToggle'; +import SelectedFileInfo from './SelectedFileInfo'; + +import "./ConversionHeader.css"; +import AdvancedModeToggle from '../AdvancedModeToggle'; + +export default function ConversionHeader() { + return ( +
+
+ +

Convert to it!

+
+ +
+ {/* Desktop File Info */ } + + + +
+
+ ); +} diff --git a/src/ui/components/Conversion/ConversionSettings.css b/src/ui/components/Conversion/ConversionSettings.css new file mode 100644 index 00000000..7c494200 --- /dev/null +++ b/src/ui/components/Conversion/ConversionSettings.css @@ -0,0 +1,224 @@ +.conversion-settings { + background: var(--bg-card); + display: flex; + flex-direction: column; + + .settings-content { + flex: 1; + padding: 1.25rem; + + .input-group { + margin-bottom: 1.5rem; + + .group-label { + display: block; + font-size: 0.75rem; + font-weight: 700; + color: #64748b; + text-transform: uppercase; + letter-spacing: 0.08em; + margin-bottom: 0.75rem; + } + } + + .dual-input { + display: flex; + align-items: center; + gap: 0.5rem; + + .link-icon { + background-color: #d1d5db; + cursor: pointer; + transition: color 0.15s ease; + display: flex; + align-items: center; + justify-content: center; + margin-top: 2px; + + &:hover { + background-color: var(--primary); + } + } + } + + .input-wrapper { + position: relative; + flex: 1; + + &.floating-label { + label { + position: absolute; + left: 0.65rem; + top: 0.4rem; + font-size: 10px; + font-weight: 600; + color: #9ca3af; + text-transform: none; + letter-spacing: normal; + margin-bottom: 0; + pointer-events: none; + z-index: 1; + } + + input { + padding: 1.25rem 0.65rem 0.35rem 0.65rem; + line-height: 1; + } + } + + input { + width: 100%; + font-size: 0.875rem; + border: 1px solid var(--border-primary); + border-radius: 0.5rem; + padding: 0.5rem 3rem 0.5rem 0.75rem; + color: var(--text-tertiary); + outline: none; + transition: all 0.15s ease; + background-color: var(--bg-input); + + &:hover { + border-color: var(--border-secondary); + } + + &:focus { + border-color: var(--primary); + box-shadow: 0 0 0 1px var(--primary); + } + } + + &.unit-right .unit { + position: absolute; + right: 0.75rem; + top: 50%; + transform: translateY(-50%); + font-size: 0.75rem; + font-weight: 700; + color: #9ca3af; + pointer-events: none; + } + } + } + + @media (max-width: 1024px) { + width: 100%; + border-left: none; + border-top: 1px solid var(--border-primary); + display: flex; + flex-direction: column; + + .collapsible-wrapper { + display: grid; + grid-template-rows: 0fr; + transition: grid-template-rows 0.3s ease-out; + overflow: hidden; + } + + .settings-content { + min-height: 0; + transition: opacity 0.2s ease, padding 0.3s ease-out; + opacity: 0; + padding: 0 1.25rem; + } + + /* Expanded State */ + &.is-expanded { + .collapsible-wrapper { + grid-template-rows: 1fr; + } + + .settings-content { + opacity: 1; + padding: 1.25rem; + } + } + + .action-footer { + display: block; + position: relative; + z-index: 10; + background: var(--bg-card); + } + } + + .chevron-icon { + transform: rotate(-180deg); + transition: transform 0.3s ease; + } + + .chevron-icon.rotate { + transform: rotate(0deg); + } + + .settings-header { + padding: 1.25rem; + border-bottom: 1px solid var(--border-primary); + + h3 { + font-size: 0.9rem; + font-weight: 800; + display: flex; + align-items: center; + gap: 0.5rem; + color: var(--text-muted); + text-transform: uppercase; + } + + i { + color: var(--primary); + } + } + + .mobile-settings-header { + user-select: none; + cursor: pointer; + border-radius: 8px; + padding: 1rem; + display: flex; + justify-content: space-between; + align-items: center; + + h3 { + font-size: 0.9rem; + } + + &:hover { + background: var(--bg-page); + color: var(--primary); + } + } + + .scroller { + overflow-y: auto; + } + + .full-select { + width: 100%; + padding: 0.6rem; + border-radius: 8px; + color: var(--text-tertiary); + border: 1px solid var(--border-primary); + background-color: var(--bg-input); + } + + .divider { + display: flex; + align-items: center; + gap: 0.5rem; + margin: 1.5rem 0; + + .line { + flex: 1; + height: 1px; + background: var(--border-primary); + } + + .divider-text { + font-size: 0.6rem; + font-weight: 800; + color: var(--text-tertiary); + text-transform: uppercase; + letter-spacing: 0.05em; + } + } +} diff --git a/src/ui/components/Conversion/ConversionSettings.tsx b/src/ui/components/Conversion/ConversionSettings.tsx new file mode 100644 index 00000000..7b14c273 --- /dev/null +++ b/src/ui/components/Conversion/ConversionSettings.tsx @@ -0,0 +1,104 @@ +import { useState } from "preact/hooks"; + +import faWrenchSolid from '../../img/fa-wrench-solid-full.svg'; +import faChevronDownSolid from '../../img/fa-chevron-down-solid-full.svg'; +import faSlidersSolid from '../../img/fa-sliders-solid-full.svg'; +import faLinkSolid from '../../img/fa-link-solid-full.svg'; + +import type { FormatCategory } from "./SideNav"; +import { Icon } from "../Icon"; + +import "./ConversionSettings.css" + +interface ConversionSettingsProps { + +} + +export default function ConversionSettings(props: ConversionSettingsProps) { + const [isSettingsExpanded, setIsSettingsExpanded] = useState(false); + + const toggleSettings = () => { + setIsSettingsExpanded(!isSettingsExpanded); + }; + + return ( +
+
+

+ { " " } + Options +

+ +
+
+

+ { " " } + Output Settings +

+
+ +
+
+
+ +
+
+ + +
+ +
+ +
+ +
+ + +
+
+
+ +
+
+ OR +
+
+ +
+ +
+ + DPI +
+
+ +
+
+
+ +
+ + +
+
+
+
+ ) +} diff --git a/src/ui/components/Conversion/ConversionSidebar.tsx b/src/ui/components/Conversion/ConversionSidebar.tsx new file mode 100644 index 00000000..984570ea --- /dev/null +++ b/src/ui/components/Conversion/ConversionSidebar.tsx @@ -0,0 +1,27 @@ +import type { ConvertPathNode, FileData } from "src/FormatHandler"; +import ConversionSettings from "./ConversionSettings"; + +interface ConversionSidebarComponentProps { + conversionData: { + files: FileData[], from: ConvertPathNode, to: ConvertPathNode + } +} + +export default function ConversionSidebar({ conversionData }: ConversionSidebarComponentProps) { + const conversionHandler = (data: ConversionSidebarComponentProps['conversionData']) => { + console.debug(data); + } + + return ( + + ); +} diff --git a/src/ui/components/Conversion/FormatCard.css b/src/ui/components/Conversion/FormatCard.css new file mode 100644 index 00000000..293e509d --- /dev/null +++ b/src/ui/components/Conversion/FormatCard.css @@ -0,0 +1,89 @@ +.format-card { + appearance: none; + background: var(--bg-card); + border: 1px solid var(--border-primary); + border-radius: 12px; + padding: 1rem; + + display: block; + text-align: left; + font: inherit; + + /* This ruins repaint metrics, especially with a lot of cards */ + /* transition: all 0.2s; */ + + .icon { + background-color: var(--text-muted); + } + + &:hover { + border-color: var(--primary); + /* box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05); */ + } + + &.active { + outline: 4px solid var(--primary); + /* box-shadow: 0 4px 12px rgba(28, 119, 255, 0.1); */ + + .icon { + background-color: var(--primary); + } + } + + .card-top { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 1rem; + } + + .card-icon-lg { + width: 40px; + height: 40px; + background: var(--bg-card-alt); + color: var(--primary); + display: flex; + align-items: center; + justify-content: center; + border-radius: 8px; + font-size: 1.2rem; + } + + .badge { + background: var(--primary); + color: var(--text-on-primary); + font-size: 0.7rem; + font-weight: 800; + padding: 2px 6px; + border-radius: 4px; + + &.gray { + background: #e5e7eb; + color: #4b5563; + } + } + + .card-mobile-header { + display: flex; + justify-content: space-between; + align-items: center; + } + + .card-title-group { + display: flex; + align-items: center; + gap: 0.5rem; + } + + h3 { + font-size: 0.95rem; + color: var(--text-primary); + font-weight: 700; + } + + .mime-type { + font-size: 0.75rem; + color: var(--text-muted); + margin-top: 4px; + } +} diff --git a/src/ui/components/Conversion/FormatCard.tsx b/src/ui/components/Conversion/FormatCard.tsx new file mode 100644 index 00000000..11e7a6b5 --- /dev/null +++ b/src/ui/components/Conversion/FormatCard.tsx @@ -0,0 +1,55 @@ +import type { ConversionOption } from "../../../main.new"; +import { Mode, ModeEnum } from "../../ModeStore"; +import { Icon } from "../Icon"; +import faImageRegularFull from "../../img/fa-image-regular-full.svg"; + +import "./FormatCard.css"; + +interface FormatCardProps { + conversionOption: ConversionOption + id: string + selected: boolean + onSelect: (id: string) => void +} + +export default function FormatCard({ conversionOption, id, selected, onSelect }: FormatCardProps) { + + return ( + + ); +} diff --git a/src/ui/components/Conversion/FormatExplorer.css b/src/ui/components/Conversion/FormatExplorer.css new file mode 100644 index 00000000..d31b5772 --- /dev/null +++ b/src/ui/components/Conversion/FormatExplorer.css @@ -0,0 +1,83 @@ +.format-explorer.content-wrapper { + display: flex; + flex: 1; + min-width: 0; + min-height: 0; +} + +.format-explorer { + .format-browser { + flex: 1; + display: flex; + flex-direction: column; + background: var(--bg-page); + + .search-container { + padding: 1.5rem; + + .search-input-wrapper { + position: relative; + max-width: 600px; + + .icon { + position: absolute; + left: 1rem; + top: 50%; + transform: translateY(-50%); + color: var(--text-muted); + } + + input { + width: 100%; + padding: 0.75rem 1rem 0.75rem 2.5rem; + border-radius: 12px; + border: 1px solid var(--border-primary); + color: var(--text-tertiary); + background-color: var(--bg-input); + outline: none; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.02); + + &:focus { + border-color: var(--primary); + box-shadow: 0 0 0 4px rgba(28, 119, 255, 0.1); + } + } + } + } + + .format-list-container { + flex: 1; + padding: 0 1.5rem 1.5rem; + + .list-header { + display: flex; + justify-content: space-between; + margin-bottom: 1rem; + + h2 { + font-size: 0.75rem; + font-weight: 800; + color: var(--text-muted); + text-transform: uppercase; + } + + span { + font-size: 0.75rem; + color: var(--text-muted); + } + } + + @media (max-width: 1024px) { + padding: 0; + } + } + } + + .format-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); + gap: 1rem; + overflow: hidden; + padding: .5rem; + } +} diff --git a/src/ui/components/Conversion/FormatExplorer.tsx b/src/ui/components/Conversion/FormatExplorer.tsx new file mode 100644 index 00000000..6d168bfa --- /dev/null +++ b/src/ui/components/Conversion/FormatExplorer.tsx @@ -0,0 +1,156 @@ +import { Icon } from "../Icon"; +import FormatCard from "./FormatCard"; +import SideNav, { type FormatCategory } from "./SideNav"; +import faMagnifyingGlassSolid from '../../img/fa-magnifying-glass-solid-full.svg'; +import { useDebouncedCallback } from 'use-debounce'; + +import './FormatExplorer.css'; +import { useMemo, useState } from "preact/hooks"; +import type { ConversionOption, ConversionOptionsMap } from "src/main.new"; +import { Mode, ModeEnum } from "src/ui/ModeStore"; + +interface FormatExplorerProps { + categories: FormatCategory[]; + conversionOptions: ConversionOptionsMap + onSelect?: (format: ConversionOption) => void; + debounceWaitMs?: number; +} + +type SearchIndex = Map + +/** + * Generates a search index which contains a `Map` of the flattened conversion option values as keys, and the actual {@linkcode ConversionOption} tuple as the value + * + * @param optionsMap The map of conversion options to process + * @param advancedModeEnabled If Advanced Mode is disabled, omit extra conversion options + */ +function generateSearchIndex(optionsMap: ConversionOptionsMap, advancedModeEnabled: boolean): SearchIndex { + const index: SearchIndex = new Map(); + for (const [file, handler] of optionsMap) { + const keyStr = `${file.name}${file.format}${file.extension}${file.mime}${handler.name}`.toLowerCase(); + if (advancedModeEnabled || !index.has(keyStr)) { + index.set(keyStr, [file, handler]); + } + } + return index; +} + +export default function FormatExplorer({ + categories, + conversionOptions, + onSelect, + debounceWaitMs = 250 +}: FormatExplorerProps) { + /** + * Cached search index of conversion options + * + * See {@linkcode generateSearchIndex} for how it's done + */ + const originalIndex = useMemo( + () => generateSearchIndex(conversionOptions, Mode.value === ModeEnum.Advanced), + [conversionOptions, Mode.value] + ); + + /** The search term */ + const [searchTerm, setSearchTerm] = useState(""); + + /** The index of search results */ + const searchResultsIndex = useMemo( + () => !searchTerm + ? originalIndex + : filterFormats(originalIndex, searchTerm), + [originalIndex, searchTerm] + ); + + /** + * Note the format ID here must correspond to the keys generated by {@linkcode generateSearchIndex} + */ + const [selectedOptionId, setSelectedOptionId] = + useState(null); + + /** + * Filter available cards according to the search term and where to search for it + * @param term The text to search for. Internally, it's lowercased + */ + function filterFormats(options: SearchIndex, term: string): SearchIndex { + const filteredFormats: SearchIndex = new Map(); + for (const [key, optionPair] of options) { + if (key.includes(term.toLowerCase())) + filteredFormats.set(key, optionPair); + } + return filteredFormats; + } + + /** + * Debounce handler for the search. + * + * If the input is empty, return all formats + */ + const handleDebounceSearch = useDebouncedCallback((term) => { + setSearchTerm(term); + }, debounceWaitMs); + + /** Handles option selection by setting state and bubbling it up to the `onSelect` handler */ + const handleOptionSelection = (id: string, option: ConversionOption) => { + setSelectedOptionId(id); + onSelect?.(option); + }; + + return ( +
+ + + {/* Center Browser */ } +
+
+
+ + handleDebounceSearch(ev.currentTarget.value) } + /> +
+
+ +
+
+ {/*

Common Formats

*/ } + Showing { searchResultsIndex.size } formats +
+ +
+ { + Array.from(searchResultsIndex).map(([key, option]) => ( + handleOptionSelection(key, option) } + conversionOption={ option } + id={ key } + key={ key } + /> + )) + } + {/* { + searchResultsIndex.map((option, i) => ( + handleFormatSelection(id, option) } + formatType={ card } + key={ card.id.concat(`-${i}`) } + id={ card.id } + handler={ card.handlerName } + /> + )) + } */} +
+
+
+
+ ) +} diff --git a/src/ui/components/Conversion/SelectedFileInfo.css b/src/ui/components/Conversion/SelectedFileInfo.css new file mode 100644 index 00000000..442b5998 --- /dev/null +++ b/src/ui/components/Conversion/SelectedFileInfo.css @@ -0,0 +1,60 @@ +.file-info-container { + + display: flex; + align-items: center; + + &.mobile-only { + margin-block: .75em; + justify-content: center; + flex-wrap: wrap; + gap: .5rem; + } + + .file-info-badge { + cursor: pointer; + background: var(--bg-section); + border: 1px solid var(--border-primary); + padding: 0.4rem 0.75rem; + border-radius: 8px; + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.875rem; + transition: background 150ms, border-color 150ms; + + &:hover, + &:focus-visible { + background: color-mix(in srgb, var(--bg-section), red 25%); + border-color: var(--border-secondaru); + } + + &:not(:last-child) { + margin-inline-end: .5em; + } + + .file-name { + font-weight: 500; + color: var(--text-primary); + max-width: 150px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .file-size { + color: var(--text-muted); + padding-left: 0.5rem; + border-left: 1px solid var(--border-primary); + } + + .format-select { + background: var(--bg-input); + color: var(--text-tertiary); + border: 1px solid var(--border-primary); + border-radius: 4px; + font-size: 0.75rem; + padding: 2px 4px; + } + } + +} diff --git a/src/ui/components/Conversion/SelectedFileInfo.tsx b/src/ui/components/Conversion/SelectedFileInfo.tsx new file mode 100644 index 00000000..0a7be4ee --- /dev/null +++ b/src/ui/components/Conversion/SelectedFileInfo.tsx @@ -0,0 +1,80 @@ +import type { CSSProperties } from "preact"; + +import faImageRegular from "../../img/fa-image-regular-full.svg"; +import faXRegular from "../../img/fa-x-solid-full.svg"; + +import { Icon } from "../Icon"; + +import "./SelectedFileInfo.css" +import { SelectedFiles } from "src/main.new"; +import { useState } from "preact/hooks"; +import { CurrentPage, Pages } from "src/ui"; + +interface SelectedFileInfoProps { + className?: string + style?: CSSProperties +} + +interface FileInfoBadgeProps { + filename: string + timestamp: string +} + +export default function SelectedFileInfo({ className = "", style = {} }: SelectedFileInfoProps) { + + function FileInfoBadge({ filename, timestamp }: FileInfoBadgeProps) { + const [deleteHover, setDeleteHover] = useState(false); + + /** + * Remove a file entry from the SelectedFiles map by key + * + * Creates a new object that omits the given name and + * re-assigns it back to the signal, ensuring proper + * capture by Signals + * + * @param name Name of file + * @param timestamp Timestamp of the file + */ + const removeFile = (name: string, timestamp: string) => { + const { [`${name}-${timestamp}` as const]: _, ...rest } = SelectedFiles.value; + SelectedFiles.value = rest; + } + + const handleMouseOver = () => { + setDeleteHover(true); + } + + const handleOnClick = () => { + setDeleteHover(false); + removeFile(filename, timestamp); + if (Object.keys(SelectedFiles.value).length === 0) CurrentPage.value = Pages.Upload; + } + + return ( +
setDeleteHover(false) } + onClick={ handleOnClick } + > + + { filename } +
+ ) + } + + return ( +
+ { + Object.values(SelectedFiles.value).map(file => + + ) + } +
+ ); +} diff --git a/src/ui/components/Conversion/SideNav.css b/src/ui/components/Conversion/SideNav.css new file mode 100644 index 00000000..dc99a94b --- /dev/null +++ b/src/ui/components/Conversion/SideNav.css @@ -0,0 +1,85 @@ +.side-nav { + min-width: 150px; + width: 250px; + background: var(--bg-card); + border-inline-end: 1px solid var(--border-primary); + display: flex; + flex-direction: column; + + @media (max-width: 1024px) { + width: 25% !important; + } + + .nav-header { + padding: 1rem; + border-bottom: 1px solid var(--border-primary); + + span { + font-size: 0.9rem; + font-weight: 800; + color: var(--text-muted); + text-transform: uppercase; + } + } + + .nav-list ul { + list-style: none; + padding: 0.5rem 0; + margin: 0; + + li { + display: flex; + align-items: center; + gap: 1rem; + padding: 0.75rem 1.5rem; + color: var(--text-secondary); + font-size: 0.875rem; + font-weight: 500; + transition: + background 0.2s, + color 0.2s, + background-color 0.2s, + border-color 0.2s; + + cursor: pointer; + + .icon { + background-color: var(--text-primary); + } + + i { + width: 20px; + } + + &:hover, &:focus-visible { + background: var(--bg-page); + color: var(--primary); + outline: none; + } + + &.active { + background: var(--bg-card-alt); + color: var(--primary); + border-right: 4px solid var(--primary); + font-weight: 700; + + .icon { + background-color: var(--primary); + } + } + } + } + + .scroller { + overflow-y: auto; + + &::-webkit-scrollbar { + width: 6px; + } + + &::-webkit-scrollbar-thumb { + background: #e2e8f0; + border-radius: 10px; + } + } +} diff --git a/src/ui/components/Conversion/SideNav.tsx b/src/ui/components/Conversion/SideNav.tsx new file mode 100644 index 00000000..f3214b93 --- /dev/null +++ b/src/ui/components/Conversion/SideNav.tsx @@ -0,0 +1,56 @@ +import { Icon } from "../Icon"; + +import "./SideNav.css" +import {useState} from "preact/hooks"; + +export type FormatCategory = { + id: string + category: string + icon: string +} + +interface SideNavProps { + items: FormatCategory[] + onSelect?: (id: string) => void +} + +export default function SideNav({ items, onSelect }: SideNavProps) { + const [selectedId, setSelectedId] = useState(items[0]?.id || null); + + const handleItemClick = (id: string) => { + setSelectedId(id); + onSelect?.(id); + }; + + return ( + + ); +} diff --git a/src/ui/components/DarkModeToggle.tsx b/src/ui/components/DarkModeToggle.tsx new file mode 100644 index 00000000..fdce82c8 --- /dev/null +++ b/src/ui/components/DarkModeToggle.tsx @@ -0,0 +1,29 @@ +import { h } from "preact"; +import { theme, toggleTheme } from "../ThemeStore"; +import { useEffect, useState } from "preact/hooks"; +import StyledButton from "./StyledButton"; + +import sunIcon from "../img/fa-sun-solid-full.svg"; +import moonIcon from "../img/fa-moon-solid-full.svg"; + +export default function DarkModeToggle() { + const [current, setCurrent] = useState(theme.value); + + useEffect(() => { + const unsubscribe = theme.subscribe((value) => setCurrent(value)); + return () => unsubscribe(); + }, []); + + return ( + + {current + + ); +} diff --git a/src/ui/components/Footer.css b/src/ui/components/Footer.css new file mode 100644 index 00000000..c8145709 --- /dev/null +++ b/src/ui/components/Footer.css @@ -0,0 +1,34 @@ +footer { + &[aria-hidden="true"] { + display: none; + } + + display: flex; + width: 100vw; + gap: 1em; + justify-content: end; + margin-block: auto 0; + + color: gray; + padding-block: .25em; + padding-inline: 1em; + background-color: var(--bg-section); + + .footer-item { + display: flex; + align-items: center; + gap: .1em; + color: gray; + text-underline-offset: .25em; + + &.footer-copyright { + margin-inline-end: auto; + } + + img { + width: 25px; + color: gray; + filter: contrast(0); + } + } +} diff --git a/src/ui/components/Footer.tsx b/src/ui/components/Footer.tsx new file mode 100644 index 00000000..f499bea6 --- /dev/null +++ b/src/ui/components/Footer.tsx @@ -0,0 +1,26 @@ +import githubImg from '../img/fa-github-brands-solid-full.svg' +import discordImg from '../img/fa-discord-brands-solid-full.svg' + +import './Footer.css' + +interface FooterComponentProps { + visible?: boolean +} + +export default function Footer({ visible = true }: FooterComponentProps) { + return ( + + ) +} diff --git a/src/ui/components/HelpButton.tsx b/src/ui/components/HelpButton.tsx new file mode 100644 index 00000000..b223de39 --- /dev/null +++ b/src/ui/components/HelpButton.tsx @@ -0,0 +1,39 @@ +import { PopupData } from ".."; +import { popupOpen } from "../PopupStore"; +import StyledButton from "./StyledButton"; + +export default function HelpButton() { + const onHelpClick = (ev: preact.TargetedMouseEvent) => { + PopupData.value = { + dismissible: true, + buttonText: "OK" + } + PopupData.value.contents = ( + <> +

Help

+

Truly universal online file converter.

+

Many online file conversion tools are boring and insecure. They only allow conversion between two formats in the same medium (images to images, videos to videos, etc.), and they require that you upload your files to some server.

+

This is not just terrible for privacy, it's also incredibly lame. What if you really need to convert an AVI video to a PDF document? Try to find an online tool for that, I dare you.

+

Convert.to.it aims to be a tool that "just works". You're almost guaranteed to get an output - perhaps not always the one you expected, but it'll try its best to not leave you hanging.

+

Usage

+
    +
  1. Upload your file using the file browser, or drag and drop your file.
  2. +
  3. Select an output format.
  4. +
  5. Click Convert!
  6. +
  7. Hopefully, after a bit (or a lot) of thinking, the program will spit out the file you wanted.
  8. +
+

Advanced mode

+

Advanced mode exposes additional conversion methods for some file types. If you do not intend on using a specific conversion method, it's better to leave it in Simple mode.

+ + ) + popupOpen.value = true; + } + + return ( + Help + ) +} diff --git a/src/ui/components/Icon.css b/src/ui/components/Icon.css new file mode 100644 index 00000000..f6381ec3 --- /dev/null +++ b/src/ui/components/Icon.css @@ -0,0 +1,13 @@ +.icon { + display: inline-block; + vertical-align: middle; + flex-shrink: 0; + + /* Static properties moved from JS */ + mask-repeat: no-repeat; + -webkit-mask-repeat: no-repeat; + mask-size: contain; + -webkit-mask-size: contain; + mask-position: center; + -webkit-mask-position: center; +} \ No newline at end of file diff --git a/src/ui/components/Icon.tsx b/src/ui/components/Icon.tsx new file mode 100644 index 00000000..dac041f1 --- /dev/null +++ b/src/ui/components/Icon.tsx @@ -0,0 +1,34 @@ +import type { CSSProperties } from "preact"; +import './Icon.css' + +type IconProps = { + src: string + size?: number | string + color?: string + className?: string + style?: CSSProperties +}; + +export function Icon({ + src, + size = 24, + color, + className = "", + style = {}, +}: IconProps) { + + const computedSize = typeof size === "number" ? `${size}px` : size; + + const maskStyles: CSSProperties = { + ...(color ? { backgroundColor: color } : {}), + + maskImage: `url("${src}")`, + WebkitMaskImage: `url("${src}")`, + + width: computedSize, + height: computedSize, + ...style, + }; + + return
; +} diff --git a/src/ui/components/Popup.css b/src/ui/components/Popup.css new file mode 100644 index 00000000..a82d02a0 --- /dev/null +++ b/src/ui/components/Popup.css @@ -0,0 +1,45 @@ +#popup { + max-width: 50rem; + padding: 1.5em 1.75em; + + background-color: var(--bg-card-alt); + color: var(--text-primary); + + border: 0; + border-radius: 8px; + + &[open] { + display: flex; + } + + flex-direction: column; + gap: 1rem; + + /* position: absolute has unbelievable quirks */ + margin: auto; + + button { + appearance: none; + border: .1rem solid var(--btn-border); + border-radius: 8px; + padding: .5em; + margin-top: .5em; + align-self: center; + min-width: 25%; + color: var(--text-on-primary); + background: var(--btn-bg-primary); + transition: color 150ms, background 150ms; + font-weight: bold; + + &:hover, + &:focus-visible { + color: var(--text-primary); + background: var(--btn-bg-secondary-hover); + } + } + + &::backdrop { + backdrop-filter: brightness(0.7) blur(4px); + background: rgba(0 0 0 / 0.4); + } +} diff --git a/src/ui/components/Popup.tsx b/src/ui/components/Popup.tsx new file mode 100644 index 00000000..097afce9 --- /dev/null +++ b/src/ui/components/Popup.tsx @@ -0,0 +1,81 @@ +import type { TargetedMouseEvent } from "preact"; +import { useSignalEffect } from "@preact/signals"; +import { useEffect, useRef } from "preact/hooks"; +import { popupOpen } from "../PopupStore"; + +import "./Popup.css"; +import { PopupData } from ".."; + +export default function Popup() { + const ref = useRef(null); + + // Use vanilla JS APIs to control popup state + useSignalEffect(() => { + const elem = ref.current!; + + if (popupOpen.value) { + if (!elem.open) elem.showModal(); + } else { + if (elem.open) elem.close(); + } + }); + + // Listen to soft-dismiss events + useEffect(() => { + window.addEventListener("keydown", (ev: KeyboardEvent) => { + if (ev.key === "Escape") ev.preventDefault(); + if ( + ev.key === "Escape" + && (typeof PopupData.value.dismissible === "undefined" || PopupData.value.dismissible) + ) popupOpen.value = false; + }) + }, []); + + /** Handle clicks to the backdrop/outisde of the dialog */ + const clickHandler = (ev: TargetedMouseEvent) => { + const elem = ref.current!; + const rect = elem.getBoundingClientRect(); + + const isInside = + rect.top <= ev.clientY + && ev.clientY <= rect.top + rect.height + && rect.left <= ev.clientX + && ev.clientX <= rect.left + rect.width; + + if ( + !isInside + && PopupData.value.dismissible + ) popupOpen.value = false; + }; + + const getPopupContents = () => { + return (PopupData.value.contents) ? PopupData.value.contents : ( + <> +

{ PopupData.value.title }

+

{ PopupData.value.text }

+ + ) + } + + return ( + + { getPopupContents() } + { + PopupData.value.buttonText && + + } + + ); +} diff --git a/src/ui/components/StyledButton.css b/src/ui/components/StyledButton.css new file mode 100644 index 00000000..584ce9d0 --- /dev/null +++ b/src/ui/components/StyledButton.css @@ -0,0 +1,73 @@ +.styled-button { + appearance: none; + border: .1rem solid var(--btn-border); + border-radius: 8px; + padding: .75em; + color: var(--btn-text-secondary); + background: var(--btn-bg-secondary); + transition: color 150ms, background 150ms; + cursor: pointer; + user-select: none; + + &:hover, + &:focus-visible { + color: var(--text-primary); + background: var(--btn-bg-secondary-hover); + } + + /* Compact variant for smaller buttons */ + &.compact { + padding: 0; + border-radius: 1rem; + width: 41px; + height: 41px; + font-weight: bold; + font-size: 1.5rem; + } + + /* Primary button variant */ + &.primary { + background: var(--btn-bg-primary); + color: var(--btn-text-primary); + border-color: transparent; + box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05); + + &:hover, + &:focus-visible { + background: var(--btn-bg-primary-hover); + } + } + + /* Icon button variant */ + &.icon { + display: flex; + align-items: center; + justify-content: center; + width: 41px; + height: 41px; + padding: 0; + border-radius: 1rem; + background: var(--btn-bg-secondary); + border-color: var(--border-primary); + + img { + width: 24px; + height: 24px; + filter: contrast(0); + } + + &:hover, + &:focus-visible { + background: var(--btn-bg-secondary-hover); + border-color: var(--border-secondary); + box-shadow: 0 6px 6px rgba(0, 0, 0, 0.07); + } + } + + /* Disabled state */ + &:disabled { + opacity: 0.5; + cursor: not-allowed; + pointer-events: none; + } +} diff --git a/src/ui/components/StyledButton.tsx b/src/ui/components/StyledButton.tsx new file mode 100644 index 00000000..04aeca08 --- /dev/null +++ b/src/ui/components/StyledButton.tsx @@ -0,0 +1,43 @@ +import { h } from "preact"; + +type ButtonVariant = 'default' | 'primary' | 'compact' | 'icon'; + +interface StyledButtonProps { + className?: string; + variant?: ButtonVariant; + onClick?: (ev: preact.TargetedMouseEvent) => void; + title?: string; + tabIndex?: number; + disabled?: boolean; + children: preact.ComponentChildren; +} + +import "./StyledButton.css"; + +export default function StyledButton({ + className, + variant = 'default', + onClick, + title, + tabIndex, + disabled = false, + children +}: StyledButtonProps) { + // Combine base class with variant and any additional classes + const variantClass = variant === 'default' ? '' : variant; + const combinedClassName = className + ? `styled-button ${variantClass} ${className}` + : `styled-button ${variantClass}`.trim(); + + return ( + + ); +} diff --git a/src/ui/components/Upload/UploadField.css b/src/ui/components/Upload/UploadField.css new file mode 100644 index 00000000..b221710c --- /dev/null +++ b/src/ui/components/Upload/UploadField.css @@ -0,0 +1,118 @@ +.upload-field { + height: 100%; + display: flex; + flex-grow: 1; + + .upload-card { + width: clamp(400px, 75vw, 800px); + height: max(450px, 50vh); + border-radius: 20px; + align-self: center; + display: flex; + flex-direction: column; + position: relative; + /* padding: 2em; */ + + background-color: var(--bg-card); + box-shadow: 0 15px 15px rgba(0 0 0 / .1); + border: .1rem solid rgba(0 0 0 / .1); + + .upload-card-header { + display: flex; + width: 100%; + justify-content: center; + border-block-end: .1rem solid rgba(0 0 0 / .1); + padding: 2em; + + h1 { + display: flex; + font-weight: 900; + align-items: center; + gap: .5em; + font-size: 2rem; + color: var(--text-primary); + + + .upload-card-logo { + display: inline-block; + width: 60px; + height: 60px; + background-color: var(--primary); + -webkit-mask-repeat: no-repeat; + -webkit-mask-size: contain; + -webkit-mask-position: center; + mask-repeat: no-repeat; + mask-size: contain; + mask-position: center; + } + } + } + + .upload-card-theme-toggle { + position: absolute; + top: 5px; + right: 5px; + } + + .upload-card-dropzone-hint { + flex-grow: 1; + margin: 1.5em; + + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + text-align: center; + + background-color: var(--bg-card-alt); + border: 2px dashed var(--bg-overlay); + border-radius: 12px; + + cursor: pointer; + transition: all 0.2s ease-in-out; + user-select: none; + + &:hover, + &.active-drag { + background-color: var(--bg-card-alt-hover); + border-color: var(--btn-bg-primary); + } + + input[type="file"] { + display: none; + } + + .upload-card-dropzone-icon-container { + background-color: var(--bg-card); + padding: 1em; + border-radius: 50%; + box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05); + margin-bottom: 1em; + transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1); + + display: flex; + align-items: center; + justify-content: center; + } + + &:hover .upload-card-dropzone-icon-container, + &.active-drag .upload-card-dropzone-icon-container { + transform: scale(1.1); + } + + + .upload-card-dropzone-subtext { + color: var(--text-secondary); + font-size: 0.875rem; + } + } + + .upload-card-buttons { + margin-block: auto 0; + display: flex; + justify-content: center; + gap: 1em; + margin-block-end: 1em; + } + } +} diff --git a/src/ui/components/Upload/UploadField.tsx b/src/ui/components/Upload/UploadField.tsx new file mode 100644 index 00000000..a8d30e74 --- /dev/null +++ b/src/ui/components/Upload/UploadField.tsx @@ -0,0 +1,126 @@ +import { useRef, useState } from 'preact/hooks'; + +import { CurrentPage, Pages } from '../../index'; +import { SelectedFiles } from 'src/main.new'; + +import uploadImage from '../../img/fa-upload-solid-full.svg'; +import logoImage from '../../img/logo.svg'; + +import DarkModeToggle from '../DarkModeToggle'; +import { Icon } from "../Icon"; +import StyledButton from "../StyledButton"; + +import './UploadField.css' + +import AdvancedModeToggle from '../AdvancedModeToggle'; +import HelpButton from '../HelpButton'; + +interface UploadFieldComponentProps { + disabled?: boolean +} + +export default function UploadField({ disabled = false }: UploadFieldComponentProps) { + const [isDisabled, setIsDisabled] = useState(disabled); + const [isDragging, setIsDragging] = useState(false); + + const dragCounter = useRef(0); + const fileRef = useRef(null); + + const handleClick = (ev: MouseEvent) => { + ev.preventDefault(); + fileRef.current?.click(); + } + + const handleDrop = (ev: DragEvent) => { + ev.preventDefault(); + console.debug(ev.dataTransfer?.files); + } + + const handleDragEnter = (ev: DragEvent) => { + ev.preventDefault(); + dragCounter.current++; + if (ev.dataTransfer?.types.includes('Files')) setIsDragging(true); + } + + const handleDragLeave = (ev: DragEvent) => { + ev.preventDefault(); + dragCounter.current--; + if (dragCounter.current == 0) setIsDragging(false); + } + + const handleDragOver = (ev: DragEvent) => { + ev.preventDefault() + + } + + const handleChange = (_ev: preact.TargetedEvent) => { + setIsDisabled(true); + const files = fileRef.current?.files; + + // Check if files uploaded were empty + if ( + !files + || files.length === 0 + ) return + + // Map array item to object format + for (const file of files) { + SelectedFiles.value[`${file.name}-${file.lastModified}`] = file + } + + CurrentPage.value = Pages.Conversion; + } + + return ( +
+
+ +
+

+ + Convert to it! +

+
+ +
+
+ +
+ ev.stopPropagation() } + tabIndex={ 0 } + multiple + /> +
+ +
+ Click to add your file + or drag and drop here +
+ +
+ + +
+ +
+
+ ) +} diff --git a/src/ui/img/fa-box-archive-solid-full.svg b/src/ui/img/fa-box-archive-solid-full.svg new file mode 100644 index 00000000..22b87a18 --- /dev/null +++ b/src/ui/img/fa-box-archive-solid-full.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/ui/img/fa-chevron-down-solid-full.svg b/src/ui/img/fa-chevron-down-solid-full.svg new file mode 100644 index 00000000..2cb73192 --- /dev/null +++ b/src/ui/img/fa-chevron-down-solid-full.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/ui/img/fa-discord-brands-solid-full.svg b/src/ui/img/fa-discord-brands-solid-full.svg new file mode 100644 index 00000000..7857ba95 --- /dev/null +++ b/src/ui/img/fa-discord-brands-solid-full.svg @@ -0,0 +1 @@ + diff --git a/src/ui/img/fa-file-lines-regular-full.svg b/src/ui/img/fa-file-lines-regular-full.svg new file mode 100644 index 00000000..847455d4 --- /dev/null +++ b/src/ui/img/fa-file-lines-regular-full.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/ui/img/fa-github-brands-solid-full.svg b/src/ui/img/fa-github-brands-solid-full.svg new file mode 100644 index 00000000..47882fc8 --- /dev/null +++ b/src/ui/img/fa-github-brands-solid-full.svg @@ -0,0 +1 @@ + diff --git a/src/ui/img/fa-image-regular-full.svg b/src/ui/img/fa-image-regular-full.svg new file mode 100644 index 00000000..69de0dbc --- /dev/null +++ b/src/ui/img/fa-image-regular-full.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/ui/img/fa-image-solid-full.svg b/src/ui/img/fa-image-solid-full.svg new file mode 100644 index 00000000..956d330a --- /dev/null +++ b/src/ui/img/fa-image-solid-full.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/ui/img/fa-link-solid-full.svg b/src/ui/img/fa-link-solid-full.svg new file mode 100644 index 00000000..b779bf42 --- /dev/null +++ b/src/ui/img/fa-link-solid-full.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/ui/img/fa-magnifying-glass-solid-full.svg b/src/ui/img/fa-magnifying-glass-solid-full.svg new file mode 100644 index 00000000..957dc21f --- /dev/null +++ b/src/ui/img/fa-magnifying-glass-solid-full.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/ui/img/fa-moon-solid-full.svg b/src/ui/img/fa-moon-solid-full.svg new file mode 100644 index 00000000..d910a7ce --- /dev/null +++ b/src/ui/img/fa-moon-solid-full.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/ui/img/fa-music-solid-full.svg b/src/ui/img/fa-music-solid-full.svg new file mode 100644 index 00000000..ec3583ec --- /dev/null +++ b/src/ui/img/fa-music-solid-full.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/ui/img/fa-sliders-solid-full.svg b/src/ui/img/fa-sliders-solid-full.svg new file mode 100644 index 00000000..7aa4ecdd --- /dev/null +++ b/src/ui/img/fa-sliders-solid-full.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/ui/img/fa-sun-solid-full.svg b/src/ui/img/fa-sun-solid-full.svg new file mode 100644 index 00000000..c0deb6cc --- /dev/null +++ b/src/ui/img/fa-sun-solid-full.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/ui/img/fa-upload-solid-full.svg b/src/ui/img/fa-upload-solid-full.svg new file mode 100644 index 00000000..d9a21dc4 --- /dev/null +++ b/src/ui/img/fa-upload-solid-full.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/ui/img/fa-video-solid-full.svg b/src/ui/img/fa-video-solid-full.svg new file mode 100644 index 00000000..45f7c972 --- /dev/null +++ b/src/ui/img/fa-video-solid-full.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/ui/img/fa-wrench-solid-full.svg b/src/ui/img/fa-wrench-solid-full.svg new file mode 100644 index 00000000..d2334022 --- /dev/null +++ b/src/ui/img/fa-wrench-solid-full.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/ui/img/fa-x-solid-full.svg b/src/ui/img/fa-x-solid-full.svg new file mode 100644 index 00000000..fb3886ca --- /dev/null +++ b/src/ui/img/fa-x-solid-full.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/ui/img/logo.svg b/src/ui/img/logo.svg new file mode 100644 index 00000000..59bf5533 --- /dev/null +++ b/src/ui/img/logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/ui/index.tsx b/src/ui/index.tsx new file mode 100644 index 00000000..1e594968 --- /dev/null +++ b/src/ui/index.tsx @@ -0,0 +1,49 @@ +import { render } from "preact"; +import { signal } from "@preact/signals"; + +import UploadPage from "./pages/Upload"; +import ConversionPage from "./pages/Conversion"; +import { initTheme } from "./ThemeStore"; +import { openPopup, type PopupDataContainer } from "./PopupStore"; +import Popup from "./components/Popup"; +import { initMode } from "./ModeStore"; + +console.log("Rendering UI"); + +export const enum Pages { + Upload = "uploadPage", + Conversion = "conversionPage" +} + +export const CurrentPage = signal(Pages.Upload); +export let PopupData = signal({ + title: "Loading tools...", + text: "Please wait while the app loads conversion tools.", + dismissible: false, + buttonText: 'Ignore' +}) + +function App() { + return ( + <> + { CurrentPage.value === Pages.Conversion && } + { CurrentPage.value === Pages.Upload && } + + + ) +} + +/** + * Debug function to change pages without user workflow +*/ +// @ts-expect-error +window.changePage = (page: Pages) => { + CurrentPage.value = page +} + +render(, document.body); + +openPopup(); + +initTheme(); +initMode(); diff --git a/src/ui/pages/Conversion.css b/src/ui/pages/Conversion.css new file mode 100644 index 00000000..d113130b --- /dev/null +++ b/src/ui/pages/Conversion.css @@ -0,0 +1,86 @@ +.conversion-body { + background-color: var(--bg-page); + color: var(--text-secondary); + min-height: 100vh; + display: flex; + + min-width: 100%; + flex-direction: column; + overflow: hidden; + + @media (max-width: 1024px) { + /*height: auto;*/ + overflow-x: hidden; + /*overflow-y: auto;*/ + } +} + +.scroller { + overflow-y: auto; +} + +.conversion-main { + display: flex; + flex: 1; + min-height: 0; + + @media (max-width: 1024px) { + flex-direction: column; + } +} + +.settings-sidebar { + width: 320px; + background: var(--bg-card); + border-inline-start: 1px solid var(--border-primary); + display: flex; + flex-direction: column; + + position: sticky; + bottom: 0; + + @media (max-width: 1024px) { + width: 100%; + } + + .spacer { + flex: 1 1 auto; + } +} + +.action-footer { + padding: 1.25rem; + background: var(--bg-page); + border-top: 1px solid var(--border-secondary); + + .btn-convert { + width: 100%; + background: var(--btn-bg-primary); + color: var(--text-on-primary); + border: none; + padding: 1rem; + border-radius: 12px; + font-weight: 800; + font-size: 1.1rem; + cursor: pointer; + box-shadow: 0 4px 14px color-mix(in srgb, var(--primary) 30%, transparent); + transition: all 0.2s; + + &:hover { + background: var(--btn-bg-primary-hover); + transform: translateY(-1px); + } + } +} + +.desktop-only { + @media (max-width: 1024px) { + display: none !important; + } +} + +.mobile-only { + @media (min-width: 1025px) { + display: none !important; + } +} diff --git a/src/ui/pages/Conversion.tsx b/src/ui/pages/Conversion.tsx new file mode 100644 index 00000000..c4858511 --- /dev/null +++ b/src/ui/pages/Conversion.tsx @@ -0,0 +1,62 @@ +import faImageRegular from '../img/fa-image-regular-full.svg'; +import faBoxArchiveSolid from '../img/fa-box-archive-solid-full.svg'; +import faFileLinesRegular from '../img/fa-file-lines-regular-full.svg'; +import faVideoSolid from '../img/fa-video-solid-full.svg'; +import faMusicSolid from '../img/fa-music-solid-full.svg'; + +import './Conversion.css' + +import { type FormatCategory } from "../components/Conversion/SideNav"; +import Footer from "../components/Footer"; +import ConversionSidebar from "../components/Conversion/ConversionSidebar"; +import SelectedFileInfo from "../components/Conversion/SelectedFileInfo"; +import ConversionHeader from "../components/Conversion/ConversionHeader"; +import { ConversionOptions, type ConversionOption, type ConversionOptionsMap } from 'src/main.new'; + +import FormatExplorer from "../components/Conversion/FormatExplorer.tsx"; +import { useState } from "preact/hooks"; + +interface ConversionPageProps { + +} + +const sidebarItems: FormatCategory[] = [ // Placeholder categories + { id: "arc", category: "Archive", icon: faBoxArchiveSolid }, + { id: "img", category: "Image", icon: faImageRegular }, + { id: "doc", category: "Document", icon: faFileLinesRegular }, + { id: "vid", category: "Video", icon: faVideoSolid }, + { id: "aud", category: "Audio", icon: faMusicSolid }, + { id: "ebk", category: "E-Book", icon: faFileLinesRegular }, +]; + +/** + * Flimsy getter to check to see if the conversion backend + * borked and didn't return any conversion options + */ +function getConversionOptions() { + if (ConversionOptions.size) { + return ConversionOptions + } else throw new Error("Can't build format list! Failed to get global format list"); +} + +export default function Conversion({ }: ConversionPageProps) { + const AvailableConversionOptions: ConversionOptionsMap = getConversionOptions(); + const [selectedOption, setSelectedOption] = useState(null); + + return ( +
+ + + {/* Mobile File Info */ } + + +
+ + + {/* Right Settings Sidebar / Bottom Settings Accordion */ } + +
+
+
+ ); +} diff --git a/src/ui/pages/Upload.css b/src/ui/pages/Upload.css new file mode 100644 index 00000000..eb63b08e --- /dev/null +++ b/src/ui/pages/Upload.css @@ -0,0 +1,12 @@ +body { + display: flex; + flex-direction: column; + height: 100vh; + + justify-content: center; + align-items: center; +} + +button { + cursor: pointer; +} diff --git a/src/ui/pages/Upload.tsx b/src/ui/pages/Upload.tsx new file mode 100644 index 00000000..f4388085 --- /dev/null +++ b/src/ui/pages/Upload.tsx @@ -0,0 +1,17 @@ +import Footer from "../components/Footer" +import UploadField from "../components/Upload/UploadField" + +import './Upload.css' + +interface UploadPageProps { + +} + +export default function Upload(props: UploadPageProps | undefined) { + return ( + <> + +