Compare commits
3
Commits
2b0b671895
...
dfbf9be61c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dfbf9be61c | ||
|
|
be29ae7aa7 | ||
|
|
e3e790fd2b |
@@ -1,11 +1,15 @@
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { useTranslation } from '@/components/locales';
|
||||
|
||||
type ColorPickerProps = { value?: string; name: string; label?: string };
|
||||
export const ColorPicker = (props: ColorPickerProps) => {
|
||||
const { value = '#000000', name, label } = props;
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div style={{ borderWidth: '1px' }} className="relative w-full rounded">
|
||||
{label && <span className="text-muted-foreground absolute z-10 -mt-2 ml-5 bg-background px-2 text-xs">{label}</span>}
|
||||
{label && (
|
||||
<span className="text-muted-foreground absolute z-10 -mt-2 ms-5 bg-background px-2 text-xs">{label}</span>
|
||||
)}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="h-12 overflow-hidden rounded-full p-0">
|
||||
<Input
|
||||
@@ -13,14 +17,14 @@ export const ColorPicker = (props: ColorPickerProps) => {
|
||||
className="absolute h-12 w-24 rounded-full border-0 text-lg"
|
||||
type="color"
|
||||
name={name}
|
||||
placeholder="Font Weight"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Input
|
||||
name={name}
|
||||
className="border-0 text-right text-base outline-none focus:outline-none"
|
||||
className="border-0 text-end text-base outline-none focus:outline-none"
|
||||
defaultValue={value}
|
||||
placeholder={t('colorPicker.placeholder')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState } from 'react';
|
||||
import { Copy, Check, Play } from 'lucide-react';
|
||||
import { copyToClipboard } from 'helpers/clipboard';
|
||||
import { useTranslation } from '@/components/locales';
|
||||
|
||||
type CommandBlockProps = {
|
||||
label?: string;
|
||||
@@ -9,6 +10,7 @@ type CommandBlockProps = {
|
||||
};
|
||||
|
||||
export const CommandBlock = ({ label, command, onRun }: CommandBlockProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const copy = () => {
|
||||
@@ -20,14 +22,14 @@ export const CommandBlock = ({ label, command, onRun }: CommandBlockProps) => {
|
||||
return (
|
||||
<div className="inline-flex flex-col gap-1">
|
||||
{label && <span className="text-xs text-duck-dark/50">{label}</span>}
|
||||
<div className="inline-flex items-center gap-1 bg-[#1a1a2e] rounded-lg pl-3 pr-1 py-1.5">
|
||||
<div className="inline-flex items-center gap-1 bg-[#1a1a2e] rounded-lg ps-3 pe-1 py-1.5">
|
||||
<code className="text-sm text-[#e0e0e0] font-mono whitespace-nowrap">{command}</code>
|
||||
{onRun && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRun(command)}
|
||||
className="shrink-0 p-1.5 rounded hover:bg-white/10 cursor-pointer transition-colors"
|
||||
title="Run in terminal"
|
||||
title={t('commandBlock.run')}
|
||||
>
|
||||
<Play className="h-3.5 w-3.5 text-duck-teal" />
|
||||
</button>
|
||||
@@ -36,7 +38,7 @@ export const CommandBlock = ({ label, command, onRun }: CommandBlockProps) => {
|
||||
type="button"
|
||||
onClick={copy}
|
||||
className="shrink-0 p-1.5 rounded hover:bg-white/10 cursor-pointer transition-colors"
|
||||
title="Copy command"
|
||||
title={t('commandBlock.copy')}
|
||||
>
|
||||
{copied ? <Check className="h-3.5 w-3.5 text-green-400" /> : <Copy className="h-3.5 w-3.5 text-white/40" />}
|
||||
</button>
|
||||
|
||||
@@ -1,115 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import md from 'markdown-it';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import rehypeRaw from 'rehype-raw';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Code, Eye } from 'lucide-react';
|
||||
|
||||
type MarkdownEditorProps = {
|
||||
content: string;
|
||||
onChange: (content: string) => void;
|
||||
placeholder?: string;
|
||||
greeting?: string;
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
};
|
||||
|
||||
export function MarkdownEditor({
|
||||
content,
|
||||
onChange,
|
||||
placeholder = 'Enter markdown...',
|
||||
greeting,
|
||||
firstName,
|
||||
lastName,
|
||||
}: MarkdownEditorProps) {
|
||||
const [showPreview, setShowPreview] = useState(false);
|
||||
|
||||
const markdown = md({
|
||||
html: true,
|
||||
breaks: true,
|
||||
linkify: true,
|
||||
});
|
||||
|
||||
const processGreeting = (text: string) => {
|
||||
return text
|
||||
.replace(/\{\{firstName\}\}/g, firstName || '')
|
||||
.replace(/\{\{fullName\}\}/g, `${firstName || ''} ${lastName || ''}`.trim());
|
||||
};
|
||||
|
||||
const processedGreeting = greeting ? processGreeting(greeting) : '';
|
||||
const greetingHtml = processedGreeting ? `<p>${processedGreeting}</p>` : '';
|
||||
const preview = markdown.render(content);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col rounded-lg border border-border bg-background overflow-hidden">
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center gap-2 border-b border-border bg-muted px-4 py-2">
|
||||
<Button size="sm" variant={!showPreview ? 'default' : 'outline'} onClick={() => setShowPreview(false)}>
|
||||
<Code className="h-4 w-4 mr-2" />
|
||||
Markdown
|
||||
</Button>
|
||||
<Button size="sm" variant={showPreview ? 'default' : 'outline'} onClick={() => setShowPreview(true)}>
|
||||
<Eye className="h-4 w-4 mr-2" />
|
||||
Preview
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Content Area */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
{!showPreview ? (
|
||||
<textarea
|
||||
rows={15}
|
||||
name="content"
|
||||
value={content}
|
||||
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
className="w-full h-full resize-none p-4 font-mono text-sm focus:outline-none bg-background text-foreground placeholder:text-muted-foreground overflow-auto"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full overflow-auto bg-slate-50 dark:bg-slate-900 p-4">
|
||||
{/* Email wrapper simulation */}
|
||||
<div className="mx-auto max-w-2xl rounded-lg bg-background p-8 shadow-lg">
|
||||
{/* Logo */}
|
||||
<div className="mb-8 text-center">
|
||||
<img src="https://app.pertento.ai/logo.png" alt="Pertento" className="h-16 mx-auto" />
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="space-y-4">
|
||||
{processedGreeting && <p className="text-base leading-relaxed">{processedGreeting}</p>}
|
||||
<style>{`
|
||||
.markdown-preview h1 { font-size: 2rem; font-weight: bold; margin: 1.5rem 0 1rem 0; }
|
||||
.markdown-preview h2 { font-size: 1.5rem; font-weight: bold; margin: 1.25rem 0 0.75rem 0; }
|
||||
.markdown-preview h3 { font-size: 1.25rem; font-weight: bold; margin: 1rem 0 0.5rem 0; }
|
||||
.markdown-preview p { margin: 0.75rem 0; line-height: 1.6; }
|
||||
/* list-style is restored explicitly — tailwind preflight strips it, which leaves an
|
||||
ordered list indented but unnumbered. */
|
||||
.markdown-preview ul, .markdown-preview ol { margin: 1rem 0; padding-left: 2rem; list-style-position: outside; }
|
||||
.markdown-preview ul { list-style-type: disc; }
|
||||
.markdown-preview ol { list-style-type: decimal; }
|
||||
.markdown-preview ul ul { list-style-type: circle; }
|
||||
.markdown-preview ol ol { list-style-type: lower-alpha; }
|
||||
.markdown-preview li { margin: 0.5rem 0; line-height: 1.6; }
|
||||
.markdown-preview code { background-color: rgb(229, 229, 229); color: rgb(31, 41, 55); padding: 0.25rem 0.5rem; border-radius: 0.25rem; font-size: 0.875rem; }
|
||||
.markdown-preview pre { background-color: rgb(17, 24, 39); color: rgb(243, 244, 246); padding: 1rem; border-radius: 0.5rem; overflow-x: auto; margin: 1rem 0; }
|
||||
.markdown-preview pre code { background-color: transparent; color: inherit; padding: 0; }
|
||||
.markdown-preview a { color: rgb(37, 99, 235); text-decoration: underline; }
|
||||
.markdown-preview blockquote { border-left: 4px solid rgb(209, 213, 219); padding-left: 1rem; font-style: italic; margin: 1rem 0; }
|
||||
.dark .markdown-preview code { background-color: rgb(55, 65, 81); color: rgb(243, 244, 246); }
|
||||
.dark .markdown-preview a { color: rgb(96, 165, 250); }
|
||||
.dark .markdown-preview blockquote { border-left-color: rgb(75, 85, 99); }
|
||||
`}</style>
|
||||
<div className="markdown-preview">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeRaw]}>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -17,6 +17,13 @@
|
||||
"tabs": {
|
||||
"filterPlaceholder": "Filter"
|
||||
},
|
||||
"commandBlock": {
|
||||
"run": "Run in terminal",
|
||||
"copy": "Copy command"
|
||||
},
|
||||
"colorPicker": {
|
||||
"placeholder": "Hex colour"
|
||||
},
|
||||
"state": {
|
||||
"loading": "Loading…"
|
||||
},
|
||||
|
||||
@@ -17,6 +17,13 @@
|
||||
"tabs": {
|
||||
"filterPlaceholder": "Filtrar"
|
||||
},
|
||||
"commandBlock": {
|
||||
"run": "Executar no terminal",
|
||||
"copy": "Copiar comando"
|
||||
},
|
||||
"colorPicker": {
|
||||
"placeholder": "Cor hexadecimal"
|
||||
},
|
||||
"state": {
|
||||
"loading": "A carregar…"
|
||||
},
|
||||
|
||||
@@ -1,212 +0,0 @@
|
||||
import React, { useEffect, useRef, useState, useCallback } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Play } from 'lucide-react';
|
||||
|
||||
export default function StartConfettiButton({
|
||||
isDraftLike,
|
||||
onStart,
|
||||
className,
|
||||
label = 'Start',
|
||||
}: {
|
||||
isDraftLike: boolean;
|
||||
onStart?: () => void;
|
||||
className?: string;
|
||||
label?: string;
|
||||
}) {
|
||||
const [showConfetti, setShowConfetti] = useState(false);
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||
const rafRef = useRef<number | null>(null);
|
||||
|
||||
// ---- Confetti Engine (inline, light) ----
|
||||
const runConfetti = useCallback(() => {
|
||||
const canvas = canvasRef.current!;
|
||||
const ctx = canvas.getContext('2d')!;
|
||||
const DPR = Math.max(1, Math.min(2, window.devicePixelRatio || 1));
|
||||
|
||||
// size canvas
|
||||
const resize = () => {
|
||||
const { innerWidth: w, innerHeight: h } = window;
|
||||
canvas.width = Math.floor(w * DPR);
|
||||
canvas.height = Math.floor(h * DPR);
|
||||
canvas.style.width = `${w}px`;
|
||||
canvas.style.height = `${h}px`;
|
||||
ctx.setTransform(DPR, 0, 0, DPR, 0, 0);
|
||||
};
|
||||
resize();
|
||||
|
||||
const onResize = () => resize();
|
||||
window.addEventListener('resize', onResize);
|
||||
|
||||
const colors = [
|
||||
'#ef4444', // red-500
|
||||
'#f59e0b', // amber-500
|
||||
'#10b981', // emerald-500
|
||||
'#3b82f6', // blue-500
|
||||
'#8b5cf6', // violet-500
|
||||
'#ec4899', // pink-500
|
||||
'#22c55e', // green-500
|
||||
'#eab308', // yellow-500
|
||||
];
|
||||
|
||||
type Particle = {
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
vx: number;
|
||||
vy: number;
|
||||
rot: number;
|
||||
rotSpeed: number;
|
||||
tilt: number;
|
||||
tiltSpeed: number;
|
||||
color: string;
|
||||
alpha: number;
|
||||
decay: number;
|
||||
shape: 'rect' | 'triangle';
|
||||
};
|
||||
|
||||
const rand = (min: number, max: number) => Math.random() * (max - min) + min;
|
||||
|
||||
const particles: Particle[] = [];
|
||||
const bursts = 5;
|
||||
const perBurst = 80;
|
||||
const spread = Math.PI / 3;
|
||||
const gravity = 0.25;
|
||||
const air = 0.002;
|
||||
const floorFriction = 0.96;
|
||||
|
||||
for (let b = 0; b < bursts; b++) {
|
||||
const originX = (window.innerWidth / (bursts + 1)) * (b + 1);
|
||||
const originY = window.innerHeight * 0.15;
|
||||
const angleBase = -Math.PI / 2;
|
||||
for (let i = 0; i < perBurst; i++) {
|
||||
const angle = angleBase + rand(-spread, spread);
|
||||
const speed = rand(6, 12);
|
||||
particles.push({
|
||||
x: originX + rand(-8, 8),
|
||||
y: originY + rand(-8, 8),
|
||||
w: rand(4, 8),
|
||||
h: rand(6, 14),
|
||||
vx: Math.cos(angle) * speed,
|
||||
vy: Math.sin(angle) * speed,
|
||||
rot: rand(0, Math.PI * 2),
|
||||
rotSpeed: rand(-0.2, 0.2),
|
||||
tilt: rand(-0.8, 0.8),
|
||||
tiltSpeed: rand(-0.02, 0.02),
|
||||
color: colors[(Math.random() * colors.length) | 0] ?? '#ef4444',
|
||||
alpha: 1,
|
||||
decay: rand(0.004, 0.01),
|
||||
shape: Math.random() < 0.3 ? 'triangle' : 'rect',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const tick = () => {
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
for (const p of particles) {
|
||||
// physics
|
||||
p.vy += gravity;
|
||||
p.vx *= 1 - air;
|
||||
p.vy *= 1 - air * 0.5;
|
||||
p.x += p.vx;
|
||||
p.y += p.vy;
|
||||
p.rot += p.rotSpeed;
|
||||
p.tilt += p.tiltSpeed;
|
||||
p.alpha -= p.decay;
|
||||
|
||||
// floor bounce & friction
|
||||
const floor = window.innerHeight - 6;
|
||||
if (p.y > floor) {
|
||||
p.y = floor;
|
||||
p.vy *= -0.45;
|
||||
p.vx *= floorFriction;
|
||||
p.rotSpeed *= floorFriction;
|
||||
p.tiltSpeed *= floorFriction;
|
||||
}
|
||||
|
||||
// draw
|
||||
if (p.alpha > 0) {
|
||||
ctx.globalAlpha = Math.max(0, p.alpha);
|
||||
ctx.save();
|
||||
ctx.translate(p.x, p.y);
|
||||
ctx.rotate(p.rot + p.tilt);
|
||||
ctx.fillStyle = p.color;
|
||||
|
||||
if (p.shape === 'rect') {
|
||||
ctx.fillRect(-p.w / 2, -p.h / 2, p.w, p.h);
|
||||
} else {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, -p.h / 2);
|
||||
ctx.lineTo(p.w / 2, p.h / 2);
|
||||
ctx.lineTo(-p.w / 2, p.h / 2);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
}
|
||||
|
||||
const alive = particles.some((p) => p.alpha > 0.02);
|
||||
if (alive) {
|
||||
rafRef.current = requestAnimationFrame(tick);
|
||||
} else {
|
||||
// cleanup
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
window.removeEventListener('resize', onResize);
|
||||
}
|
||||
};
|
||||
|
||||
tick();
|
||||
}, []);
|
||||
|
||||
const handleStart = () => {
|
||||
onStart?.();
|
||||
|
||||
if (isDraftLike) {
|
||||
setShowConfetti(true);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!showConfetti) return;
|
||||
|
||||
runConfetti();
|
||||
|
||||
const t = setTimeout(() => {
|
||||
setShowConfetti(false);
|
||||
}, 2200);
|
||||
|
||||
return () => {
|
||||
clearTimeout(t);
|
||||
if (rafRef.current) cancelAnimationFrame(rafRef.current);
|
||||
rafRef.current = null;
|
||||
};
|
||||
}, [showConfetti, runConfetti]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleStart}
|
||||
className={`min-w-24 ${isDraftLike ? 'bg-green-600 text-white hover:bg-green-700' : ''} ${className ?? ''}`}
|
||||
disabled={!isDraftLike}
|
||||
title={isDraftLike ? 'Start the experiment' : 'Only available for Draft/Created'}
|
||||
>
|
||||
<Play className="w-4 h-4 mr-2" />
|
||||
{label}
|
||||
</Button>
|
||||
|
||||
{/* Confetti Overlay */}
|
||||
{showConfetti && (
|
||||
<div
|
||||
className="fixed inset-0 z-100 pointer-events-none mask-[radial-gradient(80%_80%_at_50%_40%,#000,transparent)]"
|
||||
aria-hidden
|
||||
>
|
||||
<canvas ref={canvasRef} className="absolute inset-0 w-full h-full" />
|
||||
<div className="absolute inset-x-0 top-0 h-32 bg-linear-to-b from-white/60 to-transparent" />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -96,6 +96,10 @@ const startBrief = (name: string): string =>
|
||||
'If you CAN — say what you understood, in your own words, and ask me to confirm or correct it.',
|
||||
'If you CANNOT — ask me what you should be watching for here. One question, and wait.',
|
||||
'',
|
||||
'Once you are running, you owe me exactly two messages here per event, and I should never have to ask',
|
||||
'for them: one line when the thing you watch for happens, and a short report when the work is done.',
|
||||
'The job panel is not where I read things. The skill explains how to wake this conversation yourself.',
|
||||
'',
|
||||
'Either way, say it plainly. No headings, no bullet points, no file paths, no commit hashes, no tag',
|
||||
'names. Do not open with "Oriented" and do not tell me you verified anything — I asked you to look,',
|
||||
'so saying you looked is not news. Length is whatever the answer honestly needs and no more.',
|
||||
|
||||
@@ -49,9 +49,10 @@ handling.
|
||||
- `Dashboards/DashboardPreview`'s `NamePanel` (name + description) has its submit button in a
|
||||
_different_, independently-mounted panel (`CreatePanel`) elsewhere in the workspace layout tree — a
|
||||
`<form>` can't span two panels without restructuring the wizard.
|
||||
- `ColorPicker` and `MarkdownEditor` have zero call sites anywhere in the repo right now; both are
|
||||
shaped to be embedded in a future caller's own form (uncontrolled inputs with `name` attributes, or a
|
||||
plain controlled textarea with no submit of its own), so there's nothing to wrap yet.
|
||||
- `ColorPicker` has zero call sites anywhere in the repo right now; it is shaped to be embedded in a
|
||||
future caller's own form (uncontrolled inputs with `name` attributes), so there's nothing to wrap yet.
|
||||
`MarkdownEditor` was named here too and was deleted on 2026-08-23 during the localization pass — it
|
||||
had no call sites and hardcoded another product's logo URL.
|
||||
- `widgets/Weather`'s `LocationSetup` is a live autocomplete with several result rows and no single
|
||||
obvious Enter target (which of N matches?).
|
||||
|
||||
|
||||
Reference in New Issue
Block a user