Add contribution page and Docker deployment

This commit is contained in:
achraf
2026-06-02 20:33:09 +02:00
parent be635b1828
commit 4f1af76658
22 changed files with 791 additions and 573 deletions

23
.dockerignore Normal file
View File

@@ -0,0 +1,23 @@
.git
.claude
ignored
node_modules
dist
dist-ssr
coverage
playwright-report
test-results
*.log
pnpm-debug.log*
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.env
.env.*
!.env.example
.DS_Store
.idea
.vscode

1
.env.example Normal file
View File

@@ -0,0 +1 @@
VITE_PLIMI_REPO_URL=https://github.com/your-org/plimi

24
Dockerfile Normal file
View File

@@ -0,0 +1,24 @@
# syntax=docker/dockerfile:1
FROM node:22-alpine AS deps
WORKDIR /app
RUN corepack enable && corepack prepare pnpm@10.33.2 --activate
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
FROM node:22-alpine AS build
WORKDIR /app
RUN corepack enable && corepack prepare pnpm@10.33.2 --activate
ARG VITE_PLIMI_REPO_URL=https://github.com/your-org/plimi
ENV VITE_PLIMI_REPO_URL=$VITE_PLIMI_REPO_URL
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN pnpm build
FROM nginx:1.27-alpine AS runtime
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget -qO- http://127.0.0.1/ >/dev/null || exit 1
CMD ["nginx", "-g", "daemon off;"]

View File

@@ -32,6 +32,27 @@ npm test
npm run build
```
### Docker
Build and run the static production app with Nginx:
```bash
docker build \
--build-arg VITE_PLIMI_REPO_URL=https://github.com/your-org/plimi \
-t plimi:local .
docker run --rm -p 8080:80 --name plimi plimi:local
```
Or use Docker Compose:
```bash
VITE_PLIMI_REPO_URL=https://github.com/your-org/plimi PLIMI_PORT=8080 docker compose up --build
```
Environment:
- `VITE_PLIMI_REPO_URL`: build-time public repository URL shown on the Contribute page.
- `PLIMI_PORT`: host port used by Docker Compose, default `8080`.
---
## How to Create a Tool (Plugin)

11
docker-compose.yml Normal file
View File

@@ -0,0 +1,11 @@
services:
plimi:
build:
context: .
args:
VITE_PLIMI_REPO_URL: ${VITE_PLIMI_REPO_URL:-https://github.com/your-org/plimi}
image: plimi:local
container_name: plimi
ports:
- "${PLIMI_PORT:-8080}:80"
restart: unless-stopped

39
nginx.conf Normal file
View File

@@ -0,0 +1,39 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
server_tokens off;
gzip on;
gzip_comp_level 6;
gzip_min_length 1024;
gzip_types
text/plain
text/css
text/xml
text/javascript
application/javascript
application/json
application/xml
image/svg+xml
font/woff2;
location /assets/ {
try_files $uri =404;
expires 1y;
add_header Cache-Control "public, immutable";
}
location = /favicon.svg {
try_files $uri =404;
expires 30d;
add_header Cache-Control "public";
}
location / {
try_files $uri $uri/ /index.html;
}
}

View File

@@ -1,184 +0,0 @@
.counter {
font-size: 16px;
padding: 5px 10px;
border-radius: 5px;
color: var(--accent);
background: var(--accent-bg);
border: 2px solid transparent;
transition: border-color 0.3s;
margin-bottom: 24px;
&:hover {
border-color: var(--accent-border);
}
&:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
}
.hero {
position: relative;
.base,
.framework,
.vite {
inset-inline: 0;
margin: 0 auto;
}
.base {
width: 170px;
position: relative;
z-index: 0;
}
.framework,
.vite {
position: absolute;
}
.framework {
z-index: 1;
top: 34px;
height: 28px;
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
scale(1.4);
}
.vite {
z-index: 0;
top: 107px;
height: 26px;
width: auto;
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
scale(0.8);
}
}
#center {
display: flex;
flex-direction: column;
gap: 25px;
place-content: center;
place-items: center;
flex-grow: 1;
@media (max-width: 1024px) {
padding: 32px 20px 24px;
gap: 18px;
}
}
#next-steps {
display: flex;
border-top: 1px solid var(--border);
text-align: left;
& > div {
flex: 1 1 0;
padding: 32px;
@media (max-width: 1024px) {
padding: 24px 20px;
}
}
.icon {
margin-bottom: 16px;
width: 22px;
height: 22px;
}
@media (max-width: 1024px) {
flex-direction: column;
text-align: center;
}
}
#docs {
border-right: 1px solid var(--border);
@media (max-width: 1024px) {
border-right: none;
border-bottom: 1px solid var(--border);
}
}
#next-steps ul {
list-style: none;
padding: 0;
display: flex;
gap: 8px;
margin: 32px 0 0;
.logo {
height: 18px;
}
a {
color: var(--text-h);
font-size: 16px;
border-radius: 6px;
background: var(--social-bg);
display: flex;
padding: 6px 12px;
align-items: center;
gap: 8px;
text-decoration: none;
transition: box-shadow 0.3s;
&:hover {
box-shadow: var(--shadow);
}
.button-icon {
height: 18px;
width: 18px;
}
}
@media (max-width: 1024px) {
margin-top: 20px;
flex-wrap: wrap;
justify-content: center;
li {
flex: 1 1 calc(50% - 8px);
}
a {
width: 100%;
justify-content: center;
box-sizing: border-box;
}
}
}
#spacer {
height: 88px;
border-top: 1px solid var(--border);
@media (max-width: 1024px) {
height: 48px;
}
}
.ticks {
position: relative;
width: 100%;
&::before,
&::after {
content: "";
position: absolute;
top: -4.5px;
border: 5px solid transparent;
}
&::before {
left: 0;
border-left-color: var(--border);
}
&::after {
right: 0;
border-right-color: var(--border);
}
}

View File

@@ -1,122 +0,0 @@
import { useState } from "react";
import reactLogo from "./assets/react.svg";
import viteLogo from "./assets/vite.svg";
import heroImg from "./assets/hero.png";
import "./App.css";
function App() {
const [count, setCount] = useState(0);
return (
<>
<section id="center">
<div className="hero">
<img src={heroImg} className="base" width="170" height="179" alt="" />
<img src={reactLogo} className="framework" alt="React logo" />
<img src={viteLogo} className="vite" alt="Vite logo" />
</div>
<div>
<h1>Get started</h1>
<p>
Edit <code>src/App.tsx</code> and save to test <code>HMR</code>
</p>
</div>
<button
type="button"
className="counter"
onClick={() => setCount((count) => count + 1)}
>
Count is {count}
</button>
</section>
<div className="ticks"></div>
<section id="next-steps">
<div id="docs">
<svg className="icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#documentation-icon"></use>
</svg>
<h2>Documentation</h2>
<p>Your questions, answered</p>
<ul>
<li>
<a href="https://vite.dev/" target="_blank">
<img className="logo" src={viteLogo} alt="" />
Explore Vite
</a>
</li>
<li>
<a href="https://react.dev/" target="_blank">
<img className="button-icon" src={reactLogo} alt="" />
Learn more
</a>
</li>
</ul>
</div>
<div id="social">
<svg className="icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#social-icon"></use>
</svg>
<h2>Connect with us</h2>
<p>Join the Vite community</p>
<ul>
<li>
<a href="https://github.com/vitejs/vite" target="_blank">
<svg
className="button-icon"
role="presentation"
aria-hidden="true"
>
<use href="/icons.svg#github-icon"></use>
</svg>
GitHub
</a>
</li>
<li>
<a href="https://chat.vite.dev/" target="_blank">
<svg
className="button-icon"
role="presentation"
aria-hidden="true"
>
<use href="/icons.svg#discord-icon"></use>
</svg>
Discord
</a>
</li>
<li>
<a href="https://x.com/vite_js" target="_blank">
<svg
className="button-icon"
role="presentation"
aria-hidden="true"
>
<use href="/icons.svg#x-icon"></use>
</svg>
X.com
</a>
</li>
<li>
<a href="https://bsky.app/profile/vite.dev" target="_blank">
<svg
className="button-icon"
role="presentation"
aria-hidden="true"
>
<use href="/icons.svg#bluesky-icon"></use>
</svg>
Bluesky
</a>
</li>
</ul>
</div>
</section>
<div className="ticks"></div>
<section id="spacer"></section>
</>
);
}
export default App;

View File

@@ -4,6 +4,7 @@ import { HomePage } from "../pages/HomePage";
import { ToolsPage } from "../pages/ToolsPage";
import { ToolDetailPage } from "../pages/ToolDetailPage";
import { HowItWorksPage } from "../pages/HowItWorksPage";
import { ContributePage } from "../pages/ContributePage";
export const router = createBrowserRouter([
{
@@ -26,6 +27,10 @@ export const router = createBrowserRouter([
path: "how-it-works",
element: <HowItWorksPage />,
},
{
path: "contribute",
element: <ContributePage />,
},
],
},
]);

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

Before

Width:  |  Height:  |  Size: 4.0 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 8.5 KiB

View File

@@ -42,21 +42,19 @@ export function PlimiSearch({
return (
<div className="w-full">
<div
className="relative bg-[var(--p-surface)] border-[1.5px] border-[var(--p-border)] rounded-[20px] px-[22px] py-[20px] flex items-center gap-[14px] transition-colors cursor-text"
className="relative bg-[var(--p-surface)] border-[1.5px] border-[var(--p-border)] rounded-[18px] md:rounded-[20px] px-4 py-4 md:px-[22px] md:py-[20px] flex items-center gap-3 md:gap-[14px] transition-colors cursor-text"
style={{
boxShadow: '0 1px 0 0 var(--p-shadow-inset), 0 12px 30px -22px var(--p-shadow-soft)',
}}
onClick={() => inputRef.current?.focus()}
>
<svg
width="28"
height="28"
viewBox="0 0 24 24"
fill="none"
stroke="var(--p-muted)"
strokeWidth="2"
strokeLinecap="round"
className="shrink-0"
className="shrink-0 w-6 h-6 md:w-7 md:h-7"
>
<circle cx="11" cy="11" r="7" />
<path d="m20 20-3.5-3.5" />
@@ -78,9 +76,9 @@ export function PlimiSearch({
onChange('');
}
}}
placeholder="Start typing to find a tool…"
placeholder="Search tools…"
spellCheck={false}
className="flex-1 min-w-0 border-none outline-none bg-transparent text-[var(--p-text)] text-2xl font-sans tracking-tight font-medium"
className="flex-1 min-w-0 border-none outline-none bg-transparent text-[var(--p-text)] text-lg md:text-2xl font-sans tracking-tight font-medium"
/>
<span className="shrink-0 inline-flex items-center gap-1.5 font-mono text-xs text-[var(--p-muted)]">
{value ? (
@@ -94,7 +92,7 @@ export function PlimiSearch({
clear
</button>
) : (
<kbd className="px-2 py-1.5 rounded-md bg-[var(--p-chip)] border border-[var(--p-border)] text-[var(--p-muted)]">
<kbd className="hidden sm:inline-flex px-2 py-1.5 rounded-md bg-[var(--p-chip)] border border-[var(--p-border)] text-[var(--p-muted)]">
{shortcutText}
</kbd>
)}
@@ -102,7 +100,7 @@ export function PlimiSearch({
</div>
<div className="mt-2.5 flex justify-between font-mono text-[11px] text-[var(--p-muted)] tracking-wider uppercase">
<span>{value ? `${count} match${count === 1 ? '' : 'es'}` : `${total} tools`}</span>
<span> to browse · enter to open</span>
<span className="hidden sm:inline"> to browse · enter to open</span>
</div>
</div>
);
@@ -120,14 +118,14 @@ export function CategoryChips({
categories: { id: string; label: string }[];
}) {
return (
<div className="flex gap-2 flex-wrap">
<div className="flex gap-2 overflow-x-auto no-scrollbar snap-x -mx-6 px-6 md:mx-0 md:px-0 md:flex-wrap md:overflow-visible">
{categories.map((c) => {
const on = active === c.id;
return (
<button
key={c.id}
onClick={() => onPick(c.id)}
className={`cursor-pointer px-3.5 py-2 rounded-full font-sans text-[13px] font-medium tracking-tight inline-flex items-center gap-2 transition-all ${
className={`shrink-0 snap-start cursor-pointer px-3.5 py-2 rounded-full font-sans text-[13px] font-medium tracking-tight inline-flex items-center gap-2 transition-all ${
on
? 'bg-[var(--p-accent)] text-[var(--p-accent-ink)] border-[var(--p-accent)]'
: 'bg-[var(--p-surface)] text-[var(--p-text)] border-[var(--p-border)]'
@@ -169,11 +167,13 @@ export function ToolTile({
focused,
onClick,
dark,
index = 0,
}: {
plugin: UnknownPlimiPlugin;
focused: boolean;
onClick: (plugin: UnknownPlimiPlugin) => void;
dark: boolean;
index?: number;
}) {
const [hover, setHover] = React.useState(false);
const tints = TINTS[plugin.manifest.category] || TINTS.developer;
@@ -188,11 +188,15 @@ export function ToolTile({
: `color-mix(in oklab, ${tints.ink} 18%, var(--p-border))`;
return (
<div
className="animate-plimi-rise h-full"
style={{ animationDelay: `${Math.min(index, 12) * 34}ms` }}
>
<button
onClick={() => onClick(plugin)}
onMouseEnter={() => setHover(true)}
onMouseLeave={() => setHover(false)}
className="relative flex flex-col gap-3.5 px-[18px] py-[20px] rounded-[18px] text-left overflow-hidden cursor-pointer"
className="relative flex h-full w-full flex-col gap-3.5 px-[18px] py-[20px] rounded-[18px] text-left overflow-hidden cursor-pointer"
style={{
background: bg,
border: `1.5px solid ${lifted ? stickerEdge : 'var(--p-border)'}`,
@@ -239,6 +243,7 @@ export function ToolTile({
</div>
</div>
</button>
</div>
);
}

View File

@@ -1,6 +1,18 @@
import { useEffect, useState } from "react";
import { Link, useLocation } from "react-router-dom";
import { useTheme } from "../../app/useTheme";
const NAV_ITEMS = [
{ label: "Tools", to: "/tools" },
{ label: "How it works", to: "/how-it-works" },
{ label: "Contribute", to: "/contribute" },
];
function isNavActive(to: string, pathname: string): boolean {
if (to === "/tools") return pathname.startsWith("/tools");
return pathname === to;
}
function PlimiMark({ size = 28 }: { size?: number }) {
return (
<span
@@ -75,50 +87,109 @@ function ThemeToggle({ dark, onClick }: { dark: boolean; onClick: () => void })
);
}
function MenuToggle({ open, onClick }: { open: boolean; onClick: () => void }) {
return (
<button
onClick={onClick}
aria-label={open ? "Close menu" : "Open menu"}
aria-expanded={open}
className="md:hidden flex items-center justify-center w-9 h-8 rounded-lg bg-[var(--p-surface)] border border-[var(--p-border)] text-[var(--p-text)] cursor-pointer"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
{open ? (
<path d="M6 6l12 12M18 6L6 18" />
) : (
<path d="M3 6h18M3 12h18M3 18h18" />
)}
</svg>
</button>
);
}
export function Header() {
const { dark, toggleTheme } = useTheme();
const location = useLocation();
const [menuOpen, setMenuOpen] = useState(false);
// Close the mobile menu whenever the route changes.
useEffect(() => {
setMenuOpen(false);
}, [location.pathname]);
// Lock body scroll while the mobile menu sheet is open.
useEffect(() => {
if (!menuOpen) return;
const previous = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
document.body.style.overflow = previous;
};
}, [menuOpen]);
return (
<div className="flex items-center justify-between px-6 py-4 md:px-8 md:py-5 border-b border-[var(--p-border)] bg-[var(--p-bg)]">
<div className="flex items-center gap-5">
<Link to="/">
<header className="sticky top-0 z-40 border-b border-[var(--p-border)] bg-[color-mix(in_oklab,var(--p-bg)_85%,transparent)] backdrop-blur-md pt-safe">
<div className="flex items-center justify-between px-6 py-4 md:px-8 md:py-5">
<Link to="/" aria-label="Plimi home">
<PlimiMark size={26} />
</Link>
<nav className="hidden md:flex gap-6">
{NAV_ITEMS.map((item) => {
const active = isNavActive(item.to, location.pathname);
return (
<Link
key={item.label}
to={item.to}
className={`font-sans text-[13px] no-underline transition-colors ${
active
? "text-[var(--p-text)] font-semibold"
: "text-[var(--p-muted)] hover:text-[var(--p-text)] font-normal"
}`}
>
{item.label}
</Link>
);
})}
</nav>
<div className="flex items-center gap-2.5">
<ThemeToggle dark={dark} onClick={toggleTheme} />
<MenuToggle open={menuOpen} onClick={() => setMenuOpen((o) => !o)} />
</div>
</div>
<nav className="hidden md:flex gap-6">
{[
{ label: "Tools", to: "/tools" },
{ label: "How it works", to: "/how-it-works" },
{ label: "Privacy", to: "#" },
{ label: "Changelog", to: "#" },
].map((item) => {
const isActive = item.to !== "#" && (
item.to === "/tools"
? location.pathname.startsWith("/tools")
: location.pathname === item.to
);
return (
<Link
key={item.label}
to={item.to}
className={`font-sans text-[13px] no-underline transition-colors ${
isActive
? "text-[var(--p-text)] font-semibold"
: "text-[var(--p-muted)] hover:text-[var(--p-text)] font-normal"
}`}
>
{item.label}
</Link>
);
})}
</nav>
<div className="flex items-center gap-3">
<ThemeToggle dark={dark} onClick={toggleTheme} />
</div>
</div>
{menuOpen && (
<>
{/* Tap-away backdrop below the bar */}
<button
aria-hidden="true"
tabIndex={-1}
onClick={() => setMenuOpen(false)}
className="md:hidden fixed inset-0 top-[var(--plimi-header-h,56px)] z-30 bg-[color-mix(in_oklab,var(--p-bg)_55%,transparent)]"
/>
<nav className="md:hidden relative z-40 flex flex-col gap-1 border-t border-[var(--p-border)] bg-[var(--p-surface)] px-4 py-3 pb-[max(0.75rem,env(safe-area-inset-bottom))] animate-plimi-drop">
{NAV_ITEMS.map((item) => {
const active = isNavActive(item.to, location.pathname);
return (
<Link
key={item.label}
to={item.to}
className={`flex items-center justify-between rounded-xl px-4 py-3.5 font-sans text-[15px] no-underline transition-colors ${
active
? "bg-[var(--p-accent)] text-[var(--p-accent-ink)] font-semibold"
: "text-[var(--p-text)] hover:bg-[var(--p-chip)]"
}`}
>
{item.label}
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="opacity-50">
<path d="M9 18l6-6-6-6" />
</svg>
</Link>
);
})}
</nav>
</>
)}
</header>
);
}

View File

@@ -1,27 +0,0 @@
import { NavLink } from "react-router-dom";
export function Sidebar() {
return (
<aside className="w-64 border-r border-gray-200 bg-gray-50 flex flex-col h-[calc(100vh-4rem)] sticky top-16 overflow-y-auto">
<nav className="p-4 flex flex-col space-y-1">
<NavLink
to="/"
className={({ isActive }) =>
`px-3 py-2 rounded-md font-medium text-sm ${isActive ? "bg-purple-100 text-purple-700" : "text-gray-700 hover:bg-gray-200"}`
}
end
>
Home
</NavLink>
<NavLink
to="/tools"
className={({ isActive }) =>
`px-3 py-2 rounded-md font-medium text-sm ${isActive ? "bg-purple-100 text-purple-700" : "text-gray-700 hover:bg-gray-200"}`
}
>
Tools
</NavLink>
</nav>
</aside>
);
}

View File

@@ -160,8 +160,12 @@ export function ToolShell({
<>
<ToolHeader plugin={plugin} dark={isDark} onClose={onClose} />
<div className="grid min-h-0 flex-1 grid-cols-1 gap-[16px] overflow-y-auto bg-[var(--p-surface)] p-[18px] md:grid-cols-[1fr_280px] md:gap-[24px] md:p-[28px]">
<div className="flex flex-col gap-[24px]">
<div
className={`flex min-h-0 flex-1 flex-col gap-[16px] overflow-y-auto bg-[var(--p-surface)] p-[18px] md:grid md:gap-[24px] md:p-[28px] ${
plugin.optionsSchema ? "md:grid-cols-[1fr_300px]" : "md:grid-cols-1"
}`}
>
<div className="order-1 flex flex-col gap-[24px] md:col-start-1 md:row-start-1">
<ToolInputPanel
definition={plugin.manifest.input}
value={input}
@@ -169,7 +173,22 @@ export function ToolShell({
dark={isDark}
tints={tints}
/>
</div>
{plugin.optionsSchema && (
<div className="order-2 md:col-start-2 md:row-start-1 md:row-span-2">
<ToolOptionsPanel
schema={plugin.optionsSchema}
value={options}
onChange={setOptions}
dark={isDark}
tints={tints}
toolCategory={plugin.manifest.category}
/>
</div>
)}
<div className="order-3 flex flex-col gap-[24px] empty:hidden md:col-start-1 md:row-start-2">
{isExecuting && (
<ToolProgress
percentage={progress.percentage ?? 0}
@@ -190,22 +209,9 @@ export function ToolShell({
<ToolResultPanel result={result} dark={isDark} tints={tints} />
)}
</div>
<div className="flex flex-col gap-[24px]">
{plugin.optionsSchema && (
<ToolOptionsPanel
schema={plugin.optionsSchema}
value={options}
onChange={setOptions}
dark={isDark}
tints={tints}
toolCategory={plugin.manifest.category}
/>
)}
</div>
</div>
<div className="flex items-center justify-between gap-3 border-t border-[var(--p-border)] bg-[var(--p-surface)] px-[18px] py-[12px] md:px-[24px] md:py-[14px]">
<div className="flex items-center justify-between gap-3 border-t border-[var(--p-border)] bg-[var(--p-surface)] px-[18px] py-[12px] pb-[max(12px,env(safe-area-inset-bottom))] md:px-[24px] md:py-[14px]">
<span className="font-mono text-[11px] text-[var(--p-muted)]">
ready when you are
</span>

View File

@@ -74,6 +74,16 @@
from { opacity: 0; transform: translateY(12px); }
to { opacity: 1; transform: translateY(0); }
}
/* Staggered tile reveal (directory) */
@keyframes plimi-rise {
from { opacity: 0; transform: translateY(14px) scale(0.985); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
/* Mobile menu sheet drop */
@keyframes plimi-drop {
from { opacity: 0; transform: translateY(-8px); }
to { opacity: 1; transform: translateY(0); }
}
.animate-plimi-fade {
animation: plimi-fade 0.18s ease-out;
@@ -83,6 +93,37 @@
animation: plimi-slide 0.22s cubic-bezier(0.2, 0.8, 0.2, 1);
}
.animate-plimi-rise {
animation: plimi-rise 0.42s cubic-bezier(0.2, 0.8, 0.2, 1) both;
}
.animate-plimi-drop {
animation: plimi-drop 0.2s cubic-bezier(0.2, 0.8, 0.2, 1);
}
/* Respect reduced-motion preferences */
@media (prefers-reduced-motion: reduce) {
.animate-plimi-fade,
.animate-plimi-slide,
.animate-plimi-rise,
.animate-plimi-drop {
animation: none !important;
}
}
/* Hide scrollbar but keep scroll (mobile chip rows, etc.) */
.no-scrollbar {
-ms-overflow-style: none;
scrollbar-width: none;
}
.no-scrollbar::-webkit-scrollbar {
display: none;
}
/* Honour iOS/Android safe areas at the app edges */
.pt-safe { padding-top: env(safe-area-inset-top); }
.pb-safe { padding-bottom: env(safe-area-inset-bottom); }
html, body {
margin: 0;
padding: 0;

View File

@@ -0,0 +1,235 @@
import { Link } from "react-router-dom";
import type { ReactNode } from "react";
const repoUrl =
import.meta.env.VITE_PLIMI_REPO_URL || "https://github.com/your-org/plimi";
const steps = [
{
title: "Create a tool folder",
copy: "Add a new directory under src/tools with an index.ts plugin definition, a run.ts runner, and a run.test.ts test file.",
},
{
title: "Describe the tool",
copy: "The manifest declares the id, name, category, tags, input type, output type, permissions, and offline readiness.",
},
{
title: "Implement the runner",
copy: "The runner receives typed input, normalized options, and a context for progress, cancellation, and logging.",
},
{
title: "Register and test",
copy: "Import the plugin in the registry, add it to the list, then run the unit tests and production build.",
},
];
const apiItems = [
{
name: "manifest",
detail: "Required metadata used by the directory, routing, generated input UI, result display, and permission labels.",
},
{
name: "input",
detail: "Supports none, text, files, text-or-files, or grouped fields with per-field labels, placeholders, limits, and examples.",
},
{
name: "optionsSchema",
detail: "Optional generated controls for text, number, boolean, select, and slider values.",
},
{
name: "examples",
detail: "Optional shared Try example API. Existing manifest.example and field.example values are automatically converted.",
},
{
name: "run",
detail: "The async function that performs the work in the browser and returns text, json, table, or downloadable files.",
},
{
name: "customUi",
detail: "Optional React UI for complex tools such as image editors, canvas workflows, and richer file interactions.",
},
];
const code = `export const myToolPlugin: PlimiPlugin<MyOptions> = {
manifest: {
id: "my-tool",
name: "My Tool",
description: "Runs locally in the browser.",
category: "developer",
version: "1.0.0",
tags: ["example"],
input: { type: "text", placeholder: "Paste input..." },
output: { type: "text" },
offlineReady: true,
},
optionsSchema: {
fields: [
{ type: "boolean", key: "uppercase", label: "Uppercase", defaultValue: false }
],
},
examples: [
{
label: "Try example",
input: { text: "Hello Plimi" },
options: { uppercase: true },
},
],
capabilities: { worker: false, cancelable: false },
permissions: { network: "none", fileSystem: "none", clipboard: "none" },
run: runMyTool,
};`;
function ExternalLink({
href,
children,
}: {
href: string;
children: ReactNode;
}) {
return (
<a
href={href}
target="_blank"
rel="noreferrer"
className="inline-flex w-full items-center justify-center rounded-[12px] bg-[var(--p-accent)] px-5 py-3.5 font-sans text-sm font-semibold tracking-tight text-[var(--p-accent-ink)] no-underline transition-[filter] hover:brightness-110 sm:w-auto"
>
{children}
</a>
);
}
export function ContributePage() {
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-7 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)]">
contribute tools
</span>
<h1 className="m-0 max-w-[640px] font-sans text-4xl font-bold leading-[1.02] tracking-tight text-[var(--p-text)] text-balance md:text-[56px]">
Add useful tools to Plimi.
</h1>
<p className="m-0 max-w-[610px] text-base leading-relaxed text-[var(--p-muted)] text-pretty">
Plimi V1 supports contributions through the source code. Tools are added as internal
plugins, reviewed in Git, and shipped with the app so they remain fast, typed, and
browser-local.
</p>
<div className="flex flex-col gap-2 sm:flex-row">
<ExternalLink href={repoUrl}>Open Git repository</ExternalLink>
<Link
to="/tools"
className="inline-flex w-full items-center justify-center rounded-[12px] border border-[var(--p-border)] bg-[var(--p-chip)] px-5 py-3.5 font-sans text-sm font-semibold tracking-tight text-[var(--p-text)] no-underline transition-colors hover:bg-[var(--p-border)] sm:w-auto"
>
Browse existing tools
</Link>
</div>
</div>
<div className="rounded-[20px] border border-[var(--p-border)] bg-[var(--p-surface)] p-5">
<div className="flex flex-col gap-3">
<span className="font-mono text-[11px] uppercase tracking-[0.12em] text-[var(--p-muted)]">
V1 contribution model
</span>
<div className="rounded-[14px] border border-[var(--p-border)] bg-[var(--p-surface-2)] p-4">
<p className="m-0 text-sm leading-relaxed text-[var(--p-muted)]">
External plugin installation is not available in V1. To contribute, fork the repo,
add the tool inside <code className="text-[var(--p-text)]">src/tools</code>, register
it, test it, and open a pull request.
</p>
</div>
<div className="rounded-[14px] border border-[var(--p-border)] bg-[var(--p-bg)] p-4">
<div className="mb-2 font-mono text-[10px] uppercase tracking-[0.12em] text-[var(--p-muted)]">
repo env variable
</div>
<code className="break-all font-mono text-[13px] text-[var(--p-text)]">
VITE_PLIMI_REPO_URL={repoUrl}
</code>
</div>
</div>
</div>
</section>
<section className="grid grid-cols-1 gap-4 md:grid-cols-4">
{steps.map((step, index) => (
<article
key={step.title}
className="flex min-h-[190px] flex-col justify-between gap-5 rounded-[16px] border border-[var(--p-border)] bg-[var(--p-surface)] p-5"
>
<span className="flex h-8 w-8 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-2">
<h2 className="m-0 font-sans text-[16px] 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>
</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.72fr_1.28fr] 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 API
</span>
<h2 className="m-0 font-sans text-2xl font-bold tracking-tight text-[var(--p-text)]">
A tool is a small typed contract.
</h2>
<p className="m-0 text-sm leading-relaxed text-[var(--p-muted)]">
Most tools only need a manifest, an optional options schema, and a runner. Plimi can
generate the input panel, options controls, example action, and result panel from that
contract.
</p>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
{apiItems.map((item) => (
<div
key={item.name}
className="rounded-[14px] border border-[var(--p-border)] bg-[var(--p-surface)] p-4"
>
<h3 className="m-0 font-mono text-[12px] font-semibold text-[var(--p-text)]">
{item.name}
</h3>
<p className="mb-0 mt-2 text-[13px] leading-relaxed text-[var(--p-muted)]">
{item.detail}
</p>
</div>
))}
</div>
</section>
<section className="grid grid-cols-1 gap-6 md:grid-cols-[1fr_1fr]">
<div className="flex flex-col gap-3 rounded-[20px] border border-[var(--p-border)] bg-[var(--p-surface)] p-5 md:p-6">
<span className="font-mono text-[11px] uppercase tracking-[0.12em] text-[var(--p-muted)]">
minimal shape
</span>
<pre className="m-0 overflow-x-auto rounded-[14px] border border-[var(--p-border)] bg-[var(--p-bg)] p-4 text-[12px] leading-relaxed text-[var(--p-text)]">
<code>{code}</code>
</pre>
</div>
<div className="flex flex-col gap-4 rounded-[20px] border border-[var(--p-border)] bg-[var(--p-surface)] p-5 md:p-6">
<span className="font-mono text-[11px] uppercase tracking-[0.12em] text-[var(--p-muted)]">
contribution checklist
</span>
<ul className="m-0 flex list-none flex-col gap-3 p-0">
{[
"Keep execution local. Do not add backend calls for tool processing.",
"Prefer generated UI unless the workflow needs a custom canvas or preview.",
"Add focused tests for parsing, validation, edge cases, and output format.",
"Declare permissions honestly, especially network, clipboard, and file access.",
"Run pnpm test and pnpm build before opening the pull request.",
].map((item) => (
<li key={item} className="flex gap-3 text-sm leading-relaxed text-[var(--p-muted)]">
<span className="mt-2 h-1.5 w-1.5 shrink-0 rounded-full bg-[var(--p-accent)]" />
<span>{item}</span>
</li>
))}
</ul>
</div>
</section>
</div>
);
}

View File

@@ -192,7 +192,7 @@ export function HowItWorksPage() {
</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"
className="inline-flex w-full items-center justify-center rounded-[12px] bg-[var(--p-accent)] px-5 py-3.5 font-sans text-sm font-semibold tracking-tight text-[var(--p-accent-ink)] no-underline transition-[filter] hover:brightness-110 md:w-auto md:rounded-[10px] md:py-3"
>
Browse tools
</Link>

View File

@@ -27,7 +27,9 @@ export function ToolDetailPage() {
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center p-0 md:p-10 animate-plimi-fade"
onClick={handleClose}
role="presentation"
className="fixed inset-0 z-50 flex items-end justify-center p-0 md:items-center md:p-10 animate-plimi-fade"
style={{
background: 'color-mix(in oklab, var(--p-bg) 70%, transparent)',
backdropFilter: 'blur(8px)',
@@ -35,11 +37,18 @@ export function ToolDetailPage() {
>
<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"
role="dialog"
aria-modal="true"
aria-label={plugin.manifest.name}
className="w-full md:w-[min(900px,100%)] h-[92dvh] md:h-auto md:max-h-[calc(100dvh-5rem)] bg-[var(--p-surface)] rounded-t-[20px] md:rounded-[24px] border-[1.5px] border-[var(--p-border)] flex flex-col animate-plimi-slide overflow-hidden"
style={{
boxShadow: '0 40px 80px -30px var(--p-shadow-soft)',
}}
>
{/* Grab handle — signals the sheet is dismissable on touch */}
<div className="md:hidden flex justify-center pt-2.5 pb-1 shrink-0">
<span className="h-1 w-9 rounded-full bg-[var(--p-border)]" />
</div>
<ToolShell plugin={plugin} onClose={handleClose} dark={dark} />
</div>
</div>

View File

@@ -69,7 +69,7 @@ export function ToolsPage() {
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="grid grid-cols-1 md:grid-cols-[1.1fr_1fr] gap-7 md: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
@@ -127,7 +127,7 @@ export function ToolsPage() {
{tools.map((t) => {
const flatIdx = filtered.indexOf(t);
return (
<ToolTile key={t.manifest.id} plugin={t} onClick={handleOpenTool} focused={flatIdx === safeFocusedIndex} dark={dark} />
<ToolTile key={t.manifest.id} plugin={t} onClick={handleOpenTool} focused={flatIdx === safeFocusedIndex} dark={dark} index={flatIdx} />
);
})}
</div>
@@ -140,12 +140,9 @@ export function ToolsPage() {
</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} />
);
})}
{filtered.map((t, flatIdx) => (
<ToolTile key={t.manifest.id} plugin={t} onClick={handleOpenTool} focused={flatIdx === safeFocusedIndex} dark={dark} index={flatIdx} />
))}
</div>
)}
</div>

View File

@@ -62,6 +62,14 @@ const FORMAT_OPTIONS = [
{ label: "WebP", value: "image/webp" },
];
// On small screens the control panels collapse into a tabbed bottom dock.
type MobileTab = "style" | "layers" | "export";
const MOBILE_TABS: { id: MobileTab; label: string }[] = [
{ id: "style", label: "Style" },
{ id: "layers", label: "Layers" },
{ id: "export", label: "Export" },
];
function objectType(object: FabricObject | undefined): string {
if (!object) return "None";
if (object.type === "textbox" || object.type === "i-text") return "Text";
@@ -101,6 +109,9 @@ export default function ImageEditorUi({
const sourceFileRef = useRef<File | undefined>(undefined);
const isPanningRef = useRef(false);
const panStartRef = useRef({ x: 0, y: 0, scrollLeft: 0, scrollTop: 0 });
// Once the user zooms manually we stop auto-fitting on every resize,
// otherwise mobile viewport reflows keep snapping the zoom back to "fit".
const userZoomedRef = useRef(false);
const { ref: workspaceRef, size: workspaceSize } = useElementSize<HTMLDivElement>();
const [sourceFile, setSourceFile] = useState<File | undefined>();
@@ -117,6 +128,7 @@ export default function ImageEditorUi({
useState<ImageEditorOptions["format"]>("image/png");
const [quality, setQuality] = useState(92);
const [fontFamily, setFontFamily] = useState(FONT_OPTIONS[0].value);
const [mobileTab, setMobileTab] = useState<MobileTab>("style");
const [state, setState] = useState<EditorState>({
selectedType: "None",
fill: "#ffffff",
@@ -221,16 +233,22 @@ export default function ImageEditorUi({
const fitCanvasToWorkspace = useCallback(() => {
if (workspaceSize.width === 0 || workspaceSize.height === 0) return;
// Fit re-establishes the auto-fit baseline (resets manual-zoom lock).
userZoomedRef.current = false;
const margin = workspaceSize.width < 480 ? 24 : 56;
const zoom = Math.max(0.12, Math.min(
1,
(workspaceSize.width - 56) / canvasSize.width,
(workspaceSize.height - 56) / canvasSize.height
(workspaceSize.width - margin) / canvasSize.width,
(workspaceSize.height - margin) / canvasSize.height
));
applyDisplayZoom(zoom);
}, [applyDisplayZoom, canvasSize.height, canvasSize.width, workspaceSize.height, workspaceSize.width]);
const setCanvasZoom = useCallback((zoom: number) => {
// Manual zoom — stop auto-fit from overriding the user on resize.
userZoomedRef.current = true;
applyDisplayZoom(zoom);
}, [applyDisplayZoom]);
@@ -273,6 +291,8 @@ export default function ImageEditorUi({
}, [pushHistory, updateActiveState]);
useEffect(() => {
// Only auto-fit while the user hasn't taken manual control of the zoom.
if (userZoomedRef.current) return;
fitCanvasToWorkspace();
}, [fitCanvasToWorkspace]);
@@ -328,10 +348,13 @@ export default function ImageEditorUi({
pushHistory();
refreshHistoryControls();
refreshLayerRows();
// New image — fit it fresh and re-enable auto-fit.
userZoomedRef.current = false;
const margin = workspaceSize.width > 0 && workspaceSize.width < 480 ? 24 : 56;
const zoom = Math.max(0.12, Math.min(
1,
workspaceSize.width > 0 ? (workspaceSize.width - 56) / width : 1,
workspaceSize.height > 0 ? (workspaceSize.height - 56) / height : 1
workspaceSize.width > 0 ? (workspaceSize.width - margin) / width : 1,
workspaceSize.height > 0 ? (workspaceSize.height - margin) / height : 1
));
applyDisplayZoom(zoom, { width, height });
} finally {
@@ -544,9 +567,165 @@ export default function ImageEditorUi({
setCanvasZoom(state.zoom + delta);
}, [setCanvasZoom, state.zoom]);
// Control panels are declared once and placed either in the desktop
// sidebar or the mobile tabbed dock — the single <canvas> is never cloned.
const propertiesPanel = (
<section className="flex flex-col gap-3">
<div className="font-mono text-[10px] uppercase tracking-wider text-[var(--p-muted)]">
properties
</div>
<div className="rounded-[12px] border border-[var(--p-border)] bg-[var(--p-surface)] p-3">
<div className="mb-3 font-sans text-sm font-semibold text-[var(--p-text)]">
{state.selectedType}
</div>
<div className="grid grid-cols-2 gap-3">
<label className="flex flex-col gap-1 font-sans text-[12px] text-[var(--p-muted)]">
Fill
<input
type="color"
value={state.fill}
onChange={(event) => {
setState((prev) => ({ ...prev, fill: event.target.value }));
applyToActive({ fill: event.target.value });
}}
className="h-9 w-full rounded border border-[var(--p-border)] bg-transparent"
/>
</label>
<label className="flex flex-col gap-1 font-sans text-[12px] text-[var(--p-muted)]">
Stroke / text
<input
type="color"
value={state.stroke}
onChange={(event) => {
setState((prev) => ({ ...prev, stroke: event.target.value }));
applyToActive({ stroke: event.target.value, fill: activeObject?.type === "textbox" ? event.target.value : activeObject?.get("fill") });
}}
className="h-9 w-full rounded border border-[var(--p-border)] bg-transparent"
/>
</label>
</div>
<div className="mt-3 flex flex-col gap-3">
<Select
label="Font"
value={fontFamily}
options={FONT_OPTIONS}
onChange={(event) => {
setFontFamily(event.target.value);
applyToActive({ fontFamily: event.target.value });
}}
/>
<Slider
label="Text size"
min={12}
max={160}
value={state.fontSize}
onChange={(event) => {
const fontSize = Number(event.target.value);
setState((prev) => ({ ...prev, fontSize }));
applyToActive({ fontSize });
}}
/>
<Slider
label="Brush"
min={1}
max={48}
value={state.brushWidth}
onChange={(event) => {
setState((prev) => ({ ...prev, brushWidth: Number(event.target.value) }));
}}
/>
</div>
</div>
</section>
);
const arrangePanel = (
<section className="flex flex-col gap-3">
<div className="font-mono text-[10px] uppercase tracking-wider text-[var(--p-muted)]">
arrange
</div>
<div className="grid grid-cols-2 gap-2">
<Button variant="secondary" onClick={duplicateActive} disabled={!activeObject}>Duplicate</Button>
<Button variant="danger" onClick={deleteActive} disabled={!activeObject}>Delete</Button>
<Button variant="secondary" onClick={() => moveLayer("forward")} disabled={!activeObject}>Forward</Button>
<Button variant="secondary" onClick={() => moveLayer("backward")} disabled={!activeObject}>Backward</Button>
<Button variant="secondary" onClick={() => loadHistory(historyIndexRef.current - 1)} disabled={!canUndo}>Undo</Button>
<Button variant="secondary" onClick={() => loadHistory(historyIndexRef.current + 1)} disabled={!canRedo}>Redo</Button>
</div>
<Button variant="secondary" onClick={clearObjects}>
Clear editable objects
</Button>
</section>
);
const layersPanel = (
<section className="flex min-h-0 flex-col gap-3">
<div className="font-mono text-[10px] uppercase tracking-wider text-[var(--p-muted)]">
layers
</div>
<div className="max-h-[160px] overflow-y-auto rounded-[12px] border border-[var(--p-border)] bg-[var(--p-surface)] p-2">
{layerRows.length === 0 ? (
<div className="px-2 py-4 text-center text-sm text-[var(--p-muted)]">
No editable layers yet
</div>
) : (
layerRows.map((row) => (
<button
key={row.id}
onClick={() => {
canvasRef.current?.setActiveObject(row.object);
canvasRef.current?.requestRenderAll();
updateActiveState();
}}
className={`mb-1 w-full rounded-lg px-3 py-2 text-left font-sans text-[13px] ${
activeObject === row.object
? "bg-[var(--p-accent)] text-[var(--p-accent-ink)]"
: "bg-[var(--p-chip)] text-[var(--p-text)]"
}`}
>
{row.label}
</button>
))
)}
</div>
</section>
);
const exportPanel = (
<section className="flex flex-col gap-3 lg:mt-auto">
<div className="font-mono text-[10px] uppercase tracking-wider text-[var(--p-muted)]">
export
</div>
<Select
label="Format"
value={exportFormat}
options={FORMAT_OPTIONS}
onChange={(event) => setExportFormat(event.target.value as ImageEditorOptions["format"])}
/>
<Slider
label="Quality"
min={10}
max={100}
value={quality}
onChange={(event) => setQuality(Number(event.target.value))}
/>
<Button onClick={exportImage} disabled={!canvasReady || isExecuting}>
{isExecuting ? "Exporting..." : "Export image"}
</Button>
{sourceFile && (
<div className="truncate font-mono text-[10px] text-[var(--p-muted)]">
source: {sourceFile.name}
</div>
)}
{error && <div className="text-sm text-red-700">{error}</div>}
{result && <ToolResultPanel result={result} />}
</section>
);
return (
<div className="flex h-full min-h-[620px] flex-col bg-[var(--p-surface)]">
<div className="flex shrink-0 flex-wrap items-center gap-2 border-b border-[var(--p-border)] px-4 py-3">
<div className="flex h-full min-h-[460px] flex-col bg-[var(--p-surface)]">
<div className="flex shrink-0 flex-col gap-2 border-b border-[var(--p-border)] px-3 py-2.5 lg:flex-row lg:flex-wrap lg:items-center lg:gap-2 lg:px-4 lg:py-3">
<Dropzone
accept="image/jpeg,image/png,image/webp"
multiple={false}
@@ -554,55 +733,58 @@ export default function ImageEditorUi({
onFilesDrop={(files) => {
if (files[0]) void loadImageFile(files[0]);
}}
className="min-h-0 w-full p-3 sm:w-[260px]"
className="min-h-0 w-full p-2.5 sm:w-[240px]"
/>
<div className="flex flex-wrap gap-2">
<div className="flex gap-1.5 overflow-x-auto no-scrollbar -mx-3 px-3 lg:mx-0 lg:px-0 lg:flex-wrap lg:overflow-visible">
<Button
className="shrink-0"
variant={state.mode === "select" ? "primary" : "secondary"}
onClick={() => setState((prev) => ({ ...prev, mode: "select" }))}
>
Select
</Button>
<Button
className="shrink-0"
variant={state.mode === "draw" ? "primary" : "secondary"}
onClick={() => setState((prev) => ({ ...prev, mode: "draw" }))}
>
Draw
</Button>
<Button
className="shrink-0"
variant={state.mode === "pan" ? "primary" : "secondary"}
onClick={() => setState((prev) => ({ ...prev, mode: "pan" }))}
>
Pan
</Button>
<Button variant="secondary" onClick={addText}>Text</Button>
<Button variant="secondary" onClick={() => addShape("rect")}>Rect</Button>
<Button variant="secondary" onClick={() => addShape("circle")}>Circle</Button>
<Button variant="secondary" onClick={() => addShape("triangle")}>Triangle</Button>
<Button variant="secondary" onClick={() => addShape("line")}>Line</Button>
<Button className="shrink-0" variant="secondary" onClick={addText}>Text</Button>
<Button className="shrink-0" variant="secondary" onClick={() => addShape("rect")}>Rect</Button>
<Button className="shrink-0" variant="secondary" onClick={() => addShape("circle")}>Circle</Button>
<Button className="shrink-0" variant="secondary" onClick={() => addShape("triangle")}>Triangle</Button>
<Button className="shrink-0" variant="secondary" onClick={() => addShape("line")}>Line</Button>
</div>
<div className="ml-auto flex items-center gap-2">
<Button variant="secondary" onClick={() => setCanvasZoom(state.zoom - 0.12)}>
<div className="flex items-center gap-1.5 lg:ml-auto">
<Button className="shrink-0" variant="secondary" onClick={() => setCanvasZoom(state.zoom - 0.12)}>
-
</Button>
<button
onClick={fitCanvasToWorkspace}
className="rounded-[10px] border border-[var(--p-border)] bg-[var(--p-chip)] px-3 py-2 font-mono text-[12px] text-[var(--p-text)]"
className="shrink-0 rounded-[10px] border border-[var(--p-border)] bg-[var(--p-chip)] px-3 py-2 font-mono text-[12px] text-[var(--p-text)]"
>
{Math.round(state.zoom * 100)}%
</button>
<Button variant="secondary" onClick={() => setCanvasZoom(state.zoom + 0.12)}>
<Button className="shrink-0" variant="secondary" onClick={() => setCanvasZoom(state.zoom + 0.12)}>
+
</Button>
<Button variant="secondary" onClick={() => setCanvasZoom(1)}>
<Button className="shrink-0" variant="secondary" onClick={() => setCanvasZoom(1)}>
100%
</Button>
</div>
</div>
<div className="grid min-h-0 flex-1 grid-cols-1 lg:grid-cols-[minmax(0,1fr)_300px]">
<div className="flex min-h-0 flex-1 flex-col lg:flex-row">
<div
ref={workspaceRef}
onPointerDown={handleWorkspacePointerDown}
@@ -610,7 +792,7 @@ export default function ImageEditorUi({
onPointerUp={handleWorkspacePointerUp}
onPointerCancel={handleWorkspacePointerUp}
onWheel={handleWorkspaceWheel}
className={`min-h-[520px] overflow-auto bg-[var(--p-bg)] p-6 ${
className={`min-h-[200px] flex-1 overflow-auto bg-[var(--p-bg)] p-4 lg:min-h-0 lg:p-6 ${
state.mode === "pan" ? "cursor-grab active:cursor-grabbing" : ""
}`}
>
@@ -627,152 +809,35 @@ export default function ImageEditorUi({
</div>
</div>
<aside className="flex min-h-0 flex-col gap-4 overflow-y-auto border-l border-[var(--p-border)] bg-[var(--p-surface-2)] p-4">
<section className="flex flex-col gap-3">
<div className="font-mono text-[10px] uppercase tracking-wider text-[var(--p-muted)]">
properties
</div>
<div className="rounded-[12px] border border-[var(--p-border)] bg-[var(--p-surface)] p-3">
<div className="mb-3 font-sans text-sm font-semibold text-[var(--p-text)]">
{state.selectedType}
</div>
<div className="grid grid-cols-2 gap-3">
<label className="flex flex-col gap-1 font-sans text-[12px] text-[var(--p-muted)]">
Fill
<input
type="color"
value={state.fill}
onChange={(event) => {
setState((prev) => ({ ...prev, fill: event.target.value }));
applyToActive({ fill: event.target.value });
}}
className="h-9 w-full rounded border border-[var(--p-border)] bg-transparent"
/>
</label>
<label className="flex flex-col gap-1 font-sans text-[12px] text-[var(--p-muted)]">
Stroke / text
<input
type="color"
value={state.stroke}
onChange={(event) => {
setState((prev) => ({ ...prev, stroke: event.target.value }));
applyToActive({ stroke: event.target.value, fill: activeObject?.type === "textbox" ? event.target.value : activeObject?.get("fill") });
}}
className="h-9 w-full rounded border border-[var(--p-border)] bg-transparent"
/>
</label>
</div>
<aside className="flex min-h-0 shrink-0 flex-col border-t border-[var(--p-border)] bg-[var(--p-surface-2)] lg:w-[300px] lg:border-l lg:border-t-0">
<div className="flex gap-1 border-b border-[var(--p-border)] p-1.5 lg:hidden">
{MOBILE_TABS.map((t) => (
<button
key={t.id}
onClick={() => setMobileTab(t.id)}
className={`flex-1 rounded-lg px-3 py-2 font-sans text-[13px] font-medium transition-colors ${
mobileTab === t.id
? "bg-[var(--p-accent)] text-[var(--p-accent-ink)]"
: "text-[var(--p-muted)] hover:text-[var(--p-text)]"
}`}
>
{t.label}
</button>
))}
</div>
<div className="mt-3 flex flex-col gap-3">
<Select
label="Font"
value={fontFamily}
options={FONT_OPTIONS}
onChange={(event) => {
setFontFamily(event.target.value);
applyToActive({ fontFamily: event.target.value });
}}
/>
<Slider
label="Text size"
min={12}
max={160}
value={state.fontSize}
onChange={(event) => {
const fontSize = Number(event.target.value);
setState((prev) => ({ ...prev, fontSize }));
applyToActive({ fontSize });
}}
/>
<Slider
label="Brush"
min={1}
max={48}
value={state.brushWidth}
onChange={(event) => {
setState((prev) => ({ ...prev, brushWidth: Number(event.target.value) }));
}}
/>
</div>
<div className="flex min-h-0 max-h-[44vh] flex-col gap-4 overflow-y-auto p-4 pb-[max(1rem,env(safe-area-inset-bottom))] lg:max-h-none lg:flex-1 lg:pb-4">
<div className={`${mobileTab === "style" ? "flex flex-col gap-4" : "hidden"} lg:contents`}>
{propertiesPanel}
</div>
</section>
<section className="flex flex-col gap-3">
<div className="font-mono text-[10px] uppercase tracking-wider text-[var(--p-muted)]">
arrange
<div className={`${mobileTab === "layers" ? "flex flex-col gap-4" : "hidden"} lg:contents`}>
{arrangePanel}
{layersPanel}
</div>
<div className="grid grid-cols-2 gap-2">
<Button variant="secondary" onClick={duplicateActive} disabled={!activeObject}>Duplicate</Button>
<Button variant="danger" onClick={deleteActive} disabled={!activeObject}>Delete</Button>
<Button variant="secondary" onClick={() => moveLayer("forward")} disabled={!activeObject}>Forward</Button>
<Button variant="secondary" onClick={() => moveLayer("backward")} disabled={!activeObject}>Backward</Button>
<Button variant="secondary" onClick={() => loadHistory(historyIndexRef.current - 1)} disabled={!canUndo}>Undo</Button>
<Button variant="secondary" onClick={() => loadHistory(historyIndexRef.current + 1)} disabled={!canRedo}>Redo</Button>
<div className={`${mobileTab === "export" ? "flex flex-col gap-4" : "hidden"} lg:contents`}>
{exportPanel}
</div>
<Button variant="secondary" onClick={clearObjects}>
Clear editable objects
</Button>
</section>
<section className="flex min-h-0 flex-col gap-3">
<div className="font-mono text-[10px] uppercase tracking-wider text-[var(--p-muted)]">
layers
</div>
<div className="max-h-[160px] overflow-y-auto rounded-[12px] border border-[var(--p-border)] bg-[var(--p-surface)] p-2">
{layerRows.length === 0 ? (
<div className="px-2 py-4 text-center text-sm text-[var(--p-muted)]">
No editable layers yet
</div>
) : (
layerRows.map((row) => (
<button
key={row.id}
onClick={() => {
canvasRef.current?.setActiveObject(row.object);
canvasRef.current?.requestRenderAll();
updateActiveState();
}}
className={`mb-1 w-full rounded-lg px-3 py-2 text-left font-sans text-[13px] ${
activeObject === row.object
? "bg-[var(--p-accent)] text-[var(--p-accent-ink)]"
: "bg-[var(--p-chip)] text-[var(--p-text)]"
}`}
>
{row.label}
</button>
))
)}
</div>
</section>
<section className="mt-auto flex flex-col gap-3">
<div className="font-mono text-[10px] uppercase tracking-wider text-[var(--p-muted)]">
export
</div>
<Select
label="Format"
value={exportFormat}
options={FORMAT_OPTIONS}
onChange={(event) => setExportFormat(event.target.value as ImageEditorOptions["format"])}
/>
<Slider
label="Quality"
min={10}
max={100}
value={quality}
onChange={(event) => setQuality(Number(event.target.value))}
/>
<Button onClick={exportImage} disabled={!canvasReady || isExecuting}>
{isExecuting ? "Exporting..." : "Export image"}
</Button>
{sourceFile && (
<div className="truncate font-mono text-[10px] text-[var(--p-muted)]">
source: {sourceFile.name}
</div>
)}
{error && <div className="text-sm text-red-700">{error}</div>}
{result && <ToolResultPanel result={result} />}
</section>
</div>
</aside>
</div>
</div>