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
|
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)
|
## 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 { ToolsPage } from "../pages/ToolsPage";
|
||||||
import { ToolDetailPage } from "../pages/ToolDetailPage";
|
import { ToolDetailPage } from "../pages/ToolDetailPage";
|
||||||
import { HowItWorksPage } from "../pages/HowItWorksPage";
|
import { HowItWorksPage } from "../pages/HowItWorksPage";
|
||||||
|
import { ContributePage } from "../pages/ContributePage";
|
||||||
|
|
||||||
export const router = createBrowserRouter([
|
export const router = createBrowserRouter([
|
||||||
{
|
{
|
||||||
@@ -26,6 +27,10 @@ export const router = createBrowserRouter([
|
|||||||
path: "how-it-works",
|
path: "how-it-works",
|
||||||
element: <HowItWorksPage />,
|
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 (
|
return (
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
<div
|
<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={{
|
style={{
|
||||||
boxShadow: '0 1px 0 0 var(--p-shadow-inset), 0 12px 30px -22px var(--p-shadow-soft)',
|
boxShadow: '0 1px 0 0 var(--p-shadow-inset), 0 12px 30px -22px var(--p-shadow-soft)',
|
||||||
}}
|
}}
|
||||||
onClick={() => inputRef.current?.focus()}
|
onClick={() => inputRef.current?.focus()}
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
width="28"
|
|
||||||
height="28"
|
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="var(--p-muted)"
|
stroke="var(--p-muted)"
|
||||||
strokeWidth="2"
|
strokeWidth="2"
|
||||||
strokeLinecap="round"
|
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" />
|
<circle cx="11" cy="11" r="7" />
|
||||||
<path d="m20 20-3.5-3.5" />
|
<path d="m20 20-3.5-3.5" />
|
||||||
@@ -78,9 +76,9 @@ export function PlimiSearch({
|
|||||||
onChange('');
|
onChange('');
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
placeholder="Start typing to find a tool…"
|
placeholder="Search tools…"
|
||||||
spellCheck={false}
|
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)]">
|
<span className="shrink-0 inline-flex items-center gap-1.5 font-mono text-xs text-[var(--p-muted)]">
|
||||||
{value ? (
|
{value ? (
|
||||||
@@ -94,7 +92,7 @@ export function PlimiSearch({
|
|||||||
clear
|
clear
|
||||||
</button>
|
</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}
|
{shortcutText}
|
||||||
</kbd>
|
</kbd>
|
||||||
)}
|
)}
|
||||||
@@ -102,7 +100,7 @@ export function PlimiSearch({
|
|||||||
</div>
|
</div>
|
||||||
<div className="mt-2.5 flex justify-between font-mono text-[11px] text-[var(--p-muted)] tracking-wider uppercase">
|
<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>{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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -120,14 +118,14 @@ export function CategoryChips({
|
|||||||
categories: { id: string; label: string }[];
|
categories: { id: string; label: string }[];
|
||||||
}) {
|
}) {
|
||||||
return (
|
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) => {
|
{categories.map((c) => {
|
||||||
const on = active === c.id;
|
const on = active === c.id;
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={c.id}
|
key={c.id}
|
||||||
onClick={() => onPick(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
|
on
|
||||||
? 'bg-[var(--p-accent)] text-[var(--p-accent-ink)] border-[var(--p-accent)]'
|
? '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)]'
|
: 'bg-[var(--p-surface)] text-[var(--p-text)] border-[var(--p-border)]'
|
||||||
@@ -169,11 +167,13 @@ export function ToolTile({
|
|||||||
focused,
|
focused,
|
||||||
onClick,
|
onClick,
|
||||||
dark,
|
dark,
|
||||||
|
index = 0,
|
||||||
}: {
|
}: {
|
||||||
plugin: UnknownPlimiPlugin;
|
plugin: UnknownPlimiPlugin;
|
||||||
focused: boolean;
|
focused: boolean;
|
||||||
onClick: (plugin: UnknownPlimiPlugin) => void;
|
onClick: (plugin: UnknownPlimiPlugin) => void;
|
||||||
dark: boolean;
|
dark: boolean;
|
||||||
|
index?: number;
|
||||||
}) {
|
}) {
|
||||||
const [hover, setHover] = React.useState(false);
|
const [hover, setHover] = React.useState(false);
|
||||||
const tints = TINTS[plugin.manifest.category] || TINTS.developer;
|
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))`;
|
: `color-mix(in oklab, ${tints.ink} 18%, var(--p-border))`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<div
|
||||||
|
className="animate-plimi-rise h-full"
|
||||||
|
style={{ animationDelay: `${Math.min(index, 12) * 34}ms` }}
|
||||||
|
>
|
||||||
<button
|
<button
|
||||||
onClick={() => onClick(plugin)}
|
onClick={() => onClick(plugin)}
|
||||||
onMouseEnter={() => setHover(true)}
|
onMouseEnter={() => setHover(true)}
|
||||||
onMouseLeave={() => setHover(false)}
|
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={{
|
style={{
|
||||||
background: bg,
|
background: bg,
|
||||||
border: `1.5px solid ${lifted ? stickerEdge : 'var(--p-border)'}`,
|
border: `1.5px solid ${lifted ? stickerEdge : 'var(--p-border)'}`,
|
||||||
@@ -239,6 +243,7 @@ export function ToolTile({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,18 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
import { Link, useLocation } from "react-router-dom";
|
import { Link, useLocation } from "react-router-dom";
|
||||||
import { useTheme } from "../../app/useTheme";
|
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 }) {
|
function PlimiMark({ size = 28 }: { size?: number }) {
|
||||||
return (
|
return (
|
||||||
<span
|
<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() {
|
export function Header() {
|
||||||
const { dark, toggleTheme } = useTheme();
|
const { dark, toggleTheme } = useTheme();
|
||||||
const location = useLocation();
|
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 (
|
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)]">
|
<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 gap-5">
|
<div className="flex items-center justify-between px-6 py-4 md:px-8 md:py-5">
|
||||||
<Link to="/">
|
<Link to="/" aria-label="Plimi home">
|
||||||
<PlimiMark size={26} />
|
<PlimiMark size={26} />
|
||||||
</Link>
|
</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>
|
</div>
|
||||||
|
|
||||||
<nav className="hidden md:flex gap-6">
|
{menuOpen && (
|
||||||
{[
|
<>
|
||||||
{ label: "Tools", to: "/tools" },
|
{/* Tap-away backdrop below the bar */}
|
||||||
{ label: "How it works", to: "/how-it-works" },
|
<button
|
||||||
{ label: "Privacy", to: "#" },
|
aria-hidden="true"
|
||||||
{ label: "Changelog", to: "#" },
|
tabIndex={-1}
|
||||||
].map((item) => {
|
onClick={() => setMenuOpen(false)}
|
||||||
const isActive = item.to !== "#" && (
|
className="md:hidden fixed inset-0 top-[var(--plimi-header-h,56px)] z-30 bg-[color-mix(in_oklab,var(--p-bg)_55%,transparent)]"
|
||||||
item.to === "/tools"
|
/>
|
||||||
? location.pathname.startsWith("/tools")
|
<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">
|
||||||
: location.pathname === item.to
|
{NAV_ITEMS.map((item) => {
|
||||||
);
|
const active = isNavActive(item.to, location.pathname);
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
key={item.label}
|
key={item.label}
|
||||||
to={item.to}
|
to={item.to}
|
||||||
className={`font-sans text-[13px] no-underline transition-colors ${
|
className={`flex items-center justify-between rounded-xl px-4 py-3.5 font-sans text-[15px] no-underline transition-colors ${
|
||||||
isActive
|
active
|
||||||
? "text-[var(--p-text)] font-semibold"
|
? "bg-[var(--p-accent)] text-[var(--p-accent-ink)] font-semibold"
|
||||||
: "text-[var(--p-muted)] hover:text-[var(--p-text)] font-normal"
|
: "text-[var(--p-text)] hover:bg-[var(--p-chip)]"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{item.label}
|
{item.label}
|
||||||
</Link>
|
<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>
|
||||||
</nav>
|
</Link>
|
||||||
|
);
|
||||||
<div className="flex items-center gap-3">
|
})}
|
||||||
<ThemeToggle dark={dark} onClick={toggleTheme} />
|
</nav>
|
||||||
</div>
|
</>
|
||||||
</div>
|
)}
|
||||||
|
</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} />
|
<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
|
||||||
<div className="flex flex-col gap-[24px]">
|
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
|
<ToolInputPanel
|
||||||
definition={plugin.manifest.input}
|
definition={plugin.manifest.input}
|
||||||
value={input}
|
value={input}
|
||||||
@@ -169,7 +173,22 @@ export function ToolShell({
|
|||||||
dark={isDark}
|
dark={isDark}
|
||||||
tints={tints}
|
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 && (
|
{isExecuting && (
|
||||||
<ToolProgress
|
<ToolProgress
|
||||||
percentage={progress.percentage ?? 0}
|
percentage={progress.percentage ?? 0}
|
||||||
@@ -190,22 +209,9 @@ export function ToolShell({
|
|||||||
<ToolResultPanel result={result} dark={isDark} tints={tints} />
|
<ToolResultPanel result={result} dark={isDark} tints={tints} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<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)]">
|
<span className="font-mono text-[11px] text-[var(--p-muted)]">
|
||||||
ready when you are
|
ready when you are
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -74,6 +74,16 @@
|
|||||||
from { opacity: 0; transform: translateY(12px); }
|
from { opacity: 0; transform: translateY(12px); }
|
||||||
to { opacity: 1; transform: translateY(0); }
|
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 {
|
.animate-plimi-fade {
|
||||||
animation: plimi-fade 0.18s ease-out;
|
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);
|
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 {
|
html, body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 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>
|
</div>
|
||||||
<Link
|
<Link
|
||||||
to="/tools"
|
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
|
Browse tools
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -27,7 +27,9 @@ export function ToolDetailPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<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={{
|
style={{
|
||||||
background: 'color-mix(in oklab, var(--p-bg) 70%, transparent)',
|
background: 'color-mix(in oklab, var(--p-bg) 70%, transparent)',
|
||||||
backdropFilter: 'blur(8px)',
|
backdropFilter: 'blur(8px)',
|
||||||
@@ -35,11 +37,18 @@ export function ToolDetailPage() {
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
onClick={(e) => e.stopPropagation()}
|
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={{
|
style={{
|
||||||
boxShadow: '0 40px 80px -30px var(--p-shadow-soft)',
|
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} />
|
<ToolShell plugin={plugin} onClose={handleClose} dark={dark} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ export function ToolsPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-7 max-w-[1200px] mx-auto pb-10">
|
<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">
|
<div className="flex flex-col gap-3.5">
|
||||||
<span className="font-mono text-[11px] text-[var(--p-muted)] tracking-[0.12em] uppercase">
|
<span className="font-mono text-[11px] text-[var(--p-muted)] tracking-[0.12em] uppercase">
|
||||||
your digital pencil case
|
your digital pencil case
|
||||||
@@ -127,7 +127,7 @@ export function ToolsPage() {
|
|||||||
{tools.map((t) => {
|
{tools.map((t) => {
|
||||||
const flatIdx = filtered.indexOf(t);
|
const flatIdx = filtered.indexOf(t);
|
||||||
return (
|
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>
|
</div>
|
||||||
@@ -140,12 +140,9 @@ export function ToolsPage() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4 mt-4">
|
<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) => {
|
{filtered.map((t, flatIdx) => (
|
||||||
const flatIdx = filtered.indexOf(t);
|
<ToolTile key={t.manifest.id} plugin={t} onClick={handleOpenTool} focused={flatIdx === safeFocusedIndex} dark={dark} index={flatIdx} />
|
||||||
return (
|
))}
|
||||||
<ToolTile key={t.manifest.id} plugin={t} onClick={handleOpenTool} focused={flatIdx === safeFocusedIndex} dark={dark} />
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -62,6 +62,14 @@ const FORMAT_OPTIONS = [
|
|||||||
{ label: "WebP", value: "image/webp" },
|
{ 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 {
|
function objectType(object: FabricObject | undefined): string {
|
||||||
if (!object) return "None";
|
if (!object) return "None";
|
||||||
if (object.type === "textbox" || object.type === "i-text") return "Text";
|
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 sourceFileRef = useRef<File | undefined>(undefined);
|
||||||
const isPanningRef = useRef(false);
|
const isPanningRef = useRef(false);
|
||||||
const panStartRef = useRef({ x: 0, y: 0, scrollLeft: 0, scrollTop: 0 });
|
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 { ref: workspaceRef, size: workspaceSize } = useElementSize<HTMLDivElement>();
|
||||||
|
|
||||||
const [sourceFile, setSourceFile] = useState<File | undefined>();
|
const [sourceFile, setSourceFile] = useState<File | undefined>();
|
||||||
@@ -117,6 +128,7 @@ export default function ImageEditorUi({
|
|||||||
useState<ImageEditorOptions["format"]>("image/png");
|
useState<ImageEditorOptions["format"]>("image/png");
|
||||||
const [quality, setQuality] = useState(92);
|
const [quality, setQuality] = useState(92);
|
||||||
const [fontFamily, setFontFamily] = useState(FONT_OPTIONS[0].value);
|
const [fontFamily, setFontFamily] = useState(FONT_OPTIONS[0].value);
|
||||||
|
const [mobileTab, setMobileTab] = useState<MobileTab>("style");
|
||||||
const [state, setState] = useState<EditorState>({
|
const [state, setState] = useState<EditorState>({
|
||||||
selectedType: "None",
|
selectedType: "None",
|
||||||
fill: "#ffffff",
|
fill: "#ffffff",
|
||||||
@@ -221,16 +233,22 @@ export default function ImageEditorUi({
|
|||||||
const fitCanvasToWorkspace = useCallback(() => {
|
const fitCanvasToWorkspace = useCallback(() => {
|
||||||
if (workspaceSize.width === 0 || workspaceSize.height === 0) return;
|
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(
|
const zoom = Math.max(0.12, Math.min(
|
||||||
1,
|
1,
|
||||||
(workspaceSize.width - 56) / canvasSize.width,
|
(workspaceSize.width - margin) / canvasSize.width,
|
||||||
(workspaceSize.height - 56) / canvasSize.height
|
(workspaceSize.height - margin) / canvasSize.height
|
||||||
));
|
));
|
||||||
|
|
||||||
applyDisplayZoom(zoom);
|
applyDisplayZoom(zoom);
|
||||||
}, [applyDisplayZoom, canvasSize.height, canvasSize.width, workspaceSize.height, workspaceSize.width]);
|
}, [applyDisplayZoom, canvasSize.height, canvasSize.width, workspaceSize.height, workspaceSize.width]);
|
||||||
|
|
||||||
const setCanvasZoom = useCallback((zoom: number) => {
|
const setCanvasZoom = useCallback((zoom: number) => {
|
||||||
|
// Manual zoom — stop auto-fit from overriding the user on resize.
|
||||||
|
userZoomedRef.current = true;
|
||||||
applyDisplayZoom(zoom);
|
applyDisplayZoom(zoom);
|
||||||
}, [applyDisplayZoom]);
|
}, [applyDisplayZoom]);
|
||||||
|
|
||||||
@@ -273,6 +291,8 @@ export default function ImageEditorUi({
|
|||||||
}, [pushHistory, updateActiveState]);
|
}, [pushHistory, updateActiveState]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// Only auto-fit while the user hasn't taken manual control of the zoom.
|
||||||
|
if (userZoomedRef.current) return;
|
||||||
fitCanvasToWorkspace();
|
fitCanvasToWorkspace();
|
||||||
}, [fitCanvasToWorkspace]);
|
}, [fitCanvasToWorkspace]);
|
||||||
|
|
||||||
@@ -328,10 +348,13 @@ export default function ImageEditorUi({
|
|||||||
pushHistory();
|
pushHistory();
|
||||||
refreshHistoryControls();
|
refreshHistoryControls();
|
||||||
refreshLayerRows();
|
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(
|
const zoom = Math.max(0.12, Math.min(
|
||||||
1,
|
1,
|
||||||
workspaceSize.width > 0 ? (workspaceSize.width - 56) / width : 1,
|
workspaceSize.width > 0 ? (workspaceSize.width - margin) / width : 1,
|
||||||
workspaceSize.height > 0 ? (workspaceSize.height - 56) / height : 1
|
workspaceSize.height > 0 ? (workspaceSize.height - margin) / height : 1
|
||||||
));
|
));
|
||||||
applyDisplayZoom(zoom, { width, height });
|
applyDisplayZoom(zoom, { width, height });
|
||||||
} finally {
|
} finally {
|
||||||
@@ -544,9 +567,165 @@ export default function ImageEditorUi({
|
|||||||
setCanvasZoom(state.zoom + delta);
|
setCanvasZoom(state.zoom + delta);
|
||||||
}, [setCanvasZoom, state.zoom]);
|
}, [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 (
|
return (
|
||||||
<div className="flex h-full min-h-[620px] flex-col bg-[var(--p-surface)]">
|
<div className="flex h-full min-h-[460px] 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 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
|
<Dropzone
|
||||||
accept="image/jpeg,image/png,image/webp"
|
accept="image/jpeg,image/png,image/webp"
|
||||||
multiple={false}
|
multiple={false}
|
||||||
@@ -554,55 +733,58 @@ export default function ImageEditorUi({
|
|||||||
onFilesDrop={(files) => {
|
onFilesDrop={(files) => {
|
||||||
if (files[0]) void loadImageFile(files[0]);
|
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
|
<Button
|
||||||
|
className="shrink-0"
|
||||||
variant={state.mode === "select" ? "primary" : "secondary"}
|
variant={state.mode === "select" ? "primary" : "secondary"}
|
||||||
onClick={() => setState((prev) => ({ ...prev, mode: "select" }))}
|
onClick={() => setState((prev) => ({ ...prev, mode: "select" }))}
|
||||||
>
|
>
|
||||||
Select
|
Select
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
|
className="shrink-0"
|
||||||
variant={state.mode === "draw" ? "primary" : "secondary"}
|
variant={state.mode === "draw" ? "primary" : "secondary"}
|
||||||
onClick={() => setState((prev) => ({ ...prev, mode: "draw" }))}
|
onClick={() => setState((prev) => ({ ...prev, mode: "draw" }))}
|
||||||
>
|
>
|
||||||
Draw
|
Draw
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
|
className="shrink-0"
|
||||||
variant={state.mode === "pan" ? "primary" : "secondary"}
|
variant={state.mode === "pan" ? "primary" : "secondary"}
|
||||||
onClick={() => setState((prev) => ({ ...prev, mode: "pan" }))}
|
onClick={() => setState((prev) => ({ ...prev, mode: "pan" }))}
|
||||||
>
|
>
|
||||||
Pan
|
Pan
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="secondary" onClick={addText}>Text</Button>
|
<Button className="shrink-0" variant="secondary" onClick={addText}>Text</Button>
|
||||||
<Button variant="secondary" onClick={() => addShape("rect")}>Rect</Button>
|
<Button className="shrink-0" variant="secondary" onClick={() => addShape("rect")}>Rect</Button>
|
||||||
<Button variant="secondary" onClick={() => addShape("circle")}>Circle</Button>
|
<Button className="shrink-0" variant="secondary" onClick={() => addShape("circle")}>Circle</Button>
|
||||||
<Button variant="secondary" onClick={() => addShape("triangle")}>Triangle</Button>
|
<Button className="shrink-0" variant="secondary" onClick={() => addShape("triangle")}>Triangle</Button>
|
||||||
<Button variant="secondary" onClick={() => addShape("line")}>Line</Button>
|
<Button className="shrink-0" variant="secondary" onClick={() => addShape("line")}>Line</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="ml-auto flex items-center gap-2">
|
<div className="flex items-center gap-1.5 lg:ml-auto">
|
||||||
<Button variant="secondary" onClick={() => setCanvasZoom(state.zoom - 0.12)}>
|
<Button className="shrink-0" variant="secondary" onClick={() => setCanvasZoom(state.zoom - 0.12)}>
|
||||||
-
|
-
|
||||||
</Button>
|
</Button>
|
||||||
<button
|
<button
|
||||||
onClick={fitCanvasToWorkspace}
|
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)}%
|
{Math.round(state.zoom * 100)}%
|
||||||
</button>
|
</button>
|
||||||
<Button variant="secondary" onClick={() => setCanvasZoom(state.zoom + 0.12)}>
|
<Button className="shrink-0" variant="secondary" onClick={() => setCanvasZoom(state.zoom + 0.12)}>
|
||||||
+
|
+
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="secondary" onClick={() => setCanvasZoom(1)}>
|
<Button className="shrink-0" variant="secondary" onClick={() => setCanvasZoom(1)}>
|
||||||
100%
|
100%
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</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
|
<div
|
||||||
ref={workspaceRef}
|
ref={workspaceRef}
|
||||||
onPointerDown={handleWorkspacePointerDown}
|
onPointerDown={handleWorkspacePointerDown}
|
||||||
@@ -610,7 +792,7 @@ export default function ImageEditorUi({
|
|||||||
onPointerUp={handleWorkspacePointerUp}
|
onPointerUp={handleWorkspacePointerUp}
|
||||||
onPointerCancel={handleWorkspacePointerUp}
|
onPointerCancel={handleWorkspacePointerUp}
|
||||||
onWheel={handleWorkspaceWheel}
|
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" : ""
|
state.mode === "pan" ? "cursor-grab active:cursor-grabbing" : ""
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@@ -627,152 +809,35 @@ export default function ImageEditorUi({
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<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">
|
||||||
<section className="flex flex-col gap-3">
|
<div className="flex gap-1 border-b border-[var(--p-border)] p-1.5 lg:hidden">
|
||||||
<div className="font-mono text-[10px] uppercase tracking-wider text-[var(--p-muted)]">
|
{MOBILE_TABS.map((t) => (
|
||||||
properties
|
<button
|
||||||
</div>
|
key={t.id}
|
||||||
<div className="rounded-[12px] border border-[var(--p-border)] bg-[var(--p-surface)] p-3">
|
onClick={() => setMobileTab(t.id)}
|
||||||
<div className="mb-3 font-sans text-sm font-semibold text-[var(--p-text)]">
|
className={`flex-1 rounded-lg px-3 py-2 font-sans text-[13px] font-medium transition-colors ${
|
||||||
{state.selectedType}
|
mobileTab === t.id
|
||||||
</div>
|
? "bg-[var(--p-accent)] text-[var(--p-accent-ink)]"
|
||||||
<div className="grid grid-cols-2 gap-3">
|
: "text-[var(--p-muted)] hover:text-[var(--p-text)]"
|
||||||
<label className="flex flex-col gap-1 font-sans text-[12px] text-[var(--p-muted)]">
|
}`}
|
||||||
Fill
|
>
|
||||||
<input
|
{t.label}
|
||||||
type="color"
|
</button>
|
||||||
value={state.fill}
|
))}
|
||||||
onChange={(event) => {
|
</div>
|
||||||
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">
|
<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">
|
||||||
<Select
|
<div className={`${mobileTab === "style" ? "flex flex-col gap-4" : "hidden"} lg:contents`}>
|
||||||
label="Font"
|
{propertiesPanel}
|
||||||
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>
|
</div>
|
||||||
</section>
|
<div className={`${mobileTab === "layers" ? "flex flex-col gap-4" : "hidden"} lg:contents`}>
|
||||||
|
{arrangePanel}
|
||||||
<section className="flex flex-col gap-3">
|
{layersPanel}
|
||||||
<div className="font-mono text-[10px] uppercase tracking-wider text-[var(--p-muted)]">
|
|
||||||
arrange
|
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className={`${mobileTab === "export" ? "flex flex-col gap-4" : "hidden"} lg:contents`}>
|
||||||
<Button variant="secondary" onClick={duplicateActive} disabled={!activeObject}>Duplicate</Button>
|
{exportPanel}
|
||||||
<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>
|
</div>
|
||||||
<Button variant="secondary" onClick={clearObjects}>
|
</div>
|
||||||
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>
|
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user