Add contribution page and Docker deployment
This commit is contained in:
23
.dockerignore
Normal file
23
.dockerignore
Normal 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
1
.env.example
Normal file
@@ -0,0 +1 @@
|
||||
VITE_PLIMI_REPO_URL=https://github.com/your-org/plimi
|
||||
24
Dockerfile
Normal file
24
Dockerfile
Normal 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;"]
|
||||
21
README.md
21
README.md
@@ -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
11
docker-compose.yml
Normal 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
39
nginx.conf
Normal 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;
|
||||
}
|
||||
}
|
||||
184
src/App.css
184
src/App.css
@@ -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);
|
||||
}
|
||||
}
|
||||
122
src/App.tsx
122
src/App.tsx
@@ -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;
|
||||
@@ -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 |
@@ -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 |
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
235
src/pages/ContributePage.tsx
Normal file
235
src/pages/ContributePage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user