From fc0260afeae601e0d3a64a36dbd099f61162b7ec Mon Sep 17 00:00:00 2001 From: juliomachines Date: Fri, 22 May 2026 12:46:50 -0300 Subject: [PATCH 1/5] first commit. --- README.md | 2 +- package.json | 9 +- .../AdjustableProgressBar/index.tsx | 166 +++++ .../GanttChart/GanttChart.module.css | 148 ---- src/components/GanttChart/GanttChart.test.tsx | 53 -- src/components/GanttChart/GanttChart.tsx | 434 ------------ src/components/GanttChart/README.md | 84 --- src/components/GanttChart/index.tsx | 1 - src/components/HelloWorld/index.tsx | 15 - .../KanbanBoard/KanbanBoard.module.css | 520 -------------- src/components/KanbanBoard/KanbanBoard.tsx | 639 ------------------ src/components/KanbanBoard/index.tsx | 1 - .../PasswordStrengthAnalyzer/index.tsx | 37 - .../PasswordStrengthAnalyzer/utils.test.ts | 16 - .../PasswordStrengthAnalyzer/utils.ts | 11 - .../QRCodeGenerator.module.css | 112 --- .../QRCodeGenerator/QRCodeGenerator.tsx | 222 ------ src/components/QRCodeGenerator/README.md | 77 --- src/components/QRCodeGenerator/index.tsx | 1 - src/index.tsx | 6 +- 20 files changed, 172 insertions(+), 2382 deletions(-) create mode 100644 src/components/AdjustableProgressBar/index.tsx delete mode 100644 src/components/GanttChart/GanttChart.module.css delete mode 100644 src/components/GanttChart/GanttChart.test.tsx delete mode 100644 src/components/GanttChart/GanttChart.tsx delete mode 100644 src/components/GanttChart/README.md delete mode 100644 src/components/GanttChart/index.tsx delete mode 100644 src/components/HelloWorld/index.tsx delete mode 100644 src/components/KanbanBoard/KanbanBoard.module.css delete mode 100644 src/components/KanbanBoard/KanbanBoard.tsx delete mode 100644 src/components/KanbanBoard/index.tsx delete mode 100644 src/components/PasswordStrengthAnalyzer/index.tsx delete mode 100644 src/components/PasswordStrengthAnalyzer/utils.test.ts delete mode 100644 src/components/PasswordStrengthAnalyzer/utils.ts delete mode 100644 src/components/QRCodeGenerator/QRCodeGenerator.module.css delete mode 100644 src/components/QRCodeGenerator/QRCodeGenerator.tsx delete mode 100644 src/components/QRCodeGenerator/README.md delete mode 100644 src/components/QRCodeGenerator/index.tsx diff --git a/README.md b/README.md index 32cf4f6..180bb14 100644 --- a/README.md +++ b/README.md @@ -70,7 +70,7 @@ This prompts for a library name and description, writes metadata to `package.jso ### Step 5 — Build your component -Rename the `HelloWorld` component in `src/components/` or create a new folder for your component: +Edit the component in `src/components/AdjustableProgressBar/` or create a new folder for additional components: ``` src/ diff --git a/package.json b/package.json index 1e56f20..d331e56 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,6 @@ "private": true, "dependencies": { "@tryretool/custom-component-support": "latest", - "qrcode.react": "^4.2.0", "react": "^18.2.0", "react-dom": "^18.2.0" }, @@ -39,10 +38,10 @@ "vitest": "^4.0.17" }, "retoolCustomComponentLibraryConfig": { - "name": "custom-component-collection", - "label": "Custom Component Collection", - "description": "A collection of reusable Retool custom components", + "name": "AdjustableProgressBar", + "label": "AdjustableProgressBar", + "description": "A custom component that allows the user to use a progress bar and adjusts the value by dragging it.", "entryPoint": "src/index.tsx", "outputPath": "dist" } -} +} \ No newline at end of file diff --git a/src/components/AdjustableProgressBar/index.tsx b/src/components/AdjustableProgressBar/index.tsx new file mode 100644 index 0000000..9f6db19 --- /dev/null +++ b/src/components/AdjustableProgressBar/index.tsx @@ -0,0 +1,166 @@ +import React, { useState, useRef, useEffect, type FC } from 'react'; +import { Retool } from '@tryretool/custom-component-support'; + +export const AdjustableProgressBar: FC = () => { + const [value, setValue] = Retool.useStateNumber({ + name: 'value', + initialValue: 30 + }); + + const [isDragging, setIsDragging] = useState(false); + const progressBarRef = useRef(null); + + const getProgressColor = (val: number) => { + if (val <= 25) return '#ef4444'; + if (val <= 75) return '#3b82f6'; + return '#22c55e'; + }; + + const updateValue = (clientX: number) => { + if (!progressBarRef.current) return; + + const rect = progressBarRef.current.getBoundingClientRect(); + + let newValue = ((clientX - rect.left) / rect.width) * 100; + newValue = Math.max(0, Math.min(100, newValue)); + + setValue(Math.round(newValue)); + }; + + const handleStart = ( + e: React.MouseEvent | React.TouchEvent + ) => { + setIsDragging(true); + + if ('clientX' in e) { + updateValue(e.clientX); + } else { + updateValue(e.touches[0].clientX); + } + }; + + useEffect(() => { + const handleMove = (e: MouseEvent | TouchEvent) => { + if (!isDragging) return; + + if ('touches' in e) { + updateValue(e.touches[0].clientX); + } else { + updateValue(e.clientX); + } + }; + + const handleEnd = () => { + setIsDragging(false); + }; + + window.addEventListener('mousemove', handleMove); + window.addEventListener('mouseup', handleEnd); + + window.addEventListener('touchmove', handleMove); + window.addEventListener('touchend', handleEnd); + + return () => { + window.removeEventListener('mousemove', handleMove); + window.removeEventListener('mouseup', handleEnd); + + window.removeEventListener('touchmove', handleMove); + window.removeEventListener('touchend', handleEnd); + }; + }, [isDragging]); + + return ( +
+ {/* Header */} +
+ + Progress + + +
+ {value}% +
+
+ + {/* Progress Bar */} +
+ {/* Filled area */} +
+ + {/* Thumb */} +
+
+
+ ); +}; \ No newline at end of file diff --git a/src/components/GanttChart/GanttChart.module.css b/src/components/GanttChart/GanttChart.module.css deleted file mode 100644 index 2e36358..0000000 --- a/src/components/GanttChart/GanttChart.module.css +++ /dev/null @@ -1,148 +0,0 @@ -.root { - width: 100%; - height: 100%; - min-height: 200px; - background: #0D0F12; - border-radius: 10px; - overflow: hidden; - font-family: Inter, system-ui, sans-serif; - color: #E2E8F0; -} - -.inner { - display: flex; - height: 100%; - overflow: hidden; -} - -/* ── Label column ── */ - -.labelCol { - flex-shrink: 0; - display: flex; - flex-direction: column; - border-right: 1px solid rgba(255, 255, 255, 0.07); - background: #111318; - overflow: hidden; -} - -.labelHead { - display: flex; - align-items: center; - padding: 0 16px; - font-size: 11px; - font-weight: 600; - letter-spacing: 0.06em; - text-transform: uppercase; - color: #64748B; - border-bottom: 1px solid rgba(255, 255, 255, 0.07); - flex-shrink: 0; -} - -.labelRow { - display: flex; - align-items: center; - padding: 0 16px; - cursor: pointer; - transition: background 0.15s; - flex-shrink: 0; - overflow: hidden; -} - -.labelContent { - display: flex; - align-items: center; - justify-content: space-between; - width: 100%; - gap: 8px; - overflow: hidden; -} - -.labelText { - display: flex; - flex-direction: column; - justify-content: center; - overflow: hidden; - flex: 1; -} - -.avatarList { - display: flex; - flex-direction: row; - flex-shrink: 0; -} - -.avatar { - width: 20px; - height: 20px; - border-radius: 50%; - display: flex; - align-items: center; - justify-content: center; - font-size: 7px; - font-weight: 700; - color: white; - border: 1.5px solid #0D0F12; - margin-left: -4px; - flex-shrink: 0; -} - -.avatar:first-child { - margin-left: 0; -} - -.avatarImg { - width: 100%; - height: 100%; - border-radius: 50%; - object-fit: cover; - display: block; -} - -.labelRow:hover { - background: rgba(155, 114, 207, 0.07) !important; -} - -.group { - font-size: 10px; - font-weight: 600; - letter-spacing: 0.05em; - text-transform: uppercase; - color: #9B72CF; - margin-bottom: 2px; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.name { - font-size: 13px; - font-weight: 500; - color: #CBD5E1; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -/* ── Chart scroll ── */ - -.chartScroll { - flex: 1; - overflow-x: auto; - overflow-y: hidden; - scrollbar-width: thin; - scrollbar-color: rgba(155, 114, 207, 0.3) transparent; -} - -.chartScroll::-webkit-scrollbar { - height: 6px; -} - -.chartScroll::-webkit-scrollbar-track { - background: transparent; -} - -.chartScroll::-webkit-scrollbar-thumb { - background: rgba(155, 114, 207, 0.3); - border-radius: 3px; -} diff --git a/src/components/GanttChart/GanttChart.test.tsx b/src/components/GanttChart/GanttChart.test.tsx deleted file mode 100644 index 788106b..0000000 --- a/src/components/GanttChart/GanttChart.test.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { render, screen } from "@testing-library/react"; -import { GanttChart } from "./GanttChart"; - -// Mock Retool hooks -vi.mock("@tryretool/custom-component-support", () => ({ - Retool: { - useStateArray: vi.fn(() => [[ - { id: "1", name: "Design", start: "2024-01-01", end: "2024-01-14", progress: 100, group: "Phase 1" }, - { id: "2", name: "Development", start: "2024-01-15", end: "2024-02-15", progress: 60, group: "Phase 1" }, - { id: "3", name: "Testing", start: "2024-02-16", end: "2024-03-01", progress: 0, group: "Phase 2" }, - ]]), - useStateString: vi.fn(() => ["week"]), - useStateObject: vi.fn(() => [null, vi.fn()]), - useEventCallback: vi.fn(() => vi.fn()), - }, -})); - -describe("GanttChart", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it("renders without crashing", () => { - render(); - }); - - it("renders task names in the label column", () => { - render(); - expect(screen.getByText("Design")).toBeDefined(); - expect(screen.getByText("Development")).toBeDefined(); - expect(screen.getByText("Testing")).toBeDefined(); - }); - - it("renders group labels", () => { - render(); - const phase1 = screen.getAllByText("Phase 1"); - expect(phase1.length).toBeGreaterThan(0); - }); - - it("renders the chart SVG", () => { - const { container } = render(); - const svg = container.querySelector("svg"); - expect(svg).not.toBeNull(); - }); - - it("renders a bar for each task", () => { - const { container } = render(); - // Each task renders a hit-area rect with cursor:pointer - const hitAreas = container.querySelectorAll("rect[style*='cursor: pointer']"); - expect(hitAreas.length).toBe(3); - }); -}); diff --git a/src/components/GanttChart/GanttChart.tsx b/src/components/GanttChart/GanttChart.tsx deleted file mode 100644 index 6990f0f..0000000 --- a/src/components/GanttChart/GanttChart.tsx +++ /dev/null @@ -1,434 +0,0 @@ -import React, { FC, useMemo, useState, useCallback } from "react"; -import { Retool } from "@tryretool/custom-component-support"; -import styles from "./GanttChart.module.css"; - -// ─── Types ─────────────────────────────────────────────────────────────────── - -type Assignee = { - id?: string; - name: string; - initials?: string; - color?: string; - avatar?: string; // profile picture URL -}; - -type Task = { - id: string; - name: string; - start: string; - end: string; - progress?: number; - group?: string; - color?: string; - assignees?: Assignee[]; -}; - -// Retool user shape from the Retool API resource -type RetoolUser = { - id?: string | number; - email?: string; - firstName?: string; - lastName?: string; - name?: string; - profilePhotoUrl?: string; - avatar?: string; -}; - -type Column = { label: string; x: number; width: number }; - -// ─── Constants ─────────────────────────────────────────────────────────────── - -const ROW_HEIGHT = 64; -const HEADER_HEIGHT = 52; -const LABEL_WIDTH = 240; -const BAR_HEIGHT = 28; -const BAR_RADIUS = 7; -const AVATAR_R = 13; -const AVATAR_GAP = 5; -const MAX_AVATARS = 3; - -const PALETTE = [ - "#6BBAFF", "#8B7EFF", "#2EC98A", "#F59E0B", - "#EF4444", "#9B72CF", "#34d399", "#f87171", -]; - -const AVATAR_PALETTE = [ - "#9B72CF", "#6BBAFF", "#2EC98A", "#F59E0B", - "#EF4444", "#8B7EFF", "#34d399", "#f87171", -]; - -const DAY_WIDTH: Record = { day: 40, week: 24, month: 12 }; - -// ─── Date helpers ───────────────────────────────────────────────────────────── - -const parseDate = (s: string) => new Date(s + "T00:00:00"); -const diffDays = (a: Date, b: Date) => Math.round((b.getTime() - a.getTime()) / 86_400_000); -const addDays = (d: Date, n: number) => { const r = new Date(d); r.setDate(r.getDate() + n); return r; }; - -function getWeek(d: Date): number { - const date = new Date(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate())); - const day = date.getUTCDay() || 7; - date.setUTCDate(date.getUTCDate() + 4 - day); - const yearStart = new Date(Date.UTC(date.getUTCFullYear(), 0, 1)); - return Math.ceil(((date.getTime() - yearStart.getTime()) / 86_400_000 + 1) / 7); -} - -function buildColumns(start: Date, end: Date, mode: string): Column[] { - const dw = DAY_WIDTH[mode] ?? 24; - const cols: Column[] = []; - if (mode === "day") { - const total = diffDays(start, end); - for (let i = 0; i < total; i++) { - const d = addDays(start, i); - cols.push({ label: d.toLocaleDateString("en-US", { month: "short", day: "numeric" }), x: i * dw, width: dw }); - } - } else if (mode === "week") { - let cur = new Date(start); - cur = addDays(cur, cur.getDay() === 0 ? -6 : 1 - cur.getDay()); - while (cur < end) { - const next = addDays(cur, 7); - const x = Math.max(0, diffDays(start, cur)) * dw; - const w = Math.min(7, diffDays(start, end) - diffDays(start, cur)) * dw; - if (w > 0) cols.push({ label: `W${getWeek(cur)} ${cur.getFullYear()}`, x, width: w }); - cur = next; - } - } else { - let cur = new Date(start.getFullYear(), start.getMonth(), 1); - while (cur < end) { - const next = new Date(cur.getFullYear(), cur.getMonth() + 1, 1); - const x = Math.max(0, diffDays(start, cur)) * dw; - const w = Math.min(diffDays(cur, next), diffDays(start, end) - Math.max(0, diffDays(start, cur))) * dw; - if (w > 0) cols.push({ label: cur.toLocaleDateString("en-US", { month: "short", year: "numeric" }), x, width: w }); - cur = next; - } - } - return cols; -} - -// ─── Helpers ────────────────────────────────────────────────────────────────── - -function getInitials(name: string): string { - const parts = name.trim().split(" "); - return (parts[0]?.[0] ?? "") + (parts[1]?.[0] ?? ""); -} - -function resolveAssignees(assignees: Assignee[], userMap: Map): Assignee[] { - return assignees.map(a => { - const user = a.id ? userMap.get(String(a.id)) : undefined; - if (!user) return a; - const fullName = user.name ?? `${user.firstName ?? ""} ${user.lastName ?? ""}`.trim(); - return { - ...a, - name: fullName || a.name, - initials: a.initials ?? getInitials(fullName || a.name), - avatar: user.profilePhotoUrl ?? user.avatar ?? a.avatar, - }; - }); -} - -// ─── SVG Avatar Stack (on bar) ──────────────────────────────────────────────── - -function SvgAvatarStack({ assignees, x, y, taskId }: { assignees: Assignee[]; x: number; y: number; taskId: string }) { - const visible = assignees.slice(0, MAX_AVATARS); - const overflow = assignees.length - MAX_AVATARS; - const step = AVATAR_R * 2 - AVATAR_GAP; - const totalW = visible.length * step + (overflow > 0 ? step : 0); - - return ( - - - {visible.map((_, i) => ( - - - - ))} - - {visible.map((a, i) => { - const cx = i * step + AVATAR_R; - const cy = AVATAR_R; - const bg = a.color ?? AVATAR_PALETTE[i % AVATAR_PALETTE.length]; - return ( - - - {a.avatar ? ( - - ) : ( - - {(a.initials ?? getInitials(a.name)).toUpperCase().slice(0, 2)} - - )} - - - ); - })} - {overflow > 0 && ( - - - - +{overflow} - - - )} - - ); -} - -// ─── HTML Avatar (label column) ─────────────────────────────────────────────── - -function HtmlAvatar({ assignee, index }: { assignee: Assignee; index: number }) { - const [imgFailed, setImgFailed] = useState(false); - const bg = assignee.color ?? AVATAR_PALETTE[index % AVATAR_PALETTE.length]; - const initials = (assignee.initials ?? getInitials(assignee.name)).toUpperCase().slice(0, 2); - - return ( -
- {assignee.avatar && !imgFailed ? ( - {assignee.name} setImgFailed(true)} - /> - ) : ( - initials - )} -
- ); -} - -// ─── Component ──────────────────────────────────────────────────────────────── - -export const GanttChart: FC = () => { - const [rawTasks] = Retool.useStateArray({ - name: "tasks", - label: "Tasks", - description: "Array of tasks: { id, name, start, end, progress?, group?, color?, assignees?: [{ id?, name, initials?, color?, avatar? }] }", - defaultValue: [], - }); - const [rawUsers] = Retool.useStateArray({ - name: "users", - label: "Users", - description: "Retool org users — bind to a Retool API users query. Used to resolve avatars and names from assignee id.", - defaultValue: [], - }); - const [viewMode] = Retool.useStateString({ - name: "viewMode", - label: "View Mode", - description: "day | week | month", - defaultValue: "week", - }); - const [, setSelectedTask] = Retool.useStateObject({ - name: "selectedTask", - label: "Selected Task", - description: "Last clicked task object including resolved assignees", - defaultValue: null, - }); - const onTaskClick = Retool.useEventCallback({ name: "taskClick" }); - - const [hoveredId, setHoveredId] = useState(null); - - const tasks = (rawTasks as Task[]) ?? []; - const users = (rawUsers as RetoolUser[]) ?? []; - const mode = (viewMode as string) || "week"; - const dayW = DAY_WIDTH[mode] ?? 24; - - // Build a map of user id → user for fast lookup - const userMap = useMemo(() => { - const map = new Map(); - users.forEach(u => { if (u.id != null) map.set(String(u.id), u); }); - return map; - }, [users]); - - const { rangeStart, totalDays, columns } = useMemo(() => { - if (tasks.length === 0) { - const now = new Date(); - const start = new Date(now.getFullYear(), now.getMonth(), 1); - const end = new Date(now.getFullYear(), now.getMonth() + 3, 0); - return { rangeStart: start, totalDays: diffDays(start, end), columns: buildColumns(start, end, mode) }; - } - let min = parseDate(tasks[0].start); - let max = parseDate(tasks[0].end); - tasks.forEach(t => { - const s = parseDate(t.start), e = parseDate(t.end); - if (s < min) min = s; - if (e > max) max = e; - }); - const start = addDays(min, -7); - const end = addDays(max, 7); - return { rangeStart: start, totalDays: diffDays(start, end), columns: buildColumns(start, end, mode) }; - }, [tasks, mode]); - - const chartW = totalDays * dayW; - const chartH = HEADER_HEIGHT + tasks.length * ROW_HEIGHT + 8; - - const colorMap = useMemo(() => { - const map: Record = {}; - let i = 0; - tasks.forEach(t => { - const k = t.group ?? t.id; - if (!map[k]) map[k] = PALETTE[i++ % PALETTE.length]; - }); - return map; - }, [tasks]); - - const barProps = useCallback((task: Task) => { - const s = parseDate(task.start); - const e = parseDate(task.end); - const x = diffDays(rangeStart, s) * dayW; - const w = Math.max(diffDays(s, e) * dayW, dayW); - return { x, w }; - }, [rangeStart, dayW]); - - const handleClick = useCallback((task: Task, resolved: Assignee[]) => { - setSelectedTask({ ...task, assignees: resolved } as unknown as Record); - onTaskClick(); - }, [setSelectedTask, onTaskClick]); - - const todayX = diffDays(rangeStart, new Date()) * dayW; - const showToday = todayX >= 0 && todayX <= chartW; - - return ( -
-
- - {/* ── Label column ── */} -
-
Task
- {tasks.map((task, i) => { - const resolved = resolveAssignees(task.assignees ?? [], userMap); - return ( -
handleClick(task, resolved)} - > -
-
- {task.group && {task.group}} - {task.name} -
- {resolved.length > 0 && ( -
- {resolved.slice(0, MAX_AVATARS).map((a, ai) => ( - - ))} - {resolved.length > MAX_AVATARS && ( -
- +{resolved.length - MAX_AVATARS} -
- )} -
- )} -
-
- ); - })} -
- - {/* ── Chart scroll ── */} -
- - - {columns.map((col, ci) => ( - - - - {col.label} - - - - ))} - - - - {tasks.map((task, i) => { - const { x, w } = barProps(task); - const rowY = HEADER_HEIGHT + i * ROW_HEIGHT; - const barY = rowY + (ROW_HEIGHT - BAR_HEIGHT) / 2; - const color = task.color ?? colorMap[task.group ?? task.id] ?? PALETTE[0]; - const progress = Math.min(100, Math.max(0, task.progress ?? 0)); - const hovered = hoveredId === task.id; - const resolved = resolveAssignees(task.assignees ?? [], userMap); - const avatarX = x + w - 4; - const avatarY = rowY + ROW_HEIGHT / 2; - - return ( - - - {hovered && ( - - )} - - {progress > 0 && ( - - )} - - {progress > 0 && w > 52 && ( - - {progress}% - - )} - {resolved.length > 0 && w > 40 && ( - - )} - handleClick(task, resolved)} - onMouseEnter={() => setHoveredId(task.id)} - onMouseLeave={() => setHoveredId(null)} /> - - ); - })} - - {showToday && ( - - - - - TODAY - - - )} - -
-
-
- ); -}; diff --git a/src/components/GanttChart/README.md b/src/components/GanttChart/README.md deleted file mode 100644 index 405c914..0000000 --- a/src/components/GanttChart/README.md +++ /dev/null @@ -1,84 +0,0 @@ -# Gantt Chart - -A timeline visualization component for Retool. Displays tasks as horizontal bars across a date range with support for grouping, progress tracking, and day/week/month zoom levels. - -## Features - -- Day, week, and month view modes -- Progress bars per task (0–100%) -- Group/phase labeling -- Today marker -- Click-to-select task (exposes `selectedTask` model value) -- `taskClick` event for triggering Retool queries -- Custom per-task colors or auto-assigned palette -- Horizontal scroll for long timelines -- Dark theme, styled to match Retool - -## Installation - -1. Clone the [custom-component-collection-template](https://github.com/tryretool/custom-component-collection-template) -2. Copy this folder into `src/components/GanttChart/` -3. Add the export to `src/index.tsx`: - ```ts - export { GanttChart } from "./components/GanttChart"; - ``` -4. Run `npx retool-ccl dev` to preview in Retool - -## Model inputs - -| Property | Type | Description | -|------------|----------|-------------| -| `tasks` | array | Array of task objects (see schema below) | -| `viewMode` | string | `"day"`, `"week"` (default), or `"month"` | - -### Task schema - -```json -{ - "id": "task-1", - "name": "Design mockups", - "start": "2024-01-01", - "end": "2024-01-14", - "progress": 75, - "group": "Phase 1", - "color": "#6BBAFF" -} -``` - -| Field | Type | Required | Description | -|------------|--------|----------|-------------| -| `id` | string | yes | Unique identifier | -| `name` | string | yes | Task label shown in the sidebar | -| `start` | string | yes | Start date `YYYY-MM-DD` | -| `end` | string | yes | End date `YYYY-MM-DD` | -| `progress` | number | no | Completion percentage 0–100 | -| `group` | string | no | Groups tasks under a phase label | -| `color` | string | no | Hex color for the bar (auto-assigned if omitted) | - -## Model outputs - -| Property | Type | Description | -|----------------|--------|-------------| -| `selectedTask` | object | The task object the user last clicked | - -## Events - -| Event | When it fires | -|-------------|---------------| -| `taskClick` | User clicks a task bar | - -## Example query binding - -Bind `{{ yourQuery.data }}` to the `tasks` model input directly. The component expects the same column names as the schema above — rename columns in your query if needed. - -```sql -SELECT - id::text, - task_name AS name, - start_date::text AS start, - end_date::text AS end, - progress, - phase AS group -FROM project_tasks -ORDER BY start_date; -``` diff --git a/src/components/GanttChart/index.tsx b/src/components/GanttChart/index.tsx deleted file mode 100644 index c769c67..0000000 --- a/src/components/GanttChart/index.tsx +++ /dev/null @@ -1 +0,0 @@ -export { GanttChart } from "./GanttChart"; diff --git a/src/components/HelloWorld/index.tsx b/src/components/HelloWorld/index.tsx deleted file mode 100644 index cd3d3c8..0000000 --- a/src/components/HelloWorld/index.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import React from 'react' -import { type FC } from 'react' -import { Retool } from '@tryretool/custom-component-support' - -export const HelloWorld: FC = () => { - const [name, _setName] = Retool.useStateString({ - name: 'name' - }) - - return ( -
-
Hello {name}!
-
- ) -} diff --git a/src/components/KanbanBoard/KanbanBoard.module.css b/src/components/KanbanBoard/KanbanBoard.module.css deleted file mode 100644 index 07b8e58..0000000 --- a/src/components/KanbanBoard/KanbanBoard.module.css +++ /dev/null @@ -1,520 +0,0 @@ -.root { - position: relative; - width: 100%; - height: 100%; - min-height: 300px; - background: #0D0F12; - border-radius: 10px; - overflow: hidden; - font-family: Inter, system-ui, sans-serif; - color: #E2E8F0; -} - -/* ── Board layout ── */ - -.board { - display: flex; - gap: 12px; - padding: 16px; - height: 100%; - overflow-x: auto; - overflow-y: hidden; - align-items: flex-start; - box-sizing: border-box; - scrollbar-width: thin; - scrollbar-color: rgba(155, 114, 207, 0.3) transparent; -} - -.board::-webkit-scrollbar { - height: 6px; -} - -.board::-webkit-scrollbar-track { - background: transparent; -} - -.board::-webkit-scrollbar-thumb { - background: rgba(155, 114, 207, 0.3); - border-radius: 3px; -} - -/* ── Column ── */ - -.column { - flex-shrink: 0; - width: 272px; - display: flex; - flex-direction: column; - background: #111318; - border-radius: 8px; - max-height: calc(100% - 2px); - border: 1.5px solid transparent; - transition: border-color 0.15s, background 0.15s; -} - -.columnOver { - background: #14171f; - border-color: rgba(155, 114, 207, 0.45); -} - -.columnHeader { - display: flex; - align-items: center; - gap: 8px; - padding: 12px 14px 11px; - border-bottom: 1px solid rgba(255, 255, 255, 0.06); - flex-shrink: 0; -} - -.columnAccent { - width: 9px; - height: 9px; - border-radius: 50%; - flex-shrink: 0; -} - -.columnName { - flex: 1; - font-size: 11px; - font-weight: 700; - letter-spacing: 0.07em; - text-transform: uppercase; - color: #94A3B8; -} - -.columnCount { - font-size: 12px; - font-weight: 700; - min-width: 18px; - text-align: right; -} - -.columnCountLimit { - color: #EF4444 !important; -} - -/* ── Card list ── */ - -.cardList { - flex: 1; - overflow-y: auto; - padding: 10px; - display: flex; - flex-direction: column; - gap: 7px; - min-height: 60px; - scrollbar-width: thin; - scrollbar-color: rgba(155, 114, 207, 0.15) transparent; -} - -.cardList::-webkit-scrollbar { - width: 4px; -} - -.cardList::-webkit-scrollbar-track { - background: transparent; -} - -.cardList::-webkit-scrollbar-thumb { - background: rgba(155, 114, 207, 0.2); - border-radius: 2px; -} - -/* ── Card ── */ - -.card { - background: #1A1D24; - border-radius: 6px; - padding: 11px 12px; - cursor: pointer; - user-select: none; - border: 1px solid rgba(255, 255, 255, 0.06); - /* border-left is set inline to show priority color */ - transition: background 0.12s, box-shadow 0.12s, transform 0.1s; -} - -.card:hover { - background: #1f2330; - box-shadow: 0 3px 10px rgba(0, 0, 0, 0.35); -} - -.card:active { - transform: scale(0.985); -} - -.cardDragging { - opacity: 0.35; - transform: scale(0.97); - box-shadow: none; -} - -/* ── Drop indicator ── */ - -.dropIndicator { - height: 3px; - border-radius: 2px; - background: rgba(155, 114, 207, 0.65); - flex-shrink: 0; -} - -/* ── Card top row ── */ - -.cardTop { - display: flex; - align-items: flex-start; - gap: 8px; -} - -.cardMain { - flex: 1; - min-width: 0; -} - -/* ── Drag handle ── */ - -.dragHandle { - flex-shrink: 0; - color: #334155; - cursor: grab; - padding: 2px 2px; - border-radius: 4px; - display: flex; - align-items: center; - opacity: 0; - transition: opacity 0.15s, color 0.15s; - margin-top: 1px; -} - -.card:hover .dragHandle { - opacity: 1; -} - -.dragHandle:hover { - color: #64748B; -} - -.dragHandle:active { - cursor: grabbing; -} - -/* ── Priority badge ── */ - -.priorityBadge { - font-size: 9.5px; - font-weight: 700; - letter-spacing: 0.06em; - text-transform: uppercase; - margin-bottom: 5px; - opacity: 0.9; -} - -/* ── Card content ── */ - -.cardTitle { - font-size: 13px; - font-weight: 500; - color: #CBD5E1; - line-height: 1.4; - margin-bottom: 3px; -} - -.cardDescription { - font-size: 12px; - color: #64748B; - line-height: 1.45; - display: -webkit-box; - -webkit-line-clamp: 2; - -webkit-box-orient: vertical; - overflow: hidden; - margin-top: 4px; - margin-bottom: 7px; -} - -/* ── Tags ── */ - -.tagList { - display: flex; - flex-wrap: wrap; - gap: 4px; - margin-top: 7px; - margin-bottom: 7px; -} - -.tag { - background: rgba(155, 114, 207, 0.13); - color: #9B72CF; - font-size: 10px; - font-weight: 500; - padding: 2px 7px; - border-radius: 4px; - white-space: nowrap; -} - -/* ── Card footer ── */ - -.cardFooter { - display: flex; - align-items: center; - justify-content: space-between; - margin-top: 9px; - gap: 8px; -} - -.dueDate { - font-size: 11px; - color: #475569; - font-weight: 500; -} - -.dueDateOverdue { - color: #EF4444; - font-weight: 600; -} - -/* ── Avatars ── */ - -.avatarList { - display: flex; - flex-direction: row; - flex-shrink: 0; -} - -.avatar { - width: 22px; - height: 22px; - border-radius: 50%; - display: flex; - align-items: center; - justify-content: center; - font-weight: 700; - color: white; - border: 1.5px solid #1A1D24; - margin-left: -5px; - flex-shrink: 0; - overflow: hidden; -} - -.avatar:first-child { - margin-left: 0; -} - -.avatarImg { - width: 100%; - height: 100%; - object-fit: cover; - display: block; -} - -/* ── Empty column state ── */ - -.emptyCol { - font-size: 12px; - color: #334155; - text-align: center; - padding: 20px 0; - font-style: italic; -} - -/* ── Card detail modal ── */ - -.modalBackdrop { - position: absolute; - inset: 0; - background: rgba(0, 0, 0, 0.7); - display: flex; - align-items: center; - justify-content: center; - z-index: 1000; -} - -.modal { - background: #161922; - border: 1px solid rgba(255, 255, 255, 0.09); - border-radius: 12px; - width: 480px; - max-width: calc(100vw - 32px); - max-height: calc(100vh - 48px); - overflow-y: auto; - padding: 24px; - box-shadow: 0 24px 64px rgba(0, 0, 0, 0.6); - scrollbar-width: thin; - scrollbar-color: rgba(155, 114, 207, 0.2) transparent; -} - -.modal::-webkit-scrollbar { - width: 4px; -} - -.modal::-webkit-scrollbar-thumb { - background: rgba(155, 114, 207, 0.2); - border-radius: 2px; -} - -.modalHeader { - display: flex; - align-items: center; - justify-content: space-between; - margin-bottom: 14px; - gap: 8px; -} - -.modalHeaderLeft { - display: flex; - align-items: center; - gap: 8px; - flex-wrap: wrap; -} - -.modalPriorityBadge { - font-size: 10px; - font-weight: 700; - letter-spacing: 0.07em; - text-transform: uppercase; - border: 1px solid; - border-radius: 4px; - padding: 2px 8px; - opacity: 0.9; -} - -.modalColumnBadge { - display: flex; - align-items: center; - gap: 5px; - font-size: 11px; - font-weight: 600; - letter-spacing: 0.04em; -} - -.modalColumnDot { - width: 7px; - height: 7px; - border-radius: 50%; - flex-shrink: 0; -} - -.modalClose { - background: none; - border: none; - color: #475569; - font-size: 14px; - cursor: pointer; - padding: 4px 8px; - border-radius: 6px; - line-height: 1; - transition: background 0.12s, color 0.12s; - flex-shrink: 0; -} - -.modalClose:hover { - background: rgba(255, 255, 255, 0.07); - color: #CBD5E1; -} - -.modalTitle { - font-size: 18px; - font-weight: 600; - color: #F1F5F9; - margin: 0 0 20px; - line-height: 1.35; -} - -.modalSection { - margin-bottom: 20px; -} - -.modalSectionLabel { - font-size: 10px; - font-weight: 700; - letter-spacing: 0.08em; - text-transform: uppercase; - color: #475569; - margin-bottom: 8px; -} - -.modalDescription { - font-size: 13px; - color: #94A3B8; - line-height: 1.6; - margin: 0; - white-space: pre-wrap; -} - -.modalDescriptionEmpty { - font-size: 13px; - color: #334155; - font-style: italic; - margin: 0 0 20px; -} - -.modalDueDate { - font-size: 13px; - color: #94A3B8; - font-weight: 500; -} - -.modalDueDateOverdue { - color: #EF4444; - font-weight: 600; -} - -/* Assignees in modal */ - -.modalAssigneeList { - display: flex; - flex-direction: column; - gap: 10px; -} - -.modalAssigneeRow { - display: flex; - align-items: center; - gap: 10px; -} - -.modalAssigneeName { - font-size: 13px; - color: #CBD5E1; - font-weight: 500; -} - -/* Move to buttons */ - -.modalMoveButtons { - display: flex; - flex-wrap: wrap; - gap: 8px; -} - -.moveBtn { - display: flex; - align-items: center; - gap: 6px; - background: #1A1D24; - border: 1px solid rgba(255, 255, 255, 0.08); - border-radius: 6px; - color: #64748B; - font-size: 12px; - font-weight: 500; - padding: 6px 12px; - cursor: pointer; - transition: background 0.12s, border-color 0.12s, color 0.12s; - font-family: inherit; -} - -.moveBtn:hover:not(:disabled) { - background: #1f2330; - border-color: rgba(255, 255, 255, 0.15); - color: #CBD5E1; -} - -.moveBtnActive { - background: rgba(155, 114, 207, 0.08); - cursor: default; -} - -.moveBtnDot { - width: 7px; - height: 7px; - border-radius: 50%; - flex-shrink: 0; -} diff --git a/src/components/KanbanBoard/KanbanBoard.tsx b/src/components/KanbanBoard/KanbanBoard.tsx deleted file mode 100644 index df9e411..0000000 --- a/src/components/KanbanBoard/KanbanBoard.tsx +++ /dev/null @@ -1,639 +0,0 @@ -import React, { FC, useState, useMemo, useCallback, useRef, useEffect } from 'react' -import { Retool } from '@tryretool/custom-component-support' -import styles from './KanbanBoard.module.css' - -/* ── Types ── */ - -type Priority = 'low' | 'medium' | 'high' | 'critical' - -type CardAssignee = { - id?: string - name?: string - color?: string - avatar?: string -} - -type KanbanCard = { - id: string - title: string - description?: string - column: string - priority?: Priority - assignees?: CardAssignee[] - tags?: string[] - dueDate?: string -} - -type KanbanColumn = { - id: string - name: string - color?: string - limit?: number -} - -type RetoolUser = { - id: string - firstName?: string - lastName?: string - email?: string - profilePhotoUrl?: string -} - -type ResolvedUser = { - name: string - color: string - photoUrl?: string -} - -/* ── Constants ── */ - -const PRIORITY_COLORS: Record = { - low: '#64748B', - medium: '#F59E0B', - high: '#EF4444', - critical: '#DC2626', -} - -const PRIORITY_LABELS: Record = { - low: 'Low', - medium: 'Medium', - high: 'High', - critical: 'Critical', -} - -const DEFAULT_COLUMNS: KanbanColumn[] = [ - { id: 'todo', name: 'To Do', color: '#64748B' }, - { id: 'in_progress', name: 'In Progress', color: '#3B82F6' }, - { id: 'in_review', name: 'In Review', color: '#F59E0B' }, - { id: 'done', name: 'Done', color: '#10B981' }, -] - -const AVATAR_COLORS = [ - '#9B72CF', '#3B82F6', '#10B981', '#F59E0B', - '#EF4444', '#EC4899', '#8B5CF6', '#06B6D4', -] - -const MAX_AVATARS_CARD = 3 - -/* ── Helpers ── */ - -function hashColor(str: string): string { - let h = 0 - for (let i = 0; i < str.length; i++) h = (h * 31 + str.charCodeAt(i)) & 0xffffffff - return AVATAR_COLORS[Math.abs(h) % AVATAR_COLORS.length] -} - -function getInitials(name: string): string { - const parts = name.trim().split(/\s+/) - return parts.length >= 2 - ? (parts[0][0] + parts[parts.length - 1][0]).toUpperCase() - : name.slice(0, 2).toUpperCase() -} - -function isOverdue(dateStr: string): boolean { - const today = new Date() - today.setHours(0, 0, 0, 0) - return new Date(dateStr) < today -} - -function formatDate(dateStr: string): string { - const d = new Date(dateStr) - return d.toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' }) -} - -function formatDateShort(dateStr: string): string { - const d = new Date(dateStr) - return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) -} - -/* ── Avatar ── */ - -const Avatar: React.FC<{ - user: ResolvedUser - size?: number - borderColor?: string -}> = ({ user, size = 22, borderColor = '#1A1D24' }) => { - const [imgFailed, setImgFailed] = useState(false) - - if (user.photoUrl && !imgFailed) { - return ( -
- {user.name} setImgFailed(true)} - /> -
- ) - } - - return ( -
- {getInitials(user.name)} -
- ) -} - -/* ── Card Detail Modal ── */ - -const CardModal: React.FC<{ - card: KanbanCard - currentColumnId: string - columns: KanbanColumn[] - resolveAssignee: (a: CardAssignee) => ResolvedUser - onClose: () => void - onMoveTo: (colId: string) => void -}> = ({ card, currentColumnId, columns, resolveAssignee, onClose, onMoveTo }) => { - const priorityColor = card.priority ? PRIORITY_COLORS[card.priority] : null - const currentCol = columns.find(c => c.id === currentColumnId) - const overdue = card.dueDate && isOverdue(card.dueDate) - - useEffect(() => { - const handler = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose() } - window.addEventListener('keydown', handler) - return () => window.removeEventListener('keydown', handler) - }, [onClose]) - - return ( -
-
e.stopPropagation()}> - -
-
- {card.priority && ( - - {PRIORITY_LABELS[card.priority] || card.priority} - - )} - {currentCol && ( - - - {currentCol.name} - - )} -
- -
- -

{card.title}

- - {card.description ? ( -
-
Description
-

{card.description}

-
- ) : ( -

No description provided.

- )} - - {card.dueDate && ( -
-
Due date
-
- {overdue ? '⚠ Overdue · ' : ''}{formatDate(card.dueDate)} -
-
- )} - - {card.tags && card.tags.length > 0 && ( -
-
Tags
-
- {card.tags.map(tag => ( - {tag} - ))} -
-
- )} - - {card.assignees && card.assignees.length > 0 && ( -
-
Assignees
-
- {card.assignees.map((a, i) => { - const user = resolveAssignee(a) - return ( -
- - {user.name} -
- ) - })} -
-
- )} - -
-
Move to
-
- {columns.map(col => { - const isActive = col.id === currentColumnId - return ( - - ) - })} -
-
- -
-
- ) -} - -/* ── Main Component ── */ - -export const KanbanBoard: FC = () => { - Retool.useComponentSettings({ defaultWidth: 960, defaultHeight: 600 }) - - const [rawCards] = Retool.useStateArray({ - name: 'cards', - label: 'Cards', - description: 'Array of cards: { id, title, description?, column, priority?, assignees?, tags?, dueDate? }', - defaultValue: [], - }) - - const [rawColumns] = Retool.useStateArray({ - name: 'columns', - label: 'Columns', - description: 'Array of columns: { id, name, color?, limit? }. Leave empty to use default columns.', - defaultValue: [], - }) - - const [rawUsers] = Retool.useStateArray({ - name: 'users', - label: 'Users', - description: 'Retool users for avatar photos: { id, firstName, lastName, profilePhotoUrl }', - defaultValue: [], - }) - - const [allowDrag] = Retool.useStateBoolean({ - name: 'allowDrag', - label: 'Allow drag & drop', - defaultValue: true, - }) - - const [, setSelectedCard] = Retool.useStateObject({ - name: 'selectedCard', - label: 'Selected Card', - description: 'Last clicked card object', - defaultValue: null, - }) - - const [, setMovedCard] = Retool.useStateObject({ - name: 'movedCard', - label: 'Moved Card', - description: 'Most recently moved card, with its new column id', - defaultValue: null, - }) - - const onCardClick = Retool.useEventCallback({ name: 'cardClick' }) - const onCardMoved = Retool.useEventCallback({ name: 'cardMoved' }) - - /* ── Data ── */ - - const columns = useMemo(() => { - const cols = Array.isArray(rawColumns) ? (rawColumns as KanbanColumn[]) : [] - return cols.length > 0 ? cols : DEFAULT_COLUMNS - }, [rawColumns]) - - const cards = useMemo(() => { - return Array.isArray(rawCards) ? (rawCards as KanbanCard[]) : [] - }, [rawCards]) - - const cardMap = useMemo(() => { - const m = new Map() - cards.forEach(c => m.set(String(c.id), c)) - return m - }, [cards]) - - const userMap = useMemo(() => { - const m = new Map() - if (Array.isArray(rawUsers)) { - ;(rawUsers as RetoolUser[]).forEach(u => { if (u.id) m.set(String(u.id), u) }) - } - return m - }, [rawUsers]) - - /* ── Card order ── */ - - const buildOrder = useCallback((cs: KanbanCard[], cols: KanbanColumn[]) => { - const order: Record = {} - cols.forEach(c => { order[c.id] = [] }) - cs.forEach(card => { - const colId = String(card.column) - if (!order[colId]) order[colId] = [] - order[colId].push(String(card.id)) - }) - return order - }, []) - - const [cardOrder, setCardOrder] = useState>(() => - buildOrder(cards, columns) - ) - - const [localColMap, setLocalColMap] = useState>(() => { - const m: Record = {} - cards.forEach(c => { m[String(c.id)] = String(c.column) }) - return m - }) - - const prevSigRef = useRef('') - useEffect(() => { - const sig = cards.map(c => `${c.id}:${c.column}`).join(',') - if (sig !== prevSigRef.current) { - prevSigRef.current = sig - setCardOrder(buildOrder(cards, columns)) - const m: Record = {} - cards.forEach(c => { m[String(c.id)] = String(c.column) }) - setLocalColMap(m) - } - }, [cards, columns, buildOrder]) - - /* ── Modal state ── */ - - const [expandedEntry, setExpandedEntry] = useState<{ card: KanbanCard; colId: string } | null>(null) - - /* ── Drag state ── */ - - const [draggingId, setDraggingId] = useState(null) - const [dropColId, setDropColId] = useState(null) - const [dropBeforeId, setDropBeforeId] = useState(null) - const dragRef = useRef<{ cardId: string; sourceCol: string } | null>(null) - - /* ── Assignee resolver ── */ - - const resolveAssignee = useCallback((a: CardAssignee): ResolvedUser => { - const retoolUser = a.id ? userMap.get(String(a.id)) : undefined - const name = retoolUser - ? [retoolUser.firstName, retoolUser.lastName].filter(Boolean).join(' ') || retoolUser.email || String(a.id) - : a.name || String(a.id || 'User') - return { - name, - color: a.color || hashColor(name), - photoUrl: retoolUser?.profilePhotoUrl || a.avatar, - } - }, [userMap]) - - /* ── Move card ── */ - - const moveCard = useCallback((cardId: string, sourceCol: string, targetCol: string, beforeId: string | null = null) => { - setCardOrder(prev => { - const next: Record = {} - Object.keys(prev).forEach(k => { next[k] = [...prev[k]] }) - next[sourceCol] = (next[sourceCol] || []).filter(id => id !== cardId) - const targetList = next[targetCol] || [] - if (beforeId && targetList.includes(beforeId)) { - targetList.splice(targetList.indexOf(beforeId), 0, cardId) - } else { - targetList.push(cardId) - } - next[targetCol] = targetList - return next - }) - setLocalColMap(prev => ({ ...prev, [cardId]: targetCol })) - const card = cardMap.get(cardId) - if (card) { - setMovedCard({ ...card, column: targetCol }) - onCardMoved() - } - }, [cardMap, setMovedCard, onCardMoved]) - - /* ── Drag handlers (on the drag handle, not the card) ── */ - - const handleDragStart = useCallback((e: React.DragEvent, cardId: string, sourceCol: string) => { - e.dataTransfer.effectAllowed = 'move' - // Show the parent card as the drag ghost - const cardEl = (e.currentTarget as HTMLElement).closest('[data-card]') as HTMLElement | null - if (cardEl) e.dataTransfer.setDragImage(cardEl, 20, 20) - dragRef.current = { cardId, sourceCol } - setTimeout(() => setDraggingId(cardId), 0) - }, []) - - const handleDragEnd = useCallback(() => { - setDraggingId(null) - setDropColId(null) - setDropBeforeId(null) - dragRef.current = null - }, []) - - const handleColDragOver = useCallback((e: React.DragEvent, colId: string) => { - e.preventDefault() - e.dataTransfer.dropEffect = 'move' - setDropColId(colId) - setDropBeforeId(null) - }, []) - - const handleCardDragOver = useCallback((e: React.DragEvent, colId: string, cardId: string) => { - e.preventDefault() - e.stopPropagation() - e.dataTransfer.dropEffect = 'move' - setDropColId(colId) - setDropBeforeId(cardId) - }, []) - - const handleDrop = useCallback((e: React.DragEvent, targetCol: string) => { - e.preventDefault() - const data = dragRef.current - if (!data) return - const { cardId, sourceCol } = data - const before = dropBeforeId - moveCard(cardId, sourceCol, targetCol, before) - setDraggingId(null) - setDropColId(null) - setDropBeforeId(null) - dragRef.current = null - }, [dropBeforeId, moveCard]) - - /* ── Card click (plain onClick — no draggable on the card itself) ── */ - - const handleCardClick = useCallback((card: KanbanCard, colId: string) => { - setExpandedEntry({ card, colId }) - setSelectedCard(card) - onCardClick() - }, [setSelectedCard, onCardClick]) - - /* ── Modal move ── */ - - const handleModalMove = useCallback((targetColId: string) => { - if (!expandedEntry) return - const cardId = String(expandedEntry.card.id) - const sourceCol = localColMap[cardId] || expandedEntry.colId - if (sourceCol === targetColId) return - moveCard(cardId, sourceCol, targetColId, null) - setExpandedEntry(prev => prev ? { ...prev, colId: targetColId } : null) - }, [expandedEntry, localColMap, moveCard]) - - /* ── Render ── */ - - return ( -
-
- {columns.map(col => { - const colColor = col.color || '#64748B' - const colCardIds = cardOrder[col.id] || [] - const colCards = colCardIds - .map(id => cardMap.get(id)) - .filter((c): c is KanbanCard => c != null) - const isOver = dropColId === col.id - const atLimit = col.limit != null && colCards.length >= col.limit - - return ( -
handleColDragOver(e, col.id)} - onDrop={e => handleDrop(e, col.id)} - > -
-
- {col.name} - - {colCards.length}{col.limit != null ? `/${col.limit}` : ''} - -
- -
- {colCards.map(card => { - const isDragging = draggingId === String(card.id) - const isDropTarget = dropColId === col.id && dropBeforeId === String(card.id) - const visibleAssignees = (card.assignees || []).slice(0, MAX_AVATARS_CARD) - const extraAssignees = (card.assignees?.length || 0) - MAX_AVATARS_CARD - const overdue = card.dueDate && isOverdue(card.dueDate) - const priorityColor = card.priority ? PRIORITY_COLORS[card.priority] : 'transparent' - - return ( - - {isDropTarget &&
} - - {/* Card: NOT draggable — clean onClick works perfectly */} -
handleCardClick(card, col.id)} - onDragOver={e => handleCardDragOver(e, col.id, String(card.id))} - style={{ borderLeft: `3px solid ${priorityColor}` }} - > -
-
- {card.priority && ( -
- {PRIORITY_LABELS[card.priority] || card.priority} -
- )} -
{card.title}
- {card.description && ( -
{card.description}
- )} - {card.tags && card.tags.length > 0 && ( -
- {card.tags.map(tag => ( - {tag} - ))} -
- )} -
- - {/* Drag handle — only this is draggable */} - {allowDrag && ( -
handleDragStart(e, String(card.id), col.id)} - onDragEnd={handleDragEnd} - onClick={e => e.stopPropagation()} - title="Drag to move" - > - -
- )} -
- -
- {card.dueDate ? ( - - {overdue ? '⚠ ' : ''}{formatDateShort(card.dueDate)} - - ) : } - - {visibleAssignees.length > 0 && ( -
- {visibleAssignees.map((a, i) => ( - - ))} - {extraAssignees > 0 && ( -
- +{extraAssignees} -
- )} -
- )} -
-
- - ) - })} - - {dropColId === col.id && !dropBeforeId && ( -
- )} - - {colCards.length === 0 && dropColId !== col.id && ( -
Drop cards here
- )} -
-
- ) - })} -
- - {expandedEntry && ( - setExpandedEntry(null)} - onMoveTo={handleModalMove} - /> - )} -
- ) -} - -/* ── Drag handle icon ── */ - -const DragIcon: React.FC = () => ( - - - - - - - - -) diff --git a/src/components/KanbanBoard/index.tsx b/src/components/KanbanBoard/index.tsx deleted file mode 100644 index 6872be8..0000000 --- a/src/components/KanbanBoard/index.tsx +++ /dev/null @@ -1 +0,0 @@ -export { KanbanBoard } from './KanbanBoard' diff --git a/src/components/PasswordStrengthAnalyzer/index.tsx b/src/components/PasswordStrengthAnalyzer/index.tsx deleted file mode 100644 index 395cd8c..0000000 --- a/src/components/PasswordStrengthAnalyzer/index.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import React, { useEffect } from 'react' -import { type FC } from 'react' -import { Retool } from '@tryretool/custom-component-support' -import { calculateStrength } from './utils' - -export const PasswordStrengthAnalyzer: FC = () => { - const [password, _setPassword] = Retool.useStateString({ - name: 'password' - }) - - const [score, setScore] = Retool.useStateNumber({ - name: 'score', - initialValue: 0, - inspector: 'hidden' - }) - - useEffect(() => { - const newScore = calculateStrength(password) - if (newScore !== score) { - setScore(newScore) - } - }, [password, score, setScore]) - - return ( -
-

Password Strength Analyzer

-

- This demo shows how to pass state back and forth between a retool app and a custom component. - Please don't use this component in production. -

-

-

Input Password: {'*'.repeat(password?.length || 0)}
-
Strength Score: {score ?? 0}/4
-

-
- ) -} diff --git a/src/components/PasswordStrengthAnalyzer/utils.test.ts b/src/components/PasswordStrengthAnalyzer/utils.test.ts deleted file mode 100644 index bbedcfc..0000000 --- a/src/components/PasswordStrengthAnalyzer/utils.test.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { describe, it, expect } from 'vitest' -import { calculateStrength } from './utils' - -describe('calculateStrength', () => { - it('returns 0 for empty password', () => { - expect(calculateStrength('')).toBe(0) - }) - - it('calculates strength correctly', () => { - expect(calculateStrength('password')).toBe(0) // width < 8 no spec chars - expect(calculateStrength('longpassword')).toBe(1) // length > 8 - expect(calculateStrength('Longpassword')).toBe(2) // length > 8 + uppercase - expect(calculateStrength('Longpassword1')).toBe(3) // length > 8 + uppercase + number - expect(calculateStrength('Longpassword1!')).toBe(4) // length > 8 + uppercase + number + special char - }) -}) diff --git a/src/components/PasswordStrengthAnalyzer/utils.ts b/src/components/PasswordStrengthAnalyzer/utils.ts deleted file mode 100644 index 69dd9b9..0000000 --- a/src/components/PasswordStrengthAnalyzer/utils.ts +++ /dev/null @@ -1,11 +0,0 @@ -export const calculateStrength = (password: string): number => { - let score = 0 - if (!password) return 0 - - if (password.length > 8) score += 1 - if (/[A-Z]/.test(password)) score += 1 - if (/[0-9]/.test(password)) score += 1 - if (/[^A-Za-z0-9]/.test(password)) score += 1 - - return score -} diff --git a/src/components/QRCodeGenerator/QRCodeGenerator.module.css b/src/components/QRCodeGenerator/QRCodeGenerator.module.css deleted file mode 100644 index 4d106a9..0000000 --- a/src/components/QRCodeGenerator/QRCodeGenerator.module.css +++ /dev/null @@ -1,112 +0,0 @@ -.root { - width: 100%; - height: 100%; - display: flex; - align-items: center; - justify-content: center; - background: #0D0F12; - border-radius: 10px; - font-family: Inter, system-ui, sans-serif; - padding: 20px; - box-sizing: border-box; -} - -/* ── Card ── */ - -.card { - display: flex; - flex-direction: column; - align-items: center; - gap: 14px; - width: 100%; -} - -/* ── QR wrap ── */ - -.qrWrap { - border-radius: 10px; - overflow: hidden; - box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.06), 0 8px 32px rgba(0, 0, 0, 0.5); - flex-shrink: 0; - line-height: 0; -} - -/* ── Title ── */ - -.title { - font-size: 14px; - font-weight: 600; - color: #E2E8F0; - text-align: center; - letter-spacing: 0.01em; -} - -/* ── Value preview ── */ - -.valuePreview { - font-size: 11px; - color: #475569; - text-align: center; - max-width: 260px; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - font-family: 'Menlo', 'Monaco', 'Consolas', monospace; -} - -/* ── Actions ── */ - -.actions { - display: flex; - gap: 8px; -} - -.btn { - display: flex; - align-items: center; - gap: 6px; - background: #1A1D24; - border: 1px solid rgba(255, 255, 255, 0.09); - border-radius: 7px; - color: #94A3B8; - font-size: 12px; - font-weight: 500; - padding: 7px 13px; - cursor: pointer; - transition: background 0.12s, color 0.12s, border-color 0.12s; - font-family: inherit; - white-space: nowrap; - text-decoration: none; -} - -.btn[aria-disabled='true'] { - opacity: 0.4; - pointer-events: none; -} - -.btn:hover { - background: #1f2330; - color: #CBD5E1; - border-color: rgba(255, 255, 255, 0.14); -} - -.btn:active { - transform: scale(0.97); -} - -.btnSuccess { - color: #10B981; - border-color: rgba(16, 185, 129, 0.3); - background: rgba(16, 185, 129, 0.08); -} - -.btnSuccess:hover { - background: rgba(16, 185, 129, 0.12); - color: #10B981; -} - -.btnError { - color: #EF4444; - border-color: rgba(239, 68, 68, 0.3); - background: rgba(239, 68, 68, 0.08); -} diff --git a/src/components/QRCodeGenerator/QRCodeGenerator.tsx b/src/components/QRCodeGenerator/QRCodeGenerator.tsx deleted file mode 100644 index e20e7d2..0000000 --- a/src/components/QRCodeGenerator/QRCodeGenerator.tsx +++ /dev/null @@ -1,222 +0,0 @@ -import React, { FC, useRef, useState, useEffect } from 'react' -import { Retool } from '@tryretool/custom-component-support' -import { QRCodeCanvas } from 'qrcode.react' -import styles from './QRCodeGenerator.module.css' - -type ErrorLevel = 'L' | 'M' | 'Q' | 'H' - -/* ── Icons ── */ - -const DownloadIcon = () => ( - - - -) - -const CopyIcon = () => ( - - - - -) - -const CheckIcon = () => ( - - - -) - -/* ── Component ── */ - -export const QRCodeGenerator: FC = () => { - Retool.useComponentSettings({ defaultWidth: 280, defaultHeight: 380 }) - - /* ── Inputs ── */ - - const [value] = Retool.useStateString({ - name: 'value', - label: 'Value', - description: 'Text or URL to encode in the QR code', - initialValue: 'https://retool.com', - }) - - const [size] = Retool.useStateNumber({ - name: 'size', - label: 'Size (px)', - description: 'Width and height of the QR code in pixels', - initialValue: 200, - }) - - const [fgColor] = Retool.useStateString({ - name: 'fgColor', - label: 'Foreground color', - description: 'QR code dot color (hex)', - initialValue: '#000000', - }) - - const [bgColor] = Retool.useStateString({ - name: 'bgColor', - label: 'Background color', - description: 'QR code background color (hex)', - initialValue: '#FFFFFF', - }) - - const [errorLevel] = Retool.useStateString({ - name: 'errorLevel', - label: 'Error correction', - description: 'L = 7%, M = 15%, Q = 25%, H = 30%. Use H when embedding a logo.', - initialValue: 'M', - }) - - const [logoUrl] = Retool.useStateString({ - name: 'logoUrl', - label: 'Logo URL', - description: 'Optional image URL to embed in the center of the QR code', - initialValue: '', - }) - - const [logoSize] = Retool.useStateNumber({ - name: 'logoSize', - label: 'Logo size (%)', - description: 'Logo width as a percentage of the QR code size (5–30). Ignored if no logo URL.', - initialValue: 20, - }) - - const [title] = Retool.useStateString({ - name: 'title', - label: 'Title', - description: 'Label shown below the QR code', - initialValue: '', - }) - - /* ── Outputs ── */ - - const [, setDataUrl] = Retool.useStateString({ - name: 'dataUrl', - label: 'Data URL', - description: 'QR code as a PNG data URL — use in Image components or store in a DB column', - initialValue: '', - }) - - /* ── Events ── */ - - const onDownload = Retool.useEventCallback({ name: 'download' }) - const onCopy = Retool.useEventCallback({ name: 'copy' }) - - /* ── Derived values ── */ - - const safeValue = value?.trim() || 'https://retool.com' - const safeSize = Math.max(64, Math.min(512, size || 200)) - const safeErrorLevel = (['L', 'M', 'Q', 'H'].includes((errorLevel || '').toUpperCase()) - ? errorLevel.toUpperCase() - : 'M') as ErrorLevel - const safeLogoSize = Math.max(5, Math.min(30, logoSize || 20)) - const logoPixels = Math.round(safeSize * (safeLogoSize / 100)) - - /* ── Local state ── */ - - const canvasRef = useRef(null) - // localDataUrl drives the download href — updated whenever QR changes - const [localDataUrl, setLocalDataUrl] = useState('') - const [copied, setCopied] = useState(false) - const [copyError, setCopyError] = useState(false) - - const downloadFilename = `qr-${safeValue.slice(0, 40).replace(/[^a-z0-9]/gi, '-')}.png` - - /* ── Sync data URL whenever QR changes ── */ - - useEffect(() => { - const timeout = setTimeout(() => { - const canvas = canvasRef.current?.querySelector('canvas') - if (!canvas) return - try { - const url = canvas.toDataURL('image/png') - setLocalDataUrl(url) - setDataUrl(url) - } catch { - // Cross-origin logo blocks toDataURL — leave previous value - } - }, 120) - return () => clearTimeout(timeout) - }, [safeValue, safeSize, fgColor, bgColor, safeErrorLevel, logoUrl, safeLogoSize, setDataUrl]) - - /* ── Copy value text to clipboard ── */ - // Copying image data requires ClipboardItem which is blocked in iframes. - // Copying the encoded text is more reliable and just as useful. - - const handleCopy = async () => { - try { - await navigator.clipboard.writeText(safeValue) - setCopied(true) - setCopyError(false) - onCopy() - setTimeout(() => setCopied(false), 2000) - } catch { - setCopyError(true) - setTimeout(() => setCopyError(false), 2000) - } - } - - /* ── Logo image settings ── */ - - const imageSettings = logoUrl?.trim() - ? { src: logoUrl.trim(), height: logoPixels, width: logoPixels, excavate: true } - : undefined - - /* ── Render ── */ - - return ( - - ) -} diff --git a/src/components/QRCodeGenerator/README.md b/src/components/QRCodeGenerator/README.md deleted file mode 100644 index 30affa5..0000000 --- a/src/components/QRCodeGenerator/README.md +++ /dev/null @@ -1,77 +0,0 @@ -# QR Code Generator - -![QR Code Generator preview](cover.png) - -A Retool Custom Component that generates styled QR codes with live customization controls built directly into the component. Choose from 6 dot shapes, set border styles, pick colors for shapes and background, embed a logo, and download the result as a PNG — all without leaving the canvas. - -## Features - -- 6 dot shape styles: Square, Dots, Rounded, Soft, Classy, and Classy+ -- Border options: None, Thin, Medium, Thick, Dashed, and Dotted -- Shape and background color pickers built into the component -- Optional logo/image embedded in the center of the QR code -- Configurable error correction level (L / M / Q / H) -- One-click PNG download -- QR image exposed as a base64 Data URL — connect to an Image component or save to a database -- Fires a `download` event on PNG export — wire to any downstream query -- Fully responsive — scales to fit its container without overlapping controls - -## Installation - -1. In your Retool app, open the **Component** panel and add a **Custom Component** -2. Import this component from the repository -3. Set the **Value** field in the inspector to the URL or text you want to encode -4. Optionally set a **Title**, adjust **Size**, or embed a **Logo URL** - -## Properties - -| Property | Type | Description | -|---|---|---| -| `value` | string | Text or URL to encode in the QR code | -| `title` | string | Optional label shown below the QR code | -| `size` | number | Width and height of the QR code in pixels (default 200) | -| `fgColor` | string | Color of the QR code dots/shapes (hex) | -| `bgColor` | string | Color of the light squares inside the QR code (hex) | -| `dotShape` | enumeration | Dot style: `square`, `dots`, `rounded`, `extra-rounded`, `classy`, `classy-rounded` | -| `borderStyle` | enumeration | Border around the QR code: `none`, `thin`, `medium`, `thick`, `dashed`, `dotted` | -| `borderColor` | string | Color of the border (hex) | -| `logoUrl` | string | Optional image URL to embed in the center of the QR code | -| `logoSize` | number | Logo width as a percentage of QR code size (5–30, default 20) | -| `errorLevel` | string | Error correction level: `L` (7%), `M` (15%), `Q` (25%), `H` (30%). Use `H` when embedding a logo | -| `dataUrl` | string | The QR code as a base64 PNG — use with an Image component or to save to a database | - -## Events - -| Event | Description | -|---|---| -| `download` | Fires when the user clicks **Download PNG** | - -## Usage - -### Basic setup - -Set the **Value** inspector field to the URL or text you want to encode. The QR code renders immediately. Use the shape and border selectors inside the component to style it without touching the inspector. - -### Embedding a logo - -Set **Logo URL** to any publicly accessible image URL. Increase **Error correction** to `H` (30%) so the QR remains scannable even with a logo covering part of it. Use **Logo size** to control how much of the QR the logo covers (5–30%). - -### Capturing the QR as an image - -The `dataUrl` output property contains the QR code as a base64-encoded PNG. Connect it to a Retool Image component's **Image source** field to display it elsewhere, or pass it to a query to save it to a database or storage bucket. - -### Wiring the download event - -Connect the `download` event to a query if you want to log, track, or trigger an action whenever a user exports the QR code. - -## Ideal Use Cases - -- Marketing and campaign dashboards — generate QR codes for URLs, coupons, or landing pages -- Product and inventory management — encode SKUs, barcodes, or asset IDs -- Event management — generate QR codes for tickets or check-in links -- Customer-facing tools — let users create branded QR codes with a logo -- Any workflow that needs a scannable code generated on the fly - -## Author - -Created by [@angelikretool](https://github.com/angelikretool) for the Retool community. diff --git a/src/components/QRCodeGenerator/index.tsx b/src/components/QRCodeGenerator/index.tsx deleted file mode 100644 index d09aa9f..0000000 --- a/src/components/QRCodeGenerator/index.tsx +++ /dev/null @@ -1 +0,0 @@ -export { QRCodeGenerator } from './QRCodeGenerator' diff --git a/src/index.tsx b/src/index.tsx index 20ec2b1..d28c07a 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,5 +1 @@ -export { HelloWorld } from './components/HelloWorld' -export { PasswordStrengthAnalyzer } from './components/PasswordStrengthAnalyzer' -export { GanttChart } from './components/GanttChart' -export { KanbanBoard } from './components/KanbanBoard' -export { QRCodeGenerator } from './components/QRCodeGenerator' +export { AdjustableProgressBar } from './components/AdjustableProgressBar' From c5b6db445a6558e16c4a9b36e207c3333b138546 Mon Sep 17 00:00:00 2001 From: juliomachines Date: Fri, 22 May 2026 14:00:57 -0300 Subject: [PATCH 2/5] fixes --- .../AdjustableProgressBar/index.tsx | 295 +++++++++--------- 1 file changed, 144 insertions(+), 151 deletions(-) diff --git a/src/components/AdjustableProgressBar/index.tsx b/src/components/AdjustableProgressBar/index.tsx index 9f6db19..2d61da6 100644 --- a/src/components/AdjustableProgressBar/index.tsx +++ b/src/components/AdjustableProgressBar/index.tsx @@ -2,165 +2,158 @@ import React, { useState, useRef, useEffect, type FC } from 'react'; import { Retool } from '@tryretool/custom-component-support'; export const AdjustableProgressBar: FC = () => { - const [value, setValue] = Retool.useStateNumber({ - name: 'value', - initialValue: 30 - }); - - const [isDragging, setIsDragging] = useState(false); - const progressBarRef = useRef(null); - - const getProgressColor = (val: number) => { - if (val <= 25) return '#ef4444'; - if (val <= 75) return '#3b82f6'; - return '#22c55e'; - }; - - const updateValue = (clientX: number) => { - if (!progressBarRef.current) return; - - const rect = progressBarRef.current.getBoundingClientRect(); - - let newValue = ((clientX - rect.left) / rect.width) * 100; - newValue = Math.max(0, Math.min(100, newValue)); - - setValue(Math.round(newValue)); - }; - - const handleStart = ( - e: React.MouseEvent | React.TouchEvent - ) => { - setIsDragging(true); - - if ('clientX' in e) { - updateValue(e.clientX); - } else { - updateValue(e.touches[0].clientX); - } - }; - - useEffect(() => { - const handleMove = (e: MouseEvent | TouchEvent) => { - if (!isDragging) return; - - if ('touches' in e) { - updateValue(e.touches[0].clientX); - } else { - updateValue(e.clientX); - } - }; + const [value, setValue] = Retool.useStateNumber({ + name: 'value', + initialValue: 0.30 + }); + + const [isDragging, setIsDragging] = useState(false); + const progressBarRef = useRef(null); + + const normalizedValue = value > 1 ? value / 100 : value; - const handleEnd = () => { - setIsDragging(false); + const getProgressColor = (val: number) => { + if (val <= 25) return '#ef4444'; + if (val <= 75) return '#3b82f6'; + return '#22c55e'; }; - window.addEventListener('mousemove', handleMove); - window.addEventListener('mouseup', handleEnd); + const updateValue = (clientX: number) => { + if (!progressBarRef.current) return; - window.addEventListener('touchmove', handleMove); - window.addEventListener('touchend', handleEnd); + const rect = progressBarRef.current.getBoundingClientRect(); - return () => { - window.removeEventListener('mousemove', handleMove); - window.removeEventListener('mouseup', handleEnd); + let newValue = (clientX - rect.left) / rect.width; - window.removeEventListener('touchmove', handleMove); - window.removeEventListener('touchend', handleEnd); + newValue = Math.min(1, Math.max(0, newValue)); + + setValue(Number(newValue.toFixed(2))); }; - }, [isDragging]); - - return ( -
- {/* Header */} -
- - Progress - - + + const handleStart = ( + e: React.MouseEvent | React.TouchEvent + ) => { + setIsDragging(true); + + if ('clientX' in e) { + updateValue(e.clientX); + } else { + updateValue(e.touches[0].clientX); + } + }; + + useEffect(() => { + const handleMove = (e: MouseEvent | TouchEvent) => { + if (!isDragging) return; + + if ('touches' in e) { + updateValue(e.touches[0].clientX); + } else { + updateValue(e.clientX); + } + }; + + const handleEnd = () => { + setIsDragging(false); + }; + + window.addEventListener('mousemove', handleMove); + window.addEventListener('mouseup', handleEnd); + + window.addEventListener('touchmove', handleMove); + window.addEventListener('touchend', handleEnd); + + return () => { + window.removeEventListener('mousemove', handleMove); + window.removeEventListener('mouseup', handleEnd); + + window.removeEventListener('touchmove', handleMove); + window.removeEventListener('touchend', handleEnd); + }; + }, [isDragging]); + + return (
- {value}% + {/* Header */} +
+
+ {Math.round(normalizedValue * 100)}% +
+
+ + {/* Progress Bar */} +
+ {/* Filled area */} +
+ + {/* Thumb */} +
+
-
- - {/* Progress Bar */} -
- {/* Filled area */} -
- - {/* Thumb */} -
-
-
- ); + ); }; \ No newline at end of file From e2c8a7502377555b91aab2e957d84b64f8a5ac84 Mon Sep 17 00:00:00 2001 From: juliomachines Date: Thu, 28 May 2026 14:48:14 -0300 Subject: [PATCH 3/5] Adding back the original custom components --- package.json | 6 +- .../GanttChart/GanttChart.module.css | 148 ++++ src/components/GanttChart/GanttChart.test.tsx | 53 ++ src/components/GanttChart/GanttChart.tsx | 434 ++++++++++++ src/components/GanttChart/README.md | 84 +++ src/components/GanttChart/index.tsx | 1 + src/components/HelloWorld/index.tsx | 15 + .../KanbanBoard/KanbanBoard.module.css | 520 ++++++++++++++ src/components/KanbanBoard/KanbanBoard.tsx | 639 ++++++++++++++++++ src/components/KanbanBoard/index.tsx | 1 + .../PasswordStrengthAnalyzer/index.tsx | 37 + .../PasswordStrengthAnalyzer/utils.test.ts | 16 + .../PasswordStrengthAnalyzer/utils.ts | 11 + .../QRCodeGenerator.module.css | 112 +++ .../QRCodeGenerator/QRCodeGenerator.tsx | 222 ++++++ src/components/QRCodeGenerator/README.md | 77 +++ src/components/QRCodeGenerator/index.tsx | 1 + src/index.tsx | 7 +- 18 files changed, 2380 insertions(+), 4 deletions(-) create mode 100644 src/components/GanttChart/GanttChart.module.css create mode 100644 src/components/GanttChart/GanttChart.test.tsx create mode 100644 src/components/GanttChart/GanttChart.tsx create mode 100644 src/components/GanttChart/README.md create mode 100644 src/components/GanttChart/index.tsx create mode 100644 src/components/HelloWorld/index.tsx create mode 100644 src/components/KanbanBoard/KanbanBoard.module.css create mode 100644 src/components/KanbanBoard/KanbanBoard.tsx create mode 100644 src/components/KanbanBoard/index.tsx create mode 100644 src/components/PasswordStrengthAnalyzer/index.tsx create mode 100644 src/components/PasswordStrengthAnalyzer/utils.test.ts create mode 100644 src/components/PasswordStrengthAnalyzer/utils.ts create mode 100644 src/components/QRCodeGenerator/QRCodeGenerator.module.css create mode 100644 src/components/QRCodeGenerator/QRCodeGenerator.tsx create mode 100644 src/components/QRCodeGenerator/README.md create mode 100644 src/components/QRCodeGenerator/index.tsx diff --git a/package.json b/package.json index d331e56..db284e5 100644 --- a/package.json +++ b/package.json @@ -38,9 +38,9 @@ "vitest": "^4.0.17" }, "retoolCustomComponentLibraryConfig": { - "name": "AdjustableProgressBar", - "label": "AdjustableProgressBar", - "description": "A custom component that allows the user to use a progress bar and adjusts the value by dragging it.", + "name": "custom-component-collection", + "label": "Custom Component Collection", + "description": "A collection of reusable Retool custom components", "entryPoint": "src/index.tsx", "outputPath": "dist" } diff --git a/src/components/GanttChart/GanttChart.module.css b/src/components/GanttChart/GanttChart.module.css new file mode 100644 index 0000000..2e36358 --- /dev/null +++ b/src/components/GanttChart/GanttChart.module.css @@ -0,0 +1,148 @@ +.root { + width: 100%; + height: 100%; + min-height: 200px; + background: #0D0F12; + border-radius: 10px; + overflow: hidden; + font-family: Inter, system-ui, sans-serif; + color: #E2E8F0; +} + +.inner { + display: flex; + height: 100%; + overflow: hidden; +} + +/* ── Label column ── */ + +.labelCol { + flex-shrink: 0; + display: flex; + flex-direction: column; + border-right: 1px solid rgba(255, 255, 255, 0.07); + background: #111318; + overflow: hidden; +} + +.labelHead { + display: flex; + align-items: center; + padding: 0 16px; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.06em; + text-transform: uppercase; + color: #64748B; + border-bottom: 1px solid rgba(255, 255, 255, 0.07); + flex-shrink: 0; +} + +.labelRow { + display: flex; + align-items: center; + padding: 0 16px; + cursor: pointer; + transition: background 0.15s; + flex-shrink: 0; + overflow: hidden; +} + +.labelContent { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + gap: 8px; + overflow: hidden; +} + +.labelText { + display: flex; + flex-direction: column; + justify-content: center; + overflow: hidden; + flex: 1; +} + +.avatarList { + display: flex; + flex-direction: row; + flex-shrink: 0; +} + +.avatar { + width: 20px; + height: 20px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 7px; + font-weight: 700; + color: white; + border: 1.5px solid #0D0F12; + margin-left: -4px; + flex-shrink: 0; +} + +.avatar:first-child { + margin-left: 0; +} + +.avatarImg { + width: 100%; + height: 100%; + border-radius: 50%; + object-fit: cover; + display: block; +} + +.labelRow:hover { + background: rgba(155, 114, 207, 0.07) !important; +} + +.group { + font-size: 10px; + font-weight: 600; + letter-spacing: 0.05em; + text-transform: uppercase; + color: #9B72CF; + margin-bottom: 2px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.name { + font-size: 13px; + font-weight: 500; + color: #CBD5E1; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* ── Chart scroll ── */ + +.chartScroll { + flex: 1; + overflow-x: auto; + overflow-y: hidden; + scrollbar-width: thin; + scrollbar-color: rgba(155, 114, 207, 0.3) transparent; +} + +.chartScroll::-webkit-scrollbar { + height: 6px; +} + +.chartScroll::-webkit-scrollbar-track { + background: transparent; +} + +.chartScroll::-webkit-scrollbar-thumb { + background: rgba(155, 114, 207, 0.3); + border-radius: 3px; +} diff --git a/src/components/GanttChart/GanttChart.test.tsx b/src/components/GanttChart/GanttChart.test.tsx new file mode 100644 index 0000000..788106b --- /dev/null +++ b/src/components/GanttChart/GanttChart.test.tsx @@ -0,0 +1,53 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { GanttChart } from "./GanttChart"; + +// Mock Retool hooks +vi.mock("@tryretool/custom-component-support", () => ({ + Retool: { + useStateArray: vi.fn(() => [[ + { id: "1", name: "Design", start: "2024-01-01", end: "2024-01-14", progress: 100, group: "Phase 1" }, + { id: "2", name: "Development", start: "2024-01-15", end: "2024-02-15", progress: 60, group: "Phase 1" }, + { id: "3", name: "Testing", start: "2024-02-16", end: "2024-03-01", progress: 0, group: "Phase 2" }, + ]]), + useStateString: vi.fn(() => ["week"]), + useStateObject: vi.fn(() => [null, vi.fn()]), + useEventCallback: vi.fn(() => vi.fn()), + }, +})); + +describe("GanttChart", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("renders without crashing", () => { + render(); + }); + + it("renders task names in the label column", () => { + render(); + expect(screen.getByText("Design")).toBeDefined(); + expect(screen.getByText("Development")).toBeDefined(); + expect(screen.getByText("Testing")).toBeDefined(); + }); + + it("renders group labels", () => { + render(); + const phase1 = screen.getAllByText("Phase 1"); + expect(phase1.length).toBeGreaterThan(0); + }); + + it("renders the chart SVG", () => { + const { container } = render(); + const svg = container.querySelector("svg"); + expect(svg).not.toBeNull(); + }); + + it("renders a bar for each task", () => { + const { container } = render(); + // Each task renders a hit-area rect with cursor:pointer + const hitAreas = container.querySelectorAll("rect[style*='cursor: pointer']"); + expect(hitAreas.length).toBe(3); + }); +}); diff --git a/src/components/GanttChart/GanttChart.tsx b/src/components/GanttChart/GanttChart.tsx new file mode 100644 index 0000000..6990f0f --- /dev/null +++ b/src/components/GanttChart/GanttChart.tsx @@ -0,0 +1,434 @@ +import React, { FC, useMemo, useState, useCallback } from "react"; +import { Retool } from "@tryretool/custom-component-support"; +import styles from "./GanttChart.module.css"; + +// ─── Types ─────────────────────────────────────────────────────────────────── + +type Assignee = { + id?: string; + name: string; + initials?: string; + color?: string; + avatar?: string; // profile picture URL +}; + +type Task = { + id: string; + name: string; + start: string; + end: string; + progress?: number; + group?: string; + color?: string; + assignees?: Assignee[]; +}; + +// Retool user shape from the Retool API resource +type RetoolUser = { + id?: string | number; + email?: string; + firstName?: string; + lastName?: string; + name?: string; + profilePhotoUrl?: string; + avatar?: string; +}; + +type Column = { label: string; x: number; width: number }; + +// ─── Constants ─────────────────────────────────────────────────────────────── + +const ROW_HEIGHT = 64; +const HEADER_HEIGHT = 52; +const LABEL_WIDTH = 240; +const BAR_HEIGHT = 28; +const BAR_RADIUS = 7; +const AVATAR_R = 13; +const AVATAR_GAP = 5; +const MAX_AVATARS = 3; + +const PALETTE = [ + "#6BBAFF", "#8B7EFF", "#2EC98A", "#F59E0B", + "#EF4444", "#9B72CF", "#34d399", "#f87171", +]; + +const AVATAR_PALETTE = [ + "#9B72CF", "#6BBAFF", "#2EC98A", "#F59E0B", + "#EF4444", "#8B7EFF", "#34d399", "#f87171", +]; + +const DAY_WIDTH: Record = { day: 40, week: 24, month: 12 }; + +// ─── Date helpers ───────────────────────────────────────────────────────────── + +const parseDate = (s: string) => new Date(s + "T00:00:00"); +const diffDays = (a: Date, b: Date) => Math.round((b.getTime() - a.getTime()) / 86_400_000); +const addDays = (d: Date, n: number) => { const r = new Date(d); r.setDate(r.getDate() + n); return r; }; + +function getWeek(d: Date): number { + const date = new Date(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate())); + const day = date.getUTCDay() || 7; + date.setUTCDate(date.getUTCDate() + 4 - day); + const yearStart = new Date(Date.UTC(date.getUTCFullYear(), 0, 1)); + return Math.ceil(((date.getTime() - yearStart.getTime()) / 86_400_000 + 1) / 7); +} + +function buildColumns(start: Date, end: Date, mode: string): Column[] { + const dw = DAY_WIDTH[mode] ?? 24; + const cols: Column[] = []; + if (mode === "day") { + const total = diffDays(start, end); + for (let i = 0; i < total; i++) { + const d = addDays(start, i); + cols.push({ label: d.toLocaleDateString("en-US", { month: "short", day: "numeric" }), x: i * dw, width: dw }); + } + } else if (mode === "week") { + let cur = new Date(start); + cur = addDays(cur, cur.getDay() === 0 ? -6 : 1 - cur.getDay()); + while (cur < end) { + const next = addDays(cur, 7); + const x = Math.max(0, diffDays(start, cur)) * dw; + const w = Math.min(7, diffDays(start, end) - diffDays(start, cur)) * dw; + if (w > 0) cols.push({ label: `W${getWeek(cur)} ${cur.getFullYear()}`, x, width: w }); + cur = next; + } + } else { + let cur = new Date(start.getFullYear(), start.getMonth(), 1); + while (cur < end) { + const next = new Date(cur.getFullYear(), cur.getMonth() + 1, 1); + const x = Math.max(0, diffDays(start, cur)) * dw; + const w = Math.min(diffDays(cur, next), diffDays(start, end) - Math.max(0, diffDays(start, cur))) * dw; + if (w > 0) cols.push({ label: cur.toLocaleDateString("en-US", { month: "short", year: "numeric" }), x, width: w }); + cur = next; + } + } + return cols; +} + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +function getInitials(name: string): string { + const parts = name.trim().split(" "); + return (parts[0]?.[0] ?? "") + (parts[1]?.[0] ?? ""); +} + +function resolveAssignees(assignees: Assignee[], userMap: Map): Assignee[] { + return assignees.map(a => { + const user = a.id ? userMap.get(String(a.id)) : undefined; + if (!user) return a; + const fullName = user.name ?? `${user.firstName ?? ""} ${user.lastName ?? ""}`.trim(); + return { + ...a, + name: fullName || a.name, + initials: a.initials ?? getInitials(fullName || a.name), + avatar: user.profilePhotoUrl ?? user.avatar ?? a.avatar, + }; + }); +} + +// ─── SVG Avatar Stack (on bar) ──────────────────────────────────────────────── + +function SvgAvatarStack({ assignees, x, y, taskId }: { assignees: Assignee[]; x: number; y: number; taskId: string }) { + const visible = assignees.slice(0, MAX_AVATARS); + const overflow = assignees.length - MAX_AVATARS; + const step = AVATAR_R * 2 - AVATAR_GAP; + const totalW = visible.length * step + (overflow > 0 ? step : 0); + + return ( + + + {visible.map((_, i) => ( + + + + ))} + + {visible.map((a, i) => { + const cx = i * step + AVATAR_R; + const cy = AVATAR_R; + const bg = a.color ?? AVATAR_PALETTE[i % AVATAR_PALETTE.length]; + return ( + + + {a.avatar ? ( + + ) : ( + + {(a.initials ?? getInitials(a.name)).toUpperCase().slice(0, 2)} + + )} + + + ); + })} + {overflow > 0 && ( + + + + +{overflow} + + + )} + + ); +} + +// ─── HTML Avatar (label column) ─────────────────────────────────────────────── + +function HtmlAvatar({ assignee, index }: { assignee: Assignee; index: number }) { + const [imgFailed, setImgFailed] = useState(false); + const bg = assignee.color ?? AVATAR_PALETTE[index % AVATAR_PALETTE.length]; + const initials = (assignee.initials ?? getInitials(assignee.name)).toUpperCase().slice(0, 2); + + return ( +
+ {assignee.avatar && !imgFailed ? ( + {assignee.name} setImgFailed(true)} + /> + ) : ( + initials + )} +
+ ); +} + +// ─── Component ──────────────────────────────────────────────────────────────── + +export const GanttChart: FC = () => { + const [rawTasks] = Retool.useStateArray({ + name: "tasks", + label: "Tasks", + description: "Array of tasks: { id, name, start, end, progress?, group?, color?, assignees?: [{ id?, name, initials?, color?, avatar? }] }", + defaultValue: [], + }); + const [rawUsers] = Retool.useStateArray({ + name: "users", + label: "Users", + description: "Retool org users — bind to a Retool API users query. Used to resolve avatars and names from assignee id.", + defaultValue: [], + }); + const [viewMode] = Retool.useStateString({ + name: "viewMode", + label: "View Mode", + description: "day | week | month", + defaultValue: "week", + }); + const [, setSelectedTask] = Retool.useStateObject({ + name: "selectedTask", + label: "Selected Task", + description: "Last clicked task object including resolved assignees", + defaultValue: null, + }); + const onTaskClick = Retool.useEventCallback({ name: "taskClick" }); + + const [hoveredId, setHoveredId] = useState(null); + + const tasks = (rawTasks as Task[]) ?? []; + const users = (rawUsers as RetoolUser[]) ?? []; + const mode = (viewMode as string) || "week"; + const dayW = DAY_WIDTH[mode] ?? 24; + + // Build a map of user id → user for fast lookup + const userMap = useMemo(() => { + const map = new Map(); + users.forEach(u => { if (u.id != null) map.set(String(u.id), u); }); + return map; + }, [users]); + + const { rangeStart, totalDays, columns } = useMemo(() => { + if (tasks.length === 0) { + const now = new Date(); + const start = new Date(now.getFullYear(), now.getMonth(), 1); + const end = new Date(now.getFullYear(), now.getMonth() + 3, 0); + return { rangeStart: start, totalDays: diffDays(start, end), columns: buildColumns(start, end, mode) }; + } + let min = parseDate(tasks[0].start); + let max = parseDate(tasks[0].end); + tasks.forEach(t => { + const s = parseDate(t.start), e = parseDate(t.end); + if (s < min) min = s; + if (e > max) max = e; + }); + const start = addDays(min, -7); + const end = addDays(max, 7); + return { rangeStart: start, totalDays: diffDays(start, end), columns: buildColumns(start, end, mode) }; + }, [tasks, mode]); + + const chartW = totalDays * dayW; + const chartH = HEADER_HEIGHT + tasks.length * ROW_HEIGHT + 8; + + const colorMap = useMemo(() => { + const map: Record = {}; + let i = 0; + tasks.forEach(t => { + const k = t.group ?? t.id; + if (!map[k]) map[k] = PALETTE[i++ % PALETTE.length]; + }); + return map; + }, [tasks]); + + const barProps = useCallback((task: Task) => { + const s = parseDate(task.start); + const e = parseDate(task.end); + const x = diffDays(rangeStart, s) * dayW; + const w = Math.max(diffDays(s, e) * dayW, dayW); + return { x, w }; + }, [rangeStart, dayW]); + + const handleClick = useCallback((task: Task, resolved: Assignee[]) => { + setSelectedTask({ ...task, assignees: resolved } as unknown as Record); + onTaskClick(); + }, [setSelectedTask, onTaskClick]); + + const todayX = diffDays(rangeStart, new Date()) * dayW; + const showToday = todayX >= 0 && todayX <= chartW; + + return ( +
+
+ + {/* ── Label column ── */} +
+
Task
+ {tasks.map((task, i) => { + const resolved = resolveAssignees(task.assignees ?? [], userMap); + return ( +
handleClick(task, resolved)} + > +
+
+ {task.group && {task.group}} + {task.name} +
+ {resolved.length > 0 && ( +
+ {resolved.slice(0, MAX_AVATARS).map((a, ai) => ( + + ))} + {resolved.length > MAX_AVATARS && ( +
+ +{resolved.length - MAX_AVATARS} +
+ )} +
+ )} +
+
+ ); + })} +
+ + {/* ── Chart scroll ── */} +
+ + + {columns.map((col, ci) => ( + + + + {col.label} + + + + ))} + + + + {tasks.map((task, i) => { + const { x, w } = barProps(task); + const rowY = HEADER_HEIGHT + i * ROW_HEIGHT; + const barY = rowY + (ROW_HEIGHT - BAR_HEIGHT) / 2; + const color = task.color ?? colorMap[task.group ?? task.id] ?? PALETTE[0]; + const progress = Math.min(100, Math.max(0, task.progress ?? 0)); + const hovered = hoveredId === task.id; + const resolved = resolveAssignees(task.assignees ?? [], userMap); + const avatarX = x + w - 4; + const avatarY = rowY + ROW_HEIGHT / 2; + + return ( + + + {hovered && ( + + )} + + {progress > 0 && ( + + )} + + {progress > 0 && w > 52 && ( + + {progress}% + + )} + {resolved.length > 0 && w > 40 && ( + + )} + handleClick(task, resolved)} + onMouseEnter={() => setHoveredId(task.id)} + onMouseLeave={() => setHoveredId(null)} /> + + ); + })} + + {showToday && ( + + + + + TODAY + + + )} + +
+
+
+ ); +}; diff --git a/src/components/GanttChart/README.md b/src/components/GanttChart/README.md new file mode 100644 index 0000000..405c914 --- /dev/null +++ b/src/components/GanttChart/README.md @@ -0,0 +1,84 @@ +# Gantt Chart + +A timeline visualization component for Retool. Displays tasks as horizontal bars across a date range with support for grouping, progress tracking, and day/week/month zoom levels. + +## Features + +- Day, week, and month view modes +- Progress bars per task (0–100%) +- Group/phase labeling +- Today marker +- Click-to-select task (exposes `selectedTask` model value) +- `taskClick` event for triggering Retool queries +- Custom per-task colors or auto-assigned palette +- Horizontal scroll for long timelines +- Dark theme, styled to match Retool + +## Installation + +1. Clone the [custom-component-collection-template](https://github.com/tryretool/custom-component-collection-template) +2. Copy this folder into `src/components/GanttChart/` +3. Add the export to `src/index.tsx`: + ```ts + export { GanttChart } from "./components/GanttChart"; + ``` +4. Run `npx retool-ccl dev` to preview in Retool + +## Model inputs + +| Property | Type | Description | +|------------|----------|-------------| +| `tasks` | array | Array of task objects (see schema below) | +| `viewMode` | string | `"day"`, `"week"` (default), or `"month"` | + +### Task schema + +```json +{ + "id": "task-1", + "name": "Design mockups", + "start": "2024-01-01", + "end": "2024-01-14", + "progress": 75, + "group": "Phase 1", + "color": "#6BBAFF" +} +``` + +| Field | Type | Required | Description | +|------------|--------|----------|-------------| +| `id` | string | yes | Unique identifier | +| `name` | string | yes | Task label shown in the sidebar | +| `start` | string | yes | Start date `YYYY-MM-DD` | +| `end` | string | yes | End date `YYYY-MM-DD` | +| `progress` | number | no | Completion percentage 0–100 | +| `group` | string | no | Groups tasks under a phase label | +| `color` | string | no | Hex color for the bar (auto-assigned if omitted) | + +## Model outputs + +| Property | Type | Description | +|----------------|--------|-------------| +| `selectedTask` | object | The task object the user last clicked | + +## Events + +| Event | When it fires | +|-------------|---------------| +| `taskClick` | User clicks a task bar | + +## Example query binding + +Bind `{{ yourQuery.data }}` to the `tasks` model input directly. The component expects the same column names as the schema above — rename columns in your query if needed. + +```sql +SELECT + id::text, + task_name AS name, + start_date::text AS start, + end_date::text AS end, + progress, + phase AS group +FROM project_tasks +ORDER BY start_date; +``` diff --git a/src/components/GanttChart/index.tsx b/src/components/GanttChart/index.tsx new file mode 100644 index 0000000..c769c67 --- /dev/null +++ b/src/components/GanttChart/index.tsx @@ -0,0 +1 @@ +export { GanttChart } from "./GanttChart"; diff --git a/src/components/HelloWorld/index.tsx b/src/components/HelloWorld/index.tsx new file mode 100644 index 0000000..cd3d3c8 --- /dev/null +++ b/src/components/HelloWorld/index.tsx @@ -0,0 +1,15 @@ +import React from 'react' +import { type FC } from 'react' +import { Retool } from '@tryretool/custom-component-support' + +export const HelloWorld: FC = () => { + const [name, _setName] = Retool.useStateString({ + name: 'name' + }) + + return ( +
+
Hello {name}!
+
+ ) +} diff --git a/src/components/KanbanBoard/KanbanBoard.module.css b/src/components/KanbanBoard/KanbanBoard.module.css new file mode 100644 index 0000000..07b8e58 --- /dev/null +++ b/src/components/KanbanBoard/KanbanBoard.module.css @@ -0,0 +1,520 @@ +.root { + position: relative; + width: 100%; + height: 100%; + min-height: 300px; + background: #0D0F12; + border-radius: 10px; + overflow: hidden; + font-family: Inter, system-ui, sans-serif; + color: #E2E8F0; +} + +/* ── Board layout ── */ + +.board { + display: flex; + gap: 12px; + padding: 16px; + height: 100%; + overflow-x: auto; + overflow-y: hidden; + align-items: flex-start; + box-sizing: border-box; + scrollbar-width: thin; + scrollbar-color: rgba(155, 114, 207, 0.3) transparent; +} + +.board::-webkit-scrollbar { + height: 6px; +} + +.board::-webkit-scrollbar-track { + background: transparent; +} + +.board::-webkit-scrollbar-thumb { + background: rgba(155, 114, 207, 0.3); + border-radius: 3px; +} + +/* ── Column ── */ + +.column { + flex-shrink: 0; + width: 272px; + display: flex; + flex-direction: column; + background: #111318; + border-radius: 8px; + max-height: calc(100% - 2px); + border: 1.5px solid transparent; + transition: border-color 0.15s, background 0.15s; +} + +.columnOver { + background: #14171f; + border-color: rgba(155, 114, 207, 0.45); +} + +.columnHeader { + display: flex; + align-items: center; + gap: 8px; + padding: 12px 14px 11px; + border-bottom: 1px solid rgba(255, 255, 255, 0.06); + flex-shrink: 0; +} + +.columnAccent { + width: 9px; + height: 9px; + border-radius: 50%; + flex-shrink: 0; +} + +.columnName { + flex: 1; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.07em; + text-transform: uppercase; + color: #94A3B8; +} + +.columnCount { + font-size: 12px; + font-weight: 700; + min-width: 18px; + text-align: right; +} + +.columnCountLimit { + color: #EF4444 !important; +} + +/* ── Card list ── */ + +.cardList { + flex: 1; + overflow-y: auto; + padding: 10px; + display: flex; + flex-direction: column; + gap: 7px; + min-height: 60px; + scrollbar-width: thin; + scrollbar-color: rgba(155, 114, 207, 0.15) transparent; +} + +.cardList::-webkit-scrollbar { + width: 4px; +} + +.cardList::-webkit-scrollbar-track { + background: transparent; +} + +.cardList::-webkit-scrollbar-thumb { + background: rgba(155, 114, 207, 0.2); + border-radius: 2px; +} + +/* ── Card ── */ + +.card { + background: #1A1D24; + border-radius: 6px; + padding: 11px 12px; + cursor: pointer; + user-select: none; + border: 1px solid rgba(255, 255, 255, 0.06); + /* border-left is set inline to show priority color */ + transition: background 0.12s, box-shadow 0.12s, transform 0.1s; +} + +.card:hover { + background: #1f2330; + box-shadow: 0 3px 10px rgba(0, 0, 0, 0.35); +} + +.card:active { + transform: scale(0.985); +} + +.cardDragging { + opacity: 0.35; + transform: scale(0.97); + box-shadow: none; +} + +/* ── Drop indicator ── */ + +.dropIndicator { + height: 3px; + border-radius: 2px; + background: rgba(155, 114, 207, 0.65); + flex-shrink: 0; +} + +/* ── Card top row ── */ + +.cardTop { + display: flex; + align-items: flex-start; + gap: 8px; +} + +.cardMain { + flex: 1; + min-width: 0; +} + +/* ── Drag handle ── */ + +.dragHandle { + flex-shrink: 0; + color: #334155; + cursor: grab; + padding: 2px 2px; + border-radius: 4px; + display: flex; + align-items: center; + opacity: 0; + transition: opacity 0.15s, color 0.15s; + margin-top: 1px; +} + +.card:hover .dragHandle { + opacity: 1; +} + +.dragHandle:hover { + color: #64748B; +} + +.dragHandle:active { + cursor: grabbing; +} + +/* ── Priority badge ── */ + +.priorityBadge { + font-size: 9.5px; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; + margin-bottom: 5px; + opacity: 0.9; +} + +/* ── Card content ── */ + +.cardTitle { + font-size: 13px; + font-weight: 500; + color: #CBD5E1; + line-height: 1.4; + margin-bottom: 3px; +} + +.cardDescription { + font-size: 12px; + color: #64748B; + line-height: 1.45; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + margin-top: 4px; + margin-bottom: 7px; +} + +/* ── Tags ── */ + +.tagList { + display: flex; + flex-wrap: wrap; + gap: 4px; + margin-top: 7px; + margin-bottom: 7px; +} + +.tag { + background: rgba(155, 114, 207, 0.13); + color: #9B72CF; + font-size: 10px; + font-weight: 500; + padding: 2px 7px; + border-radius: 4px; + white-space: nowrap; +} + +/* ── Card footer ── */ + +.cardFooter { + display: flex; + align-items: center; + justify-content: space-between; + margin-top: 9px; + gap: 8px; +} + +.dueDate { + font-size: 11px; + color: #475569; + font-weight: 500; +} + +.dueDateOverdue { + color: #EF4444; + font-weight: 600; +} + +/* ── Avatars ── */ + +.avatarList { + display: flex; + flex-direction: row; + flex-shrink: 0; +} + +.avatar { + width: 22px; + height: 22px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-weight: 700; + color: white; + border: 1.5px solid #1A1D24; + margin-left: -5px; + flex-shrink: 0; + overflow: hidden; +} + +.avatar:first-child { + margin-left: 0; +} + +.avatarImg { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} + +/* ── Empty column state ── */ + +.emptyCol { + font-size: 12px; + color: #334155; + text-align: center; + padding: 20px 0; + font-style: italic; +} + +/* ── Card detail modal ── */ + +.modalBackdrop { + position: absolute; + inset: 0; + background: rgba(0, 0, 0, 0.7); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; +} + +.modal { + background: #161922; + border: 1px solid rgba(255, 255, 255, 0.09); + border-radius: 12px; + width: 480px; + max-width: calc(100vw - 32px); + max-height: calc(100vh - 48px); + overflow-y: auto; + padding: 24px; + box-shadow: 0 24px 64px rgba(0, 0, 0, 0.6); + scrollbar-width: thin; + scrollbar-color: rgba(155, 114, 207, 0.2) transparent; +} + +.modal::-webkit-scrollbar { + width: 4px; +} + +.modal::-webkit-scrollbar-thumb { + background: rgba(155, 114, 207, 0.2); + border-radius: 2px; +} + +.modalHeader { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 14px; + gap: 8px; +} + +.modalHeaderLeft { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.modalPriorityBadge { + font-size: 10px; + font-weight: 700; + letter-spacing: 0.07em; + text-transform: uppercase; + border: 1px solid; + border-radius: 4px; + padding: 2px 8px; + opacity: 0.9; +} + +.modalColumnBadge { + display: flex; + align-items: center; + gap: 5px; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.04em; +} + +.modalColumnDot { + width: 7px; + height: 7px; + border-radius: 50%; + flex-shrink: 0; +} + +.modalClose { + background: none; + border: none; + color: #475569; + font-size: 14px; + cursor: pointer; + padding: 4px 8px; + border-radius: 6px; + line-height: 1; + transition: background 0.12s, color 0.12s; + flex-shrink: 0; +} + +.modalClose:hover { + background: rgba(255, 255, 255, 0.07); + color: #CBD5E1; +} + +.modalTitle { + font-size: 18px; + font-weight: 600; + color: #F1F5F9; + margin: 0 0 20px; + line-height: 1.35; +} + +.modalSection { + margin-bottom: 20px; +} + +.modalSectionLabel { + font-size: 10px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: #475569; + margin-bottom: 8px; +} + +.modalDescription { + font-size: 13px; + color: #94A3B8; + line-height: 1.6; + margin: 0; + white-space: pre-wrap; +} + +.modalDescriptionEmpty { + font-size: 13px; + color: #334155; + font-style: italic; + margin: 0 0 20px; +} + +.modalDueDate { + font-size: 13px; + color: #94A3B8; + font-weight: 500; +} + +.modalDueDateOverdue { + color: #EF4444; + font-weight: 600; +} + +/* Assignees in modal */ + +.modalAssigneeList { + display: flex; + flex-direction: column; + gap: 10px; +} + +.modalAssigneeRow { + display: flex; + align-items: center; + gap: 10px; +} + +.modalAssigneeName { + font-size: 13px; + color: #CBD5E1; + font-weight: 500; +} + +/* Move to buttons */ + +.modalMoveButtons { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.moveBtn { + display: flex; + align-items: center; + gap: 6px; + background: #1A1D24; + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 6px; + color: #64748B; + font-size: 12px; + font-weight: 500; + padding: 6px 12px; + cursor: pointer; + transition: background 0.12s, border-color 0.12s, color 0.12s; + font-family: inherit; +} + +.moveBtn:hover:not(:disabled) { + background: #1f2330; + border-color: rgba(255, 255, 255, 0.15); + color: #CBD5E1; +} + +.moveBtnActive { + background: rgba(155, 114, 207, 0.08); + cursor: default; +} + +.moveBtnDot { + width: 7px; + height: 7px; + border-radius: 50%; + flex-shrink: 0; +} diff --git a/src/components/KanbanBoard/KanbanBoard.tsx b/src/components/KanbanBoard/KanbanBoard.tsx new file mode 100644 index 0000000..df9e411 --- /dev/null +++ b/src/components/KanbanBoard/KanbanBoard.tsx @@ -0,0 +1,639 @@ +import React, { FC, useState, useMemo, useCallback, useRef, useEffect } from 'react' +import { Retool } from '@tryretool/custom-component-support' +import styles from './KanbanBoard.module.css' + +/* ── Types ── */ + +type Priority = 'low' | 'medium' | 'high' | 'critical' + +type CardAssignee = { + id?: string + name?: string + color?: string + avatar?: string +} + +type KanbanCard = { + id: string + title: string + description?: string + column: string + priority?: Priority + assignees?: CardAssignee[] + tags?: string[] + dueDate?: string +} + +type KanbanColumn = { + id: string + name: string + color?: string + limit?: number +} + +type RetoolUser = { + id: string + firstName?: string + lastName?: string + email?: string + profilePhotoUrl?: string +} + +type ResolvedUser = { + name: string + color: string + photoUrl?: string +} + +/* ── Constants ── */ + +const PRIORITY_COLORS: Record = { + low: '#64748B', + medium: '#F59E0B', + high: '#EF4444', + critical: '#DC2626', +} + +const PRIORITY_LABELS: Record = { + low: 'Low', + medium: 'Medium', + high: 'High', + critical: 'Critical', +} + +const DEFAULT_COLUMNS: KanbanColumn[] = [ + { id: 'todo', name: 'To Do', color: '#64748B' }, + { id: 'in_progress', name: 'In Progress', color: '#3B82F6' }, + { id: 'in_review', name: 'In Review', color: '#F59E0B' }, + { id: 'done', name: 'Done', color: '#10B981' }, +] + +const AVATAR_COLORS = [ + '#9B72CF', '#3B82F6', '#10B981', '#F59E0B', + '#EF4444', '#EC4899', '#8B5CF6', '#06B6D4', +] + +const MAX_AVATARS_CARD = 3 + +/* ── Helpers ── */ + +function hashColor(str: string): string { + let h = 0 + for (let i = 0; i < str.length; i++) h = (h * 31 + str.charCodeAt(i)) & 0xffffffff + return AVATAR_COLORS[Math.abs(h) % AVATAR_COLORS.length] +} + +function getInitials(name: string): string { + const parts = name.trim().split(/\s+/) + return parts.length >= 2 + ? (parts[0][0] + parts[parts.length - 1][0]).toUpperCase() + : name.slice(0, 2).toUpperCase() +} + +function isOverdue(dateStr: string): boolean { + const today = new Date() + today.setHours(0, 0, 0, 0) + return new Date(dateStr) < today +} + +function formatDate(dateStr: string): string { + const d = new Date(dateStr) + return d.toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' }) +} + +function formatDateShort(dateStr: string): string { + const d = new Date(dateStr) + return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) +} + +/* ── Avatar ── */ + +const Avatar: React.FC<{ + user: ResolvedUser + size?: number + borderColor?: string +}> = ({ user, size = 22, borderColor = '#1A1D24' }) => { + const [imgFailed, setImgFailed] = useState(false) + + if (user.photoUrl && !imgFailed) { + return ( +
+ {user.name} setImgFailed(true)} + /> +
+ ) + } + + return ( +
+ {getInitials(user.name)} +
+ ) +} + +/* ── Card Detail Modal ── */ + +const CardModal: React.FC<{ + card: KanbanCard + currentColumnId: string + columns: KanbanColumn[] + resolveAssignee: (a: CardAssignee) => ResolvedUser + onClose: () => void + onMoveTo: (colId: string) => void +}> = ({ card, currentColumnId, columns, resolveAssignee, onClose, onMoveTo }) => { + const priorityColor = card.priority ? PRIORITY_COLORS[card.priority] : null + const currentCol = columns.find(c => c.id === currentColumnId) + const overdue = card.dueDate && isOverdue(card.dueDate) + + useEffect(() => { + const handler = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose() } + window.addEventListener('keydown', handler) + return () => window.removeEventListener('keydown', handler) + }, [onClose]) + + return ( +
+
e.stopPropagation()}> + +
+
+ {card.priority && ( + + {PRIORITY_LABELS[card.priority] || card.priority} + + )} + {currentCol && ( + + + {currentCol.name} + + )} +
+ +
+ +

{card.title}

+ + {card.description ? ( +
+
Description
+

{card.description}

+
+ ) : ( +

No description provided.

+ )} + + {card.dueDate && ( +
+
Due date
+
+ {overdue ? '⚠ Overdue · ' : ''}{formatDate(card.dueDate)} +
+
+ )} + + {card.tags && card.tags.length > 0 && ( +
+
Tags
+
+ {card.tags.map(tag => ( + {tag} + ))} +
+
+ )} + + {card.assignees && card.assignees.length > 0 && ( +
+
Assignees
+
+ {card.assignees.map((a, i) => { + const user = resolveAssignee(a) + return ( +
+ + {user.name} +
+ ) + })} +
+
+ )} + +
+
Move to
+
+ {columns.map(col => { + const isActive = col.id === currentColumnId + return ( + + ) + })} +
+
+ +
+
+ ) +} + +/* ── Main Component ── */ + +export const KanbanBoard: FC = () => { + Retool.useComponentSettings({ defaultWidth: 960, defaultHeight: 600 }) + + const [rawCards] = Retool.useStateArray({ + name: 'cards', + label: 'Cards', + description: 'Array of cards: { id, title, description?, column, priority?, assignees?, tags?, dueDate? }', + defaultValue: [], + }) + + const [rawColumns] = Retool.useStateArray({ + name: 'columns', + label: 'Columns', + description: 'Array of columns: { id, name, color?, limit? }. Leave empty to use default columns.', + defaultValue: [], + }) + + const [rawUsers] = Retool.useStateArray({ + name: 'users', + label: 'Users', + description: 'Retool users for avatar photos: { id, firstName, lastName, profilePhotoUrl }', + defaultValue: [], + }) + + const [allowDrag] = Retool.useStateBoolean({ + name: 'allowDrag', + label: 'Allow drag & drop', + defaultValue: true, + }) + + const [, setSelectedCard] = Retool.useStateObject({ + name: 'selectedCard', + label: 'Selected Card', + description: 'Last clicked card object', + defaultValue: null, + }) + + const [, setMovedCard] = Retool.useStateObject({ + name: 'movedCard', + label: 'Moved Card', + description: 'Most recently moved card, with its new column id', + defaultValue: null, + }) + + const onCardClick = Retool.useEventCallback({ name: 'cardClick' }) + const onCardMoved = Retool.useEventCallback({ name: 'cardMoved' }) + + /* ── Data ── */ + + const columns = useMemo(() => { + const cols = Array.isArray(rawColumns) ? (rawColumns as KanbanColumn[]) : [] + return cols.length > 0 ? cols : DEFAULT_COLUMNS + }, [rawColumns]) + + const cards = useMemo(() => { + return Array.isArray(rawCards) ? (rawCards as KanbanCard[]) : [] + }, [rawCards]) + + const cardMap = useMemo(() => { + const m = new Map() + cards.forEach(c => m.set(String(c.id), c)) + return m + }, [cards]) + + const userMap = useMemo(() => { + const m = new Map() + if (Array.isArray(rawUsers)) { + ;(rawUsers as RetoolUser[]).forEach(u => { if (u.id) m.set(String(u.id), u) }) + } + return m + }, [rawUsers]) + + /* ── Card order ── */ + + const buildOrder = useCallback((cs: KanbanCard[], cols: KanbanColumn[]) => { + const order: Record = {} + cols.forEach(c => { order[c.id] = [] }) + cs.forEach(card => { + const colId = String(card.column) + if (!order[colId]) order[colId] = [] + order[colId].push(String(card.id)) + }) + return order + }, []) + + const [cardOrder, setCardOrder] = useState>(() => + buildOrder(cards, columns) + ) + + const [localColMap, setLocalColMap] = useState>(() => { + const m: Record = {} + cards.forEach(c => { m[String(c.id)] = String(c.column) }) + return m + }) + + const prevSigRef = useRef('') + useEffect(() => { + const sig = cards.map(c => `${c.id}:${c.column}`).join(',') + if (sig !== prevSigRef.current) { + prevSigRef.current = sig + setCardOrder(buildOrder(cards, columns)) + const m: Record = {} + cards.forEach(c => { m[String(c.id)] = String(c.column) }) + setLocalColMap(m) + } + }, [cards, columns, buildOrder]) + + /* ── Modal state ── */ + + const [expandedEntry, setExpandedEntry] = useState<{ card: KanbanCard; colId: string } | null>(null) + + /* ── Drag state ── */ + + const [draggingId, setDraggingId] = useState(null) + const [dropColId, setDropColId] = useState(null) + const [dropBeforeId, setDropBeforeId] = useState(null) + const dragRef = useRef<{ cardId: string; sourceCol: string } | null>(null) + + /* ── Assignee resolver ── */ + + const resolveAssignee = useCallback((a: CardAssignee): ResolvedUser => { + const retoolUser = a.id ? userMap.get(String(a.id)) : undefined + const name = retoolUser + ? [retoolUser.firstName, retoolUser.lastName].filter(Boolean).join(' ') || retoolUser.email || String(a.id) + : a.name || String(a.id || 'User') + return { + name, + color: a.color || hashColor(name), + photoUrl: retoolUser?.profilePhotoUrl || a.avatar, + } + }, [userMap]) + + /* ── Move card ── */ + + const moveCard = useCallback((cardId: string, sourceCol: string, targetCol: string, beforeId: string | null = null) => { + setCardOrder(prev => { + const next: Record = {} + Object.keys(prev).forEach(k => { next[k] = [...prev[k]] }) + next[sourceCol] = (next[sourceCol] || []).filter(id => id !== cardId) + const targetList = next[targetCol] || [] + if (beforeId && targetList.includes(beforeId)) { + targetList.splice(targetList.indexOf(beforeId), 0, cardId) + } else { + targetList.push(cardId) + } + next[targetCol] = targetList + return next + }) + setLocalColMap(prev => ({ ...prev, [cardId]: targetCol })) + const card = cardMap.get(cardId) + if (card) { + setMovedCard({ ...card, column: targetCol }) + onCardMoved() + } + }, [cardMap, setMovedCard, onCardMoved]) + + /* ── Drag handlers (on the drag handle, not the card) ── */ + + const handleDragStart = useCallback((e: React.DragEvent, cardId: string, sourceCol: string) => { + e.dataTransfer.effectAllowed = 'move' + // Show the parent card as the drag ghost + const cardEl = (e.currentTarget as HTMLElement).closest('[data-card]') as HTMLElement | null + if (cardEl) e.dataTransfer.setDragImage(cardEl, 20, 20) + dragRef.current = { cardId, sourceCol } + setTimeout(() => setDraggingId(cardId), 0) + }, []) + + const handleDragEnd = useCallback(() => { + setDraggingId(null) + setDropColId(null) + setDropBeforeId(null) + dragRef.current = null + }, []) + + const handleColDragOver = useCallback((e: React.DragEvent, colId: string) => { + e.preventDefault() + e.dataTransfer.dropEffect = 'move' + setDropColId(colId) + setDropBeforeId(null) + }, []) + + const handleCardDragOver = useCallback((e: React.DragEvent, colId: string, cardId: string) => { + e.preventDefault() + e.stopPropagation() + e.dataTransfer.dropEffect = 'move' + setDropColId(colId) + setDropBeforeId(cardId) + }, []) + + const handleDrop = useCallback((e: React.DragEvent, targetCol: string) => { + e.preventDefault() + const data = dragRef.current + if (!data) return + const { cardId, sourceCol } = data + const before = dropBeforeId + moveCard(cardId, sourceCol, targetCol, before) + setDraggingId(null) + setDropColId(null) + setDropBeforeId(null) + dragRef.current = null + }, [dropBeforeId, moveCard]) + + /* ── Card click (plain onClick — no draggable on the card itself) ── */ + + const handleCardClick = useCallback((card: KanbanCard, colId: string) => { + setExpandedEntry({ card, colId }) + setSelectedCard(card) + onCardClick() + }, [setSelectedCard, onCardClick]) + + /* ── Modal move ── */ + + const handleModalMove = useCallback((targetColId: string) => { + if (!expandedEntry) return + const cardId = String(expandedEntry.card.id) + const sourceCol = localColMap[cardId] || expandedEntry.colId + if (sourceCol === targetColId) return + moveCard(cardId, sourceCol, targetColId, null) + setExpandedEntry(prev => prev ? { ...prev, colId: targetColId } : null) + }, [expandedEntry, localColMap, moveCard]) + + /* ── Render ── */ + + return ( +
+
+ {columns.map(col => { + const colColor = col.color || '#64748B' + const colCardIds = cardOrder[col.id] || [] + const colCards = colCardIds + .map(id => cardMap.get(id)) + .filter((c): c is KanbanCard => c != null) + const isOver = dropColId === col.id + const atLimit = col.limit != null && colCards.length >= col.limit + + return ( +
handleColDragOver(e, col.id)} + onDrop={e => handleDrop(e, col.id)} + > +
+
+ {col.name} + + {colCards.length}{col.limit != null ? `/${col.limit}` : ''} + +
+ +
+ {colCards.map(card => { + const isDragging = draggingId === String(card.id) + const isDropTarget = dropColId === col.id && dropBeforeId === String(card.id) + const visibleAssignees = (card.assignees || []).slice(0, MAX_AVATARS_CARD) + const extraAssignees = (card.assignees?.length || 0) - MAX_AVATARS_CARD + const overdue = card.dueDate && isOverdue(card.dueDate) + const priorityColor = card.priority ? PRIORITY_COLORS[card.priority] : 'transparent' + + return ( + + {isDropTarget &&
} + + {/* Card: NOT draggable — clean onClick works perfectly */} +
handleCardClick(card, col.id)} + onDragOver={e => handleCardDragOver(e, col.id, String(card.id))} + style={{ borderLeft: `3px solid ${priorityColor}` }} + > +
+
+ {card.priority && ( +
+ {PRIORITY_LABELS[card.priority] || card.priority} +
+ )} +
{card.title}
+ {card.description && ( +
{card.description}
+ )} + {card.tags && card.tags.length > 0 && ( +
+ {card.tags.map(tag => ( + {tag} + ))} +
+ )} +
+ + {/* Drag handle — only this is draggable */} + {allowDrag && ( +
handleDragStart(e, String(card.id), col.id)} + onDragEnd={handleDragEnd} + onClick={e => e.stopPropagation()} + title="Drag to move" + > + +
+ )} +
+ +
+ {card.dueDate ? ( + + {overdue ? '⚠ ' : ''}{formatDateShort(card.dueDate)} + + ) : } + + {visibleAssignees.length > 0 && ( +
+ {visibleAssignees.map((a, i) => ( + + ))} + {extraAssignees > 0 && ( +
+ +{extraAssignees} +
+ )} +
+ )} +
+
+ + ) + })} + + {dropColId === col.id && !dropBeforeId && ( +
+ )} + + {colCards.length === 0 && dropColId !== col.id && ( +
Drop cards here
+ )} +
+
+ ) + })} +
+ + {expandedEntry && ( + setExpandedEntry(null)} + onMoveTo={handleModalMove} + /> + )} +
+ ) +} + +/* ── Drag handle icon ── */ + +const DragIcon: React.FC = () => ( + + + + + + + + +) diff --git a/src/components/KanbanBoard/index.tsx b/src/components/KanbanBoard/index.tsx new file mode 100644 index 0000000..6872be8 --- /dev/null +++ b/src/components/KanbanBoard/index.tsx @@ -0,0 +1 @@ +export { KanbanBoard } from './KanbanBoard' diff --git a/src/components/PasswordStrengthAnalyzer/index.tsx b/src/components/PasswordStrengthAnalyzer/index.tsx new file mode 100644 index 0000000..395cd8c --- /dev/null +++ b/src/components/PasswordStrengthAnalyzer/index.tsx @@ -0,0 +1,37 @@ +import React, { useEffect } from 'react' +import { type FC } from 'react' +import { Retool } from '@tryretool/custom-component-support' +import { calculateStrength } from './utils' + +export const PasswordStrengthAnalyzer: FC = () => { + const [password, _setPassword] = Retool.useStateString({ + name: 'password' + }) + + const [score, setScore] = Retool.useStateNumber({ + name: 'score', + initialValue: 0, + inspector: 'hidden' + }) + + useEffect(() => { + const newScore = calculateStrength(password) + if (newScore !== score) { + setScore(newScore) + } + }, [password, score, setScore]) + + return ( +
+

Password Strength Analyzer

+

+ This demo shows how to pass state back and forth between a retool app and a custom component. + Please don't use this component in production. +

+

+

Input Password: {'*'.repeat(password?.length || 0)}
+
Strength Score: {score ?? 0}/4
+

+
+ ) +} diff --git a/src/components/PasswordStrengthAnalyzer/utils.test.ts b/src/components/PasswordStrengthAnalyzer/utils.test.ts new file mode 100644 index 0000000..bbedcfc --- /dev/null +++ b/src/components/PasswordStrengthAnalyzer/utils.test.ts @@ -0,0 +1,16 @@ +import { describe, it, expect } from 'vitest' +import { calculateStrength } from './utils' + +describe('calculateStrength', () => { + it('returns 0 for empty password', () => { + expect(calculateStrength('')).toBe(0) + }) + + it('calculates strength correctly', () => { + expect(calculateStrength('password')).toBe(0) // width < 8 no spec chars + expect(calculateStrength('longpassword')).toBe(1) // length > 8 + expect(calculateStrength('Longpassword')).toBe(2) // length > 8 + uppercase + expect(calculateStrength('Longpassword1')).toBe(3) // length > 8 + uppercase + number + expect(calculateStrength('Longpassword1!')).toBe(4) // length > 8 + uppercase + number + special char + }) +}) diff --git a/src/components/PasswordStrengthAnalyzer/utils.ts b/src/components/PasswordStrengthAnalyzer/utils.ts new file mode 100644 index 0000000..69dd9b9 --- /dev/null +++ b/src/components/PasswordStrengthAnalyzer/utils.ts @@ -0,0 +1,11 @@ +export const calculateStrength = (password: string): number => { + let score = 0 + if (!password) return 0 + + if (password.length > 8) score += 1 + if (/[A-Z]/.test(password)) score += 1 + if (/[0-9]/.test(password)) score += 1 + if (/[^A-Za-z0-9]/.test(password)) score += 1 + + return score +} diff --git a/src/components/QRCodeGenerator/QRCodeGenerator.module.css b/src/components/QRCodeGenerator/QRCodeGenerator.module.css new file mode 100644 index 0000000..4d106a9 --- /dev/null +++ b/src/components/QRCodeGenerator/QRCodeGenerator.module.css @@ -0,0 +1,112 @@ +.root { + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + background: #0D0F12; + border-radius: 10px; + font-family: Inter, system-ui, sans-serif; + padding: 20px; + box-sizing: border-box; +} + +/* ── Card ── */ + +.card { + display: flex; + flex-direction: column; + align-items: center; + gap: 14px; + width: 100%; +} + +/* ── QR wrap ── */ + +.qrWrap { + border-radius: 10px; + overflow: hidden; + box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.06), 0 8px 32px rgba(0, 0, 0, 0.5); + flex-shrink: 0; + line-height: 0; +} + +/* ── Title ── */ + +.title { + font-size: 14px; + font-weight: 600; + color: #E2E8F0; + text-align: center; + letter-spacing: 0.01em; +} + +/* ── Value preview ── */ + +.valuePreview { + font-size: 11px; + color: #475569; + text-align: center; + max-width: 260px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + font-family: 'Menlo', 'Monaco', 'Consolas', monospace; +} + +/* ── Actions ── */ + +.actions { + display: flex; + gap: 8px; +} + +.btn { + display: flex; + align-items: center; + gap: 6px; + background: #1A1D24; + border: 1px solid rgba(255, 255, 255, 0.09); + border-radius: 7px; + color: #94A3B8; + font-size: 12px; + font-weight: 500; + padding: 7px 13px; + cursor: pointer; + transition: background 0.12s, color 0.12s, border-color 0.12s; + font-family: inherit; + white-space: nowrap; + text-decoration: none; +} + +.btn[aria-disabled='true'] { + opacity: 0.4; + pointer-events: none; +} + +.btn:hover { + background: #1f2330; + color: #CBD5E1; + border-color: rgba(255, 255, 255, 0.14); +} + +.btn:active { + transform: scale(0.97); +} + +.btnSuccess { + color: #10B981; + border-color: rgba(16, 185, 129, 0.3); + background: rgba(16, 185, 129, 0.08); +} + +.btnSuccess:hover { + background: rgba(16, 185, 129, 0.12); + color: #10B981; +} + +.btnError { + color: #EF4444; + border-color: rgba(239, 68, 68, 0.3); + background: rgba(239, 68, 68, 0.08); +} diff --git a/src/components/QRCodeGenerator/QRCodeGenerator.tsx b/src/components/QRCodeGenerator/QRCodeGenerator.tsx new file mode 100644 index 0000000..e20e7d2 --- /dev/null +++ b/src/components/QRCodeGenerator/QRCodeGenerator.tsx @@ -0,0 +1,222 @@ +import React, { FC, useRef, useState, useEffect } from 'react' +import { Retool } from '@tryretool/custom-component-support' +import { QRCodeCanvas } from 'qrcode.react' +import styles from './QRCodeGenerator.module.css' + +type ErrorLevel = 'L' | 'M' | 'Q' | 'H' + +/* ── Icons ── */ + +const DownloadIcon = () => ( + + + +) + +const CopyIcon = () => ( + + + + +) + +const CheckIcon = () => ( + + + +) + +/* ── Component ── */ + +export const QRCodeGenerator: FC = () => { + Retool.useComponentSettings({ defaultWidth: 280, defaultHeight: 380 }) + + /* ── Inputs ── */ + + const [value] = Retool.useStateString({ + name: 'value', + label: 'Value', + description: 'Text or URL to encode in the QR code', + initialValue: 'https://retool.com', + }) + + const [size] = Retool.useStateNumber({ + name: 'size', + label: 'Size (px)', + description: 'Width and height of the QR code in pixels', + initialValue: 200, + }) + + const [fgColor] = Retool.useStateString({ + name: 'fgColor', + label: 'Foreground color', + description: 'QR code dot color (hex)', + initialValue: '#000000', + }) + + const [bgColor] = Retool.useStateString({ + name: 'bgColor', + label: 'Background color', + description: 'QR code background color (hex)', + initialValue: '#FFFFFF', + }) + + const [errorLevel] = Retool.useStateString({ + name: 'errorLevel', + label: 'Error correction', + description: 'L = 7%, M = 15%, Q = 25%, H = 30%. Use H when embedding a logo.', + initialValue: 'M', + }) + + const [logoUrl] = Retool.useStateString({ + name: 'logoUrl', + label: 'Logo URL', + description: 'Optional image URL to embed in the center of the QR code', + initialValue: '', + }) + + const [logoSize] = Retool.useStateNumber({ + name: 'logoSize', + label: 'Logo size (%)', + description: 'Logo width as a percentage of the QR code size (5–30). Ignored if no logo URL.', + initialValue: 20, + }) + + const [title] = Retool.useStateString({ + name: 'title', + label: 'Title', + description: 'Label shown below the QR code', + initialValue: '', + }) + + /* ── Outputs ── */ + + const [, setDataUrl] = Retool.useStateString({ + name: 'dataUrl', + label: 'Data URL', + description: 'QR code as a PNG data URL — use in Image components or store in a DB column', + initialValue: '', + }) + + /* ── Events ── */ + + const onDownload = Retool.useEventCallback({ name: 'download' }) + const onCopy = Retool.useEventCallback({ name: 'copy' }) + + /* ── Derived values ── */ + + const safeValue = value?.trim() || 'https://retool.com' + const safeSize = Math.max(64, Math.min(512, size || 200)) + const safeErrorLevel = (['L', 'M', 'Q', 'H'].includes((errorLevel || '').toUpperCase()) + ? errorLevel.toUpperCase() + : 'M') as ErrorLevel + const safeLogoSize = Math.max(5, Math.min(30, logoSize || 20)) + const logoPixels = Math.round(safeSize * (safeLogoSize / 100)) + + /* ── Local state ── */ + + const canvasRef = useRef(null) + // localDataUrl drives the download href — updated whenever QR changes + const [localDataUrl, setLocalDataUrl] = useState('') + const [copied, setCopied] = useState(false) + const [copyError, setCopyError] = useState(false) + + const downloadFilename = `qr-${safeValue.slice(0, 40).replace(/[^a-z0-9]/gi, '-')}.png` + + /* ── Sync data URL whenever QR changes ── */ + + useEffect(() => { + const timeout = setTimeout(() => { + const canvas = canvasRef.current?.querySelector('canvas') + if (!canvas) return + try { + const url = canvas.toDataURL('image/png') + setLocalDataUrl(url) + setDataUrl(url) + } catch { + // Cross-origin logo blocks toDataURL — leave previous value + } + }, 120) + return () => clearTimeout(timeout) + }, [safeValue, safeSize, fgColor, bgColor, safeErrorLevel, logoUrl, safeLogoSize, setDataUrl]) + + /* ── Copy value text to clipboard ── */ + // Copying image data requires ClipboardItem which is blocked in iframes. + // Copying the encoded text is more reliable and just as useful. + + const handleCopy = async () => { + try { + await navigator.clipboard.writeText(safeValue) + setCopied(true) + setCopyError(false) + onCopy() + setTimeout(() => setCopied(false), 2000) + } catch { + setCopyError(true) + setTimeout(() => setCopyError(false), 2000) + } + } + + /* ── Logo image settings ── */ + + const imageSettings = logoUrl?.trim() + ? { src: logoUrl.trim(), height: logoPixels, width: logoPixels, excavate: true } + : undefined + + /* ── Render ── */ + + return ( + + ) +} diff --git a/src/components/QRCodeGenerator/README.md b/src/components/QRCodeGenerator/README.md new file mode 100644 index 0000000..30affa5 --- /dev/null +++ b/src/components/QRCodeGenerator/README.md @@ -0,0 +1,77 @@ +# QR Code Generator + +![QR Code Generator preview](cover.png) + +A Retool Custom Component that generates styled QR codes with live customization controls built directly into the component. Choose from 6 dot shapes, set border styles, pick colors for shapes and background, embed a logo, and download the result as a PNG — all without leaving the canvas. + +## Features + +- 6 dot shape styles: Square, Dots, Rounded, Soft, Classy, and Classy+ +- Border options: None, Thin, Medium, Thick, Dashed, and Dotted +- Shape and background color pickers built into the component +- Optional logo/image embedded in the center of the QR code +- Configurable error correction level (L / M / Q / H) +- One-click PNG download +- QR image exposed as a base64 Data URL — connect to an Image component or save to a database +- Fires a `download` event on PNG export — wire to any downstream query +- Fully responsive — scales to fit its container without overlapping controls + +## Installation + +1. In your Retool app, open the **Component** panel and add a **Custom Component** +2. Import this component from the repository +3. Set the **Value** field in the inspector to the URL or text you want to encode +4. Optionally set a **Title**, adjust **Size**, or embed a **Logo URL** + +## Properties + +| Property | Type | Description | +|---|---|---| +| `value` | string | Text or URL to encode in the QR code | +| `title` | string | Optional label shown below the QR code | +| `size` | number | Width and height of the QR code in pixels (default 200) | +| `fgColor` | string | Color of the QR code dots/shapes (hex) | +| `bgColor` | string | Color of the light squares inside the QR code (hex) | +| `dotShape` | enumeration | Dot style: `square`, `dots`, `rounded`, `extra-rounded`, `classy`, `classy-rounded` | +| `borderStyle` | enumeration | Border around the QR code: `none`, `thin`, `medium`, `thick`, `dashed`, `dotted` | +| `borderColor` | string | Color of the border (hex) | +| `logoUrl` | string | Optional image URL to embed in the center of the QR code | +| `logoSize` | number | Logo width as a percentage of QR code size (5–30, default 20) | +| `errorLevel` | string | Error correction level: `L` (7%), `M` (15%), `Q` (25%), `H` (30%). Use `H` when embedding a logo | +| `dataUrl` | string | The QR code as a base64 PNG — use with an Image component or to save to a database | + +## Events + +| Event | Description | +|---|---| +| `download` | Fires when the user clicks **Download PNG** | + +## Usage + +### Basic setup + +Set the **Value** inspector field to the URL or text you want to encode. The QR code renders immediately. Use the shape and border selectors inside the component to style it without touching the inspector. + +### Embedding a logo + +Set **Logo URL** to any publicly accessible image URL. Increase **Error correction** to `H` (30%) so the QR remains scannable even with a logo covering part of it. Use **Logo size** to control how much of the QR the logo covers (5–30%). + +### Capturing the QR as an image + +The `dataUrl` output property contains the QR code as a base64-encoded PNG. Connect it to a Retool Image component's **Image source** field to display it elsewhere, or pass it to a query to save it to a database or storage bucket. + +### Wiring the download event + +Connect the `download` event to a query if you want to log, track, or trigger an action whenever a user exports the QR code. + +## Ideal Use Cases + +- Marketing and campaign dashboards — generate QR codes for URLs, coupons, or landing pages +- Product and inventory management — encode SKUs, barcodes, or asset IDs +- Event management — generate QR codes for tickets or check-in links +- Customer-facing tools — let users create branded QR codes with a logo +- Any workflow that needs a scannable code generated on the fly + +## Author + +Created by [@angelikretool](https://github.com/angelikretool) for the Retool community. diff --git a/src/components/QRCodeGenerator/index.tsx b/src/components/QRCodeGenerator/index.tsx new file mode 100644 index 0000000..d09aa9f --- /dev/null +++ b/src/components/QRCodeGenerator/index.tsx @@ -0,0 +1 @@ +export { QRCodeGenerator } from './QRCodeGenerator' diff --git a/src/index.tsx b/src/index.tsx index d28c07a..eef0757 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1 +1,6 @@ -export { AdjustableProgressBar } from './components/AdjustableProgressBar' +export { HelloWorld } from './components/HelloWorld' +export { PasswordStrengthAnalyzer } from './components/PasswordStrengthAnalyzer' +export { GanttChart } from './components/GanttChart' +export { KanbanBoard } from './components/KanbanBoard' +export { QRCodeGenerator } from './components/QRCodeGenerator' +export { AdjustableProgressBar } from './components/AdjustableProgressBar' \ No newline at end of file From 8ede65913270bfecdc5b30efc940cc24e7d2e7f4 Mon Sep 17 00:00:00 2001 From: juliomachines Date: Thu, 28 May 2026 14:49:50 -0300 Subject: [PATCH 4/5] fix README --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 180bb14..32cf4f6 100644 --- a/README.md +++ b/README.md @@ -70,7 +70,7 @@ This prompts for a library name and description, writes metadata to `package.jso ### Step 5 — Build your component -Edit the component in `src/components/AdjustableProgressBar/` or create a new folder for additional components: +Rename the `HelloWorld` component in `src/components/` or create a new folder for your component: ``` src/ From b2d1c85477af0c1dce3830831e1b031de0b62f32 Mon Sep 17 00:00:00 2001 From: juliomachines Date: Thu, 28 May 2026 14:50:43 -0300 Subject: [PATCH 5/5] fix package --- package.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index db284e5..1e56f20 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,7 @@ "private": true, "dependencies": { "@tryretool/custom-component-support": "latest", + "qrcode.react": "^4.2.0", "react": "^18.2.0", "react-dom": "^18.2.0" }, @@ -44,4 +45,4 @@ "entryPoint": "src/index.tsx", "outputPath": "dist" } -} \ No newline at end of file +}