First implementation of Plimi
This commit is contained in:
5
src/pages/HomePage.tsx
Normal file
5
src/pages/HomePage.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { Navigate } from "react-router-dom";
|
||||
|
||||
export function HomePage() {
|
||||
return <Navigate to="/tools" replace />;
|
||||
}
|
||||
202
src/pages/HowItWorksPage.tsx
Normal file
202
src/pages/HowItWorksPage.tsx
Normal file
@@ -0,0 +1,202 @@
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
const workflow = [
|
||||
{
|
||||
label: "Input",
|
||||
title: "Choose a tool",
|
||||
copy: "Paste text, pick a file, or adjust options in a generated tool panel.",
|
||||
icon: (
|
||||
<path d="M12 5v14M5 12h14" />
|
||||
),
|
||||
},
|
||||
{
|
||||
label: "Run",
|
||||
title: "Process locally",
|
||||
copy: "The plugin runs in your browser using JavaScript, workers, Canvas, or PDF libraries.",
|
||||
icon: (
|
||||
<>
|
||||
<path d="M12 3v3M12 18v3M3 12h3M18 12h3" />
|
||||
<circle cx="12" cy="12" r="4" />
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: "Output",
|
||||
title: "Copy or download",
|
||||
copy: "Results appear immediately in the page. Files stay on the device unless you export them.",
|
||||
icon: (
|
||||
<>
|
||||
<path d="M12 4v11" />
|
||||
<path d="m7 10 5 5 5-5" />
|
||||
<path d="M5 20h14" />
|
||||
</>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const principles = [
|
||||
{
|
||||
title: "No upload step",
|
||||
copy: "Plimi does not send your inputs to an application server for conversion.",
|
||||
},
|
||||
{
|
||||
title: "Plugin boundaries",
|
||||
copy: "Each utility declares its own inputs, options, permissions, and result type.",
|
||||
},
|
||||
{
|
||||
title: "Responsive execution",
|
||||
copy: "Heavier work can move off the main interface thread so the page stays usable.",
|
||||
},
|
||||
{
|
||||
title: "Explicit results",
|
||||
copy: "Tools return structured outputs: copied text, rendered previews, or downloadable files.",
|
||||
},
|
||||
];
|
||||
|
||||
const layers = [
|
||||
{ name: "Tool manifest", detail: "Inputs, options, permissions" },
|
||||
{ name: "Generated UI", detail: "Forms, sliders, selects, validation" },
|
||||
{ name: "Runner", detail: "Browser APIs, workers, local libraries" },
|
||||
{ name: "Result panel", detail: "Preview, copy, download" },
|
||||
];
|
||||
|
||||
function IconFrame({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<span className="inline-flex h-10 w-10 items-center justify-center rounded-[10px] border border-[var(--p-border)] bg-[var(--p-chip)] text-[var(--p-text)]">
|
||||
<svg
|
||||
width="19"
|
||||
height="19"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{children}
|
||||
</svg>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function HowItWorksPage() {
|
||||
return (
|
||||
<div className="mx-auto flex max-w-[1120px] flex-col gap-10 pb-16 animate-plimi-slide">
|
||||
<section className="grid grid-cols-1 gap-8 md:grid-cols-[0.95fr_1.05fr] md:items-end">
|
||||
<div className="flex flex-col gap-4">
|
||||
<span className="font-mono text-[11px] uppercase tracking-[0.12em] text-[var(--p-muted)]">
|
||||
browser-first architecture
|
||||
</span>
|
||||
<h1 className="m-0 max-w-[620px] font-sans text-4xl font-bold leading-[1.02] tracking-tight text-[var(--p-text)] text-balance md:text-[56px]">
|
||||
Your files stay where they started.
|
||||
</h1>
|
||||
<p className="m-0 max-w-[560px] text-base leading-relaxed text-[var(--p-muted)] text-pretty">
|
||||
Plimi is a collection of small local utilities. The interface loads the tool, runs the
|
||||
work in the browser, then hands the result back to you without a server round trip.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="rounded-[20px] border border-[var(--p-border)] bg-[var(--p-surface)] p-4 md:p-5"
|
||||
style={{
|
||||
boxShadow:
|
||||
"0 18px 38px -28px var(--p-shadow-soft), 0 1px 0 0 var(--p-shadow-inset)",
|
||||
}}
|
||||
>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3 md:grid-cols-1 lg:grid-cols-3">
|
||||
{workflow.map((step, index) => (
|
||||
<div
|
||||
key={step.label}
|
||||
className="relative flex min-h-[170px] flex-col gap-4 rounded-[16px] border border-[var(--p-border)] bg-[var(--p-surface-2)] p-5"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<IconFrame>{step.icon}</IconFrame>
|
||||
<span className="font-mono text-[11px] uppercase tracking-[0.12em] text-[var(--p-muted)]">
|
||||
{String(index + 1).padStart(2, "0")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<span className="font-mono text-[11px] uppercase tracking-[0.12em] text-[var(--p-accent)]">
|
||||
{step.label}
|
||||
</span>
|
||||
<h2 className="m-0 font-sans text-[17px] font-semibold tracking-tight text-[var(--p-text)]">
|
||||
{step.title}
|
||||
</h2>
|
||||
<p className="m-0 text-sm leading-relaxed text-[var(--p-muted)]">{step.copy}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="grid grid-cols-1 gap-4 md:grid-cols-4">
|
||||
{principles.map((item) => (
|
||||
<article
|
||||
key={item.title}
|
||||
className="flex min-h-[150px] flex-col justify-between gap-5 rounded-[16px] border border-[var(--p-border)] bg-[var(--p-surface)] p-5"
|
||||
>
|
||||
<h2 className="m-0 font-sans text-[16px] font-semibold tracking-tight text-[var(--p-text)]">
|
||||
{item.title}
|
||||
</h2>
|
||||
<p className="m-0 text-sm leading-relaxed text-[var(--p-muted)]">{item.copy}</p>
|
||||
</article>
|
||||
))}
|
||||
</section>
|
||||
|
||||
<section className="grid grid-cols-1 gap-6 rounded-[20px] border border-[var(--p-border)] bg-[var(--p-surface-2)] p-5 md:grid-cols-[0.75fr_1.25fr] md:p-7">
|
||||
<div className="flex flex-col gap-3">
|
||||
<span className="font-mono text-[11px] uppercase tracking-[0.12em] text-[var(--p-muted)]">
|
||||
plugin model
|
||||
</span>
|
||||
<h2 className="m-0 font-sans text-2xl font-bold tracking-tight text-[var(--p-text)]">
|
||||
One shell, many tools.
|
||||
</h2>
|
||||
<p className="m-0 text-sm leading-relaxed text-[var(--p-muted)]">
|
||||
Plimi keeps the application shell simple. Each tool contributes a compact manifest and
|
||||
a runner, so new utilities can share the same dependable interface.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
{layers.map((layer, index) => (
|
||||
<div
|
||||
key={layer.name}
|
||||
className="flex items-start gap-4 rounded-[14px] border border-[var(--p-border)] bg-[var(--p-surface)] p-4"
|
||||
>
|
||||
<span className="mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-lg bg-[var(--p-chip)] font-mono text-[11px] text-[var(--p-muted)]">
|
||||
{index + 1}
|
||||
</span>
|
||||
<div className="flex flex-col gap-1">
|
||||
<h3 className="m-0 font-sans text-sm font-semibold text-[var(--p-text)]">
|
||||
{layer.name}
|
||||
</h3>
|
||||
<p className="m-0 text-[13px] leading-relaxed text-[var(--p-muted)]">
|
||||
{layer.detail}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="flex flex-col items-start justify-between gap-4 border-t border-[var(--p-border)] pt-7 md:flex-row md:items-center">
|
||||
<div className="flex flex-col gap-1">
|
||||
<h2 className="m-0 font-sans text-xl font-semibold tracking-tight text-[var(--p-text)]">
|
||||
Ready to run something?
|
||||
</h2>
|
||||
<p className="m-0 text-sm text-[var(--p-muted)]">
|
||||
Pick a utility and the same local workflow applies.
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
to="/tools"
|
||||
className="inline-flex items-center justify-center rounded-[10px] bg-[var(--p-accent)] px-5 py-3 font-sans text-sm font-semibold tracking-tight text-[var(--p-accent-ink)] no-underline transition-[filter] hover:brightness-110"
|
||||
>
|
||||
Browse tools
|
||||
</Link>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
47
src/pages/ToolDetailPage.tsx
Normal file
47
src/pages/ToolDetailPage.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import { useParams, useNavigate } from "react-router-dom";
|
||||
import { pluginRegistry } from "../core/plugins/plugin-registry";
|
||||
import { ToolShell } from "../components/tool/ToolShell";
|
||||
import { useTheme } from "../app/useTheme";
|
||||
|
||||
export function ToolDetailPage() {
|
||||
const { toolId } = useParams<{ toolId: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { dark } = useTheme();
|
||||
|
||||
const plugin = pluginRegistry.find((p) => p.manifest.id === toolId);
|
||||
|
||||
if (!plugin) {
|
||||
return (
|
||||
<div className="p-8">
|
||||
<h2 className="text-xl font-semibold mb-4 text-[var(--p-text)]">Tool not found</h2>
|
||||
<button onClick={() => navigate("/tools")} className="text-blue-500 hover:underline">
|
||||
Return to directory
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
navigate("/tools");
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center p-0 md:p-10 animate-plimi-fade"
|
||||
style={{
|
||||
background: 'color-mix(in oklab, var(--p-bg) 70%, transparent)',
|
||||
backdropFilter: 'blur(8px)',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="w-full md:w-[min(900px,100%)] h-[92%] md:h-auto md:max-h-full bg-[var(--p-surface)] rounded-t-[20px] md:rounded-[24px] border-[1.5px] border-[var(--p-border)] flex flex-col animate-plimi-slide self-end md:self-center overflow-hidden"
|
||||
style={{
|
||||
boxShadow: '0 40px 80px -30px var(--p-shadow-soft)',
|
||||
}}
|
||||
>
|
||||
<ToolShell plugin={plugin} onClose={handleClose} dark={dark} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
153
src/pages/ToolsPage.tsx
Normal file
153
src/pages/ToolsPage.tsx
Normal file
@@ -0,0 +1,153 @@
|
||||
import { useState, useMemo } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { pluginRegistry } from "../core/plugins/plugin-registry";
|
||||
import type { UnknownPlimiPlugin } from "../core/plugins/plugin-types";
|
||||
import { useTheme } from "../app/useTheme";
|
||||
import { PlimiSearch, CategoryChips, ToolTile, SectionHeader } from "../components/directory/DirectoryComponents";
|
||||
|
||||
const PLIMI_CATEGORIES = [
|
||||
{ id: "all", label: "All tools" },
|
||||
{ id: "developer", label: "Developer" },
|
||||
{ id: "image", label: "Image" },
|
||||
{ id: "text", label: "Text" },
|
||||
{ id: "pdf", label: "PDF" },
|
||||
{ id: "crypto", label: "Crypto" },
|
||||
{ id: "privacy", label: "Privacy" },
|
||||
];
|
||||
|
||||
export function ToolsPage() {
|
||||
const navigate = useNavigate();
|
||||
const { dark } = useTheme();
|
||||
|
||||
const [q, setQ] = useState("");
|
||||
const [cat, setCat] = useState("all");
|
||||
const [focusedIndex, setFocusedIndex] = useState(0);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
return pluginRegistry.filter(
|
||||
(p) =>
|
||||
(cat === "all" || p.manifest.category === cat) &&
|
||||
(p.manifest.name.toLowerCase().includes(q.toLowerCase()) ||
|
||||
p.manifest.description.toLowerCase().includes(q.toLowerCase()))
|
||||
);
|
||||
}, [q, cat]);
|
||||
|
||||
const safeFocusedIndex =
|
||||
filtered.length === 0 ? -1 : Math.min(focusedIndex, filtered.length - 1);
|
||||
|
||||
const counts = useMemo(() => {
|
||||
const out: Record<string, number> = { all: 0 };
|
||||
PLIMI_CATEGORIES.forEach((c) => { out[c.id] = 0; });
|
||||
|
||||
pluginRegistry.forEach((p) => {
|
||||
const matchSearch = p.manifest.name.toLowerCase().includes(q.toLowerCase()) ||
|
||||
p.manifest.description.toLowerCase().includes(q.toLowerCase());
|
||||
if (!matchSearch) return;
|
||||
|
||||
out.all += 1;
|
||||
if (out[p.manifest.category] !== undefined) {
|
||||
out[p.manifest.category] += 1;
|
||||
} else {
|
||||
out[p.manifest.category] = 1;
|
||||
}
|
||||
});
|
||||
return out;
|
||||
}, [q]);
|
||||
|
||||
const grouped = useMemo(() => {
|
||||
if (cat !== 'all' || q) return null;
|
||||
const map: Record<string, typeof pluginRegistry> = {};
|
||||
filtered.forEach((t) => {
|
||||
(map[t.manifest.category] = map[t.manifest.category] || []).push(t);
|
||||
});
|
||||
return PLIMI_CATEGORIES.filter((c) => c.id !== 'all' && map[c.id]?.length).map((c) => ({ cat: c, tools: map[c.id] }));
|
||||
}, [filtered, cat, q]);
|
||||
|
||||
const handleOpenTool = (plugin: UnknownPlimiPlugin) => {
|
||||
navigate(`/tools/${plugin.manifest.id}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-7 max-w-[1200px] mx-auto pb-10">
|
||||
<div className="grid grid-cols-1 md:grid-cols-[1.1fr_1fr] gap-12 items-end">
|
||||
<div className="flex flex-col gap-3.5">
|
||||
<span className="font-mono text-[11px] text-[var(--p-muted)] tracking-[0.12em] uppercase">
|
||||
your digital pencil case
|
||||
</span>
|
||||
<h1 className="m-0 font-sans text-4xl md:text-[56px] font-bold tracking-tight leading-[1.02] text-[var(--p-text)] text-balance">
|
||||
Small tools.<br />
|
||||
<span className="text-[var(--p-muted)]">Big trust.</span>
|
||||
</h1>
|
||||
<p className="m-0 max-w-[480px] text-base leading-relaxed text-[var(--p-muted)] text-pretty">
|
||||
{pluginRegistry.length} utilities for files, text and code — running entirely in your browser. No upload. No account. No server.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<PlimiSearch
|
||||
value={q}
|
||||
onChange={(nextQuery) => {
|
||||
setQ(nextQuery);
|
||||
setFocusedIndex(0);
|
||||
}}
|
||||
count={filtered.length}
|
||||
total={pluginRegistry.length}
|
||||
onArrow={(dir) => {
|
||||
if (filtered.length === 0) return;
|
||||
setFocusedIndex((prev) => {
|
||||
const next = prev + dir;
|
||||
if (next < 0) return filtered.length - 1;
|
||||
if (next >= filtered.length) return 0;
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
onEnter={() => {
|
||||
if (safeFocusedIndex >= 0 && safeFocusedIndex < filtered.length) {
|
||||
handleOpenTool(filtered[safeFocusedIndex]);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<CategoryChips
|
||||
active={cat}
|
||||
onPick={(nextCategory) => {
|
||||
setCat(nextCategory);
|
||||
setFocusedIndex(0);
|
||||
}}
|
||||
counts={counts}
|
||||
categories={PLIMI_CATEGORIES}
|
||||
/>
|
||||
|
||||
{grouped ? (
|
||||
<div className="flex flex-col gap-7 mt-4">
|
||||
{grouped.map(({ cat: c, tools }) => (
|
||||
<div key={c.id} className="flex flex-col gap-3.5">
|
||||
<SectionHeader catLabel={c.label} catId={c.id} n={tools.length} />
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||
{tools.map((t) => {
|
||||
const flatIdx = filtered.indexOf(t);
|
||||
return (
|
||||
<ToolTile key={t.manifest.id} plugin={t} onClick={handleOpenTool} focused={flatIdx === safeFocusedIndex} dark={dark} />
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="py-20 text-center text-[var(--p-muted)] font-sans">
|
||||
No tools found matching "{q}".
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4 mt-4">
|
||||
{filtered.map((t) => {
|
||||
const flatIdx = filtered.indexOf(t);
|
||||
return (
|
||||
<ToolTile key={t.manifest.id} plugin={t} onClick={handleOpenTool} focused={flatIdx === safeFocusedIndex} dark={dark} />
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user