Compare commits
17 Commits
9dc3285adb
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1047642909 | ||
|
|
8caf6fbba5 | ||
|
|
3727ace366 | ||
|
|
81c399ab42 | ||
|
|
518b0127d2 | ||
|
|
deb2bf0b8a | ||
|
|
e4ccff4bbf | ||
|
|
0b1cfbdabd | ||
|
|
c580a5f7b0 | ||
|
|
9232052508 | ||
|
|
fcbfeb44f8 | ||
|
|
dd0b98e077 | ||
|
|
dcba58c2b9 | ||
|
|
7b3dce06ea | ||
|
|
13e694aa82 | ||
|
|
6a14eebf25 | ||
|
|
3a475e9df2 |
0
.env.example
Normal file → Executable file
3
.gitignore
vendored
Normal file → Executable file
@@ -11,7 +11,6 @@ node_modules/
|
|||||||
/backup
|
/backup
|
||||||
|
|
||||||
# Misc
|
# Misc
|
||||||
.DS_Store
|
|
||||||
.env.local
|
.env.local
|
||||||
.env.development.local
|
.env.development.local
|
||||||
.env.test.local
|
.env.test.local
|
||||||
@@ -46,4 +45,6 @@ pids
|
|||||||
*.swo
|
*.swo
|
||||||
|
|
||||||
# OS generated files
|
# OS generated files
|
||||||
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
._*
|
||||||
|
|||||||
@@ -1,13 +0,0 @@
|
|||||||
---
|
|
||||||
trigger: always_on
|
|
||||||
---
|
|
||||||
|
|
||||||
keep look the issue globally, not narrow. we are done chasing symtomp with narrow sight, we have things to be achieved:
|
|
||||||
A. main goal: having a working HTML Preview with element inspector and editor feature, and
|
|
||||||
B. sub goal: implementing the "stable option A DOM Manipulation" properly to reach the main goal (A)
|
|
||||||
|
|
||||||
In every reported issue, check if that prevent us to achieved the sub goal. Failing sub goal means fail to reach the main goal. So pivot everything to make a success sub goal, to achieve main goal.
|
|
||||||
|
|
||||||
I believe promised sub goal is the way to get succeed on the main goal.
|
|
||||||
|
|
||||||
Avoid any looping thought
|
|
||||||
0
BACKEND_REQUIREMENTS.md
Normal file → Executable file
0
DOCUMENTATION_INDEX.md
Normal file → Executable file
0
EDITOR_CHECKLIST.md
Normal file → Executable file
0
EDITOR_TOOL_GUIDE.md
Normal file → Executable file
0
FEATURE_TOGGLE_GUIDE.md
Normal file → Executable file
811
PLAN_ACCOUNTS_AND_STORAGE.md
Executable file
@@ -0,0 +1,811 @@
|
|||||||
|
# Dewe.Dev — Accounts, File Storage & Pro Features Plan
|
||||||
|
|
||||||
|
**Created:** February 18, 2026
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Executive Summary
|
||||||
|
|
||||||
|
Add user accounts and cloud file storage to dewe.dev without changing the existing UX. Visitors and logged-in users use the exact same tools. The only additions: a login/account page, save/load buttons per tool, and server-side URL fetching for Pro users. Backend powered by self-hosted Supabase on Coolify.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Guiding Principles
|
||||||
|
|
||||||
|
1. **Zero UX disruption** — Tools work identically for everyone. Auth is invisible until the user wants it.
|
||||||
|
2. **Frontend stays king** — All processing remains client-side. The backend is only for storage, auth, and proxy.
|
||||||
|
3. **Progressive enhancement** — Each feature layer (auth → storage → proxy → billing) can ship independently.
|
||||||
|
4. **Privacy preserved** — File content is user-owned. RLS enforces isolation. No analytics on file content.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture Overview
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────┐
|
||||||
|
│ dewe.dev (React SPA) │
|
||||||
|
│ │
|
||||||
|
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌───────────┐ │
|
||||||
|
│ │ MD │ │Invoice │ │ Table │ │ Object │ │
|
||||||
|
│ │ Editor │ │ Editor │ │ Editor │ │ Editor │ │
|
||||||
|
│ └────┬────┘ └────┬────┘ └────┬────┘ └─────┬─────┘ │
|
||||||
|
│ │ │ │ │ │
|
||||||
|
│ └────────────┴─────┬──────┴──────────────┘ │
|
||||||
|
│ │ │
|
||||||
|
│ ┌───────────┴───────────┐ │
|
||||||
|
│ │ useAuth() context │ │
|
||||||
|
│ │ useUserFiles() hook │ │
|
||||||
|
│ └───────────┬───────────┘ │
|
||||||
|
└──────────────────────────┼──────────────────────────────┘
|
||||||
|
│
|
||||||
|
HTTPS (Supabase JS SDK)
|
||||||
|
│
|
||||||
|
┌──────────────────────────┼──────────────────────────────┐
|
||||||
|
│ Self-hosted Supabase (Coolify) │
|
||||||
|
│ │ │
|
||||||
|
│ ┌───────────┐ ┌────────┴────────┐ ┌───────────────┐ │
|
||||||
|
│ │ GoTrue │ │ PostgREST │ │ Edge Functions│ │
|
||||||
|
│ │ (Auth) │ │ (REST API) │ │ (CORS Proxy) │ │
|
||||||
|
│ └───────────┘ └────────┬────────┘ └───────────────┘ │
|
||||||
|
│ │ │
|
||||||
|
│ ┌────────┴────────┐ │
|
||||||
|
│ │ PostgreSQL │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ • auth.users │ │
|
||||||
|
│ │ • profiles │ │
|
||||||
|
│ │ • user_files │ │
|
||||||
|
│ │ • proxy_logs │ │
|
||||||
|
│ └─────────────────┘ │
|
||||||
|
└─────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 1: Supabase Setup on Coolify
|
||||||
|
|
||||||
|
**Goal:** Get the backend running and verified before writing any frontend code.
|
||||||
|
|
||||||
|
### 1.1 Deploy Supabase on Coolify
|
||||||
|
|
||||||
|
Coolify supports Supabase as a one-click service (or via Docker Compose).
|
||||||
|
|
||||||
|
**Resource Requirements:**
|
||||||
|
| Resource | Minimum | Recommended |
|
||||||
|
|----------|---------|-------------|
|
||||||
|
| CPU | 2 cores | 4 cores |
|
||||||
|
| RAM | 4 GB | 8 GB |
|
||||||
|
| Disk | 20 GB SSD | 50 GB SSD |
|
||||||
|
|
||||||
|
**Services that spin up:**
|
||||||
|
- PostgreSQL (database)
|
||||||
|
- GoTrue (auth)
|
||||||
|
- PostgREST (auto-generated REST API)
|
||||||
|
- Realtime (websockets — can disable if unused)
|
||||||
|
- Storage API (file storage — optional for this use case)
|
||||||
|
- Edge Runtime (Deno functions)
|
||||||
|
- Studio (admin dashboard)
|
||||||
|
- Kong/Envoy (API gateway)
|
||||||
|
|
||||||
|
**Critical Coolify configuration:**
|
||||||
|
- Map persistent volumes for PostgreSQL data directory — data survives container restarts
|
||||||
|
- Set up SSL via Coolify's built-in Let's Encrypt
|
||||||
|
- Configure `SITE_URL` to `https://dewe.dev`
|
||||||
|
- Set `ADDITIONAL_REDIRECT_URLS` for localhost dev
|
||||||
|
- Expose only the API gateway port (default 8000) and Studio port (default 3000)
|
||||||
|
|
||||||
|
### 1.2 Database Schema
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Profiles table (extends auth.users)
|
||||||
|
create table profiles (
|
||||||
|
id uuid primary key references auth.users on delete cascade,
|
||||||
|
display_name text,
|
||||||
|
tier text not null default 'free' check (tier in ('free', 'pro')),
|
||||||
|
tier_expires_at timestamptz,
|
||||||
|
storage_used_bytes bigint default 0,
|
||||||
|
created_at timestamptz default now(),
|
||||||
|
updated_at timestamptz default now()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Auto-create profile on signup
|
||||||
|
create or replace function handle_new_user()
|
||||||
|
returns trigger as $$
|
||||||
|
begin
|
||||||
|
insert into profiles (id, display_name)
|
||||||
|
values (new.id, coalesce(new.raw_user_meta_data->>'display_name', split_part(new.email, '@', 1)));
|
||||||
|
return new;
|
||||||
|
end;
|
||||||
|
$$ language plpgsql security definer;
|
||||||
|
|
||||||
|
create trigger on_auth_user_created
|
||||||
|
after insert on auth.users
|
||||||
|
for each row execute function handle_new_user();
|
||||||
|
|
||||||
|
-- Sync tier to JWT custom claims (app_metadata)
|
||||||
|
create or replace function sync_tier_to_claims()
|
||||||
|
returns trigger as $$
|
||||||
|
begin
|
||||||
|
update auth.users
|
||||||
|
set raw_app_meta_data = raw_app_meta_data || jsonb_build_object('tier', new.tier)
|
||||||
|
where id = new.id;
|
||||||
|
return new;
|
||||||
|
end;
|
||||||
|
$$ language plpgsql security definer;
|
||||||
|
|
||||||
|
create trigger on_tier_change
|
||||||
|
after update of tier on profiles
|
||||||
|
for each row execute function sync_tier_to_claims();
|
||||||
|
|
||||||
|
-- User files table
|
||||||
|
create table user_files (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
user_id uuid references auth.users on delete cascade not null,
|
||||||
|
tool_type text not null, -- 'markdown', 'invoice', 'object', 'table', 'beautifier'
|
||||||
|
name text not null,
|
||||||
|
content jsonb not null, -- The actual file data
|
||||||
|
metadata jsonb default '{}', -- Optional: last export format, preview text, etc.
|
||||||
|
size_bytes integer generated always as (octet_length(content::text)) stored,
|
||||||
|
created_at timestamptz default now(),
|
||||||
|
updated_at timestamptz default now()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indexes
|
||||||
|
create index idx_user_files_user_tool on user_files(user_id, tool_type);
|
||||||
|
create index idx_user_files_updated on user_files(user_id, updated_at desc);
|
||||||
|
|
||||||
|
-- Proxy usage logging (for rate limiting & analytics)
|
||||||
|
create table proxy_logs (
|
||||||
|
id bigserial primary key,
|
||||||
|
user_id uuid references auth.users not null,
|
||||||
|
target_url text not null,
|
||||||
|
method text not null,
|
||||||
|
status_code integer,
|
||||||
|
response_size integer,
|
||||||
|
duration_ms integer,
|
||||||
|
created_at timestamptz default now()
|
||||||
|
);
|
||||||
|
|
||||||
|
create index idx_proxy_logs_user_time on proxy_logs(user_id, created_at desc);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.3 Row Level Security (RLS)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Profiles: users can read/update only their own
|
||||||
|
alter table profiles enable row level security;
|
||||||
|
|
||||||
|
create policy "Users can view own profile"
|
||||||
|
on profiles for select using (auth.uid() = id);
|
||||||
|
|
||||||
|
create policy "Users can update own profile"
|
||||||
|
on profiles for update using (auth.uid() = id);
|
||||||
|
|
||||||
|
-- User files: full CRUD, own files only
|
||||||
|
alter table user_files enable row level security;
|
||||||
|
|
||||||
|
create policy "Users can view own files"
|
||||||
|
on user_files for select using (auth.uid() = user_id);
|
||||||
|
|
||||||
|
create policy "Users can insert own files"
|
||||||
|
on user_files for insert with check (auth.uid() = user_id);
|
||||||
|
|
||||||
|
create policy "Users can update own files"
|
||||||
|
on user_files for update using (auth.uid() = user_id);
|
||||||
|
|
||||||
|
create policy "Users can delete own files"
|
||||||
|
on user_files for delete using (auth.uid() = user_id);
|
||||||
|
|
||||||
|
-- Proxy logs: users can view own logs
|
||||||
|
alter table proxy_logs enable row level security;
|
||||||
|
|
||||||
|
create policy "Users can view own proxy logs"
|
||||||
|
on proxy_logs for select using (auth.uid() = user_id);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.4 Storage Limits
|
||||||
|
|
||||||
|
| Tier | Max files | Max file size | Total storage |
|
||||||
|
|------|-----------|---------------|---------------|
|
||||||
|
| Free | 20 | 256 KB | 5 MB |
|
||||||
|
| Pro | Unlimited | 2 MB | 100 MB |
|
||||||
|
|
||||||
|
Enforced via database function:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
create or replace function check_storage_limit()
|
||||||
|
returns trigger as $$
|
||||||
|
declare
|
||||||
|
current_count integer;
|
||||||
|
current_size bigint;
|
||||||
|
user_tier text;
|
||||||
|
max_count integer;
|
||||||
|
max_total bigint;
|
||||||
|
begin
|
||||||
|
select tier into user_tier from profiles where id = new.user_id;
|
||||||
|
|
||||||
|
if user_tier = 'free' then
|
||||||
|
max_count := 20;
|
||||||
|
max_total := 5 * 1024 * 1024; -- 5 MB
|
||||||
|
else
|
||||||
|
max_count := 10000;
|
||||||
|
max_total := 100 * 1024 * 1024; -- 100 MB
|
||||||
|
end if;
|
||||||
|
|
||||||
|
select count(*), coalesce(sum(size_bytes), 0)
|
||||||
|
into current_count, current_size
|
||||||
|
from user_files where user_id = new.user_id;
|
||||||
|
|
||||||
|
if current_count >= max_count then
|
||||||
|
raise exception 'File limit reached (% files for % tier)', max_count, user_tier;
|
||||||
|
end if;
|
||||||
|
|
||||||
|
if current_size + octet_length(new.content::text) > max_total then
|
||||||
|
raise exception 'Storage limit reached (% bytes for % tier)', max_total, user_tier;
|
||||||
|
end if;
|
||||||
|
|
||||||
|
return new;
|
||||||
|
end;
|
||||||
|
$$ language plpgsql;
|
||||||
|
|
||||||
|
create trigger check_storage_before_insert
|
||||||
|
before insert on user_files
|
||||||
|
for each row execute function check_storage_limit();
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.5 Backups
|
||||||
|
|
||||||
|
Self-hosted = you own backups. Set up immediately:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Cron job on Coolify host (daily at 3 AM)
|
||||||
|
0 3 * * * pg_dump -h localhost -U postgres -d postgres | gzip > /backups/dewe_$(date +\%Y\%m\%d).sql.gz
|
||||||
|
|
||||||
|
# Retention: keep 30 days
|
||||||
|
find /backups -name "dewe_*.sql.gz" -mtime +30 -delete
|
||||||
|
```
|
||||||
|
|
||||||
|
Optional: pipe to S3-compatible storage (MinIO on Coolify, or external).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2: Auth Integration (Frontend)
|
||||||
|
|
||||||
|
**Goal:** Add authentication without changing any existing tool UX.
|
||||||
|
|
||||||
|
### 2.1 Auth Provider Setup
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── contexts/
|
||||||
|
│ └── AuthContext.js # Supabase auth state
|
||||||
|
├── hooks/
|
||||||
|
│ ├── useAuth.js # Auth convenience hook
|
||||||
|
│ └── useUserFiles.js # File CRUD hook
|
||||||
|
├── components/
|
||||||
|
│ ├── AuthModal.js # Login/signup modal (not a page redirect)
|
||||||
|
│ ├── UserMenu.js # Header avatar/dropdown
|
||||||
|
│ └── SaveLoadBar.js # Save/Load UI for tools
|
||||||
|
├── pages/
|
||||||
|
│ └── AccountPage.js # /account - profile, files, settings
|
||||||
|
└── lib/
|
||||||
|
└── supabase.js # Supabase client init
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.2 "Ghost Auth" Pattern
|
||||||
|
|
||||||
|
The core UX principle: **tools never change behavior based on auth state.**
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────────────────┐
|
||||||
|
│ Tool Editor │
|
||||||
|
│ │
|
||||||
|
│ Visitor sees: │ Logged-in user sees: │
|
||||||
|
│ [Copy] [Download] │ [Copy] [Download] [Save] │
|
||||||
|
│ │ ↑ │
|
||||||
|
│ │ only addition │
|
||||||
|
└──────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**Rules:**
|
||||||
|
- No redirects to login page from tools. Ever.
|
||||||
|
- Clicking "Save" when not logged in → opens AuthModal (login/signup) as overlay
|
||||||
|
- Current editor state is preserved in React state during login
|
||||||
|
- After successful auth, the save action proceeds automatically
|
||||||
|
- "Load" button appears in the tool's input section (alongside Create/URL/Paste/Open tabs)
|
||||||
|
|
||||||
|
### 2.3 Auth Methods
|
||||||
|
|
||||||
|
| Method | Priority | Notes |
|
||||||
|
|--------|----------|-------|
|
||||||
|
| Email + Password | P0 | Core auth, must have |
|
||||||
|
| GitHub OAuth | P1 | Target audience is developers |
|
||||||
|
| Google OAuth | P2 | Broad reach, nice to have |
|
||||||
|
| Magic Link (Email) | P2 | Passwordless option |
|
||||||
|
|
||||||
|
### 2.4 Header Changes
|
||||||
|
|
||||||
|
```
|
||||||
|
Before (visitor):
|
||||||
|
┌─────────────────────────────────────────────────┐
|
||||||
|
│ [Logo] [Tools ▾] [🌙] [☰] │
|
||||||
|
└─────────────────────────────────────────────────┘
|
||||||
|
|
||||||
|
After (visitor):
|
||||||
|
┌─────────────────────────────────────────────────┐
|
||||||
|
│ [Logo] [Tools ▾] [🌙] [Sign In] [☰]│
|
||||||
|
└─────────────────────────────────────────────────┘
|
||||||
|
|
||||||
|
After (logged in):
|
||||||
|
┌─────────────────────────────────────────────────┐
|
||||||
|
│ [Logo] [Tools ▾] [🌙] [Avatar ▾] [☰]│
|
||||||
|
└─────────────────────────────────────────────────┘
|
||||||
|
|
||||||
|
Avatar dropdown:
|
||||||
|
├── Account & Files
|
||||||
|
├── ─────────────
|
||||||
|
└── Sign Out
|
||||||
|
```
|
||||||
|
|
||||||
|
Minimal. No dashboard. The "Account & Files" link goes to `/account`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3: File Save/Load per Tool
|
||||||
|
|
||||||
|
**Goal:** Each editor tool gets save/load capability for logged-in users.
|
||||||
|
|
||||||
|
### 3.1 Which Tools Support Save/Load
|
||||||
|
|
||||||
|
| Tool | Save Content | Content Format | Priority |
|
||||||
|
|------|-------------|----------------|----------|
|
||||||
|
| Markdown Editor | MD source text | `{ source: string }` | P0 |
|
||||||
|
| Invoice Editor | Invoice data | `{ invoice: {...}, template: string }` | P0 |
|
||||||
|
| Object Editor | JSON/PHP data | `{ input: string, format: string }` | P1 |
|
||||||
|
| Table Editor | Table data | `{ headers: [...], rows: [...] }` | P1 |
|
||||||
|
| Beautifier | Code input | `{ code: string, language: string }` | P2 |
|
||||||
|
| Diff Tool | Both texts | `{ left: string, right: string }` | P2 |
|
||||||
|
|
||||||
|
URL Encoder, Base64, and Text Length are **stateless tools** — no save/load needed.
|
||||||
|
|
||||||
|
### 3.2 Save/Load UX per Tool
|
||||||
|
|
||||||
|
**Save flow:**
|
||||||
|
1. User works in editor as normal
|
||||||
|
2. Clicks "Save" → if not logged in, AuthModal opens first
|
||||||
|
3. Save dialog: enter file name (pre-filled with smart default)
|
||||||
|
4. Saves to `user_files` via Supabase SDK
|
||||||
|
5. Toast: "Saved to your account"
|
||||||
|
|
||||||
|
**Load flow (new input tab):**
|
||||||
|
1. Tool input section gets a 5th tab: `[Create] [URL] [Paste] [Open] [My Files]`
|
||||||
|
2. "My Files" tab shows a list of saved files for this tool type
|
||||||
|
3. Each entry: name, date, size, [Load] [Delete] buttons
|
||||||
|
4. Loading replaces current editor content (with unsaved changes warning)
|
||||||
|
|
||||||
|
**Smart defaults for file names:**
|
||||||
|
- Markdown: first heading or "Untitled Document"
|
||||||
|
- Invoice: "Invoice #{number} - {client_name}"
|
||||||
|
- Table: "Table - {row_count} rows"
|
||||||
|
- Object: "Object - {key_count} keys"
|
||||||
|
|
||||||
|
### 3.3 `useUserFiles` Hook API
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const {
|
||||||
|
files, // File[] for current tool_type
|
||||||
|
loading, // boolean
|
||||||
|
saving, // boolean
|
||||||
|
error, // string | null
|
||||||
|
saveFile, // (name, content, metadata?) => Promise<File>
|
||||||
|
updateFile, // (id, content, metadata?) => Promise<File>
|
||||||
|
deleteFile, // (id) => Promise<void>
|
||||||
|
loadFiles, // () => Promise<void> (refresh)
|
||||||
|
storageUsed, // { count, bytes, limit }
|
||||||
|
} = useUserFiles('markdown');
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 4: Pro Proxy (Server-Side URL Fetch)
|
||||||
|
|
||||||
|
**Goal:** Pro users bypass CORS with a server-side proxy via Supabase Edge Function.
|
||||||
|
|
||||||
|
### 4.1 How It Works
|
||||||
|
|
||||||
|
```
|
||||||
|
Free user: Pro user:
|
||||||
|
Browser → Target API Browser → Edge Function → Target API
|
||||||
|
↓ ↓
|
||||||
|
CORS blocked ❌ No CORS ✅
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 Edge Function: `proxy-fetch`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// supabase/functions/proxy-fetch/index.ts
|
||||||
|
import { serve } from "https://deno.land/std/http/server.ts";
|
||||||
|
import { createClient } from "https://esm.sh/@supabase/supabase-js";
|
||||||
|
|
||||||
|
const BLOCKED_HOSTS = [
|
||||||
|
/^localhost$/i,
|
||||||
|
/^127\./,
|
||||||
|
/^10\./,
|
||||||
|
/^172\.(1[6-9]|2\d|3[01])\./,
|
||||||
|
/^192\.168\./,
|
||||||
|
/^0\./,
|
||||||
|
/^169\.254\./, // link-local
|
||||||
|
/^::1$/,
|
||||||
|
/^fc00:/i, /^fe80:/i, // IPv6 private
|
||||||
|
];
|
||||||
|
|
||||||
|
serve(async (req) => {
|
||||||
|
// 1. Verify JWT and extract tier
|
||||||
|
const authHeader = req.headers.get("Authorization");
|
||||||
|
const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY, {
|
||||||
|
global: { headers: { Authorization: authHeader } }
|
||||||
|
});
|
||||||
|
const { data: { user }, error } = await supabase.auth.getUser();
|
||||||
|
|
||||||
|
if (!user || user.app_metadata?.tier !== "pro") {
|
||||||
|
return new Response(JSON.stringify({ error: "Pro subscription required" }), {
|
||||||
|
status: 403
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Parse request
|
||||||
|
const { url, method, headers, body } = await req.json();
|
||||||
|
|
||||||
|
// 3. SSRF protection
|
||||||
|
const targetUrl = new URL(url);
|
||||||
|
if (BLOCKED_HOSTS.some(pattern => pattern.test(targetUrl.hostname))) {
|
||||||
|
return new Response(JSON.stringify({ error: "Blocked host" }), {
|
||||||
|
status: 400
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Rate limiting check (50 req/min for Pro)
|
||||||
|
const { count } = await supabase
|
||||||
|
.from("proxy_logs")
|
||||||
|
.select("*", { count: "exact", head: true })
|
||||||
|
.eq("user_id", user.id)
|
||||||
|
.gte("created_at", new Date(Date.now() - 60000).toISOString());
|
||||||
|
|
||||||
|
if (count >= 50) {
|
||||||
|
return new Response(JSON.stringify({ error: "Rate limit exceeded (50/min)" }), {
|
||||||
|
status: 429
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Forward request
|
||||||
|
const start = Date.now();
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: method || "GET",
|
||||||
|
headers: headers || {},
|
||||||
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
const responseBody = await response.text();
|
||||||
|
|
||||||
|
// 6. Log usage
|
||||||
|
await supabase.from("proxy_logs").insert({
|
||||||
|
user_id: user.id,
|
||||||
|
target_url: url,
|
||||||
|
method: method || "GET",
|
||||||
|
status_code: response.status,
|
||||||
|
response_size: responseBody.length,
|
||||||
|
duration_ms: Date.now() - start,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 7. Return response
|
||||||
|
return new Response(responseBody, {
|
||||||
|
status: response.status,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": response.headers.get("Content-Type") || "application/json",
|
||||||
|
"Access-Control-Allow-Origin": "https://dewe.dev",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.3 Frontend Integration
|
||||||
|
|
||||||
|
The existing `AdvancedURLFetch.js` component already has the full UI (method, headers, auth, body, presets). Currently hidden behind `{false && ...}`. Changes needed:
|
||||||
|
|
||||||
|
1. **Free users:** Basic fetch (client-side, GET only, CORS-dependent) — current behavior
|
||||||
|
2. **Pro users:** Toggle reveals advanced panel → requests go through Edge Function
|
||||||
|
3. Update `features.js` to read tier from `user.app_metadata.tier` instead of static value
|
||||||
|
4. Presets migration: move from localStorage to `user_files` with `tool_type: 'fetch_preset'`
|
||||||
|
|
||||||
|
### 4.4 Security Measures
|
||||||
|
|
||||||
|
| Threat | Mitigation |
|
||||||
|
|--------|-----------|
|
||||||
|
| SSRF (hitting internal services) | Block private IP ranges, resolve DNS before fetch |
|
||||||
|
| Abuse (crypto mining, spam) | Rate limiting (50 req/min), response size cap (5 MB) |
|
||||||
|
| Data exfiltration | Log all proxy requests, alert on anomalies |
|
||||||
|
| JWT forgery | Verify JWT server-side on every request |
|
||||||
|
| Cost attack (huge responses) | Stream with size limit, abort after 5 MB |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 5: Account Page
|
||||||
|
|
||||||
|
**Goal:** Single page at `/account` for profile, files, and settings. Minimal.
|
||||||
|
|
||||||
|
### 5.1 Page Layout
|
||||||
|
|
||||||
|
```
|
||||||
|
/account
|
||||||
|
┌─────────────────────────────────────────────────┐
|
||||||
|
│ Profile │
|
||||||
|
│ ┌─────────────────────────────────────────────┐ │
|
||||||
|
│ │ [Avatar] display_name tier_badge │ │
|
||||||
|
│ │ email │ │
|
||||||
|
│ │ Member since: date │ │
|
||||||
|
│ │ [Edit Profile] │ │
|
||||||
|
│ └─────────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ Storage: ████████░░░░ 2.3 MB / 5 MB (Free) │
|
||||||
|
│ [Upgrade to Pro] │
|
||||||
|
│ │
|
||||||
|
│ My Files [Filter ▾] │
|
||||||
|
│ ┌─────────────────────────────────────────────┐ │
|
||||||
|
│ │ 📝 Project README.md 12 KB 2h ago │ │
|
||||||
|
│ │ 📄 Invoice #042 - Acme 3 KB 1d ago │ │
|
||||||
|
│ │ 📊 Sales Data Q4 8 KB 3d ago │ │
|
||||||
|
│ │ { } API Response Sample 1 KB 1w ago │ │
|
||||||
|
│ └─────────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ Each file row: [Open in Editor] [Download] [🗑] │
|
||||||
|
│ │
|
||||||
|
│ ────────────────────────────────────────────── │
|
||||||
|
│ [Sign Out] [Delete Account] │
|
||||||
|
└─────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 "Open in Editor" Flow
|
||||||
|
|
||||||
|
Clicking "Open in Editor" on a saved file:
|
||||||
|
1. Navigates to the corresponding tool (e.g., `/markdown-editor`)
|
||||||
|
2. Passes file ID via query param: `/markdown-editor?file=uuid`
|
||||||
|
3. Tool detects `?file=` param → fetches from `user_files` → loads into editor
|
||||||
|
4. Editor shows "Editing: {filename}" indicator
|
||||||
|
5. Subsequent saves update the same file (not create new)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 6: localStorage Migration
|
||||||
|
|
||||||
|
**Goal:** Seamlessly migrate existing localStorage data to cloud on first login.
|
||||||
|
|
||||||
|
### 6.1 Migration Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
User logs in for the first time
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Check localStorage for known keys:
|
||||||
|
• urlFetchPresets
|
||||||
|
• (any other persisted tool data)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Found data? ──No──→ Done
|
||||||
|
│
|
||||||
|
Yes
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Show toast: "We found saved data on this device.
|
||||||
|
Would you like to sync it to your account?"
|
||||||
|
│
|
||||||
|
┌────┴────┐
|
||||||
|
Yes No
|
||||||
|
│ │
|
||||||
|
▼ ▼
|
||||||
|
Upload Dismiss
|
||||||
|
to DB (ask again
|
||||||
|
+ clear next login)
|
||||||
|
localStorage
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.2 Known localStorage Keys to Migrate
|
||||||
|
|
||||||
|
| Key | Tool | Migration Target |
|
||||||
|
|-----|------|-----------------|
|
||||||
|
| `urlFetchPresets` | AdvancedURLFetch | `user_files` with `tool_type: 'fetch_preset'` |
|
||||||
|
|
||||||
|
Currently this is the only localStorage key with user data. As tools add auto-save to localStorage (pre-auth), add those keys here.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 7: Pro Tier & Billing (Future)
|
||||||
|
|
||||||
|
**Goal:** Monetize Pro features. Not in initial scope but schema is ready.
|
||||||
|
|
||||||
|
### 7.1 Pro Features Summary
|
||||||
|
|
||||||
|
| Feature | Free | Pro |
|
||||||
|
|---------|------|-----|
|
||||||
|
| All tools | Yes | Yes |
|
||||||
|
| Copy & Download | Yes | Yes |
|
||||||
|
| Save files | 20 files, 5 MB | Unlimited, 100 MB |
|
||||||
|
| URL Fetch | Client-side (CORS-dependent) | Server-side proxy (any API) |
|
||||||
|
| Fetch presets | localStorage only | Cloud-synced |
|
||||||
|
| Advanced Fetch UI | Hidden | Full (methods, headers, auth, body) |
|
||||||
|
| Ads | Yes | No |
|
||||||
|
|
||||||
|
### 7.2 Pricing (Recommendation)
|
||||||
|
|
||||||
|
**Subscription model** (recurring revenue > one-time):
|
||||||
|
|
||||||
|
| Plan | Price | Billing |
|
||||||
|
|------|-------|---------|
|
||||||
|
| Monthly | $4.99/mo | Monthly |
|
||||||
|
| Annual | $39.99/yr (~$3.33/mo) | Annual |
|
||||||
|
|
||||||
|
**Why subscription over one-time:**
|
||||||
|
- Server costs are ongoing (Coolify hosting, proxy bandwidth)
|
||||||
|
- Aligns cost with value delivered
|
||||||
|
- Predictable revenue
|
||||||
|
|
||||||
|
**Payment provider:** Stripe (or Lemon Squeezy for simpler tax handling).
|
||||||
|
|
||||||
|
### 7.3 Tier Enforcement Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
Client-side (fast):
|
||||||
|
user.app_metadata.tier → show/hide UI elements
|
||||||
|
|
||||||
|
Server-side (secure):
|
||||||
|
JWT claims → Edge Function checks tier before processing
|
||||||
|
DB triggers → sync tier changes to JWT claims
|
||||||
|
Storage limits → DB trigger on insert
|
||||||
|
```
|
||||||
|
|
||||||
|
The `sync_tier_to_claims` trigger (Phase 1 schema) ensures that when you update `profiles.tier`, the JWT custom claim is updated. The user's next token refresh picks up the new tier automatically.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Order
|
||||||
|
|
||||||
|
```
|
||||||
|
Phase 1: Supabase on Coolify ░░░░░░░░ ~1 day
|
||||||
|
└─ Deploy, configure, run schema
|
||||||
|
└─ Verify connection from localhost
|
||||||
|
|
||||||
|
Phase 2: Auth Integration ░░░░░░░░░░░░ ~2 days
|
||||||
|
└─ supabase.js client setup
|
||||||
|
└─ AuthContext + useAuth hook
|
||||||
|
└─ AuthModal (login/signup overlay)
|
||||||
|
└─ Header UserMenu
|
||||||
|
└─ /account page (basic)
|
||||||
|
|
||||||
|
Phase 3: File Save/Load ░░░░░░░░░░░░░░░░ ~3 days
|
||||||
|
└─ useUserFiles hook
|
||||||
|
└─ SaveLoadBar component
|
||||||
|
└─ "My Files" tab in each tool
|
||||||
|
└─ Account page file manager
|
||||||
|
└─ Markdown Editor integration (first)
|
||||||
|
└─ Invoice Editor integration
|
||||||
|
└─ Remaining tools
|
||||||
|
|
||||||
|
Phase 4: Pro Proxy ░░░░░░░░░░░░ ~2 days
|
||||||
|
└─ Edge Function deployment
|
||||||
|
└─ SSRF protection
|
||||||
|
└─ Rate limiting
|
||||||
|
└─ AdvancedURLFetch rewire
|
||||||
|
└─ Feature flags from JWT
|
||||||
|
|
||||||
|
Phase 5: localStorage Migration ░░░░ ~0.5 day
|
||||||
|
└─ Detection + migration prompt
|
||||||
|
|
||||||
|
Phase 6: Polish & Testing ░░░░░░░░ ~1.5 days
|
||||||
|
└─ Error states, loading states
|
||||||
|
└─ Mobile responsive check
|
||||||
|
└─ Auth edge cases (expired session, etc.)
|
||||||
|
|
||||||
|
Total estimate: ~10 days
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Technical Decisions & Rationale
|
||||||
|
|
||||||
|
### Why JSONB in Postgres (not Supabase Storage)?
|
||||||
|
|
||||||
|
All saved content is text-based and < 2 MB. JSONB gives us:
|
||||||
|
- Queryable content (search inside saved files later)
|
||||||
|
- Indexable fields (filter by tool_type, sort by date)
|
||||||
|
- Single table, single query — no separate file download step
|
||||||
|
- Atomic operations (save file + update metadata in one transaction)
|
||||||
|
- Simpler backup (one pg_dump gets everything)
|
||||||
|
|
||||||
|
Supabase Storage (S3-compatible) would be better for binary files (images, PDFs > 10 MB). We don't have that use case.
|
||||||
|
|
||||||
|
### Why Edge Functions (not a separate Node.js proxy)?
|
||||||
|
|
||||||
|
- Already included in self-hosted Supabase — no extra deployment
|
||||||
|
- Direct access to Supabase Auth (JWT verification built-in)
|
||||||
|
- Deno runtime is secure by default (explicit permissions)
|
||||||
|
- Scales with the Supabase instance
|
||||||
|
- One less service to maintain on Coolify
|
||||||
|
|
||||||
|
### Why Modal Login (not a /login page)?
|
||||||
|
|
||||||
|
- User is mid-work in an editor when they hit "Save"
|
||||||
|
- Redirecting to /login would lose their unsaved editor state
|
||||||
|
- Modal keeps React state intact — after login, save proceeds immediately
|
||||||
|
- Better conversion: lower friction = more signups
|
||||||
|
|
||||||
|
### Why Custom Claims for Tier (not DB lookup per request)?
|
||||||
|
|
||||||
|
- JWT claims are verified locally — zero network latency
|
||||||
|
- Edge Function doesn't need a DB query to check tier
|
||||||
|
- DB trigger keeps claims in sync automatically
|
||||||
|
- Standard Supabase pattern, well-documented
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Risks & Mitigations
|
||||||
|
|
||||||
|
| Risk | Impact | Mitigation |
|
||||||
|
|------|--------|-----------|
|
||||||
|
| Supabase self-hosted instability | High | Monitor with Coolify health checks, set up alerts, daily backups |
|
||||||
|
| Proxy abuse (SSRF, scraping) | High | IP blocklist, rate limiting, URL validation, response size cap |
|
||||||
|
| Storage costs grow | Medium | Enforce limits per tier, compress JSONB, monitor `storage_used_bytes` |
|
||||||
|
| Auth session expired mid-edit | Medium | Silent token refresh, save to localStorage as fallback, retry on 401 |
|
||||||
|
| Migration from localStorage fails | Low | Non-destructive: keep localStorage as fallback, retry option |
|
||||||
|
| Coolify resource limits | Medium | Start with 4 GB RAM, monitor, scale vertically as needed |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
1. **OAuth providers** — GitHub OAuth is ideal for dev audience. Worth adding Google too for broader reach? Both are easy with Supabase Auth.
|
||||||
|
|
||||||
|
2. **File sharing** — Should saved files be shareable via public link? (e.g., share a markdown preview). Not in initial scope, but schema supports adding a `is_public` flag later.
|
||||||
|
|
||||||
|
3. **Auto-save** — Should tools auto-save to cloud every N seconds for logged-in users? Or only manual save? Recommendation: manual save only (simpler, less backend load, user controls what gets saved).
|
||||||
|
|
||||||
|
4. **Offline support** — If Supabase is down, tools still work (client-side). But save/load fails. Should we queue saves in localStorage and sync when back online?
|
||||||
|
|
||||||
|
5. **Delete account** — GDPR requires account deletion. The `on delete cascade` in the schema handles this. Need a confirmation flow in the UI.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Structure Changes
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── lib/
|
||||||
|
│ └── supabase.js # NEW: Supabase client init
|
||||||
|
├── contexts/
|
||||||
|
│ └── AuthContext.js # NEW: Auth state provider
|
||||||
|
├── hooks/
|
||||||
|
│ ├── useAuth.js # NEW: Auth convenience hook
|
||||||
|
│ ├── useUserFiles.js # NEW: File CRUD operations
|
||||||
|
│ ├── useAnalytics.js
|
||||||
|
│ └── useNavigationGuard.js
|
||||||
|
├── components/
|
||||||
|
│ ├── AuthModal.js # NEW: Login/signup modal overlay
|
||||||
|
│ ├── UserMenu.js # NEW: Avatar dropdown in header
|
||||||
|
│ ├── SaveLoadBar.js # NEW: Save/Load buttons for tools
|
||||||
|
│ ├── MyFilesTab.js # NEW: "My Files" input tab
|
||||||
|
│ ├── StorageMeter.js # NEW: Storage usage indicator
|
||||||
|
│ ├── AdvancedURLFetch.js # MODIFY: use proxy for Pro
|
||||||
|
│ ├── ProBadge.js # KEEP: already exists
|
||||||
|
│ └── Layout.js # MODIFY: add UserMenu to header
|
||||||
|
├── pages/
|
||||||
|
│ ├── AccountPage.js # NEW: /account
|
||||||
|
│ └── ...existing tools... # MODIFY: add save/load integration
|
||||||
|
├── config/
|
||||||
|
│ ├── features.js # MODIFY: read tier from JWT claims
|
||||||
|
│ └── tools.js # KEEP
|
||||||
|
└── App.js # MODIFY: wrap with AuthProvider, add /account route
|
||||||
|
|
||||||
|
supabase/
|
||||||
|
├── migrations/
|
||||||
|
│ └── 001_initial_schema.sql # NEW: all tables, RLS, triggers
|
||||||
|
└── functions/
|
||||||
|
└── proxy-fetch/
|
||||||
|
└── index.ts # NEW: CORS proxy edge function
|
||||||
|
```
|
||||||
55
PROJECT_ROADMAP.md
Normal file → Executable file
@@ -131,37 +131,36 @@ Build a comprehensive suite of developer tools with a focus on:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
#### Priority 3: AdSense Integration 💵
|
#### Priority 3: Adsterra Integration 💵
|
||||||
**Status:** ⏳ In Progress (Awaiting approval)
|
**Status:** ✅ Completed
|
||||||
**Timeline:** 1 day
|
**Timeline:** 1 day
|
||||||
**Impact:** HIGH - Start earning revenue
|
**Impact:** HIGH - Start earning revenue
|
||||||
|
|
||||||
**Steps:**
|
**Steps:**
|
||||||
1. Apply for Google AdSense account
|
1. ✅ Apply for Adsterra account
|
||||||
2. Add AdSense script to `index.html`
|
2. ✅ Add Adsterra anti-adblock script to `index.html` and components
|
||||||
3. Create ad units in AdSense dashboard
|
3. ✅ Create ad units in Adsterra dashboard
|
||||||
4. Implement ad components with AdSense code
|
4. ✅ Implement ad components with Adsterra code
|
||||||
5. Test ad display and responsiveness
|
5. ✅ Test ad display and responsiveness
|
||||||
6. Monitor ad performance
|
|
||||||
|
|
||||||
**Ad Units Needed:**
|
**Ad Units Needed:**
|
||||||
- Desktop Sidebar 1 (300x250)
|
- ✅ Desktop Sidebar 1 (300x250)
|
||||||
- Desktop Sidebar 2 (300x250)
|
- ✅ Desktop Sidebar 2 (300x250)
|
||||||
- Desktop Sidebar 3 (300x250)
|
- ✅ Desktop Sidebar 3 (300x250)
|
||||||
- Mobile Bottom Banner (320x50)
|
- ✅ Mobile Bottom Banner (320x50)
|
||||||
|
|
||||||
**Compliance:**
|
**Compliance:**
|
||||||
- Add Privacy Policy page
|
- ✅ Add Privacy Policy page
|
||||||
- Add Terms of Service page
|
- ✅ Add Terms of Service page
|
||||||
- Cookie consent banner (if required)
|
- ✅ Cookie consent banner
|
||||||
- GDPR compliance (if applicable)
|
- ✅ GDPR compliance
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Phase 2: Content Expansion (Week 3-6)
|
### Phase 2: Content Expansion (Week 3-6)
|
||||||
|
|
||||||
#### Markdown Editor 📝
|
#### Markdown Editor 📝
|
||||||
**Status:** ✅ Completed (October 22, 2025)
|
**Status:** ✅ Completed (June 14, 2026)
|
||||||
**Timeline:** 1-2 weeks
|
**Timeline:** 1-2 weeks
|
||||||
**Impact:** HIGH - Major new feature, attracts new users
|
**Impact:** HIGH - Major new feature, attracts new users
|
||||||
|
|
||||||
@@ -170,29 +169,25 @@ Build a comprehensive suite of developer tools with a focus on:
|
|||||||
- Create New (empty/sample)
|
- Create New (empty/sample)
|
||||||
- URL Import (fetch markdown from GitHub, Gist, etc.)
|
- URL Import (fetch markdown from GitHub, Gist, etc.)
|
||||||
- Paste (markdown, HTML auto-convert, plain text)
|
- Paste (markdown, HTML auto-convert, plain text)
|
||||||
- Open Files (.md, .txt, .html, .docx)
|
- Open Files (.md, .txt)
|
||||||
|
|
||||||
- **Editor:**
|
- **Editor:**
|
||||||
- CodeMirror with markdown syntax highlighting
|
- Tiptap-powered WYSIWYG Rich Text Editor
|
||||||
- Split view (editor + live preview)
|
- Fallback Raw Markdown CodeMirror Editor
|
||||||
- View modes: Split, Editor Only, Preview Only, Fullscreen
|
- View modes: Read, Edit, Markdown
|
||||||
- Markdown toolbar (Bold, Italic, Headers, Links, Images, Code, Lists, Tables)
|
- Toolbar (Bold, Italic, Headers, Links, Images, Code, Lists, Tables)
|
||||||
- Line numbers
|
|
||||||
- Word count & statistics
|
- Word count & statistics
|
||||||
|
|
||||||
- **Preview:**
|
- **Preview:**
|
||||||
- Live rendering (marked + DOMPurify)
|
- Auto-generated HTML parsing
|
||||||
- Syntax highlighting for code blocks (highlight.js)
|
- Syntax highlighting for code blocks (highlight.js)
|
||||||
- GitHub Flavored Markdown support
|
|
||||||
- Table of Contents auto-generation
|
|
||||||
- Mermaid diagram rendering (in preview)
|
|
||||||
|
|
||||||
- **Export:**
|
- **Export:**
|
||||||
- Markdown (.md) - Standard, GFM, CommonMark
|
- Markdown (.md)
|
||||||
- HTML (.html) - Standalone with CSS
|
- HTML (.html)
|
||||||
|
- HTML Content Body
|
||||||
- Plain Text (.txt)
|
- Plain Text (.txt)
|
||||||
- PDF (.pdf) - via html2pdf
|
- PDF (.pdf) - via html2pdf
|
||||||
- DOCX (.docx) - via docx library
|
|
||||||
|
|
||||||
**Advanced Features (Post-MVP):**
|
**Advanced Features (Post-MVP):**
|
||||||
- Tables support (GitHub-style)
|
- Tables support (GitHub-style)
|
||||||
|
|||||||
0
SEO_IMPROVEMENT_PLAN.md
Normal file → Executable file
368
TODO.md
Normal file → Executable file
@@ -179,310 +179,168 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 💵 Priority 3: AdSense Integration (1 day) - ⏳ IN PROGRESS
|
### 💵 Priority 3: Adsterra Integration (1 day) - ✅ COMPLETED
|
||||||
|
|
||||||
#### AdSense Setup
|
#### Adsterra Setup
|
||||||
- [ ] Apply for Google AdSense account
|
- [x] Apply for Adsterra publisher account
|
||||||
- [ ] Provide website URL
|
- [x] Add website URL
|
||||||
- [ ] Provide contact information
|
- [x] Receive approval
|
||||||
- [ ] Wait for approval (can take 1-3 days)
|
|
||||||
- [ ] Verify site ownership (add verification code)
|
|
||||||
|
|
||||||
#### Ad Units Creation
|
#### Ad Units Creation
|
||||||
- [ ] Log in to AdSense dashboard
|
- [x] Create ad unit: Desktop Sidebar 1 (300x250)
|
||||||
- [ ] Create ad unit: Desktop Sidebar 1 (300x250)
|
- [x] Create ad unit: Desktop Sidebar 2 (300x250)
|
||||||
- [ ] Create ad unit: Desktop Sidebar 2 (300x250)
|
- [x] Create ad unit: Mobile Bottom Banner (320x50)
|
||||||
- [ ] Create ad unit: Desktop Sidebar 3 (300x250)
|
- [x] Copy ad unit codes
|
||||||
- [ ] Create ad unit: Mobile Bottom Banner (320x50)
|
- [x] Request Anti-Adblock custom domain
|
||||||
- [ ] Copy ad unit codes
|
|
||||||
|
|
||||||
#### Implementation
|
#### Implementation
|
||||||
- [ ] Add AdSense script to `public/index.html`
|
- [x] Update `AdBlock.jsx` with Adsterra iframe code
|
||||||
```html
|
- [x] Update `MobileAdBanner.jsx` with Adsterra iframe code
|
||||||
<script async src="https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=ca-pub-XXXXXXXX"
|
- [x] Update custom Anti-Adblock domain (`downconvenientmagnetic.com`)
|
||||||
crossorigin="anonymous"></script>
|
|
||||||
```
|
|
||||||
- [ ] Update `AdBlock.jsx` with AdSense code
|
|
||||||
```jsx
|
|
||||||
<ins className="adsbygoogle"
|
|
||||||
style={{ display: 'block' }}
|
|
||||||
data-ad-client="ca-pub-XXXXXXXX"
|
|
||||||
data-ad-slot="XXXXXXXXXX"
|
|
||||||
data-ad-format="auto"
|
|
||||||
data-full-width-responsive="true"></ins>
|
|
||||||
```
|
|
||||||
- [ ] Update `MobileAdBanner.jsx` with AdSense code
|
|
||||||
- [ ] Initialize ads: `(adsbygoogle = window.adsbygoogle || []).push({});`
|
|
||||||
|
|
||||||
#### Testing
|
#### Testing
|
||||||
- [ ] Test ad display on desktop
|
- [x] Test ad display on desktop
|
||||||
- [ ] Test ad display on mobile
|
- [x] Test ad display on mobile
|
||||||
- [ ] Verify ads load correctly
|
- [x] Verify ads load correctly
|
||||||
- [ ] Check for console errors
|
- [x] Check for console errors
|
||||||
- [ ] Test with ad blocker (should show message)
|
- [x] Test on different devices
|
||||||
- [ ] Test on different browsers (Chrome, Firefox, Safari)
|
|
||||||
- [ ] Test on different devices
|
|
||||||
|
|
||||||
#### Monitoring
|
#### Monitoring
|
||||||
- [ ] Set up AdSense reporting
|
- [x] Monitor ad impressions
|
||||||
- [ ] Monitor ad impressions
|
- [x] Monitor ad clicks
|
||||||
- [ ] Monitor ad clicks
|
- [x] Track CTR (Click-Through Rate)
|
||||||
- [ ] Monitor ad revenue
|
|
||||||
- [ ] Track CTR (Click-Through Rate)
|
|
||||||
- [ ] Identify best-performing ad units
|
|
||||||
|
|
||||||
#### Compliance
|
#### Compliance
|
||||||
- [ ] Create Privacy Policy page
|
- [x] Create Privacy Policy page
|
||||||
- [ ] Data collection disclosure
|
- [x] Data collection disclosure
|
||||||
- [ ] Cookie usage disclosure
|
- [x] Cookie usage disclosure
|
||||||
- [ ] Third-party services (AdSense)
|
- [x] Third-party services (Adsterra)
|
||||||
- [ ] User rights (GDPR)
|
- [x] User rights (GDPR)
|
||||||
- [ ] Create Terms of Service page
|
- [x] Create Terms of Service page
|
||||||
- [ ] Acceptable use policy
|
- [x] Acceptable use policy
|
||||||
- [ ] Disclaimer
|
- [x] Disclaimer
|
||||||
- [ ] Limitation of liability
|
- [x] Limitation of liability
|
||||||
- [ ] Add cookie consent banner (if required)
|
- [x] Add cookie consent banner
|
||||||
- [ ] Show on first visit
|
- [x] Add "Privacy Policy" link in footer
|
||||||
- [ ] Allow accept/decline
|
- [x] Add "Terms of Service" link in footer
|
||||||
- [ ] Store preference
|
|
||||||
- [ ] Add "About Ads" link in footer
|
|
||||||
- [ ] Add "Privacy Policy" link in footer
|
|
||||||
- [ ] Add "Terms of Service" link in footer
|
|
||||||
|
|
||||||
#### Optimization
|
|
||||||
- [ ] Test different ad placements
|
|
||||||
- [ ] Test different ad sizes
|
|
||||||
- [ ] Monitor ad viewability
|
|
||||||
- [ ] Optimize for higher CTR
|
|
||||||
- [ ] A/B test ad positions (optional)
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 📋 Phase 2: Content Expansion
|
## 📋 Phase 2: Content Expansion
|
||||||
|
|
||||||
### 📝 Markdown Editor - MVP (1-2 weeks) - ✅ COMPLETED (Oct 22, 2025)
|
### 📝 Markdown Editor - MVP (1-2 weeks) - ✅ COMPLETED
|
||||||
|
|
||||||
#### Planning
|
#### Planning
|
||||||
- [ ] Finalize feature list for MVP
|
- [x] Finalize feature list for MVP
|
||||||
- [ ] Design UI mockup (split view)
|
- [x] Design UI mockup (WYSIWYG layout)
|
||||||
- [ ] Plan component structure
|
- [x] Plan component structure
|
||||||
- [ ] Choose markdown parser (marked vs markdown-it)
|
- [x] Implement Tiptap integration
|
||||||
- [ ] Plan export formats
|
- [x] Plan export formats
|
||||||
|
|
||||||
#### Project Setup
|
#### Project Setup
|
||||||
- [ ] Create `MarkdownEditor.jsx` page
|
- [x] Create `MarkdownEditor.jsx` page
|
||||||
- [ ] Set up routing (`/markdown-editor`)
|
- [x] Create `RichMarkdownEditor.js` component
|
||||||
- [ ] Add to navigation menu
|
- [x] Set up routing (`/markdown-editor`)
|
||||||
- [ ] Add to homepage tools list
|
- [x] Add to navigation menu
|
||||||
|
- [x] Add to homepage tools list
|
||||||
|
|
||||||
#### Input Section
|
#### Input Section
|
||||||
- [ ] Implement Create New tab
|
- [x] Implement Create New tab
|
||||||
- [ ] Start Empty button
|
- [x] Start Empty button
|
||||||
- [ ] Load Sample button (with example markdown)
|
- [x] Load Sample button (with example markdown)
|
||||||
- [ ] Tip box
|
- [x] Tip box
|
||||||
- [ ] Implement URL tab
|
- [x] Implement URL tab
|
||||||
- [ ] Use AdvancedURLFetch component
|
- [x] Use AdvancedURLFetch component
|
||||||
- [ ] Support GitHub raw URLs
|
- [x] Support GitHub raw URLs
|
||||||
- [ ] Support Gist URLs
|
- [x] Support Gist URLs
|
||||||
- [ ] Test with various markdown sources
|
- [x] Test with various markdown sources
|
||||||
- [ ] Implement Paste tab
|
- [x] Implement Paste tab
|
||||||
- [ ] CodeMirror editor
|
- [x] CodeMirror editor
|
||||||
- [ ] Markdown syntax highlighting
|
- [x] Markdown syntax highlighting
|
||||||
- [ ] Auto-detect markdown
|
- [x] Parse button
|
||||||
- [ ] Parse button
|
- [x] Implement Open tab
|
||||||
- [ ] Collapse after parse
|
- [x] Support .md files
|
||||||
- [ ] Implement Open tab
|
- [x] Support .txt files
|
||||||
- [ ] Support .md files
|
- [x] Auto-load on file selection
|
||||||
- [ ] Support .txt files
|
|
||||||
- [ ] Support .html files (convert to markdown)
|
|
||||||
- [ ] Support .docx files (convert to markdown)
|
|
||||||
- [ ] Auto-load on file selection
|
|
||||||
|
|
||||||
#### Editor Section
|
#### Editor Section
|
||||||
- [ ] Set up CodeMirror for markdown
|
- [x] Implement WYSIWYG Editor (Tiptap)
|
||||||
- [ ] Install @codemirror/lang-markdown
|
- [x] Install `@tiptap/react` and `tiptap-markdown`
|
||||||
- [ ] Configure markdown mode
|
- [x] Add standard text formatting (bold, italic, strike)
|
||||||
- [ ] Add syntax highlighting
|
- [x] Add block formatting (headers, quotes, lists)
|
||||||
- [ ] Add line numbers
|
- [x] Add inline code and code block extensions
|
||||||
- [ ] Add line wrapping
|
- [x] Set up Lowlight syntax highlighting
|
||||||
- [ ] Implement split view layout
|
- [x] Implement view mode toggle
|
||||||
- [ ] Editor pane (left)
|
- [x] Read mode (Clean preview default)
|
||||||
- [ ] Preview pane (right)
|
- [x] Edit mode (WYSIWYG Tiptap)
|
||||||
- [ ] Resizable divider (optional)
|
- [x] Markdown mode (Raw CodeMirror)
|
||||||
- [ ] Implement view mode toggle
|
- [x] Fullscreen mode
|
||||||
- [ ] Split view (default)
|
- [x] Add editor features
|
||||||
- [ ] Editor only
|
- [x] Word count
|
||||||
- [ ] Preview only
|
- [x] Character count
|
||||||
- [ ] Fullscreen mode
|
- [x] Line count
|
||||||
- [ ] Add markdown toolbar
|
- [x] Reading time estimate
|
||||||
- [ ] Bold button (Ctrl+B)
|
|
||||||
- [ ] Italic button (Ctrl+I)
|
|
||||||
- [ ] H1 button
|
|
||||||
- [ ] H2 button
|
|
||||||
- [ ] H3 button
|
|
||||||
- [ ] Link button (Ctrl+K)
|
|
||||||
- [ ] Image button
|
|
||||||
- [ ] Code button (Ctrl+`)
|
|
||||||
- [ ] Quote button
|
|
||||||
- [ ] Unordered list button
|
|
||||||
- [ ] Ordered list button
|
|
||||||
- [ ] Table button
|
|
||||||
- [ ] Add editor features
|
|
||||||
- [ ] Word count
|
|
||||||
- [ ] Character count
|
|
||||||
- [ ] Line count
|
|
||||||
- [ ] Reading time estimate
|
|
||||||
|
|
||||||
#### Preview Section
|
#### Preview Section
|
||||||
- [ ] Set up markdown parser (marked)
|
- [x] Build robust HTML to Markdown / Markdown to HTML sync
|
||||||
- [ ] Install marked
|
- [x] Set up markdown fallback parser (marked)
|
||||||
- [ ] Install DOMPurify
|
- [x] GitHub Flavored Markdown support (Tables, task lists)
|
||||||
- [ ] Configure marked options
|
- [x] Custom code block rendering with Copy button in Read mode
|
||||||
- [ ] Implement live preview
|
|
||||||
- [ ] Real-time rendering
|
|
||||||
- [ ] Debounce for performance
|
|
||||||
- [ ] Scroll sync (optional)
|
|
||||||
- [ ] Add syntax highlighting for code blocks
|
|
||||||
- [ ] Install highlight.js
|
|
||||||
- [ ] Configure languages
|
|
||||||
- [ ] Apply highlighting
|
|
||||||
- [ ] Add GitHub Flavored Markdown support
|
|
||||||
- [ ] Tables
|
|
||||||
- [ ] Strikethrough
|
|
||||||
- [ ] Task lists
|
|
||||||
- [ ] Autolinks
|
|
||||||
- [ ] Implement Table of Contents
|
|
||||||
- [ ] Auto-generate from headers
|
|
||||||
- [ ] Clickable links
|
|
||||||
- [ ] Collapsible (optional)
|
|
||||||
- [ ] Add mermaid diagram rendering
|
|
||||||
- [ ] Install mermaid
|
|
||||||
- [ ] Detect mermaid code blocks
|
|
||||||
- [ ] Render diagrams
|
|
||||||
- [ ] Error handling
|
|
||||||
|
|
||||||
#### Export Section
|
#### Export Section
|
||||||
- [ ] Create collapsible export section
|
- [x] Create collapsible export section
|
||||||
- [ ] Implement Markdown export
|
- [x] Implement Markdown export
|
||||||
- [ ] Standard Markdown
|
- [x] Copy to clipboard
|
||||||
- [ ] GitHub Flavored Markdown
|
- [x] Download as .md file
|
||||||
- [ ] CommonMark
|
- [x] Implement HTML export
|
||||||
- [ ] Copy to clipboard
|
- [x] Standalone HTML with CSS
|
||||||
- [ ] Download as .md file
|
- [x] Download as .html file
|
||||||
- [ ] Implement HTML export
|
- [x] Implement HTML Content export
|
||||||
- [ ] Standalone HTML with CSS
|
- [x] Strip React/Tailwind wrapper classes
|
||||||
- [ ] Inline styles
|
- [x] Download body HTML only
|
||||||
- [ ] Include syntax highlighting CSS
|
- [x] Implement Plain Text export
|
||||||
- [ ] Copy to clipboard
|
- [x] Strip markdown syntax via regex
|
||||||
- [ ] Download as .html file
|
- [x] Download as .txt file
|
||||||
- [ ] Implement Plain Text export
|
- [x] Implement PDF export
|
||||||
- [ ] Strip all formatting
|
- [x] Install html2pdf.js
|
||||||
- [ ] Copy to clipboard
|
- [x] Inject CSS print media rules to prevent pre overflow
|
||||||
- [ ] Download as .txt file
|
- [x] Download as .pdf file
|
||||||
- [ ] Implement PDF export
|
|
||||||
- [ ] Install html2pdf.js
|
|
||||||
- [ ] Convert HTML to PDF
|
|
||||||
- [ ] Maintain formatting
|
|
||||||
- [ ] Download as .pdf file
|
|
||||||
- [ ] Implement DOCX export
|
|
||||||
- [ ] Install docx library
|
|
||||||
- [ ] Convert markdown to DOCX
|
|
||||||
- [ ] Maintain formatting
|
|
||||||
- [ ] Download as .docx file
|
|
||||||
|
|
||||||
#### Conversion Features
|
|
||||||
- [ ] HTML to Markdown conversion
|
|
||||||
- [ ] Install turndown
|
|
||||||
- [ ] Convert on paste (if HTML detected)
|
|
||||||
- [ ] Convert on file open (.html)
|
|
||||||
- [ ] DOCX to Markdown conversion
|
|
||||||
- [ ] Install mammoth.js
|
|
||||||
- [ ] Convert on file open (.docx)
|
|
||||||
- [ ] Extract text and formatting
|
|
||||||
|
|
||||||
#### Usage Tips
|
|
||||||
- [ ] Create collapsible Usage Tips section
|
|
||||||
- [ ] Add Input Methods tips
|
|
||||||
- [ ] Add Editor Features tips
|
|
||||||
- [ ] Add Markdown Syntax tips
|
|
||||||
- [ ] Add Export Options tips
|
|
||||||
- [ ] Add Data Privacy tips
|
|
||||||
|
|
||||||
#### Data Loss Prevention
|
#### Data Loss Prevention
|
||||||
- [ ] Implement hasUserData() function
|
- [x] Implement `hasUserData()` function
|
||||||
- [ ] Implement hasModifiedData() function
|
- [x] Implement `hasModifiedData()` function
|
||||||
- [ ] Add confirmation modal for tab changes
|
- [x] Add confirmation modal for tab changes
|
||||||
- [ ] Add confirmation for Create New buttons
|
- [x] Add confirmation for Create New buttons
|
||||||
|
|
||||||
#### Testing
|
#### Testing
|
||||||
- [ ] Test all input methods
|
- [x] Test all input methods
|
||||||
- [ ] Test markdown rendering
|
- [x] Test Tiptap to Markdown serialization
|
||||||
- [ ] Test all export formats
|
- [x] Test all export formats
|
||||||
- [ ] Test HTML to Markdown conversion
|
- [x] Test code syntax highlighting
|
||||||
- [ ] Test DOCX import
|
- [x] Test view mode toggle
|
||||||
- [ ] Test mermaid diagrams
|
- [x] Test toolbar buttons
|
||||||
- [ ] Test code syntax highlighting
|
- [x] Test responsive design
|
||||||
- [ ] Test Table of Contents
|
- [x] Test dark mode
|
||||||
- [ ] Test view mode toggle
|
|
||||||
- [ ] Test toolbar buttons
|
|
||||||
- [ ] Test keyboard shortcuts
|
|
||||||
- [ ] Test responsive design
|
|
||||||
- [ ] Test dark mode
|
|
||||||
- [ ] Test on mobile devices
|
|
||||||
|
|
||||||
#### Documentation
|
|
||||||
- [ ] Add to EDITOR_TOOL_GUIDE.md
|
|
||||||
- [ ] Create user guide
|
|
||||||
- [ ] Add screenshots
|
|
||||||
- [ ] Create tutorial video (optional)
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 📝 Markdown Editor - Post-MVP (Future)
|
### 📝 Markdown Editor - Post-MVP (Future)
|
||||||
|
|
||||||
#### Advanced Markdown Features
|
#### Advanced Markdown Features
|
||||||
- [ ] Add table support (GitHub-style)
|
|
||||||
- [ ] Add task lists (checkboxes)
|
|
||||||
- [ ] Add footnotes support
|
- [ ] Add footnotes support
|
||||||
- [ ] Add emoji support (:smile:)
|
- [ ] Add emoji support (WYSIWYG picker)
|
||||||
- [ ] Add math equations (KaTeX)
|
- [ ] Add math equations (KaTeX)
|
||||||
- [ ] Install katex
|
- [ ] Add mermaid diagram rendering
|
||||||
- [ ] Detect math blocks
|
- [ ] Implement Table of Contents auto-generation
|
||||||
- [ ] Render equations
|
|
||||||
|
|
||||||
#### Templates
|
|
||||||
- [ ] Create README.md template
|
|
||||||
- [ ] Create Documentation template
|
|
||||||
- [ ] Create Blog post template
|
|
||||||
- [ ] Create Meeting notes template
|
|
||||||
- [ ] Create Project proposal template
|
|
||||||
- [ ] Add template selector UI
|
|
||||||
- [ ] Allow custom templates
|
|
||||||
|
|
||||||
#### Utilities
|
#### Utilities
|
||||||
- [ ] Add markdown linter
|
- [ ] Add markdown linter
|
||||||
- [ ] Check for common issues
|
|
||||||
- [ ] Suggest improvements
|
|
||||||
- [ ] Show warnings
|
|
||||||
- [ ] Add link checker
|
|
||||||
- [ ] Validate URLs
|
|
||||||
- [ ] Check for broken links
|
|
||||||
- [ ] Show status
|
|
||||||
- [ ] Add format beautifier
|
- [ ] Add format beautifier
|
||||||
- [ ] Clean up markdown
|
|
||||||
- [ ] Consistent formatting
|
|
||||||
- [ ] Fix indentation
|
|
||||||
- [ ] Add image optimizer
|
- [ ] Add image optimizer
|
||||||
- [ ] Compress images
|
|
||||||
- [ ] Convert to base64
|
|
||||||
- [ ] Optimize for web
|
|
||||||
|
|
||||||
#### Enhanced Features
|
#### Enhanced Features
|
||||||
- [ ] Add keyboard shortcuts
|
|
||||||
- [ ] Add auto-save (localStorage)
|
- [ ] Add auto-save (localStorage)
|
||||||
- [ ] Add export history
|
|
||||||
- [ ] Add version history
|
- [ ] Add version history
|
||||||
- [ ] Add collaborative editing (future)
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
0
nixpacks.toml
Normal file → Executable file
45362
package-lock.json
generated
Normal file → Executable file
21
package.json
Normal file → Executable file
@@ -18,22 +18,37 @@
|
|||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
|
"@tailwindcss/typography": "^0.5.20",
|
||||||
"@testing-library/jest-dom": "^6.8.0",
|
"@testing-library/jest-dom": "^6.8.0",
|
||||||
"@testing-library/react": "^16.3.0",
|
"@testing-library/react": "^16.3.0",
|
||||||
"@testing-library/user-event": "^14.6.1",
|
"@testing-library/user-event": "^14.6.1",
|
||||||
|
"@tiptap/extension-code-block-lowlight": "^3.26.1",
|
||||||
|
"@tiptap/extension-image": "^3.26.1",
|
||||||
|
"@tiptap/extension-link": "^3.26.1",
|
||||||
|
"@tiptap/extension-table": "^3.26.1",
|
||||||
|
"@tiptap/extension-table-cell": "^3.26.1",
|
||||||
|
"@tiptap/extension-table-header": "^3.26.1",
|
||||||
|
"@tiptap/extension-table-row": "^3.26.1",
|
||||||
|
"@tiptap/extension-task-item": "^3.26.1",
|
||||||
|
"@tiptap/extension-task-list": "^3.26.1",
|
||||||
|
"@tiptap/react": "^3.26.1",
|
||||||
|
"@tiptap/starter-kit": "^3.26.1",
|
||||||
"@uiw/react-codemirror": "^4.25.1",
|
"@uiw/react-codemirror": "^4.25.1",
|
||||||
"codemirror": "^6.0.2",
|
"codemirror": "^6.0.2",
|
||||||
"diff-match-patch": "^1.0.5",
|
"diff-match-patch": "^1.0.5",
|
||||||
"dompurify": "^3.3.0",
|
"dompurify": "^3.3.0",
|
||||||
"file-saver": "^2.0.5",
|
"file-saver": "^2.0.5",
|
||||||
"highlight.js": "^11.11.1",
|
"highlight.js": "^11.11.1",
|
||||||
|
"html-to-image": "^1.11.13",
|
||||||
"html2pdf.js": "^0.12.1",
|
"html2pdf.js": "^0.12.1",
|
||||||
"js-beautify": "^1.15.4",
|
"js-beautify": "^1.15.4",
|
||||||
"jspdf": "^3.0.3",
|
"jspdf": "^3.0.3",
|
||||||
"jspdf-autotable": "^5.0.2",
|
"jspdf-autotable": "^5.0.2",
|
||||||
|
"lowlight": "^3.3.0",
|
||||||
"lucide-react": "^0.540.0",
|
"lucide-react": "^0.540.0",
|
||||||
"marked": "^16.4.1",
|
"marked": "^16.4.1",
|
||||||
"marked-emoji": "^2.0.1",
|
"marked-emoji": "^2.0.1",
|
||||||
|
"mermaid": "^11.15.0",
|
||||||
"papaparse": "^5.5.3",
|
"papaparse": "^5.5.3",
|
||||||
"react": "18.3.1",
|
"react": "18.3.1",
|
||||||
"react-diff-view": "^3.3.2",
|
"react-diff-view": "^3.3.2",
|
||||||
@@ -41,9 +56,12 @@
|
|||||||
"react-helmet-async": "^2.0.5",
|
"react-helmet-async": "^2.0.5",
|
||||||
"react-router-dom": "6.26.2",
|
"react-router-dom": "6.26.2",
|
||||||
"react-snap": "^1.23.0",
|
"react-snap": "^1.23.0",
|
||||||
|
"react-zoom-pan-pinch": "^4.0.3",
|
||||||
"reactflow": "^11.11.4",
|
"reactflow": "^11.11.4",
|
||||||
"serialize-javascript": "^6.0.0",
|
"serialize-javascript": "^6.0.0",
|
||||||
"serve": "^14.2.4",
|
"serve": "^14.2.4",
|
||||||
|
"tailwindcss-typography": "^3.1.0",
|
||||||
|
"tiptap-markdown": "^0.9.0",
|
||||||
"turndown": "^7.2.1",
|
"turndown": "^7.2.1",
|
||||||
"web-vitals": "^2.1.4"
|
"web-vitals": "^2.1.4"
|
||||||
},
|
},
|
||||||
@@ -89,8 +107,7 @@
|
|||||||
},
|
},
|
||||||
"eslintConfig": {
|
"eslintConfig": {
|
||||||
"extends": [
|
"extends": [
|
||||||
"react-app",
|
"react-app"
|
||||||
"react-app/jest"
|
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"browserslist": {
|
"browserslist": {
|
||||||
|
|||||||
0
postcss.config.js
Normal file → Executable file
0
public/ads.txt
Normal file → Executable file
0
public/android-chrome-192x192.png
Normal file → Executable file
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 13 KiB |
0
public/android-chrome-512x512.png
Normal file → Executable file
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 36 KiB |
0
public/apple-touch-icon.png
Normal file → Executable file
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 12 KiB |
58
public/data/commits.json
Normal file → Executable file
@@ -1,5 +1,63 @@
|
|||||||
{
|
{
|
||||||
"changelog": [
|
"changelog": [
|
||||||
|
{
|
||||||
|
"date": "2026-06-14",
|
||||||
|
"changes": [
|
||||||
|
{
|
||||||
|
"datetime": "2026-06-14T10:00:00+07:00",
|
||||||
|
"type": "feature",
|
||||||
|
"title": "Major Markdown Editor Rewrite: WYSIWYG Experience",
|
||||||
|
"description": "Completely rebuilt the Markdown Editor to feature a true WYSIWYG (What You See Is What You Get) interface using Tiptap. You can now edit rich text visually like a Word document, while seamlessly converting back and forth to raw Markdown and clean HTML."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"datetime": "2026-06-14T09:30:00+07:00",
|
||||||
|
"type": "enhancement",
|
||||||
|
"title": "Object Editor: Data Preview & Multidimensional Search",
|
||||||
|
"description": "The Object Editor now defaults to a fast Read-Only preview when you paste JSON data. We also added an incredibly powerful multidimensional search bar that instantly filters, highlights, and expands nested nodes matching your query."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2026-02-18",
|
||||||
|
"changes": [
|
||||||
|
{
|
||||||
|
"datetime": "2026-02-18T15:00:00+07:00",
|
||||||
|
"type": "feature",
|
||||||
|
"title": "Performance Boost with Code Splitting",
|
||||||
|
"description": "Dramatically improved page load times by implementing lazy loading for all tool pages. Each tool now loads only when you need it, reducing initial bundle size by over 50%. Experience faster navigation between tools with a smooth loading transition."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"datetime": "2026-02-18T14:00:00+07:00",
|
||||||
|
"type": "enhancement",
|
||||||
|
"title": "WCAG AA Accessibility Improvements",
|
||||||
|
"description": "Made the site more accessible for all users: added proper ARIA labels to navigation buttons, improved keyboard navigation, enhanced screen reader support with live error announcements, and fixed 300+ low-contrast text instances to meet WCAG AA standards."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"datetime": "2026-02-18T13:00:00+07:00",
|
||||||
|
"type": "feature",
|
||||||
|
"title": "New Advertising Partnership with Adsterra",
|
||||||
|
"description": "Migrated from Google AdSense to Adsterra for better ad performance. Desktop users see a non-intrusive sidebar ad, while mobile users get a dismissible bottom banner. All ads respect your privacy and GDPR consent preferences."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"datetime": "2026-02-18T12:00:00+07:00",
|
||||||
|
"type": "feature",
|
||||||
|
"title": "Onidel Affiliate Partnership",
|
||||||
|
"description": "Partnered with Onidel to bring you professional development services. Check out the sidebar on desktop for special offers and services from our trusted partner."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"datetime": "2026-02-18T11:00:00+07:00",
|
||||||
|
"type": "enhancement",
|
||||||
|
"title": "Code Quality & Performance Cleanup",
|
||||||
|
"description": "Removed 150+ debug console statements, deleted deprecated packages, eliminated duplicate dependencies, and cleaned up dead code. The codebase is now leaner and more maintainable."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"datetime": "2026-02-18T10:00:00+07:00",
|
||||||
|
"type": "enhancement",
|
||||||
|
"title": "Improved Configuration Management",
|
||||||
|
"description": "Moved Google Analytics configuration to environment variables for better security and easier deployment. Created .env.example for seamless local development setup."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"date": "2025-10-22",
|
"date": "2025-10-22",
|
||||||
"changes": [
|
"changes": [
|
||||||
|
|||||||
0
public/data/currencies.json
Normal file → Executable file
0
public/favicon-16x16.png
Normal file → Executable file
|
Before Width: | Height: | Size: 962 B After Width: | Height: | Size: 962 B |
0
public/favicon-32x32.png
Normal file → Executable file
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 1.7 KiB |
0
public/favicon.ico
Normal file → Executable file
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 1.7 KiB |
0
public/icon-192x192.png
Normal file → Executable file
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 13 KiB |
0
public/icon-512x512.png
Normal file → Executable file
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 36 KiB |
0
public/images/onidel-banner.webp
Normal file → Executable file
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 28 KiB |
0
public/index.html
Normal file → Executable file
0
public/logo.svg
Normal file → Executable file
|
Before Width: | Height: | Size: 4.9 KiB After Width: | Height: | Size: 4.9 KiB |
0
public/manifest.json
Normal file → Executable file
0
public/robots.txt
Normal file → Executable file
0
public/sitemap.xml
Normal file → Executable file
67
src/App.js
Normal file → Executable file
@@ -1,29 +1,37 @@
|
|||||||
import React, { useEffect, Suspense, lazy } from 'react';
|
import React, { useEffect, Suspense, lazy } from "react";
|
||||||
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
|
import {
|
||||||
import { HelmetProvider } from 'react-helmet-async';
|
BrowserRouter as Router,
|
||||||
import Layout from './components/Layout';
|
Routes,
|
||||||
import ErrorBoundary from './components/ErrorBoundary';
|
Route,
|
||||||
import Loading from './components/Loading';
|
Navigate,
|
||||||
import { initGA } from './utils/analytics';
|
} from "react-router-dom";
|
||||||
|
import { HelmetProvider } from "react-helmet-async";
|
||||||
|
import Layout from "./components/Layout";
|
||||||
|
import ErrorBoundary from "./components/ErrorBoundary";
|
||||||
|
import Loading from "./components/Loading";
|
||||||
|
import { initGA } from "./utils/analytics";
|
||||||
|
|
||||||
import './index.css';
|
import "./index.css";
|
||||||
|
|
||||||
const Home = lazy(() => import('./pages/Home'));
|
const Home = lazy(() => import("./pages/Home"));
|
||||||
const UrlTool = lazy(() => import('./pages/UrlTool'));
|
const UrlTool = lazy(() => import("./pages/UrlTool"));
|
||||||
const Base64Tool = lazy(() => import('./pages/Base64Tool'));
|
const Base64Tool = lazy(() => import("./pages/Base64Tool"));
|
||||||
const BeautifierTool = lazy(() => import('./pages/BeautifierTool'));
|
const BeautifierTool = lazy(() => import("./pages/BeautifierTool"));
|
||||||
const DiffTool = lazy(() => import('./pages/DiffTool'));
|
const DiffTool = lazy(() => import("./pages/DiffTool"));
|
||||||
const TextLengthTool = lazy(() => import('./pages/TextLengthTool'));
|
const TextLengthTool = lazy(() => import("./pages/TextLengthTool"));
|
||||||
const ObjectEditor = lazy(() => import('./pages/ObjectEditor'));
|
const ObjectEditor = lazy(() => import("./pages/ObjectEditor"));
|
||||||
const TableEditor = lazy(() => import('./pages/TableEditor'));
|
const TableEditor = lazy(() => import("./pages/TableEditor"));
|
||||||
const InvoiceEditor = lazy(() => import('./pages/InvoiceEditor'));
|
const InvoiceEditor = lazy(() => import("./pages/InvoiceEditor"));
|
||||||
const MarkdownEditor = lazy(() => import('./pages/MarkdownEditor'));
|
const MarkdownEditor = lazy(() => import("./pages/MarkdownEditor"));
|
||||||
const InvoicePreview = lazy(() => import('./pages/InvoicePreview'));
|
const DiagramEditor = lazy(() => import("./pages/DiagramEditor"));
|
||||||
const InvoicePreviewMinimal = lazy(() => import('./pages/InvoicePreviewMinimal'));
|
const InvoicePreview = lazy(() => import("./pages/InvoicePreview"));
|
||||||
const ReleaseNotes = lazy(() => import('./pages/ReleaseNotes'));
|
const InvoicePreviewMinimal = lazy(
|
||||||
const TermsOfService = lazy(() => import('./pages/TermsOfService'));
|
() => import("./pages/InvoicePreviewMinimal"),
|
||||||
const PrivacyPolicy = lazy(() => import('./pages/PrivacyPolicy'));
|
);
|
||||||
const NotFound = lazy(() => import('./pages/NotFound'));
|
const ReleaseNotes = lazy(() => import("./pages/ReleaseNotes"));
|
||||||
|
const TermsOfService = lazy(() => import("./pages/TermsOfService"));
|
||||||
|
const PrivacyPolicy = lazy(() => import("./pages/PrivacyPolicy"));
|
||||||
|
const NotFound = lazy(() => import("./pages/NotFound"));
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
// Initialize Google Analytics on app startup
|
// Initialize Google Analytics on app startup
|
||||||
@@ -48,9 +56,16 @@ function App() {
|
|||||||
<Route path="/table-editor" element={<TableEditor />} />
|
<Route path="/table-editor" element={<TableEditor />} />
|
||||||
<Route path="/invoice-editor" element={<InvoiceEditor />} />
|
<Route path="/invoice-editor" element={<InvoiceEditor />} />
|
||||||
<Route path="/markdown-editor" element={<MarkdownEditor />} />
|
<Route path="/markdown-editor" element={<MarkdownEditor />} />
|
||||||
|
<Route path="/diagram-editor" element={<DiagramEditor />} />
|
||||||
<Route path="/invoice-preview" element={<InvoicePreview />} />
|
<Route path="/invoice-preview" element={<InvoicePreview />} />
|
||||||
<Route path="/invoice-preview-minimal" element={<InvoicePreviewMinimal />} />
|
<Route
|
||||||
<Route path="/whats-new" element={<Navigate to="/release-notes" replace />} />
|
path="/invoice-preview-minimal"
|
||||||
|
element={<InvoicePreviewMinimal />}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/whats-new"
|
||||||
|
element={<Navigate to="/release-notes" replace />}
|
||||||
|
/>
|
||||||
<Route path="/release-notes" element={<ReleaseNotes />} />
|
<Route path="/release-notes" element={<ReleaseNotes />} />
|
||||||
<Route path="/privacy" element={<PrivacyPolicy />} />
|
<Route path="/privacy" element={<PrivacyPolicy />} />
|
||||||
<Route path="/terms" element={<TermsOfService />} />
|
<Route path="/terms" element={<TermsOfService />} />
|
||||||
|
|||||||
14
src/components/AdBlock.js
Normal file → Executable file
@@ -1,6 +1,10 @@
|
|||||||
import React, { useEffect, useRef } from 'react';
|
import React, { useEffect, useRef } from "react";
|
||||||
|
|
||||||
const AdBlock = ({ className = '', adKey = 'e0ca7c61c83457f093bbc2e261b43d31' }) => {
|
const AdBlock = ({
|
||||||
|
className = "",
|
||||||
|
adKey = "e0ca7c61c83457f093bbc2e261b43d31",
|
||||||
|
adDomain = "downconvenientmagnetic.com",
|
||||||
|
}) => {
|
||||||
const iframeRef = useRef(null);
|
const iframeRef = useRef(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -28,18 +32,18 @@ const AdBlock = ({ className = '', adKey = 'e0ca7c61c83457f093bbc2e261b43d31' })
|
|||||||
'params' : {}
|
'params' : {}
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
<script type="text/javascript" src="https://bustleplaguereed.com/${adKey}/invoke.js"></script>
|
<script type="text/javascript" src="https://${adDomain}/${adKey}/invoke.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
`);
|
`);
|
||||||
doc.close();
|
doc.close();
|
||||||
}, [adKey]);
|
}, [adKey, adDomain]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<iframe
|
<iframe
|
||||||
ref={iframeRef}
|
ref={iframeRef}
|
||||||
className={`bg-gray-100 dark:bg-gray-800 rounded-lg overflow-hidden ${className}`}
|
className={`bg-gray-100 dark:bg-gray-800 rounded-lg overflow-hidden ${className}`}
|
||||||
style={{ width: '300px', height: '250px', border: 'none' }}
|
style={{ width: "300px", height: "250px", border: "none" }}
|
||||||
title="Advertisement"
|
title="Advertisement"
|
||||||
sandbox="allow-scripts allow-same-origin"
|
sandbox="allow-scripts allow-same-origin"
|
||||||
/>
|
/>
|
||||||
|
|||||||
0
src/components/AdColumn.js
Normal file → Executable file
6
src/components/AdvancedURLFetch.js
Normal file → Executable file
@@ -454,7 +454,7 @@ const AdvancedURLFetch = ({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
<p className="text-xs text-gray-600 dark:text-gray-600 mt-1">
|
||||||
💡 Tip: Toggle between raw JSON and visual tree editor for easier editing
|
💡 Tip: Toggle between raw JSON and visual tree editor for easier editing
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -495,7 +495,7 @@ const AdvancedURLFetch = ({
|
|||||||
>
|
>
|
||||||
<FolderOpen className="h-4 w-4" />
|
<FolderOpen className="h-4 w-4" />
|
||||||
<span className="font-medium">{preset.name}</span>
|
<span className="font-medium">{preset.name}</span>
|
||||||
<span className="text-xs text-gray-500">({preset.method})</span>
|
<span className="text-xs text-gray-600">({preset.method})</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => deletePreset(index)}
|
onClick={() => deletePreset(index)}
|
||||||
@@ -518,7 +518,7 @@ const AdvancedURLFetch = ({
|
|||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
<p className="text-xs text-gray-600 dark:text-gray-600">
|
||||||
{showAdvanced
|
{showAdvanced
|
||||||
? 'Configure HTTP method, headers, authentication, and request body for API testing'
|
? 'Configure HTTP method, headers, authentication, and request body for API testing'
|
||||||
: 'Enter any URL that returns JSON data. Examples: Telegram Bot API, JSONPlaceholder, GitHub API, etc.'
|
: 'Enter any URL that returns JSON data. Examples: Telegram Bot API, JSONPlaceholder, GitHub API, etc.'
|
||||||
|
|||||||
0
src/components/AffiliateBlock.js
Normal file → Executable file
34
src/components/CodeBlockComponent.js
Executable file
@@ -0,0 +1,34 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { NodeViewWrapper, NodeViewContent } from "@tiptap/react";
|
||||||
|
|
||||||
|
const CodeBlockComponent = ({ node, updateAttributes, extension }) => {
|
||||||
|
return (
|
||||||
|
<NodeViewWrapper className="code-block-wrapper relative bg-[#0d1117] rounded-md overflow-hidden mb-[0.65em]">
|
||||||
|
<div className="code-block-header flex justify-between items-center px-4 py-2 bg-[#161b22] border border-[#30363d] border-b-0 rounded-t-md text-xs font-mono">
|
||||||
|
<select
|
||||||
|
contentEditable={false}
|
||||||
|
value={node.attrs.language || "text"}
|
||||||
|
onChange={(event) =>
|
||||||
|
updateAttributes({ language: event.target.value })
|
||||||
|
}
|
||||||
|
className="bg-transparent text-[#8b949e] border-none outline-none focus:ring-0 uppercase tracking-wider cursor-pointer"
|
||||||
|
>
|
||||||
|
<option value="text">text</option>
|
||||||
|
<option value="javascript">javascript</option>
|
||||||
|
<option value="typescript">typescript</option>
|
||||||
|
<option value="html">html</option>
|
||||||
|
<option value="css">css</option>
|
||||||
|
<option value="json">json</option>
|
||||||
|
<option value="bash">bash</option>
|
||||||
|
<option value="python">python</option>
|
||||||
|
<option value="sql">sql</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<pre className="!mt-0 !rounded-t-none !bg-transparent !border-[#30363d] !p-4 !text-[#e6edf3]">
|
||||||
|
<NodeViewContent as="code" />
|
||||||
|
</pre>
|
||||||
|
</NodeViewWrapper>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CodeBlockComponent;
|
||||||
0
src/components/CodeEditor.js
Normal file → Executable file
2
src/components/CodeMirrorEditor.js
Normal file → Executable file
@@ -217,7 +217,7 @@ const CodeMirrorEditor = ({
|
|||||||
}
|
}
|
||||||
}, 50);
|
}, 50);
|
||||||
}}
|
}}
|
||||||
className="absolute bottom-2 right-2 p-1 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 bg-white dark:bg-gray-800 rounded border border-gray-300 dark:border-gray-600 shadow-sm z-10"
|
className="absolute bottom-2 right-2 p-1 text-gray-600 hover:text-gray-700 dark:text-gray-600 dark:hover:text-gray-200 bg-white dark:bg-gray-800 rounded border border-gray-300 dark:border-gray-600 shadow-sm z-10"
|
||||||
title={isExpanded ? 'Collapse' : 'Expand'}
|
title={isExpanded ? 'Collapse' : 'Expand'}
|
||||||
>
|
>
|
||||||
{isExpanded ? (
|
{isExpanded ? (
|
||||||
|
|||||||
4
src/components/ConsentBanner.js
Normal file → Executable file
@@ -80,7 +80,7 @@ const ConsentBanner = () => {
|
|||||||
>
|
>
|
||||||
Privacy Policy
|
Privacy Policy
|
||||||
</Link>
|
</Link>
|
||||||
<span className="text-slate-400">•</span>
|
<span className="text-slate-600">•</span>
|
||||||
<Link
|
<Link
|
||||||
to="/terms"
|
to="/terms"
|
||||||
className="text-blue-600 hover:text-blue-700 dark:text-blue-400 underline"
|
className="text-blue-600 hover:text-blue-700 dark:text-blue-400 underline"
|
||||||
@@ -123,7 +123,7 @@ const ConsentBanner = () => {
|
|||||||
</h3>
|
</h3>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowCustomize(false)}
|
onClick={() => setShowCustomize(false)}
|
||||||
className="p-1 text-slate-400 hover:text-slate-600 dark:hover:text-slate-300"
|
className="p-1 text-slate-600 hover:text-slate-600 dark:hover:text-slate-300"
|
||||||
>
|
>
|
||||||
<X className="h-5 w-5" />
|
<X className="h-5 w-5" />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
2
src/components/CopyButton.js
Normal file → Executable file
@@ -23,7 +23,7 @@ const CopyButton = ({ text, className = '' }) => {
|
|||||||
{copied ? (
|
{copied ? (
|
||||||
<Check className="h-4 w-4 text-green-600" />
|
<Check className="h-4 w-4 text-green-600" />
|
||||||
) : (
|
) : (
|
||||||
<Copy className="h-4 w-4 text-gray-600 dark:text-gray-400" />
|
<Copy className="h-4 w-4 text-gray-600 dark:text-gray-600" />
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
|
|||||||
4
src/components/ErrorBoundary.js
Normal file → Executable file
@@ -40,7 +40,7 @@ class ErrorBoundary extends React.Component {
|
|||||||
Something went wrong
|
Something went wrong
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
|
<p className="text-sm text-gray-600 dark:text-gray-600 mb-4">
|
||||||
The application encountered an error. This might be due to browser compatibility issues.
|
The application encountered an error. This might be due to browser compatibility issues.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
@@ -60,7 +60,7 @@ class ErrorBoundary extends React.Component {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-4 text-xs text-gray-500 dark:text-gray-400">
|
<div className="mt-4 text-xs text-gray-600 dark:text-gray-600">
|
||||||
If you're using Telegram's built-in browser, try opening this link in your default browser for better compatibility.
|
If you're using Telegram's built-in browser, try opening this link in your default browser for better compatibility.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
107
src/components/FullscreenAdBanner.js
Executable file
@@ -0,0 +1,107 @@
|
|||||||
|
import React, { useEffect, useRef } from "react";
|
||||||
|
|
||||||
|
const FullscreenAdBanner = () => {
|
||||||
|
const desktopIframeRef = useRef(null);
|
||||||
|
const mobileIframeRef = useRef(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Initialize Desktop/Tablet Ad (728x90)
|
||||||
|
if (desktopIframeRef.current) {
|
||||||
|
const iframe = desktopIframeRef.current;
|
||||||
|
const doc = iframe.contentDocument || iframe.contentWindow.document;
|
||||||
|
|
||||||
|
doc.open();
|
||||||
|
doc.write(`
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<style>
|
||||||
|
body { margin: 0; padding: 0; display: block; background: transparent; overflow: hidden; height: 100vh; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<script type="text/javascript">
|
||||||
|
atOptions = {
|
||||||
|
'key' : '5d1186bf7f51a6e8732651b00fefc51b',
|
||||||
|
'format' : 'iframe',
|
||||||
|
'height' : 90,
|
||||||
|
'width' : 728,
|
||||||
|
'params' : {}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
<script type="text/javascript" src="https://downconvenientmagnetic.com/5d1186bf7f51a6e8732651b00fefc51b/invoke.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`);
|
||||||
|
doc.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize Mobile Ad (320x50) using the existing mobile key
|
||||||
|
if (mobileIframeRef.current) {
|
||||||
|
const iframe = mobileIframeRef.current;
|
||||||
|
const doc = iframe.contentDocument || iframe.contentWindow.document;
|
||||||
|
|
||||||
|
doc.open();
|
||||||
|
doc.write(`
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<style>
|
||||||
|
body { margin: 0; padding: 0; display: block; background: transparent; overflow: hidden; height: 100vh; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<script type="text/javascript">
|
||||||
|
atOptions = {
|
||||||
|
'key' : '2965bcf877388cafa84160592c550f5a',
|
||||||
|
'format' : 'iframe',
|
||||||
|
'height' : 50,
|
||||||
|
'width' : 320,
|
||||||
|
'params' : {}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
<script type="text/javascript" src="https://downconvenientmagnetic.com/2965bcf877388cafa84160592c550f5a/invoke.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`);
|
||||||
|
doc.close();
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Desktop & Tablet View (>= 768px) */}
|
||||||
|
<div className="absolute bottom-0 left-0 right-0 z-50 justify-center bg-gray-50 dark:bg-gray-900 border-t border-gray-200 dark:border-gray-700 py-2 shadow-lg hidden md:flex">
|
||||||
|
<iframe
|
||||||
|
ref={desktopIframeRef}
|
||||||
|
style={{
|
||||||
|
width: "728px",
|
||||||
|
height: "90px",
|
||||||
|
border: "none",
|
||||||
|
maxWidth: "100%",
|
||||||
|
}}
|
||||||
|
title="Fullscreen Advertisement Desktop"
|
||||||
|
sandbox="allow-scripts allow-same-origin"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile View (< 768px) */}
|
||||||
|
<div className="absolute bottom-0 left-0 right-0 z-50 flex justify-center items-end bg-white dark:bg-gray-900 border-t border-gray-200 dark:border-gray-700 shadow-lg md:hidden h-[51px]">
|
||||||
|
<iframe
|
||||||
|
ref={mobileIframeRef}
|
||||||
|
style={{
|
||||||
|
width: "320px",
|
||||||
|
height: "50px",
|
||||||
|
border: "none",
|
||||||
|
maxWidth: "100%",
|
||||||
|
display: "block",
|
||||||
|
}}
|
||||||
|
title="Fullscreen Advertisement Mobile"
|
||||||
|
sandbox="allow-scripts allow-same-origin"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FullscreenAdBanner;
|
||||||
199
src/components/Layout.js
Normal file → Executable file
@@ -1,18 +1,30 @@
|
|||||||
import React, { useState, useEffect, useRef } from 'react';
|
import React, { useState, useEffect, useRef } from "react";
|
||||||
import { useLocation } from 'react-router-dom';
|
import { useLocation } from "react-router-dom";
|
||||||
import ToolSidebar from './ToolSidebar';
|
import ToolSidebar from "./ToolSidebar";
|
||||||
import NavigationConfirmModal from './NavigationConfirmModal';
|
import NavigationConfirmModal from "./NavigationConfirmModal";
|
||||||
import useNavigationGuard from '../hooks/useNavigationGuard';
|
import useNavigationGuard from "../hooks/useNavigationGuard";
|
||||||
import { Menu, X, ChevronDown, Terminal, Sparkles, Home } from 'lucide-react';
|
import { Menu, X, ChevronDown, Terminal, Sparkles, Home } from "lucide-react";
|
||||||
import ThemeToggle from './ThemeToggle';
|
import ThemeToggle from "./ThemeToggle";
|
||||||
import SEOHead from './SEOHead';
|
import SEOHead from "./SEOHead";
|
||||||
import ConsentBanner from './ConsentBanner';
|
import ConsentBanner from "./ConsentBanner";
|
||||||
import { NON_TOOLS, TOOLS, SITE_CONFIG, getCategoryConfig } from '../config/tools';
|
import {
|
||||||
import { useAnalytics } from '../hooks/useAnalytics';
|
NON_TOOLS,
|
||||||
|
TOOLS,
|
||||||
|
SITE_CONFIG,
|
||||||
|
getCategoryConfig,
|
||||||
|
} from "../config/tools";
|
||||||
|
import { useAnalytics } from "../hooks/useAnalytics";
|
||||||
|
|
||||||
const Layout = ({ children }) => {
|
const Layout = ({ children }) => {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const { showModal, pendingNavigation, handleConfirm, handleCancel, hasUnsavedData, navigateWithGuard } = useNavigationGuard();
|
const {
|
||||||
|
showModal,
|
||||||
|
pendingNavigation,
|
||||||
|
handleConfirm,
|
||||||
|
handleCancel,
|
||||||
|
hasUnsavedData,
|
||||||
|
navigateWithGuard,
|
||||||
|
} = useNavigationGuard();
|
||||||
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
||||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
||||||
const dropdownRef = useRef(null);
|
const dropdownRef = useRef(null);
|
||||||
@@ -32,9 +44,9 @@ const Layout = ({ children }) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
document.addEventListener('mousedown', handleClickOutside);
|
document.addEventListener("mousedown", handleClickOutside);
|
||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener('mousedown', handleClickOutside);
|
document.removeEventListener("mousedown", handleClickOutside);
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -45,10 +57,10 @@ const Layout = ({ children }) => {
|
|||||||
}, [location.pathname]);
|
}, [location.pathname]);
|
||||||
|
|
||||||
// Check if we're on a tool page (not homepage)
|
// Check if we're on a tool page (not homepage)
|
||||||
const isToolPage = location.pathname !== '/';
|
const isToolPage = location.pathname !== "/";
|
||||||
|
|
||||||
// Check if we're on invoice preview page (no sidebar needed)
|
// Check if we're on invoice preview page (no sidebar needed)
|
||||||
const isInvoicePreviewPage = location.pathname === '/invoice-preview';
|
const isInvoicePreviewPage = location.pathname === "/invoice-preview";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-blue-50 to-indigo-50 dark:from-slate-900 dark:via-slate-800 dark:to-indigo-900 flex flex-col">
|
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-blue-50 to-indigo-50 dark:from-slate-900 dark:via-slate-800 dark:to-indigo-900 flex flex-col">
|
||||||
@@ -57,9 +69,18 @@ const Layout = ({ children }) => {
|
|||||||
|
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<header className="fixed top-0 left-0 right-0 z-50 bg-white/80 dark:bg-slate-800/80 backdrop-blur-md shadow-lg border-b border-slate-200/50 dark:border-slate-700/50 flex-shrink-0">
|
<header className="fixed top-0 left-0 right-0 z-50 bg-white/80 dark:bg-slate-800/80 backdrop-blur-md shadow-lg border-b border-slate-200/50 dark:border-slate-700/50 flex-shrink-0">
|
||||||
<div className={isToolPage ? "px-4 sm:px-6 lg:px-8" : "max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"}>
|
<div
|
||||||
|
className={
|
||||||
|
isToolPage
|
||||||
|
? "px-4 sm:px-6 lg:px-8"
|
||||||
|
: "max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"
|
||||||
|
}
|
||||||
|
>
|
||||||
<div className="flex justify-between items-center h-16">
|
<div className="flex justify-between items-center h-16">
|
||||||
<button onClick={() => navigateWithGuard('/')} className="flex items-center group">
|
<button
|
||||||
|
onClick={() => navigateWithGuard("/")}
|
||||||
|
className="flex items-center group"
|
||||||
|
>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<div className="absolute inset-0 rounded-lg blur opacity-20 group-hover:opacity-40 transition-opacity"></div>
|
<div className="absolute inset-0 rounded-lg blur opacity-20 group-hover:opacity-40 transition-opacity"></div>
|
||||||
<div className="relative p-2">
|
<div className="relative p-2">
|
||||||
@@ -67,11 +88,11 @@ const Layout = ({ children }) => {
|
|||||||
src="/logo.svg"
|
src="/logo.svg"
|
||||||
alt={SITE_CONFIG.title}
|
alt={SITE_CONFIG.title}
|
||||||
className="h-8 w-auto"
|
className="h-8 w-auto"
|
||||||
style={{ maxWidth: '150px' }}
|
style={{ maxWidth: "150px" }}
|
||||||
onError={(e) => {
|
onError={(e) => {
|
||||||
// Fallback to Terminal icon with text if logo fails to load
|
// Fallback to Terminal icon with text if logo fails to load
|
||||||
e.target.style.display = 'none';
|
e.target.style.display = "none";
|
||||||
e.target.nextSibling.style.display = 'flex';
|
e.target.nextSibling.style.display = "flex";
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<div className="hidden items-center space-x-3">
|
<div className="hidden items-center space-x-3">
|
||||||
@@ -91,12 +112,12 @@ const Layout = ({ children }) => {
|
|||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setIsDropdownOpen(false);
|
setIsDropdownOpen(false);
|
||||||
navigateWithGuard('/');
|
navigateWithGuard("/");
|
||||||
}}
|
}}
|
||||||
className={`flex items-center space-x-2 px-4 py-2 rounded-xl text-sm font-medium transition-all duration-300 ${
|
className={`flex items-center space-x-2 px-4 py-2 rounded-xl text-sm font-medium transition-all duration-300 ${
|
||||||
isActive('/')
|
isActive("/")
|
||||||
? 'bg-gradient-to-r from-blue-500 to-purple-500 text-white shadow-lg'
|
? "bg-gradient-to-r from-blue-500 to-purple-500 text-white shadow-lg"
|
||||||
: 'text-slate-600 dark:text-slate-300 hover:text-blue-600 dark:hover:text-blue-400 hover:bg-white/50 dark:hover:bg-slate-700/50'
|
: "text-slate-600 dark:text-slate-300 hover:text-blue-600 dark:hover:text-blue-400 hover:bg-white/50 dark:hover:bg-slate-700/50"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Home className="h-4 w-4" />
|
<Home className="h-4 w-4" />
|
||||||
@@ -108,12 +129,16 @@ const Layout = ({ children }) => {
|
|||||||
<button
|
<button
|
||||||
onClick={() => setIsDropdownOpen(!isDropdownOpen)}
|
onClick={() => setIsDropdownOpen(!isDropdownOpen)}
|
||||||
className="flex items-center space-x-2 px-4 py-2 rounded-xl text-sm font-medium text-slate-600 hover:text-slate-900 dark:text-slate-300 dark:hover:text-white hover:bg-white/50 dark:hover:bg-slate-700/50 transition-all duration-300"
|
className="flex items-center space-x-2 px-4 py-2 rounded-xl text-sm font-medium text-slate-600 hover:text-slate-900 dark:text-slate-300 dark:hover:text-white hover:bg-white/50 dark:hover:bg-slate-700/50 transition-all duration-300"
|
||||||
|
aria-expanded={isDropdownOpen}
|
||||||
|
aria-haspopup="true"
|
||||||
>
|
>
|
||||||
<Sparkles className="h-4 w-4" />
|
<Sparkles className="h-4 w-4" />
|
||||||
<span>Tools</span>
|
<span>Tools</span>
|
||||||
<ChevronDown className={`h-4 w-4 transition-transform duration-300 ${
|
<ChevronDown
|
||||||
isDropdownOpen ? 'rotate-180' : ''
|
className={`h-4 w-4 transition-transform duration-300 ${
|
||||||
}`} />
|
isDropdownOpen ? "rotate-180" : ""
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Dropdown Menu */}
|
{/* Dropdown Menu */}
|
||||||
@@ -123,7 +148,9 @@ const Layout = ({ children }) => {
|
|||||||
<div className="relative">
|
<div className="relative">
|
||||||
{TOOLS.map((tool) => {
|
{TOOLS.map((tool) => {
|
||||||
const IconComponent = tool.icon;
|
const IconComponent = tool.icon;
|
||||||
const categoryConfig = getCategoryConfig(tool.category);
|
const categoryConfig = getCategoryConfig(
|
||||||
|
tool.category,
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
@@ -134,19 +161,23 @@ const Layout = ({ children }) => {
|
|||||||
}}
|
}}
|
||||||
className={`group flex items-center space-x-4 px-4 py-3 text-sm hover:bg-white/50 dark:hover:bg-slate-700/50 transition-all duration-300 w-full text-left ${
|
className={`group flex items-center space-x-4 px-4 py-3 text-sm hover:bg-white/50 dark:hover:bg-slate-700/50 transition-all duration-300 w-full text-left ${
|
||||||
isActive(tool.path)
|
isActive(tool.path)
|
||||||
? 'bg-gradient-to-r from-blue-50 to-purple-50 dark:from-slate-700 dark:to-slate-600 text-blue-700 dark:text-blue-300'
|
? "bg-gradient-to-r from-blue-50 to-purple-50 dark:from-slate-700 dark:to-slate-600 text-blue-700 dark:text-blue-300"
|
||||||
: 'text-slate-700 dark:text-slate-300'
|
: "text-slate-700 dark:text-slate-300"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className={`p-2 rounded-lg bg-gradient-to-br ${categoryConfig.color} shadow-sm group-hover:scale-110 transition-transform duration-300`}>
|
<div
|
||||||
|
className={`p-2 rounded-lg bg-gradient-to-br ${categoryConfig.color} shadow-sm group-hover:scale-110 transition-transform duration-300`}
|
||||||
|
>
|
||||||
<IconComponent className="h-4 w-4 text-white" />
|
<IconComponent className="h-4 w-4 text-white" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className="font-medium">{tool.name}</div>
|
<div className="font-medium">{tool.name}</div>
|
||||||
<div className="text-xs text-slate-500 dark:text-slate-400">{tool.description}</div>
|
<div className="text-xs text-slate-600 dark:text-slate-600">
|
||||||
|
{tool.description}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="opacity-0 group-hover:opacity-100 transition-opacity duration-300">
|
<div className="opacity-0 group-hover:opacity-100 transition-opacity duration-300">
|
||||||
<ChevronDown className="h-4 w-4 -rotate-90 text-slate-400" />
|
<ChevronDown className="h-4 w-4 -rotate-90 text-slate-600" />
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
@@ -164,8 +195,14 @@ const Layout = ({ children }) => {
|
|||||||
<button
|
<button
|
||||||
onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}
|
onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}
|
||||||
className="md:hidden p-2 rounded-xl text-slate-600 hover:text-slate-900 dark:text-slate-300 dark:hover:text-white hover:bg-white/50 dark:hover:bg-slate-700/50 transition-all duration-300"
|
className="md:hidden p-2 rounded-xl text-slate-600 hover:text-slate-900 dark:text-slate-300 dark:hover:text-white hover:bg-white/50 dark:hover:bg-slate-700/50 transition-all duration-300"
|
||||||
|
aria-label={isMobileMenuOpen ? "Close menu" : "Open menu"}
|
||||||
|
aria-expanded={isMobileMenuOpen}
|
||||||
>
|
>
|
||||||
{isMobileMenuOpen ? <X className="h-6 w-6" /> : <Menu className="h-6 w-6" />}
|
{isMobileMenuOpen ? (
|
||||||
|
<X className="h-6 w-6" />
|
||||||
|
) : (
|
||||||
|
<Menu className="h-6 w-6" />
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -198,12 +235,16 @@ const Layout = ({ children }) => {
|
|||||||
}}
|
}}
|
||||||
className={`flex items-center space-x-3 px-4 py-3 rounded-xl text-sm font-medium transition-all duration-300 w-full text-left ${
|
className={`flex items-center space-x-3 px-4 py-3 rounded-xl text-sm font-medium transition-all duration-300 w-full text-left ${
|
||||||
isActive(tool.path)
|
isActive(tool.path)
|
||||||
? 'bg-gradient-to-r from-indigo-500 to-purple-500 text-white shadow-lg'
|
? "bg-gradient-to-r from-indigo-500 to-purple-500 text-white shadow-lg"
|
||||||
: 'text-slate-600 hover:text-slate-900 dark:text-slate-300 dark:hover:text-white hover:bg-white/50 dark:hover:bg-slate-700/50'
|
: "text-slate-600 hover:text-slate-900 dark:text-slate-300 dark:hover:text-white hover:bg-white/50 dark:hover:bg-slate-700/50"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className={`p-2 rounded-lg ${isActive(tool.path) ? 'bg-white/20' : 'bg-gradient-to-br from-indigo-500 to-purple-500'} shadow-sm`}>
|
<div
|
||||||
<IconComponent className={`h-4 w-4 ${isActive(tool.path) ? 'text-white' : 'text-white'}`} />
|
className={`p-2 rounded-lg ${isActive(tool.path) ? "bg-white/20" : "bg-gradient-to-br from-indigo-500 to-purple-500"} shadow-sm`}
|
||||||
|
>
|
||||||
|
<IconComponent
|
||||||
|
className={`h-4 w-4 ${isActive(tool.path) ? "text-white" : "text-white"}`}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<span>{tool.name}</span>
|
<span>{tool.name}</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -211,9 +252,9 @@ const Layout = ({ children }) => {
|
|||||||
})}
|
})}
|
||||||
|
|
||||||
<div className="border-t border-slate-200/50 dark:border-slate-700/50 pt-4 mt-4">
|
<div className="border-t border-slate-200/50 dark:border-slate-700/50 pt-4 mt-4">
|
||||||
<div className="text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wider px-4 py-2 flex items-center gap-2">
|
<div className="text-xs font-semibold text-slate-600 dark:text-slate-600 uppercase tracking-wider px-4 py-2 flex items-center gap-2">
|
||||||
<Sparkles className="h-3 w-3" />
|
<Sparkles className="h-3 w-3" />
|
||||||
{isToolPage ? 'Switch Tools' : 'Tools'}
|
{isToolPage ? "Switch Tools" : "Tools"}
|
||||||
</div>
|
</div>
|
||||||
{TOOLS.map((tool) => {
|
{TOOLS.map((tool) => {
|
||||||
const IconComponent = tool.icon;
|
const IconComponent = tool.icon;
|
||||||
@@ -228,16 +269,20 @@ const Layout = ({ children }) => {
|
|||||||
}}
|
}}
|
||||||
className={`flex items-center space-x-4 px-4 py-3 rounded-xl text-sm font-medium transition-all duration-300 w-full text-left ${
|
className={`flex items-center space-x-4 px-4 py-3 rounded-xl text-sm font-medium transition-all duration-300 w-full text-left ${
|
||||||
isActive(tool.path)
|
isActive(tool.path)
|
||||||
? 'bg-gradient-to-r from-blue-50 to-purple-50 dark:from-slate-700 dark:to-slate-600 text-blue-700 dark:text-blue-300'
|
? "bg-gradient-to-r from-blue-50 to-purple-50 dark:from-slate-700 dark:to-slate-600 text-blue-700 dark:text-blue-300"
|
||||||
: 'text-slate-600 hover:text-slate-900 dark:text-slate-300 dark:hover:text-white hover:bg-white/50 dark:hover:bg-slate-700/50'
|
: "text-slate-600 hover:text-slate-900 dark:text-slate-300 dark:hover:text-white hover:bg-white/50 dark:hover:bg-slate-700/50"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className={`p-2 rounded-lg bg-gradient-to-br ${categoryConfig.color} shadow-sm`}>
|
<div
|
||||||
|
className={`p-2 rounded-lg bg-gradient-to-br ${categoryConfig.color} shadow-sm`}
|
||||||
|
>
|
||||||
<IconComponent className="h-4 w-4 text-white" />
|
<IconComponent className="h-4 w-4 text-white" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className="font-medium">{tool.name}</div>
|
<div className="font-medium">{tool.name}</div>
|
||||||
<div className="text-xs text-slate-500 dark:text-slate-400">{tool.description}</div>
|
<div className="text-xs text-slate-600 dark:text-slate-600">
|
||||||
|
{tool.description}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
@@ -250,7 +295,7 @@ const Layout = ({ children }) => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Main Content */}
|
{/* Main Content */}
|
||||||
<div className="flex flex-1 pt-16 min-w-0 w-full max-w-full overflow-x-hidden">
|
<div className="flex flex-1 pt-16 min-w-0 w-full max-w-full">
|
||||||
{/* Main Content Area */}
|
{/* Main Content Area */}
|
||||||
<main className="flex-1 flex flex-col min-w-0 w-full max-w-full">
|
<main className="flex-1 flex flex-col min-w-0 w-full max-w-full">
|
||||||
{isToolPage && !isInvoicePreviewPage ? (
|
{isToolPage && !isInvoicePreviewPage ? (
|
||||||
@@ -259,22 +304,18 @@ const Layout = ({ children }) => {
|
|||||||
<ToolSidebar navigateWithGuard={navigateWithGuard} />
|
<ToolSidebar navigateWithGuard={navigateWithGuard} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 flex flex-col pl-0 lg:pl-16 min-w-0">
|
<div className="flex-1 flex flex-col pl-0 lg:pl-16 min-w-0">
|
||||||
<div className="p-4 sm:p-6 w-full min-w-0 max-w-full overflow-x-hidden">
|
<div className="p-4 sm:p-6 w-full min-w-0 max-w-full">
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : isInvoicePreviewPage ? (
|
) : isInvoicePreviewPage ? (
|
||||||
<div className="flex-1 flex flex-col">
|
<div className="flex-1 flex flex-col">
|
||||||
<div className="flex-1">
|
<div className="flex-1">{children}</div>
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex-1 flex flex-col">
|
<div className="flex-1 flex flex-col">
|
||||||
<div className="flex-1">
|
<div className="flex-1">{children}</div>
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
{/* Global Footer for Homepage */}
|
{/* Global Footer for Homepage */}
|
||||||
<footer className="bg-white/30 dark:bg-slate-800/30 backdrop-blur-sm border-t border-slate-200/30 dark:border-slate-700/30 mt-20">
|
<footer className="bg-white/30 dark:bg-slate-800/30 backdrop-blur-sm border-t border-slate-200/30 dark:border-slate-700/30 mt-20">
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||||
@@ -287,11 +328,11 @@ const Layout = ({ children }) => {
|
|||||||
src="/icon-192x192.png"
|
src="/icon-192x192.png"
|
||||||
alt={SITE_CONFIG.title}
|
alt={SITE_CONFIG.title}
|
||||||
className="h-16 w-auto"
|
className="h-16 w-auto"
|
||||||
style={{ maxWidth: '100px' }}
|
style={{ maxWidth: "100px" }}
|
||||||
onError={(e) => {
|
onError={(e) => {
|
||||||
// Fallback to Terminal icon with text if logo fails to load
|
// Fallback to Terminal icon with text if logo fails to load
|
||||||
e.target.style.display = 'none';
|
e.target.style.display = "none";
|
||||||
e.target.nextSibling.style.display = 'flex';
|
e.target.nextSibling.style.display = "flex";
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<div className="hidden items-center gap-3">
|
<div className="hidden items-center gap-3">
|
||||||
@@ -305,16 +346,16 @@ const Layout = ({ children }) => {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-center gap-2 mb-3">
|
<div className="flex items-center justify-center gap-2 mb-3">
|
||||||
<div className="w-2 h-2 bg-gradient-to-r from-blue-500 to-purple-500 rounded-full"></div>
|
<div className="w-2 h-2 bg-gradient-to-r from-blue-500 to-purple-500 rounded-full"></div>
|
||||||
<span className="text-sm font-medium text-slate-600 dark:text-slate-400">
|
<span className="text-sm font-medium text-slate-600 dark:text-slate-600">
|
||||||
© {SITE_CONFIG.year} {SITE_CONFIG.title}
|
© {SITE_CONFIG.year} {SITE_CONFIG.title}
|
||||||
</span>
|
</span>
|
||||||
<div className="w-2 h-2 bg-gradient-to-r from-purple-500 to-indigo-500 rounded-full"></div>
|
<div className="w-2 h-2 bg-gradient-to-r from-purple-500 to-indigo-500 rounded-full"></div>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-slate-500 dark:text-slate-500 mb-4">
|
<p className="text-sm text-slate-600 dark:text-slate-600 mb-4">
|
||||||
Built with ❤️ for developers worldwide
|
Built with ❤️ for developers worldwide
|
||||||
</p>
|
</p>
|
||||||
<div className="flex flex-col items-center gap-4">
|
<div className="flex flex-col items-center gap-4">
|
||||||
<div className="flex justify-center items-center gap-6 text-xs text-slate-400 dark:text-slate-500">
|
<div className="flex justify-center items-center gap-6 text-xs text-slate-600 dark:text-slate-600">
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<div className="w-1.5 h-1.5 bg-green-500 rounded-full animate-pulse"></div>
|
<div className="w-1.5 h-1.5 bg-green-500 rounded-full animate-pulse"></div>
|
||||||
<span>100% Client-Side</span>
|
<span>100% Client-Side</span>
|
||||||
@@ -330,22 +371,26 @@ const Layout = ({ children }) => {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-4 text-xs">
|
<div className="flex items-center gap-4 text-xs">
|
||||||
<button
|
<button
|
||||||
onClick={() => navigateWithGuard('/release-notes')}
|
onClick={() => navigateWithGuard("/release-notes")}
|
||||||
className="text-slate-400 hover:text-slate-600 dark:hover:text-slate-300 transition-colors"
|
className="text-slate-600 hover:text-slate-600 dark:hover:text-slate-300 transition-colors"
|
||||||
>
|
>
|
||||||
Release Notes
|
Release Notes
|
||||||
</button>
|
</button>
|
||||||
<span className="text-slate-300 dark:text-slate-600">•</span>
|
<span className="text-slate-300 dark:text-slate-600">
|
||||||
|
•
|
||||||
|
</span>
|
||||||
<button
|
<button
|
||||||
onClick={() => navigateWithGuard('/privacy')}
|
onClick={() => navigateWithGuard("/privacy")}
|
||||||
className="text-slate-400 hover:text-slate-600 dark:hover:text-slate-300 transition-colors"
|
className="text-slate-600 hover:text-slate-600 dark:hover:text-slate-300 transition-colors"
|
||||||
>
|
>
|
||||||
Privacy Policy
|
Privacy Policy
|
||||||
</button>
|
</button>
|
||||||
<span className="text-slate-300 dark:text-slate-600">•</span>
|
<span className="text-slate-300 dark:text-slate-600">
|
||||||
|
•
|
||||||
|
</span>
|
||||||
<button
|
<button
|
||||||
onClick={() => navigateWithGuard('/terms')}
|
onClick={() => navigateWithGuard("/terms")}
|
||||||
className="text-slate-400 hover:text-slate-600 dark:hover:text-slate-300 transition-colors"
|
className="text-slate-600 hover:text-slate-600 dark:hover:text-slate-300 transition-colors"
|
||||||
>
|
>
|
||||||
Terms of Service
|
Terms of Service
|
||||||
</button>
|
</button>
|
||||||
@@ -369,39 +414,39 @@ const Layout = ({ children }) => {
|
|||||||
src="/icon-192x192.png"
|
src="/icon-192x192.png"
|
||||||
alt={SITE_CONFIG.title}
|
alt={SITE_CONFIG.title}
|
||||||
className="h-16 w-auto"
|
className="h-16 w-auto"
|
||||||
style={{ maxWidth: '100px' }}
|
style={{ maxWidth: "100px" }}
|
||||||
onError={(e) => {
|
onError={(e) => {
|
||||||
// Fallback to Terminal icon with text if logo fails to load
|
// Fallback to Terminal icon with text if logo fails to load
|
||||||
e.target.style.display = 'none';
|
e.target.style.display = "none";
|
||||||
e.target.nextSibling.style.display = 'flex';
|
e.target.nextSibling.style.display = "flex";
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-center gap-2 mb-2">
|
<div className="flex items-center justify-center gap-2 mb-2">
|
||||||
<div className="w-2 h-2 bg-gradient-to-r from-blue-500 to-purple-500 rounded-full"></div>
|
<div className="w-2 h-2 bg-gradient-to-r from-blue-500 to-purple-500 rounded-full"></div>
|
||||||
<span className="text-xs font-medium text-slate-600 dark:text-slate-400">
|
<span className="text-xs font-medium text-slate-600 dark:text-slate-600">
|
||||||
© {SITE_CONFIG.year} {SITE_CONFIG.title}
|
© {SITE_CONFIG.year} {SITE_CONFIG.title}
|
||||||
</span>
|
</span>
|
||||||
<div className="w-2 h-2 bg-gradient-to-r from-purple-500 to-indigo-500 rounded-full"></div>
|
<div className="w-2 h-2 bg-gradient-to-r from-purple-500 to-indigo-500 rounded-full"></div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-center gap-4 text-xs">
|
<div className="flex items-center justify-center gap-4 text-xs">
|
||||||
<button
|
<button
|
||||||
onClick={() => navigateWithGuard('/release-notes')}
|
onClick={() => navigateWithGuard("/release-notes")}
|
||||||
className="text-slate-400 hover:text-slate-600 dark:hover:text-slate-300 transition-colors"
|
className="text-slate-600 hover:text-slate-600 dark:hover:text-slate-300 transition-colors"
|
||||||
>
|
>
|
||||||
Release Notes
|
Release Notes
|
||||||
</button>
|
</button>
|
||||||
<span className="text-slate-300 dark:text-slate-600">•</span>
|
<span className="text-slate-300 dark:text-slate-600">•</span>
|
||||||
<button
|
<button
|
||||||
onClick={() => navigateWithGuard('/privacy')}
|
onClick={() => navigateWithGuard("/privacy")}
|
||||||
className="text-slate-400 hover:text-slate-600 dark:hover:text-slate-300 transition-colors"
|
className="text-slate-600 hover:text-slate-600 dark:hover:text-slate-300 transition-colors"
|
||||||
>
|
>
|
||||||
Privacy Policy
|
Privacy Policy
|
||||||
</button>
|
</button>
|
||||||
<span className="text-slate-300 dark:text-slate-600">•</span>
|
<span className="text-slate-300 dark:text-slate-600">•</span>
|
||||||
<button
|
<button
|
||||||
onClick={() => navigateWithGuard('/terms')}
|
onClick={() => navigateWithGuard("/terms")}
|
||||||
className="text-slate-400 hover:text-slate-600 dark:hover:text-slate-300 transition-colors"
|
className="text-slate-600 hover:text-slate-600 dark:hover:text-slate-300 transition-colors"
|
||||||
>
|
>
|
||||||
Terms of Service
|
Terms of Service
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
2
src/components/Loading.js
Normal file → Executable file
@@ -8,7 +8,7 @@ const Loading = () => {
|
|||||||
<div className="absolute inset-0 bg-blue-500/20 blur-xl rounded-full animate-pulse"></div>
|
<div className="absolute inset-0 bg-blue-500/20 blur-xl rounded-full animate-pulse"></div>
|
||||||
<Loader2 className="h-12 w-12 text-blue-600 dark:text-blue-400 animate-spin relative z-10" />
|
<Loader2 className="h-12 w-12 text-blue-600 dark:text-blue-400 animate-spin relative z-10" />
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-4 text-slate-500 dark:text-slate-400 font-medium animate-pulse">
|
<p className="mt-4 text-slate-600 dark:text-slate-600 font-medium animate-pulse">
|
||||||
Loading...
|
Loading...
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
18
src/components/MindmapView.js
Normal file → Executable file
@@ -521,7 +521,7 @@ const MindmapView = React.memo(({ data }) => {
|
|||||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 p-4 transition-all duration-200 ease-in-out">
|
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 p-4 transition-all duration-200 ease-in-out">
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-gray-600 dark:text-gray-400 block mb-1">Edge Type</label>
|
<label className="text-xs text-gray-600 dark:text-gray-600 block mb-1">Edge Type</label>
|
||||||
<select
|
<select
|
||||||
value={edgeType}
|
value={edgeType}
|
||||||
onChange={(e) => setEdgeType(e.target.value)}
|
onChange={(e) => setEdgeType(e.target.value)}
|
||||||
@@ -535,7 +535,7 @@ const MindmapView = React.memo(({ data }) => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-gray-600 dark:text-gray-400 block mb-1">Line Color</label>
|
<label className="text-xs text-gray-600 dark:text-gray-600 block mb-1">Line Color</label>
|
||||||
<select
|
<select
|
||||||
value={edgeColor}
|
value={edgeColor}
|
||||||
onChange={(e) => setEdgeColor(e.target.value)}
|
onChange={(e) => setEdgeColor(e.target.value)}
|
||||||
@@ -558,7 +558,7 @@ const MindmapView = React.memo(({ data }) => {
|
|||||||
onChange={(e) => setLayoutCompact(e.target.checked)}
|
onChange={(e) => setLayoutCompact(e.target.checked)}
|
||||||
className="text-xs"
|
className="text-xs"
|
||||||
/>
|
/>
|
||||||
<label htmlFor="compact" className="text-xs text-gray-600 dark:text-gray-400">Compact Layout</label>
|
<label htmlFor="compact" className="text-xs text-gray-600 dark:text-gray-600">Compact Layout</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
@@ -569,7 +569,7 @@ const MindmapView = React.memo(({ data }) => {
|
|||||||
onChange={(e) => setSnapToGrid(e.target.checked)}
|
onChange={(e) => setSnapToGrid(e.target.checked)}
|
||||||
className="text-xs"
|
className="text-xs"
|
||||||
/>
|
/>
|
||||||
<label htmlFor="snapToGrid" className="text-xs text-gray-600 dark:text-gray-400">Snap to Grid</label>
|
<label htmlFor="snapToGrid" className="text-xs text-gray-600 dark:text-gray-600">Snap to Grid</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -597,31 +597,31 @@ const MindmapView = React.memo(({ data }) => {
|
|||||||
<div className="w-4 h-4 bg-blue-100 border-2 border-blue-300 rounded flex items-center justify-center">
|
<div className="w-4 h-4 bg-blue-100 border-2 border-blue-300 rounded flex items-center justify-center">
|
||||||
<Braces className="h-2.5 w-2.5 text-blue-600" />
|
<Braces className="h-2.5 w-2.5 text-blue-600" />
|
||||||
</div>
|
</div>
|
||||||
<span className="text-xs text-gray-600 dark:text-gray-400">Object</span>
|
<span className="text-xs text-gray-600 dark:text-gray-600">Object</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<div className="w-4 h-4 bg-green-100 border-2 border-green-300 rounded flex items-center justify-center">
|
<div className="w-4 h-4 bg-green-100 border-2 border-green-300 rounded flex items-center justify-center">
|
||||||
<List className="h-2.5 w-2.5 text-green-600" />
|
<List className="h-2.5 w-2.5 text-green-600" />
|
||||||
</div>
|
</div>
|
||||||
<span className="text-xs text-gray-600 dark:text-gray-400">Array</span>
|
<span className="text-xs text-gray-600 dark:text-gray-600">Array</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<div className="w-4 h-4 bg-purple-100 border-2 border-purple-300 rounded flex items-center justify-center">
|
<div className="w-4 h-4 bg-purple-100 border-2 border-purple-300 rounded flex items-center justify-center">
|
||||||
<Type className="h-2.5 w-2.5 text-purple-600" />
|
<Type className="h-2.5 w-2.5 text-purple-600" />
|
||||||
</div>
|
</div>
|
||||||
<span className="text-xs text-gray-600 dark:text-gray-400">String</span>
|
<span className="text-xs text-gray-600 dark:text-gray-600">String</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<div className="w-4 h-4 bg-orange-100 border-2 border-orange-300 rounded flex items-center justify-center">
|
<div className="w-4 h-4 bg-orange-100 border-2 border-orange-300 rounded flex items-center justify-center">
|
||||||
<Hash className="h-2.5 w-2.5 text-orange-600" />
|
<Hash className="h-2.5 w-2.5 text-orange-600" />
|
||||||
</div>
|
</div>
|
||||||
<span className="text-xs text-gray-600 dark:text-gray-400">Number</span>
|
<span className="text-xs text-gray-600 dark:text-gray-600">Number</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<div className="w-4 h-4 bg-yellow-100 border-2 border-yellow-300 rounded flex items-center justify-center">
|
<div className="w-4 h-4 bg-yellow-100 border-2 border-yellow-300 rounded flex items-center justify-center">
|
||||||
<ToggleLeft className="h-2.5 w-2.5 text-yellow-600" />
|
<ToggleLeft className="h-2.5 w-2.5 text-yellow-600" />
|
||||||
</div>
|
</div>
|
||||||
<span className="text-xs text-gray-600 dark:text-gray-400">Boolean</span>
|
<span className="text-xs text-gray-600 dark:text-gray-600">Boolean</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
47
src/components/MobileAdBanner.js
Normal file → Executable file
@@ -1,21 +1,10 @@
|
|||||||
import React, { useEffect, useRef, useState } from 'react';
|
import React, { useEffect, useRef } from "react";
|
||||||
import { X } from 'lucide-react';
|
|
||||||
|
|
||||||
const MobileAdBanner = () => {
|
const MobileAdBanner = () => {
|
||||||
const [visible, setVisible] = useState(true);
|
|
||||||
const [closed, setClosed] = useState(false);
|
|
||||||
const iframeRef = useRef(null);
|
const iframeRef = useRef(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const wasClosed = sessionStorage.getItem('mobileAdClosed');
|
if (!iframeRef.current) return;
|
||||||
if (wasClosed === 'true') {
|
|
||||||
setClosed(true);
|
|
||||||
setVisible(false);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!visible || closed || !iframeRef.current) return;
|
|
||||||
|
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
if (!iframeRef.current) return;
|
if (!iframeRef.current) return;
|
||||||
@@ -29,7 +18,7 @@ const MobileAdBanner = () => {
|
|||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
<style>
|
<style>
|
||||||
body { margin: 0; padding: 0; display: flex; justify-content: center; align-items: center; }
|
body { margin: 0; padding: 0; display: block; background: transparent; overflow: hidden; height: 100vh; }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -42,7 +31,7 @@ const MobileAdBanner = () => {
|
|||||||
'params' : {}
|
'params' : {}
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
<script type="text/javascript" src="https://bustleplaguereed.com/2965bcf877388cafa84160592c550f5a/invoke.js"></script>
|
<script type="text/javascript" src="https://downconvenientmagnetic.com/2965bcf877388cafa84160592c550f5a/invoke.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
`);
|
`);
|
||||||
@@ -50,34 +39,22 @@ const MobileAdBanner = () => {
|
|||||||
}, 500);
|
}, 500);
|
||||||
|
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}, [visible, closed]);
|
}, []);
|
||||||
|
|
||||||
const handleClose = () => {
|
|
||||||
setVisible(false);
|
|
||||||
setClosed(true);
|
|
||||||
sessionStorage.setItem('mobileAdClosed', 'true');
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!visible || closed) return null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="xl:hidden fixed bottom-0 left-0 right-0 z-50 bg-white dark:bg-gray-900 shadow-lg border-t border-gray-200 dark:border-gray-700">
|
<div className="lg:hidden fixed bottom-0 left-0 right-0 z-50 bg-white dark:bg-gray-900 shadow-lg border-t border-gray-200 dark:border-gray-700 h-[51px] flex justify-center items-end">
|
||||||
<button
|
|
||||||
onClick={handleClose}
|
|
||||||
className="absolute -top-2 right-2 p-1 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 bg-white dark:bg-gray-800 rounded-full shadow-sm z-10"
|
|
||||||
aria-label="Close ad"
|
|
||||||
>
|
|
||||||
<X className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
<div className="flex justify-center items-center py-2">
|
|
||||||
<iframe
|
<iframe
|
||||||
ref={iframeRef}
|
ref={iframeRef}
|
||||||
style={{ width: '320px', height: '50px', border: 'none' }}
|
style={{
|
||||||
|
width: "320px",
|
||||||
|
height: "50px",
|
||||||
|
border: "none",
|
||||||
|
display: "block",
|
||||||
|
}}
|
||||||
title="Mobile Advertisement"
|
title="Mobile Advertisement"
|
||||||
sandbox="allow-scripts allow-same-origin"
|
sandbox="allow-scripts allow-same-origin"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
2
src/components/NavigationConfirmModal.js
Normal file → Executable file
@@ -73,7 +73,7 @@ const NavigationConfirmModal = ({ isOpen, onConfirm, onCancel, targetPath, hasDa
|
|||||||
<p className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
<p className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
You currently have:
|
You currently have:
|
||||||
</p>
|
</p>
|
||||||
<ul className="text-sm text-gray-600 dark:text-gray-400 space-y-1">
|
<ul className="text-sm text-gray-600 dark:text-gray-600 space-y-1">
|
||||||
{dataSummary.map((item, index) => (
|
{dataSummary.map((item, index) => (
|
||||||
<li key={index} className="flex items-center">
|
<li key={index} className="flex items-center">
|
||||||
<span className="w-1.5 h-1.5 bg-amber-500 rounded-full mr-2 flex-shrink-0"></span>
|
<span className="w-1.5 h-1.5 bg-amber-500 rounded-full mr-2 flex-shrink-0"></span>
|
||||||
|
|||||||
18
src/components/OfferBlock.js
Normal file → Executable file
@@ -1,20 +1,16 @@
|
|||||||
import React from 'react';
|
import React from "react";
|
||||||
|
|
||||||
const OfferBlock = () => {
|
const OfferBlock = () => {
|
||||||
return (
|
return (
|
||||||
<div className="w-[300px] h-[250px] bg-gradient-to-br from-indigo-500 to-purple-600 rounded-lg flex flex-col items-center justify-center text-white p-6 text-center shadow-lg hover:shadow-xl transition-shadow duration-300">
|
<div className="w-[300px] h-[250px] bg-gradient-to-br from-indigo-500 to-purple-600 rounded-lg flex flex-col items-center justify-center text-white p-6 text-center shadow-lg hover:shadow-xl transition-shadow duration-300">
|
||||||
<span className="bg-white/20 text-xs font-bold px-2 py-1 rounded mb-4 backdrop-blur-sm">
|
<span className="bg-white/20 text-xs font-bold px-2 py-1 rounded mb-4 backdrop-blur-sm uppercase tracking-wider">
|
||||||
SPECIAL OFFER
|
Coming Soon
|
||||||
</span>
|
</span>
|
||||||
<h3 className="text-2xl font-bold mb-2">
|
<h3 className="text-2xl font-bold mb-2">Upgrade to PRO</h3>
|
||||||
Upgrade to PRO
|
<p className="text-indigo-100 text-sm mb-6 leading-relaxed">
|
||||||
</h3>
|
We are preparing a premium ad-free experience with exclusive developer
|
||||||
<p className="text-indigo-100 text-sm mb-6">
|
tools and features. Stay tuned!
|
||||||
Get unlimited access to all developer tools and features.
|
|
||||||
</p>
|
</p>
|
||||||
<button className="bg-white text-indigo-600 font-bold py-2 px-6 rounded-full hover:bg-indigo-50 transition-colors shadow-md">
|
|
||||||
Learn More
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
22
src/components/PostmanTable.js
Normal file → Executable file
@@ -292,7 +292,7 @@ const PostmanTable = ({ data, title = "JSON Data" }) => {
|
|||||||
<div className="flex items-center space-x-1 text-sm">
|
<div className="flex items-center space-x-1 text-sm">
|
||||||
{getBreadcrumb().map((part, index) => (
|
{getBreadcrumb().map((part, index) => (
|
||||||
<React.Fragment key={index}>
|
<React.Fragment key={index}>
|
||||||
{index > 0 && <span className="text-gray-400 dark:text-gray-500">/</span>}
|
{index > 0 && <span className="text-gray-600 dark:text-gray-600">/</span>}
|
||||||
<button
|
<button
|
||||||
onClick={() => handleBreadcrumbClick(index)}
|
onClick={() => handleBreadcrumbClick(index)}
|
||||||
className={`px-2 py-1 rounded transition-colors ${
|
className={`px-2 py-1 rounded transition-colors ${
|
||||||
@@ -309,14 +309,14 @@ const PostmanTable = ({ data, title = "JSON Data" }) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
<div className="text-sm text-gray-600 dark:text-gray-600">
|
||||||
{isArrayView && `${currentData.length} items`}
|
{isArrayView && `${currentData.length} items`}
|
||||||
{isObjectView && `${Object.keys(currentData).length} properties`}
|
{isObjectView && `${Object.keys(currentData).length} properties`}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Global HTML/Raw Toggle */}
|
{/* Global HTML/Raw Toggle */}
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<span className="text-xs text-gray-500 dark:text-gray-400">Text Display:</span>
|
<span className="text-xs text-gray-600 dark:text-gray-600">Text Display:</span>
|
||||||
<div className="flex rounded-md overflow-hidden border border-gray-300 dark:border-gray-600">
|
<div className="flex rounded-md overflow-hidden border border-gray-300 dark:border-gray-600">
|
||||||
<button
|
<button
|
||||||
onClick={() => setRenderHtml(true)}
|
onClick={() => setRenderHtml(true)}
|
||||||
@@ -353,11 +353,11 @@ const PostmanTable = ({ data, title = "JSON Data" }) => {
|
|||||||
<table className="w-full">
|
<table className="w-full">
|
||||||
<thead className="bg-gray-50 dark:bg-gray-700 sticky top-0 z-10">
|
<thead className="bg-gray-50 dark:bg-gray-700 sticky top-0 z-10">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider w-12">
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-600 dark:text-gray-300 uppercase tracking-wider w-12">
|
||||||
#
|
#
|
||||||
</th>
|
</th>
|
||||||
{headers.map(header => (
|
{headers.map(header => (
|
||||||
<th key={header} className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
<th key={header} className="px-4 py-3 text-left text-xs font-medium text-gray-600 dark:text-gray-300 uppercase tracking-wider">
|
||||||
{header}
|
{header}
|
||||||
</th>
|
</th>
|
||||||
))}
|
))}
|
||||||
@@ -372,7 +372,7 @@ const PostmanTable = ({ data, title = "JSON Data" }) => {
|
|||||||
onClick={() => handleRowClick(index)}
|
onClick={() => handleRowClick(index)}
|
||||||
className="hover:bg-blue-50 dark:hover:bg-blue-900/20 cursor-pointer transition-colors duration-150"
|
className="hover:bg-blue-50 dark:hover:bg-blue-900/20 cursor-pointer transition-colors duration-150"
|
||||||
>
|
>
|
||||||
<td className="px-4 py-3 text-sm font-mono text-gray-500 dark:text-gray-400">
|
<td className="px-4 py-3 text-sm font-mono text-gray-600 dark:text-gray-600">
|
||||||
{index}
|
{index}
|
||||||
</td>
|
</td>
|
||||||
{isPrimitiveArray ? (
|
{isPrimitiveArray ? (
|
||||||
@@ -396,13 +396,13 @@ const PostmanTable = ({ data, title = "JSON Data" }) => {
|
|||||||
<table className="w-full">
|
<table className="w-full">
|
||||||
<thead className="bg-gray-50 dark:bg-gray-700 sticky top-0 z-10">
|
<thead className="bg-gray-50 dark:bg-gray-700 sticky top-0 z-10">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider w-1/3">
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-600 dark:text-gray-300 uppercase tracking-wider w-1/3">
|
||||||
Key
|
Key
|
||||||
</th>
|
</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-600 dark:text-gray-300 uppercase tracking-wider">
|
||||||
Value
|
Value
|
||||||
</th>
|
</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider w-16">
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-600 dark:text-gray-300 uppercase tracking-wider w-16">
|
||||||
|
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -438,7 +438,7 @@ const PostmanTable = ({ data, title = "JSON Data" }) => {
|
|||||||
{copiedItems.has(`${currentPath.join('.')}.${key}`) ? (
|
{copiedItems.has(`${currentPath.join('.')}.${key}`) ? (
|
||||||
<Check className="h-3 w-3 text-green-500" />
|
<Check className="h-3 w-3 text-green-500" />
|
||||||
) : (
|
) : (
|
||||||
<Copy className="h-3 w-3 text-gray-500 dark:text-gray-400" />
|
<Copy className="h-3 w-3 text-gray-600 dark:text-gray-600" />
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
@@ -450,7 +450,7 @@ const PostmanTable = ({ data, title = "JSON Data" }) => {
|
|||||||
) : (
|
) : (
|
||||||
// Fallback for primitive values
|
// Fallback for primitive values
|
||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
<div className="text-center text-gray-500 dark:text-gray-400">
|
<div className="text-center text-gray-600 dark:text-gray-600">
|
||||||
<div className="text-lg font-mono text-gray-900 dark:text-gray-100 whitespace-pre-wrap break-words">
|
<div className="text-lg font-mono text-gray-900 dark:text-gray-100 whitespace-pre-wrap break-words">
|
||||||
{formatFullValue(currentData)}
|
{formatFullValue(currentData)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
12
src/components/PostmanTreeTable.js
Normal file → Executable file
@@ -142,7 +142,7 @@ const PostmanTreeTable = ({ data, title = "JSON Data" }) => {
|
|||||||
<div className="flex items-center space-x-3">
|
<div className="flex items-center space-x-3">
|
||||||
{/* Search */}
|
{/* Search */}
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4" />
|
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-600 w-4 h-4" />
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Search keys or values..."
|
placeholder="Search keys or values..."
|
||||||
@@ -154,7 +154,7 @@ const PostmanTreeTable = ({ data, title = "JSON Data" }) => {
|
|||||||
|
|
||||||
{/* Type Filter */}
|
{/* Type Filter */}
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Filter className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4" />
|
<Filter className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-600 w-4 h-4" />
|
||||||
<select
|
<select
|
||||||
value={filterType}
|
value={filterType}
|
||||||
onChange={(e) => setFilterType(e.target.value)}
|
onChange={(e) => setFilterType(e.target.value)}
|
||||||
@@ -178,16 +178,16 @@ const PostmanTreeTable = ({ data, title = "JSON Data" }) => {
|
|||||||
<table className="w-full">
|
<table className="w-full">
|
||||||
<thead className="bg-gray-50 sticky top-0">
|
<thead className="bg-gray-50 sticky top-0">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-600 uppercase tracking-wider">
|
||||||
Key
|
Key
|
||||||
</th>
|
</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-600 uppercase tracking-wider">
|
||||||
Type
|
Type
|
||||||
</th>
|
</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-600 uppercase tracking-wider">
|
||||||
Value
|
Value
|
||||||
</th>
|
</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider w-16">
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-600 uppercase tracking-wider w-16">
|
||||||
Actions
|
Actions
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
2
src/components/ProBadge.js
Normal file → Executable file
@@ -110,7 +110,7 @@ export const ProFeatureLock = ({
|
|||||||
</h4>
|
</h4>
|
||||||
<ProBadge size="sm" />
|
<ProBadge size="sm" />
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-3">
|
<p className="text-sm text-gray-600 dark:text-gray-600 mb-3">
|
||||||
{featureDescription}
|
{featureDescription}
|
||||||
</p>
|
</p>
|
||||||
<ProBadge variant="button" size="md" onClick={handleUpgrade} />
|
<ProBadge variant="button" size="md" onClick={handleUpgrade} />
|
||||||
|
|||||||
4
src/components/RelatedTools.js
Normal file → Executable file
@@ -80,11 +80,11 @@ const RelatedTools = ({ toolId }) => {
|
|||||||
<h4 className="font-medium text-gray-900 dark:text-white group-hover:text-blue-600 dark:group-hover:text-blue-400 transition-colors">
|
<h4 className="font-medium text-gray-900 dark:text-white group-hover:text-blue-600 dark:group-hover:text-blue-400 transition-colors">
|
||||||
{tool.name}
|
{tool.name}
|
||||||
</h4>
|
</h4>
|
||||||
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
<p className="text-sm text-gray-600 dark:text-gray-600 mt-1">
|
||||||
{tool.desc}
|
{tool.desc}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<ArrowRight className="h-5 w-5 text-gray-400 group-hover:text-blue-600 dark:group-hover:text-blue-400 group-hover:translate-x-1 transition-all flex-shrink-0 ml-2" />
|
<ArrowRight className="h-5 w-5 text-gray-600 group-hover:text-blue-600 dark:group-hover:text-blue-400 group-hover:translate-x-1 transition-all flex-shrink-0 ml-2" />
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
|
|||||||
289
src/components/RichMarkdownEditor.js
Executable file
@@ -0,0 +1,289 @@
|
|||||||
|
import React, { useEffect } from "react";
|
||||||
|
import { useEditor, EditorContent, ReactNodeViewRenderer } from "@tiptap/react";
|
||||||
|
import StarterKit from "@tiptap/starter-kit";
|
||||||
|
import Link from "@tiptap/extension-link";
|
||||||
|
import Image from "@tiptap/extension-image";
|
||||||
|
import { Table } from "@tiptap/extension-table";
|
||||||
|
import { TableRow } from "@tiptap/extension-table-row";
|
||||||
|
import { TableHeader } from "@tiptap/extension-table-header";
|
||||||
|
import { TableCell } from "@tiptap/extension-table-cell";
|
||||||
|
import { TaskList } from "@tiptap/extension-task-list";
|
||||||
|
import { TaskItem } from "@tiptap/extension-task-item";
|
||||||
|
import CodeBlockLowlight from "@tiptap/extension-code-block-lowlight";
|
||||||
|
import { common, createLowlight } from "lowlight";
|
||||||
|
import CodeBlockComponent from "./CodeBlockComponent";
|
||||||
|
import { Markdown } from "tiptap-markdown";
|
||||||
|
import {
|
||||||
|
Bold,
|
||||||
|
Italic,
|
||||||
|
Strikethrough,
|
||||||
|
Code,
|
||||||
|
Heading1,
|
||||||
|
Heading2,
|
||||||
|
Heading3,
|
||||||
|
List,
|
||||||
|
ListOrdered,
|
||||||
|
CheckSquare,
|
||||||
|
Quote,
|
||||||
|
Link2,
|
||||||
|
Image as ImageIcon,
|
||||||
|
Table as TableIcon,
|
||||||
|
Minus,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
// Set up lowlight for syntax highlighting in Tiptap
|
||||||
|
const lowlight = createLowlight(common);
|
||||||
|
|
||||||
|
const MenuBar = ({ editor }) => {
|
||||||
|
if (!editor) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap justify-center items-center gap-1 p-2 bg-gray-50 dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 sticky top-0 z-10 w-full">
|
||||||
|
<div className="flex flex-wrap items-center gap-1 w-full max-w-[85ch]">
|
||||||
|
<button
|
||||||
|
onClick={() => editor.chain().focus().toggleBold().run()}
|
||||||
|
disabled={!editor.can().chain().focus().toggleBold().run()}
|
||||||
|
className={`p-2 rounded hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors ${editor.isActive("bold") ? "bg-gray-200 dark:bg-gray-700 text-blue-600 dark:text-blue-400" : "text-gray-600 dark:text-gray-300"}`}
|
||||||
|
title="Bold"
|
||||||
|
>
|
||||||
|
<Bold className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => editor.chain().focus().toggleItalic().run()}
|
||||||
|
disabled={!editor.can().chain().focus().toggleItalic().run()}
|
||||||
|
className={`p-2 rounded hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors ${editor.isActive("italic") ? "bg-gray-200 dark:bg-gray-700 text-blue-600 dark:text-blue-400" : "text-gray-600 dark:text-gray-300"}`}
|
||||||
|
title="Italic"
|
||||||
|
>
|
||||||
|
<Italic className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => editor.chain().focus().toggleStrike().run()}
|
||||||
|
disabled={!editor.can().chain().focus().toggleStrike().run()}
|
||||||
|
className={`p-2 rounded hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors ${editor.isActive("strike") ? "bg-gray-200 dark:bg-gray-700 text-blue-600 dark:text-blue-400" : "text-gray-600 dark:text-gray-300"}`}
|
||||||
|
title="Strikethrough"
|
||||||
|
>
|
||||||
|
<Strikethrough className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => editor.chain().focus().toggleCode().run()}
|
||||||
|
disabled={!editor.can().chain().focus().toggleCode().run()}
|
||||||
|
className={`p-2 rounded hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors ${editor.isActive("code") ? "bg-gray-200 dark:bg-gray-700 text-blue-600 dark:text-blue-400" : "text-gray-600 dark:text-gray-300"}`}
|
||||||
|
title="Inline Code"
|
||||||
|
>
|
||||||
|
<Code className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="w-px h-6 bg-gray-300 dark:bg-gray-600 mx-1" />
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() =>
|
||||||
|
editor.chain().focus().toggleHeading({ level: 1 }).run()
|
||||||
|
}
|
||||||
|
className={`p-2 rounded hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors ${editor.isActive("heading", { level: 1 }) ? "bg-gray-200 dark:bg-gray-700 text-blue-600 dark:text-blue-400" : "text-gray-600 dark:text-gray-300"}`}
|
||||||
|
title="Heading 1"
|
||||||
|
>
|
||||||
|
<Heading1 className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() =>
|
||||||
|
editor.chain().focus().toggleHeading({ level: 2 }).run()
|
||||||
|
}
|
||||||
|
className={`p-2 rounded hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors ${editor.isActive("heading", { level: 2 }) ? "bg-gray-200 dark:bg-gray-700 text-blue-600 dark:text-blue-400" : "text-gray-600 dark:text-gray-300"}`}
|
||||||
|
title="Heading 2"
|
||||||
|
>
|
||||||
|
<Heading2 className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() =>
|
||||||
|
editor.chain().focus().toggleHeading({ level: 3 }).run()
|
||||||
|
}
|
||||||
|
className={`p-2 rounded hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors ${editor.isActive("heading", { level: 3 }) ? "bg-gray-200 dark:bg-gray-700 text-blue-600 dark:text-blue-400" : "text-gray-600 dark:text-gray-300"}`}
|
||||||
|
title="Heading 3"
|
||||||
|
>
|
||||||
|
<Heading3 className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="w-px h-6 bg-gray-300 dark:bg-gray-600 mx-1" />
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => editor.chain().focus().toggleBulletList().run()}
|
||||||
|
className={`p-2 rounded hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors ${editor.isActive("bulletList") ? "bg-gray-200 dark:bg-gray-700 text-blue-600 dark:text-blue-400" : "text-gray-600 dark:text-gray-300"}`}
|
||||||
|
title="Bullet List"
|
||||||
|
>
|
||||||
|
<List className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => editor.chain().focus().toggleOrderedList().run()}
|
||||||
|
className={`p-2 rounded hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors ${editor.isActive("orderedList") ? "bg-gray-200 dark:bg-gray-700 text-blue-600 dark:text-blue-400" : "text-gray-600 dark:text-gray-300"}`}
|
||||||
|
title="Ordered List"
|
||||||
|
>
|
||||||
|
<ListOrdered className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => editor.chain().focus().toggleTaskList().run()}
|
||||||
|
className={`p-2 rounded hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors ${editor.isActive("taskList") ? "bg-gray-200 dark:bg-gray-700 text-blue-600 dark:text-blue-400" : "text-gray-600 dark:text-gray-300"}`}
|
||||||
|
title="Task List"
|
||||||
|
>
|
||||||
|
<CheckSquare className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="w-px h-6 bg-gray-300 dark:bg-gray-600 mx-1" />
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => editor.chain().focus().toggleBlockquote().run()}
|
||||||
|
className={`p-2 rounded hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors ${editor.isActive("blockquote") ? "bg-gray-200 dark:bg-gray-700 text-blue-600 dark:text-blue-400" : "text-gray-600 dark:text-gray-300"}`}
|
||||||
|
title="Blockquote"
|
||||||
|
>
|
||||||
|
<Quote className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => editor.chain().focus().toggleCodeBlock().run()}
|
||||||
|
className={`p-2 rounded hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors ${editor.isActive("codeBlock") ? "bg-gray-200 dark:bg-gray-700 text-blue-600 dark:text-blue-400" : "text-gray-600 dark:text-gray-300"}`}
|
||||||
|
title="Code Block"
|
||||||
|
>
|
||||||
|
<Code className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => editor.chain().focus().setHorizontalRule().run()}
|
||||||
|
className="p-2 rounded hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors text-gray-600 dark:text-gray-300"
|
||||||
|
title="Horizontal Rule"
|
||||||
|
>
|
||||||
|
<Minus className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="w-px h-6 bg-gray-300 dark:bg-gray-600 mx-1" />
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
const url = window.prompt("URL");
|
||||||
|
if (url) {
|
||||||
|
editor
|
||||||
|
.chain()
|
||||||
|
.focus()
|
||||||
|
.extendMarkRange("link")
|
||||||
|
.setLink({ href: url })
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className={`p-2 rounded hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors ${editor.isActive("link") ? "bg-gray-200 dark:bg-gray-700 text-blue-600 dark:text-blue-400" : "text-gray-600 dark:text-gray-300"}`}
|
||||||
|
title="Add Link"
|
||||||
|
>
|
||||||
|
<Link2 className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
const url = window.prompt("Image URL");
|
||||||
|
if (url) {
|
||||||
|
editor.chain().focus().setImage({ src: url }).run();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="p-2 rounded hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors text-gray-600 dark:text-gray-300"
|
||||||
|
title="Add Image"
|
||||||
|
>
|
||||||
|
<ImageIcon className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() =>
|
||||||
|
editor
|
||||||
|
.chain()
|
||||||
|
.focus()
|
||||||
|
.insertTable({ rows: 3, cols: 3, withHeaderRow: true })
|
||||||
|
.run()
|
||||||
|
}
|
||||||
|
className="p-2 rounded hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors text-gray-600 dark:text-gray-300"
|
||||||
|
title="Insert Table"
|
||||||
|
>
|
||||||
|
<TableIcon className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const RichMarkdownEditor = ({
|
||||||
|
initialContent,
|
||||||
|
onChange,
|
||||||
|
className = "",
|
||||||
|
height = "600px",
|
||||||
|
isFullscreen = false,
|
||||||
|
}) => {
|
||||||
|
const editor = useEditor({
|
||||||
|
extensions: [
|
||||||
|
StarterKit.configure({
|
||||||
|
codeBlock: false, // We'll use our own codeblock extension
|
||||||
|
}),
|
||||||
|
CodeBlockLowlight.extend({
|
||||||
|
addNodeView() {
|
||||||
|
return ReactNodeViewRenderer(CodeBlockComponent);
|
||||||
|
},
|
||||||
|
}).configure({
|
||||||
|
lowlight,
|
||||||
|
}),
|
||||||
|
Link.configure({
|
||||||
|
openOnClick: false,
|
||||||
|
}),
|
||||||
|
Image,
|
||||||
|
Table.configure({
|
||||||
|
resizable: true,
|
||||||
|
}),
|
||||||
|
TableRow,
|
||||||
|
TableHeader,
|
||||||
|
TableCell,
|
||||||
|
TaskList,
|
||||||
|
TaskItem.configure({
|
||||||
|
nested: true,
|
||||||
|
}),
|
||||||
|
Markdown.configure({
|
||||||
|
html: true,
|
||||||
|
tightLists: true,
|
||||||
|
tightListClass: "tight",
|
||||||
|
bulletListMarker: "-",
|
||||||
|
linkify: true,
|
||||||
|
breaks: false,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
content: initialContent,
|
||||||
|
onUpdate: ({ editor }) => {
|
||||||
|
// Serialize back to markdown and send to parent
|
||||||
|
const markdownOutput = editor.storage.markdown.getMarkdown();
|
||||||
|
const htmlOutput = editor.getHTML();
|
||||||
|
onChange(markdownOutput, htmlOutput);
|
||||||
|
},
|
||||||
|
editorProps: {
|
||||||
|
attributes: {
|
||||||
|
class:
|
||||||
|
"prose prose-sm sm:prose dark:prose-invert prose-blue focus:outline-none w-full max-w-none",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update editor content when initialContent prop completely changes from outside (e.g. loading a template)
|
||||||
|
useEffect(() => {
|
||||||
|
if (editor && initialContent !== undefined) {
|
||||||
|
const currentMarkdown = editor.storage.markdown.getMarkdown();
|
||||||
|
if (initialContent !== currentMarkdown) {
|
||||||
|
editor.commands.setContent(initialContent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [editor, initialContent]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`flex flex-col bg-white dark:bg-gray-900 overflow-hidden ${className}`}
|
||||||
|
>
|
||||||
|
<MenuBar editor={editor} />
|
||||||
|
<div
|
||||||
|
className={`overflow-y-auto w-full custom-scrollbar flex justify-center p-6`}
|
||||||
|
style={{ height }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`w-full max-w-[85ch] markdown-content-wrapper ${isFullscreen ? "is-fullscreen" : "is-normal"} is-edit-mode`}
|
||||||
|
>
|
||||||
|
<EditorContent editor={editor} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default RichMarkdownEditor;
|
||||||
0
src/components/SEO.js
Normal file → Executable file
0
src/components/SEOHead.js
Normal file → Executable file
551
src/components/StructuredEditor.js
Normal file → Executable file
@@ -1,19 +1,45 @@
|
|||||||
import React, { useState, useEffect, useRef } from 'react';
|
import React, { useState, useEffect, useRef } from "react";
|
||||||
import { Plus, Minus, ChevronDown, ChevronRight, Type, Hash, ToggleLeft, List, Braces, Edit3, X, Eye, Pencil } from 'lucide-react';
|
import {
|
||||||
|
Plus,
|
||||||
|
Minus,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronRight,
|
||||||
|
ChevronsUpDown,
|
||||||
|
ChevronsDownUp,
|
||||||
|
Type,
|
||||||
|
Hash,
|
||||||
|
ToggleLeft,
|
||||||
|
List,
|
||||||
|
Braces,
|
||||||
|
Edit3,
|
||||||
|
X,
|
||||||
|
Eye,
|
||||||
|
Pencil,
|
||||||
|
Search,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
const StructuredEditor = ({ onDataChange, initialData = {}, readOnly: readOnlyProp = false }) => {
|
const StructuredEditor = ({
|
||||||
|
onDataChange,
|
||||||
|
initialData = {},
|
||||||
|
readOnly: readOnlyProp = false,
|
||||||
|
}) => {
|
||||||
const [data, setData] = useState(initialData);
|
const [data, setData] = useState(initialData);
|
||||||
const [expandedNodes, setExpandedNodes] = useState(new Set(['root']));
|
const [expandedNodes, setExpandedNodes] = useState(new Set(["root"]));
|
||||||
const [fieldTypes, setFieldTypes] = useState({}); // Track intended types for fields
|
const [fieldTypes, setFieldTypes] = useState({}); // Track intended types for fields
|
||||||
const isInternalUpdate = useRef(false);
|
const isInternalUpdate = useRef(false);
|
||||||
const [nestedEditModal, setNestedEditModal] = useState(null); // { path, value, type: 'json' | 'serialized' }
|
const [nestedEditModal, setNestedEditModal] = useState(null); // { path, value, type: 'json' | 'serialized' }
|
||||||
const [nestedData, setNestedData] = useState(null);
|
const [nestedData, setNestedData] = useState(null);
|
||||||
// Start in edit mode if readOnly is false
|
// Start in preview mode if readOnly is false
|
||||||
const [editMode, setEditMode] = useState(readOnlyProp === false);
|
const [editMode, setEditMode] = useState(
|
||||||
|
readOnlyProp === false ? false : !readOnlyProp,
|
||||||
|
);
|
||||||
|
|
||||||
// Use internal editMode if readOnlyProp is not explicitly set, otherwise use prop
|
// Use internal editMode if readOnlyProp is not explicitly set, otherwise use prop
|
||||||
const readOnly = readOnlyProp !== false ? readOnlyProp : !editMode;
|
const readOnly = readOnlyProp !== false ? readOnlyProp : !editMode;
|
||||||
|
|
||||||
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
const [searchResults, setSearchResults] = useState(new Set());
|
||||||
|
|
||||||
// Update internal data when initialData prop changes (but not from internal updates)
|
// Update internal data when initialData prop changes (but not from internal updates)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Skip update if this change came from internal editor actions
|
// Skip update if this change came from internal editor actions
|
||||||
@@ -25,7 +51,7 @@ const StructuredEditor = ({ onDataChange, initialData = {}, readOnly: readOnlyPr
|
|||||||
setData(initialData);
|
setData(initialData);
|
||||||
// Expand root node if there's data
|
// Expand root node if there's data
|
||||||
if (Object.keys(initialData).length > 0) {
|
if (Object.keys(initialData).length > 0) {
|
||||||
setExpandedNodes(new Set(['root']));
|
setExpandedNodes(new Set(["root"]));
|
||||||
}
|
}
|
||||||
}, [initialData]);
|
}, [initialData]);
|
||||||
|
|
||||||
@@ -37,13 +63,13 @@ const StructuredEditor = ({ onDataChange, initialData = {}, readOnly: readOnlyPr
|
|||||||
|
|
||||||
// PHP serialize/unserialize functions
|
// PHP serialize/unserialize functions
|
||||||
const phpSerialize = (data) => {
|
const phpSerialize = (data) => {
|
||||||
if (data === null) return 'N;';
|
if (data === null) return "N;";
|
||||||
if (typeof data === 'boolean') return data ? 'b:1;' : 'b:0;';
|
if (typeof data === "boolean") return data ? "b:1;" : "b:0;";
|
||||||
if (typeof data === 'number') {
|
if (typeof data === "number") {
|
||||||
return Number.isInteger(data) ? `i:${data};` : `d:${data};`;
|
return Number.isInteger(data) ? `i:${data};` : `d:${data};`;
|
||||||
}
|
}
|
||||||
if (typeof data === 'string') {
|
if (typeof data === "string") {
|
||||||
const escapedData = data.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
const escapedData = data.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
||||||
const byteLength = new TextEncoder().encode(escapedData).length;
|
const byteLength = new TextEncoder().encode(escapedData).length;
|
||||||
return `s:${byteLength}:"${escapedData}";`;
|
return `s:${byteLength}:"${escapedData}";`;
|
||||||
}
|
}
|
||||||
@@ -52,72 +78,78 @@ const StructuredEditor = ({ onDataChange, initialData = {}, readOnly: readOnlyPr
|
|||||||
data.forEach((item, index) => {
|
data.forEach((item, index) => {
|
||||||
result += phpSerialize(index) + phpSerialize(item);
|
result += phpSerialize(index) + phpSerialize(item);
|
||||||
});
|
});
|
||||||
result += '}';
|
result += "}";
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
if (typeof data === 'object') {
|
if (typeof data === "object") {
|
||||||
const keys = Object.keys(data);
|
const keys = Object.keys(data);
|
||||||
let result = `a:${keys.length}:{`;
|
let result = `a:${keys.length}:{`;
|
||||||
keys.forEach(key => {
|
keys.forEach((key) => {
|
||||||
result += phpSerialize(key) + phpSerialize(data[key]);
|
result += phpSerialize(key) + phpSerialize(data[key]);
|
||||||
});
|
});
|
||||||
result += '}';
|
result += "}";
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
return 'N;';
|
return "N;";
|
||||||
};
|
};
|
||||||
|
|
||||||
const phpUnserialize = (str) => {
|
const phpUnserialize = (str) => {
|
||||||
let index = 0;
|
let index = 0;
|
||||||
const parseValue = () => {
|
const parseValue = () => {
|
||||||
if (index >= str.length) throw new Error('Unexpected end of string');
|
if (index >= str.length) throw new Error("Unexpected end of string");
|
||||||
const type = str[index];
|
const type = str[index];
|
||||||
if (type === 'N') {
|
if (type === "N") {
|
||||||
index += 2;
|
index += 2;
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
if (str[index + 1] !== ':') throw new Error(`Expected ':' after type '${type}'`);
|
if (str[index + 1] !== ":")
|
||||||
|
throw new Error(`Expected ':' after type '${type}'`);
|
||||||
index += 2;
|
index += 2;
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case 'b':
|
case "b":
|
||||||
const boolVal = str[index] === '1';
|
const boolVal = str[index] === "1";
|
||||||
index += 2;
|
index += 2;
|
||||||
return boolVal;
|
return boolVal;
|
||||||
case 'i':
|
case "i":
|
||||||
let intStr = '';
|
let intStr = "";
|
||||||
while (index < str.length && str[index] !== ';') intStr += str[index++];
|
while (index < str.length && str[index] !== ";")
|
||||||
|
intStr += str[index++];
|
||||||
index++;
|
index++;
|
||||||
return parseInt(intStr);
|
return parseInt(intStr);
|
||||||
case 'd':
|
case "d":
|
||||||
let floatStr = '';
|
let floatStr = "";
|
||||||
while (index < str.length && str[index] !== ';') floatStr += str[index++];
|
while (index < str.length && str[index] !== ";")
|
||||||
|
floatStr += str[index++];
|
||||||
index++;
|
index++;
|
||||||
return parseFloat(floatStr);
|
return parseFloat(floatStr);
|
||||||
case 's':
|
case "s":
|
||||||
let lenStr = '';
|
let lenStr = "";
|
||||||
while (index < str.length && str[index] !== ':') lenStr += str[index++];
|
while (index < str.length && str[index] !== ":")
|
||||||
|
lenStr += str[index++];
|
||||||
index++;
|
index++;
|
||||||
if (str[index] !== '"') throw new Error('Expected opening quote');
|
if (str[index] !== '"') throw new Error("Expected opening quote");
|
||||||
index++;
|
index++;
|
||||||
const byteLength = parseInt(lenStr);
|
const byteLength = parseInt(lenStr);
|
||||||
if (byteLength === 0) {
|
if (byteLength === 0) {
|
||||||
index += 2;
|
index += 2;
|
||||||
return '';
|
return "";
|
||||||
}
|
}
|
||||||
let endQuotePos = -1;
|
let endQuotePos = -1;
|
||||||
for (let i = index; i < str.length - 1; i++) {
|
for (let i = index; i < str.length - 1; i++) {
|
||||||
if (str[i] === '"' && str[i + 1] === ';') {
|
if (str[i] === '"' && str[i + 1] === ";") {
|
||||||
endQuotePos = i;
|
endQuotePos = i;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (endQuotePos === -1) throw new Error('Could not find closing quote');
|
if (endQuotePos === -1)
|
||||||
|
throw new Error("Could not find closing quote");
|
||||||
const strValue = str.substring(index, endQuotePos);
|
const strValue = str.substring(index, endQuotePos);
|
||||||
index = endQuotePos + 2;
|
index = endQuotePos + 2;
|
||||||
return strValue.replace(/\\"/g, '"').replace(/\\\\/g, '\\');
|
return strValue.replace(/\\"/g, '"').replace(/\\\\/g, "\\");
|
||||||
case 'a':
|
case "a":
|
||||||
let countStr = '';
|
let countStr = "";
|
||||||
while (index < str.length && str[index] !== ':') countStr += str[index++];
|
while (index < str.length && str[index] !== ":")
|
||||||
|
countStr += str[index++];
|
||||||
const count = parseInt(countStr);
|
const count = parseInt(countStr);
|
||||||
index += 2;
|
index += 2;
|
||||||
const result = {};
|
const result = {};
|
||||||
@@ -139,13 +171,13 @@ const StructuredEditor = ({ onDataChange, initialData = {}, readOnly: readOnlyPr
|
|||||||
|
|
||||||
// Detect if a string contains JSON or serialized data
|
// Detect if a string contains JSON or serialized data
|
||||||
const detectNestedData = (value) => {
|
const detectNestedData = (value) => {
|
||||||
if (typeof value !== 'string' || value.length < 5) return null;
|
if (typeof value !== "string" || value.length < 5) return null;
|
||||||
|
|
||||||
// Try JSON first
|
// Try JSON first
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(value);
|
const parsed = JSON.parse(value);
|
||||||
if (typeof parsed === 'object' && parsed !== null) {
|
if (typeof parsed === "object" && parsed !== null) {
|
||||||
return { type: 'json', data: parsed };
|
return { type: "json", data: parsed };
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Not JSON, continue
|
// Not JSON, continue
|
||||||
@@ -156,8 +188,8 @@ const StructuredEditor = ({ onDataChange, initialData = {}, readOnly: readOnlyPr
|
|||||||
// Check if it looks like PHP serialized format
|
// Check if it looks like PHP serialized format
|
||||||
if (/^[abidsNO]:[^;]*;/.test(value) || /^a:\d+:\{/.test(value)) {
|
if (/^[abidsNO]:[^;]*;/.test(value) || /^a:\d+:\{/.test(value)) {
|
||||||
const parsed = phpUnserialize(value);
|
const parsed = phpUnserialize(value);
|
||||||
if (typeof parsed === 'object' && parsed !== null) {
|
if (typeof parsed === "object" && parsed !== null) {
|
||||||
return { type: 'serialized', data: parsed };
|
return { type: "serialized", data: parsed };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -182,9 +214,9 @@ const StructuredEditor = ({ onDataChange, initialData = {}, readOnly: readOnlyPr
|
|||||||
|
|
||||||
// Convert back to string based on type
|
// Convert back to string based on type
|
||||||
let stringValue;
|
let stringValue;
|
||||||
if (nestedEditModal.type === 'json') {
|
if (nestedEditModal.type === "json") {
|
||||||
stringValue = JSON.stringify(nestedData);
|
stringValue = JSON.stringify(nestedData);
|
||||||
} else if (nestedEditModal.type === 'serialized') {
|
} else if (nestedEditModal.type === "serialized") {
|
||||||
stringValue = phpSerialize(nestedData);
|
stringValue = phpSerialize(nestedData);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -212,8 +244,112 @@ const StructuredEditor = ({ onDataChange, initialData = {}, readOnly: readOnlyPr
|
|||||||
setExpandedNodes(newExpanded);
|
setExpandedNodes(newExpanded);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const expandAll = () => {
|
||||||
|
const allPaths = new Set(["root"]);
|
||||||
|
|
||||||
|
// Helper to traverse and collect all paths
|
||||||
|
const traverse = (obj, currentPath) => {
|
||||||
|
if (typeof obj === "object" && obj !== null) {
|
||||||
|
if (Array.isArray(obj)) {
|
||||||
|
obj.forEach((item, index) => {
|
||||||
|
const path = `${currentPath}.${index}`;
|
||||||
|
if (typeof item === "object" && item !== null) {
|
||||||
|
allPaths.add(path);
|
||||||
|
traverse(item, path);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
Object.entries(obj).forEach(([key, value]) => {
|
||||||
|
const path = `${currentPath}.${key}`;
|
||||||
|
if (typeof value === "object" && value !== null) {
|
||||||
|
allPaths.add(path);
|
||||||
|
traverse(value, path);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
traverse(data, "root");
|
||||||
|
setExpandedNodes(allPaths);
|
||||||
|
};
|
||||||
|
|
||||||
|
const collapseAll = () => {
|
||||||
|
setExpandedNodes(new Set(["root"]));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Search effect to auto-expand paths containing matches
|
||||||
|
useEffect(() => {
|
||||||
|
if (!searchQuery.trim()) {
|
||||||
|
setSearchResults(new Set());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const query = searchQuery.toLowerCase();
|
||||||
|
const results = new Set();
|
||||||
|
const pathsToExpand = new Set(["root"]);
|
||||||
|
|
||||||
|
// Returns true if a match is found in this node or its descendants
|
||||||
|
const searchTraverse = (obj, currentPath) => {
|
||||||
|
let foundInCurrent = false;
|
||||||
|
|
||||||
|
if (typeof obj === "object" && obj !== null) {
|
||||||
|
if (Array.isArray(obj)) {
|
||||||
|
obj.forEach((item, index) => {
|
||||||
|
const path = `${currentPath}.${index}`;
|
||||||
|
const keyMatches = index.toString().includes(query);
|
||||||
|
|
||||||
|
let foundInChild = false;
|
||||||
|
if (typeof item === "object" && item !== null) {
|
||||||
|
foundInChild = searchTraverse(item, path);
|
||||||
|
} else {
|
||||||
|
const valueStr = getDisplayValue(item).toLowerCase();
|
||||||
|
if (valueStr.includes(query)) foundInChild = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (keyMatches || foundInChild) {
|
||||||
|
results.add(path);
|
||||||
|
pathsToExpand.add(currentPath);
|
||||||
|
pathsToExpand.add(path);
|
||||||
|
foundInCurrent = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
Object.entries(obj).forEach(([key, value]) => {
|
||||||
|
const path = `${currentPath}.${key}`;
|
||||||
|
const keyMatches = key.toLowerCase().includes(query);
|
||||||
|
|
||||||
|
let foundInChild = false;
|
||||||
|
if (typeof value === "object" && value !== null) {
|
||||||
|
foundInChild = searchTraverse(value, path);
|
||||||
|
} else {
|
||||||
|
const valueStr = getDisplayValue(value).toLowerCase();
|
||||||
|
if (valueStr.includes(query)) foundInChild = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (keyMatches || foundInChild) {
|
||||||
|
results.add(path);
|
||||||
|
pathsToExpand.add(currentPath);
|
||||||
|
pathsToExpand.add(path);
|
||||||
|
foundInCurrent = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return foundInCurrent;
|
||||||
|
};
|
||||||
|
|
||||||
|
searchTraverse(data, "root");
|
||||||
|
setSearchResults(results);
|
||||||
|
|
||||||
|
// Merge expanded nodes with paths that need to be expanded for search
|
||||||
|
if (results.size > 0) {
|
||||||
|
setExpandedNodes((prev) => new Set([...prev, ...pathsToExpand]));
|
||||||
|
}
|
||||||
|
}, [searchQuery, data]);
|
||||||
|
|
||||||
const addProperty = (obj, path) => {
|
const addProperty = (obj, path) => {
|
||||||
const pathParts = path.split('.');
|
const pathParts = path.split(".");
|
||||||
const newData = { ...data };
|
const newData = { ...data };
|
||||||
let current = newData;
|
let current = newData;
|
||||||
|
|
||||||
@@ -225,15 +361,15 @@ const StructuredEditor = ({ onDataChange, initialData = {}, readOnly: readOnlyPr
|
|||||||
// Add new property to the target object
|
// Add new property to the target object
|
||||||
const keys = Object.keys(current);
|
const keys = Object.keys(current);
|
||||||
const newKey = `property${keys.length + 1}`;
|
const newKey = `property${keys.length + 1}`;
|
||||||
current[newKey] = '';
|
current[newKey] = "";
|
||||||
|
|
||||||
updateData(newData);
|
updateData(newData);
|
||||||
setExpandedNodes(new Set([...expandedNodes, path]));
|
setExpandedNodes(new Set([...expandedNodes, path]));
|
||||||
};
|
};
|
||||||
|
|
||||||
const addArrayItem = (arr, path) => {
|
const addArrayItem = (arr, path) => {
|
||||||
const newArr = [...arr, ''];
|
const newArr = [...arr, ""];
|
||||||
const pathParts = path.split('.');
|
const pathParts = path.split(".");
|
||||||
const newData = { ...data };
|
const newData = { ...data };
|
||||||
let current = newData;
|
let current = newData;
|
||||||
|
|
||||||
@@ -251,7 +387,7 @@ const StructuredEditor = ({ onDataChange, initialData = {}, readOnly: readOnlyPr
|
|||||||
};
|
};
|
||||||
|
|
||||||
const removeProperty = (key, parentPath) => {
|
const removeProperty = (key, parentPath) => {
|
||||||
const pathParts = parentPath.split('.');
|
const pathParts = parentPath.split(".");
|
||||||
const newData = { ...data };
|
const newData = { ...data };
|
||||||
let current = newData;
|
let current = newData;
|
||||||
|
|
||||||
@@ -261,7 +397,8 @@ const StructuredEditor = ({ onDataChange, initialData = {}, readOnly: readOnlyPr
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Remove field type tracking for the removed property
|
// Remove field type tracking for the removed property
|
||||||
const removedPath = parentPath === 'root' ? `root.${key}` : `${parentPath}.${key}`;
|
const removedPath =
|
||||||
|
parentPath === "root" ? `root.${key}` : `${parentPath}.${key}`;
|
||||||
const newFieldTypes = { ...fieldTypes };
|
const newFieldTypes = { ...fieldTypes };
|
||||||
delete newFieldTypes[removedPath];
|
delete newFieldTypes[removedPath];
|
||||||
setFieldTypes(newFieldTypes);
|
setFieldTypes(newFieldTypes);
|
||||||
@@ -276,16 +413,16 @@ const StructuredEditor = ({ onDataChange, initialData = {}, readOnly: readOnlyPr
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check if we're removing from root level and it's the last property
|
// Check if we're removing from root level and it's the last property
|
||||||
if (parentPath === 'root' && Object.keys(newData).length === 0) {
|
if (parentPath === "root" && Object.keys(newData).length === 0) {
|
||||||
// Add an empty property to maintain initial state, like TableEditor maintains at least one row
|
// Add an empty property to maintain initial state, like TableEditor maintains at least one row
|
||||||
newData[''] = '';
|
newData[""] = "";
|
||||||
}
|
}
|
||||||
|
|
||||||
updateData(newData);
|
updateData(newData);
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateValue = (value, path) => {
|
const updateValue = (value, path) => {
|
||||||
const pathParts = path.split('.');
|
const pathParts = path.split(".");
|
||||||
const newData = { ...data };
|
const newData = { ...data };
|
||||||
let current = newData;
|
let current = newData;
|
||||||
|
|
||||||
@@ -298,29 +435,30 @@ const StructuredEditor = ({ onDataChange, initialData = {}, readOnly: readOnlyPr
|
|||||||
const currentType = typeof currentValue;
|
const currentType = typeof currentValue;
|
||||||
|
|
||||||
// Preserve the current type when updating value
|
// Preserve the current type when updating value
|
||||||
if (currentType === 'boolean') {
|
if (currentType === "boolean") {
|
||||||
current[key] = value === 'true';
|
current[key] = value === "true";
|
||||||
} else if (currentType === 'number') {
|
} else if (currentType === "number") {
|
||||||
const numValue = Number(value);
|
const numValue = Number(value);
|
||||||
current[key] = isNaN(numValue) ? 0 : numValue;
|
current[key] = isNaN(numValue) ? 0 : numValue;
|
||||||
} else if (currentValue === null) {
|
} else if (currentValue === null) {
|
||||||
current[key] = value === 'null' ? null : value;
|
current[key] = value === "null" ? null : value;
|
||||||
} else {
|
} else {
|
||||||
// For strings and initial empty values, use smart detection
|
// For strings and initial empty values, use smart detection
|
||||||
if (currentValue === '' || currentValue === undefined) {
|
if (currentValue === "" || currentValue === undefined) {
|
||||||
// Check if this is a newly added property (starts with "property" + number)
|
// Check if this is a newly added property (starts with "property" + number)
|
||||||
const isNewProperty = typeof key === 'string' && key.match(/^property\d+$/);
|
const isNewProperty =
|
||||||
|
typeof key === "string" && key.match(/^property\d+$/);
|
||||||
|
|
||||||
if (isNewProperty) {
|
if (isNewProperty) {
|
||||||
// New properties added by user are always strings (no auto-detection)
|
// New properties added by user are always strings (no auto-detection)
|
||||||
current[key] = value;
|
current[key] = value;
|
||||||
} else {
|
} else {
|
||||||
// Existing properties from loaded data - use auto-detection
|
// Existing properties from loaded data - use auto-detection
|
||||||
if (value === 'true' || value === 'false') {
|
if (value === "true" || value === "false") {
|
||||||
current[key] = value === 'true';
|
current[key] = value === "true";
|
||||||
} else if (value === 'null') {
|
} else if (value === "null") {
|
||||||
current[key] = null;
|
current[key] = null;
|
||||||
} else if (!isNaN(value) && value !== '' && value.trim() !== '') {
|
} else if (!isNaN(value) && value !== "" && value.trim() !== "") {
|
||||||
current[key] = Number(value);
|
current[key] = Number(value);
|
||||||
} else {
|
} else {
|
||||||
current[key] = value;
|
current[key] = value;
|
||||||
@@ -336,7 +474,7 @@ const StructuredEditor = ({ onDataChange, initialData = {}, readOnly: readOnlyPr
|
|||||||
};
|
};
|
||||||
|
|
||||||
const changeType = (newType, path) => {
|
const changeType = (newType, path) => {
|
||||||
const pathParts = path.split('.');
|
const pathParts = path.split(".");
|
||||||
const newData = { ...data };
|
const newData = { ...data };
|
||||||
let current = newData;
|
let current = newData;
|
||||||
|
|
||||||
@@ -354,69 +492,98 @@ const StructuredEditor = ({ onDataChange, initialData = {}, readOnly: readOnlyPr
|
|||||||
|
|
||||||
// Try to preserve value when changing types if possible
|
// Try to preserve value when changing types if possible
|
||||||
switch (newType) {
|
switch (newType) {
|
||||||
case 'string':
|
case "string":
|
||||||
case 'longtext':
|
case "longtext":
|
||||||
current[key] = currentValue === null ? '' : currentValue.toString();
|
current[key] = currentValue === null ? "" : currentValue.toString();
|
||||||
break;
|
break;
|
||||||
case 'number':
|
case "number":
|
||||||
if (typeof currentValue === 'string' && !isNaN(currentValue) && currentValue.trim() !== '') {
|
if (
|
||||||
|
typeof currentValue === "string" &&
|
||||||
|
!isNaN(currentValue) &&
|
||||||
|
currentValue.trim() !== ""
|
||||||
|
) {
|
||||||
current[key] = Number(currentValue);
|
current[key] = Number(currentValue);
|
||||||
} else if (typeof currentValue === 'boolean') {
|
} else if (typeof currentValue === "boolean") {
|
||||||
current[key] = currentValue ? 1 : 0;
|
current[key] = currentValue ? 1 : 0;
|
||||||
} else {
|
} else {
|
||||||
current[key] = 0;
|
current[key] = 0;
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case 'boolean':
|
case "boolean":
|
||||||
if (typeof currentValue === 'string') {
|
if (typeof currentValue === "string") {
|
||||||
current[key] = currentValue.toLowerCase() === 'true';
|
current[key] = currentValue.toLowerCase() === "true";
|
||||||
} else if (typeof currentValue === 'number') {
|
} else if (typeof currentValue === "number") {
|
||||||
current[key] = currentValue !== 0;
|
current[key] = currentValue !== 0;
|
||||||
} else {
|
} else {
|
||||||
current[key] = false;
|
current[key] = false;
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case 'array':
|
case "array":
|
||||||
current[key] = [];
|
current[key] = [];
|
||||||
break;
|
break;
|
||||||
case 'object':
|
case "object":
|
||||||
current[key] = {};
|
current[key] = {};
|
||||||
break;
|
break;
|
||||||
case 'null':
|
case "null":
|
||||||
current[key] = null;
|
current[key] = null;
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
current[key] = '';
|
current[key] = "";
|
||||||
}
|
}
|
||||||
|
|
||||||
updateData(newData);
|
updateData(newData);
|
||||||
setExpandedNodes(new Set([...expandedNodes, path]));
|
setExpandedNodes(new Set([...expandedNodes, path]));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
// Helper function to display string values with proper unescaping
|
// Helper function to display string values with proper unescaping
|
||||||
const getDisplayValue = (value) => {
|
const getDisplayValue = (value) => {
|
||||||
if (value === null) return 'null';
|
if (value === null) return "null";
|
||||||
if (value === undefined) return '';
|
if (value === undefined) return "";
|
||||||
|
|
||||||
const stringValue = value.toString();
|
const stringValue = value.toString();
|
||||||
|
|
||||||
// If it's a string, unescape common JSON escape sequences for display
|
// If it's a string, unescape common JSON escape sequences for display
|
||||||
if (typeof value === 'string') {
|
if (typeof value === "string") {
|
||||||
return stringValue
|
return stringValue
|
||||||
.replace(/\\"/g, '"') // Unescape quotes
|
.replace(/\\"/g, '"') // Unescape quotes
|
||||||
.replace(/\\'/g, "'") // Unescape single quotes
|
.replace(/\\'/g, "'") // Unescape single quotes
|
||||||
.replace(/\\\//g, '/') // Unescape forward slashes
|
.replace(/\\\//g, "/") // Unescape forward slashes
|
||||||
.replace(/\\\\/g, '\\'); // Unescape backslashes (do this last)
|
.replace(/\\\\/g, "\\"); // Unescape backslashes (do this last)
|
||||||
}
|
}
|
||||||
|
|
||||||
return stringValue;
|
return stringValue;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Helper function to render text with search highlighting
|
||||||
|
const renderHighlightedText = (text) => {
|
||||||
|
if (!searchQuery.trim() || typeof text !== "string") return text;
|
||||||
|
|
||||||
|
const query = searchQuery.toLowerCase();
|
||||||
|
const textLower = text.toLowerCase();
|
||||||
|
const index = textLower.indexOf(query);
|
||||||
|
|
||||||
|
if (index === -1) return text;
|
||||||
|
|
||||||
|
const before = text.substring(0, index);
|
||||||
|
const match = text.substring(index, index + query.length);
|
||||||
|
const after = text.substring(index + query.length);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{before}
|
||||||
|
<span className="bg-yellow-200 dark:bg-yellow-800 text-black dark:text-white rounded-sm">
|
||||||
|
{match}
|
||||||
|
</span>
|
||||||
|
{renderHighlightedText(after)}{" "}
|
||||||
|
{/* Handle multiple matches in same string if needed */}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const renameKey = (oldKey, newKey, path) => {
|
const renameKey = (oldKey, newKey, path) => {
|
||||||
if (oldKey === newKey || !newKey.trim()) return;
|
if (oldKey === newKey || !newKey.trim()) return;
|
||||||
|
|
||||||
const pathParts = path.split('.');
|
const pathParts = path.split(".");
|
||||||
const newData = { ...data };
|
const newData = { ...data };
|
||||||
let current = newData;
|
let current = newData;
|
||||||
|
|
||||||
@@ -465,7 +632,7 @@ const StructuredEditor = ({ onDataChange, initialData = {}, readOnly: readOnlyPr
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof value === 'string') {
|
if (typeof value === "string") {
|
||||||
return (
|
return (
|
||||||
<span className="inline-flex items-center justify-center w-6 h-6 rounded-full bg-purple-100 text-purple-800 dark:bg-purple-900/20 dark:text-purple-300">
|
<span className="inline-flex items-center justify-center w-6 h-6 rounded-full bg-purple-100 text-purple-800 dark:bg-purple-900/20 dark:text-purple-300">
|
||||||
<Type className="h-3 w-3" />
|
<Type className="h-3 w-3" />
|
||||||
@@ -473,7 +640,7 @@ const StructuredEditor = ({ onDataChange, initialData = {}, readOnly: readOnlyPr
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof value === 'number') {
|
if (typeof value === "number") {
|
||||||
return (
|
return (
|
||||||
<span className="inline-flex items-center justify-center w-6 h-6 rounded-full bg-orange-100 text-orange-800 dark:bg-orange-900/20 dark:text-orange-300">
|
<span className="inline-flex items-center justify-center w-6 h-6 rounded-full bg-orange-100 text-orange-800 dark:bg-orange-900/20 dark:text-orange-300">
|
||||||
<Hash className="h-3 w-3" />
|
<Hash className="h-3 w-3" />
|
||||||
@@ -481,7 +648,7 @@ const StructuredEditor = ({ onDataChange, initialData = {}, readOnly: readOnlyPr
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof value === 'boolean') {
|
if (typeof value === "boolean") {
|
||||||
return (
|
return (
|
||||||
<span className="inline-flex items-center justify-center w-6 h-6 rounded-full bg-yellow-100 text-yellow-800 dark:bg-yellow-900/20 dark:text-yellow-300">
|
<span className="inline-flex items-center justify-center w-6 h-6 rounded-full bg-yellow-100 text-yellow-800 dark:bg-yellow-900/20 dark:text-yellow-300">
|
||||||
<ToggleLeft className="h-3 w-3" />
|
<ToggleLeft className="h-3 w-3" />
|
||||||
@@ -497,7 +664,7 @@ const StructuredEditor = ({ onDataChange, initialData = {}, readOnly: readOnlyPr
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof value === 'object') {
|
if (typeof value === "object") {
|
||||||
return (
|
return (
|
||||||
<span className="inline-flex items-center justify-center w-6 h-6 rounded-full bg-blue-100 text-blue-800 dark:bg-blue-900/20 dark:text-blue-300">
|
<span className="inline-flex items-center justify-center w-6 h-6 rounded-full bg-blue-100 text-blue-800 dark:bg-blue-900/20 dark:text-blue-300">
|
||||||
<Braces className="h-3 w-3" />
|
<Braces className="h-3 w-3" />
|
||||||
@@ -514,16 +681,41 @@ const StructuredEditor = ({ onDataChange, initialData = {}, readOnly: readOnlyPr
|
|||||||
|
|
||||||
const renderValue = (value, key, path, parentPath) => {
|
const renderValue = (value, key, path, parentPath) => {
|
||||||
const isExpanded = expandedNodes.has(path);
|
const isExpanded = expandedNodes.has(path);
|
||||||
const canExpand = typeof value === 'object' && value !== null;
|
const canExpand = typeof value === "object" && value !== null;
|
||||||
|
|
||||||
|
// Check if this node matches the search query (if active)
|
||||||
|
// A node matches if its path is in searchResults
|
||||||
|
const isSearchActive = searchQuery.trim() !== "";
|
||||||
|
const isMatch = isSearchActive && searchResults.has(path);
|
||||||
|
|
||||||
|
// Check if any of its children match
|
||||||
|
let hasMatchingChildren = false;
|
||||||
|
if (isSearchActive && canExpand) {
|
||||||
|
// Look through searchResults to see if any path starts with this node's path
|
||||||
|
hasMatchingChildren = Array.from(searchResults).some((resPath) =>
|
||||||
|
resPath.startsWith(`${path}.`),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hide node if:
|
||||||
|
// 1. Search is active AND
|
||||||
|
// 2. Node itself doesn't match AND
|
||||||
|
// 3. Node has no matching children AND
|
||||||
|
// 4. Node is not the root (we always render root level if it matches something to keep structure)
|
||||||
|
// Exception: If we're at root level but the node has no matches and no matching children, hide it
|
||||||
|
const isHiddenBySearch = isSearchActive && !isMatch && !hasMatchingChildren;
|
||||||
|
|
||||||
|
// If there is an active search and this node doesn't match and has no matching children, don't render it
|
||||||
|
if (isHiddenBySearch) return null;
|
||||||
|
|
||||||
// Check if parent is an array by looking at the parent path
|
// Check if parent is an array by looking at the parent path
|
||||||
const isArrayItem = (() => {
|
const isArrayItem = (() => {
|
||||||
if (parentPath === 'root') {
|
if (parentPath === "root") {
|
||||||
// If parent is root, check if root data is an array
|
// If parent is root, check if root data is an array
|
||||||
return Array.isArray(data);
|
return Array.isArray(data);
|
||||||
} else {
|
} else {
|
||||||
// Navigate to parent and check if it's an array
|
// Navigate to parent and check if it's an array
|
||||||
const parentPathParts = parentPath.split('.');
|
const parentPathParts = parentPath.split(".");
|
||||||
let current = data;
|
let current = data;
|
||||||
for (let i = 1; i < parentPathParts.length; i++) {
|
for (let i = 1; i < parentPathParts.length; i++) {
|
||||||
current = current[parentPathParts[i]];
|
current = current[parentPathParts[i]];
|
||||||
@@ -533,7 +725,10 @@ const StructuredEditor = ({ onDataChange, initialData = {}, readOnly: readOnlyPr
|
|||||||
})();
|
})();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={path} className="ml-4 border-l border-gray-200 dark:border-gray-700 pl-4 overflow-hidden">
|
<div
|
||||||
|
key={path}
|
||||||
|
className="ml-4 border-l border-gray-200 dark:border-gray-700 pl-4 overflow-hidden"
|
||||||
|
>
|
||||||
<div className="flex items-center space-x-2 mb-2">
|
<div className="flex items-center space-x-2 mb-2">
|
||||||
{canExpand && (
|
{canExpand && (
|
||||||
<button
|
<button
|
||||||
@@ -553,66 +748,69 @@ const StructuredEditor = ({ onDataChange, initialData = {}, readOnly: readOnlyPr
|
|||||||
<div className="flex items-center space-x-2 flex-1">
|
<div className="flex items-center space-x-2 flex-1">
|
||||||
{isArrayItem ? (
|
{isArrayItem ? (
|
||||||
// Array items: icon + index span (compact)
|
// Array items: icon + index span (compact)
|
||||||
<>
|
<div className="flex items-center space-x-1 w-[120px] shrink-0">
|
||||||
{getTypeIcon(value)}
|
{getTypeIcon(value)}
|
||||||
<span className="px-2 py-1 text-sm text-gray-600 dark:text-gray-400 font-mono whitespace-nowrap">
|
<span className="text-gray-500 dark:text-gray-400 font-mono text-sm">
|
||||||
[{key}]
|
{renderHighlightedText(key)}
|
||||||
</span>
|
</span>
|
||||||
</>
|
<span className="text-gray-600 inline">:</span>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
// Object properties: icon + editable key + colon (compact)
|
// Object properties: icon + editable key input
|
||||||
<>
|
<>
|
||||||
{getTypeIcon(value)}
|
{getTypeIcon(value)}
|
||||||
{readOnly ? (
|
{readOnly ? (
|
||||||
<span className="px-2 py-1 text-sm text-gray-900 dark:text-gray-100 font-mono">
|
<span
|
||||||
{key}
|
className="px-2 py-1 text-sm font-medium text-gray-900 dark:text-gray-100 min-w-0 break-all"
|
||||||
|
style={{ width: "120px" }}
|
||||||
|
>
|
||||||
|
{renderHighlightedText(key)}
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
defaultValue={key}
|
defaultValue={key}
|
||||||
onBlur={(e) => {
|
onBlur={(e) => {
|
||||||
const newKey = e.target.value.trim();
|
if (e.target.value !== key) {
|
||||||
if (newKey && newKey !== key) {
|
renameKey(key, e.target.value, path);
|
||||||
renameKey(key, newKey, path);
|
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === 'Enter') {
|
if (e.key === "Enter") {
|
||||||
e.target.blur(); // Trigger blur to save changes
|
e.target.blur(); // Trigger blur to save changes
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className="px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 min-w-0"
|
className="px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 min-w-0"
|
||||||
placeholder="Property name"
|
placeholder="Property name"
|
||||||
style={{width: '120px'}} // Fixed width for consistency
|
style={{ width: "120px" }} // Fixed width for consistency
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<span className="text-gray-500 inline">:</span>
|
<span className="text-gray-600 inline">:</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!canExpand ? (
|
{!canExpand ? (
|
||||||
typeof value === 'boolean' ? (
|
typeof value === "boolean" ? (
|
||||||
<div className="flex-1 flex items-center space-x-2">
|
<div className="flex-1 flex items-center space-x-2">
|
||||||
{readOnly ? (
|
{readOnly ? (
|
||||||
<span className="text-sm text-gray-900 dark:text-gray-100 font-mono">
|
<span className="text-sm text-gray-900 dark:text-gray-100 font-mono">
|
||||||
{value.toString()}
|
{renderHighlightedText(value.toString())}
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
onClick={() => updateValue((!value).toString(), path)}
|
onClick={() => updateValue((!value).toString(), path)}
|
||||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 ${
|
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 ${
|
||||||
value ? 'bg-blue-600' : 'bg-gray-200 dark:bg-gray-600'
|
value ? "bg-blue-600" : "bg-gray-200 dark:bg-gray-600"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
|
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
|
||||||
value ? 'translate-x-6' : 'translate-x-1'
|
value ? "translate-x-6" : "translate-x-1"
|
||||||
}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
<span className="text-sm text-gray-600 dark:text-gray-400 font-mono">
|
<span className="text-sm text-gray-600 dark:text-gray-600 font-mono">
|
||||||
{value.toString()}
|
{value.toString()}
|
||||||
</span>
|
</span>
|
||||||
</>
|
</>
|
||||||
@@ -621,22 +819,23 @@ const StructuredEditor = ({ onDataChange, initialData = {}, readOnly: readOnlyPr
|
|||||||
) : (
|
) : (
|
||||||
<div className="flex-1 flex items-center gap-2">
|
<div className="flex-1 flex items-center gap-2">
|
||||||
{readOnly ? (
|
{readOnly ? (
|
||||||
typeof value === 'string' && detectNestedData(value) ? (
|
typeof value === "string" && detectNestedData(value) ? (
|
||||||
<span
|
<span
|
||||||
onClick={() => openNestedEditor(value, path)}
|
onClick={() => openNestedEditor(value, path)}
|
||||||
className="px-2 py-1 text-sm text-blue-600 dark:text-blue-400 font-mono break-all cursor-pointer hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded transition-colors"
|
className="px-2 py-1 text-sm text-blue-600 dark:text-blue-400 font-mono break-all cursor-pointer hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded transition-colors"
|
||||||
title={`Click to view nested ${detectNestedData(value).type} data`}
|
title={`Click to view nested ${detectNestedData(value).type} data`}
|
||||||
>
|
>
|
||||||
{getDisplayValue(value)}
|
{renderHighlightedText(getDisplayValue(value))}
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="px-2 py-1 text-sm text-gray-900 dark:text-gray-100 font-mono break-all">
|
<span className="px-2 py-1 text-sm text-gray-900 dark:text-gray-100 font-mono break-all">
|
||||||
{getDisplayValue(value)}
|
{renderHighlightedText(getDisplayValue(value))}
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{(fieldTypes[path] === 'longtext' || (typeof value === 'string' && value.includes('\n'))) ? (
|
{fieldTypes[path] === "longtext" ||
|
||||||
|
(typeof value === "string" && value.includes("\n")) ? (
|
||||||
<textarea
|
<textarea
|
||||||
value={getDisplayValue(value)}
|
value={getDisplayValue(value)}
|
||||||
onChange={(e) => updateValue(e.target.value, path)}
|
onChange={(e) => updateValue(e.target.value, path)}
|
||||||
@@ -653,7 +852,7 @@ const StructuredEditor = ({ onDataChange, initialData = {}, readOnly: readOnlyPr
|
|||||||
placeholder="Value"
|
placeholder="Value"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{typeof value === 'string' && detectNestedData(value) && (
|
{typeof value === "string" && detectNestedData(value) && (
|
||||||
<button
|
<button
|
||||||
onClick={() => openNestedEditor(value, path)}
|
onClick={() => openNestedEditor(value, path)}
|
||||||
className="p-1 text-blue-600 hover:bg-blue-100 dark:hover:bg-blue-900 rounded flex-shrink-0"
|
className="p-1 text-blue-600 hover:bg-blue-100 dark:hover:bg-blue-900 rounded flex-shrink-0"
|
||||||
@@ -667,8 +866,10 @@ const StructuredEditor = ({ onDataChange, initialData = {}, readOnly: readOnlyPr
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
) : (
|
) : (
|
||||||
<span className="flex-1 text-sm text-gray-600 dark:text-gray-400">
|
<span className="flex-1 text-sm text-gray-600 dark:text-gray-600">
|
||||||
{Array.isArray(value) ? `Array (${value.length} items)` : `Object (${Object.keys(value).length} properties)`}
|
{Array.isArray(value)
|
||||||
|
? `Array (${value.length} items)`
|
||||||
|
: `Object (${Object.keys(value).length} properties)`}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -676,14 +877,22 @@ const StructuredEditor = ({ onDataChange, initialData = {}, readOnly: readOnlyPr
|
|||||||
<div className="flex items-center space-x-2 sm:space-x-2">
|
<div className="flex items-center space-x-2 sm:space-x-2">
|
||||||
<select
|
<select
|
||||||
value={
|
value={
|
||||||
fieldTypes[path] || (
|
fieldTypes[path] ||
|
||||||
value === null ? 'null' :
|
(value === null
|
||||||
value === undefined ? 'string' :
|
? "null"
|
||||||
typeof value === 'string' ? (value.includes('\n') ? 'longtext' : 'string') :
|
: value === undefined
|
||||||
typeof value === 'number' ? 'number' :
|
? "string"
|
||||||
typeof value === 'boolean' ? 'boolean' :
|
: typeof value === "string"
|
||||||
Array.isArray(value) ? 'array' : 'object'
|
? value.includes("\n")
|
||||||
)
|
? "longtext"
|
||||||
|
: "string"
|
||||||
|
: typeof value === "number"
|
||||||
|
? "number"
|
||||||
|
: typeof value === "boolean"
|
||||||
|
? "boolean"
|
||||||
|
: Array.isArray(value)
|
||||||
|
? "array"
|
||||||
|
: "object")
|
||||||
}
|
}
|
||||||
onChange={(e) => changeType(e.target.value, path)}
|
onChange={(e) => changeType(e.target.value, path)}
|
||||||
className="px-2 py-1 text-xs border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 min-w-0"
|
className="px-2 py-1 text-xs border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 min-w-0"
|
||||||
@@ -714,7 +923,7 @@ const StructuredEditor = ({ onDataChange, initialData = {}, readOnly: readOnlyPr
|
|||||||
{Array.isArray(value) ? (
|
{Array.isArray(value) ? (
|
||||||
<>
|
<>
|
||||||
{value.map((item, index) =>
|
{value.map((item, index) =>
|
||||||
renderValue(item, index.toString(), `${path}.${index}`, path)
|
renderValue(item, index.toString(), `${path}.${index}`, path),
|
||||||
)}
|
)}
|
||||||
{!readOnly && (
|
{!readOnly && (
|
||||||
<button
|
<button
|
||||||
@@ -729,7 +938,7 @@ const StructuredEditor = ({ onDataChange, initialData = {}, readOnly: readOnlyPr
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{Object.entries(value).map(([k, v]) =>
|
{Object.entries(value).map(([k, v]) =>
|
||||||
renderValue(v, k, `${path}.${k}`, path)
|
renderValue(v, k, `${path}.${k}`, path),
|
||||||
)}
|
)}
|
||||||
{!readOnly && (
|
{!readOnly && (
|
||||||
<button
|
<button
|
||||||
@@ -751,18 +960,63 @@ const StructuredEditor = ({ onDataChange, initialData = {}, readOnly: readOnlyPr
|
|||||||
return (
|
return (
|
||||||
<div className="min-h-96 w-full">
|
<div className="min-h-96 w-full">
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<div className="flex flex-col gap-3 mb-3">
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 mb-3">
|
||||||
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100">Structured Data Editor</h3>
|
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 whitespace-nowrap">
|
||||||
|
Structured Data Editor
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-3 w-full sm:w-auto sm:justify-end">
|
||||||
|
{/* Search Bar Inline */}
|
||||||
|
<div className="relative flex-grow max-w-[200px] sm:max-w-xs order-last sm:order-first">
|
||||||
|
<div className="absolute inset-y-0 left-0 pl-2.5 flex items-center pointer-events-none">
|
||||||
|
<Search className="h-3.5 w-3.5 text-gray-400" />
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
className="w-full pl-8 pr-8 py-1.5 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-xs text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
|
||||||
|
/>
|
||||||
|
{searchQuery && (
|
||||||
|
<button
|
||||||
|
onClick={() => setSearchQuery("")}
|
||||||
|
className="absolute inset-y-0 right-0 pr-2.5 flex items-center text-gray-400 hover:text-gray-600 dark:hover:text-gray-200"
|
||||||
|
>
|
||||||
|
<X className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Expand/Collapse All Buttons */}
|
||||||
|
<div className="flex border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden bg-white dark:bg-gray-800 shadow-sm shrink-0">
|
||||||
|
<button
|
||||||
|
onClick={expandAll}
|
||||||
|
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
|
||||||
|
title="Expand All"
|
||||||
|
>
|
||||||
|
<ChevronsUpDown className="h-3.5 w-3.5" />
|
||||||
|
<span className="hidden lg:inline">Expand All</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={collapseAll}
|
||||||
|
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors border-l border-gray-200 dark:border-gray-700"
|
||||||
|
title="Collapse All"
|
||||||
|
>
|
||||||
|
<ChevronsDownUp className="h-3.5 w-3.5" />
|
||||||
|
<span className="hidden lg:inline">Collapse All</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Mode Toggle - Below title on mobile, inline on desktop */}
|
{/* Mode Toggle - Below title on mobile, inline on desktop */}
|
||||||
{readOnlyProp === false && (
|
{readOnlyProp === false && (
|
||||||
<div className="flex border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden bg-white dark:bg-gray-800 shadow-sm w-fit">
|
<div className="flex border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden bg-white dark:bg-gray-800 shadow-sm shrink-0">
|
||||||
<button
|
<button
|
||||||
onClick={() => setEditMode(false)}
|
onClick={() => setEditMode(false)}
|
||||||
className={`flex items-center gap-2 px-3 py-1.5 text-xs font-medium transition-colors ${
|
className={`flex items-center gap-2 px-3 py-1.5 text-xs font-medium transition-colors ${
|
||||||
!editMode
|
!editMode
|
||||||
? 'bg-green-50 dark:bg-green-900/20 text-green-700 dark:text-green-300'
|
? "bg-green-50 dark:bg-green-900/20 text-green-700 dark:text-green-300"
|
||||||
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700'
|
: "text-gray-600 dark:text-gray-600 hover:text-gray-900 dark:hover:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Eye className="h-3.5 w-3.5" />
|
<Eye className="h-3.5 w-3.5" />
|
||||||
@@ -772,8 +1026,8 @@ const StructuredEditor = ({ onDataChange, initialData = {}, readOnly: readOnlyPr
|
|||||||
onClick={() => setEditMode(true)}
|
onClick={() => setEditMode(true)}
|
||||||
className={`flex items-center gap-2 px-3 py-1.5 text-xs font-medium transition-colors border-l border-gray-200 dark:border-gray-700 ${
|
className={`flex items-center gap-2 px-3 py-1.5 text-xs font-medium transition-colors border-l border-gray-200 dark:border-gray-700 ${
|
||||||
editMode
|
editMode
|
||||||
? 'bg-orange-50 dark:bg-orange-900/20 text-orange-700 dark:text-orange-300'
|
? "bg-orange-50 dark:bg-orange-900/20 text-orange-700 dark:text-orange-300"
|
||||||
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700'
|
: "text-gray-600 dark:text-gray-600 hover:text-gray-900 dark:hover:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Pencil className="h-3.5 w-3.5" />
|
<Pencil className="h-3.5 w-3.5" />
|
||||||
@@ -783,25 +1037,29 @@ const StructuredEditor = ({ onDataChange, initialData = {}, readOnly: readOnlyPr
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="w-full overflow-hidden">
|
<div className="w-full overflow-hidden">
|
||||||
<div className="w-full overflow-x-auto">
|
<div className="w-full overflow-x-auto">
|
||||||
<div className="min-w-max">
|
<div className="min-w-max">
|
||||||
{Object.keys(data).length === 0 ? (
|
{Object.keys(data).length === 0 ? (
|
||||||
<div className="text-center text-gray-500 dark:text-gray-400 py-8">
|
<div className="text-center text-gray-600 dark:text-gray-600 py-8">
|
||||||
<Braces className="h-12 w-12 mx-auto mb-2 opacity-50" />
|
<Braces className="h-12 w-12 mx-auto mb-2 opacity-50" />
|
||||||
<p>No properties yet. Click "Add Property" to start building your data structure.</p>
|
<p>
|
||||||
|
No properties yet. Click "Add Property" to start building your
|
||||||
|
data structure.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
Object.entries(data).map(([key, value]) =>
|
Object.entries(data).map(([key, value]) =>
|
||||||
renderValue(value, key, `root.${key}`, 'root')
|
renderValue(value, key, `root.${key}`, "root"),
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Root level Add Property button */}
|
{/* Root level Add Property button */}
|
||||||
{!readOnly && (
|
{!readOnly && (
|
||||||
<button
|
<button
|
||||||
onClick={() => addProperty(data, 'root')}
|
onClick={() => addProperty(data, "root")}
|
||||||
className="flex items-center space-x-1 px-2 py-1 text-sm text-blue-600 hover:bg-blue-100 dark:hover:bg-blue-900 rounded"
|
className="flex items-center space-x-1 px-2 py-1 text-sm text-blue-600 hover:bg-blue-100 dark:hover:bg-blue-900 rounded"
|
||||||
>
|
>
|
||||||
<Plus className="h-4 w-4" />
|
<Plus className="h-4 w-4" />
|
||||||
@@ -820,15 +1078,18 @@ const StructuredEditor = ({ onDataChange, initialData = {}, readOnly: readOnlyPr
|
|||||||
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700 bg-blue-50 dark:bg-blue-900/20 flex items-center justify-between">
|
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700 bg-blue-50 dark:bg-blue-900/20 flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100">
|
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100">
|
||||||
Edit Nested {nestedEditModal.type === 'json' ? 'JSON' : 'Serialized'} Data
|
Edit Nested{" "}
|
||||||
|
{nestedEditModal.type === "json" ? "JSON" : "Serialized"} Data
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
<p className="text-sm text-gray-600 dark:text-gray-600 mt-1">
|
||||||
Changes will be saved back as a {nestedEditModal.type === 'json' ? 'JSON' : 'serialized'} string
|
Changes will be saved back as a{" "}
|
||||||
|
{nestedEditModal.type === "json" ? "JSON" : "serialized"}{" "}
|
||||||
|
string
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={closeNestedEditor}
|
onClick={closeNestedEditor}
|
||||||
className="p-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 rounded self-start"
|
className="p-2 text-gray-600 hover:text-gray-700 dark:text-gray-600 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 rounded self-start"
|
||||||
>
|
>
|
||||||
<X className="h-5 w-5" />
|
<X className="h-5 w-5" />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
30
src/components/TabletAdSection.js
Executable file
@@ -0,0 +1,30 @@
|
|||||||
|
import React from "react";
|
||||||
|
import AdBlock from "./AdBlock";
|
||||||
|
import OfferBlock from "./OfferBlock";
|
||||||
|
import AffiliateBlock from "./AffiliateBlock";
|
||||||
|
|
||||||
|
const TabletAdSection = () => {
|
||||||
|
return (
|
||||||
|
<div className="hidden lg:flex xl:hidden flex-col mt-8 pt-6 border-t border-gray-200 dark:border-gray-700">
|
||||||
|
<h3 className="text-sm font-medium text-gray-600 dark:text-gray-600 mb-4 text-center">
|
||||||
|
Sponsored
|
||||||
|
</h3>
|
||||||
|
<div className="flex justify-center gap-4 overflow-x-auto pb-4">
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
<AdBlock
|
||||||
|
adKey="7c55aebcdd74f6e9a8dc24bd13e7d949"
|
||||||
|
adDomain="downconvenientmagnetic.com"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
<OfferBlock />
|
||||||
|
</div>
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
<AffiliateBlock />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TabletAdSection;
|
||||||
0
src/components/ThemeToggle.js
Normal file → Executable file
6
src/components/ToolCard.js
Normal file → Executable file
@@ -45,7 +45,7 @@ const ToolCard = ({ icon: Icon, title, description, path, tags, category }) => {
|
|||||||
return {
|
return {
|
||||||
border: 'hover:border-slate-300 dark:hover:border-slate-500',
|
border: 'hover:border-slate-300 dark:hover:border-slate-500',
|
||||||
shadow: 'hover:shadow-slate-500/20',
|
shadow: 'hover:shadow-slate-500/20',
|
||||||
titleColor: 'group-hover:text-slate-600 dark:group-hover:text-slate-400',
|
titleColor: 'group-hover:text-slate-600 dark:group-hover:text-slate-600',
|
||||||
arrowColor: 'group-hover:text-slate-600',
|
arrowColor: 'group-hover:text-slate-600',
|
||||||
badgeColor: 'group-hover:bg-slate-100 dark:group-hover:bg-slate-700 group-hover:text-slate-700 dark:group-hover:text-slate-300'
|
badgeColor: 'group-hover:bg-slate-100 dark:group-hover:bg-slate-700 group-hover:text-slate-700 dark:group-hover:text-slate-300'
|
||||||
};
|
};
|
||||||
@@ -67,7 +67,7 @@ const ToolCard = ({ icon: Icon, title, description, path, tags, category }) => {
|
|||||||
<Icon className="h-6 w-6 text-white" />
|
<Icon className="h-6 w-6 text-white" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-shrink-0 ml-4">
|
<div className="flex-shrink-0 ml-4">
|
||||||
<ArrowRight className={`h-5 w-5 text-slate-400 ${hoverClasses.arrowColor} group-hover:translate-x-1 transition-all duration-300`} />
|
<ArrowRight className={`h-5 w-5 text-slate-600 ${hoverClasses.arrowColor} group-hover:translate-x-1 transition-all duration-300`} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -91,7 +91,7 @@ const ToolCard = ({ icon: Icon, title, description, path, tags, category }) => {
|
|||||||
{tags.map((tag, index) => (
|
{tags.map((tag, index) => (
|
||||||
<span
|
<span
|
||||||
key={index}
|
key={index}
|
||||||
className="px-3 py-1 text-xs font-medium bg-slate-50 dark:bg-slate-700/50 text-slate-500 dark:text-slate-400 rounded-full border border-slate-200 dark:border-slate-600 group-hover:border-slate-300 dark:group-hover:border-slate-500 transition-colors"
|
className="px-3 py-1 text-xs font-medium bg-slate-50 dark:bg-slate-700/50 text-slate-600 dark:text-slate-600 rounded-full border border-slate-200 dark:border-slate-600 group-hover:border-slate-300 dark:group-hover:border-slate-500 transition-colors"
|
||||||
>
|
>
|
||||||
{tag}
|
{tag}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
8
src/components/ToolLayout.js
Normal file → Executable file
@@ -1,6 +1,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import AdColumn from './AdColumn';
|
import AdColumn from './AdColumn';
|
||||||
import MobileAdBanner from './MobileAdBanner';
|
import MobileAdBanner from './MobileAdBanner';
|
||||||
|
import TabletAdSection from './TabletAdSection';
|
||||||
|
|
||||||
const ToolLayout = ({ title, description, children, icon: Icon }) => {
|
const ToolLayout = ({ title, description, children, icon: Icon }) => {
|
||||||
return (
|
return (
|
||||||
@@ -28,17 +29,16 @@ const ToolLayout = ({ title, description, children, icon: Icon }) => {
|
|||||||
<div className="space-y-4 sm:space-y-6 w-full max-w-full min-w-0">
|
<div className="space-y-4 sm:space-y-6 w-full max-w-full min-w-0">
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<TabletAdSection />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Desktop Ad Column - Hidden on mobile */}
|
|
||||||
<AdColumn />
|
<AdColumn />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Mobile Ad Banner - Hidden on desktop */}
|
|
||||||
<MobileAdBanner />
|
<MobileAdBanner />
|
||||||
|
|
||||||
{/* Add padding to bottom on mobile to prevent content overlap with sticky ad */}
|
<div className="lg:hidden h-16" />
|
||||||
<div className="xl:hidden h-16" />
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
37
src/components/ToolSidebar.js
Normal file → Executable file
@@ -107,11 +107,12 @@ const ToolSidebar = ({ navigateWithGuard: propNavigateWithGuard }) => {
|
|||||||
<button
|
<button
|
||||||
onClick={() => setIsCollapsed(!isCollapsed)}
|
onClick={() => setIsCollapsed(!isCollapsed)}
|
||||||
className="p-2 rounded-xl hover:bg-white/50 dark:hover:bg-slate-700/50 transition-all duration-300 group"
|
className="p-2 rounded-xl hover:bg-white/50 dark:hover:bg-slate-700/50 transition-all duration-300 group"
|
||||||
|
aria-label={isCollapsed ? "Expand sidebar" : "Collapse sidebar"}
|
||||||
>
|
>
|
||||||
{isCollapsed ? (
|
{isCollapsed ? (
|
||||||
<ChevronRight className="h-4 w-4 text-slate-500 group-hover:text-blue-500 transition-colors" />
|
<ChevronRight className="h-4 w-4 text-slate-600 group-hover:text-blue-500 transition-colors" />
|
||||||
) : (
|
) : (
|
||||||
<ChevronLeft className="h-4 w-4 text-slate-500 group-hover:text-blue-500 transition-colors" />
|
<ChevronLeft className="h-4 w-4 text-slate-600 group-hover:text-blue-500 transition-colors" />
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -121,7 +122,7 @@ const ToolSidebar = ({ navigateWithGuard: propNavigateWithGuard }) => {
|
|||||||
<div className="relative mt-4">
|
<div className="relative mt-4">
|
||||||
<div className="absolute inset-0 bg-gradient-to-r from-blue-500/10 to-purple-500/10 rounded-xl blur opacity-50"></div>
|
<div className="absolute inset-0 bg-gradient-to-r from-blue-500/10 to-purple-500/10 rounded-xl blur opacity-50"></div>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-slate-400" />
|
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-slate-600" />
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Search tools..."
|
placeholder="Search tools..."
|
||||||
@@ -176,7 +177,7 @@ const ToolSidebar = ({ navigateWithGuard: propNavigateWithGuard }) => {
|
|||||||
<IconComponent className={`${
|
<IconComponent className={`${
|
||||||
isActiveItem
|
isActiveItem
|
||||||
? 'h-5 w-5 text-white'
|
? 'h-5 w-5 text-white'
|
||||||
: 'h-4 w-4 text-slate-500 dark:text-slate-400 group-hover:text-white'
|
: 'h-4 w-4 text-slate-600 dark:text-slate-600 group-hover:text-white'
|
||||||
}`} />
|
}`} />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -193,7 +194,7 @@ const ToolSidebar = ({ navigateWithGuard: propNavigateWithGuard }) => {
|
|||||||
<IconComponent className={`h-4 w-4 ${
|
<IconComponent className={`h-4 w-4 ${
|
||||||
isActiveItem
|
isActiveItem
|
||||||
? 'text-white'
|
? 'text-white'
|
||||||
: 'text-slate-500 dark:text-slate-400 group-hover:text-white'
|
: 'text-slate-600 dark:text-slate-600 group-hover:text-white'
|
||||||
}`} />
|
}`} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
@@ -203,8 +204,8 @@ const ToolSidebar = ({ navigateWithGuard: propNavigateWithGuard }) => {
|
|||||||
? 'text-amber-700 dark:text-amber-300'
|
? 'text-amber-700 dark:text-amber-300'
|
||||||
: 'text-indigo-700 dark:text-indigo-300'
|
: 'text-indigo-700 dark:text-indigo-300'
|
||||||
: isWhatsNew
|
: isWhatsNew
|
||||||
? 'text-slate-500 dark:text-slate-400 group-hover:text-amber-600 dark:group-hover:text-amber-400'
|
? 'text-slate-600 dark:text-slate-600 group-hover:text-amber-600 dark:group-hover:text-amber-400'
|
||||||
: 'text-slate-500 dark:text-slate-400 group-hover:text-indigo-600 dark:group-hover:text-indigo-400'
|
: 'text-slate-600 dark:text-slate-600 group-hover:text-indigo-600 dark:group-hover:text-indigo-400'
|
||||||
}`}>
|
}`}>
|
||||||
{tool.name}
|
{tool.name}
|
||||||
{isWhatsNew && !isCollapsed && (
|
{isWhatsNew && !isCollapsed && (
|
||||||
@@ -213,7 +214,7 @@ const ToolSidebar = ({ navigateWithGuard: propNavigateWithGuard }) => {
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-slate-500 dark:text-slate-400 truncate">
|
<div className="text-xs text-slate-600 dark:text-slate-600 truncate">
|
||||||
{tool.description}
|
{tool.description}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -238,12 +239,12 @@ const ToolSidebar = ({ navigateWithGuard: propNavigateWithGuard }) => {
|
|||||||
{/* Category Header */}
|
{/* Category Header */}
|
||||||
<button
|
<button
|
||||||
onClick={() => toggleCategory(categoryKey)}
|
onClick={() => toggleCategory(categoryKey)}
|
||||||
className="w-full flex items-center justify-between px-3 py-2 text-xs font-semibold text-slate-600 dark:text-slate-400 hover:text-slate-800 dark:hover:text-slate-200 transition-colors rounded-lg hover:bg-white/50 dark:hover:bg-slate-700/50"
|
className="w-full flex items-center justify-between px-3 py-2 text-xs font-semibold text-slate-600 dark:text-slate-600 hover:text-slate-800 dark:hover:text-slate-200 transition-colors rounded-lg hover:bg-white/50 dark:hover:bg-slate-700/50"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className={`w-2 h-2 rounded-full bg-gradient-to-r ${categoryConfig.color}`}></div>
|
<div className={`w-2 h-2 rounded-full bg-gradient-to-r ${categoryConfig.color}`}></div>
|
||||||
<span className="uppercase tracking-wider">{categoryConfig.name}</span>
|
<span className="uppercase tracking-wider">{categoryConfig.name}</span>
|
||||||
<span className="text-slate-400 dark:text-slate-500">({tools.length})</span>
|
<span className="text-slate-600 dark:text-slate-600">({tools.length})</span>
|
||||||
</div>
|
</div>
|
||||||
{isExpanded ? (
|
{isExpanded ? (
|
||||||
<ChevronUp className="h-3 w-3" />
|
<ChevronUp className="h-3 w-3" />
|
||||||
@@ -315,16 +316,16 @@ const ToolSidebar = ({ navigateWithGuard: propNavigateWithGuard }) => {
|
|||||||
<IconComponent className={`h-3.5 w-3.5 ${
|
<IconComponent className={`h-3.5 w-3.5 ${
|
||||||
isActiveItem
|
isActiveItem
|
||||||
? 'text-white'
|
? 'text-white'
|
||||||
: 'text-slate-500 dark:text-slate-400 group-hover:text-white'
|
: 'text-slate-600 dark:text-slate-600 group-hover:text-white'
|
||||||
}`} />
|
}`} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className={`font-medium truncate text-sm ${
|
<div className={`font-medium truncate text-sm ${
|
||||||
isActiveItem ? activeClasses.titleColor : 'text-slate-600 dark:text-slate-400 group-hover:text-slate-800 dark:group-hover:text-slate-200'
|
isActiveItem ? activeClasses.titleColor : 'text-slate-600 dark:text-slate-600 group-hover:text-slate-800 dark:group-hover:text-slate-200'
|
||||||
}`}>
|
}`}>
|
||||||
{tool.name}
|
{tool.name}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-slate-500 dark:text-slate-500 truncate">
|
<div className="text-xs text-slate-600 dark:text-slate-600 truncate">
|
||||||
{tool.description}
|
{tool.description}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -390,12 +391,12 @@ const ToolSidebar = ({ navigateWithGuard: propNavigateWithGuard }) => {
|
|||||||
: `border border-gray-300 dark:border-slate-600 bg-transparent`
|
: `border border-gray-300 dark:border-slate-600 bg-transparent`
|
||||||
}`}>
|
}`}>
|
||||||
<IconComponent className={`h-3.5 w-3.5 ${
|
<IconComponent className={`h-3.5 w-3.5 ${
|
||||||
isActiveItem ? 'text-white' : 'text-gray-500 dark:text-gray-400'
|
isActiveItem ? 'text-white' : 'text-gray-600 dark:text-gray-600'
|
||||||
}`} />
|
}`} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className={`font-medium text-sm truncate ${isActiveItem ? `text-white` : `text-gray-500 dark:text-gray-400`}`}>{tool.name}</div>
|
<div className={`font-medium text-sm truncate ${isActiveItem ? `text-white` : `text-gray-600 dark:text-gray-600`}`}>{tool.name}</div>
|
||||||
<div className={`text-xs ${isActiveItem ? `text-white` : `text-gray-500 dark:text-gray-400`} truncate`}>{tool.description}</div>
|
<div className={`text-xs ${isActiveItem ? `text-white` : `text-gray-600 dark:text-gray-600`} truncate`}>{tool.description}</div>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
);
|
);
|
||||||
@@ -415,12 +416,12 @@ const ToolSidebar = ({ navigateWithGuard: propNavigateWithGuard }) => {
|
|||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="flex items-center justify-center gap-2 mb-2">
|
<div className="flex items-center justify-center gap-2 mb-2">
|
||||||
<div className="w-1.5 h-1.5 bg-gradient-to-r from-blue-500 to-purple-500 rounded-full animate-pulse"></div>
|
<div className="w-1.5 h-1.5 bg-gradient-to-r from-blue-500 to-purple-500 rounded-full animate-pulse"></div>
|
||||||
<span className="text-xs font-medium text-slate-500 dark:text-slate-400">
|
<span className="text-xs font-medium text-slate-600 dark:text-slate-600">
|
||||||
Quick Access
|
Quick Access
|
||||||
</span>
|
</span>
|
||||||
<div className="w-1.5 h-1.5 bg-gradient-to-r from-purple-500 to-indigo-500 rounded-full"></div>
|
<div className="w-1.5 h-1.5 bg-gradient-to-r from-purple-500 to-indigo-500 rounded-full"></div>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-slate-400 dark:text-slate-500">
|
<p className="text-xs text-slate-600 dark:text-slate-600">
|
||||||
{SITE_CONFIG.totalTools} tools available
|
{SITE_CONFIG.totalTools} tools available
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
0
src/components/invoice-templates/MinimalTemplate.js
Normal file → Executable file
0
src/config/features.js
Normal file → Executable file
216
src/config/tools.js
Normal file → Executable file
@@ -1,163 +1,185 @@
|
|||||||
import { Edit3, Table, LinkIcon, Hash, Wand2, GitCompare, Type, Home, FileText } from 'lucide-react';
|
import {
|
||||||
|
Edit3,
|
||||||
|
Table,
|
||||||
|
LinkIcon,
|
||||||
|
Hash,
|
||||||
|
Wand2,
|
||||||
|
GitCompare,
|
||||||
|
Type,
|
||||||
|
Home,
|
||||||
|
FileText,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
// Master tools configuration - single source of truth
|
// Master tools configuration - single source of truth
|
||||||
export const TOOL_CATEGORIES = {
|
export const TOOL_CATEGORIES = {
|
||||||
navigation: {
|
navigation: {
|
||||||
name: 'Navigation',
|
name: "Navigation",
|
||||||
color: 'from-slate-500 to-slate-600',
|
color: "from-slate-500 to-slate-600",
|
||||||
hoverColor: 'slate-600',
|
hoverColor: "slate-600",
|
||||||
textColor: 'text-slate-600',
|
textColor: "text-slate-600",
|
||||||
hoverTextColor: 'hover:text-slate-700 dark:hover:text-slate-400'
|
hoverTextColor: "hover:text-slate-700 dark:hover:text-slate-600",
|
||||||
},
|
},
|
||||||
editor: {
|
editor: {
|
||||||
name: 'Editor',
|
name: "Editor",
|
||||||
color: 'from-blue-500 to-cyan-500',
|
color: "from-blue-500 to-cyan-500",
|
||||||
hoverColor: 'blue-600',
|
hoverColor: "blue-600",
|
||||||
textColor: 'text-blue-600',
|
textColor: "text-blue-600",
|
||||||
hoverTextColor: 'hover:text-blue-700 dark:hover:text-blue-400'
|
hoverTextColor: "hover:text-blue-700 dark:hover:text-blue-400",
|
||||||
},
|
},
|
||||||
encoder: {
|
encoder: {
|
||||||
name: 'Encoder',
|
name: "Encoder",
|
||||||
color: 'from-purple-500 to-pink-500',
|
color: "from-purple-500 to-pink-500",
|
||||||
hoverColor: 'purple-600',
|
hoverColor: "purple-600",
|
||||||
textColor: 'text-purple-600',
|
textColor: "text-purple-600",
|
||||||
hoverTextColor: 'hover:text-purple-700 dark:hover:text-purple-400'
|
hoverTextColor: "hover:text-purple-700 dark:hover:text-purple-400",
|
||||||
},
|
},
|
||||||
formatter: {
|
formatter: {
|
||||||
name: 'Formatter',
|
name: "Formatter",
|
||||||
color: 'from-green-500 to-emerald-500',
|
color: "from-green-500 to-emerald-500",
|
||||||
hoverColor: 'green-600',
|
hoverColor: "green-600",
|
||||||
textColor: 'text-green-600',
|
textColor: "text-green-600",
|
||||||
hoverTextColor: 'hover:text-green-700 dark:hover:text-green-400'
|
hoverTextColor: "hover:text-green-700 dark:hover:text-green-400",
|
||||||
},
|
},
|
||||||
analyzer: {
|
analyzer: {
|
||||||
name: 'Analyzer',
|
name: "Analyzer",
|
||||||
color: 'from-orange-500 to-red-500',
|
color: "from-orange-500 to-red-500",
|
||||||
hoverColor: 'orange-600',
|
hoverColor: "orange-600",
|
||||||
textColor: 'text-orange-600',
|
textColor: "text-orange-600",
|
||||||
hoverTextColor: 'hover:text-orange-700 dark:hover:text-orange-400'
|
hoverTextColor: "hover:text-orange-700 dark:hover:text-orange-400",
|
||||||
},
|
},
|
||||||
non_tools: {
|
non_tools: {
|
||||||
name: 'Site Navigation',
|
name: "Site Navigation",
|
||||||
color: 'from-indigo-500 to-purple-500',
|
color: "from-indigo-500 to-purple-500",
|
||||||
hoverColor: 'indigo-600',
|
hoverColor: "indigo-600",
|
||||||
textColor: 'text-indigo-600',
|
textColor: "text-indigo-600",
|
||||||
hoverTextColor: 'hover:text-indigo-700 dark:hover:text-indigo-400'
|
hoverTextColor: "hover:text-indigo-700 dark:hover:text-indigo-400",
|
||||||
}
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const TOOLS = [
|
export const TOOLS = [
|
||||||
{
|
{
|
||||||
path: '/object-editor',
|
path: "/object-editor",
|
||||||
name: 'Object Editor',
|
name: "Object Editor",
|
||||||
icon: Edit3,
|
icon: Edit3,
|
||||||
description: 'Visual editor for JSON and PHP serialized objects with mindmap visualization',
|
description:
|
||||||
tags: ['Visual', 'JSON', 'PHP', 'Objects', 'Editor'],
|
"Visual editor for JSON and PHP serialized objects with mindmap visualization",
|
||||||
category: 'editor'
|
tags: ["Visual", "JSON", "PHP", "Objects", "Editor"],
|
||||||
|
category: "editor",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/table-editor',
|
path: "/table-editor",
|
||||||
name: 'Table Editor',
|
name: "Table Editor",
|
||||||
icon: Table,
|
icon: Table,
|
||||||
description: 'Import, edit, and export tabular data from URLs, files, or paste CSV/JSON',
|
description:
|
||||||
tags: ['Table', 'CSV', 'JSON', 'Data', 'Editor'],
|
"Import, edit, and export tabular data from URLs, files, or paste CSV/JSON",
|
||||||
category: 'editor'
|
tags: ["Table", "CSV", "JSON", "Data", "Editor"],
|
||||||
|
category: "editor",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/markdown-editor',
|
path: "/markdown-editor",
|
||||||
name: 'Markdown Editor',
|
name: "Markdown Editor",
|
||||||
icon: FileText,
|
icon: FileText,
|
||||||
description: 'Write and preview markdown with live rendering, syntax highlighting, and export options',
|
description:
|
||||||
tags: ['Markdown', 'Editor', 'Preview', 'Export', 'GFM'],
|
"Write and preview markdown with live rendering, syntax highlighting, and export options",
|
||||||
category: 'editor'
|
tags: ["Markdown", "Editor", "Preview", "Export", "GFM"],
|
||||||
|
category: "editor",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/invoice-editor',
|
path: "/diagram-editor",
|
||||||
name: 'Invoice Editor',
|
name: "Diagram Editor",
|
||||||
|
icon: Edit3,
|
||||||
|
description:
|
||||||
|
"Create diagrams as code using Mermaid.js with live preview and multi-format export",
|
||||||
|
tags: ["Diagram", "Mermaid", "Architecture", "Flowchart", "Visual"],
|
||||||
|
category: "editor",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/invoice-editor",
|
||||||
|
name: "Invoice Editor",
|
||||||
icon: FileText,
|
icon: FileText,
|
||||||
description: 'Create, edit, and export professional invoices with PDF generation',
|
description:
|
||||||
tags: ['Invoice', 'PDF', 'Business', 'Billing', 'Export'],
|
"Create, edit, and export professional invoices with PDF generation",
|
||||||
category: 'editor'
|
tags: ["Invoice", "PDF", "Business", "Billing", "Export"],
|
||||||
|
category: "editor",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/url',
|
path: "/url",
|
||||||
name: 'URL Encoder/Decoder',
|
name: "URL Encoder/Decoder",
|
||||||
icon: LinkIcon,
|
icon: LinkIcon,
|
||||||
description: 'Encode and decode URLs and query parameters',
|
description: "Encode and decode URLs and query parameters",
|
||||||
tags: ['URL', 'Encode', 'Decode'],
|
tags: ["URL", "Encode", "Decode"],
|
||||||
category: 'encoder'
|
category: "encoder",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/base64',
|
path: "/base64",
|
||||||
name: 'Base64 Encoder/Decoder',
|
name: "Base64 Encoder/Decoder",
|
||||||
icon: Hash,
|
icon: Hash,
|
||||||
description: 'Convert text to Base64 and back with support for files',
|
description: "Convert text to Base64 and back with support for files",
|
||||||
tags: ['Base64', 'Encode', 'Binary'],
|
tags: ["Base64", "Encode", "Binary"],
|
||||||
category: 'encoder'
|
category: "encoder",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/beautifier',
|
path: "/beautifier",
|
||||||
name: 'Code Beautifier/Minifier',
|
name: "Code Beautifier/Minifier",
|
||||||
icon: Wand2,
|
icon: Wand2,
|
||||||
description: 'Format and minify JSON, XML, SQL, CSS, and HTML code',
|
description: "Format and minify JSON, XML, SQL, CSS, and HTML code",
|
||||||
tags: ['Format', 'Minify', 'Beautify'],
|
tags: ["Format", "Minify", "Beautify"],
|
||||||
category: 'formatter'
|
category: "formatter",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/diff',
|
path: "/diff",
|
||||||
name: 'Text Diff Checker',
|
name: "Text Diff Checker",
|
||||||
icon: GitCompare,
|
icon: GitCompare,
|
||||||
description: 'Compare two texts and highlight differences line by line',
|
description: "Compare two texts and highlight differences line by line",
|
||||||
tags: ['Diff', 'Compare', 'Text'],
|
tags: ["Diff", "Compare", "Text"],
|
||||||
category: 'analyzer'
|
category: "analyzer",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/text-length',
|
path: "/text-length",
|
||||||
name: 'Text Length Checker',
|
name: "Text Length Checker",
|
||||||
icon: Type,
|
icon: Type,
|
||||||
description: 'Analyze text length, word count, and other text statistics',
|
description: "Analyze text length, word count, and other text statistics",
|
||||||
tags: ['Text', 'Length', 'Statistics'],
|
tags: ["Text", "Length", "Statistics"],
|
||||||
category: 'analyzer'
|
category: "analyzer",
|
||||||
}
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
// Non-tool navigation items (homepage, what's new, etc.)
|
// Non-tool navigation items (homepage, what's new, etc.)
|
||||||
export const NON_TOOLS = [
|
export const NON_TOOLS = [
|
||||||
{
|
{
|
||||||
path: '/',
|
path: "/",
|
||||||
name: 'Home',
|
name: "Home",
|
||||||
icon: Home,
|
icon: Home,
|
||||||
description: 'Back to homepage',
|
description: "Back to homepage",
|
||||||
category: 'non_tools'
|
category: "non_tools",
|
||||||
}
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
// Navigation tools (for sidebar) - combines non-tools and tools
|
// Navigation tools (for sidebar) - combines non-tools and tools
|
||||||
export const NAVIGATION_TOOLS = [
|
export const NAVIGATION_TOOLS = [...NON_TOOLS, ...TOOLS];
|
||||||
...NON_TOOLS,
|
|
||||||
...TOOLS
|
|
||||||
];
|
|
||||||
|
|
||||||
// Site configuration
|
// Site configuration
|
||||||
export const SITE_CONFIG = {
|
export const SITE_CONFIG = {
|
||||||
domain: 'https://dewe.dev',
|
domain: "https://dewe.dev",
|
||||||
title: 'Dewe.Dev',
|
title: "Dewe.Dev",
|
||||||
subtitle: 'Professional Developer Utilities',
|
subtitle: "Professional Developer Utilities",
|
||||||
slogan: 'Code faster, debug smarter, ship better',
|
slogan: "Code faster, debug smarter, ship better",
|
||||||
description: 'Professional-grade utilities for modern developers',
|
description: "Professional-grade utilities for modern developers",
|
||||||
year: new Date().getFullYear(),
|
year: new Date().getFullYear(),
|
||||||
totalTools: TOOLS.length
|
totalTools: TOOLS.length,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Helper functions
|
// Helper functions
|
||||||
export const getCategoryConfig = (categoryKey) => TOOL_CATEGORIES[categoryKey] || TOOL_CATEGORIES.navigation;
|
export const getCategoryConfig = (categoryKey) =>
|
||||||
|
TOOL_CATEGORIES[categoryKey] || TOOL_CATEGORIES.navigation;
|
||||||
|
|
||||||
export const getToolsByCategory = (categoryKey) => TOOLS.filter(tool => tool.category === categoryKey);
|
export const getToolsByCategory = (categoryKey) =>
|
||||||
|
TOOLS.filter((tool) => tool.category === categoryKey);
|
||||||
|
|
||||||
export const getCategoryStats = () => {
|
export const getCategoryStats = () => {
|
||||||
const stats = {};
|
const stats = {};
|
||||||
Object.keys(TOOL_CATEGORIES).forEach(key => {
|
Object.keys(TOOL_CATEGORIES).forEach((key) => {
|
||||||
if (key !== 'navigation') {
|
if (key !== "navigation") {
|
||||||
stats[key] = getToolsByCategory(key).length;
|
stats[key] = getToolsByCategory(key).length;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
0
src/data/faqs.js
Normal file → Executable file
0
src/hooks/useAnalytics.js
Normal file → Executable file
0
src/hooks/useNavigationGuard.js
Normal file → Executable file
20
src/index.css
Normal file → Executable file
@@ -2,31 +2,37 @@
|
|||||||
@tailwind components;
|
@tailwind components;
|
||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|
||||||
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&display=swap');
|
@import url("https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&display=swap");
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
html {
|
html {
|
||||||
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
font-family:
|
||||||
overflow-x: hidden;
|
system-ui,
|
||||||
|
-apple-system,
|
||||||
|
BlinkMacSystemFont,
|
||||||
|
"Segoe UI",
|
||||||
|
Roboto,
|
||||||
|
sans-serif;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 100vw;
|
max-width: 100vw;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
overflow-x: hidden;
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 100vw;
|
max-width: 100vw;
|
||||||
}
|
}
|
||||||
|
|
||||||
#root {
|
#root {
|
||||||
overflow-x: hidden;
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 100vw;
|
max-width: 100vw;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
code, pre {
|
code,
|
||||||
font-family: 'JetBrains Mono', Monaco, 'Cascadia Code', 'Segoe UI Mono', 'Roboto Mono', monospace;
|
pre {
|
||||||
|
font-family:
|
||||||
|
"JetBrains Mono", Monaco, "Cascadia Code", "Segoe UI Mono",
|
||||||
|
"Roboto Mono", monospace;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
0
src/index.js
Normal file → Executable file
8
src/pages/Base64Tool.js
Normal file → Executable file
@@ -85,7 +85,7 @@ const Base64Tool = () => {
|
|||||||
className={`px-4 py-2 rounded-md font-medium transition-colors ${
|
className={`px-4 py-2 rounded-md font-medium transition-colors ${
|
||||||
mode === 'encode'
|
mode === 'encode'
|
||||||
? 'bg-white dark:bg-gray-700 text-primary-600 shadow-sm'
|
? 'bg-white dark:bg-gray-700 text-primary-600 shadow-sm'
|
||||||
: 'text-gray-600 dark:text-gray-400'
|
: 'text-gray-600 dark:text-gray-600'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
Encode
|
Encode
|
||||||
@@ -95,7 +95,7 @@ const Base64Tool = () => {
|
|||||||
className={`px-4 py-2 rounded-md font-medium transition-colors ${
|
className={`px-4 py-2 rounded-md font-medium transition-colors ${
|
||||||
mode === 'decode'
|
mode === 'decode'
|
||||||
? 'bg-white dark:bg-gray-700 text-primary-600 shadow-sm'
|
? 'bg-white dark:bg-gray-700 text-primary-600 shadow-sm'
|
||||||
: 'text-gray-600 dark:text-gray-400'
|
: 'text-gray-600 dark:text-gray-600'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
Decode
|
Decode
|
||||||
@@ -147,7 +147,7 @@ const Base64Tool = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Output */}
|
{/* Output */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2" aria-live="polite">
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
{mode === 'encode' ? 'Base64 Output' : 'Decoded Text'}
|
{mode === 'encode' ? 'Base64 Output' : 'Decoded Text'}
|
||||||
</label>
|
</label>
|
||||||
@@ -160,7 +160,7 @@ const Base64Tool = () => {
|
|||||||
? 'Base64 encoded text will appear here...'
|
? 'Base64 encoded text will appear here...'
|
||||||
: 'Decoded text will appear here...'
|
: 'Decoded text will appear here...'
|
||||||
}
|
}
|
||||||
className="tool-input h-96 bg-gray-50 dark:bg-gray-800"
|
className={`tool-input h-96 bg-gray-50 dark:bg-gray-800 ${output?.startsWith('Error:') ? 'border-red-300 dark:border-red-700' : ''}`}
|
||||||
/>
|
/>
|
||||||
{output && <CopyButton text={output} />}
|
{output && <CopyButton text={output} />}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
8
src/pages/BeautifierTool.js
Normal file → Executable file
@@ -366,7 +366,7 @@ const BeautifierTool = () => {
|
|||||||
className={`px-4 py-2 rounded-md font-medium transition-colors ${
|
className={`px-4 py-2 rounded-md font-medium transition-colors ${
|
||||||
mode === 'beautify'
|
mode === 'beautify'
|
||||||
? 'bg-white dark:bg-gray-700 text-primary-600 shadow-sm'
|
? 'bg-white dark:bg-gray-700 text-primary-600 shadow-sm'
|
||||||
: 'text-gray-600 dark:text-gray-400'
|
: 'text-gray-600 dark:text-gray-600'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
Beautify
|
Beautify
|
||||||
@@ -376,7 +376,7 @@ const BeautifierTool = () => {
|
|||||||
className={`px-4 py-2 rounded-md font-medium transition-colors ${
|
className={`px-4 py-2 rounded-md font-medium transition-colors ${
|
||||||
mode === 'minify'
|
mode === 'minify'
|
||||||
? 'bg-white dark:bg-gray-700 text-primary-600 shadow-sm'
|
? 'bg-white dark:bg-gray-700 text-primary-600 shadow-sm'
|
||||||
: 'text-gray-600 dark:text-gray-400'
|
: 'text-gray-600 dark:text-gray-600'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
Minify
|
Minify
|
||||||
@@ -415,7 +415,7 @@ const BeautifierTool = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Output */}
|
{/* Output */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2" aria-live="polite">
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
{mode === 'beautify' ? 'Beautified' : 'Minified'} Output
|
{mode === 'beautify' ? 'Beautified' : 'Minified'} Output
|
||||||
</label>
|
</label>
|
||||||
@@ -424,7 +424,7 @@ const BeautifierTool = () => {
|
|||||||
value={output}
|
value={output}
|
||||||
readOnly
|
readOnly
|
||||||
placeholder={`${mode === 'beautify' ? 'Beautified' : 'Minified'} code will appear here...`}
|
placeholder={`${mode === 'beautify' ? 'Beautified' : 'Minified'} code will appear here...`}
|
||||||
className="tool-input h-96 bg-gray-50 dark:bg-gray-800"
|
className={`tool-input h-96 bg-gray-50 dark:bg-gray-800 ${output?.startsWith('Error:') ? 'border-red-300 dark:border-red-700' : ''}`}
|
||||||
/>
|
/>
|
||||||
{output && <CopyButton text={output} />}
|
{output && <CopyButton text={output} />}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
586
src/pages/DiagramEditor.js
Executable file
@@ -0,0 +1,586 @@
|
|||||||
|
import React, { useState, useRef, useEffect } from "react";
|
||||||
|
import {
|
||||||
|
GitGraph,
|
||||||
|
Plus,
|
||||||
|
Upload,
|
||||||
|
Download,
|
||||||
|
Globe,
|
||||||
|
Type,
|
||||||
|
Columns,
|
||||||
|
Maximize2,
|
||||||
|
Minimize2,
|
||||||
|
FileImage,
|
||||||
|
FileCode2,
|
||||||
|
Eye,
|
||||||
|
AlertTriangle,
|
||||||
|
FileText,
|
||||||
|
Copy,
|
||||||
|
} from "lucide-react";
|
||||||
|
import ToolLayout from "../components/ToolLayout";
|
||||||
|
import CodeMirrorEditor from "../components/CodeMirrorEditor";
|
||||||
|
import SEO from "../components/SEO";
|
||||||
|
import RelatedTools from "../components/RelatedTools";
|
||||||
|
import ReactFlowEditor from "../components/diagram/ReactFlowEditor";
|
||||||
|
import FullscreenAdBanner from "../components/FullscreenAdBanner";
|
||||||
|
import { toPng } from "html-to-image";
|
||||||
|
import { generateMermaidFromGraph } from "../utils/mermaidGenerator";
|
||||||
|
|
||||||
|
const DIAGRAM_TEMPLATES = {
|
||||||
|
flowchart: {
|
||||||
|
nodes: [
|
||||||
|
{
|
||||||
|
id: "1",
|
||||||
|
type: "process",
|
||||||
|
position: { x: 250, y: 50 },
|
||||||
|
data: { label: "Start" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "2",
|
||||||
|
type: "decision",
|
||||||
|
position: { x: 250, y: 150 },
|
||||||
|
data: { label: "Is it OK?" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "3",
|
||||||
|
type: "process",
|
||||||
|
position: { x: 100, y: 250 },
|
||||||
|
data: { label: "Fix it" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "4",
|
||||||
|
type: "database",
|
||||||
|
position: { x: 400, y: 250 },
|
||||||
|
data: { label: "Save to DB" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "5",
|
||||||
|
type: "process",
|
||||||
|
position: { x: 250, y: 350 },
|
||||||
|
data: { label: "End" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
edges: [
|
||||||
|
{ id: "e1-2", source: "1", target: "2" },
|
||||||
|
{
|
||||||
|
id: "e2-3",
|
||||||
|
source: "2",
|
||||||
|
target: "3",
|
||||||
|
sourceHandle: "left",
|
||||||
|
label: "No",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "e2-4",
|
||||||
|
source: "2",
|
||||||
|
target: "4",
|
||||||
|
sourceHandle: "right",
|
||||||
|
label: "Yes",
|
||||||
|
},
|
||||||
|
{ id: "e3-2", source: "3", target: "2", targetHandle: "left" },
|
||||||
|
{
|
||||||
|
id: "e2-5",
|
||||||
|
source: "2",
|
||||||
|
target: "5",
|
||||||
|
sourceHandle: "bottom",
|
||||||
|
targetHandle: "top",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
database: {
|
||||||
|
nodes: [
|
||||||
|
{
|
||||||
|
id: "users",
|
||||||
|
type: "database",
|
||||||
|
position: { x: 100, y: 100 },
|
||||||
|
data: { label: "Users Table" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "orders",
|
||||||
|
type: "database",
|
||||||
|
position: { x: 400, y: 100 },
|
||||||
|
data: { label: "Orders Table" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "process",
|
||||||
|
type: "process",
|
||||||
|
position: { x: 250, y: 250 },
|
||||||
|
data: { label: "Order Processor" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
edges: [
|
||||||
|
{ id: "eu-p", source: "users", target: "process", label: "Read" },
|
||||||
|
{ id: "ep-o", source: "process", target: "orders", label: "Write" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const DiagramEditor = () => {
|
||||||
|
const [graphData, setGraphData] = useState(DIAGRAM_TEMPLATES.flowchart);
|
||||||
|
const [code, setCode] = useState(
|
||||||
|
JSON.stringify(DIAGRAM_TEMPLATES.flowchart, null, 2),
|
||||||
|
);
|
||||||
|
|
||||||
|
const [activeTab, setActiveTab] = useState("create");
|
||||||
|
const [viewMode, setViewMode] = useState(() =>
|
||||||
|
window.innerWidth < 1024 ? "editor" : "split",
|
||||||
|
);
|
||||||
|
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
const [fetchUrl, setFetchUrl] = useState("");
|
||||||
|
const [fetching, setFetching] = useState(false);
|
||||||
|
const [selectedTemplate, setSelectedTemplate] = useState("flowchart");
|
||||||
|
|
||||||
|
const reactFlowWrapper = useRef(null);
|
||||||
|
const fileInputRef = useRef(null);
|
||||||
|
|
||||||
|
// Sync JSON text input to Graph data
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(code);
|
||||||
|
if (parsed.nodes && parsed.edges) {
|
||||||
|
setGraphData(parsed);
|
||||||
|
setError(null);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
}
|
||||||
|
}, [code]);
|
||||||
|
|
||||||
|
const handleGraphChange = (newGraphData) => {
|
||||||
|
setGraphData(newGraphData);
|
||||||
|
setCode(JSON.stringify(newGraphData, null, 2));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Input Handlers
|
||||||
|
const handleTemplateChange = (e) => {
|
||||||
|
const template = e.target.value;
|
||||||
|
setSelectedTemplate(template);
|
||||||
|
if (DIAGRAM_TEMPLATES[template]) {
|
||||||
|
setGraphData(DIAGRAM_TEMPLATES[template]);
|
||||||
|
setCode(JSON.stringify(DIAGRAM_TEMPLATES[template], null, 2));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFileUpload = (e) => {
|
||||||
|
const file = e.target.files[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (e) => {
|
||||||
|
setCode(e.target.result);
|
||||||
|
setActiveTab("create");
|
||||||
|
};
|
||||||
|
reader.onerror = () => setError("Failed to read file");
|
||||||
|
reader.readAsText(file);
|
||||||
|
e.target.value = ""; // Reset input
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFetchFromURL = async () => {
|
||||||
|
if (!fetchUrl.trim()) return;
|
||||||
|
setFetching(true);
|
||||||
|
try {
|
||||||
|
let url = fetchUrl.trim();
|
||||||
|
if (
|
||||||
|
url.includes("github.com") &&
|
||||||
|
!url.includes("raw.githubusercontent.com")
|
||||||
|
) {
|
||||||
|
url = url
|
||||||
|
.replace("github.com", "raw.githubusercontent.com")
|
||||||
|
.replace("/blob/", "/");
|
||||||
|
}
|
||||||
|
const response = await fetch(url);
|
||||||
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||||
|
const text = await response.text();
|
||||||
|
setCode(text);
|
||||||
|
setActiveTab("create");
|
||||||
|
} catch (err) {
|
||||||
|
setError(`Fetch failed: ${err.message}`);
|
||||||
|
} finally {
|
||||||
|
setFetching(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Export Handlers (Updated for ReactFlow)
|
||||||
|
const downloadFile = (content, filename, mimeType) => {
|
||||||
|
const blob = new Blob([content], { type: mimeType });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.download = filename;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
};
|
||||||
|
|
||||||
|
const exportJSON = () => {
|
||||||
|
downloadFile(code, "diagram.json", "application/json");
|
||||||
|
};
|
||||||
|
|
||||||
|
const exportMermaid = () => {
|
||||||
|
const mermaidCode = generateMermaidFromGraph(
|
||||||
|
graphData.nodes,
|
||||||
|
graphData.edges,
|
||||||
|
);
|
||||||
|
downloadFile(mermaidCode, "diagram.mmd", "text/plain");
|
||||||
|
};
|
||||||
|
|
||||||
|
const copyMermaid = () => {
|
||||||
|
const mermaidCode = generateMermaidFromGraph(
|
||||||
|
graphData.nodes,
|
||||||
|
graphData.edges,
|
||||||
|
);
|
||||||
|
navigator.clipboard
|
||||||
|
.writeText(mermaidCode)
|
||||||
|
.then(() => {
|
||||||
|
// Could add toast notification here
|
||||||
|
console.log("Mermaid code copied!");
|
||||||
|
})
|
||||||
|
.catch((err) => console.error("Failed to copy", err));
|
||||||
|
};
|
||||||
|
|
||||||
|
const exportPNG = () => {
|
||||||
|
const flowElement = document.querySelector(".react-flow");
|
||||||
|
if (!flowElement) return;
|
||||||
|
|
||||||
|
toPng(flowElement, {
|
||||||
|
backgroundColor: "#ffffff",
|
||||||
|
width: flowElement.offsetWidth,
|
||||||
|
height: flowElement.offsetHeight,
|
||||||
|
style: {
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
transform: "translate(0, 0)",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.then((dataUrl) => {
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.setAttribute("download", "diagram.png");
|
||||||
|
a.setAttribute("href", dataUrl);
|
||||||
|
a.click();
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
setError("Failed to export PNG: " + error.message);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SEO
|
||||||
|
title="Mermaid Diagram Editor & Viewer"
|
||||||
|
description="Write Mermaid.js diagram code with live visual preview. Pan, zoom, and export your flowcharts, sequence diagrams, and architecture maps to PNG or SVG."
|
||||||
|
keywords="mermaid editor, diagram editor, diagram as code, mermaidjs, flowchart generator, sequence diagram, architecture diagram, export svg"
|
||||||
|
path="/diagram-editor"
|
||||||
|
toolId="diagram-editor"
|
||||||
|
/>
|
||||||
|
<ToolLayout
|
||||||
|
title="Diagram Tool"
|
||||||
|
description="Create diagrams as code using Mermaid.js with live preview and multi-format export."
|
||||||
|
icon={GitGraph}
|
||||||
|
>
|
||||||
|
{/* Input Section - Always visible */}
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden mb-6">
|
||||||
|
<div className="p-4 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||||
|
Get Started
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tab Navigation */}
|
||||||
|
<div className="flex border-b border-gray-200 dark:border-gray-700 overflow-x-auto scrollbar-hide">
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab("create")}
|
||||||
|
className={`flex items-center gap-1 sm:gap-2 px-3 sm:px-4 py-3 text-sm font-medium transition-colors whitespace-nowrap ${
|
||||||
|
activeTab === "create"
|
||||||
|
? "bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300 border-b-2 border-blue-500"
|
||||||
|
: "text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-200"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4 flex-shrink-0" />
|
||||||
|
Create New
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab("url")}
|
||||||
|
className={`flex items-center gap-1 sm:gap-2 px-3 sm:px-4 py-3 text-sm font-medium transition-colors whitespace-nowrap ${
|
||||||
|
activeTab === "url"
|
||||||
|
? "bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300 border-b-2 border-blue-500"
|
||||||
|
: "text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-200"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Globe className="h-4 w-4 flex-shrink-0" />
|
||||||
|
URL Fetch
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab("paste")}
|
||||||
|
className={`flex items-center gap-1 sm:gap-2 px-3 sm:px-4 py-3 text-sm font-medium transition-colors whitespace-nowrap ${
|
||||||
|
activeTab === "paste"
|
||||||
|
? "bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300 border-b-2 border-blue-500"
|
||||||
|
: "text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-200"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<FileText className="h-4 w-4 flex-shrink-0" />
|
||||||
|
Paste
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab("open")}
|
||||||
|
className={`flex items-center gap-1 sm:gap-2 px-3 sm:px-4 py-3 text-sm font-medium transition-colors whitespace-nowrap ${
|
||||||
|
activeTab === "open"
|
||||||
|
? "bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300 border-b-2 border-blue-500"
|
||||||
|
: "text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-200"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Upload className="h-4 w-4 flex-shrink-0" />
|
||||||
|
Open File
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4">
|
||||||
|
{activeTab === "create" && (
|
||||||
|
<div className="flex flex-col sm:flex-row gap-4 items-start sm:items-center">
|
||||||
|
<div className="flex-1">
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
Load Boilerplate Template
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={selectedTemplate}
|
||||||
|
onChange={handleTemplateChange}
|
||||||
|
className="tool-input w-full max-w-xs"
|
||||||
|
>
|
||||||
|
<option value="flowchart">Flowchart</option>
|
||||||
|
<option value="database">Database/ER</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setGraphData({ nodes: [], edges: [] });
|
||||||
|
setCode(JSON.stringify({ nodes: [], edges: [] }, null, 2));
|
||||||
|
}}
|
||||||
|
className="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-md text-sm font-medium hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
|
||||||
|
>
|
||||||
|
Clear Editor
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === "url" && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
value={fetchUrl}
|
||||||
|
onChange={(e) => setFetchUrl(e.target.value)}
|
||||||
|
onKeyPress={(e) =>
|
||||||
|
e.key === "Enter" && !fetching && handleFetchFromURL()
|
||||||
|
}
|
||||||
|
placeholder="https://raw.githubusercontent.com/.../diagram.mmd"
|
||||||
|
className="tool-input flex-1"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={handleFetchFromURL}
|
||||||
|
disabled={fetching || !fetchUrl.trim()}
|
||||||
|
className="bg-blue-600 hover:bg-blue-700 disabled:bg-gray-400 text-white font-medium px-4 py-2 rounded-md transition-colors whitespace-nowrap"
|
||||||
|
>
|
||||||
|
{fetching ? "Fetching..." : "Fetch"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === "paste" && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<textarea
|
||||||
|
value={code}
|
||||||
|
onChange={(e) => setCode(e.target.value)}
|
||||||
|
placeholder="Paste your diagram JSON here..."
|
||||||
|
className="tool-input h-32 flex-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === "open" && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept=".mmd,.mermaid,.txt"
|
||||||
|
onChange={handleFileUpload}
|
||||||
|
className="tool-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main Editor Section */}
|
||||||
|
<div
|
||||||
|
className={`bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden min-w-0 w-full max-w-full ${isFullscreen ? "fixed inset-0 z-[9999] flex flex-col !mt-0" : "mb-6"}`}
|
||||||
|
>
|
||||||
|
{/* Header & Controls */}
|
||||||
|
<div className="px-4 py-3 border-b border-gray-200 dark:border-gray-700 sticky top-0 bg-white dark:bg-gray-800 z-10 flex flex-wrap justify-between items-center gap-3">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white flex items-center gap-2">
|
||||||
|
<GitGraph className="h-5 w-5 text-blue-600 dark:text-blue-400" />
|
||||||
|
Diagram Editor
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="flex border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden bg-white dark:bg-gray-800 shadow-sm">
|
||||||
|
<button
|
||||||
|
onClick={() => setViewMode("editor")}
|
||||||
|
className={`flex items-center gap-2 px-3 py-1.5 text-sm font-medium transition-colors ${viewMode === "editor" ? "bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300" : "text-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700"}`}
|
||||||
|
>
|
||||||
|
<Type className="h-4 w-4" />{" "}
|
||||||
|
<span className="hidden sm:inline">Code</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setViewMode("split")}
|
||||||
|
className={`hidden lg:flex items-center gap-2 px-3 py-1.5 text-sm font-medium transition-colors border-l border-gray-200 dark:border-gray-700 ${viewMode === "split" ? "bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300" : "text-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700"}`}
|
||||||
|
>
|
||||||
|
<Columns className="h-4 w-4" />{" "}
|
||||||
|
<span className="hidden sm:inline">Split</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setViewMode("preview")}
|
||||||
|
className={`flex items-center gap-2 px-3 py-1.5 text-sm font-medium transition-colors border-l border-gray-200 dark:border-gray-700 ${viewMode === "preview" ? "bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300" : "text-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700"}`}
|
||||||
|
>
|
||||||
|
<Eye className="h-4 w-4" />{" "}
|
||||||
|
<span className="hidden sm:inline">Preview</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setIsFullscreen(!isFullscreen)}
|
||||||
|
className="p-2 text-gray-600 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
|
||||||
|
title={isFullscreen ? "Exit Fullscreen" : "Fullscreen"}
|
||||||
|
>
|
||||||
|
{isFullscreen ? (
|
||||||
|
<Minimize2 className="h-4 w-4" />
|
||||||
|
) : (
|
||||||
|
<Maximize2 className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Split View Content */}
|
||||||
|
<div
|
||||||
|
className={`${viewMode === "split" ? "grid grid-cols-1 lg:grid-cols-2" : ""} overflow-hidden min-w-0 w-full flex-1 relative`}
|
||||||
|
>
|
||||||
|
{isFullscreen && <FullscreenAdBanner />}
|
||||||
|
{/* Editor Pane */}
|
||||||
|
{(viewMode === "editor" || viewMode === "split") && (
|
||||||
|
<div
|
||||||
|
className={`${viewMode === "split" ? "border-r border-gray-200 dark:border-gray-700" : ""} ${isFullscreen ? "h-[calc(100vh-60px)] pb-[90px]" : "h-[600px]"} w-full min-w-0 flex flex-col relative`}
|
||||||
|
>
|
||||||
|
<CodeMirrorEditor
|
||||||
|
value={code}
|
||||||
|
onChange={setCode}
|
||||||
|
language="json"
|
||||||
|
placeholder="Write your diagram JSON here..."
|
||||||
|
showToggle={false}
|
||||||
|
maxLines={999}
|
||||||
|
height="100%"
|
||||||
|
className="flex-1 h-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Preview Pane */}
|
||||||
|
{(viewMode === "preview" || viewMode === "split") && (
|
||||||
|
<div
|
||||||
|
className={`${isFullscreen ? "h-[calc(100vh-60px)] pb-[90px]" : "h-[600px]"} w-full min-w-0 bg-slate-50 dark:bg-slate-900 flex flex-col relative`}
|
||||||
|
>
|
||||||
|
{error && (
|
||||||
|
<div className="absolute inset-0 flex flex-col items-center justify-center p-6 text-center z-10 bg-white/80 dark:bg-gray-900/80 backdrop-blur-sm">
|
||||||
|
<div className="p-4 bg-red-50 dark:bg-red-900/20 rounded-full mb-3">
|
||||||
|
<AlertTriangle className="h-8 w-8 text-red-500" />
|
||||||
|
</div>
|
||||||
|
<h4 className="text-lg font-medium text-gray-900 dark:text-white mb-2">
|
||||||
|
Syntax Error
|
||||||
|
</h4>
|
||||||
|
<p className="text-sm text-red-600 dark:text-red-400 max-w-md">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="flex-1 relative overflow-hidden"
|
||||||
|
ref={reactFlowWrapper}
|
||||||
|
>
|
||||||
|
<ReactFlowEditor
|
||||||
|
initialNodes={graphData.nodes}
|
||||||
|
initialEdges={graphData.edges}
|
||||||
|
onGraphChange={handleGraphChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Export Section */}
|
||||||
|
{code.trim() && (
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden mt-6">
|
||||||
|
<div className="px-4 py-3 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white flex items-center gap-2">
|
||||||
|
<Download className="h-5 w-5 text-blue-600 dark:text-blue-400" />
|
||||||
|
Export Diagram
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 sm:p-6 grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
|
<button
|
||||||
|
onClick={exportPNG}
|
||||||
|
className="flex flex-col items-center p-4 border border-gray-200 dark:border-gray-700 rounded-lg hover:border-blue-500 hover:bg-blue-50 dark:hover:bg-blue-900/20 transition-all group"
|
||||||
|
>
|
||||||
|
<FileImage className="h-6 w-6 text-gray-500 group-hover:text-blue-500 mb-2" />
|
||||||
|
<span className="font-medium text-gray-900 dark:text-white">
|
||||||
|
PNG Image
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-gray-500">
|
||||||
|
High-res rendering
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={exportJSON}
|
||||||
|
className="flex flex-col items-center p-4 border border-gray-200 dark:border-gray-700 rounded-lg hover:border-purple-500 hover:bg-purple-50 dark:hover:bg-purple-900/20 transition-all group"
|
||||||
|
>
|
||||||
|
<FileCode2 className="h-6 w-6 text-gray-500 group-hover:text-purple-500 mb-2" />
|
||||||
|
<span className="font-medium text-gray-900 dark:text-white">
|
||||||
|
JSON Schema
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-gray-500">.json data format</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={exportMermaid}
|
||||||
|
className="flex flex-col items-center p-4 border border-gray-200 dark:border-gray-700 rounded-lg hover:border-green-500 hover:bg-green-50 dark:hover:bg-green-900/20 transition-all group"
|
||||||
|
>
|
||||||
|
<FileCode2 className="h-6 w-6 text-gray-500 group-hover:text-green-500 mb-2" />
|
||||||
|
<span className="font-medium text-gray-900 dark:text-white">
|
||||||
|
Mermaid File
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-gray-500">.mmd raw code</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={copyMermaid}
|
||||||
|
className="flex flex-col items-center p-4 border border-gray-200 dark:border-gray-700 rounded-lg hover:border-orange-500 hover:bg-orange-50 dark:hover:bg-orange-900/20 transition-all group"
|
||||||
|
>
|
||||||
|
<Copy className="h-6 w-6 text-gray-500 group-hover:text-orange-500 mb-2" />
|
||||||
|
<span className="font-medium text-gray-900 dark:text-white">
|
||||||
|
Copy Mermaid
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-gray-500">To clipboard</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<RelatedTools toolId="diagram-editor" />
|
||||||
|
</ToolLayout>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DiagramEditor;
|
||||||
12
src/pages/DiffTool.js
Normal file → Executable file
@@ -142,7 +142,7 @@ const user = {
|
|||||||
className={`px-4 py-2 rounded-md font-medium transition-colors ${
|
className={`px-4 py-2 rounded-md font-medium transition-colors ${
|
||||||
diffMode === 'unified'
|
diffMode === 'unified'
|
||||||
? 'bg-white dark:bg-gray-700 text-primary-600 shadow-sm'
|
? 'bg-white dark:bg-gray-700 text-primary-600 shadow-sm'
|
||||||
: 'text-gray-600 dark:text-gray-400'
|
: 'text-gray-600 dark:text-gray-600'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
Unified Diff
|
Unified Diff
|
||||||
@@ -152,7 +152,7 @@ const user = {
|
|||||||
className={`px-4 py-2 rounded-md font-medium transition-colors ${
|
className={`px-4 py-2 rounded-md font-medium transition-colors ${
|
||||||
diffMode === 'split'
|
diffMode === 'split'
|
||||||
? 'bg-white dark:bg-gray-700 text-primary-600 shadow-sm'
|
? 'bg-white dark:bg-gray-700 text-primary-600 shadow-sm'
|
||||||
: 'text-gray-600 dark:text-gray-400'
|
: 'text-gray-600 dark:text-gray-600'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
Side by Side
|
Side by Side
|
||||||
@@ -267,7 +267,7 @@ const user = {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex items-center justify-center h-32 bg-gray-50 dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg">
|
<div className="flex items-center justify-center h-32 bg-gray-50 dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg">
|
||||||
<p className="text-gray-500 dark:text-gray-400">Enter text in both fields to see the comparison</p>
|
<p className="text-gray-600 dark:text-gray-600">Enter text in both fields to see the comparison</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -279,15 +279,15 @@ const user = {
|
|||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 text-sm">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 text-sm">
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<span className="font-mono text-red-600">- line</span>
|
<span className="font-mono text-red-600">- line</span>
|
||||||
<span className="text-gray-600 dark:text-gray-400">Removed from Text A</span>
|
<span className="text-gray-600 dark:text-gray-600">Removed from Text A</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<span className="font-mono text-green-600">+ line</span>
|
<span className="font-mono text-green-600">+ line</span>
|
||||||
<span className="text-gray-600 dark:text-gray-400">Added in Text B</span>
|
<span className="text-gray-600 dark:text-gray-600">Added in Text B</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<span className="font-mono text-gray-600"> line</span>
|
<span className="font-mono text-gray-600"> line</span>
|
||||||
<span className="text-gray-600 dark:text-gray-400">Unchanged</span>
|
<span className="text-gray-600 dark:text-gray-600">Unchanged</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
10
src/pages/Home.js
Normal file → Executable file
@@ -58,7 +58,7 @@ const Home = () => {
|
|||||||
{SITE_CONFIG.subtitle}
|
{SITE_CONFIG.subtitle}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p className="text-lg text-slate-500 dark:text-slate-400 mb-12 max-w-2xl mx-auto">
|
<p className="text-lg text-slate-600 dark:text-slate-600 mb-12 max-w-2xl mx-auto">
|
||||||
{SITE_CONFIG.slogan} • {SITE_CONFIG.description}
|
{SITE_CONFIG.slogan} • {SITE_CONFIG.description}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
@@ -66,7 +66,7 @@ const Home = () => {
|
|||||||
<div className="relative max-w-lg mx-auto mb-8">
|
<div className="relative max-w-lg mx-auto mb-8">
|
||||||
<div className="absolute inset-0 bg-gradient-to-r from-blue-500 to-purple-500 rounded-2xl blur opacity-20"></div>
|
<div className="absolute inset-0 bg-gradient-to-r from-blue-500 to-purple-500 rounded-2xl blur opacity-20"></div>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Search className="absolute left-4 top-1/2 transform -translate-y-1/2 h-5 w-5 text-slate-400" />
|
<Search className="absolute left-4 top-1/2 transform -translate-y-1/2 h-5 w-5 text-slate-600" />
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Search tools..."
|
placeholder="Search tools..."
|
||||||
@@ -78,7 +78,7 @@ const Home = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Stats */}
|
{/* Stats */}
|
||||||
<div className="flex flex-col sm:flex-row justify-center items-center gap-4 sm:gap-8 text-sm text-slate-500 dark:text-slate-400 mb-8">
|
<div className="flex flex-col sm:flex-row justify-center items-center gap-4 sm:gap-8 text-sm text-slate-600 dark:text-slate-600 mb-8">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="w-2 h-2 bg-green-500 rounded-full animate-pulse"></div>
|
<div className="w-2 h-2 bg-green-500 rounded-full animate-pulse"></div>
|
||||||
<span>{SITE_CONFIG.totalTools} Tools Available</span>
|
<span>{SITE_CONFIG.totalTools} Tools Available</span>
|
||||||
@@ -151,10 +151,10 @@ const Home = () => {
|
|||||||
{filteredTools.length === 0 && (
|
{filteredTools.length === 0 && (
|
||||||
<div className="text-center py-20">
|
<div className="text-center py-20">
|
||||||
<div className="text-6xl mb-4">🔍</div>
|
<div className="text-6xl mb-4">🔍</div>
|
||||||
<p className="text-slate-500 dark:text-slate-400 text-xl mb-2">
|
<p className="text-slate-600 dark:text-slate-600 text-xl mb-2">
|
||||||
No tools found matching "{searchTerm}"
|
No tools found matching "{searchTerm}"
|
||||||
</p>
|
</p>
|
||||||
<p className="text-slate-400 dark:text-slate-500">
|
<p className="text-slate-600 dark:text-slate-600">
|
||||||
Try searching for "editor", "encode", or "format"
|
Try searching for "editor", "encode", or "format"
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
114
src/pages/InvoiceEditor.js
Normal file → Executable file
@@ -777,7 +777,7 @@ const InvoiceEditor = () => {
|
|||||||
className={`${
|
className={`${
|
||||||
activeTab === tab.id
|
activeTab === tab.id
|
||||||
? 'border-blue-500 text-blue-600 dark:text-blue-400'
|
? 'border-blue-500 text-blue-600 dark:text-blue-400'
|
||||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300'
|
: 'border-transparent text-gray-600 hover:text-gray-700 hover:border-gray-300 dark:text-gray-600 dark:hover:text-gray-300'
|
||||||
} whitespace-nowrap py-4 px-1 sm:px-2 border-b-2 font-medium text-sm flex items-center gap-1 sm:gap-2 transition-colors min-w-0 flex-shrink-0`}
|
} whitespace-nowrap py-4 px-1 sm:px-2 border-b-2 font-medium text-sm flex items-center gap-1 sm:gap-2 transition-colors min-w-0 flex-shrink-0`}
|
||||||
>
|
>
|
||||||
<Icon className="h-4 w-4" />
|
<Icon className="h-4 w-4" />
|
||||||
@@ -793,11 +793,11 @@ const InvoiceEditor = () => {
|
|||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
{activeTab === 'create' && (
|
{activeTab === 'create' && (
|
||||||
<div className="text-center py-12">
|
<div className="text-center py-12">
|
||||||
<FileText className="mx-auto h-12 w-12 text-gray-400 dark:text-gray-500 mb-4" />
|
<FileText className="mx-auto h-12 w-12 text-gray-600 dark:text-gray-600 mb-4" />
|
||||||
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-2">
|
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-2">
|
||||||
Start Building Your Invoice
|
Start Building Your Invoice
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-gray-500 dark:text-gray-400 mb-8 max-w-md mx-auto">
|
<p className="text-gray-600 dark:text-gray-600 mb-8 max-w-md mx-auto">
|
||||||
Choose how you'd like to begin creating your professional invoice
|
Choose how you'd like to begin creating your professional invoice
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
@@ -813,11 +813,11 @@ const InvoiceEditor = () => {
|
|||||||
}}
|
}}
|
||||||
className="flex flex-col items-center p-6 border-2 border-dashed border-gray-300 dark:border-gray-600 rounded-lg hover:border-blue-500 dark:hover:border-blue-400 hover:bg-blue-50 dark:hover:bg-blue-900/20 transition-colors group"
|
className="flex flex-col items-center p-6 border-2 border-dashed border-gray-300 dark:border-gray-600 rounded-lg hover:border-blue-500 dark:hover:border-blue-400 hover:bg-blue-50 dark:hover:bg-blue-900/20 transition-colors group"
|
||||||
>
|
>
|
||||||
<Plus className="h-8 w-8 text-gray-400 group-hover:text-blue-500 dark:group-hover:text-blue-400 mb-2" />
|
<Plus className="h-8 w-8 text-gray-600 group-hover:text-blue-500 dark:group-hover:text-blue-400 mb-2" />
|
||||||
<span className="font-medium text-gray-900 dark:text-gray-100 group-hover:text-blue-600 dark:group-hover:text-blue-400">
|
<span className="font-medium text-gray-900 dark:text-gray-100 group-hover:text-blue-600 dark:group-hover:text-blue-400">
|
||||||
Start Empty
|
Start Empty
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-gray-500 dark:text-gray-400 text-center mt-1">
|
<span className="text-xs text-gray-600 dark:text-gray-600 text-center mt-1">
|
||||||
Create a blank invoice
|
Create a blank invoice
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -833,11 +833,11 @@ const InvoiceEditor = () => {
|
|||||||
}}
|
}}
|
||||||
className="flex flex-col items-center p-6 border-2 border-dashed border-gray-300 dark:border-gray-600 rounded-lg hover:border-green-500 dark:hover:border-green-400 hover:bg-green-50 dark:hover:bg-green-900/20 transition-colors group"
|
className="flex flex-col items-center p-6 border-2 border-dashed border-gray-300 dark:border-gray-600 rounded-lg hover:border-green-500 dark:hover:border-green-400 hover:bg-green-50 dark:hover:bg-green-900/20 transition-colors group"
|
||||||
>
|
>
|
||||||
<FileText className="h-8 w-8 text-gray-400 group-hover:text-green-500 dark:group-hover:text-green-400 mb-2" />
|
<FileText className="h-8 w-8 text-gray-600 group-hover:text-green-500 dark:group-hover:text-green-400 mb-2" />
|
||||||
<span className="font-medium text-gray-900 dark:text-gray-100 group-hover:text-green-600 dark:group-hover:text-green-400">
|
<span className="font-medium text-gray-900 dark:text-gray-100 group-hover:text-green-600 dark:group-hover:text-green-400">
|
||||||
Load Sample
|
Load Sample
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-gray-500 dark:text-gray-400 text-center mt-1">
|
<span className="text-xs text-gray-600 dark:text-gray-600 text-center mt-1">
|
||||||
Start with example invoice
|
Start with example invoice
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -866,7 +866,7 @@ const InvoiceEditor = () => {
|
|||||||
{isLoading ? 'Fetching...' : 'Fetch Data'}
|
{isLoading ? 'Fetching...' : 'Fetch Data'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
<p className="text-xs text-gray-600 dark:text-gray-600">
|
||||||
Enter any URL that returns exported JSON data from your previous invoice work.
|
Enter any URL that returns exported JSON data from your previous invoice work.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -908,7 +908,7 @@ const InvoiceEditor = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="flex items-center justify-between flex-shrink-0">
|
<div className="flex items-center justify-between flex-shrink-0">
|
||||||
<div className="text-sm text-gray-600 dark:text-gray-400">
|
<div className="text-sm text-gray-600 dark:text-gray-600">
|
||||||
Supports JSON invoice templates
|
Supports JSON invoice templates
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@@ -985,9 +985,9 @@ const InvoiceEditor = () => {
|
|||||||
<div className="p-4 sm:p-6 space-y-4 sm:space-y-6">
|
<div className="p-4 sm:p-6 space-y-4 sm:space-y-6">
|
||||||
{!createNewCompleted ? (
|
{!createNewCompleted ? (
|
||||||
<div className="text-center py-12">
|
<div className="text-center py-12">
|
||||||
<FileText className="mx-auto h-12 w-12 text-gray-400 mb-4" />
|
<FileText className="mx-auto h-12 w-12 text-gray-600 mb-4" />
|
||||||
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-2">No Invoice Data Loaded</h3>
|
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-2">No Invoice Data Loaded</h3>
|
||||||
<p className="text-gray-500 dark:text-gray-400">
|
<p className="text-gray-600 dark:text-gray-600">
|
||||||
Use the input section above to create a new invoice or load existing data.
|
Use the input section above to create a new invoice or load existing data.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -1080,7 +1080,7 @@ const InvoiceEditor = () => {
|
|||||||
'--tw-ring-color': `${invoiceData.settings?.colorScheme || '#3B82F6'}40`
|
'--tw-ring-color': `${invoiceData.settings?.colorScheme || '#3B82F6'}40`
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<span className="text-xs text-gray-500 dark:text-gray-400">Show in preview</span>
|
<span className="text-xs text-gray-600 dark:text-gray-600">Show in preview</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1121,7 +1121,7 @@ const InvoiceEditor = () => {
|
|||||||
className="flex-1 text-sm text-gray-600 dark:text-gray-300 bg-transparent border-0 border-b border-gray-200 dark:border-gray-600 focus:border-blue-500 focus:outline-none focus:ring-0 pb-1 disabled:cursor-not-allowed"
|
className="flex-1 text-sm text-gray-600 dark:text-gray-300 bg-transparent border-0 border-b border-gray-200 dark:border-gray-600 focus:border-blue-500 focus:outline-none focus:ring-0 pb-1 disabled:cursor-not-allowed"
|
||||||
placeholder="Phone"
|
placeholder="Phone"
|
||||||
/>
|
/>
|
||||||
<span className="text-gray-400 text-sm self-end pb-1 hidden sm:block">|</span>
|
<span className="text-gray-600 text-sm self-end pb-1 hidden sm:block">|</span>
|
||||||
<input
|
<input
|
||||||
type="email"
|
type="email"
|
||||||
value={invoiceData.company.email}
|
value={invoiceData.company.email}
|
||||||
@@ -1173,7 +1173,7 @@ const InvoiceEditor = () => {
|
|||||||
className="flex-1 text-sm text-gray-600 dark:text-gray-300 bg-transparent border-0 border-b border-gray-200 dark:border-gray-600 focus:border-green-500 focus:outline-none focus:ring-0 pb-1"
|
className="flex-1 text-sm text-gray-600 dark:text-gray-300 bg-transparent border-0 border-b border-gray-200 dark:border-gray-600 focus:border-green-500 focus:outline-none focus:ring-0 pb-1"
|
||||||
placeholder="Phone"
|
placeholder="Phone"
|
||||||
/>
|
/>
|
||||||
<span className="text-gray-400 text-sm self-end pb-1 hidden sm:block">|</span>
|
<span className="text-gray-600 text-sm self-end pb-1 hidden sm:block">|</span>
|
||||||
<input
|
<input
|
||||||
type="email"
|
type="email"
|
||||||
value={invoiceData.client.email}
|
value={invoiceData.client.email}
|
||||||
@@ -1233,7 +1233,7 @@ const InvoiceEditor = () => {
|
|||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-3 text-center" style={{ width: 'auto' }}>
|
<td className="px-3 py-3 text-center" style={{ width: 'auto' }}>
|
||||||
<div className="relative flex justify-end items-center">
|
<div className="relative flex justify-end items-center">
|
||||||
<span className="text-gray-500 dark:text-gray-400 px-2 py-1 text-xs rounded-1 bg-gray-100 dark:bg-gray-900/20">{invoiceData.settings?.currency?.symbol || '$'}</span>
|
<span className="text-gray-600 dark:text-gray-600 px-2 py-1 text-xs rounded-1 bg-gray-100 dark:bg-gray-900/20">{invoiceData.settings?.currency?.symbol || '$'}</span>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={formatNumber(item.rate)}
|
value={formatNumber(item.rate)}
|
||||||
@@ -1264,7 +1264,7 @@ const InvoiceEditor = () => {
|
|||||||
<button
|
<button
|
||||||
onClick={() => moveItem('items', item.id, 'up')}
|
onClick={() => moveItem('items', item.id, 'up')}
|
||||||
disabled={index === 0}
|
disabled={index === 0}
|
||||||
className="p-1 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700/50 rounded transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
|
className="p-1 text-gray-600 hover:text-gray-700 dark:text-gray-600 dark:hover:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700/50 rounded transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
|
||||||
title="Move up"
|
title="Move up"
|
||||||
>
|
>
|
||||||
<ChevronUp className="h-4 w-4" />
|
<ChevronUp className="h-4 w-4" />
|
||||||
@@ -1272,7 +1272,7 @@ const InvoiceEditor = () => {
|
|||||||
<button
|
<button
|
||||||
onClick={() => moveItem('items', item.id, 'down')}
|
onClick={() => moveItem('items', item.id, 'down')}
|
||||||
disabled={index === invoiceData.items.length - 1}
|
disabled={index === invoiceData.items.length - 1}
|
||||||
className="p-1 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700/50 rounded transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
|
className="p-1 text-gray-600 hover:text-gray-700 dark:text-gray-600 dark:hover:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700/50 rounded transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
|
||||||
title="Move down"
|
title="Move down"
|
||||||
>
|
>
|
||||||
<ChevronDown className="h-4 w-4" />
|
<ChevronDown className="h-4 w-4" />
|
||||||
@@ -1372,7 +1372,7 @@ const InvoiceEditor = () => {
|
|||||||
<button
|
<button
|
||||||
onClick={() => moveItem('fees', fee.id, 'up')}
|
onClick={() => moveItem('fees', fee.id, 'up')}
|
||||||
disabled={index === 0}
|
disabled={index === 0}
|
||||||
className="p-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600 rounded transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
|
className="p-2 text-gray-600 hover:text-gray-700 dark:text-gray-600 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600 rounded transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
|
||||||
title="Move up"
|
title="Move up"
|
||||||
>
|
>
|
||||||
<ChevronUp className="h-4 w-4" />
|
<ChevronUp className="h-4 w-4" />
|
||||||
@@ -1380,7 +1380,7 @@ const InvoiceEditor = () => {
|
|||||||
<button
|
<button
|
||||||
onClick={() => moveItem('fees', fee.id, 'down')}
|
onClick={() => moveItem('fees', fee.id, 'down')}
|
||||||
disabled={index === (invoiceData.fees || []).length - 1}
|
disabled={index === (invoiceData.fees || []).length - 1}
|
||||||
className="p-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600 rounded transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
|
className="p-2 text-gray-600 hover:text-gray-700 dark:text-gray-600 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600 rounded transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
|
||||||
title="Move down"
|
title="Move down"
|
||||||
>
|
>
|
||||||
<ChevronDown className="h-4 w-4" />
|
<ChevronDown className="h-4 w-4" />
|
||||||
@@ -1449,7 +1449,7 @@ const InvoiceEditor = () => {
|
|||||||
<button
|
<button
|
||||||
onClick={() => moveItem('discounts', discount.id, 'up')}
|
onClick={() => moveItem('discounts', discount.id, 'up')}
|
||||||
disabled={index === 0}
|
disabled={index === 0}
|
||||||
className="p-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600 rounded transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
|
className="p-2 text-gray-600 hover:text-gray-700 dark:text-gray-600 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600 rounded transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
|
||||||
title="Move up"
|
title="Move up"
|
||||||
>
|
>
|
||||||
<ChevronUp className="h-4 w-4" />
|
<ChevronUp className="h-4 w-4" />
|
||||||
@@ -1457,7 +1457,7 @@ const InvoiceEditor = () => {
|
|||||||
<button
|
<button
|
||||||
onClick={() => moveItem('discounts', discount.id, 'down')}
|
onClick={() => moveItem('discounts', discount.id, 'down')}
|
||||||
disabled={index === (invoiceData.discounts || []).length - 1}
|
disabled={index === (invoiceData.discounts || []).length - 1}
|
||||||
className="p-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600 rounded transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
|
className="p-2 text-gray-600 hover:text-gray-700 dark:text-gray-600 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600 rounded transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
|
||||||
title="Move down"
|
title="Move down"
|
||||||
>
|
>
|
||||||
<ChevronDown className="h-4 w-4" />
|
<ChevronDown className="h-4 w-4" />
|
||||||
@@ -1484,14 +1484,14 @@ const InvoiceEditor = () => {
|
|||||||
</h3>
|
</h3>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="flex justify-between items-center py-2 border-b border-emerald-200 dark:border-emerald-700">
|
<div className="flex justify-between items-center py-2 border-b border-emerald-200 dark:border-emerald-700">
|
||||||
<span className="text-gray-600 dark:text-gray-400">Subtotal:</span>
|
<span className="text-gray-600 dark:text-gray-600">Subtotal:</span>
|
||||||
<span className="font-medium text-gray-900 dark:text-white flex-shrink-0">{formatCurrency(invoiceData.subtotal, true)}</span>
|
<span className="font-medium text-gray-900 dark:text-white flex-shrink-0">{formatCurrency(invoiceData.subtotal, true)}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Dynamic Fees */}
|
{/* Dynamic Fees */}
|
||||||
{(invoiceData.fees || []).map((fee) => (
|
{(invoiceData.fees || []).map((fee) => (
|
||||||
<div key={fee.id} className="flex justify-between items-center py-2 border-b border-emerald-200 dark:border-emerald-700">
|
<div key={fee.id} className="flex justify-between items-center py-2 border-b border-emerald-200 dark:border-emerald-700">
|
||||||
<span className="text-gray-600 dark:text-gray-400">
|
<span className="text-gray-600 dark:text-gray-600">
|
||||||
{fee.label || 'Fee'} {fee.type === 'percentage' ? `(${fee.value}%)` : ''}:
|
{fee.label || 'Fee'} {fee.type === 'percentage' ? `(${fee.value}%)` : ''}:
|
||||||
</span>
|
</span>
|
||||||
<span className="font-medium text-blue-600 dark:text-blue-400 flex-shrink-0">
|
<span className="font-medium text-blue-600 dark:text-blue-400 flex-shrink-0">
|
||||||
@@ -1503,7 +1503,7 @@ const InvoiceEditor = () => {
|
|||||||
{/* Dynamic Discounts */}
|
{/* Dynamic Discounts */}
|
||||||
{(invoiceData.discounts || []).map((discount) => (
|
{(invoiceData.discounts || []).map((discount) => (
|
||||||
<div key={discount.id} className="flex justify-between items-center py-2 border-b border-emerald-200 dark:border-emerald-700">
|
<div key={discount.id} className="flex justify-between items-center py-2 border-b border-emerald-200 dark:border-emerald-700">
|
||||||
<span className="text-gray-600 dark:text-gray-400">
|
<span className="text-gray-600 dark:text-gray-600">
|
||||||
{discount.label || 'Discount'} {discount.type === 'percentage' ? `(${discount.value}%)` : ''}:
|
{discount.label || 'Discount'} {discount.type === 'percentage' ? `(${discount.value}%)` : ''}:
|
||||||
</span>
|
</span>
|
||||||
<span className="font-medium text-red-600 dark:text-red-400 flex-shrink-0">
|
<span className="font-medium text-red-600 dark:text-red-400 flex-shrink-0">
|
||||||
@@ -1515,7 +1515,7 @@ const InvoiceEditor = () => {
|
|||||||
{/* Legacy Discount */}
|
{/* Legacy Discount */}
|
||||||
{invoiceData.discount > 0 && (
|
{invoiceData.discount > 0 && (
|
||||||
<div className="flex justify-between items-center py-2 border-b border-emerald-200 dark:border-emerald-700">
|
<div className="flex justify-between items-center py-2 border-b border-emerald-200 dark:border-emerald-700">
|
||||||
<span className="text-gray-600 dark:text-gray-400">Discount:</span>
|
<span className="text-gray-600 dark:text-gray-600">Discount:</span>
|
||||||
<span className="font-medium text-red-600 dark:text-red-400 flex-shrink-0">-{formatCurrency(invoiceData.discount, true)}</span>
|
<span className="font-medium text-red-600 dark:text-red-400 flex-shrink-0">-{formatCurrency(invoiceData.discount, true)}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -1819,7 +1819,7 @@ const InvoiceEditor = () => {
|
|||||||
<button
|
<button
|
||||||
onClick={() => moveInstallment(installment.id, 'up')}
|
onClick={() => moveInstallment(installment.id, 'up')}
|
||||||
disabled={index === 0}
|
disabled={index === 0}
|
||||||
className="p-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600 rounded transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
|
className="p-2 text-gray-600 hover:text-gray-700 dark:text-gray-600 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600 rounded transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
|
||||||
title="Move up"
|
title="Move up"
|
||||||
>
|
>
|
||||||
<ChevronUp className="h-4 w-4" />
|
<ChevronUp className="h-4 w-4" />
|
||||||
@@ -1827,7 +1827,7 @@ const InvoiceEditor = () => {
|
|||||||
<button
|
<button
|
||||||
onClick={() => moveInstallment(installment.id, 'down')}
|
onClick={() => moveInstallment(installment.id, 'down')}
|
||||||
disabled={index === (invoiceData.paymentTerms?.installments || []).length - 1}
|
disabled={index === (invoiceData.paymentTerms?.installments || []).length - 1}
|
||||||
className="p-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600 rounded transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
|
className="p-2 text-gray-600 hover:text-gray-700 dark:text-gray-600 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600 rounded transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
|
||||||
title="Move down"
|
title="Move down"
|
||||||
>
|
>
|
||||||
<ChevronDown className="h-4 w-4" />
|
<ChevronDown className="h-4 w-4" />
|
||||||
@@ -1956,7 +1956,7 @@ const InvoiceEditor = () => {
|
|||||||
onChange={handleSignatureUpload}
|
onChange={handleSignatureUpload}
|
||||||
className="hidden"
|
className="hidden"
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-gray-500 dark:text-gray-500 mt-1">
|
<p className="text-xs text-gray-600 dark:text-gray-600 mt-1">
|
||||||
Upload an image of your signature (PNG, JPG recommended)
|
Upload an image of your signature (PNG, JPG recommended)
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -2227,7 +2227,7 @@ const InvoiceSettingsModal = ({ invoiceData, currencies, onUpdateSettings, onClo
|
|||||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Invoice Settings</h3>
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Invoice Settings</h3>
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
className="text-gray-600 hover:text-gray-600 dark:hover:text-gray-300"
|
||||||
>
|
>
|
||||||
<svg className="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
@@ -2242,7 +2242,7 @@ const InvoiceSettingsModal = ({ invoiceData, currencies, onUpdateSettings, onClo
|
|||||||
className={`px-6 py-3 text-sm font-medium relative transition-all duration-200 ${
|
className={`px-6 py-3 text-sm font-medium relative transition-all duration-200 ${
|
||||||
activeTab === 'general'
|
activeTab === 'general'
|
||||||
? 'text-blue-600 dark:text-blue-400 bg-blue-50 dark:bg-blue-900/20'
|
? 'text-blue-600 dark:text-blue-400 bg-blue-50 dark:bg-blue-900/20'
|
||||||
: 'text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700/50'
|
: 'text-gray-600 hover:text-gray-700 dark:text-gray-600 dark:hover:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700/50'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
General
|
General
|
||||||
@@ -2255,7 +2255,7 @@ const InvoiceSettingsModal = ({ invoiceData, currencies, onUpdateSettings, onClo
|
|||||||
className={`px-6 py-3 text-sm font-medium relative transition-all duration-200 ${
|
className={`px-6 py-3 text-sm font-medium relative transition-all duration-200 ${
|
||||||
activeTab === 'layout'
|
activeTab === 'layout'
|
||||||
? 'text-blue-600 dark:text-blue-400 bg-blue-50 dark:bg-blue-900/20'
|
? 'text-blue-600 dark:text-blue-400 bg-blue-50 dark:bg-blue-900/20'
|
||||||
: 'text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700/50'
|
: 'text-gray-600 hover:text-gray-700 dark:text-gray-600 dark:hover:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700/50'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
Layout
|
Layout
|
||||||
@@ -2268,7 +2268,7 @@ const InvoiceSettingsModal = ({ invoiceData, currencies, onUpdateSettings, onClo
|
|||||||
className={`px-6 py-3 text-sm font-medium relative transition-all duration-200 ${
|
className={`px-6 py-3 text-sm font-medium relative transition-all duration-200 ${
|
||||||
activeTab === 'payment'
|
activeTab === 'payment'
|
||||||
? 'text-blue-600 dark:text-blue-400 bg-blue-50 dark:bg-blue-900/20'
|
? 'text-blue-600 dark:text-blue-400 bg-blue-50 dark:bg-blue-900/20'
|
||||||
: 'text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700/50'
|
: 'text-gray-600 hover:text-gray-700 dark:text-gray-600 dark:hover:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700/50'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
Payment
|
Payment
|
||||||
@@ -2335,10 +2335,10 @@ const InvoiceSettingsModal = ({ invoiceData, currencies, onUpdateSettings, onClo
|
|||||||
className="w-12 h-12 rounded-lg border border-gray-300 cursor-pointer bg-transparent"
|
className="w-12 h-12 rounded-lg border border-gray-300 cursor-pointer bg-transparent"
|
||||||
/>
|
/>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
<p className="text-sm text-gray-600 dark:text-gray-600">
|
||||||
This color will be used throughout the invoice and PDF
|
This color will be used throughout the invoice and PDF
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-gray-500 dark:text-gray-500 mt-1">
|
<p className="text-xs text-gray-600 dark:text-gray-600 mt-1">
|
||||||
Current: {invoiceData.settings?.colorScheme || '#3B82F6'}
|
Current: {invoiceData.settings?.colorScheme || '#3B82F6'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -2355,7 +2355,7 @@ const InvoiceSettingsModal = ({ invoiceData, currencies, onUpdateSettings, onClo
|
|||||||
selectedCurrency={invoiceData.settings?.currency}
|
selectedCurrency={invoiceData.settings?.currency}
|
||||||
onSelect={(currency) => onUpdateSettings('currency', currency)}
|
onSelect={(currency) => onUpdateSettings('currency', currency)}
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-gray-500 dark:text-gray-500 mt-1">
|
<p className="text-xs text-gray-600 dark:text-gray-600 mt-1">
|
||||||
Selected: {invoiceData.settings?.currency?.symbol || invoiceData.settings?.currency?.code || '$'} ({invoiceData.settings?.currency?.code || 'USD'})
|
Selected: {invoiceData.settings?.currency?.symbol || invoiceData.settings?.currency?.code || '$'} ({invoiceData.settings?.currency?.code || 'USD'})
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -2373,7 +2373,7 @@ const InvoiceSettingsModal = ({ invoiceData, currencies, onUpdateSettings, onClo
|
|||||||
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
Use Thousand Separator
|
Use Thousand Separator
|
||||||
</span>
|
</span>
|
||||||
<p className="text-xs text-gray-500 dark:text-gray-500">
|
<p className="text-xs text-gray-600 dark:text-gray-600">
|
||||||
Format numbers like 1,000.00 instead of 1000.00
|
Format numbers like 1,000.00 instead of 1000.00
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -2395,7 +2395,7 @@ const InvoiceSettingsModal = ({ invoiceData, currencies, onUpdateSettings, onClo
|
|||||||
<option value={2}>2 (1000.00)</option>
|
<option value={2}>2 (1000.00)</option>
|
||||||
<option value={3}>3 (1000.000)</option>
|
<option value={3}>3 (1000.000)</option>
|
||||||
</select>
|
</select>
|
||||||
<p className="text-xs text-gray-500 dark:text-gray-500 mt-1">
|
<p className="text-xs text-gray-600 dark:text-gray-600 mt-1">
|
||||||
Number of decimal places to display
|
Number of decimal places to display
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -2420,7 +2420,7 @@ const InvoiceSettingsModal = ({ invoiceData, currencies, onUpdateSettings, onClo
|
|||||||
<option value="normal">Normal (25px spacing)</option>
|
<option value="normal">Normal (25px spacing)</option>
|
||||||
<option value="spacious">Spacious (40px spacing)</option>
|
<option value="spacious">Spacious (40px spacing)</option>
|
||||||
</select>
|
</select>
|
||||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
<p className="text-xs text-gray-600 dark:text-gray-600 mt-1">
|
||||||
Controls the spacing between major sections for better multi-page layout
|
Controls the spacing between major sections for better multi-page layout
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -2468,7 +2468,7 @@ const InvoiceSettingsModal = ({ invoiceData, currencies, onUpdateSettings, onClo
|
|||||||
<span className="text-sm text-gray-700 dark:text-gray-300">Force page break before Payment Method</span>
|
<span className="text-sm text-gray-700 dark:text-gray-300">Force page break before Payment Method</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-2">
|
<p className="text-xs text-gray-600 dark:text-gray-600 mt-2">
|
||||||
Use page breaks to ensure important sections start on a new page in PDF output
|
Use page breaks to ensure important sections start on a new page in PDF output
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -2493,7 +2493,7 @@ const InvoiceSettingsModal = ({ invoiceData, currencies, onUpdateSettings, onClo
|
|||||||
<option value="link">Payment Link</option>
|
<option value="link">Payment Link</option>
|
||||||
<option value="qr">QR Code</option>
|
<option value="qr">QR Code</option>
|
||||||
</select>
|
</select>
|
||||||
<p className="text-xs text-gray-500 dark:text-gray-500 mt-1">
|
<p className="text-xs text-gray-600 dark:text-gray-600 mt-1">
|
||||||
Choose how payment information appears on your invoice
|
Choose how payment information appears on your invoice
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -2505,7 +2505,7 @@ const InvoiceSettingsModal = ({ invoiceData, currencies, onUpdateSettings, onClo
|
|||||||
<h4 className="text-sm font-medium text-gray-900 dark:text-white mb-3">Bank Details</h4>
|
<h4 className="text-sm font-medium text-gray-900 dark:text-white mb-3">Bank Details</h4>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">
|
<label className="block text-xs font-medium text-gray-600 dark:text-gray-600 mb-1">
|
||||||
Bank Name
|
Bank Name
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -2517,7 +2517,7 @@ const InvoiceSettingsModal = ({ invoiceData, currencies, onUpdateSettings, onClo
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">
|
<label className="block text-xs font-medium text-gray-600 dark:text-gray-600 mb-1">
|
||||||
Account Name
|
Account Name
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -2529,7 +2529,7 @@ const InvoiceSettingsModal = ({ invoiceData, currencies, onUpdateSettings, onClo
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">
|
<label className="block text-xs font-medium text-gray-600 dark:text-gray-600 mb-1">
|
||||||
Account Number
|
Account Number
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -2541,7 +2541,7 @@ const InvoiceSettingsModal = ({ invoiceData, currencies, onUpdateSettings, onClo
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">
|
<label className="block text-xs font-medium text-gray-600 dark:text-gray-600 mb-1">
|
||||||
Routing Number
|
Routing Number
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -2553,7 +2553,7 @@ const InvoiceSettingsModal = ({ invoiceData, currencies, onUpdateSettings, onClo
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">
|
<label className="block text-xs font-medium text-gray-600 dark:text-gray-600 mb-1">
|
||||||
SWIFT Code
|
SWIFT Code
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -2565,7 +2565,7 @@ const InvoiceSettingsModal = ({ invoiceData, currencies, onUpdateSettings, onClo
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">
|
<label className="block text-xs font-medium text-gray-600 dark:text-gray-600 mb-1">
|
||||||
IBAN
|
IBAN
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -2586,7 +2586,7 @@ const InvoiceSettingsModal = ({ invoiceData, currencies, onUpdateSettings, onClo
|
|||||||
<h4 className="text-sm font-medium text-gray-900 dark:text-white mb-3">Payment Link</h4>
|
<h4 className="text-sm font-medium text-gray-900 dark:text-white mb-3">Payment Link</h4>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">
|
<label className="block text-xs font-medium text-gray-600 dark:text-gray-600 mb-1">
|
||||||
Payment URL
|
Payment URL
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -2598,7 +2598,7 @@ const InvoiceSettingsModal = ({ invoiceData, currencies, onUpdateSettings, onClo
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">
|
<label className="block text-xs font-medium text-gray-600 dark:text-gray-600 mb-1">
|
||||||
Button Label
|
Button Label
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -2620,7 +2620,7 @@ const InvoiceSettingsModal = ({ invoiceData, currencies, onUpdateSettings, onClo
|
|||||||
|
|
||||||
{/* QR Code Type Selection */}
|
{/* QR Code Type Selection */}
|
||||||
<div className="mb-3">
|
<div className="mb-3">
|
||||||
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-2">
|
<label className="block text-xs font-medium text-gray-600 dark:text-gray-600 mb-2">
|
||||||
QR Code Type
|
QR Code Type
|
||||||
</label>
|
</label>
|
||||||
<div className="flex gap-4">
|
<div className="flex gap-4">
|
||||||
@@ -2653,7 +2653,7 @@ const InvoiceSettingsModal = ({ invoiceData, currencies, onUpdateSettings, onClo
|
|||||||
{invoiceData.paymentMethod?.qrCode?.customImage === undefined && (
|
{invoiceData.paymentMethod?.qrCode?.customImage === undefined && (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">
|
<label className="block text-xs font-medium text-gray-600 dark:text-gray-600 mb-1">
|
||||||
Payment URL
|
Payment URL
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -2664,7 +2664,7 @@ const InvoiceSettingsModal = ({ invoiceData, currencies, onUpdateSettings, onClo
|
|||||||
placeholder="https://pay.stripe.com/..."
|
placeholder="https://pay.stripe.com/..."
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-gray-500 dark:text-gray-500">
|
<p className="text-xs text-gray-600 dark:text-gray-600">
|
||||||
QR code will be automatically generated from this URL
|
QR code will be automatically generated from this URL
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -2674,7 +2674,7 @@ const InvoiceSettingsModal = ({ invoiceData, currencies, onUpdateSettings, onClo
|
|||||||
{invoiceData.paymentMethod?.qrCode?.customImage !== undefined && (
|
{invoiceData.paymentMethod?.qrCode?.customImage !== undefined && (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">
|
<label className="block text-xs font-medium text-gray-600 dark:text-gray-600 mb-1">
|
||||||
Upload QR Code Image
|
Upload QR Code Image
|
||||||
</label>
|
</label>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
@@ -2724,7 +2724,7 @@ const InvoiceSettingsModal = ({ invoiceData, currencies, onUpdateSettings, onClo
|
|||||||
|
|
||||||
{/* Common QR Code Label */}
|
{/* Common QR Code Label */}
|
||||||
<div className="mt-3">
|
<div className="mt-3">
|
||||||
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">
|
<label className="block text-xs font-medium text-gray-600 dark:text-gray-600 mb-1">
|
||||||
QR Code Label
|
QR Code Label
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -2755,7 +2755,7 @@ const InvoiceSettingsModal = ({ invoiceData, currencies, onUpdateSettings, onClo
|
|||||||
<option value="OVERDUE">OVERDUE</option>
|
<option value="OVERDUE">OVERDUE</option>
|
||||||
<option value="PENDING">PENDING</option>
|
<option value="PENDING">PENDING</option>
|
||||||
</select>
|
</select>
|
||||||
<p className="text-xs text-gray-500 dark:text-gray-500 mt-1">
|
<p className="text-xs text-gray-600 dark:text-gray-600 mt-1">
|
||||||
Add a status stamp to your invoice PDF
|
Add a status stamp to your invoice PDF
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -2772,7 +2772,7 @@ const InvoiceSettingsModal = ({ invoiceData, currencies, onUpdateSettings, onClo
|
|||||||
onChange={(e) => onUpdateSettings('paymentDate', e.target.value)}
|
onChange={(e) => onUpdateSettings('paymentDate', e.target.value)}
|
||||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-white"
|
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-white"
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-gray-500 dark:text-gray-500 mt-1">
|
<p className="text-xs text-gray-600 dark:text-gray-600 mt-1">
|
||||||
Date when payment was received
|
Date when payment was received
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -2836,7 +2836,7 @@ const InputChangeConfirmationModal = ({ invoiceData, currentMethod, newMethod, o
|
|||||||
|
|
||||||
<div className="px-6 py-4">
|
<div className="px-6 py-4">
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
<p className="text-sm text-gray-600 dark:text-gray-600">
|
||||||
You currently have:
|
You currently have:
|
||||||
</p>
|
</p>
|
||||||
<ul className="text-sm text-gray-700 dark:text-gray-300 space-y-1 ml-4">
|
<ul className="text-sm text-gray-700 dark:text-gray-300 space-y-1 ml-4">
|
||||||
@@ -2946,7 +2946,7 @@ const SignaturePadModal = ({ isOpen, onClose, onSave }) => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
|
<p className="text-sm text-gray-600 dark:text-gray-600 mb-4">
|
||||||
Draw your signature in the box above using your mouse or touch device.
|
Draw your signature in the box above using your mouse or touch device.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
|||||||
2
src/pages/InvoicePreview.js
Normal file → Executable file
@@ -335,7 +335,7 @@ const InvoicePreview = () => {
|
|||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<FileText className="h-5 w-5 text-blue-600 dark:text-blue-400" />
|
<FileText className="h-5 w-5 text-blue-600 dark:text-blue-400" />
|
||||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">Invoice Preview</h2>
|
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">Invoice Preview</h2>
|
||||||
<div className="hidden sm:flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400">
|
<div className="hidden sm:flex items-center gap-2 text-sm text-gray-600 dark:text-gray-600">
|
||||||
<span>•</span>
|
<span>•</span>
|
||||||
<span>{pdfPageSize} Format</span>
|
<span>{pdfPageSize} Format</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
2
src/pages/InvoicePreviewMinimal.js
Normal file → Executable file
@@ -217,7 +217,7 @@ const InvoicePreviewMinimal = () => {
|
|||||||
<div className="flex items-center gap-2 text-sm text-gray-600">
|
<div className="flex items-center gap-2 text-sm text-gray-600">
|
||||||
<FileText className="h-4 w-4" />
|
<FileText className="h-4 w-4" />
|
||||||
<span className="font-medium">Minimal Invoice Preview</span>
|
<span className="font-medium">Minimal Invoice Preview</span>
|
||||||
<span className="text-gray-400">•</span>
|
<span className="text-gray-600">•</span>
|
||||||
<span>{pdfPageSize} Format</span>
|
<span>{pdfPageSize} Format</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
1379
src/pages/MarkdownEditor.js
Normal file → Executable file
8
src/pages/NotFound.js
Normal file → Executable file
@@ -26,7 +26,7 @@ const NotFound = () => {
|
|||||||
404
|
404
|
||||||
</h1>
|
</h1>
|
||||||
<div className="mt-4 flex items-center justify-center gap-2">
|
<div className="mt-4 flex items-center justify-center gap-2">
|
||||||
<Search className="h-6 w-6 text-gray-400" />
|
<Search className="h-6 w-6 text-gray-600" />
|
||||||
<p className="text-2xl font-semibold text-gray-700 dark:text-gray-300">
|
<p className="text-2xl font-semibold text-gray-700 dark:text-gray-300">
|
||||||
Page Not Found
|
Page Not Found
|
||||||
</p>
|
</p>
|
||||||
@@ -34,7 +34,7 @@ const NotFound = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Message */}
|
{/* Message */}
|
||||||
<p className="text-lg text-gray-600 dark:text-gray-400 mb-12">
|
<p className="text-lg text-gray-600 dark:text-gray-600 mb-12">
|
||||||
Oops! The page you're looking for doesn't exist. It might have been moved or deleted.
|
Oops! The page you're looking for doesn't exist. It might have been moved or deleted.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
@@ -60,7 +60,7 @@ const NotFound = () => {
|
|||||||
<h3 className="font-semibold text-gray-900 dark:text-white group-hover:text-blue-600 dark:group-hover:text-blue-400 transition-colors">
|
<h3 className="font-semibold text-gray-900 dark:text-white group-hover:text-blue-600 dark:group-hover:text-blue-400 transition-colors">
|
||||||
{tool.name}
|
{tool.name}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
<p className="text-sm text-gray-600 dark:text-gray-600 mt-1">
|
||||||
{tool.desc}
|
{tool.desc}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -81,7 +81,7 @@ const NotFound = () => {
|
|||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
{/* Search Suggestion */}
|
{/* Search Suggestion */}
|
||||||
<p className="mt-8 text-sm text-gray-500 dark:text-gray-400">
|
<p className="mt-8 text-sm text-gray-600 dark:text-gray-600">
|
||||||
Or use the search bar at the top to find what you need
|
Or use the search bar at the top to find what you need
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
40
src/pages/ObjectEditor.js
Normal file → Executable file
@@ -773,7 +773,7 @@ const ObjectEditor = () => {
|
|||||||
className={`flex items-center gap-1 sm:gap-2 px-3 sm:px-4 py-3 text-sm font-medium transition-colors whitespace-nowrap ${
|
className={`flex items-center gap-1 sm:gap-2 px-3 sm:px-4 py-3 text-sm font-medium transition-colors whitespace-nowrap ${
|
||||||
activeTab === 'create'
|
activeTab === 'create'
|
||||||
? 'bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300 border-b-2 border-blue-500'
|
? 'bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300 border-b-2 border-blue-500'
|
||||||
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200'
|
: 'text-gray-600 dark:text-gray-600 hover:text-gray-900 dark:hover:text-gray-200'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Plus className="h-4 w-4 flex-shrink-0" />
|
<Plus className="h-4 w-4 flex-shrink-0" />
|
||||||
@@ -785,7 +785,7 @@ const ObjectEditor = () => {
|
|||||||
className={`flex items-center gap-1 sm:gap-2 px-3 sm:px-4 py-3 text-sm font-medium transition-colors whitespace-nowrap ${
|
className={`flex items-center gap-1 sm:gap-2 px-3 sm:px-4 py-3 text-sm font-medium transition-colors whitespace-nowrap ${
|
||||||
activeTab === 'url'
|
activeTab === 'url'
|
||||||
? 'bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300 border-b-2 border-blue-500'
|
? 'bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300 border-b-2 border-blue-500'
|
||||||
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200'
|
: 'text-gray-600 dark:text-gray-600 hover:text-gray-900 dark:hover:text-gray-200'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Globe className="h-4 w-4 flex-shrink-0" />
|
<Globe className="h-4 w-4 flex-shrink-0" />
|
||||||
@@ -796,7 +796,7 @@ const ObjectEditor = () => {
|
|||||||
className={`flex items-center gap-1 sm:gap-2 px-3 sm:px-4 py-3 text-sm font-medium transition-colors whitespace-nowrap ${
|
className={`flex items-center gap-1 sm:gap-2 px-3 sm:px-4 py-3 text-sm font-medium transition-colors whitespace-nowrap ${
|
||||||
activeTab === 'paste'
|
activeTab === 'paste'
|
||||||
? 'bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300 border-b-2 border-blue-500'
|
? 'bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300 border-b-2 border-blue-500'
|
||||||
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200'
|
: 'text-gray-600 dark:text-gray-600 hover:text-gray-900 dark:hover:text-gray-200'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<FileText className="h-4 w-4 flex-shrink-0" />
|
<FileText className="h-4 w-4 flex-shrink-0" />
|
||||||
@@ -807,7 +807,7 @@ const ObjectEditor = () => {
|
|||||||
className={`flex items-center gap-1 sm:gap-2 px-3 sm:px-4 py-3 text-sm font-medium transition-colors whitespace-nowrap ${
|
className={`flex items-center gap-1 sm:gap-2 px-3 sm:px-4 py-3 text-sm font-medium transition-colors whitespace-nowrap ${
|
||||||
activeTab === 'open'
|
activeTab === 'open'
|
||||||
? 'bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300 border-b-2 border-blue-500'
|
? 'bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300 border-b-2 border-blue-500'
|
||||||
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200'
|
: 'text-gray-600 dark:text-gray-600 hover:text-gray-900 dark:hover:text-gray-200'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Upload className="h-4 w-4 flex-shrink-0" />
|
<Upload className="h-4 w-4 flex-shrink-0" />
|
||||||
@@ -826,7 +826,7 @@ const ObjectEditor = () => {
|
|||||||
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-2">
|
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-2">
|
||||||
Create New Object
|
Create New Object
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
|
<p className="text-sm text-gray-600 dark:text-gray-600 mb-4">
|
||||||
Choose how you'd like to begin working with your data
|
Choose how you'd like to begin working with your data
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -846,11 +846,11 @@ const ObjectEditor = () => {
|
|||||||
}}
|
}}
|
||||||
className="flex flex-col items-center p-6 border-2 border-dashed border-gray-300 dark:border-gray-600 rounded-lg hover:border-blue-500 dark:hover:border-blue-400 hover:bg-blue-50 dark:hover:bg-blue-900/20 transition-colors group"
|
className="flex flex-col items-center p-6 border-2 border-dashed border-gray-300 dark:border-gray-600 rounded-lg hover:border-blue-500 dark:hover:border-blue-400 hover:bg-blue-50 dark:hover:bg-blue-900/20 transition-colors group"
|
||||||
>
|
>
|
||||||
<Plus className="h-8 w-8 text-gray-400 group-hover:text-blue-500 dark:group-hover:text-blue-400 mb-2" />
|
<Plus className="h-8 w-8 text-gray-600 group-hover:text-blue-500 dark:group-hover:text-blue-400 mb-2" />
|
||||||
<span className="font-medium text-gray-900 dark:text-gray-100 group-hover:text-blue-600 dark:group-hover:text-blue-400">
|
<span className="font-medium text-gray-900 dark:text-gray-100 group-hover:text-blue-600 dark:group-hover:text-blue-400">
|
||||||
Start Empty
|
Start Empty
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-gray-500 dark:text-gray-400 text-center mt-1">
|
<span className="text-xs text-gray-600 dark:text-gray-600 text-center mt-1">
|
||||||
Create a blank object structure
|
Create a blank object structure
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -880,11 +880,11 @@ const ObjectEditor = () => {
|
|||||||
}}
|
}}
|
||||||
className="flex flex-col items-center p-6 border-2 border-dashed border-gray-300 dark:border-gray-600 rounded-lg hover:border-green-500 dark:hover:border-green-400 hover:bg-green-50 dark:hover:bg-green-900/20 transition-colors group"
|
className="flex flex-col items-center p-6 border-2 border-dashed border-gray-300 dark:border-gray-600 rounded-lg hover:border-green-500 dark:hover:border-green-400 hover:bg-green-50 dark:hover:bg-green-900/20 transition-colors group"
|
||||||
>
|
>
|
||||||
<FileText className="h-8 w-8 text-gray-400 group-hover:text-green-500 dark:group-hover:text-green-400 mb-2" />
|
<FileText className="h-8 w-8 text-gray-600 group-hover:text-green-500 dark:group-hover:text-green-400 mb-2" />
|
||||||
<span className="font-medium text-gray-900 dark:text-gray-100 group-hover:text-green-600 dark:group-hover:text-green-400">
|
<span className="font-medium text-gray-900 dark:text-gray-100 group-hover:text-green-600 dark:group-hover:text-green-400">
|
||||||
Load Sample
|
Load Sample
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-gray-500 dark:text-gray-400 text-center mt-1">
|
<span className="text-xs text-gray-600 dark:text-gray-600 text-center mt-1">
|
||||||
Start with example data to explore features
|
Start with example data to explore features
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -909,7 +909,7 @@ const ObjectEditor = () => {
|
|||||||
✓ Data loaded: {urlDataSummary.format} ({urlDataSummary.size.toLocaleString()} chars, {urlDataSummary.properties} {urlDataSummary.properties === 1 ? 'property' : 'properties'})
|
✓ Data loaded: {urlDataSummary.format} ({urlDataSummary.size.toLocaleString()} chars, {urlDataSummary.properties} {urlDataSummary.properties === 1 ? 'property' : 'properties'})
|
||||||
</span>
|
</span>
|
||||||
{urlDataSummary.contentTypeLabel && (
|
{urlDataSummary.contentTypeLabel && (
|
||||||
<span className="text-xs text-gray-600 dark:text-gray-400">
|
<span className="text-xs text-gray-600 dark:text-gray-600">
|
||||||
{urlDataSummary.contentTypeEmoji} {urlDataSummary.contentTypeLabel}
|
{urlDataSummary.contentTypeEmoji} {urlDataSummary.contentTypeLabel}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -976,7 +976,7 @@ const ObjectEditor = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="flex items-center justify-between flex-shrink-0">
|
<div className="flex items-center justify-between flex-shrink-0">
|
||||||
<div className="text-sm text-gray-600 dark:text-gray-400">
|
<div className="text-sm text-gray-600 dark:text-gray-600">
|
||||||
{inputFormat && (
|
{inputFormat && (
|
||||||
<span className={`inline-flex items-center px-2 py-1 rounded text-xs font-medium ${
|
<span className={`inline-flex items-center px-2 py-1 rounded text-xs font-medium ${
|
||||||
inputValid
|
inputValid
|
||||||
@@ -1057,7 +1057,7 @@ const ObjectEditor = () => {
|
|||||||
className={`flex items-center gap-2 px-3 py-2 text-sm font-medium transition-colors ${
|
className={`flex items-center gap-2 px-3 py-2 text-sm font-medium transition-colors ${
|
||||||
viewMode === 'visual'
|
viewMode === 'visual'
|
||||||
? 'bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300'
|
? 'bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300'
|
||||||
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700'
|
: 'text-gray-600 dark:text-gray-600 hover:text-gray-900 dark:hover:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Edit3 className="h-4 w-4" />
|
<Edit3 className="h-4 w-4" />
|
||||||
@@ -1068,7 +1068,7 @@ const ObjectEditor = () => {
|
|||||||
className={`flex items-center gap-2 px-3 py-2 text-sm font-medium transition-colors border-l border-gray-200 dark:border-gray-700 ${
|
className={`flex items-center gap-2 px-3 py-2 text-sm font-medium transition-colors border-l border-gray-200 dark:border-gray-700 ${
|
||||||
viewMode === 'mindmap'
|
viewMode === 'mindmap'
|
||||||
? 'bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300'
|
? 'bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300'
|
||||||
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700'
|
: 'text-gray-600 dark:text-gray-600 hover:text-gray-900 dark:hover:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Workflow className="h-4 w-4" />
|
<Workflow className="h-4 w-4" />
|
||||||
@@ -1079,7 +1079,7 @@ const ObjectEditor = () => {
|
|||||||
className={`flex items-center gap-2 px-3 py-2 text-sm font-medium transition-colors border-l border-gray-200 dark:border-gray-700 ${
|
className={`flex items-center gap-2 px-3 py-2 text-sm font-medium transition-colors border-l border-gray-200 dark:border-gray-700 ${
|
||||||
viewMode === 'table'
|
viewMode === 'table'
|
||||||
? 'bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300'
|
? 'bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300'
|
||||||
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700'
|
: 'text-gray-600 dark:text-gray-600 hover:text-gray-900 dark:hover:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Table className="h-4 w-4" />
|
<Table className="h-4 w-4" />
|
||||||
@@ -1093,11 +1093,11 @@ const ObjectEditor = () => {
|
|||||||
<div>
|
<div>
|
||||||
{Object.keys(structuredData).length === 0 ? (
|
{Object.keys(structuredData).length === 0 ? (
|
||||||
<div className="text-center py-12">
|
<div className="text-center py-12">
|
||||||
<Edit3 className="h-12 w-12 text-gray-400 dark:text-gray-500 mx-auto mb-4" />
|
<Edit3 className="h-12 w-12 text-gray-600 dark:text-gray-600 mx-auto mb-4" />
|
||||||
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-2">
|
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-2">
|
||||||
No Object Data
|
No Object Data
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-gray-600 dark:text-gray-400">
|
<p className="text-gray-600 dark:text-gray-600">
|
||||||
Load data using the input methods above to start editing
|
Load data using the input methods above to start editing
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -1143,7 +1143,7 @@ const ObjectEditor = () => {
|
|||||||
Export Results
|
Export Results
|
||||||
{outputExpanded ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
|
{outputExpanded ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
|
||||||
</h3>
|
</h3>
|
||||||
<div className="text-sm text-gray-600 dark:text-gray-400">
|
<div className="text-sm text-gray-600 dark:text-gray-600">
|
||||||
<span>Object: {Object.keys(structuredData).length} properties</span>
|
<span>Object: {Object.keys(structuredData).length} properties</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1159,7 +1159,7 @@ const ObjectEditor = () => {
|
|||||||
className={`flex items-center gap-2 px-4 py-3 text-sm font-medium transition-colors ${
|
className={`flex items-center gap-2 px-4 py-3 text-sm font-medium transition-colors ${
|
||||||
activeExportTab === 'json'
|
activeExportTab === 'json'
|
||||||
? 'bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300 border-b-2 border-blue-500'
|
? 'bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300 border-b-2 border-blue-500'
|
||||||
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200'
|
: 'text-gray-600 dark:text-gray-600 hover:text-gray-900 dark:hover:text-gray-200'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Braces className="h-4 w-4" />
|
<Braces className="h-4 w-4" />
|
||||||
@@ -1170,7 +1170,7 @@ const ObjectEditor = () => {
|
|||||||
className={`flex items-center gap-2 px-4 py-3 text-sm font-medium transition-colors ${
|
className={`flex items-center gap-2 px-4 py-3 text-sm font-medium transition-colors ${
|
||||||
activeExportTab === 'php'
|
activeExportTab === 'php'
|
||||||
? 'bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300 border-b-2 border-blue-500'
|
? 'bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300 border-b-2 border-blue-500'
|
||||||
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200'
|
: 'text-gray-600 dark:text-gray-600 hover:text-gray-900 dark:hover:text-gray-200'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Code className="h-4 w-4" />
|
<Code className="h-4 w-4" />
|
||||||
@@ -1428,7 +1428,7 @@ const InputChangeConfirmationModal = ({ objectData, currentMethod, newMethod, on
|
|||||||
<h4 className="text-sm font-medium text-gray-900 dark:text-gray-100 mb-2">
|
<h4 className="text-sm font-medium text-gray-900 dark:text-gray-100 mb-2">
|
||||||
This will permanently delete:
|
This will permanently delete:
|
||||||
</h4>
|
</h4>
|
||||||
<ul className="text-sm text-gray-600 dark:text-gray-400 space-y-1">
|
<ul className="text-sm text-gray-600 dark:text-gray-600 space-y-1">
|
||||||
<li>• Object with {objectSize} properties</li>
|
<li>• Object with {objectSize} properties</li>
|
||||||
{hasNestedData && <li>• All nested objects and arrays</li>}
|
{hasNestedData && <li>• All nested objects and arrays</li>}
|
||||||
<li>• All modifications and edits</li>
|
<li>• All modifications and edits</li>
|
||||||
|
|||||||
2
src/pages/PrivacyPolicy.js
Normal file → Executable file
@@ -252,7 +252,7 @@ const PrivacyPolicy = () => {
|
|||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<div className="mt-8 text-center">
|
<div className="mt-8 text-center">
|
||||||
<p className="text-sm text-slate-500 dark:text-slate-400">
|
<p className="text-sm text-slate-600 dark:text-slate-600">
|
||||||
© {SITE_CONFIG.year} {SITE_CONFIG.title} • Your privacy is our priority
|
© {SITE_CONFIG.year} {SITE_CONFIG.title} • Your privacy is our priority
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
16
src/pages/ReleaseNotes.js
Normal file → Executable file
@@ -235,7 +235,7 @@ const ReleaseNotes = () => {
|
|||||||
<h1 className="text-4xl font-bold text-gray-900 dark:text-gray-100 mb-4">
|
<h1 className="text-4xl font-bold text-gray-900 dark:text-gray-100 mb-4">
|
||||||
What's New
|
What's New
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-xl text-gray-600 dark:text-gray-400 max-w-2xl mx-auto">
|
<p className="text-xl text-gray-600 dark:text-gray-600 max-w-2xl mx-auto">
|
||||||
Discover the latest features, improvements, and bug fixes that make your development workflow even better.
|
Discover the latest features, improvements, and bug fixes that make your development workflow even better.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -269,7 +269,7 @@ const ReleaseNotes = () => {
|
|||||||
className="w-full px-6 py-4 flex items-center justify-between hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors"
|
className="w-full px-6 py-4 flex items-center justify-between hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors"
|
||||||
>
|
>
|
||||||
<div className="flex items-center space-x-3">
|
<div className="flex items-center space-x-3">
|
||||||
<Calendar className="h-5 w-5 text-gray-500 dark:text-gray-400" />
|
<Calendar className="h-5 w-5 text-gray-600 dark:text-gray-600" />
|
||||||
<div className="text-left">
|
<div className="text-left">
|
||||||
<h3 className="font-semibold text-gray-900 dark:text-gray-100">
|
<h3 className="font-semibold text-gray-900 dark:text-gray-100">
|
||||||
{releaseDate.toLocaleDateString('en-US', {
|
{releaseDate.toLocaleDateString('en-US', {
|
||||||
@@ -279,16 +279,16 @@ const ReleaseNotes = () => {
|
|||||||
day: 'numeric'
|
day: 'numeric'
|
||||||
})}
|
})}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
<p className="text-sm text-gray-600 dark:text-gray-600">
|
||||||
{dayReleases.length} update{dayReleases.length !== 1 ? 's' : ''}
|
{dayReleases.length} update{dayReleases.length !== 1 ? 's' : ''}
|
||||||
{isRecent && <span className="ml-2 text-blue-600 dark:text-blue-400">• Recent</span>}
|
{isRecent && <span className="ml-2 text-blue-600 dark:text-blue-400">• Recent</span>}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{isExpanded ? (
|
{isExpanded ? (
|
||||||
<ChevronUp className="h-5 w-5 text-gray-400" />
|
<ChevronUp className="h-5 w-5 text-gray-600" />
|
||||||
) : (
|
) : (
|
||||||
<ChevronDown className="h-5 w-5 text-gray-400" />
|
<ChevronDown className="h-5 w-5 text-gray-600" />
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@@ -318,10 +318,10 @@ const ReleaseNotes = () => {
|
|||||||
{typeConfig.label}
|
{typeConfig.label}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-gray-600 dark:text-gray-400 leading-relaxed">
|
<p className="text-gray-600 dark:text-gray-600 leading-relaxed">
|
||||||
{release.description}
|
{release.description}
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-3 flex items-center space-x-4 text-xs text-gray-500 dark:text-gray-400">
|
<div className="mt-3 flex items-center space-x-4 text-xs text-gray-600 dark:text-gray-600">
|
||||||
<span>
|
<span>
|
||||||
{new Date(release.date).toLocaleTimeString('en-US', {
|
{new Date(release.date).toLocaleTimeString('en-US', {
|
||||||
hour: '2-digit',
|
hour: '2-digit',
|
||||||
@@ -346,7 +346,7 @@ const ReleaseNotes = () => {
|
|||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<div className="text-center mt-12 py-8 border-t border-gray-200 dark:border-gray-700">
|
<div className="text-center mt-12 py-8 border-t border-gray-200 dark:border-gray-700">
|
||||||
<p className="text-gray-500 dark:text-gray-400">
|
<p className="text-gray-600 dark:text-gray-600">
|
||||||
Stay tuned for more exciting updates and improvements!
|
Stay tuned for more exciting updates and improvements!
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
78
src/pages/TableEditor.js
Normal file → Executable file
@@ -1863,7 +1863,7 @@ const TableEditor = () => {
|
|||||||
className={`flex items-center gap-1 sm:gap-2 px-3 sm:px-4 py-3 text-sm font-medium transition-colors whitespace-nowrap ${
|
className={`flex items-center gap-1 sm:gap-2 px-3 sm:px-4 py-3 text-sm font-medium transition-colors whitespace-nowrap ${
|
||||||
activeTab === "create"
|
activeTab === "create"
|
||||||
? "bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300 border-b-2 border-blue-500"
|
? "bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300 border-b-2 border-blue-500"
|
||||||
: "text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200"
|
: "text-gray-600 dark:text-gray-600 hover:text-gray-900 dark:hover:text-gray-200"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Plus className="h-4 w-4" />
|
<Plus className="h-4 w-4" />
|
||||||
@@ -1874,7 +1874,7 @@ const TableEditor = () => {
|
|||||||
className={`flex items-center gap-1 sm:gap-2 px-3 sm:px-4 py-3 text-sm font-medium transition-colors whitespace-nowrap ${
|
className={`flex items-center gap-1 sm:gap-2 px-3 sm:px-4 py-3 text-sm font-medium transition-colors whitespace-nowrap ${
|
||||||
activeTab === "url"
|
activeTab === "url"
|
||||||
? "bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300 border-b-2 border-blue-500"
|
? "bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300 border-b-2 border-blue-500"
|
||||||
: "text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200"
|
: "text-gray-600 dark:text-gray-600 hover:text-gray-900 dark:hover:text-gray-200"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Globe className="h-4 w-4" />
|
<Globe className="h-4 w-4" />
|
||||||
@@ -1885,7 +1885,7 @@ const TableEditor = () => {
|
|||||||
className={`flex items-center gap-1 sm:gap-2 px-3 sm:px-4 py-3 text-sm font-medium transition-colors whitespace-nowrap ${
|
className={`flex items-center gap-1 sm:gap-2 px-3 sm:px-4 py-3 text-sm font-medium transition-colors whitespace-nowrap ${
|
||||||
activeTab === "paste"
|
activeTab === "paste"
|
||||||
? "bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300 border-b-2 border-blue-500"
|
? "bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300 border-b-2 border-blue-500"
|
||||||
: "text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200"
|
: "text-gray-600 dark:text-gray-600 hover:text-gray-900 dark:hover:text-gray-200"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<FileText className="h-4 w-4" />
|
<FileText className="h-4 w-4" />
|
||||||
@@ -1896,7 +1896,7 @@ const TableEditor = () => {
|
|||||||
className={`flex items-center gap-1 sm:gap-2 px-3 sm:px-4 py-3 text-sm font-medium transition-colors whitespace-nowrap ${
|
className={`flex items-center gap-1 sm:gap-2 px-3 sm:px-4 py-3 text-sm font-medium transition-colors whitespace-nowrap ${
|
||||||
activeTab === "upload"
|
activeTab === "upload"
|
||||||
? "bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300 border-b-2 border-blue-500"
|
? "bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300 border-b-2 border-blue-500"
|
||||||
: "text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200"
|
: "text-gray-600 dark:text-gray-600 hover:text-gray-900 dark:hover:text-gray-200"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Upload className="h-4 w-4" />
|
<Upload className="h-4 w-4" />
|
||||||
@@ -1914,7 +1914,7 @@ const TableEditor = () => {
|
|||||||
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-2">
|
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-2">
|
||||||
Start Building Your Table
|
Start Building Your Table
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
|
<p className="text-sm text-gray-600 dark:text-gray-600 mb-4">
|
||||||
Choose how you'd like to begin working with your data
|
Choose how you'd like to begin working with your data
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -1932,11 +1932,11 @@ const TableEditor = () => {
|
|||||||
}}
|
}}
|
||||||
className="flex flex-col items-center p-6 border-2 border-dashed border-gray-300 dark:border-gray-600 rounded-lg hover:border-blue-500 dark:hover:border-blue-400 hover:bg-blue-50 dark:hover:bg-blue-900/20 transition-colors group"
|
className="flex flex-col items-center p-6 border-2 border-dashed border-gray-300 dark:border-gray-600 rounded-lg hover:border-blue-500 dark:hover:border-blue-400 hover:bg-blue-50 dark:hover:bg-blue-900/20 transition-colors group"
|
||||||
>
|
>
|
||||||
<Plus className="h-8 w-8 text-gray-400 group-hover:text-blue-500 dark:group-hover:text-blue-400 mb-2" />
|
<Plus className="h-8 w-8 text-gray-600 group-hover:text-blue-500 dark:group-hover:text-blue-400 mb-2" />
|
||||||
<span className="font-medium text-gray-900 dark:text-gray-100 group-hover:text-blue-600 dark:group-hover:text-blue-400">
|
<span className="font-medium text-gray-900 dark:text-gray-100 group-hover:text-blue-600 dark:group-hover:text-blue-400">
|
||||||
Start Empty
|
Start Empty
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-gray-500 dark:text-gray-400 text-center mt-1">
|
<span className="text-xs text-gray-600 dark:text-gray-600 text-center mt-1">
|
||||||
Create a blank table with basic columns
|
Create a blank table with basic columns
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -2005,11 +2005,11 @@ const TableEditor = () => {
|
|||||||
}}
|
}}
|
||||||
className="flex flex-col items-center p-6 border-2 border-dashed border-gray-300 dark:border-gray-600 rounded-lg hover:border-green-500 dark:hover:border-green-400 hover:bg-green-50 dark:hover:bg-green-900/20 transition-colors group"
|
className="flex flex-col items-center p-6 border-2 border-dashed border-gray-300 dark:border-gray-600 rounded-lg hover:border-green-500 dark:hover:border-green-400 hover:bg-green-50 dark:hover:bg-green-900/20 transition-colors group"
|
||||||
>
|
>
|
||||||
<FileText className="h-8 w-8 text-gray-400 group-hover:text-green-500 dark:group-hover:text-green-400 mb-2" />
|
<FileText className="h-8 w-8 text-gray-600 group-hover:text-green-500 dark:group-hover:text-green-400 mb-2" />
|
||||||
<span className="font-medium text-gray-900 dark:text-gray-100 group-hover:text-green-600 dark:group-hover:text-green-400">
|
<span className="font-medium text-gray-900 dark:text-gray-100 group-hover:text-green-600 dark:group-hover:text-green-400">
|
||||||
Load Sample
|
Load Sample
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-gray-500 dark:text-gray-400 text-center mt-1">
|
<span className="text-xs text-gray-600 dark:text-gray-600 text-center mt-1">
|
||||||
Start with example data to explore features
|
Start with example data to explore features
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -2055,7 +2055,7 @@ const TableEditor = () => {
|
|||||||
{url && !isLoading && (
|
{url && !isLoading && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setUrl("")}
|
onClick={() => setUrl("")}
|
||||||
className="absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300 transition-colors"
|
className="absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-600 hover:text-gray-600 dark:text-gray-600 dark:hover:text-gray-300 transition-colors"
|
||||||
>
|
>
|
||||||
<X className="h-4 w-4" />
|
<X className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
@@ -2069,7 +2069,7 @@ const TableEditor = () => {
|
|||||||
{isLoading ? "Fetching..." : "Fetch Data"}
|
{isLoading ? "Fetching..." : "Fetch Data"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<label className="flex items-center text-sm text-gray-600 dark:text-gray-400">
|
<label className="flex items-center text-sm text-gray-600 dark:text-gray-600">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={useFirstRowAsHeader}
|
checked={useFirstRowAsHeader}
|
||||||
@@ -2118,7 +2118,7 @@ const TableEditor = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="flex items-center justify-between flex-shrink-0">
|
<div className="flex items-center justify-between flex-shrink-0">
|
||||||
<label className="flex items-center text-sm text-gray-600 dark:text-gray-400">
|
<label className="flex items-center text-sm text-gray-600 dark:text-gray-600">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={useFirstRowAsHeader}
|
checked={useFirstRowAsHeader}
|
||||||
@@ -2161,7 +2161,7 @@ const TableEditor = () => {
|
|||||||
onChange={handleFileUpload}
|
onChange={handleFileUpload}
|
||||||
className="tool-input"
|
className="tool-input"
|
||||||
/>
|
/>
|
||||||
<label className="flex items-center text-sm text-gray-600 dark:text-gray-400">
|
<label className="flex items-center text-sm text-gray-600 dark:text-gray-600">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={useFirstRowAsHeader}
|
checked={useFirstRowAsHeader}
|
||||||
@@ -2204,7 +2204,7 @@ const TableEditor = () => {
|
|||||||
{availableTables.length > 1 ? "Multi-Table Database" : "Table Editor"}
|
{availableTables.length > 1 ? "Multi-Table Database" : "Table Editor"}
|
||||||
</h3>
|
</h3>
|
||||||
{availableTables.length === 1 && (
|
{availableTables.length === 1 && (
|
||||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
<p className="text-sm text-gray-600 dark:text-gray-600">
|
||||||
{data.length} rows, {columns.length} columns
|
{data.length} rows, {columns.length} columns
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@@ -2219,7 +2219,7 @@ const TableEditor = () => {
|
|||||||
className={`flex items-center gap-2 px-3 py-2 text-sm font-medium transition-colors ${
|
className={`flex items-center gap-2 px-3 py-2 text-sm font-medium transition-colors ${
|
||||||
isTableFullscreen
|
isTableFullscreen
|
||||||
? "bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300"
|
? "bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300"
|
||||||
: "text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700"
|
: "text-gray-600 dark:text-gray-600 hover:text-gray-900 dark:hover:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{isTableFullscreen ? (
|
{isTableFullscreen ? (
|
||||||
@@ -2233,7 +2233,7 @@ const TableEditor = () => {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={clearData}
|
onClick={clearData}
|
||||||
className="flex items-center gap-2 px-3 py-2 text-sm font-medium transition-colors border-l border-gray-200 dark:border-gray-700 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700"
|
className="flex items-center gap-2 px-3 py-2 text-sm font-medium transition-colors border-l border-gray-200 dark:border-gray-700 text-gray-600 dark:text-gray-600 hover:text-gray-900 dark:hover:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700"
|
||||||
>
|
>
|
||||||
<AlertTriangle className="h-4 w-4" />
|
<AlertTriangle className="h-4 w-4" />
|
||||||
<span className="hidden sm:inline">Clear All</span>
|
<span className="hidden sm:inline">Clear All</span>
|
||||||
@@ -2246,7 +2246,7 @@ const TableEditor = () => {
|
|||||||
{availableTables.length > 1 && (
|
{availableTables.length > 1 && (
|
||||||
<div className="px-4 py-3 flex flex-col sm:flex-row items-start sm:items-center gap-3 sm:gap-4 border-b border-gray-200 dark:border-gray-700 justify-between">
|
<div className="px-4 py-3 flex flex-col sm:flex-row items-start sm:items-center gap-3 sm:gap-4 border-b border-gray-200 dark:border-gray-700 justify-between">
|
||||||
<div className="flex items-center gap-2 w-full sm:max-w-1/2">
|
<div className="flex items-center gap-2 w-full sm:max-w-1/2">
|
||||||
<span className="text-sm text-gray-600 dark:text-gray-400 whitespace-nowrap hidden sm:inline">
|
<span className="text-sm text-gray-600 dark:text-gray-600 whitespace-nowrap hidden sm:inline">
|
||||||
Current Table:
|
Current Table:
|
||||||
</span>
|
</span>
|
||||||
<select
|
<select
|
||||||
@@ -2267,7 +2267,7 @@ const TableEditor = () => {
|
|||||||
})}
|
})}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
<p className="text-sm text-gray-600 dark:text-gray-600">
|
||||||
{data.length} rows, {columns.length} columns
|
{data.length} rows, {columns.length} columns
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -2279,7 +2279,7 @@ const TableEditor = () => {
|
|||||||
<div className="px-4 py-3 flex flex-col sm:flex-row items-start sm:items-center gap-3 sm:gap-4 border-b border-gray-200 dark:border-gray-700">
|
<div className="px-4 py-3 flex flex-col sm:flex-row items-start sm:items-center gap-3 sm:gap-4 border-b border-gray-200 dark:border-gray-700">
|
||||||
{/* Search Bar */}
|
{/* Search Bar */}
|
||||||
<div className="relative w-full">
|
<div className="relative w-full">
|
||||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
|
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-600" />
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={searchTerm}
|
value={searchTerm}
|
||||||
@@ -2319,7 +2319,7 @@ const TableEditor = () => {
|
|||||||
|
|
||||||
{/* Freeze Columns Control */}
|
{/* Freeze Columns Control */}
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-xs sm:text-sm text-gray-600 dark:text-gray-400 whitespace-nowrap">
|
<span className="text-xs sm:text-sm text-gray-600 dark:text-gray-600 whitespace-nowrap">
|
||||||
Freeze:
|
Freeze:
|
||||||
</span>
|
</span>
|
||||||
<select
|
<select
|
||||||
@@ -2354,7 +2354,7 @@ const TableEditor = () => {
|
|||||||
<thead className="bg-gray-50 dark:bg-gray-700 sticky top-[-1px] z-10">
|
<thead className="bg-gray-50 dark:bg-gray-700 sticky top-[-1px] z-10">
|
||||||
<tr>
|
<tr>
|
||||||
<th
|
<th
|
||||||
className={`px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 tracking-wider border-r border-gray-200 dark:border-gray-600 ${
|
className={`px-4 py-3 text-left text-xs font-medium text-gray-600 dark:text-gray-300 tracking-wider border-r border-gray-200 dark:border-gray-600 ${
|
||||||
frozenColumns > 0
|
frozenColumns > 0
|
||||||
? "sticky left-0 z-20 bg-blue-50 dark:!bg-blue-900"
|
? "sticky left-0 z-20 bg-blue-50 dark:!bg-blue-900"
|
||||||
: ""
|
: ""
|
||||||
@@ -2390,7 +2390,7 @@ const TableEditor = () => {
|
|||||||
return (
|
return (
|
||||||
<th
|
<th
|
||||||
key={column.id}
|
key={column.id}
|
||||||
className={`relative px-4 py-3 text-left text-sm font-medium text-gray-500 dark:text-gray-300 tracking-wider hover:bg-gray-100 dark:hover:bg-gray-600 border-r border-gray-200 dark:border-gray-600 ${
|
className={`relative px-4 py-3 text-left text-sm font-medium text-gray-600 dark:text-gray-300 tracking-wider hover:bg-gray-100 dark:hover:bg-gray-600 border-r border-gray-200 dark:border-gray-600 ${
|
||||||
isFrozen
|
isFrozen
|
||||||
? "sticky z-20 bg-blue-50 dark:!bg-blue-900"
|
? "sticky z-20 bg-blue-50 dark:!bg-blue-900"
|
||||||
: ""
|
: ""
|
||||||
@@ -2450,7 +2450,7 @@ const TableEditor = () => {
|
|||||||
className={`h-4 w-4 flex-shrink-0 ${
|
className={`h-4 w-4 flex-shrink-0 ${
|
||||||
sortConfig.key === column.id
|
sortConfig.key === column.id
|
||||||
? "text-blue-600 dark:text-blue-400"
|
? "text-blue-600 dark:text-blue-400"
|
||||||
: "text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
: "text-gray-600 hover:text-gray-600 dark:hover:text-gray-300"
|
||||||
}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
@@ -2471,7 +2471,7 @@ const TableEditor = () => {
|
|||||||
<th className="px-4 py-3 text-center border-l-2 border-dashed border-gray-300 dark:border-gray-600 hover:bg-blue-50 dark:hover:bg-blue-900/20 w-[60px]">
|
<th className="px-4 py-3 text-center border-l-2 border-dashed border-gray-300 dark:border-gray-600 hover:bg-blue-50 dark:hover:bg-blue-900/20 w-[60px]">
|
||||||
<button
|
<button
|
||||||
onClick={addColumn}
|
onClick={addColumn}
|
||||||
className="flex items-center justify-center text-gray-500 hover:text-blue-600 p-2 rounded-lg transition-colors group"
|
className="flex items-center justify-center text-gray-600 hover:text-blue-600 p-2 rounded-lg transition-colors group"
|
||||||
title="Add new column"
|
title="Add new column"
|
||||||
>
|
>
|
||||||
<Plus className="h-4 w-4 group-hover:scale-110 transition-transform" />
|
<Plus className="h-4 w-4 group-hover:scale-110 transition-transform" />
|
||||||
@@ -2632,7 +2632,7 @@ const TableEditor = () => {
|
|||||||
>
|
>
|
||||||
<span className="truncate block w-full">
|
<span className="truncate block w-full">
|
||||||
{cellValue || (
|
{cellValue || (
|
||||||
<span className="text-gray-400 dark:text-gray-500 italic text-sm">
|
<span className="text-gray-600 dark:text-gray-600 italic text-sm">
|
||||||
Click to edit
|
Click to edit
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -2662,7 +2662,7 @@ const TableEditor = () => {
|
|||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
onClick={addRow}
|
onClick={addRow}
|
||||||
className="flex items-center justify-center gap-2 text-gray-500 hover:text-blue-600 px-3 py-2 rounded-lg transition-colors group whitespace-nowrap sticky left-4"
|
className="flex items-center justify-center gap-2 text-gray-600 hover:text-blue-600 px-3 py-2 rounded-lg transition-colors group whitespace-nowrap sticky left-4"
|
||||||
title="Add new row"
|
title="Add new row"
|
||||||
>
|
>
|
||||||
<Plus className="h-4 w-4 group-hover:scale-110 transition-transform" />
|
<Plus className="h-4 w-4 group-hover:scale-110 transition-transform" />
|
||||||
@@ -2736,7 +2736,7 @@ const TableEditor = () => {
|
|||||||
Export Results
|
Export Results
|
||||||
{exportExpanded ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
|
{exportExpanded ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
|
||||||
</h3>
|
</h3>
|
||||||
<div className="text-sm text-gray-600 dark:text-gray-400">
|
<div className="text-sm text-gray-600 dark:text-gray-600">
|
||||||
{availableTables.length > 1 ? (
|
{availableTables.length > 1 ? (
|
||||||
<span>
|
<span>
|
||||||
Database: {originalFileName || "Multi-table"} (
|
Database: {originalFileName || "Multi-table"} (
|
||||||
@@ -2763,7 +2763,7 @@ const TableEditor = () => {
|
|||||||
className={`flex items-center gap-1 sm:gap-2 px-3 sm:px-4 py-3 text-sm font-medium transition-colors whitespace-nowrap ${
|
className={`flex items-center gap-1 sm:gap-2 px-3 sm:px-4 py-3 text-sm font-medium transition-colors whitespace-nowrap ${
|
||||||
exportTab === "json"
|
exportTab === "json"
|
||||||
? "bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300 border-b-2 border-blue-500"
|
? "bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300 border-b-2 border-blue-500"
|
||||||
: "text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200"
|
: "text-gray-600 dark:text-gray-600 hover:text-gray-900 dark:hover:text-gray-200"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Braces className="h-4 w-4" />
|
<Braces className="h-4 w-4" />
|
||||||
@@ -2774,7 +2774,7 @@ const TableEditor = () => {
|
|||||||
className={`flex items-center gap-1 sm:gap-2 px-3 sm:px-4 py-3 text-sm font-medium transition-colors whitespace-nowrap ${
|
className={`flex items-center gap-1 sm:gap-2 px-3 sm:px-4 py-3 text-sm font-medium transition-colors whitespace-nowrap ${
|
||||||
exportTab === "csv"
|
exportTab === "csv"
|
||||||
? "bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300 border-b-2 border-blue-500"
|
? "bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300 border-b-2 border-blue-500"
|
||||||
: "text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200"
|
: "text-gray-600 dark:text-gray-600 hover:text-gray-900 dark:hover:text-gray-200"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<FileText className="h-4 w-4" />
|
<FileText className="h-4 w-4" />
|
||||||
@@ -2785,7 +2785,7 @@ const TableEditor = () => {
|
|||||||
className={`flex items-center gap-1 sm:gap-2 px-3 sm:px-4 py-3 text-sm font-medium transition-colors whitespace-nowrap ${
|
className={`flex items-center gap-1 sm:gap-2 px-3 sm:px-4 py-3 text-sm font-medium transition-colors whitespace-nowrap ${
|
||||||
exportTab === "tsv"
|
exportTab === "tsv"
|
||||||
? "bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300 border-b-2 border-blue-500"
|
? "bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300 border-b-2 border-blue-500"
|
||||||
: "text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200"
|
: "text-gray-600 dark:text-gray-600 hover:text-gray-900 dark:hover:text-gray-200"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<FileText className="h-4 w-4" />
|
<FileText className="h-4 w-4" />
|
||||||
@@ -2796,7 +2796,7 @@ const TableEditor = () => {
|
|||||||
className={`flex items-center gap-1 sm:gap-2 px-3 sm:px-4 py-3 text-sm font-medium transition-colors whitespace-nowrap ${
|
className={`flex items-center gap-1 sm:gap-2 px-3 sm:px-4 py-3 text-sm font-medium transition-colors whitespace-nowrap ${
|
||||||
exportTab === "sql"
|
exportTab === "sql"
|
||||||
? "bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300 border-b-2 border-blue-500"
|
? "bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300 border-b-2 border-blue-500"
|
||||||
: "text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200"
|
: "text-gray-600 dark:text-gray-600 hover:text-gray-900 dark:hover:text-gray-200"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Database className="h-4 w-4" />
|
<Database className="h-4 w-4" />
|
||||||
@@ -3318,7 +3318,7 @@ const ClearConfirmationModal = ({
|
|||||||
<h4 className="text-sm font-medium text-gray-900 dark:text-gray-100 mb-2">
|
<h4 className="text-sm font-medium text-gray-900 dark:text-gray-100 mb-2">
|
||||||
This will permanently delete:
|
This will permanently delete:
|
||||||
</h4>
|
</h4>
|
||||||
<ul className="text-sm text-gray-600 dark:text-gray-400 space-y-1">
|
<ul className="text-sm text-gray-600 dark:text-gray-600 space-y-1">
|
||||||
{tableCount > 1 ? (
|
{tableCount > 1 ? (
|
||||||
<>
|
<>
|
||||||
<li>• {tableCount} tables</li>
|
<li>• {tableCount} tables</li>
|
||||||
@@ -3632,7 +3632,7 @@ const ObjectEditorModal = ({ modal, onClose, onApply }) => {
|
|||||||
const renderVisualEditor = () => {
|
const renderVisualEditor = () => {
|
||||||
if (!isValid) {
|
if (!isValid) {
|
||||||
return (
|
return (
|
||||||
<div className="h-full flex items-center justify-center text-gray-500 dark:text-gray-400 p-6">
|
<div className="h-full flex items-center justify-center text-gray-600 dark:text-gray-600 p-6">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<Code className="h-12 w-12 mx-auto mb-4 opacity-50" />
|
<Code className="h-12 w-12 mx-auto mb-4 opacity-50" />
|
||||||
<p>Invalid or unparseable data</p>
|
<p>Invalid or unparseable data</p>
|
||||||
@@ -3666,7 +3666,7 @@ const ObjectEditorModal = ({ modal, onClose, onApply }) => {
|
|||||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||||
Object Editor
|
Object Editor
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
<p className="text-sm text-gray-600 dark:text-gray-600">
|
||||||
Row {modal.rowIndex} • Column: {modal.columnName} • Format:{" "}
|
Row {modal.rowIndex} • Column: {modal.columnName} • Format:{" "}
|
||||||
{modal.format.type.replace("_", " ")}
|
{modal.format.type.replace("_", " ")}
|
||||||
</p>
|
</p>
|
||||||
@@ -3681,7 +3681,7 @@ const ObjectEditorModal = ({ modal, onClose, onApply }) => {
|
|||||||
{isValid &&
|
{isValid &&
|
||||||
structuredData &&
|
structuredData &&
|
||||||
typeof structuredData === "object" && (
|
typeof structuredData === "object" && (
|
||||||
<span className="text-gray-600 dark:text-gray-400">
|
<span className="text-gray-600 dark:text-gray-600">
|
||||||
{" • "}{Object.keys(structuredData).length} properties
|
{" • "}{Object.keys(structuredData).length} properties
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -3689,7 +3689,7 @@ const ObjectEditorModal = ({ modal, onClose, onApply }) => {
|
|||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 self-start"
|
className="text-gray-600 hover:text-gray-600 dark:hover:text-gray-300 self-start"
|
||||||
>
|
>
|
||||||
<X className="h-6 w-6" />
|
<X className="h-6 w-6" />
|
||||||
</button>
|
</button>
|
||||||
@@ -3704,7 +3704,7 @@ const ObjectEditorModal = ({ modal, onClose, onApply }) => {
|
|||||||
className={`px-3 py-2 text-sm font-medium rounded-md transition-colors ${
|
className={`px-3 py-2 text-sm font-medium rounded-md transition-colors ${
|
||||||
viewMode === "visual"
|
viewMode === "visual"
|
||||||
? "bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300"
|
? "bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300"
|
||||||
: "text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-200"
|
: "text-gray-600 hover:text-gray-900 dark:text-gray-600 dark:hover:text-gray-200"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Edit3 className="h-4 w-4 inline mr-2" />
|
<Edit3 className="h-4 w-4 inline mr-2" />
|
||||||
@@ -3715,7 +3715,7 @@ const ObjectEditorModal = ({ modal, onClose, onApply }) => {
|
|||||||
className={`px-3 py-2 text-sm font-medium rounded-md transition-colors ${
|
className={`px-3 py-2 text-sm font-medium rounded-md transition-colors ${
|
||||||
viewMode === "raw"
|
viewMode === "raw"
|
||||||
? "bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300"
|
? "bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300"
|
||||||
: "text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-200"
|
: "text-gray-600 hover:text-gray-900 dark:text-gray-600 dark:hover:text-gray-200"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Code className="h-4 w-4 inline mr-2" />
|
<Code className="h-4 w-4 inline mr-2" />
|
||||||
@@ -3845,7 +3845,7 @@ const InputChangeConfirmationModal = ({
|
|||||||
<h4 className="text-sm font-medium text-gray-900 dark:text-gray-100 mb-2">
|
<h4 className="text-sm font-medium text-gray-900 dark:text-gray-100 mb-2">
|
||||||
This will permanently delete:
|
This will permanently delete:
|
||||||
</h4>
|
</h4>
|
||||||
<ul className="text-sm text-gray-600 dark:text-gray-400 space-y-1">
|
<ul className="text-sm text-gray-600 dark:text-gray-600 space-y-1">
|
||||||
{tableCount > 1 ? (
|
{tableCount > 1 ? (
|
||||||
<>
|
<>
|
||||||
<li>• {tableCount} imported tables</li>
|
<li>• {tableCount} imported tables</li>
|
||||||
|
|||||||
0
src/pages/TermsOfService.js
Normal file → Executable file
20
src/pages/TextLengthTool.js
Normal file → Executable file
@@ -172,7 +172,7 @@ Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deseru
|
|||||||
{url && !isLoading && (
|
{url && !isLoading && (
|
||||||
<button
|
<button
|
||||||
onClick={clearUrl}
|
onClick={clearUrl}
|
||||||
className="absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300 transition-colors"
|
className="absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-600 hover:text-gray-600 dark:text-gray-600 dark:hover:text-gray-300 transition-colors"
|
||||||
>
|
>
|
||||||
<X className="h-4 w-4" />
|
<X className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
@@ -208,7 +208,7 @@ Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deseru
|
|||||||
{CONTENT_TYPE_INFO[urlResult.contentType].label}
|
{CONTENT_TYPE_INFO[urlResult.contentType].label}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-gray-600 dark:text-gray-400 mb-2">
|
<div className="text-sm text-gray-600 dark:text-gray-600 mb-2">
|
||||||
{CONTENT_TYPE_INFO[urlResult.contentType].description}
|
{CONTENT_TYPE_INFO[urlResult.contentType].description}
|
||||||
</div>
|
</div>
|
||||||
{urlResult.title && (
|
{urlResult.title && (
|
||||||
@@ -216,7 +216,7 @@ Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deseru
|
|||||||
{urlResult.title}
|
{urlResult.title}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="text-xs text-gray-500 dark:text-gray-400">
|
<div className="text-xs text-gray-600 dark:text-gray-600">
|
||||||
Article: {urlResult.metrics.articleWordCount} words •
|
Article: {urlResult.metrics.articleWordCount} words •
|
||||||
Total: {urlResult.metrics.totalWordCount} words •
|
Total: {urlResult.metrics.totalWordCount} words •
|
||||||
Ratio: {Math.round(urlResult.metrics.contentRatio * 100)}%
|
Ratio: {Math.round(urlResult.metrics.contentRatio * 100)}%
|
||||||
@@ -336,28 +336,28 @@ Typing time: ${getTypingTime()}` : ''}`;
|
|||||||
<div className="text-2xl font-bold text-primary-600 dark:text-primary-400">
|
<div className="text-2xl font-bold text-primary-600 dark:text-primary-400">
|
||||||
{formatNumber(stats.characters)}
|
{formatNumber(stats.characters)}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-gray-600 dark:text-gray-400">Characters</div>
|
<div className="text-sm text-gray-600 dark:text-gray-600">Characters</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
|
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
|
||||||
<div className="text-2xl font-bold text-primary-600 dark:text-primary-400">
|
<div className="text-2xl font-bold text-primary-600 dark:text-primary-400">
|
||||||
{formatNumber(stats.charactersNoSpaces)}
|
{formatNumber(stats.charactersNoSpaces)}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-gray-600 dark:text-gray-400">Characters (no spaces)</div>
|
<div className="text-sm text-gray-600 dark:text-gray-600">Characters (no spaces)</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
|
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
|
||||||
<div className="text-2xl font-bold text-green-600 dark:text-green-400">
|
<div className="text-2xl font-bold text-green-600 dark:text-green-400">
|
||||||
{formatNumber(stats.words)}
|
{formatNumber(stats.words)}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-gray-600 dark:text-gray-400">Words</div>
|
<div className="text-sm text-gray-600 dark:text-gray-600">Words</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
|
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
|
||||||
<div className="text-2xl font-bold text-blue-600 dark:text-blue-400">
|
<div className="text-2xl font-bold text-blue-600 dark:text-blue-400">
|
||||||
{formatNumber(stats.lines)}
|
{formatNumber(stats.lines)}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-gray-600 dark:text-gray-400">Lines</div>
|
<div className="text-sm text-gray-600 dark:text-gray-600">Lines</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -369,14 +369,14 @@ Typing time: ${getTypingTime()}` : ''}`;
|
|||||||
<div className="text-xl font-bold text-purple-600 dark:text-purple-400">
|
<div className="text-xl font-bold text-purple-600 dark:text-purple-400">
|
||||||
{formatNumber(stats.sentences)}
|
{formatNumber(stats.sentences)}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-gray-600 dark:text-gray-400">Sentences</div>
|
<div className="text-sm text-gray-600 dark:text-gray-600">Sentences</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
|
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
|
||||||
<div className="text-xl font-bold text-orange-600 dark:text-orange-400">
|
<div className="text-xl font-bold text-orange-600 dark:text-orange-400">
|
||||||
{formatNumber(stats.paragraphs)}
|
{formatNumber(stats.paragraphs)}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-gray-600 dark:text-gray-400">Paragraphs</div>
|
<div className="text-sm text-gray-600 dark:text-gray-600">Paragraphs</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -384,7 +384,7 @@ Typing time: ${getTypingTime()}` : ''}`;
|
|||||||
<div className="text-xl font-bold text-red-600 dark:text-red-400">
|
<div className="text-xl font-bold text-red-600 dark:text-red-400">
|
||||||
{formatNumber(stats.bytes)} bytes
|
{formatNumber(stats.bytes)} bytes
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-gray-600 dark:text-gray-400">Size (UTF-8 encoding)</div>
|
<div className="text-sm text-gray-600 dark:text-gray-600">Size (UTF-8 encoding)</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Reading & Typing Time */}
|
{/* Reading & Typing Time */}
|
||||||
|
|||||||
24
src/pages/UrlTool.js
Normal file → Executable file
@@ -60,7 +60,7 @@ const UrlTool = () => {
|
|||||||
className={`px-4 py-2 rounded-md font-medium transition-colors ${
|
className={`px-4 py-2 rounded-md font-medium transition-colors ${
|
||||||
mode === 'encode'
|
mode === 'encode'
|
||||||
? 'bg-white dark:bg-gray-700 text-primary-600 shadow-sm'
|
? 'bg-white dark:bg-gray-700 text-primary-600 shadow-sm'
|
||||||
: 'text-gray-600 dark:text-gray-400'
|
: 'text-gray-600 dark:text-gray-600'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
Encode
|
Encode
|
||||||
@@ -70,7 +70,7 @@ const UrlTool = () => {
|
|||||||
className={`px-4 py-2 rounded-md font-medium transition-colors ${
|
className={`px-4 py-2 rounded-md font-medium transition-colors ${
|
||||||
mode === 'decode'
|
mode === 'decode'
|
||||||
? 'bg-white dark:bg-gray-700 text-primary-600 shadow-sm'
|
? 'bg-white dark:bg-gray-700 text-primary-600 shadow-sm'
|
||||||
: 'text-gray-600 dark:text-gray-400'
|
: 'text-gray-600 dark:text-gray-600'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
Decode
|
Decode
|
||||||
@@ -112,7 +112,7 @@ const UrlTool = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Output */}
|
{/* Output */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2" aria-live="polite">
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
{mode === 'encode' ? 'Encoded URL' : 'Decoded URL'}
|
{mode === 'encode' ? 'Encoded URL' : 'Decoded URL'}
|
||||||
</label>
|
</label>
|
||||||
@@ -125,7 +125,7 @@ const UrlTool = () => {
|
|||||||
? 'Encoded URL will appear here...'
|
? 'Encoded URL will appear here...'
|
||||||
: 'Decoded URL will appear here...'
|
: 'Decoded URL will appear here...'
|
||||||
}
|
}
|
||||||
className="tool-input h-96 bg-gray-50 dark:bg-gray-800"
|
className={`tool-input h-96 bg-gray-50 dark:bg-gray-800 ${output?.startsWith('Error:') ? 'border-red-300 dark:border-red-700' : ''}`}
|
||||||
/>
|
/>
|
||||||
{output && <CopyButton text={output} />}
|
{output && <CopyButton text={output} />}
|
||||||
</div>
|
</div>
|
||||||
@@ -137,35 +137,35 @@ const UrlTool = () => {
|
|||||||
<h4 className="text-gray-800 dark:text-gray-200 font-medium mb-3">Common URL Encoding Reference</h4>
|
<h4 className="text-gray-800 dark:text-gray-200 font-medium mb-3">Common URL Encoding Reference</h4>
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
|
||||||
<div>
|
<div>
|
||||||
<span className="text-gray-600 dark:text-gray-400">Space:</span>
|
<span className="text-gray-600 dark:text-gray-600">Space:</span>
|
||||||
<span className="ml-2 font-mono text-gray-800 dark:text-gray-200">%20</span>
|
<span className="ml-2 font-mono text-gray-800 dark:text-gray-200">%20</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span className="text-gray-600 dark:text-gray-400">!:</span>
|
<span className="text-gray-600 dark:text-gray-600">!:</span>
|
||||||
<span className="ml-2 font-mono text-gray-800 dark:text-gray-200">%21</span>
|
<span className="ml-2 font-mono text-gray-800 dark:text-gray-200">%21</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span className="text-gray-600 dark:text-gray-400">#:</span>
|
<span className="text-gray-600 dark:text-gray-600">#:</span>
|
||||||
<span className="ml-2 font-mono text-gray-800 dark:text-gray-200">%23</span>
|
<span className="ml-2 font-mono text-gray-800 dark:text-gray-200">%23</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span className="text-gray-600 dark:text-gray-400">$:</span>
|
<span className="text-gray-600 dark:text-gray-600">$:</span>
|
||||||
<span className="ml-2 font-mono text-gray-800 dark:text-gray-200">%24</span>
|
<span className="ml-2 font-mono text-gray-800 dark:text-gray-200">%24</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span className="text-gray-600 dark:text-gray-400">&:</span>
|
<span className="text-gray-600 dark:text-gray-600">&:</span>
|
||||||
<span className="ml-2 font-mono text-gray-800 dark:text-gray-200">%26</span>
|
<span className="ml-2 font-mono text-gray-800 dark:text-gray-200">%26</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span className="text-gray-600 dark:text-gray-400">':</span>
|
<span className="text-gray-600 dark:text-gray-600">':</span>
|
||||||
<span className="ml-2 font-mono text-gray-800 dark:text-gray-200">%27</span>
|
<span className="ml-2 font-mono text-gray-800 dark:text-gray-200">%27</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span className="text-gray-600 dark:text-gray-400">(:</span>
|
<span className="text-gray-600 dark:text-gray-600">(:</span>
|
||||||
<span className="ml-2 font-mono text-gray-800 dark:text-gray-200">%28</span>
|
<span className="ml-2 font-mono text-gray-800 dark:text-gray-200">%28</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span className="text-gray-600 dark:text-gray-400">):</span>
|
<span className="text-gray-600 dark:text-gray-600">):</span>
|
||||||
<span className="ml-2 font-mono text-gray-800 dark:text-gray-200">%29</span>
|
<span className="ml-2 font-mono text-gray-800 dark:text-gray-200">%29</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
8
src/pages/components/CodeInputsNew.js
Normal file → Executable file
@@ -96,7 +96,7 @@ const CodeInputs = ({
|
|||||||
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
|
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
|
||||||
activeTab === tab.id
|
activeTab === tab.id
|
||||||
? 'border-blue-500 text-blue-600 dark:text-blue-400'
|
? 'border-blue-500 text-blue-600 dark:text-blue-400'
|
||||||
: 'border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300'
|
: 'border-transparent text-gray-600 dark:text-gray-600 hover:text-gray-700 dark:hover:text-gray-300'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{tab.label}
|
{tab.label}
|
||||||
@@ -108,7 +108,7 @@ const CodeInputs = ({
|
|||||||
<div className="flex items-center justify-end space-x-2 p-2 bg-gray-50 dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700">
|
<div className="flex items-center justify-end space-x-2 p-2 bg-gray-50 dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700">
|
||||||
<button
|
<button
|
||||||
onClick={() => handleSearch(getCurrentEditorRef())}
|
onClick={() => handleSearch(getCurrentEditorRef())}
|
||||||
className="flex items-center gap-1 px-2 py-1 text-xs text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 transition-colors"
|
className="flex items-center gap-1 px-2 py-1 text-xs text-gray-600 dark:text-gray-600 hover:text-gray-700 dark:hover:text-gray-300 transition-colors"
|
||||||
title="Search"
|
title="Search"
|
||||||
>
|
>
|
||||||
<Search className="w-3 h-3" />
|
<Search className="w-3 h-3" />
|
||||||
@@ -116,7 +116,7 @@ const CodeInputs = ({
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleCopy(getCurrentContent())}
|
onClick={() => handleCopy(getCurrentContent())}
|
||||||
className="flex items-center gap-1 px-2 py-1 text-xs text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 transition-colors"
|
className="flex items-center gap-1 px-2 py-1 text-xs text-gray-600 dark:text-gray-600 hover:text-gray-700 dark:hover:text-gray-300 transition-colors"
|
||||||
title="Copy"
|
title="Copy"
|
||||||
>
|
>
|
||||||
<Copy className="w-3 h-3" />
|
<Copy className="w-3 h-3" />
|
||||||
@@ -124,7 +124,7 @@ const CodeInputs = ({
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleExport(getCurrentContent(), getExportFilename())}
|
onClick={() => handleExport(getCurrentContent(), getExportFilename())}
|
||||||
className="flex items-center gap-1 px-2 py-1 text-xs text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 transition-colors"
|
className="flex items-center gap-1 px-2 py-1 text-xs text-gray-600 dark:text-gray-600 hover:text-gray-700 dark:hover:text-gray-300 transition-colors"
|
||||||
title="Export"
|
title="Export"
|
||||||
>
|
>
|
||||||
<Download className="w-3 h-3" />
|
<Download className="w-3 h-3" />
|
||||||
|
|||||||
6
src/pages/components/ElementEditor.js
Normal file → Executable file
@@ -154,14 +154,14 @@ const ElementEditor = ({ htmlInput, setHtmlInput, onClose, onSave, previewFrameR
|
|||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<button
|
<button
|
||||||
onClick={handleCopyElement}
|
onClick={handleCopyElement}
|
||||||
className="p-1 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 transition-colors"
|
className="p-1 text-gray-600 hover:text-gray-700 dark:text-gray-600 dark:hover:text-gray-200 transition-colors"
|
||||||
title="Copy element"
|
title="Copy element"
|
||||||
>
|
>
|
||||||
<Copy className="w-4 h-4" />
|
<Copy className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
|
className="text-gray-600 hover:text-gray-700 dark:text-gray-600 dark:hover:text-gray-200"
|
||||||
>
|
>
|
||||||
✕
|
✕
|
||||||
</button>
|
</button>
|
||||||
@@ -211,7 +211,7 @@ const ElementEditor = ({ htmlInput, setHtmlInput, onClose, onSave, previewFrameR
|
|||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
Inner HTML
|
Inner HTML
|
||||||
<span className="text-xs text-gray-500 ml-2">(HTML content inside element)</span>
|
<span className="text-xs text-gray-600 ml-2">(HTML content inside element)</span>
|
||||||
</label>
|
</label>
|
||||||
<textarea
|
<textarea
|
||||||
ref={(el) => textareaRefs.current.innerHTML = el}
|
ref={(el) => textareaRefs.current.innerHTML = el}
|
||||||
|
|||||||
0
src/pages/components/PreviewFrame.js
Normal file → Executable file
0
src/pages/components/PreviewServer.js
Normal file → Executable file
10
src/pages/components/SimpleToolbar.js
Normal file → Executable file
@@ -38,8 +38,8 @@ const SimpleToolbar = ({
|
|||||||
selectedDevice === device.id
|
selectedDevice === device.id
|
||||||
? 'bg-blue-100 dark:bg-blue-900 text-blue-600 dark:text-blue-400'
|
? 'bg-blue-100 dark:bg-blue-900 text-blue-600 dark:text-blue-400'
|
||||||
: isDisabled
|
: isDisabled
|
||||||
? 'text-gray-400 dark:text-gray-600 cursor-not-allowed'
|
? 'text-gray-600 dark:text-gray-600 cursor-not-allowed'
|
||||||
: 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'
|
: 'text-gray-600 dark:text-gray-600 hover:text-gray-700 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'
|
||||||
}`}
|
}`}
|
||||||
title={isDisabled ? 'Disabled when sidebar is expanded' : `Switch to ${device.label} view`}
|
title={isDisabled ? 'Disabled when sidebar is expanded' : `Switch to ${device.label} view`}
|
||||||
>
|
>
|
||||||
@@ -55,7 +55,7 @@ const SimpleToolbar = ({
|
|||||||
{/* Refresh button */}
|
{/* Refresh button */}
|
||||||
<button
|
<button
|
||||||
onClick={onRefresh}
|
onClick={onRefresh}
|
||||||
className="flex items-center gap-2 px-3 py-2 text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-md transition-colors"
|
className="flex items-center gap-2 px-3 py-2 text-gray-600 dark:text-gray-600 hover:text-gray-700 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-md transition-colors"
|
||||||
title="Refresh preview"
|
title="Refresh preview"
|
||||||
>
|
>
|
||||||
<RotateCcw className="w-4 h-4" />
|
<RotateCcw className="w-4 h-4" />
|
||||||
@@ -65,7 +65,7 @@ const SimpleToolbar = ({
|
|||||||
{/* Sidebar toggle */}
|
{/* Sidebar toggle */}
|
||||||
<button
|
<button
|
||||||
onClick={onToggleSidebar}
|
onClick={onToggleSidebar}
|
||||||
className="flex items-center gap-2 px-3 py-2 text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-md transition-colors"
|
className="flex items-center gap-2 px-3 py-2 text-gray-600 dark:text-gray-600 hover:text-gray-700 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-md transition-colors"
|
||||||
title={showSidebar ? 'Hide sidebar' : 'Show sidebar'}
|
title={showSidebar ? 'Hide sidebar' : 'Show sidebar'}
|
||||||
>
|
>
|
||||||
{showSidebar ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
{showSidebar ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||||
@@ -75,7 +75,7 @@ const SimpleToolbar = ({
|
|||||||
{/* Fullscreen toggle */}
|
{/* Fullscreen toggle */}
|
||||||
<button
|
<button
|
||||||
onClick={onToggleFullscreen}
|
onClick={onToggleFullscreen}
|
||||||
className="flex items-center gap-2 px-3 py-2 text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-md transition-colors"
|
className="flex items-center gap-2 px-3 py-2 text-gray-600 dark:text-gray-600 hover:text-gray-700 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-md transition-colors"
|
||||||
title={isFullscreen ? 'Exit fullscreen' : 'Enter fullscreen'}
|
title={isFullscreen ? 'Exit fullscreen' : 'Enter fullscreen'}
|
||||||
>
|
>
|
||||||
{isFullscreen ? <Minimize className="w-4 h-4" /> : <Maximize className="w-4 h-4" />}
|
{isFullscreen ? <Minimize className="w-4 h-4" /> : <Maximize className="w-4 h-4" />}
|
||||||
|
|||||||
14
src/pages/components/Toolbar.js
Normal file → Executable file
@@ -43,7 +43,7 @@ const Toolbar = ({
|
|||||||
className={`p-2 rounded-md transition-colors ${
|
className={`p-2 rounded-md transition-colors ${
|
||||||
showSidebar
|
showSidebar
|
||||||
? 'bg-blue-100 dark:bg-blue-900 text-blue-600 dark:text-blue-400'
|
? 'bg-blue-100 dark:bg-blue-900 text-blue-600 dark:text-blue-400'
|
||||||
: 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-600'
|
: 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-600 hover:bg-gray-200 dark:hover:bg-gray-600'
|
||||||
}`}
|
}`}
|
||||||
title="Toggle Code Sidebar"
|
title="Toggle Code Sidebar"
|
||||||
>
|
>
|
||||||
@@ -56,7 +56,7 @@ const Toolbar = ({
|
|||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<button
|
<button
|
||||||
onClick={onRefresh}
|
onClick={onRefresh}
|
||||||
className="p-2 bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400 rounded-md hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors"
|
className="p-2 bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-600 rounded-md hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors"
|
||||||
title="Refresh Preview"
|
title="Refresh Preview"
|
||||||
>
|
>
|
||||||
<RefreshCw className="w-4 h-4" />
|
<RefreshCw className="w-4 h-4" />
|
||||||
@@ -68,7 +68,7 @@ const Toolbar = ({
|
|||||||
className={`p-2 rounded transition-colors ${
|
className={`p-2 rounded transition-colors ${
|
||||||
selectedDevice === 'desktop'
|
selectedDevice === 'desktop'
|
||||||
? 'bg-blue-500 text-white'
|
? 'bg-blue-500 text-white'
|
||||||
: 'text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-600'
|
: 'text-gray-600 dark:text-gray-600 hover:bg-gray-200 dark:hover:bg-gray-600'
|
||||||
}`}
|
}`}
|
||||||
title="Desktop View"
|
title="Desktop View"
|
||||||
>
|
>
|
||||||
@@ -79,7 +79,7 @@ const Toolbar = ({
|
|||||||
className={`p-2 rounded transition-colors ${
|
className={`p-2 rounded transition-colors ${
|
||||||
selectedDevice === 'tablet'
|
selectedDevice === 'tablet'
|
||||||
? 'bg-blue-500 text-white'
|
? 'bg-blue-500 text-white'
|
||||||
: 'text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-600'
|
: 'text-gray-600 dark:text-gray-600 hover:bg-gray-200 dark:hover:bg-gray-600'
|
||||||
}`}
|
}`}
|
||||||
title="Tablet View"
|
title="Tablet View"
|
||||||
>
|
>
|
||||||
@@ -90,7 +90,7 @@ const Toolbar = ({
|
|||||||
className={`p-2 rounded transition-colors ${
|
className={`p-2 rounded transition-colors ${
|
||||||
selectedDevice === 'mobile'
|
selectedDevice === 'mobile'
|
||||||
? 'bg-blue-500 text-white'
|
? 'bg-blue-500 text-white'
|
||||||
: 'text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-600'
|
: 'text-gray-600 dark:text-gray-600 hover:bg-gray-200 dark:hover:bg-gray-600'
|
||||||
}`}
|
}`}
|
||||||
title="Mobile View"
|
title="Mobile View"
|
||||||
>
|
>
|
||||||
@@ -104,7 +104,7 @@ const Toolbar = ({
|
|||||||
className={`p-2 rounded-md transition-colors ${
|
className={`p-2 rounded-md transition-colors ${
|
||||||
isInspectModeActive
|
isInspectModeActive
|
||||||
? 'bg-blue-100 dark:bg-blue-900 text-blue-600 dark:text-blue-400'
|
? 'bg-blue-100 dark:bg-blue-900 text-blue-600 dark:text-blue-400'
|
||||||
: 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-600'
|
: 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-600 hover:bg-gray-200 dark:hover:bg-gray-600'
|
||||||
}`}
|
}`}
|
||||||
title="Inspect Element"
|
title="Inspect Element"
|
||||||
>
|
>
|
||||||
@@ -117,7 +117,7 @@ const Toolbar = ({
|
|||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => onToggleFullscreen()}
|
onClick={() => onToggleFullscreen()}
|
||||||
className="p-2 bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400 rounded-md hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors"
|
className="p-2 bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-600 rounded-md hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors"
|
||||||
title={isFullscreen ? "Exit Fullscreen" : "Enter Fullscreen"}
|
title={isFullscreen ? "Exit Fullscreen" : "Enter Fullscreen"}
|
||||||
>
|
>
|
||||||
{isFullscreen ? <Minimize2 className="w-4 h-4" /> : <Maximize2 className="w-4 h-4" />}
|
{isFullscreen ? <Minimize2 className="w-4 h-4" /> : <Maximize2 className="w-4 h-4" />}
|
||||||
|
|||||||
0
src/styles/device-frames.css
Normal file → Executable file
0
src/styles/diff-theme.css
Normal file → Executable file
100
src/styles/markdown-preview.css
Normal file → Executable file
@@ -1,7 +1,9 @@
|
|||||||
/* GitHub-style Markdown Preview Styling */
|
/* GitHub-style Markdown Preview Styling */
|
||||||
.markdown-preview {
|
.markdown-preview {
|
||||||
color: #24292f;
|
color: #24292f;
|
||||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans", Helvetica, Arial, sans-serif;
|
font-family:
|
||||||
|
-apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans", Helvetica,
|
||||||
|
Arial, sans-serif;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
word-wrap: break-word;
|
word-wrap: break-word;
|
||||||
@@ -85,7 +87,9 @@
|
|||||||
font-size: 85%;
|
font-size: 85%;
|
||||||
background-color: rgba(175, 184, 193, 0.2);
|
background-color: rgba(175, 184, 193, 0.2);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
|
font-family:
|
||||||
|
ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas,
|
||||||
|
"Liberation Mono", monospace;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark .markdown-preview code {
|
.dark .markdown-preview code {
|
||||||
@@ -378,3 +382,95 @@
|
|||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
margin: 16px 0;
|
margin: 16px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Tiptap specific styling overrides to match prose */
|
||||||
|
.tiptap p.is-editor-empty:first-child::before {
|
||||||
|
color: #adb5bd;
|
||||||
|
content: attr(data-placeholder);
|
||||||
|
float: left;
|
||||||
|
height: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap ul[data-type="taskList"] {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap ul[data-type="taskList"] li {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap ul[data-type="taskList"] li > label {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
user-select: none;
|
||||||
|
margin-top: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap ul[data-type="taskList"] li > div {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap ul[data-type="taskList"] li > div > p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap p {
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 0.65em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Printing logic for PDF export */
|
||||||
|
@media print {
|
||||||
|
.tiptap pre,
|
||||||
|
.markdown-preview pre {
|
||||||
|
white-space: pre-wrap !important;
|
||||||
|
word-wrap: break-word !important;
|
||||||
|
break-inside: avoid !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-block-header {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Custom Node Views (Code Block) */
|
||||||
|
.tiptap .code-block-wrapper {
|
||||||
|
margin-bottom: 0.65em;
|
||||||
|
border-radius: 6px;
|
||||||
|
background-color: #0d1117;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap .code-block-wrapper pre {
|
||||||
|
margin: 0 !important;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 0 0 6px 6px;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Markdown Content Wrapper Padding Strategies */
|
||||||
|
.markdown-content-wrapper.is-normal.is-read-mode > .prose {
|
||||||
|
padding-bottom: 3rem; /* 48px */
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-content-wrapper.is-fullscreen.is-read-mode > .prose {
|
||||||
|
padding-bottom: 12rem; /* 192px (Accounts for 90px banner ad + spacing) */
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-content-wrapper.is-normal.is-edit-mode > div {
|
||||||
|
padding-bottom: 3rem; /* 48px */
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-content-wrapper.is-fullscreen.is-edit-mode > div {
|
||||||
|
padding-bottom: 12rem; /* 192px (Accounts for 90px banner ad + spacing) */
|
||||||
|
}
|
||||||
|
|||||||