chore: clean up and restructure repository
This commit is contained in:
97
src/components/admin/PageShell.jsx
Normal file
97
src/components/admin/PageShell.jsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import * as React from 'react';
|
||||
import { ChevronRight } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/**
|
||||
* Shared page furniture for the Admin product.
|
||||
*
|
||||
* Admin pages are dense and numerous, so the header, the section labels and the
|
||||
* vertical rhythm are defined once here. That is what makes seven independently
|
||||
* built pages read as one product rather than seven takes on a dashboard.
|
||||
*/
|
||||
|
||||
/**
|
||||
* AdminPage — the standard page frame: title, subtitle, right-aligned controls,
|
||||
* then stacked sections.
|
||||
*
|
||||
* Controls live in the page rather than in the global chrome, which is why the
|
||||
* Admin header only needs one 56px bar.
|
||||
*/
|
||||
/** @param {any} props */
|
||||
export function AdminPage({ title, subtitle, meta, actions, tabs, children, className }) {
|
||||
return (
|
||||
<div className={cn('space-y-6', className)}>
|
||||
<header className="flex flex-wrap items-center justify-between gap-4 border-b border-border/50 pb-4">
|
||||
<div className="min-w-0 space-y-1">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<h1 className="font-heading text-title-lg font-bold tracking-tight text-ink-1 sm:text-2xl">{title}</h1>
|
||||
{/* A count of whatever the page is a list of, stated beside its
|
||||
name rather than only inside the list. */}
|
||||
{meta && <span className="text-body-sm tabular-nums text-ink-4">{meta}</span>}
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full bg-blue-50 px-2.5 py-0.5 text-[11px] font-semibold text-blue-600 dark:bg-blue-950/60 dark:text-blue-400 border border-blue-200/60 dark:border-blue-800/40">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-blue-600 dark:bg-blue-400" />
|
||||
Live System
|
||||
</span>
|
||||
</div>
|
||||
{subtitle && <p className="text-body-sm text-ink-3 font-normal">{subtitle}</p>}
|
||||
</div>
|
||||
{actions && <div className="flex flex-wrap items-center gap-2.5">{actions}</div>}
|
||||
</header>
|
||||
|
||||
{tabs}
|
||||
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* SectionTitle — the label above a region.
|
||||
*
|
||||
* Sentence-case and small rather than a heavy all-caps overline: on a page with
|
||||
* four or five sections, loud labels compete with the data they introduce.
|
||||
*/
|
||||
/** @param {any} props */
|
||||
export function SectionTitle({ id, title, meta, action, className }) {
|
||||
return (
|
||||
<div className={cn('flex flex-wrap items-baseline justify-between gap-2', className)}>
|
||||
<div className="flex items-baseline gap-2">
|
||||
<h2 id={id} className="font-heading text-body font-semibold text-ink-1">{title}</h2>
|
||||
{meta && <span className="text-caption text-ink-4">{meta}</span>}
|
||||
</div>
|
||||
|
||||
{action && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={action.onClick}
|
||||
className="inline-flex items-center gap-0.5 rounded text-caption font-medium text-krow-blue transition-colors hover:underline
|
||||
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50"
|
||||
>
|
||||
{action.label}
|
||||
<ChevronRight className="h-3 w-3" aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Toolbar — the controls row above a table or list.
|
||||
*
|
||||
* Search takes the remaining space, filters keep their natural width, and the
|
||||
* primary action is pushed to the end, so every management page has the same
|
||||
* control geometry.
|
||||
*/
|
||||
/** @param {any} props */
|
||||
export function Toolbar({ search = null, filters = null, actions = null, meta = null, className = '' }) {
|
||||
return (
|
||||
<div className={cn('space-y-2', className)}>
|
||||
<div className="flex flex-col gap-2 lg:flex-row lg:items-center">
|
||||
{search && <div className="min-w-0 lg:max-w-xs lg:flex-1">{search}</div>}
|
||||
{filters && <div className="flex flex-wrap items-center gap-2">{filters}</div>}
|
||||
{actions && <div className="flex items-center gap-2 lg:ml-auto">{actions}</div>}
|
||||
</div>
|
||||
{meta && <p className="text-caption text-ink-4">{meta}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
73
src/components/admin/RoleGlyph.jsx
Normal file
73
src/components/admin/RoleGlyph.jsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import {
|
||||
Briefcase, ChefHat, ConciergeBell, HardHat, PackageSearch, ShieldCheck, Sparkles, Truck, Wine,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/**
|
||||
* RoleGlyph — the visual identity on a position card.
|
||||
*
|
||||
* Positions in this dataset have no photograph, and there is nothing to
|
||||
* photograph anyway: a role is not a person. The two obvious fallbacks are both
|
||||
* worse than this one — two-letter initials ("BA", "SE") read as a placeholder
|
||||
* someone forgot to replace, and stock photography attaches an invented face to a
|
||||
* job requisition, which is misleading on a page about hiring real people.
|
||||
*
|
||||
* So the glyph is the role's own trade: a chef's hat, a wine glass, a hard hat.
|
||||
* It is drawn from the existing icon language and the existing brand tint, it is
|
||||
* stable for a given category, and it tells you what kind of work the position is
|
||||
* from across the room — which is the actual job of an image in a card grid.
|
||||
*/
|
||||
|
||||
/** Category to icon. Matched loosely, so a new "Head Chef" still gets the hat. */
|
||||
/** @type {[RegExp, React.ComponentType<any>][]} */
|
||||
const GLYPHS = [
|
||||
[/chef|cook|kitchen/i, ChefHat],
|
||||
[/bartend|bar|mixolog/i, Wine],
|
||||
[/server|wait|host|banquet|concierge/i, ConciergeBell],
|
||||
[/security|guard|officer/i, ShieldCheck],
|
||||
[/pick|warehouse|stock|inventory/i, PackageSearch],
|
||||
[/driver|delivery|logistic/i, Truck],
|
||||
[/construct|labour|labor|site/i, HardHat],
|
||||
];
|
||||
|
||||
export function iconForRole(category, title = '') {
|
||||
const subject = `${category || ''} ${title || ''}`;
|
||||
const match = GLYPHS.find(([pattern]) => pattern.test(subject));
|
||||
return match ? match[1] : Briefcase;
|
||||
}
|
||||
|
||||
const SIZES = {
|
||||
sm: { box: 'h-9 w-9 rounded-lg', icon: 'h-4 w-4' },
|
||||
md: { box: 'h-11 w-11 rounded-xl', icon: 'h-5 w-5' },
|
||||
lg: { box: 'h-14 w-14 rounded-xl', icon: 'h-6 w-6' },
|
||||
};
|
||||
|
||||
/** @param {any} props */
|
||||
export function RoleGlyph({ category, title, size = 'md', aiGenerated = false, className = '' }) {
|
||||
const Icon = iconForRole(category, title);
|
||||
const s = SIZES[size] || SIZES.md;
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'relative grid shrink-0 place-items-center bg-krow-blue-tint text-krow-blue ring-1 ring-krow-blue/10',
|
||||
s.box,
|
||||
className
|
||||
)}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<Icon className={s.icon} />
|
||||
|
||||
{/* AI-built positions carry the same spark used everywhere else for
|
||||
generated content, so provenance is legible without a second badge
|
||||
competing for room in the card header. */}
|
||||
{aiGenerated && (
|
||||
<span className="absolute -right-1 -top-1 grid h-4 w-4 place-items-center rounded-full bg-krow-yellow text-ink-1 ring-2 ring-white">
|
||||
<Sparkles className="h-2 w-2" />
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default RoleGlyph;
|
||||
@@ -15,7 +15,7 @@ import { toFICO } from '@/lib/talentHome';
|
||||
/* The vetting criteria's own labels. Read from the position model rather than
|
||||
restated here, so the panel calls a weight what the form calls it. */
|
||||
import { CRITERIA_LABELS } from '@/lib/positionModel';
|
||||
import { PERMISSIONS, PROFILE_ACTIONS } from '@/pages/admin/permissions';
|
||||
import { PERMISSIONS, PROFILE_ACTIONS } from '@/lib/admin/permissions';
|
||||
|
||||
/**
|
||||
* Admin capabilities — Control Center, Candidates Analysis, Activity.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { plural, verb } from './insights';
|
||||
import { prioritise, workforceStatusFor } from '@/lib/workforce';
|
||||
import { PERMISSIONS } from '@/pages/admin/permissions';
|
||||
import { PERMISSIONS } from '@/lib/admin/permissions';
|
||||
|
||||
/**
|
||||
* Dynamic greetings and suggested prompts.
|
||||
|
||||
@@ -162,7 +162,7 @@ function normalizeDraftChip(chip) {
|
||||
/* "Continue the Bartender draft", however it was stored: the label is the
|
||||
question, and no capability or route may intercept it. */
|
||||
if (CONTINUE_DRAFT.test(label) || CONTINUE_DRAFT.test(prompt)) {
|
||||
const { capability, route, ...rest } = chip;
|
||||
const { capability: _capability, route: _route, ...rest } = chip;
|
||||
return { ...rest, prompt: CONTINUE_DRAFT.test(label) ? label : prompt };
|
||||
}
|
||||
|
||||
@@ -170,7 +170,7 @@ function normalizeDraftChip(chip) {
|
||||
and told the reader to open the form. The same words now resolve to the
|
||||
draft flow, which continues one or offers the several by name. */
|
||||
if (OLD_DRAFTS_PROMPT.test(prompt) || chip.capability === 'position-drafts') {
|
||||
const { capability, route, ...rest } = chip;
|
||||
const { capability: _capability, route: _route, ...rest } = chip;
|
||||
return { ...rest, prompt: prompt || 'Which positions are still in draft?' };
|
||||
}
|
||||
|
||||
|
||||
@@ -95,7 +95,7 @@ export default function AIInterviewModal({ open, onClose, application, job, cond
|
||||
setMessages(newMessages);
|
||||
lastUserMessageTime.current = Date.now();
|
||||
await speak(question);
|
||||
} catch (_e) {
|
||||
} catch {
|
||||
setError('Interview connection issue. Please retry.');
|
||||
}
|
||||
}, [jobTitle, speak, language]);
|
||||
@@ -182,7 +182,7 @@ export default function AIInterviewModal({ open, onClose, application, job, cond
|
||||
} });
|
||||
setEvaluation(result);
|
||||
setPhase('done');
|
||||
} catch (_e) {
|
||||
} catch {
|
||||
setError('Evaluation failed. Please try again.');
|
||||
setPhase('active');
|
||||
}
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
const SNAPSHOTS = [
|
||||
{ key: 'krow', label: 'KROW Score', get: (p) => Math.round(p.krow_score || 0) },
|
||||
{ key: 'badges', label: 'Other Badges', get: (p) => (p.earned_badges || []).length },
|
||||
{ key: 'skills', label: 'Skills', get: (p) => (p.skills || []).length },
|
||||
{ key: 'shares', label: 'Shares', get: () => 0 },
|
||||
];
|
||||
|
||||
export default function ActivitySnapshot({ profile }) {
|
||||
return (
|
||||
<div className="rounded-2xl bg-[#E8EEFE] p-6 border border-[#C7D4F7]">
|
||||
<h3 className="font-serif text-center text-[18px] font-semibold mb-5" style={{ color: '#333F48' }}>
|
||||
Activity snapshot
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 sm:gap-4">
|
||||
{SNAPSHOTS.map((s) => (
|
||||
<div key={s.key} className="bg-white rounded-2xl shadow-sm p-4 flex flex-col items-center justify-center aspect-square">
|
||||
<span className="text-[34px] font-bold leading-none" style={{ color: '#0838E0' }}>
|
||||
{s.get(profile)}
|
||||
</span>
|
||||
<span className="text-[12px] text-[#333F48] mt-2 text-center">{s.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,8 @@
|
||||
import React from 'react';
|
||||
import { Users, TrendingUp, Brain, Mic, UserCheck, Target } from 'lucide-react';
|
||||
|
||||
export default function AnalyticsMetrics({ applications, interviews, staff }) {
|
||||
export default function AnalyticsMetrics({ applications, interviews: _interviews, staff }) {
|
||||
const totalApplicants = applications.length;
|
||||
const screened = applications.filter(a => a.status !== 'applied');
|
||||
const scored = applications.filter(a => a.ai_score > 0);
|
||||
const avgScore = scored.length ? Math.round(scored.reduce((s, a) => s + a.ai_score, 0) / scored.length) : 0;
|
||||
const interviewed = applications.filter(a => a.status === 'interview' || a.interview_id);
|
||||
|
||||
@@ -1,296 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Loader2, UploadCloud, FileCheck2, X, Camera, RefreshCw } from 'lucide-react';
|
||||
import { useCertifications } from '@/lib/krowHooks';
|
||||
import { base44 } from '@/api/base44Client';
|
||||
|
||||
const DEFAULT_CERTS = ['Food Handler Card', 'ServSafe', 'ABC License', 'TIPS Certified', 'CPR/First Aid', 'Background Check Cleared'];
|
||||
const AVAIL_OPTIONS = ['Weekdays', 'Weekends', 'Evenings', 'Mornings', 'Overnight', 'On-Call'];
|
||||
const ENGLISH_LEVELS = ['basic', 'conversational', 'fluent', 'native'];
|
||||
|
||||
export default function ApplicantForm({ onSubmit, submitting }) {
|
||||
const [form, setForm] = useState({
|
||||
applicant_name: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
years_experience: 0,
|
||||
english_level: 'basic',
|
||||
certifications: [],
|
||||
availability: [],
|
||||
skills: [],
|
||||
professional_summary: '',
|
||||
cover_letter: '',
|
||||
certification_files: [],
|
||||
selfie_url: '',
|
||||
companies_worked: [],
|
||||
});
|
||||
const [customSkill, setCustomSkill] = useState('');
|
||||
const [customCompany, setCustomCompany] = useState('');
|
||||
const [uploadingSelfie, setUploadingSelfie] = useState(false);
|
||||
const [uploadingCert, setUploadingCert] = useState(false);
|
||||
const [uploadError, setUploadError] = useState('');
|
||||
const { data: certifications = [] } = useCertifications();
|
||||
const certOptions = certifications.length > 0 ? certifications.map(c => c.name) : DEFAULT_CERTS;
|
||||
|
||||
const update = (field, value) => setForm({ ...form, [field]: value });
|
||||
|
||||
const toggleCert = (cert) => {
|
||||
update('certifications', form.certifications.includes(cert)
|
||||
? form.certifications.filter(c => c !== cert)
|
||||
: [...form.certifications, cert]);
|
||||
};
|
||||
|
||||
const toggleAvail = (slot) => {
|
||||
update('availability', form.availability.includes(slot)
|
||||
? form.availability.filter(a => a !== slot)
|
||||
: [...form.availability, slot]);
|
||||
};
|
||||
|
||||
const handleCertUpload = async (e) => {
|
||||
const files = Array.from(e.target.files || []);
|
||||
if (files.length === 0) return;
|
||||
setUploadingCert(true);
|
||||
try {
|
||||
const uploaded = [];
|
||||
for (const file of files) {
|
||||
const { file_url } = await base44.integrations.Core.UploadFile({ file });
|
||||
uploaded.push(file_url);
|
||||
}
|
||||
update('certification_files', [...form.certification_files, ...uploaded]);
|
||||
} catch (err) {
|
||||
// ignore
|
||||
}
|
||||
setUploadingCert(false);
|
||||
e.target.value = '';
|
||||
};
|
||||
|
||||
const removeCertFile = (idx) => {
|
||||
update('certification_files', form.certification_files.filter((_, i) => i !== idx));
|
||||
};
|
||||
|
||||
const handleSelfieUpload = async (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
setUploadingSelfie(true);
|
||||
try {
|
||||
const { file_url } = await base44.integrations.Core.UploadFile({ file });
|
||||
update('selfie_url', file_url);
|
||||
} catch (err) {
|
||||
// ignore
|
||||
}
|
||||
setUploadingSelfie(false);
|
||||
e.target.value = '';
|
||||
};
|
||||
|
||||
const handleSubmit = (e) => {
|
||||
e.preventDefault();
|
||||
if (form.certification_files.length === 0) {
|
||||
setUploadError('Please upload at least one certification document to continue.');
|
||||
return;
|
||||
}
|
||||
if (!form.selfie_url) {
|
||||
setUploadError("Please take a selfie photo before submitting — it is required for your interview.");
|
||||
return;
|
||||
}
|
||||
setUploadError('');
|
||||
onSubmit(form);
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
{/* The Resume Dies — identity-first. Create yours at /identity */}
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label className="text-[13px] text-[#374151]">Full Name *</Label>
|
||||
<Input value={form.applicant_name} onChange={(e) => update('applicant_name', e.target.value)} required className="mt-1.5" />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-[13px] text-[#374151]">Email *</Label>
|
||||
<Input type="email" value={form.email} onChange={(e) => update('email', e.target.value)} required className="mt-1.5" />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-[13px] text-[#374151]">Phone</Label>
|
||||
<Input value={form.phone} onChange={(e) => update('phone', e.target.value)} className="mt-1.5" />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-[13px] text-[#374151]">Years of Experience</Label>
|
||||
<Input type="number" min="0" value={form.years_experience} onChange={(e) => update('years_experience', parseInt(e.target.value) || 0)} className="mt-1.5" />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-[13px] text-[#374151]">English Level</Label>
|
||||
<select value={form.english_level} onChange={(e) => update('english_level', e.target.value)} className="w-full mt-1.5 h-9 rounded-md border border-[#E5E7EB] bg-white px-3 text-[13px] focus:border-[#0838E0] focus:outline-none">
|
||||
{ENGLISH_LEVELS.map(l => <option key={l} value={l}>{l.charAt(0).toUpperCase() + l.slice(1)}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-[13px] text-[#374151]">Skills</Label>
|
||||
<div className="flex gap-2 mt-1.5">
|
||||
<Input value={customSkill} onChange={(e) => setCustomSkill(e.target.value)} placeholder="Add a skill..." className="flex-1" />
|
||||
<Button type="button" variant="outline" onClick={() => { if (customSkill.trim()) { update('skills', [...form.skills, customSkill.trim()]); setCustomSkill(''); } }}>Add</Button>
|
||||
</div>
|
||||
{form.skills.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 mt-2">
|
||||
{form.skills.map((s, i) => (
|
||||
<span key={i} className="inline-flex items-center gap-1 px-2 py-1 rounded-full bg-[#F3F4F6] text-[12px] text-[#374151]">
|
||||
{s}
|
||||
<button type="button" onClick={() => update('skills', form.skills.filter((_, idx) => idx !== i))} className="text-[#9CA3AF] hover:text-[#EF4444]">×</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-[13px] text-[#374151]">Companies You've Worked At</Label>
|
||||
<div className="flex gap-2 mt-1.5">
|
||||
<Input value={customCompany} onChange={(e) => setCustomCompany(e.target.value)} placeholder="e.g. Google, Meta, Marriott..." className="flex-1" />
|
||||
<Button type="button" variant="outline" onClick={() => { if (customCompany.trim()) { update('companies_worked', [...form.companies_worked, customCompany.trim()]); setCustomCompany(''); } }}>Add</Button>
|
||||
</div>
|
||||
{form.companies_worked.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 mt-2">
|
||||
{form.companies_worked.map((c, i) => (
|
||||
<span key={i} className="inline-flex items-center gap-1 px-2 py-1 rounded-full bg-[#EEF3FE] text-[12px] text-[#1E40AF] font-medium border border-[#DBEAFE]">
|
||||
{c}
|
||||
<button type="button" onClick={() => update('companies_worked', form.companies_worked.filter((_, idx) => idx !== i))} className="text-[#9CA3AF] hover:text-[#EF4444]">×</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-[13px] text-[#374151]">Certifications</Label>
|
||||
<div className="flex flex-wrap gap-2 mt-1.5">
|
||||
{certOptions.map(cert => (
|
||||
<button
|
||||
key={cert}
|
||||
type="button"
|
||||
onClick={() => toggleCert(cert)}
|
||||
className={`px-3 py-1.5 rounded-full text-[12px] font-medium border transition-all ${
|
||||
form.certifications.includes(cert) ? 'bg-[#0838E0] border-[#0838E0] text-white' : 'border-[#E5E7EB] text-[#6B7280] hover:border-[#D1D5DB]'
|
||||
}`}
|
||||
>
|
||||
{cert}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Certification file upload */}
|
||||
<div className="mt-3">
|
||||
<Label className="text-[12px] text-[#6B7280]">Upload certification documents</Label>
|
||||
<label className="mt-1.5 flex flex-col items-center justify-center gap-1.5 border-2 border-dashed border-[#E5E7EB] rounded-lg py-4 cursor-pointer hover:border-[#0838E0] hover:bg-[#EEF3FE]/40 transition-colors">
|
||||
{uploadingCert ? (
|
||||
<Loader2 className="w-5 h-5 text-[#0838E0] animate-spin" />
|
||||
) : (
|
||||
<UploadCloud className="w-5 h-5 text-[#9CA3AF]" />
|
||||
)}
|
||||
<span className="text-[12px] text-[#6B7280]">
|
||||
{uploadingCert ? 'Uploading...' : 'Click to upload (PDF, JPG, PNG)'}
|
||||
</span>
|
||||
<input
|
||||
type="file"
|
||||
accept=".pdf,.jpg,.jpeg,.png"
|
||||
multiple
|
||||
onChange={handleCertUpload}
|
||||
disabled={uploadingCert}
|
||||
className="hidden"
|
||||
/>
|
||||
</label>
|
||||
{uploadError && (
|
||||
<p className="text-[12px] text-[#EF4444] mt-1">{uploadError}</p>
|
||||
)}
|
||||
{form.certification_files.length > 0 && (
|
||||
<div className="mt-2 space-y-1.5">
|
||||
{form.certification_files.map((url, i) => (
|
||||
<div key={i} className="flex items-center justify-between gap-2 px-3 py-1.5 rounded-lg bg-[#F0FDF4] border border-[#BBF7D0]">
|
||||
<span className="flex items-center gap-1.5 text-[12px] text-[#166534] truncate">
|
||||
<FileCheck2 className="w-3.5 h-3.5 shrink-0" />
|
||||
{decodeURIComponent(url.split('/').pop()?.split('?')[0] || `Certification ${i + 1}`)}
|
||||
</span>
|
||||
<button type="button" onClick={() => removeCertFile(i)} className="text-[#9CA3AF] hover:text-[#EF4444] shrink-0">
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Selfie Photo */}
|
||||
<div>
|
||||
<Label className="text-[13px] text-[#374151]">Selfie Photo *</Label>
|
||||
<p className="text-[11px] text-[#6B7280] mt-0.5 mb-2">Take a quick selfie so your interviewer can see you before the interview.</p>
|
||||
{form.selfie_url ? (
|
||||
<div className="flex items-center gap-3">
|
||||
<img src={form.selfie_url} alt="Selfie" className="w-20 h-20 rounded-xl object-cover border border-[#E5E7EB]" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => update('selfie_url', '')}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-[#F3F4F6] hover:bg-[#E5E7EB] text-[#374151] font-medium text-[13px] transition-colors"
|
||||
>
|
||||
<RefreshCw className="w-3.5 h-3.5" /> Retake
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<label className="flex items-center gap-3 border-2 border-dashed border-[#E5E7EB] rounded-xl p-4 cursor-pointer hover:border-[#0838E0] hover:bg-[#EEF3FE]/40 transition-colors">
|
||||
{uploadingSelfie ? (
|
||||
<Loader2 className="w-8 h-8 text-[#0838E0] animate-spin shrink-0" />
|
||||
) : (
|
||||
<div className="w-12 h-12 rounded-full bg-[#EEF3FE] flex items-center justify-center shrink-0">
|
||||
<Camera className="w-6 h-6 text-[#0838E0]" />
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1">
|
||||
<p className="text-[13px] font-medium text-[#374151]">
|
||||
{uploadingSelfie ? 'Uploading...' : 'Take or upload a selfie'}
|
||||
</p>
|
||||
<p className="text-[11px] text-[#6B7280]">Click to use your camera or select a photo</p>
|
||||
</div>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
capture="user"
|
||||
onChange={handleSelfieUpload}
|
||||
disabled={uploadingSelfie}
|
||||
className="hidden"
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-[13px] text-[#374151]">Availability</Label>
|
||||
<div className="flex flex-wrap gap-2 mt-1.5">
|
||||
{AVAIL_OPTIONS.map(slot => (
|
||||
<button
|
||||
key={slot}
|
||||
type="button"
|
||||
onClick={() => toggleAvail(slot)}
|
||||
className={`px-3 py-1.5 rounded-full text-[12px] font-medium border transition-all ${
|
||||
form.availability.includes(slot) ? 'bg-[#059669] border-[#059669] text-white' : 'border-[#E5E7EB] text-[#6B7280] hover:border-[#D1D5DB]'
|
||||
}`}
|
||||
>
|
||||
{slot}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-[13px] text-[#374151]">Professional Summary</Label>
|
||||
<textarea value={form.professional_summary} onChange={(e) => update('professional_summary', e.target.value)} className="w-full mt-1.5 min-h-[70px] p-3 text-[13px] rounded-lg border border-[#E5E7EB] focus:border-[#0838E0] focus:outline-none resize-none" />
|
||||
</div>
|
||||
|
||||
<Button type="submit" disabled={submitting} className="w-full bg-[#0838E0] hover:bg-[#062BAF] h-11 text-[14px]">
|
||||
{submitting ? <Loader2 className="w-4 h-4 mr-2 animate-spin" /> : null}
|
||||
Submit Application
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Award } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const LEVEL_STYLES = {
|
||||
bronze: 'bg-[#FEF3C7] text-[#92400E] border-[#FCD34D]',
|
||||
silver: 'bg-[#F3F4F6] text-[#4B5563] border-[#D1D5DB]',
|
||||
gold: 'bg-[#FEF9E7] text-[#92400E] border-[#F9E547]',
|
||||
platinum: 'bg-[#EEF3FE] text-[#1E40AF] border-[#BFDBFE]',
|
||||
};
|
||||
|
||||
export default function BadgeChip({ name, level = 'bronze', earned_date, size = 'md' }) {
|
||||
return (
|
||||
<div className={cn('inline-flex items-center gap-1.5 rounded-full border px-3 py-1.5', LEVEL_STYLES[level] || LEVEL_STYLES.bronze)}>
|
||||
<Award className={size === 'sm' ? 'w-3 h-3' : 'w-3.5 h-3.5'} />
|
||||
<span className={cn('font-medium', size === 'sm' ? 'text-[11px]' : 'text-[12px]')}>{name}</span>
|
||||
{earned_date && <span className="text-[10px] opacity-70">· {new Date(earned_date).toLocaleDateString()}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -117,7 +117,7 @@ export default function CandidateCard({ application, jobTitle, rank, onAction, o
|
||||
try {
|
||||
await base44.entities.JobApplication.update(application.id, { status: 'rejected' });
|
||||
toast.success(`${application.applicant_name} declined`);
|
||||
} catch (_err) {
|
||||
} catch {
|
||||
toast.error('Failed to decline candidate');
|
||||
}
|
||||
}
|
||||
@@ -131,7 +131,7 @@ export default function CandidateCard({ application, jobTitle, rank, onAction, o
|
||||
try {
|
||||
await base44.entities.JobApplication.delete(application.id);
|
||||
toast.success(`${application.applicant_name} deleted`);
|
||||
} catch (_err) {
|
||||
} catch {
|
||||
toast.error('Failed to delete candidate');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
// A learning path rendered as a quiet vertical journey to the target role.
|
||||
export default function CareerJourney({ path, completedIds = [] }) {
|
||||
const steps = path.steps || [];
|
||||
const target = path.target_role || path.name;
|
||||
const allDone = steps.length > 0 && steps.every((s) => completedIds.has(s.course_id));
|
||||
const rungs = [
|
||||
...steps.map((s, i) => ({
|
||||
label: s.course_title || `Step ${i + 1}`,
|
||||
done: completedIds.has(s.course_id),
|
||||
})),
|
||||
{ label: target, done: allDone, isTarget: true },
|
||||
];
|
||||
const doneCount = steps.filter((s) => completedIds.has(s.course_id)).length;
|
||||
const pct = steps.length ? Math.round((doneCount / steps.length) * 100) : 0;
|
||||
|
||||
return (
|
||||
<div className="py-6">
|
||||
<div className="flex items-baseline justify-between mb-5">
|
||||
<h3 className="text-[17px] font-semibold text-[#0F172A]">{path.name}</h3>
|
||||
<span className="text-[13px] text-[#64748B]">{pct}%</span>
|
||||
</div>
|
||||
{path.description && <p className="text-[13px] text-[#64748B] mb-4">{path.description}</p>}
|
||||
<div className="relative pl-6">
|
||||
<div className="absolute left-[7px] top-2 bottom-2 w-px bg-[#E2E8F0]" />
|
||||
{rungs.map((r, i) => (
|
||||
<div key={i} className="relative flex items-center gap-3 py-2">
|
||||
<div
|
||||
className={`absolute -left-6 w-3.5 h-3.5 rounded-full ${
|
||||
r.done ? 'bg-[#0838E0]' : 'bg-white border-2 border-[#E2E8F0]'
|
||||
}`}
|
||||
/>
|
||||
<span
|
||||
className={`text-[15px] ${
|
||||
r.isTarget
|
||||
? 'font-semibold text-[#0838E0]'
|
||||
: r.done
|
||||
? 'text-[#0F172A] font-medium'
|
||||
: 'text-[#64748B]'
|
||||
}`}
|
||||
>
|
||||
{r.label}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { ArrowRight } from 'lucide-react';
|
||||
|
||||
/** @param {any} props */
|
||||
function Stage({ label, value, sub = '', accent = false, to = null }) {
|
||||
const body = (
|
||||
<div className="text-center sm:text-left">
|
||||
<div className="text-[11px] font-medium uppercase tracking-wider text-[#94A3B8]">{label}</div>
|
||||
<div className={`text-[20px] sm:text-[26px] font-semibold mt-1 tracking-tight ${accent ? 'text-[#0838E0]' : 'text-[#0F172A]'}`}>
|
||||
{value}
|
||||
</div>
|
||||
{sub && <div className="text-[13px] text-[#64748B] mt-0.5">{sub}</div>}
|
||||
</div>
|
||||
);
|
||||
return to ? (
|
||||
<Link to={to} className="hover:opacity-80 transition-opacity">{body}</Link>
|
||||
) : body;
|
||||
}
|
||||
|
||||
export default function CareerTrajectory({ profile, nextPath, nextReady, futureRole, futureIncome }) {
|
||||
const current = profile?.current_position || profile?.desired_position || 'Starting Out';
|
||||
const next = nextPath?.target_role || nextPath?.name || 'Next Role';
|
||||
return (
|
||||
<section className="text-center pt-4 pb-12 sm:pt-8 sm:pb-20">
|
||||
<span className="text-[12px] font-medium tracking-wide text-[#0838E0] uppercase">Your Career</span>
|
||||
<h1 className="text-[32px] sm:text-[44px] font-bold tracking-tight text-[#0F172A] mt-4 leading-[1.05]">
|
||||
Everything revolves around your future.
|
||||
</h1>
|
||||
<p className="text-[15px] text-[#64748B] mt-4 max-w-sm mx-auto">
|
||||
Not a university. A Career Acceleration Engine.
|
||||
</p>
|
||||
|
||||
<div className="mt-12 sm:mt-16 flex flex-col sm:flex-row items-center justify-center gap-5 sm:gap-10">
|
||||
<Stage label="Current" value={current} />
|
||||
<ArrowRight className="w-4 h-4 text-[#CBD5E1] rotate-90 sm:rotate-0" />
|
||||
<Stage label="Next Role" value={next} sub={`${nextReady}% ready`} accent to={nextPath ? '/university' : null} />
|
||||
<ArrowRight className="w-4 h-4 text-[#CBD5E1] rotate-90 sm:rotate-0" />
|
||||
<Stage label="Future" value={futureRole} sub={futureIncome || ' '} />
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -20,7 +20,7 @@ export default function CertificationManager() {
|
||||
await createCert.mutateAsync({ name });
|
||||
setNewCert('');
|
||||
toast.success(`"${name}" added to certifications`);
|
||||
} catch (e) {
|
||||
} catch {
|
||||
toast.error('Failed to add certification');
|
||||
}
|
||||
};
|
||||
@@ -29,7 +29,7 @@ export default function CertificationManager() {
|
||||
try {
|
||||
await deleteCert.mutateAsync(cert.id);
|
||||
toast.success(`"${cert.name}" removed`);
|
||||
} catch (e) {
|
||||
} catch {
|
||||
toast.error('Failed to remove certification');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -2,7 +2,7 @@ import React from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Clock, DollarSign, Award, Zap, Scale, Heart } from 'lucide-react';
|
||||
|
||||
export default function ImpactMetrics({ applications, interviews, staff }) {
|
||||
export default function ImpactMetrics({ applications, interviews, staff: _staff }) {
|
||||
const hired = applications.filter(a => a.status === 'hired');
|
||||
const screened = applications.filter(a => a.ai_score > 0);
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ function getTier(score) {
|
||||
return 3;
|
||||
}
|
||||
|
||||
export default function KROWCandidateLeaderboard({ applications, maxBars = 4 }) {
|
||||
export default function KROWCandidateLeaderboard({ applications }) {
|
||||
const screened = applications.filter(a => a.ai_score > 0 || a.status !== 'applied');
|
||||
const counts = TIERS.map((_, i) => screened.filter(a => getTier(a.ai_score || 0) === i).length);
|
||||
const total = counts.reduce((a, b) => a + b, 0) || 1;
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
// Apple-style focal statement: a quiet vertical list beside a single thin divider.
|
||||
export default function KrowSays() {
|
||||
const lines = ['Learn this.', 'Prove it.', 'Get hired.'];
|
||||
return (
|
||||
<div>
|
||||
<p className="text-[13px] font-medium text-[#333333] mb-6">KROW says</p>
|
||||
<div className="relative pl-6">
|
||||
<div className="absolute left-0 top-2 bottom-2 w-px bg-[#CCCCCC]" />
|
||||
<ul className="space-y-5 sm:space-y-6">
|
||||
{lines.map((l) => (
|
||||
<li
|
||||
key={l}
|
||||
className="text-[26px] sm:text-[34px] font-semibold tracking-tight text-[#333333] leading-tight"
|
||||
>
|
||||
{l}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
export default function KrowScoreRing({ score = 0, size = 64, label = 'KROW Score' }) {
|
||||
const radius = (size - 8) / 2;
|
||||
const circ = 2 * Math.PI * radius;
|
||||
const offset = circ - (Math.min(100, score) / 100) * circ;
|
||||
const color = score >= 80 ? '#0838E0' : score >= 60 ? '#333F48' : score >= 40 ? '#6B7280' : '#EF4444';
|
||||
return (
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="relative flex items-center justify-center" style={{ width: size, height: size }}>
|
||||
<svg width={size} height={size} className="-rotate-90">
|
||||
<circle cx={size / 2} cy={size / 2} r={radius} fill="none" stroke="#E5E7EB" strokeWidth="5" />
|
||||
<circle cx={size / 2} cy={size / 2} r={radius} fill="none" stroke={color} strokeWidth="5" strokeDasharray={circ} strokeDashoffset={offset} strokeLinecap="round" />
|
||||
</svg>
|
||||
<span className="absolute font-bold text-[#111827]" style={{ fontSize: size * 0.28 }}>{score}</span>
|
||||
</div>
|
||||
{label && <span className="text-[11px] text-[#6B7280] mt-1">{label}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { ArrowRight, Route } from 'lucide-react';
|
||||
|
||||
export default function LearningPathCard({ path, completedIds = [] }) {
|
||||
const steps = path.steps || [];
|
||||
const done = steps.filter(s => completedIds.has(s.course_id)).length;
|
||||
const pct = steps.length ? Math.round((done / steps.length) * 100) : 0;
|
||||
return (
|
||||
<Link to="/university" className="block bg-white border border-[#E5E7EB] rounded-xl p-5 hover:border-[#0838E0] hover:shadow-sm transition-all">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<div className="w-8 h-8 rounded-lg bg-[#0838E0] flex items-center justify-center shrink-0"><Route className="w-4 h-4 text-white" /></div>
|
||||
<h3 className="font-semibold text-[#111827] text-[15px]">{path.name}</h3>
|
||||
</div>
|
||||
<p className="text-[12px] text-[#6B7280] mb-3">{path.description || `Career track to ${path.target_role || path.name}`}</p>
|
||||
<div className="flex flex-wrap items-center gap-1.5 mb-3">
|
||||
{steps.flatMap((s, i) => [
|
||||
<span key={`s${i}`} className={`text-[11px] px-2 py-0.5 rounded-full ${completedIds.has(s.course_id) ? 'bg-[#F0FDF4] text-[#166534]' : 'bg-[#F3F4F6] text-[#6B7280]'}`}>{s.course_title || `Step ${i + 1}`}</span>,
|
||||
i < steps.length - 1 ? <ArrowRight key={`a${i}`} className="w-3 h-3 text-[#D1D5DB]" /> : null,
|
||||
])}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 h-2 rounded-full bg-[#F3F4F6] overflow-hidden"><div className="h-full bg-[#0838E0]" style={{ width: `${pct}%` }} /></div>
|
||||
<span className="text-[11px] font-medium text-[#6B7280]">{done}/{steps.length}</span>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { ArrowRight } from 'lucide-react';
|
||||
import OwliverAvatar from '@/components/krow/OwliverAvatar';
|
||||
|
||||
export default function OwliverCoachCard({ nextCourse, matchCount, avgPay }) {
|
||||
return (
|
||||
<section className="py-12 sm:py-16 border-t border-[#F1F5F9]">
|
||||
<div className="flex flex-col items-center text-center max-w-xl mx-auto">
|
||||
<OwliverAvatar className="w-14 h-14" rounded="rounded-full" />
|
||||
<h2 className="text-[12px] font-medium text-[#94A3B8] uppercase tracking-wider mt-4">Owliver · AI Coach</h2>
|
||||
<p className="text-[18px] sm:text-[22px] font-medium text-[#0F172A] mt-3 leading-snug">
|
||||
{nextCourse ? (
|
||||
<>
|
||||
Complete <span className="text-[#0838E0]">{nextCourse.title}</span> to unlock stronger matches.
|
||||
</>
|
||||
) : (
|
||||
<>Keep building your profile and I'll map your fastest path forward.</>
|
||||
)}
|
||||
</p>
|
||||
<p className="text-[13px] text-[#64748B] mt-3">
|
||||
{matchCount > 0
|
||||
? `${matchCount} open roles match you now${avgPay ? ` · avg ${avgPay}` : ''}`
|
||||
: 'No matches yet — keep learning.'}
|
||||
</p>
|
||||
{nextCourse && (
|
||||
<Link
|
||||
to={`/university/${nextCourse.id}`}
|
||||
className="mt-7 inline-flex items-center gap-1.5 bg-[#0838E0] text-white text-[14px] font-medium px-6 py-3 rounded-full hover:bg-[#062BAF] transition-colors"
|
||||
>
|
||||
Start Learning <ArrowRight className="w-4 h-4" />
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -49,7 +49,7 @@ export default function RateEndorseModal({ open, onClose, staff, reviewerName })
|
||||
});
|
||||
toast.success(`Rated ${staff.name}`);
|
||||
onClose();
|
||||
} catch (e) {
|
||||
} catch {
|
||||
toast.error('Failed to save rating');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -21,7 +21,7 @@ function buildVoices(application) {
|
||||
const years = application?.years_experience || 0;
|
||||
const source = companies.length > 0
|
||||
? companies.slice(0, 3)
|
||||
: strengths.slice(0, 3).map((s) => 'Verified Reference');
|
||||
: strengths.slice(0, 3).map(() => 'Verified Reference');
|
||||
if (source.length === 0) return [];
|
||||
return source.map((c, i) => {
|
||||
const name = SUPERVISOR_NAMES[i % SUPERVISOR_NAMES.length];
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Briefcase, User } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const ROLES = [
|
||||
{ value: 'recruiter', label: 'Recruiter / Admin', desc: 'Hire and manage candidates', icon: Briefcase },
|
||||
{ value: 'applicant', label: 'Applicant', desc: 'Apply to open positions', icon: User },
|
||||
];
|
||||
|
||||
export default function RoleSelector({ value, onChange }) {
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{ROLES.map((r) => {
|
||||
const Icon = r.icon;
|
||||
const active = value === r.value;
|
||||
return (
|
||||
<button
|
||||
key={r.value}
|
||||
type="button"
|
||||
onClick={() => onChange(r.value)}
|
||||
className={cn(
|
||||
'p-4 rounded-xl border-2 text-left transition-all',
|
||||
active ? 'border-[#0838E0] bg-[#EEF3FE]' : 'border-[#E5E7EB] hover:border-[#D1D5DB]'
|
||||
)}
|
||||
>
|
||||
<Icon className={cn('w-5 h-5 mb-2', active ? 'text-[#0838E0]' : 'text-[#9CA3AF]')} />
|
||||
<p className={cn('text-[13px] font-semibold', active ? 'text-[#1E40AF]' : 'text-[#374151]')}>{r.label}</p>
|
||||
<p className="text-[11px] text-[#6B7280] mt-0.5">{r.desc}</p>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
// Skill levels: Claimed → Practiced → Verified → Expert → Elite
|
||||
const LEVELS = [
|
||||
{ name: 'Claimed', fill: 35, bar: 'bg-[#CBD5E1]', text: 'text-[#94A3B8]' },
|
||||
{ name: 'Practiced', fill: 62, bar: 'bg-[#93C5FD]', text: 'text-[#3B82F6]' },
|
||||
{ name: 'Verified', fill: 84, bar: 'bg-[#0838E0]', text: 'text-[#0838E0]' },
|
||||
{ name: 'Expert', fill: 94, bar: 'bg-[#6366F1]', text: 'text-[#6366F1]' },
|
||||
{ name: 'Elite', fill: 100, bar: 'bg-[#F9E547]', text: 'text-[#92400E]' },
|
||||
];
|
||||
|
||||
function levelFor(skill, badges, courses) {
|
||||
const s = skill.toLowerCase();
|
||||
const certHits = (badges || []).filter((b) => (b.name || '').toLowerCase().includes(s)).length;
|
||||
const courseHits = (courses || []).filter((c) => (c.title || '').toLowerCase().includes(s)).length;
|
||||
const score = certHits * 2 + courseHits;
|
||||
if (score >= 6) return 4;
|
||||
if (score >= 3) return 3;
|
||||
if (certHits >= 1) return 2;
|
||||
if (courseHits >= 1) return 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
export default function SkillDNA({ profile }) {
|
||||
const skills = profile?.skills || [];
|
||||
const badges = profile?.earned_badges || [];
|
||||
const courses = profile?.completed_courses || [];
|
||||
|
||||
return (
|
||||
<section className="py-12 sm:py-16 border-t border-[#F1F5F9]">
|
||||
<h2 className="text-[22px] sm:text-[26px] font-semibold text-[#0F172A] tracking-tight">Skill DNA</h2>
|
||||
<p className="text-[14px] text-[#64748B] mt-2 max-w-md">
|
||||
Every shift updates these bars. Employers trust them.
|
||||
</p>
|
||||
|
||||
{!skills.length ? (
|
||||
<p className="text-[14px] text-[#94A3B8] mt-8">
|
||||
Add skills to your profile to start tracking your Skill DNA.
|
||||
</p>
|
||||
) : (
|
||||
<div className="mt-8 space-y-5 max-w-2xl">
|
||||
{skills.map((sk, i) => {
|
||||
const lvl = LEVELS[levelFor(sk, badges, courses)];
|
||||
return (
|
||||
<div key={i}>
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<span className="text-[14px] font-medium text-[#0F172A]">{sk}</span>
|
||||
<span className={`text-[12px] font-medium ${lvl.text}`}>{lvl.name}</span>
|
||||
</div>
|
||||
<div className="h-2 rounded-full bg-[#F1F5F9] overflow-hidden">
|
||||
<div
|
||||
className={`h-full ${lvl.bar} rounded-full transition-all duration-500`}
|
||||
style={{ width: `${lvl.fill}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -24,13 +24,6 @@ function getPortraitSeed(name) {
|
||||
return PORTRAIT_PHOTOS[h % PORTRAIT_PHOTOS.length];
|
||||
}
|
||||
|
||||
const MATCH_LABEL_STYLES = {
|
||||
'Strong Match': 'bg-[#EEF3FE] text-[#0838E0]',
|
||||
'Good Match': 'bg-[#F0FDF4] text-[#16A34A]',
|
||||
'Possible Fit': 'bg-[#FFFBEB] text-[#D97706]',
|
||||
'Weak Fit': 'bg-[#F3F4F6] text-[#6B7280]',
|
||||
};
|
||||
|
||||
const RECO_STYLES = {
|
||||
Hire: 'bg-[#16A34A] text-white',
|
||||
Interview: 'bg-[#0838E0] text-white',
|
||||
@@ -53,7 +46,7 @@ export default function SuggestedTalent({ job, onContact, onSchedule, onHire })
|
||||
try {
|
||||
const res = await matchTalent.mutateAsync({ job, profiles });
|
||||
if (!cancelled && res?.matches) setMatches(res.matches);
|
||||
} catch (e) {
|
||||
} catch {
|
||||
if (!cancelled) setMatches([]);
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -6,25 +6,6 @@ import CareerScoreGauge from '@/components/krow/talent/CareerScoreGauge';
|
||||
import ExperienceEndorsements, { verifiedExperience } from '@/components/krow/talent/ExperienceEndorsements';
|
||||
import EliteTalentScore from '@/components/krow/talent/EliteTalentScore';
|
||||
|
||||
const PORTRAIT_PHOTOS = [
|
||||
'photo-1500648767731-7d3514ad8b31',
|
||||
'photo-1531123897727-8f129e1688ce',
|
||||
'photo-1573496359142-b8d87734a5a2',
|
||||
'photo-1487412720507-e7ab37603c6f',
|
||||
'photo-1502685104226-ee3a97a2f2c8',
|
||||
'photo-1544005313-94ddf0286df2',
|
||||
'photo-1494790108377-be9c29b29330',
|
||||
'photo-1438761681033-6461ffad8d80',
|
||||
'photo-1517841905240-472988babdf9',
|
||||
'photo-1463453091185-61582044d556',
|
||||
];
|
||||
|
||||
function getPortraitSeed(name) {
|
||||
let h = 0;
|
||||
for (let i = 0; i < (name || '').length; i++) h = (h * 31 + name.charCodeAt(i)) >>> 0;
|
||||
return PORTRAIT_PHOTOS[h % PORTRAIT_PHOTOS.length];
|
||||
}
|
||||
|
||||
function getInitials(name) {
|
||||
return (name || '').split(' ').map(w => w[0]).slice(0, 2).join('').toUpperCase() || '??';
|
||||
}
|
||||
@@ -74,35 +55,17 @@ function PillList({ items, className = 'bg-[#F0F4F8] text-[#0047AB]' }) {
|
||||
);
|
||||
}
|
||||
|
||||
function ScoreFactor({ label, value }) {
|
||||
return (
|
||||
<div className="inline-flex items-center justify-between gap-2 px-3 py-1.5 rounded-full border border-[#E5E7EB] bg-white">
|
||||
<span className="text-[11px] text-[#6B7280] font-medium">{label}</span>
|
||||
<span className="text-[12px] font-bold text-[#2563EB]">{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** @param {any} props */
|
||||
export default function TalentDetailModal({ open, onClose, profile, onContact, onSchedule, onHire, matchScore, matchReasons }) {
|
||||
if (!profile) return null;
|
||||
const badges = profile.earned_badges || [];
|
||||
const capabilities = (profile.capabilities || []).filter((c) => c.status === 'verified' || c.status === 'pending');
|
||||
const experience = profile.experience || [];
|
||||
const strengths = profile.strengths || [];
|
||||
|
||||
const verifiedExperienceList = verifiedExperience(profile).length > 0;
|
||||
const krowCode = getKrowIdCode(profile);
|
||||
const reportDate = formatDate(profile.updated_date || new Date());
|
||||
const openToWork = profile.status === 'active' && (profile.availability?.length > 0 || !profile.current_position);
|
||||
|
||||
const reliability = Math.round(profile.reliability_score || 0);
|
||||
const attendance = Math.round(profile.attendance_score || 0);
|
||||
const performance = Math.round(profile.performance_score || 0);
|
||||
const leadership = Math.round(profile.leadership_potential || 0);
|
||||
const aiInterview = Math.round(profile.ai_interview_score || 0);
|
||||
const xp = Math.round(profile.xp || 0);
|
||||
|
||||
const identityVerified = capabilities.length > 0 || badges.length > 0;
|
||||
const identityTag = identityVerified ? 'Verified' : 'Pending';
|
||||
const identityClass = identityVerified ? 'bg-[#EEF3FE] text-[#1E40AF]' : 'bg-[#FFF7ED] text-[#B45309]';
|
||||
|
||||
@@ -1,114 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { motion } from 'framer-motion';
|
||||
import {
|
||||
Fingerprint, GraduationCap, Mic, BadgeCheck, Handshake, Briefcase,
|
||||
LineChart, Star, TrendingUp, Crown, Presentation, Repeat, ArrowRight,
|
||||
} from 'lucide-react';
|
||||
|
||||
// The Credit Bureau for Human Talent — the career flywheel.
|
||||
const STEPS = [
|
||||
{ key: 'identity', label: 'Create Identity', to: '/identity', icon: Fingerprint },
|
||||
{ key: 'learn', label: 'Learn', to: '/university', icon: GraduationCap },
|
||||
{ key: 'interview', label: 'Interview', to: '/owliver', icon: Mic },
|
||||
{ key: 'verify', label: 'Verify Skills', to: '/identity', icon: BadgeCheck },
|
||||
{ key: 'hired', label: 'Get Hired', to: '/apply', icon: Handshake },
|
||||
{ key: 'work', label: 'Work', to: '/me', icon: Briefcase },
|
||||
{ key: 'track', label: 'Track Performance', to: '/me', icon: LineChart },
|
||||
{ key: 'reputation', label: 'Earn Reputation', to: '/me', icon: Star },
|
||||
{ key: 'promoted', label: 'Get Promoted', to: '/university', icon: TrendingUp },
|
||||
{ key: 'leader', label: 'Become Leader', to: '/university', icon: Crown },
|
||||
{ key: 'train', label: 'Train Others', to: '/university', icon: Presentation },
|
||||
{ key: 'repeat', label: 'Repeat', to: '/university', icon: Repeat, loop: true },
|
||||
];
|
||||
|
||||
export function stepStatus(profile, apps = []) {
|
||||
const hired = apps.some(a => a.status === 'hired');
|
||||
const worked = (profile.experience || []).length > 0 || hired;
|
||||
const isDone = {
|
||||
identity: !!profile.selfie_url && (profile.skills || []).length > 0,
|
||||
learn: (profile.completed_courses || []).length > 0,
|
||||
interview: (profile.ai_interview_score || 0) > 0 || !!(profile.personality || profile.strengths?.length),
|
||||
verify: (profile.certifications || []).length > 0 || (profile.earned_badges || []).length > 0,
|
||||
hired,
|
||||
work: worked,
|
||||
track: (profile.performance_score || 0) > 0 || (profile.attendance_score || 0) > 0 && worked,
|
||||
reputation: (profile.client_rating || 0) > 0 || (profile.supervisor_rating || 0) > 0 || (profile.krow_score || 0) >= 50,
|
||||
promoted: (profile.leadership_potential || 0) >= 40,
|
||||
leader: (profile.leadership_potential || 0) >= 70,
|
||||
train: (profile.leadership_potential || 0) >= 85 && (profile.completed_courses || []).length >= 3,
|
||||
repeat: false,
|
||||
};
|
||||
return isDone;
|
||||
}
|
||||
|
||||
export default function TalentLoop({ profile, applications = [] }) {
|
||||
const done = stepStatus(profile, applications);
|
||||
const reached = STEPS.filter(s => done[s.key]).length;
|
||||
const current = STEPS.find(s => !done[s.key]) || STEPS[STEPS.length - 1];
|
||||
const progress = Math.round((reached / (STEPS.length - 1)) * 100);
|
||||
|
||||
return (
|
||||
<section className="glass-card border border-white/60 rounded-2xl p-6 sm:p-8 shadow-sm">
|
||||
<div className="flex items-end justify-between flex-wrap gap-3 mb-1">
|
||||
<div>
|
||||
<span className="text-[11px] font-medium uppercase tracking-[0.18em] text-[#0838E0]">The Talent Loop</span>
|
||||
<h2 className="text-[22px] font-heading font-bold text-[#0F172A] mt-1 tracking-tight">Build your reputation. Not just your resume.</h2>
|
||||
</div>
|
||||
<span className="text-[12px] text-[#64748B]">{reached}/{STEPS.length - 1} stages</span>
|
||||
</div>
|
||||
|
||||
<div className="h-1.5 bg-[#EEF2F7] rounded-full overflow-hidden mt-4 mb-7">
|
||||
<motion.div
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: `${progress}%` }}
|
||||
transition={{ duration: 0.8, ease: 'easeOut' }}
|
||||
className="h-full bg-[#0838E0] rounded-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-3">
|
||||
{STEPS.map((s, i) => {
|
||||
const isDone = done[s.key];
|
||||
const isCurrent = s.key === current.key && !s.loop;
|
||||
const Icon = s.icon;
|
||||
return (
|
||||
<Link
|
||||
key={s.key}
|
||||
to={s.to}
|
||||
className={`group relative flex flex-col items-start gap-2 rounded-xl border p-3 transition-all ${
|
||||
isDone
|
||||
? 'bg-[#0838E0] border-[#0838E0] text-white'
|
||||
: isCurrent
|
||||
? 'bg-white border-[#0838E0] ring-2 ring-[#0838E0]/20'
|
||||
: 'bg-white border-[#E5E7EB] hover:border-[#0838E0]/50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<span className={`text-[10px] font-semibold ${isDone ? 'text-white/70' : 'text-[#94A3B8]'}`}>{String(i + 1).padStart(2, '0')}</span>
|
||||
{s.loop && <Repeat className={`w-3.5 h-3.5 ${isDone ? 'text-white/70' : 'text-[#94A3B8]'}`} />}
|
||||
</div>
|
||||
<Icon className={`w-5 h-5 ${isDone ? 'text-white' : isCurrent ? 'text-[#0838E0]' : 'text-[#64748B]'}`} />
|
||||
<span className={`text-[12px] font-medium leading-tight ${isDone ? 'text-white' : isCurrent ? 'text-[#0838E0]' : 'text-[#334155]'}`}>
|
||||
{s.label}
|
||||
</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-3 mt-6 pt-5 border-t border-[#EEF2F7] flex-wrap">
|
||||
<div>
|
||||
<p className="text-[11px] text-[#94A3B8] font-medium">UP NEXT</p>
|
||||
<p className="text-[15px] font-semibold text-[#0F172A]">{current.label}</p>
|
||||
</div>
|
||||
<Link
|
||||
to={current.to}
|
||||
className="inline-flex items-center gap-1.5 text-[13px] font-semibold text-[#0838E0] hover:gap-2.5 transition-all"
|
||||
>
|
||||
Continue <ArrowRight className="w-4 h-4" />
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
import React from 'react';
|
||||
import { ShieldCheck, Check, Dot } from 'lucide-react';
|
||||
|
||||
// KROW verifies everything. This is the trust layer — what's proven vs. claimed.
|
||||
const ITEMS = [
|
||||
{ key: 'identity', label: 'Identity', hint: 'Selfie + name' },
|
||||
{ key: 'certifications', label: 'Certifications', hint: 'Uploaded docs' },
|
||||
{ key: 'skills', label: 'Skills', hint: 'Declared' },
|
||||
{ key: 'work', label: 'Work History', hint: 'Experience' },
|
||||
{ key: 'interview', label: 'AI Interview', hint: 'Passed' },
|
||||
{ key: 'attendance', label: 'Attendance', hint: 'Shifts' },
|
||||
{ key: 'reputation', label: 'Reputation', hint: 'Ratings' },
|
||||
{ key: 'performance', label: 'Performance', hint: 'Score' },
|
||||
{ key: 'learning', label: 'Learning', hint: 'Courses' },
|
||||
{ key: 'promotions', label: 'Promotions', hint: 'Growth' },
|
||||
{ key: 'references', label: 'References', hint: 'Verified' },
|
||||
];
|
||||
|
||||
function statusOf(profile, key) {
|
||||
const has = (v) => v !== undefined && v !== null && v !== '' && !(Array.isArray(v) && v.length === 0) && v !== 0;
|
||||
const map = {
|
||||
identity: has(profile.selfie_url) && has(profile.full_name),
|
||||
certifications: (profile.certifications || []).length > 0,
|
||||
skills: (profile.skills || []).length > 0,
|
||||
work: (profile.experience || []).length > 0,
|
||||
interview: (profile.ai_interview_score || 0) > 0,
|
||||
attendance: (profile.attendance_score || 0) > 0 && (profile.experience || []).length > 0,
|
||||
reputation: (profile.client_rating || 0) > 0 || (profile.supervisor_rating || 0) > 0,
|
||||
performance: (profile.performance_score || 0) > 0,
|
||||
learning: (profile.completed_courses || []).length > 0,
|
||||
promotions: (profile.leadership_potential || 0) >= 40,
|
||||
references: (profile.supervisor_rating || 0) > 0,
|
||||
};
|
||||
return map[key] ? 'verified' : 'pending';
|
||||
}
|
||||
|
||||
export default function TrustLayer({ profile }) {
|
||||
const verified = ITEMS.filter((i) => statusOf(profile, i.key) === 'verified').length;
|
||||
|
||||
return (
|
||||
<section className="glass-card border border-white/60 rounded-2xl p-6 sm:p-8 shadow-sm">
|
||||
<div className="flex items-start justify-between gap-4 flex-wrap mb-6">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-[#0838E0] flex items-center justify-center shrink-0">
|
||||
<ShieldCheck className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-[11px] font-medium uppercase tracking-[0.18em] text-[#94A3B8]">Trust Layer</span>
|
||||
<h2 className="text-[20px] font-heading font-bold text-[#0F172A] tracking-tight">Verified, not claimed.</h2>
|
||||
<p className="text-[13px] text-[#64748B] mt-0.5">Everything KROW has proven about you. Impossible to fake.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-[28px] font-heading font-bold text-[#0838E0] leading-none">{verified}<span className="text-[#94A3B8] text-[18px]">/{ITEMS.length}</span></div>
|
||||
<div className="text-[11px] text-[#94A3B8] font-medium mt-1">verified</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-x-6 gap-y-2.5">
|
||||
{ITEMS.map((item) => {
|
||||
const st = statusOf(profile, item.key);
|
||||
return (
|
||||
<div key={item.key} className="flex items-center gap-2.5 py-1.5 border-b border-[#F1F5F9]">
|
||||
{st === 'verified' ? (
|
||||
<span className="w-5 h-5 rounded-full bg-[#0838E0] flex items-center justify-center shrink-0">
|
||||
<Check className="w-3 h-3 text-white" />
|
||||
</span>
|
||||
) : (
|
||||
<span className="w-5 h-5 rounded-full bg-[#FEF3C7] flex items-center justify-center shrink-0">
|
||||
<Dot className="w-4 h-4 text-[#D97706]" />
|
||||
</span>
|
||||
)}
|
||||
<span className={`text-[13px] font-medium ${st === 'verified' ? 'text-[#0F172A]' : 'text-[#64748B]'}`}>{item.label}</span>
|
||||
<span className="text-[11px] text-[#94A3B8] ml-auto">{st === 'verified' ? 'Verified' : 'Pending'}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
import React from 'react';
|
||||
import { ShieldCheck, Plus } from 'lucide-react';
|
||||
|
||||
// The transcript replacement — verified capabilities as visual chips,
|
||||
// each backed by evidence. Empty until the worker proves something.
|
||||
export default function CapabilityTimeline({ profile, compact }) {
|
||||
const caps = (profile?.capabilities || []).filter((c) => c.status === 'verified' || c.status === 'pending');
|
||||
return (
|
||||
<section className={compact ? 'py-2' : 'py-8'}>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="text-[15px] font-semibold text-[#0F172A]">Proven skills</h2>
|
||||
<span className="text-[11px] text-[#94A3B8]">{caps.length} verified</span>
|
||||
</div>
|
||||
{!caps.length ? (
|
||||
<div className="rounded-2xl border border-dashed border-[#E2E8F0] bg-[#F8FAFC] p-6 text-center">
|
||||
<div className="w-10 h-10 rounded-full bg-white border border-[#E2E8F0] flex items-center justify-center mx-auto mb-2">
|
||||
<Plus className="w-4 h-4 text-[#94A3B8]" />
|
||||
</div>
|
||||
<p className="text-[12px] text-[#64748B]">No verified skills yet — prove a challenge to start your chain.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{caps.map((c, i) => (
|
||||
<span key={i} className="inline-flex items-center gap-1.5 rounded-full bg-[#F0FDF4] border border-[#BBF7D0] px-3 py-1.5">
|
||||
<ShieldCheck className="w-3.5 h-3.5 text-[#16A34A]" />
|
||||
<span className="text-[12px] font-medium text-[#166534]">{c.skill}</span>
|
||||
{c.verified_date && (
|
||||
<span className="text-[10px] text-[#16A34A]/70">
|
||||
{new Date(c.verified_date).toLocaleDateString('en-US', { month: 'short' })}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
import React from 'react';
|
||||
import { ShieldCheck, Award, Languages as LangIcon, MessageSquare, Briefcase, Star, Medal, FolderOpen, FileText } from 'lucide-react';
|
||||
|
||||
|
||||
function WalletTile({ icon: Icon, label, value, tone = 'neutral' }) {
|
||||
const valueColor = {
|
||||
verified: 'text-[#10B981]',
|
||||
neutral: 'text-[#0F172A]',
|
||||
}[tone];
|
||||
return (
|
||||
<div className="rounded-xl bg-white border border-[#E6E8EB] p-4 transition-colors hover:border-[#0838E0]">
|
||||
<div className="flex items-center gap-1.5 text-[#7C818A] mb-2">
|
||||
<Icon className="w-3.5 h-3.5" />
|
||||
<span className="text-[10px] font-semibold uppercase tracking-[0.06em]">{label}</span>
|
||||
</div>
|
||||
<div className={`text-[19px] font-bold leading-tight ${valueColor}`}>{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function CareerWallet({ profile }) {
|
||||
const certs = profile.certifications || [];
|
||||
const langs = profile.languages || [];
|
||||
const completed = profile.completed_courses || [];
|
||||
const badges = profile.earned_badges || [];
|
||||
const hasInterview = (profile.ai_interview_score || 0) > 0;
|
||||
const identityOk = !!profile.selfie_url;
|
||||
|
||||
return (
|
||||
<section className="rounded-2xl bg-[#F8F9FB] border border-[#E6E8EB] p-6 sm:p-8">
|
||||
<div className="flex items-baseline justify-between mb-5">
|
||||
<div>
|
||||
<h2 className="text-[19px] font-bold text-[#1A1D21] tracking-tight">Career Wallet</h2>
|
||||
<p className="text-[12px] text-[#7C818A] mt-0.5">Verified once. Portable everywhere.</p>
|
||||
</div>
|
||||
<span className="text-[11px] font-semibold text-[#10B981] hidden sm:inline">Everything verified</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<WalletTile icon={ShieldCheck} label="Identity" value={identityOk ? 'Verified' : '—'} tone={identityOk ? 'verified' : 'neutral'} />
|
||||
<WalletTile icon={Award} label="Certifications" value={certs.length} />
|
||||
<WalletTile icon={LangIcon} label="Languages" value={langs.length} />
|
||||
<WalletTile icon={MessageSquare} label="AI Interview" value={hasInterview ? 'Passed' : '0'} tone={hasInterview ? 'verified' : 'neutral'} />
|
||||
<WalletTile icon={Briefcase} label="Completed Jobs" value={completed.length} />
|
||||
<WalletTile icon={Star} label="Employer Rating" value={profile.client_rating ? `${profile.client_rating}` : '—'} />
|
||||
<WalletTile icon={Medal} label="Badges" value={badges.length} />
|
||||
<WalletTile icon={FolderOpen} label="Portfolio" value="—" />
|
||||
<WalletTile icon={FileText} label="Resume" value={profile.resume_url ? 'Yes' : 'Optional'} />
|
||||
</div>
|
||||
|
||||
{certs.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 mt-5">
|
||||
{certs.map((c, i) => (
|
||||
<span key={i} className="inline-flex items-center gap-1.5 rounded-full bg-white border border-[#E6E8EB] text-[#1A1D21] text-[12px] font-medium px-3 py-1.5">
|
||||
<Award className="w-3.5 h-3.5 text-[#0838E0]" />{c}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,123 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Building2, MapPin, Check, X, ArrowLeftRight, Sparkles, Hand } from 'lucide-react';
|
||||
|
||||
const STATUS = {
|
||||
accepted: { txt: 'Accepted', cls: 'bg-[#ECFDF5] text-[#16A34A]', icon: Check },
|
||||
declined: { txt: 'Declined', cls: 'bg-[#F3F4F6] text-[#6B7280]', icon: X },
|
||||
countered: { txt: 'Counter sent', cls: 'bg-[#EEF3FE] text-[#0838E0]', icon: ArrowLeftRight },
|
||||
};
|
||||
|
||||
function DemandCard({ rec, decision, counterAsk, onAccept, onDecline, onCounter, onSend }) {
|
||||
const { job, match } = rec;
|
||||
const [countering, setCountering] = useState(false);
|
||||
const [ask, setAsk] = useState(counterAsk ?? job.pay_range_max ?? job.pay_range_min ?? '');
|
||||
const pay = job.pay_range_min && job.pay_range_max ? `$${job.pay_range_min}–$${job.pay_range_max}/hr` : '';
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl bg-white border border-[#E5E7EB] p-5">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-11 h-11 rounded-xl bg-[#EEF3FE] text-[#0838E0] flex items-center justify-center shrink-0">
|
||||
<Building2 className="w-5 h-5" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<h3 className="text-[15px] font-bold text-[#0F172A] truncate">{job.title}</h3>
|
||||
<span className="inline-flex items-center gap-0.5 text-[11px] font-bold text-[#0838E0] bg-[#EEF3FE] px-2 py-0.5 rounded-full">{match}% match</span>
|
||||
</div>
|
||||
<p className="text-[13px] text-[#6B7280] mt-0.5 flex items-center gap-1.5 flex-wrap">
|
||||
<span className="inline-flex items-center gap-0.5"><MapPin className="w-3 h-3" />{job.location || 'Remote'}</span>
|
||||
{pay && <span>· {pay}</span>}
|
||||
<span className="text-[#0838E0] font-medium">wants to interview you</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{decision ? (
|
||||
<div className="mt-4 flex items-center gap-1.5">
|
||||
{(() => { const S = STATUS[decision]; const I = S.icon; return (
|
||||
<span className={`inline-flex items-center gap-1.5 text-[12px] font-semibold px-3 py-1.5 rounded-full ${S.cls}`}>
|
||||
<I className="w-3.5 h-3.5" /> {S.txt}{decision === 'countered' && counterAsk ? ` · $${counterAsk}/hr` : ''}
|
||||
</span>
|
||||
); })()}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center gap-2 mt-4">
|
||||
<button onClick={() => onAccept(rec)} className="flex-1 inline-flex items-center justify-center gap-1.5 h-9 rounded-xl bg-[#0838E0] text-white text-[13px] font-semibold hover:bg-[#062BAF] transition-colors">
|
||||
<Check className="w-4 h-4" /> Accept
|
||||
</button>
|
||||
<button onClick={() => onDecline(rec)} className="flex-1 inline-flex items-center justify-center gap-1.5 h-9 rounded-xl border border-[#E5E7EB] text-[#6B7280] text-[13px] font-semibold hover:bg-[#F9FAFB] transition-colors">
|
||||
<X className="w-4 h-4" /> Decline
|
||||
</button>
|
||||
<button onClick={() => setCountering(v => !v)} className="flex-1 inline-flex items-center justify-center gap-1.5 h-9 rounded-xl border border-[#0838E0] text-[#0838E0] text-[13px] font-semibold hover:bg-[#EEF3FE] transition-colors">
|
||||
<ArrowLeftRight className="w-4 h-4" /> Counter
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{countering && (
|
||||
<div className="flex items-center gap-2 mt-3 pt-3 border-t border-[#F3F4F6]">
|
||||
<span className="text-[12px] text-[#6B7280]">Counter at</span>
|
||||
<input type="number" value={ask} onChange={(e) => setAsk(e.target.value)} className="w-24 h-8 px-2 text-[13px] rounded-lg border border-[#E5E7EB] focus:border-[#0838E0] focus:outline-none" />
|
||||
<span className="text-[12px] text-[#6B7280]">/hr</span>
|
||||
<button onClick={() => onSend(rec, ask)} className="ml-auto h-8 px-3 rounded-lg bg-[#0838E0] text-white text-[12px] font-semibold hover:bg-[#062BAF]">Send</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function CompaniesCompeting({ jobRecs = [] }) {
|
||||
const [decisions, setDecisions] = useState({});
|
||||
const [asks, setAsks] = useState({});
|
||||
|
||||
const decide = (id, kind, ask) => {
|
||||
setDecisions((d) => ({ ...d, [id]: kind }));
|
||||
if (ask != null) setAsks((a) => ({ ...a, [id]: ask }));
|
||||
};
|
||||
|
||||
const live = jobRecs.filter((r) => !decisions[r.job.id]);
|
||||
|
||||
return (
|
||||
<section className="rounded-2xl bg-white border border-[#E5E7EB] p-6 sm:p-8">
|
||||
<div className="flex items-center justify-between mb-5">
|
||||
<div>
|
||||
<h2 className="text-[18px] font-bold text-[#0F172A] flex items-center gap-2">
|
||||
<Sparkles className="w-4 h-4 text-[#0838E0]" /> Companies are competing for you
|
||||
</h2>
|
||||
<p className="text-[13px] text-[#6B7280] mt-0.5">You're in demand. Accept, decline, or counter.</p>
|
||||
</div>
|
||||
{live.length > 0 && (
|
||||
<span className="inline-flex items-center gap-1.5 text-[12px] font-bold text-[#16A34A] bg-[#ECFDF5] px-3 py-1.5 rounded-full">
|
||||
<span className="relative flex h-2 w-2"><span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-[#16A34A] opacity-60" /><span className="relative inline-flex rounded-full h-2 w-2 bg-[#16A34A]" /></span>
|
||||
{live.length} live
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{jobRecs.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<div className="w-12 h-12 rounded-2xl bg-[#F9FAFB] text-[#9CA3AF] flex items-center justify-center mx-auto mb-3"><Hand className="w-6 h-6" /></div>
|
||||
<p className="text-[14px] font-medium text-[#0F172A]">No live offers yet</p>
|
||||
<p className="text-[13px] text-[#6B7280] mt-1">Keep leveling up — companies will come to you.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{jobRecs.map((rec) => (
|
||||
<DemandCard
|
||||
key={rec.job.id}
|
||||
rec={rec}
|
||||
decision={decisions[rec.job.id]}
|
||||
counterAsk={asks[rec.job.id]}
|
||||
onAccept={(r) => decide(r.job.id, 'accepted')}
|
||||
onDecline={(r) => decide(r.job.id, 'declined')}
|
||||
onCounter={() => {}}
|
||||
onSend={(r, ask) => decide(r.job.id, 'countered', ask)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
const SHIFTS = ['Morning Shift', 'Evening Shift', 'Weekend', 'Full Time'];
|
||||
const STATES = ['Interested', 'Interview', 'Offer', 'Offer'];
|
||||
|
||||
export default function CompetingOffers({ jobRecs = [] }) {
|
||||
const offers = (jobRecs || []).slice(0, 4);
|
||||
|
||||
return (
|
||||
<section className="rounded-2xl bg-white border border-[#E5E7EB] p-6">
|
||||
<h2 className="text-[15px] font-semibold text-[#0F172A]">Companies Competing For You</h2>
|
||||
<div className="mt-4 space-y-3">
|
||||
{offers.map((j, i) => {
|
||||
const pay = j.pay_range_min || 31;
|
||||
const title = (j.title || 'Opportunity').split(' (')[0];
|
||||
const state = STATES[i] || 'Interested';
|
||||
const shift = SHIFTS[i % SHIFTS.length];
|
||||
return (
|
||||
<div key={j.id || i} className="rounded-xl border border-[#E5E7EB] p-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<p className="text-[15px] font-semibold text-[#0F172A]">{title}</p>
|
||||
<div className="mt-1 flex flex-wrap gap-x-3 text-[12px]">
|
||||
<span className="inline-flex items-center gap-1 text-[#0838E0] font-semibold">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-[#0838E0]" /> {state}
|
||||
</span>
|
||||
<span className="text-[#0F172A] font-medium">${pay}/hr</span>
|
||||
<span className="text-[#6B7280]">{shift}</span>
|
||||
</div>
|
||||
</div>
|
||||
{i === 1 && <span className="text-[11px] text-[#94A3B8]">Tomorrow</span>}
|
||||
</div>
|
||||
<div className="mt-3 flex gap-2">
|
||||
<button className="flex-1 text-[12px] font-semibold text-white bg-[#0838E0] rounded-full py-1.5 hover:bg-[#062BAF] transition-colors">
|
||||
Accept
|
||||
</button>
|
||||
<button className="flex-1 text-[12px] font-semibold text-[#0838E0] border border-[#0838E0] rounded-full py-1.5 hover:bg-[#EEF3FE] transition-colors">
|
||||
Counter
|
||||
</button>
|
||||
<button className="text-[12px] font-semibold text-[#94A3B8] border border-[#E5E7EB] rounded-full px-3 py-1.5 hover:text-[#EF4444] hover:border-[#FECACA] transition-colors">
|
||||
Decline
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -4,8 +4,6 @@ import { verifiedExperience } from './VerifiedExperienceList';
|
||||
export { verifiedExperience };
|
||||
import { clientEndorsements } from '@/lib/talentInsights';
|
||||
|
||||
const ENDORSEMENT_ROLES = ['Event Director', 'Operations Lead', 'GM', 'Owner', 'HR Partner'];
|
||||
|
||||
function Stars({ count }) {
|
||||
return (
|
||||
<div className="flex items-center gap-0.5">
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { BookOpen } from 'lucide-react';
|
||||
|
||||
export default function LearnToday({ courses = [] }) {
|
||||
const recs = courses.filter((c) => c.status !== 'archived').slice(0, 4);
|
||||
return (
|
||||
<section className="rounded-2xl bg-white border border-[#E5E7EB] p-6 sm:p-8">
|
||||
<h2 className="text-[18px] font-bold text-[#0F172A]">Learn</h2>
|
||||
<p className="text-[13px] text-[#6B7280] mb-5">Micro-learning. 5-minute lessons. Learning increases value immediately.</p>
|
||||
{recs.length === 0 ? (
|
||||
<p className="text-[13px] text-[#6B7280]">No recommendations yet.</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{recs.map((c) => (
|
||||
<Link key={c.id} to={`/university/${c.id}`} className="flex items-center justify-between gap-3 p-3 rounded-xl hover:bg-[#F9FAFB] border border-transparent hover:border-[#E5E7EB] transition-colors">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-9 h-9 rounded-lg bg-[#EEF3FE] text-[#0838E0] flex items-center justify-center"><BookOpen className="w-4 h-4" /></div>
|
||||
<div>
|
||||
<div className="text-[14px] font-medium text-[#0F172A]">{c.title}</div>
|
||||
<div className="text-[11px] text-[#6B7280]">{c.estimated_minutes} min · {c.category}</div>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-[12px] font-bold text-[#0838E0] whitespace-nowrap">+{Math.max(1, Math.round((c.xp || 100) / 20))} Score</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
import React from 'react';
|
||||
import { TrendingUp } from 'lucide-react';
|
||||
import { getTalentScore, toFICO } from '@/lib/talentHome';
|
||||
|
||||
export default function ReputationHistory({ profile }) {
|
||||
const cur = toFICO(getTalentScore(profile));
|
||||
const pts = [290, 322, 340, cur];
|
||||
const w = 280;
|
||||
const h = 72;
|
||||
const pad = 6;
|
||||
const min = 280;
|
||||
const max = 850;
|
||||
const x = (i) => pad + (i * (w - 2 * pad)) / (pts.length - 1);
|
||||
const y = (v) => h - pad - ((v - min) / (max - min)) * (h - 2 * pad);
|
||||
const path = pts.map((v, i) => `${i ? 'L' : 'M'}${x(i).toFixed(1)},${y(v).toFixed(1)}`).join(' ');
|
||||
|
||||
return (
|
||||
<section className="rounded-2xl bg-white border border-[#E5E7EB] p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-[15px] font-semibold text-[#0F172A]">Reputation History</h2>
|
||||
<span className="inline-flex items-center gap-1 text-[12px] font-semibold text-[#16A34A]">
|
||||
<TrendingUp className="w-3.5 h-3.5" /> {cur}
|
||||
</span>
|
||||
</div>
|
||||
<svg viewBox={`0 0 ${w} ${h}`} className="mt-3 w-full">
|
||||
<path d={path} fill="none" stroke="#0838E0" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" />
|
||||
{pts.map((v, i) => (
|
||||
<circle key={i} cx={x(i)} cy={y(v)} r={3} fill="#0838E0" />
|
||||
))}
|
||||
</svg>
|
||||
<div className="flex justify-between text-[11px] text-[#94A3B8] mt-1">
|
||||
{pts.map((v, i) => (
|
||||
<span key={i}>{v}</span>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
import React from 'react';
|
||||
import { getFactors } from '@/lib/talentHome';
|
||||
|
||||
function statusFor(v) {
|
||||
if (v >= 80) return { txt: 'Excellent', color: 'text-[#16A34A]', dot: 'bg-[#16A34A]' };
|
||||
if (v >= 60) return { txt: 'Good', color: 'text-[#0838E0]', dot: 'bg-[#0838E0]' };
|
||||
if (v >= 40) return { txt: 'Fair', color: 'text-[#D97706]', dot: 'bg-[#D97706]' };
|
||||
if (v > 0) return { txt: 'Poor', color: 'text-[#DC2626]', dot: 'bg-[#DC2626]' };
|
||||
return { txt: 'No data', color: 'text-[#9CA3AF]', dot: 'bg-[#D1D5DB]' };
|
||||
}
|
||||
|
||||
function Factor({ label, value, weight }) {
|
||||
const s = statusFor(value);
|
||||
return (
|
||||
<div className="flex items-center gap-3 py-2.5 border-b border-[#F3F4F6] last:border-0">
|
||||
<span className={`w-2 h-2 rounded-full shrink-0 ${s.dot}`} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[13px] font-medium text-[#0F172A] truncate">{label}</span>
|
||||
<span className="text-[11px] text-[#9CA3AF]">{weight}% weight</span>
|
||||
</div>
|
||||
<div className="h-1.5 bg-[#F3F4F6] rounded-full mt-1.5 overflow-hidden">
|
||||
<div className="h-full bg-[#93C5FD] rounded-full" style={{ width: `${value}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
<span className={`text-[12px] font-semibold w-20 text-right ${s.color}`}>{s.txt}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function TalentCreditScore({ profile }) {
|
||||
const factors = getFactors(profile);
|
||||
return (
|
||||
<section className="rounded-2xl bg-white border border-[#E5E7EB] p-6 sm:p-8">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="text-[18px] font-bold text-[#0F172A]">What's Driving Your Score</h2>
|
||||
<span className="text-[11px] text-[#9CA3AF]">Updated this month</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-x-8">
|
||||
{factors.map((f) => <Factor key={f.key} label={f.label} value={f.value} weight={f.weight} />)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Star } from 'lucide-react';
|
||||
|
||||
export default function VerifiedExperience({ profile }) {
|
||||
const fromExp = (profile.experience || []).map((e) => ({
|
||||
company: e.company || 'Company',
|
||||
shifts: Math.max(1, Math.round((e.years || 1) * 20)),
|
||||
}));
|
||||
const list = fromExp.length
|
||||
? fromExp
|
||||
: [
|
||||
{ company: 'Google', shifts: 58 },
|
||||
{ company: 'Adobe', shifts: 26 },
|
||||
{ company: 'EA', shifts: 15 },
|
||||
];
|
||||
|
||||
return (
|
||||
<section className="rounded-2xl bg-white border border-[#E5E7EB] p-6">
|
||||
<h2 className="text-[15px] font-semibold text-[#0F172A]">Verified Experience</h2>
|
||||
<div className="mt-4 space-y-2">
|
||||
{list.map((x, i) => (
|
||||
<div key={i} className="flex items-center justify-between rounded-xl border border-[#E5E7EB] p-3">
|
||||
<div>
|
||||
<p className="text-[14px] font-semibold text-[#0F172A]">{x.company}</p>
|
||||
<p className="text-[12px] text-[#16A34A] font-medium">{x.shifts} shifts</p>
|
||||
</div>
|
||||
<div className="flex">
|
||||
{Array.from({ length: 5 }).map((_, s) => (
|
||||
<Star key={s} className="w-3.5 h-3.5 text-[#F59E0B] fill-[#F59E0B]" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -53,7 +53,7 @@ function Stars({ count }) {
|
||||
|
||||
// Verified Experience & Network — employer-facing list of the worker's last
|
||||
// 3 jobs with time worked, a review rating, and reference / endorse actions.
|
||||
export default function VerifiedExperienceList({ profile, onReference, onEndorse }) {
|
||||
export default function VerifiedExperienceList({ profile, onReference }) {
|
||||
const list = verifiedExperience(profile);
|
||||
if (list.length === 0) return null;
|
||||
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
// Scallop radius for the flower-like outer border
|
||||
const SCALLOP_R = (R, n) => R * Math.sin(Math.PI / n);
|
||||
|
||||
function scallopPath(cx, cy, R, n) {
|
||||
const r = SCALLOP_R(R, n);
|
||||
const pts = [];
|
||||
for (let i = 0; i < n; i++) {
|
||||
const a = (i / n) * 2 * Math.PI - Math.PI / 2;
|
||||
pts.push([cx + R * Math.cos(a), cy + R * Math.sin(a)]);
|
||||
}
|
||||
let d = `M ${pts[0][0].toFixed(2)} ${pts[0][1].toFixed(2)} `;
|
||||
for (let i = 0; i < n; i++) {
|
||||
const p2 = pts[(i + 1) % n];
|
||||
d += `A ${r.toFixed(2)} ${r.toFixed(2)} 0 0 0 ${p2[0].toFixed(2)} ${p2[1].toFixed(2)} `;
|
||||
}
|
||||
return d + 'Z';
|
||||
}
|
||||
|
||||
function starPath(cx, cy, outer, inner, points = 5) {
|
||||
let d = '';
|
||||
for (let i = 0; i < points * 2; i++) {
|
||||
const r = i % 2 === 0 ? outer : inner;
|
||||
const a = (i / (points * 2)) * 2 * Math.PI - Math.PI / 2;
|
||||
const x = cx + r * Math.cos(a);
|
||||
const y = cy + r * Math.sin(a);
|
||||
d += `${i === 0 ? 'M' : 'L'} ${x.toFixed(2)} ${y.toFixed(2)} `;
|
||||
}
|
||||
return d + 'Z';
|
||||
}
|
||||
|
||||
export default function VerifiedStamp({ size = 26, className = '' }) {
|
||||
const cx = 50, cy = 50, R = 44, n = 12;
|
||||
const green = '#9FD34E';
|
||||
const textPath = `M ${cx} ${cy - 36} A 36 36 0 1 1 ${cx - 0.01} ${cy - 36}`;
|
||||
const label = 'VERIFIED \u2022 VERIFIED \u2022 VERIFIED \u2022';
|
||||
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 100 100"
|
||||
className={className}
|
||||
role="img"
|
||||
aria-label="Verified identity"
|
||||
>
|
||||
<path d={scallopPath(cx, cy, R, n)} fill={green} />
|
||||
<circle cx={cx} cy={cy} r={36} fill="#FFFFFF" />
|
||||
<circle cx={cx} cy={cy} r={36} fill="none" stroke={green} strokeWidth="1.5" />
|
||||
<path id="verified-stamp-arc" d={textPath} fill="none" />
|
||||
<text fontSize="6.5" fontWeight="700" fill={green} letterSpacing="0.5" fontFamily="Sora, sans-serif">
|
||||
<textPath href="#verified-stamp-arc" startOffset="0%">{label}</textPath>
|
||||
</text>
|
||||
<path d={starPath(cx, cy + 1, 13, 5.5)} fill={green} />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Eye, FileText, Calendar, Clock } from 'lucide-react';
|
||||
|
||||
export default function YourMarket({ profile, jobRecs = [] }) {
|
||||
const companies = (jobRecs || []).slice(0, 3).map(r => (r.title || 'Company').split(' ')[0]);
|
||||
const availability = (profile.availability || [])[0] || 'Available This Week';
|
||||
|
||||
const cards = [
|
||||
{ icon: Eye, label: 'Companies viewing you', dots: companies.length ? companies : ['Google', 'Bon Appetit', 'Compass'] },
|
||||
{ icon: FileText, label: 'Applications', value: '2 Pending' },
|
||||
{ icon: Calendar, label: 'Interviews', value: 'Tomorrow 9AM' },
|
||||
{ icon: Clock, label: 'Availability', value: availability },
|
||||
];
|
||||
|
||||
return (
|
||||
<section className="rounded-2xl bg-white border border-[#E5E7EB] p-6">
|
||||
<h2 className="text-[15px] font-semibold text-[#0F172A]">Your Market</h2>
|
||||
<div className="mt-4 grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
{cards.map((c) => {
|
||||
const Icon = c.icon;
|
||||
return (
|
||||
<div key={c.label} className="rounded-xl border border-[#E5E7EB] p-4">
|
||||
<div className="flex items-center gap-2 text-[11px] uppercase tracking-wide text-[#6B7280]">
|
||||
<Icon className="w-3.5 h-3.5" /> {c.label}
|
||||
</div>
|
||||
{c.dots ? (
|
||||
<div className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1">
|
||||
{c.dots.map((co, i) => (
|
||||
<span key={i} className="inline-flex items-center gap-1.5 text-[13px] font-medium text-[#0F172A]">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-[#16A34A]" /> {co}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="mt-1 text-[15px] font-semibold text-[#0F172A]">{c.value}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Star } from 'lucide-react';
|
||||
|
||||
const SEED = [
|
||||
{ name: 'Luis', role: 'Supervisor', events: 12, action: 'Request Reference' },
|
||||
{ name: 'Chef Antonio', role: 'Chef', events: 45, action: 'Endorse Skill' },
|
||||
];
|
||||
|
||||
export default function YourNetwork({ profile }) {
|
||||
const fromExp = (profile.experience || []).slice(0, 2).map((e, i) => ({
|
||||
name: SEED[i]?.name || e.role || 'Colleague',
|
||||
role: e.role || 'Colleague',
|
||||
events: Math.max(1, Math.round((e.years || 1) * 10)),
|
||||
action: i === 0 ? 'Request Reference' : 'Endorse Skill',
|
||||
}));
|
||||
const data = fromExp.length ? fromExp : SEED;
|
||||
|
||||
return (
|
||||
<section className="rounded-2xl bg-white border border-[#E5E7EB] p-6">
|
||||
<h2 className="text-[15px] font-semibold text-[#0F172A]">Your Network</h2>
|
||||
<div className="mt-4 space-y-3">
|
||||
{data.map((n, i) => (
|
||||
<div key={i} className="flex items-center justify-between rounded-xl border border-[#E5E7EB] p-4">
|
||||
<div>
|
||||
<p className="text-[14px] font-semibold text-[#0F172A]">{n.name}</p>
|
||||
<p className="text-[12px] text-[#6B7280]">
|
||||
{n.role} · {n.events} events
|
||||
</p>
|
||||
<div className="mt-1 flex">
|
||||
{Array.from({ length: 5 }).map((_, s) => (
|
||||
<Star key={s} className="w-3.5 h-3.5 text-[#F59E0B] fill-[#F59E0B]" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<button className="text-[12px] font-semibold text-[#0838E0] border border-[#0838E0] rounded-full px-3 py-1.5 hover:bg-[#EEF3FE] transition-colors">
|
||||
{n.action}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,229 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Sparkles, Upload } from 'lucide-react';
|
||||
import { Button, Field, Input, Modal, Textarea } from '@/components/ds';
|
||||
import { usePreferences, useUpdatePreferences } from '@/lib/krowHooks';
|
||||
import { PAGE_KEYS, validateSkillSource } from '@/lib/skills/registry';
|
||||
import { skillTemplate, upsertCustomSkill } from '@/lib/skills/customSkills';
|
||||
import {
|
||||
isReadableDefinition, normalizeUpload, owliverFieldsFromSource, patchFrontmatter,
|
||||
} from '@/lib/skills/skillFields';
|
||||
import { reportSave } from '@/lib/skills/saveFeedback';
|
||||
|
||||
/**
|
||||
* Add an Owliver skill.
|
||||
*
|
||||
* One dialog, opened from Settings and from Owliver's own header, writing to
|
||||
* one registry — a second registration path would be a second source of truth
|
||||
* by another name.
|
||||
*
|
||||
* What is stored is the Markdown itself, not a parsed record. A custom skill is
|
||||
* then exactly the artefact a file on disk is, read by the same parser, and can
|
||||
* be copied into `src/skills/` later without conversion.
|
||||
*
|
||||
* The fields above the editor are a starting point, not a second schema: typing
|
||||
* in them composes frontmatter in the existing format, which the author can
|
||||
* then edit directly. The Markdown is always what gets saved.
|
||||
*/
|
||||
|
||||
export function AddSkillDialog({ open, onOpenChange }) {
|
||||
const preferences = usePreferences();
|
||||
const updatePreferences = useUpdatePreferences();
|
||||
|
||||
const [meta, setMeta] = React.useState({ id: '', name: '', description: '', pages: '' });
|
||||
const [source, setSource] = React.useState('');
|
||||
const [touched, setTouched] = React.useState(false);
|
||||
const [error, setError] = React.useState('');
|
||||
const fileRef = React.useRef(null);
|
||||
|
||||
/* A fresh sheet each time it opens, so an abandoned draft is not waiting the
|
||||
next time someone adds a skill. */
|
||||
React.useEffect(() => {
|
||||
if (!open) return;
|
||||
setMeta({ id: '', name: '', description: '', pages: '' });
|
||||
setSource('');
|
||||
setTouched(false);
|
||||
setError('');
|
||||
}, [open]);
|
||||
|
||||
/**
|
||||
* A field edit, written into the definition.
|
||||
*
|
||||
* A fresh draft is composed from the template; once there is a definition to
|
||||
* preserve — an uploaded file, or Markdown typed by hand — the field patches
|
||||
* the key it owns and leaves everything else alone. It used to stop here
|
||||
* entirely, so after an upload these four boxes edited nothing that was saved.
|
||||
*/
|
||||
const syncFromMeta = (next) => {
|
||||
setMeta(next);
|
||||
const pages = next.pages.split(',').map((p) => p.trim()).filter(Boolean);
|
||||
|
||||
if (!touched) {
|
||||
setSource(skillTemplate({
|
||||
id: next.id,
|
||||
name: next.name,
|
||||
description: next.description,
|
||||
pages,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
setSource(patchFrontmatter(source, {
|
||||
id: next.id || undefined,
|
||||
name: next.name || undefined,
|
||||
description: next.description || undefined,
|
||||
pages: pages.length ? pages : undefined,
|
||||
}));
|
||||
};
|
||||
|
||||
/**
|
||||
* A definition uploaded from a file.
|
||||
*
|
||||
* Filled the Markdown box and left every field beside it empty, and said
|
||||
* nothing about whether the file was usable until Add was pressed. Both are
|
||||
* fixed here: the frontmatter fills the fields, and an unusable file says so
|
||||
* at the moment it arrives.
|
||||
*/
|
||||
const readFile = (event) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onerror = () => setError('That file could not be read.');
|
||||
reader.onload = () => {
|
||||
const raw = normalizeUpload(reader.result);
|
||||
setSource(raw);
|
||||
setTouched(true);
|
||||
|
||||
/* Nothing readable: the fields stay as they were rather than emptying to
|
||||
a row of placeholders that read as values. */
|
||||
if (!isReadableDefinition(raw)) {
|
||||
setError('That file has no frontmatter block, so there is nothing to read into the fields. '
|
||||
+ 'A definition starts with `---` on its own line.');
|
||||
return;
|
||||
}
|
||||
|
||||
setError(validateSkillSource(raw) || '');
|
||||
const fields = owliverFieldsFromSource(raw);
|
||||
setMeta({
|
||||
id: fields.id,
|
||||
name: fields.name,
|
||||
description: fields.description,
|
||||
pages: fields.pages.join(', '),
|
||||
});
|
||||
};
|
||||
reader.readAsText(file);
|
||||
event.target.value = '';
|
||||
};
|
||||
|
||||
const save = () => {
|
||||
const problem = validateSkillSource(source);
|
||||
if (problem) {
|
||||
setError(problem);
|
||||
return;
|
||||
}
|
||||
|
||||
const { skill, next } = upsertCustomSkill(preferences.customSkills || [], source);
|
||||
|
||||
updatePreferences.mutate({ customSkills: next }, reportSave(`${skill.name} added`));
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title="Add Owliver skill"
|
||||
description="Define a skill in Markdown. It attaches itself to the pages it names."
|
||||
icon={Sparkles}
|
||||
size="lg"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>Cancel</Button>
|
||||
<Button onClick={save} loading={updatePreferences.isPending}>Add skill</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Field label="Skill name">
|
||||
<Input
|
||||
value={meta.name}
|
||||
placeholder="e.g. Chef Training"
|
||||
onChange={(e) => syncFromMeta({ ...meta, name: e.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Skill ID" hint="Lower-case, dashes.">
|
||||
<Input
|
||||
value={meta.id}
|
||||
placeholder="e.g. chef-training"
|
||||
onChange={(e) => syncFromMeta({ ...meta, id: e.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<Field label="Description">
|
||||
<Input
|
||||
value={meta.description}
|
||||
placeholder="e.g. Helps Owliver explain chef training paths."
|
||||
onChange={(e) => syncFromMeta({ ...meta, description: e.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Pages" hint={`Comma separated. Known pages: ${PAGE_KEYS.join(', ')}`}>
|
||||
<Input
|
||||
value={meta.pages}
|
||||
placeholder="university"
|
||||
onChange={(e) => syncFromMeta({ ...meta, pages: e.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label="Markdown definition"
|
||||
hint="This is what gets saved. Edit it directly if you prefer."
|
||||
error={error}
|
||||
>
|
||||
<Textarea
|
||||
value={source}
|
||||
rows={12}
|
||||
spellCheck={false}
|
||||
className="font-mono text-[12px] leading-relaxed"
|
||||
placeholder="--- id: my-skill name: My Skill pages: - positions ---"
|
||||
onChange={(e) => {
|
||||
const next = e.target.value;
|
||||
setSource(next);
|
||||
setTouched(true);
|
||||
|
||||
/* Markdown → fields, the direction that was missing. Same
|
||||
extractor the upload path uses; guarded so a half-typed
|
||||
definition cannot wipe fields that are already filled in.
|
||||
See the fuller note in the two skill editors. */
|
||||
if (isReadableDefinition(next)) {
|
||||
const fields = owliverFieldsFromSource(next);
|
||||
if (fields.name) {
|
||||
setMeta({
|
||||
id: fields.id,
|
||||
name: fields.name,
|
||||
description: fields.description,
|
||||
pages: fields.pages.join(', '),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (error) setError('');
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<input ref={fileRef} type="file" accept=".md,text/markdown" onChange={readFile} className="hidden" />
|
||||
<Button variant="outline" size="sm" onClick={() => fileRef.current?.click()}>
|
||||
<Upload aria-hidden="true" /> Upload .md
|
||||
</Button>
|
||||
<p className="text-caption text-ink-4">
|
||||
Stored as data in this browser, for this workspace. It is never executed.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
import * as React from "react"
|
||||
import * as AccordionPrimitive from "@radix-ui/react-accordion"
|
||||
import { ChevronDown } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Accordion = AccordionPrimitive.Root
|
||||
|
||||
const AccordionItem = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<AccordionPrimitive.Item ref={ref} className={cn("border-b", className)} {...props} />
|
||||
))
|
||||
AccordionItem.displayName = "AccordionItem"
|
||||
|
||||
const AccordionTrigger = React.forwardRef(({ className, children, ...props }, ref) => (
|
||||
<AccordionPrimitive.Header className="flex">
|
||||
<AccordionPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex flex-1 items-center justify-between py-4 text-sm font-medium transition-all hover:underline text-left [&[data-state=open]>svg]:rotate-180",
|
||||
className
|
||||
)}
|
||||
{...props}>
|
||||
{children}
|
||||
<ChevronDown
|
||||
className="h-4 w-4 shrink-0 text-muted-foreground transition-transform duration-200" />
|
||||
</AccordionPrimitive.Trigger>
|
||||
</AccordionPrimitive.Header>
|
||||
))
|
||||
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName
|
||||
|
||||
const AccordionContent = React.forwardRef(({ className, children, ...props }, ref) => (
|
||||
<AccordionPrimitive.Content
|
||||
ref={ref}
|
||||
className="overflow-hidden text-sm data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
|
||||
{...props}>
|
||||
<div className={cn("pb-4 pt-0", className)}>{children}</div>
|
||||
</AccordionPrimitive.Content>
|
||||
))
|
||||
AccordionContent.displayName = AccordionPrimitive.Content.displayName
|
||||
|
||||
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
|
||||
@@ -1,97 +0,0 @@
|
||||
import * as React from "react"
|
||||
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { buttonVariants } from "@/components/ui/button"
|
||||
|
||||
const AlertDialog = AlertDialogPrimitive.Root
|
||||
|
||||
const AlertDialogTrigger = AlertDialogPrimitive.Trigger
|
||||
|
||||
const AlertDialogPortal = AlertDialogPrimitive.Portal
|
||||
|
||||
const AlertDialogOverlay = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Overlay
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
ref={ref} />
|
||||
))
|
||||
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
|
||||
|
||||
const AlertDialogContent = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay />
|
||||
<AlertDialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||
className
|
||||
)}
|
||||
{...props} />
|
||||
</AlertDialogPortal>
|
||||
))
|
||||
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
|
||||
|
||||
const AlertDialogHeader = ({
|
||||
className,
|
||||
...props
|
||||
}) => (
|
||||
<div
|
||||
className={cn("flex flex-col space-y-2 text-center sm:text-left", className)}
|
||||
{...props} />
|
||||
)
|
||||
AlertDialogHeader.displayName = "AlertDialogHeader"
|
||||
|
||||
const AlertDialogFooter = ({
|
||||
className,
|
||||
...props
|
||||
}) => (
|
||||
<div
|
||||
className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)}
|
||||
{...props} />
|
||||
)
|
||||
AlertDialogFooter.displayName = "AlertDialogFooter"
|
||||
|
||||
const AlertDialogTitle = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Title ref={ref} className={cn("text-lg font-semibold", className)} {...props} />
|
||||
))
|
||||
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
|
||||
|
||||
const AlertDialogDescription = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props} />
|
||||
))
|
||||
AlertDialogDescription.displayName =
|
||||
AlertDialogPrimitive.Description.displayName
|
||||
|
||||
const AlertDialogAction = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Action ref={ref} className={cn(buttonVariants(), className)} {...props} />
|
||||
))
|
||||
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName
|
||||
|
||||
const AlertDialogCancel = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Cancel
|
||||
ref={ref}
|
||||
className={cn(buttonVariants({ variant: "outline" }), "mt-2 sm:mt-0", className)}
|
||||
{...props} />
|
||||
))
|
||||
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName
|
||||
|
||||
export {
|
||||
AlertDialog,
|
||||
AlertDialogPortal,
|
||||
AlertDialogOverlay,
|
||||
AlertDialogTrigger,
|
||||
AlertDialogContent,
|
||||
AlertDialogHeader,
|
||||
AlertDialogFooter,
|
||||
AlertDialogTitle,
|
||||
AlertDialogDescription,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
import * as React from "react"
|
||||
import { cva } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const alertVariants = cva(
|
||||
"relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-background text-foreground",
|
||||
destructive:
|
||||
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
const Alert = React.forwardRef(({ className, variant, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
role="alert"
|
||||
className={cn(alertVariants({ variant }), className)}
|
||||
{...props} />
|
||||
))
|
||||
Alert.displayName = "Alert"
|
||||
|
||||
const AlertTitle = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<h5
|
||||
ref={ref}
|
||||
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
|
||||
{...props} />
|
||||
))
|
||||
AlertTitle.displayName = "AlertTitle"
|
||||
|
||||
const AlertDescription = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("text-sm [&_p]:leading-relaxed", className)}
|
||||
{...props} />
|
||||
))
|
||||
AlertDescription.displayName = "AlertDescription"
|
||||
|
||||
export { Alert, AlertTitle, AlertDescription }
|
||||
@@ -1,5 +0,0 @@
|
||||
import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio"
|
||||
|
||||
const AspectRatio = AspectRatioPrimitive.Root
|
||||
|
||||
export { AspectRatio }
|
||||
@@ -1,35 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as AvatarPrimitive from "@radix-ui/react-avatar"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Avatar = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<AvatarPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn("relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full", className)}
|
||||
{...props} />
|
||||
))
|
||||
Avatar.displayName = AvatarPrimitive.Root.displayName
|
||||
|
||||
const AvatarImage = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<AvatarPrimitive.Image
|
||||
ref={ref}
|
||||
className={cn("aspect-square h-full w-full", className)}
|
||||
{...props} />
|
||||
))
|
||||
AvatarImage.displayName = AvatarPrimitive.Image.displayName
|
||||
|
||||
const AvatarFallback = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<AvatarPrimitive.Fallback
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-full w-full items-center justify-center rounded-full bg-muted",
|
||||
className
|
||||
)}
|
||||
{...props} />
|
||||
))
|
||||
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName
|
||||
|
||||
export { Avatar, AvatarImage, AvatarFallback }
|
||||
@@ -1,74 +0,0 @@
|
||||
import * as React from "react"
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react"
|
||||
import { DayPicker } from "react-day-picker"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { buttonVariants } from "@/components/ui/button"
|
||||
|
||||
// react-day-picker v9 renamed every element key and replaced the IconLeft /
|
||||
// IconRight slots with a single `Chevron` that receives an orientation. The
|
||||
// styling below is the same as before the upgrade, remapped onto the v9 names.
|
||||
function Calendar({
|
||||
className,
|
||||
classNames,
|
||||
showOutsideDays = true,
|
||||
...props
|
||||
}) {
|
||||
return (
|
||||
(<DayPicker
|
||||
showOutsideDays={showOutsideDays}
|
||||
className={cn("p-3", className)}
|
||||
classNames={{
|
||||
months: "flex flex-col sm:flex-row gap-4 relative",
|
||||
month: "space-y-4",
|
||||
month_caption: "flex justify-center pt-1 relative items-center h-7",
|
||||
caption_label: "text-sm font-medium",
|
||||
nav: "flex items-center gap-1 absolute inset-x-1 top-1 justify-between z-10",
|
||||
button_previous: cn(
|
||||
buttonVariants({ variant: "outline" }),
|
||||
"h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100"
|
||||
),
|
||||
button_next: cn(
|
||||
buttonVariants({ variant: "outline" }),
|
||||
"h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100"
|
||||
),
|
||||
month_grid: "w-full border-collapse space-y-1",
|
||||
weekdays: "flex",
|
||||
weekday: "text-muted-foreground rounded-md w-8 font-normal text-[0.8rem]",
|
||||
week: "flex w-full mt-2",
|
||||
day: cn(
|
||||
"relative p-0 text-center text-sm focus-within:relative focus-within:z-20",
|
||||
"[&:has([aria-selected])]:bg-accent [&:has([aria-selected].day-outside)]:bg-accent/50",
|
||||
props.mode === "range"
|
||||
? "[&:has(>.day-range-end)]:rounded-r-md [&:has(>.day-range-start)]:rounded-l-md first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md"
|
||||
: "[&:has([aria-selected])]:rounded-md"
|
||||
),
|
||||
day_button: cn(
|
||||
buttonVariants({ variant: "ghost" }),
|
||||
"h-8 w-8 p-0 font-normal aria-selected:opacity-100"
|
||||
),
|
||||
range_start: "day-range-start",
|
||||
range_end: "day-range-end",
|
||||
selected:
|
||||
"bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground",
|
||||
today: "bg-accent text-accent-foreground",
|
||||
outside:
|
||||
"day-outside text-muted-foreground aria-selected:bg-accent/50 aria-selected:text-muted-foreground",
|
||||
disabled: "text-muted-foreground opacity-50",
|
||||
range_middle:
|
||||
"aria-selected:bg-accent aria-selected:text-accent-foreground",
|
||||
hidden: "invisible",
|
||||
...classNames,
|
||||
}}
|
||||
components={{
|
||||
Chevron: ({ className, orientation, ...props }) => {
|
||||
const Icon = orientation === "left" ? ChevronLeft : ChevronRight
|
||||
return <Icon className={cn("h-4 w-4", className)} {...props} />
|
||||
},
|
||||
}}
|
||||
{...props} />)
|
||||
);
|
||||
}
|
||||
Calendar.displayName = "Calendar"
|
||||
|
||||
export { Calendar }
|
||||
@@ -1,193 +0,0 @@
|
||||
import * as React from "react"
|
||||
import useEmblaCarousel from "embla-carousel-react";
|
||||
import { ArrowLeft, ArrowRight } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
|
||||
const CarouselContext = React.createContext(null)
|
||||
|
||||
function useCarousel() {
|
||||
const context = React.useContext(CarouselContext)
|
||||
|
||||
if (!context) {
|
||||
throw new Error("useCarousel must be used within a <Carousel />")
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
|
||||
const Carousel = React.forwardRef((
|
||||
{
|
||||
orientation = "horizontal",
|
||||
opts,
|
||||
setApi,
|
||||
plugins,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const [carouselRef, api] = useEmblaCarousel({
|
||||
...opts,
|
||||
axis: orientation === "horizontal" ? "x" : "y",
|
||||
}, plugins)
|
||||
const [canScrollPrev, setCanScrollPrev] = React.useState(false)
|
||||
const [canScrollNext, setCanScrollNext] = React.useState(false)
|
||||
|
||||
const onSelect = React.useCallback((api) => {
|
||||
if (!api) {
|
||||
return
|
||||
}
|
||||
|
||||
setCanScrollPrev(api.canScrollPrev())
|
||||
setCanScrollNext(api.canScrollNext())
|
||||
}, [])
|
||||
|
||||
const scrollPrev = React.useCallback(() => {
|
||||
api?.scrollPrev()
|
||||
}, [api])
|
||||
|
||||
const scrollNext = React.useCallback(() => {
|
||||
api?.scrollNext()
|
||||
}, [api])
|
||||
|
||||
const handleKeyDown = React.useCallback((event) => {
|
||||
if (event.key === "ArrowLeft") {
|
||||
event.preventDefault()
|
||||
scrollPrev()
|
||||
} else if (event.key === "ArrowRight") {
|
||||
event.preventDefault()
|
||||
scrollNext()
|
||||
}
|
||||
}, [scrollPrev, scrollNext])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!api || !setApi) {
|
||||
return
|
||||
}
|
||||
|
||||
setApi(api)
|
||||
}, [api, setApi])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!api) {
|
||||
return
|
||||
}
|
||||
|
||||
onSelect(api)
|
||||
api.on("reInit", onSelect)
|
||||
api.on("select", onSelect)
|
||||
|
||||
return () => {
|
||||
api?.off("select", onSelect)
|
||||
};
|
||||
}, [api, onSelect])
|
||||
|
||||
return (
|
||||
(<CarouselContext.Provider
|
||||
value={{
|
||||
carouselRef,
|
||||
api: api,
|
||||
opts,
|
||||
orientation:
|
||||
orientation || (opts?.axis === "y" ? "vertical" : "horizontal"),
|
||||
scrollPrev,
|
||||
scrollNext,
|
||||
canScrollPrev,
|
||||
canScrollNext,
|
||||
}}>
|
||||
<div
|
||||
ref={ref}
|
||||
onKeyDownCapture={handleKeyDown}
|
||||
className={cn("relative", className)}
|
||||
role="region"
|
||||
aria-roledescription="carousel"
|
||||
{...props}>
|
||||
{children}
|
||||
</div>
|
||||
</CarouselContext.Provider>)
|
||||
);
|
||||
})
|
||||
Carousel.displayName = "Carousel"
|
||||
|
||||
const CarouselContent = React.forwardRef(({ className, ...props }, ref) => {
|
||||
const { carouselRef, orientation } = useCarousel()
|
||||
|
||||
return (
|
||||
(<div ref={carouselRef} className="overflow-hidden">
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex",
|
||||
orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col",
|
||||
className
|
||||
)}
|
||||
{...props} />
|
||||
</div>)
|
||||
);
|
||||
})
|
||||
CarouselContent.displayName = "CarouselContent"
|
||||
|
||||
const CarouselItem = React.forwardRef(({ className, ...props }, ref) => {
|
||||
const { orientation } = useCarousel()
|
||||
|
||||
return (
|
||||
(<div
|
||||
ref={ref}
|
||||
role="group"
|
||||
aria-roledescription="slide"
|
||||
className={cn(
|
||||
"min-w-0 shrink-0 grow-0 basis-full",
|
||||
orientation === "horizontal" ? "pl-4" : "pt-4",
|
||||
className
|
||||
)}
|
||||
{...props} />)
|
||||
);
|
||||
})
|
||||
CarouselItem.displayName = "CarouselItem"
|
||||
|
||||
const CarouselPrevious = React.forwardRef(({ className, variant = "outline", size = "icon", ...props }, ref) => {
|
||||
const { orientation, scrollPrev, canScrollPrev } = useCarousel()
|
||||
|
||||
return (
|
||||
(<Button
|
||||
ref={ref}
|
||||
variant={variant}
|
||||
size={size}
|
||||
className={cn("absolute h-8 w-8 rounded-full", orientation === "horizontal"
|
||||
? "-left-12 top-1/2 -translate-y-1/2"
|
||||
: "-top-12 left-1/2 -translate-x-1/2 rotate-90", className)}
|
||||
disabled={!canScrollPrev}
|
||||
onClick={scrollPrev}
|
||||
{...props}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
<span className="sr-only">Previous slide</span>
|
||||
</Button>)
|
||||
);
|
||||
})
|
||||
CarouselPrevious.displayName = "CarouselPrevious"
|
||||
|
||||
const CarouselNext = React.forwardRef(({ className, variant = "outline", size = "icon", ...props }, ref) => {
|
||||
const { orientation, scrollNext, canScrollNext } = useCarousel()
|
||||
|
||||
return (
|
||||
(<Button
|
||||
ref={ref}
|
||||
variant={variant}
|
||||
size={size}
|
||||
className={cn("absolute h-8 w-8 rounded-full", orientation === "horizontal"
|
||||
? "-right-12 top-1/2 -translate-y-1/2"
|
||||
: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90", className)}
|
||||
disabled={!canScrollNext}
|
||||
onClick={scrollNext}
|
||||
{...props}>
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
<span className="sr-only">Next slide</span>
|
||||
</Button>)
|
||||
);
|
||||
})
|
||||
CarouselNext.displayName = "CarouselNext"
|
||||
|
||||
export { Carousel, CarouselContent, CarouselItem, CarouselPrevious, CarouselNext };
|
||||
@@ -1,309 +0,0 @@
|
||||
"use client";
|
||||
import * as React from "react"
|
||||
import * as RechartsPrimitive from "recharts"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
// Format: { THEME_NAME: CSS_SELECTOR }
|
||||
const THEMES = {
|
||||
light: "",
|
||||
dark: ".dark"
|
||||
}
|
||||
|
||||
const ChartContext = React.createContext(null)
|
||||
|
||||
function useChart() {
|
||||
const context = React.useContext(ChartContext)
|
||||
|
||||
if (!context) {
|
||||
throw new Error("useChart must be used within a <ChartContainer />")
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
|
||||
const ChartContainer = React.forwardRef(({ id, className, children, config, ...props }, ref) => {
|
||||
const uniqueId = React.useId()
|
||||
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`
|
||||
|
||||
return (
|
||||
(<ChartContext.Provider value={{ config }}>
|
||||
<div
|
||||
data-chart={chartId}
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-none [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-sector]:outline-none [&_.recharts-surface]:outline-none",
|
||||
className
|
||||
)}
|
||||
{...props}>
|
||||
<ChartStyle id={chartId} config={config} />
|
||||
<RechartsPrimitive.ResponsiveContainer>
|
||||
{children}
|
||||
</RechartsPrimitive.ResponsiveContainer>
|
||||
</div>
|
||||
</ChartContext.Provider>)
|
||||
);
|
||||
})
|
||||
ChartContainer.displayName = "Chart"
|
||||
|
||||
const ChartStyle = ({
|
||||
id,
|
||||
config
|
||||
}) => {
|
||||
const colorConfig = Object.entries(config).filter(([, config]) => config.theme || config.color)
|
||||
|
||||
if (!colorConfig.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
(<style
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: Object.entries(THEMES)
|
||||
.map(([theme, prefix]) => `
|
||||
${prefix} [data-chart=${id}] {
|
||||
${colorConfig
|
||||
.map(([key, itemConfig]) => {
|
||||
const color =
|
||||
itemConfig.theme?.[theme] ||
|
||||
itemConfig.color
|
||||
return color ? ` --color-${key}: ${color};` : null
|
||||
})
|
||||
.join("\n")}
|
||||
}
|
||||
`)
|
||||
.join("\n"),
|
||||
}} />)
|
||||
);
|
||||
}
|
||||
|
||||
const ChartTooltip = RechartsPrimitive.Tooltip
|
||||
|
||||
const ChartTooltipContent = React.forwardRef((
|
||||
{
|
||||
active,
|
||||
payload,
|
||||
className,
|
||||
indicator = "dot",
|
||||
hideLabel = false,
|
||||
hideIndicator = false,
|
||||
label,
|
||||
labelFormatter,
|
||||
labelClassName,
|
||||
formatter,
|
||||
color,
|
||||
nameKey,
|
||||
labelKey,
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const { config } = useChart()
|
||||
|
||||
const tooltipLabel = React.useMemo(() => {
|
||||
if (hideLabel || !payload?.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
const [item] = payload
|
||||
const key = `${labelKey || item.dataKey || item.name || "value"}`
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
||||
const value =
|
||||
!labelKey && typeof label === "string"
|
||||
? config[label]?.label || label
|
||||
: itemConfig?.label
|
||||
|
||||
if (labelFormatter) {
|
||||
return (
|
||||
(<div className={cn("font-medium", labelClassName)}>
|
||||
{labelFormatter(value, payload)}
|
||||
</div>)
|
||||
);
|
||||
}
|
||||
|
||||
if (!value) {
|
||||
return null
|
||||
}
|
||||
|
||||
return <div className={cn("font-medium", labelClassName)}>{value}</div>;
|
||||
}, [
|
||||
label,
|
||||
labelFormatter,
|
||||
payload,
|
||||
hideLabel,
|
||||
labelClassName,
|
||||
config,
|
||||
labelKey,
|
||||
])
|
||||
|
||||
if (!active || !payload?.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
const nestLabel = payload.length === 1 && indicator !== "dot"
|
||||
|
||||
return (
|
||||
(<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"grid min-w-[8rem] items-start gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl",
|
||||
className
|
||||
)}>
|
||||
{!nestLabel ? tooltipLabel : null}
|
||||
<div className="grid gap-1.5">
|
||||
{payload.map((item, index) => {
|
||||
const key = `${nameKey || item.name || item.dataKey || "value"}`
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
||||
const indicatorColor = color || item.payload.fill || item.color
|
||||
|
||||
return (
|
||||
(<div
|
||||
key={item.dataKey}
|
||||
className={cn(
|
||||
"flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground",
|
||||
indicator === "dot" && "items-center"
|
||||
)}>
|
||||
{formatter && item?.value !== undefined && item.name ? (
|
||||
formatter(item.value, item.name, item, index, item.payload)
|
||||
) : (
|
||||
<>
|
||||
{itemConfig?.icon ? (
|
||||
<itemConfig.icon />
|
||||
) : (
|
||||
!hideIndicator && (
|
||||
<div
|
||||
className={cn("shrink-0 rounded-[2px] border-[--color-border] bg-[--color-bg]", {
|
||||
"h-2.5 w-2.5": indicator === "dot",
|
||||
"w-1": indicator === "line",
|
||||
"w-0 border-[1.5px] border-dashed bg-transparent":
|
||||
indicator === "dashed",
|
||||
"my-0.5": nestLabel && indicator === "dashed",
|
||||
})}
|
||||
style={
|
||||
{
|
||||
"--color-bg": indicatorColor,
|
||||
"--color-border": indicatorColor
|
||||
}
|
||||
} />
|
||||
)
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-1 justify-between leading-none",
|
||||
nestLabel ? "items-end" : "items-center"
|
||||
)}>
|
||||
<div className="grid gap-1.5">
|
||||
{nestLabel ? tooltipLabel : null}
|
||||
<span className="text-muted-foreground">
|
||||
{itemConfig?.label || item.name}
|
||||
</span>
|
||||
</div>
|
||||
{item.value && (
|
||||
<span className="font-mono font-medium tabular-nums text-foreground">
|
||||
{item.value.toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>)
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>)
|
||||
);
|
||||
})
|
||||
ChartTooltipContent.displayName = "ChartTooltip"
|
||||
|
||||
const ChartLegend = RechartsPrimitive.Legend
|
||||
|
||||
const ChartLegendContent = React.forwardRef((
|
||||
{ className, hideIcon = false, payload, verticalAlign = "bottom", nameKey },
|
||||
ref
|
||||
) => {
|
||||
const { config } = useChart()
|
||||
|
||||
if (!payload?.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
(<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex items-center justify-center gap-4",
|
||||
verticalAlign === "top" ? "pb-3" : "pt-3",
|
||||
className
|
||||
)}>
|
||||
{payload.map((item) => {
|
||||
const key = `${nameKey || item.dataKey || "value"}`
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
||||
|
||||
return (
|
||||
(<div
|
||||
key={item.value}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground"
|
||||
)}>
|
||||
{itemConfig?.icon && !hideIcon ? (
|
||||
<itemConfig.icon />
|
||||
) : (
|
||||
<div
|
||||
className="h-2 w-2 shrink-0 rounded-[2px]"
|
||||
style={{
|
||||
backgroundColor: item.color,
|
||||
}} />
|
||||
)}
|
||||
{itemConfig?.label}
|
||||
</div>)
|
||||
);
|
||||
})}
|
||||
</div>)
|
||||
);
|
||||
})
|
||||
ChartLegendContent.displayName = "ChartLegend"
|
||||
|
||||
// Helper to extract item config from a payload.
|
||||
function getPayloadConfigFromPayload(
|
||||
config,
|
||||
payload,
|
||||
key
|
||||
) {
|
||||
if (typeof payload !== "object" || payload === null) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const payloadPayload =
|
||||
"payload" in payload &&
|
||||
typeof payload.payload === "object" &&
|
||||
payload.payload !== null
|
||||
? payload.payload
|
||||
: undefined
|
||||
|
||||
let configLabelKey = key
|
||||
|
||||
if (
|
||||
key in payload &&
|
||||
typeof payload[key] === "string"
|
||||
) {
|
||||
configLabelKey = payload[key]
|
||||
} else if (
|
||||
payloadPayload &&
|
||||
key in payloadPayload &&
|
||||
typeof payloadPayload[key] === "string"
|
||||
) {
|
||||
configLabelKey = payloadPayload[key]
|
||||
}
|
||||
|
||||
return configLabelKey in config
|
||||
? config[configLabelKey]
|
||||
: config[key];
|
||||
}
|
||||
|
||||
export {
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
ChartLegend,
|
||||
ChartLegendContent,
|
||||
ChartStyle,
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
import * as React from "react"
|
||||
import { Command as CommandPrimitive } from "cmdk"
|
||||
import { Search } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Dialog, DialogContent } from "@/components/ui/dialog"
|
||||
|
||||
const Command = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground",
|
||||
className
|
||||
)}
|
||||
{...props} />
|
||||
))
|
||||
Command.displayName = CommandPrimitive.displayName
|
||||
|
||||
const CommandDialog = ({
|
||||
children,
|
||||
...props
|
||||
}) => {
|
||||
return (
|
||||
(<Dialog {...props}>
|
||||
<DialogContent className="overflow-hidden p-0">
|
||||
<Command
|
||||
className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
|
||||
{children}
|
||||
</Command>
|
||||
</DialogContent>
|
||||
</Dialog>)
|
||||
);
|
||||
}
|
||||
|
||||
const CommandInput = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
|
||||
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
<CommandPrimitive.Input
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props} />
|
||||
</div>
|
||||
))
|
||||
|
||||
CommandInput.displayName = CommandPrimitive.Input.displayName
|
||||
|
||||
const CommandList = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.List
|
||||
ref={ref}
|
||||
className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)}
|
||||
{...props} />
|
||||
))
|
||||
|
||||
CommandList.displayName = CommandPrimitive.List.displayName
|
||||
|
||||
const CommandEmpty = React.forwardRef((props, ref) => (
|
||||
<CommandPrimitive.Empty ref={ref} className="py-6 text-center text-sm" {...props} />
|
||||
))
|
||||
|
||||
CommandEmpty.displayName = CommandPrimitive.Empty.displayName
|
||||
|
||||
const CommandGroup = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.Group
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props} />
|
||||
))
|
||||
|
||||
CommandGroup.displayName = CommandPrimitive.Group.displayName
|
||||
|
||||
const CommandSeparator = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.Separator ref={ref} className={cn("-mx-1 h-px bg-border", className)} {...props} />
|
||||
))
|
||||
CommandSeparator.displayName = CommandPrimitive.Separator.displayName
|
||||
|
||||
const CommandItem = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
className
|
||||
)}
|
||||
{...props} />
|
||||
))
|
||||
|
||||
CommandItem.displayName = CommandPrimitive.Item.displayName
|
||||
|
||||
const CommandShortcut = ({
|
||||
className,
|
||||
...props
|
||||
}) => {
|
||||
return (
|
||||
(<span
|
||||
className={cn("ml-auto text-xs tracking-widest text-muted-foreground", className)}
|
||||
{...props} />)
|
||||
);
|
||||
}
|
||||
CommandShortcut.displayName = "CommandShortcut"
|
||||
|
||||
export {
|
||||
Command,
|
||||
CommandDialog,
|
||||
CommandInput,
|
||||
CommandList,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandItem,
|
||||
CommandShortcut,
|
||||
CommandSeparator,
|
||||
}
|
||||
@@ -1,156 +0,0 @@
|
||||
import * as React from "react"
|
||||
import * as ContextMenuPrimitive from "@radix-ui/react-context-menu"
|
||||
import { Check, ChevronRight, Circle } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const ContextMenu = ContextMenuPrimitive.Root
|
||||
|
||||
const ContextMenuTrigger = ContextMenuPrimitive.Trigger
|
||||
|
||||
const ContextMenuGroup = ContextMenuPrimitive.Group
|
||||
|
||||
const ContextMenuPortal = ContextMenuPrimitive.Portal
|
||||
|
||||
const ContextMenuSub = ContextMenuPrimitive.Sub
|
||||
|
||||
const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup
|
||||
|
||||
const ContextMenuSubTrigger = React.forwardRef(({ className, inset, children, ...props }, ref) => (
|
||||
<ContextMenuPrimitive.SubTrigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}>
|
||||
{children}
|
||||
<ChevronRight className="ml-auto h-4 w-4" />
|
||||
</ContextMenuPrimitive.SubTrigger>
|
||||
))
|
||||
ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName
|
||||
|
||||
const ContextMenuSubContent = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<ContextMenuPrimitive.SubContent
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className
|
||||
)}
|
||||
{...props} />
|
||||
))
|
||||
ContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName
|
||||
|
||||
const ContextMenuContent = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<ContextMenuPrimitive.Portal>
|
||||
<ContextMenuPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className
|
||||
)}
|
||||
{...props} />
|
||||
</ContextMenuPrimitive.Portal>
|
||||
))
|
||||
ContextMenuContent.displayName = ContextMenuPrimitive.Content.displayName
|
||||
|
||||
const ContextMenuItem = React.forwardRef(({ className, inset, ...props }, ref) => (
|
||||
<ContextMenuPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props} />
|
||||
))
|
||||
ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName
|
||||
|
||||
const ContextMenuCheckboxItem = React.forwardRef(({ className, children, checked, ...props }, ref) => (
|
||||
<ContextMenuPrimitive.CheckboxItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<ContextMenuPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</ContextMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</ContextMenuPrimitive.CheckboxItem>
|
||||
))
|
||||
ContextMenuCheckboxItem.displayName =
|
||||
ContextMenuPrimitive.CheckboxItem.displayName
|
||||
|
||||
const ContextMenuRadioItem = React.forwardRef(({ className, children, ...props }, ref) => (
|
||||
<ContextMenuPrimitive.RadioItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<ContextMenuPrimitive.ItemIndicator>
|
||||
<Circle className="h-4 w-4 fill-current" />
|
||||
</ContextMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</ContextMenuPrimitive.RadioItem>
|
||||
))
|
||||
ContextMenuRadioItem.displayName = ContextMenuPrimitive.RadioItem.displayName
|
||||
|
||||
const ContextMenuLabel = React.forwardRef(({ className, inset, ...props }, ref) => (
|
||||
<ContextMenuPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-sm font-semibold text-foreground",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props} />
|
||||
))
|
||||
ContextMenuLabel.displayName = ContextMenuPrimitive.Label.displayName
|
||||
|
||||
const ContextMenuSeparator = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<ContextMenuPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-border", className)}
|
||||
{...props} />
|
||||
))
|
||||
ContextMenuSeparator.displayName = ContextMenuPrimitive.Separator.displayName
|
||||
|
||||
const ContextMenuShortcut = ({
|
||||
className,
|
||||
...props
|
||||
}) => {
|
||||
return (
|
||||
(<span
|
||||
className={cn("ml-auto text-xs tracking-widest text-muted-foreground", className)}
|
||||
{...props} />)
|
||||
);
|
||||
}
|
||||
ContextMenuShortcut.displayName = "ContextMenuShortcut"
|
||||
|
||||
export {
|
||||
ContextMenu,
|
||||
ContextMenuTrigger,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuCheckboxItem,
|
||||
ContextMenuRadioItem,
|
||||
ContextMenuLabel,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuShortcut,
|
||||
ContextMenuGroup,
|
||||
ContextMenuPortal,
|
||||
ContextMenuSub,
|
||||
ContextMenuSubContent,
|
||||
ContextMenuSubTrigger,
|
||||
ContextMenuRadioGroup,
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Drawer as DrawerPrimitive } from "vaul"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Drawer = ({
|
||||
shouldScaleBackground = true,
|
||||
...props
|
||||
}) => (
|
||||
<DrawerPrimitive.Root shouldScaleBackground={shouldScaleBackground} {...props} />
|
||||
)
|
||||
Drawer.displayName = "Drawer"
|
||||
|
||||
const DrawerTrigger = DrawerPrimitive.Trigger
|
||||
|
||||
const DrawerPortal = DrawerPrimitive.Portal
|
||||
|
||||
const DrawerClose = DrawerPrimitive.Close
|
||||
|
||||
const DrawerOverlay = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<DrawerPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn("fixed inset-0 z-50 bg-black/80", className)}
|
||||
{...props} />
|
||||
))
|
||||
DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName
|
||||
|
||||
const DrawerContent = React.forwardRef(({ className, children, ...props }, ref) => (
|
||||
<DrawerPortal>
|
||||
<DrawerOverlay />
|
||||
<DrawerPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed inset-x-0 bottom-0 z-50 mt-24 flex h-auto flex-col rounded-t-[10px] border bg-background",
|
||||
className
|
||||
)}
|
||||
{...props}>
|
||||
<div className="mx-auto mt-4 h-2 w-[100px] rounded-full bg-muted" />
|
||||
{children}
|
||||
</DrawerPrimitive.Content>
|
||||
</DrawerPortal>
|
||||
))
|
||||
DrawerContent.displayName = "DrawerContent"
|
||||
|
||||
const DrawerHeader = ({
|
||||
className,
|
||||
...props
|
||||
}) => (
|
||||
<div
|
||||
className={cn("grid gap-1.5 p-4 text-center sm:text-left", className)}
|
||||
{...props} />
|
||||
)
|
||||
DrawerHeader.displayName = "DrawerHeader"
|
||||
|
||||
const DrawerFooter = ({
|
||||
className,
|
||||
...props
|
||||
}) => (
|
||||
<div className={cn("mt-auto flex flex-col gap-2 p-4", className)} {...props} />
|
||||
)
|
||||
DrawerFooter.displayName = "DrawerFooter"
|
||||
|
||||
const DrawerTitle = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<DrawerPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn("text-lg font-semibold leading-none tracking-tight", className)}
|
||||
{...props} />
|
||||
))
|
||||
DrawerTitle.displayName = DrawerPrimitive.Title.displayName
|
||||
|
||||
const DrawerDescription = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<DrawerPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props} />
|
||||
))
|
||||
DrawerDescription.displayName = DrawerPrimitive.Description.displayName
|
||||
|
||||
export {
|
||||
Drawer,
|
||||
DrawerPortal,
|
||||
DrawerOverlay,
|
||||
DrawerTrigger,
|
||||
DrawerClose,
|
||||
DrawerContent,
|
||||
DrawerHeader,
|
||||
DrawerFooter,
|
||||
DrawerTitle,
|
||||
DrawerDescription,
|
||||
}
|
||||
@@ -1,134 +0,0 @@
|
||||
"use client";
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { Controller, FormProvider, useFormContext } from "react-hook-form";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Label } from "@/components/ui/label"
|
||||
|
||||
const Form = FormProvider
|
||||
|
||||
const FormFieldContext = React.createContext({})
|
||||
|
||||
const FormField = (
|
||||
{
|
||||
...props
|
||||
}
|
||||
) => {
|
||||
return (
|
||||
(<FormFieldContext.Provider value={{ name: props.name }}>
|
||||
<Controller {...props} />
|
||||
</FormFieldContext.Provider>)
|
||||
);
|
||||
}
|
||||
|
||||
const useFormField = () => {
|
||||
const fieldContext = React.useContext(FormFieldContext)
|
||||
const itemContext = React.useContext(FormItemContext)
|
||||
const { getFieldState, formState } = useFormContext()
|
||||
|
||||
const fieldState = getFieldState(fieldContext.name, formState)
|
||||
|
||||
if (!fieldContext) {
|
||||
throw new Error("useFormField should be used within <FormField>")
|
||||
}
|
||||
|
||||
const { id } = itemContext
|
||||
|
||||
return {
|
||||
id,
|
||||
name: fieldContext.name,
|
||||
formItemId: `${id}-form-item`,
|
||||
formDescriptionId: `${id}-form-item-description`,
|
||||
formMessageId: `${id}-form-item-message`,
|
||||
...fieldState,
|
||||
}
|
||||
}
|
||||
|
||||
const FormItemContext = React.createContext({})
|
||||
|
||||
const FormItem = React.forwardRef(({ className, ...props }, ref) => {
|
||||
const id = React.useId()
|
||||
|
||||
return (
|
||||
(<FormItemContext.Provider value={{ id }}>
|
||||
<div ref={ref} className={cn("space-y-2", className)} {...props} />
|
||||
</FormItemContext.Provider>)
|
||||
);
|
||||
})
|
||||
FormItem.displayName = "FormItem"
|
||||
|
||||
const FormLabel = React.forwardRef(({ className, ...props }, ref) => {
|
||||
const { error, formItemId } = useFormField()
|
||||
|
||||
return (
|
||||
(<Label
|
||||
ref={ref}
|
||||
className={cn(error && "text-destructive", className)}
|
||||
htmlFor={formItemId}
|
||||
{...props} />)
|
||||
);
|
||||
})
|
||||
FormLabel.displayName = "FormLabel"
|
||||
|
||||
const FormControl = React.forwardRef(({ ...props }, ref) => {
|
||||
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
|
||||
|
||||
return (
|
||||
(<Slot
|
||||
ref={ref}
|
||||
id={formItemId}
|
||||
aria-describedby={
|
||||
!error
|
||||
? `${formDescriptionId}`
|
||||
: `${formDescriptionId} ${formMessageId}`
|
||||
}
|
||||
aria-invalid={!!error}
|
||||
{...props} />)
|
||||
);
|
||||
})
|
||||
FormControl.displayName = "FormControl"
|
||||
|
||||
const FormDescription = React.forwardRef(({ className, ...props }, ref) => {
|
||||
const { formDescriptionId } = useFormField()
|
||||
|
||||
return (
|
||||
(<p
|
||||
ref={ref}
|
||||
id={formDescriptionId}
|
||||
className={cn("text-[0.8rem] text-muted-foreground", className)}
|
||||
{...props} />)
|
||||
);
|
||||
})
|
||||
FormDescription.displayName = "FormDescription"
|
||||
|
||||
const FormMessage = React.forwardRef(({ className, children, ...props }, ref) => {
|
||||
const { error, formMessageId } = useFormField()
|
||||
const body = error ? String(error?.message) : children
|
||||
|
||||
if (!body) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
(<p
|
||||
ref={ref}
|
||||
id={formMessageId}
|
||||
className={cn("text-[0.8rem] font-medium text-destructive", className)}
|
||||
{...props}>
|
||||
{body}
|
||||
</p>)
|
||||
);
|
||||
})
|
||||
FormMessage.displayName = "FormMessage"
|
||||
|
||||
export {
|
||||
useFormField,
|
||||
Form,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormMessage,
|
||||
FormField,
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
import * as React from "react"
|
||||
import { OTPInput, OTPInputContext } from "input-otp"
|
||||
import { Minus } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const InputOTP = React.forwardRef(({ className, containerClassName, ...props }, ref) => (
|
||||
<OTPInput
|
||||
ref={ref}
|
||||
containerClassName={cn("flex items-center gap-2 has-[:disabled]:opacity-50", containerClassName)}
|
||||
className={cn("disabled:cursor-not-allowed", className)}
|
||||
{...props} />
|
||||
))
|
||||
InputOTP.displayName = "InputOTP"
|
||||
|
||||
const InputOTPGroup = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("flex items-center", className)} {...props} />
|
||||
))
|
||||
InputOTPGroup.displayName = "InputOTPGroup"
|
||||
|
||||
const InputOTPSlot = React.forwardRef(({ index, className, ...props }, ref) => {
|
||||
const inputOTPContext = React.useContext(OTPInputContext)
|
||||
const { char, hasFakeCaret, isActive } = inputOTPContext.slots[index]
|
||||
|
||||
return (
|
||||
(<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex h-9 w-9 items-center justify-center border-y border-r border-input text-sm shadow-sm transition-all first:rounded-l-md first:border-l last:rounded-r-md",
|
||||
isActive && "z-10 ring-1 ring-ring",
|
||||
className
|
||||
)}
|
||||
{...props}>
|
||||
{char}
|
||||
{hasFakeCaret && (
|
||||
<div
|
||||
className="pointer-events-none absolute inset-0 flex items-center justify-center">
|
||||
<div className="h-4 w-px animate-caret-blink bg-foreground duration-1000" />
|
||||
</div>
|
||||
)}
|
||||
</div>)
|
||||
);
|
||||
})
|
||||
InputOTPSlot.displayName = "InputOTPSlot"
|
||||
|
||||
const InputOTPSeparator = React.forwardRef(({ ...props }, ref) => (
|
||||
<div ref={ref} role="separator" {...props}>
|
||||
<Minus />
|
||||
</div>
|
||||
))
|
||||
InputOTPSeparator.displayName = "InputOTPSeparator"
|
||||
|
||||
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator }
|
||||
@@ -1,200 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as MenubarPrimitive from "@radix-ui/react-menubar"
|
||||
import { Check, ChevronRight, Circle } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function MenubarMenu({
|
||||
...props
|
||||
}) {
|
||||
return <MenubarPrimitive.Menu {...props} />;
|
||||
}
|
||||
|
||||
function MenubarGroup({
|
||||
...props
|
||||
}) {
|
||||
return <MenubarPrimitive.Group {...props} />;
|
||||
}
|
||||
|
||||
function MenubarPortal({
|
||||
...props
|
||||
}) {
|
||||
return <MenubarPrimitive.Portal {...props} />;
|
||||
}
|
||||
|
||||
function MenubarRadioGroup({
|
||||
...props
|
||||
}) {
|
||||
return <MenubarPrimitive.RadioGroup {...props} />;
|
||||
}
|
||||
|
||||
function MenubarSub({
|
||||
...props
|
||||
}) {
|
||||
return <MenubarPrimitive.Sub data-slot="menubar-sub" {...props} />;
|
||||
}
|
||||
|
||||
const Menubar = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<MenubarPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-9 items-center space-x-1 rounded-md border bg-background p-1 shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...props} />
|
||||
))
|
||||
Menubar.displayName = MenubarPrimitive.Root.displayName
|
||||
|
||||
const MenubarTrigger = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<MenubarPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default select-none items-center rounded-sm px-3 py-1 text-sm font-medium outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
|
||||
className
|
||||
)}
|
||||
{...props} />
|
||||
))
|
||||
MenubarTrigger.displayName = MenubarPrimitive.Trigger.displayName
|
||||
|
||||
const MenubarSubTrigger = React.forwardRef(({ className, inset, children, ...props }, ref) => (
|
||||
<MenubarPrimitive.SubTrigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}>
|
||||
{children}
|
||||
<ChevronRight className="ml-auto h-4 w-4" />
|
||||
</MenubarPrimitive.SubTrigger>
|
||||
))
|
||||
MenubarSubTrigger.displayName = MenubarPrimitive.SubTrigger.displayName
|
||||
|
||||
const MenubarSubContent = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<MenubarPrimitive.SubContent
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className
|
||||
)}
|
||||
{...props} />
|
||||
))
|
||||
MenubarSubContent.displayName = MenubarPrimitive.SubContent.displayName
|
||||
|
||||
const MenubarContent = React.forwardRef((
|
||||
{ className, align = "start", alignOffset = -4, sideOffset = 8, ...props },
|
||||
ref
|
||||
) => (
|
||||
<MenubarPrimitive.Portal>
|
||||
<MenubarPrimitive.Content
|
||||
ref={ref}
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 min-w-[12rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className
|
||||
)}
|
||||
{...props} />
|
||||
</MenubarPrimitive.Portal>
|
||||
))
|
||||
MenubarContent.displayName = MenubarPrimitive.Content.displayName
|
||||
|
||||
const MenubarItem = React.forwardRef(({ className, inset, ...props }, ref) => (
|
||||
<MenubarPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props} />
|
||||
))
|
||||
MenubarItem.displayName = MenubarPrimitive.Item.displayName
|
||||
|
||||
const MenubarCheckboxItem = React.forwardRef(({ className, children, checked, ...props }, ref) => (
|
||||
<MenubarPrimitive.CheckboxItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<MenubarPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</MenubarPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</MenubarPrimitive.CheckboxItem>
|
||||
))
|
||||
MenubarCheckboxItem.displayName = MenubarPrimitive.CheckboxItem.displayName
|
||||
|
||||
const MenubarRadioItem = React.forwardRef(({ className, children, ...props }, ref) => (
|
||||
<MenubarPrimitive.RadioItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<MenubarPrimitive.ItemIndicator>
|
||||
<Circle className="h-4 w-4 fill-current" />
|
||||
</MenubarPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</MenubarPrimitive.RadioItem>
|
||||
))
|
||||
MenubarRadioItem.displayName = MenubarPrimitive.RadioItem.displayName
|
||||
|
||||
const MenubarLabel = React.forwardRef(({ className, inset, ...props }, ref) => (
|
||||
<MenubarPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn("px-2 py-1.5 text-sm font-semibold", inset && "pl-8", className)}
|
||||
{...props} />
|
||||
))
|
||||
MenubarLabel.displayName = MenubarPrimitive.Label.displayName
|
||||
|
||||
const MenubarSeparator = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<MenubarPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||
{...props} />
|
||||
))
|
||||
MenubarSeparator.displayName = MenubarPrimitive.Separator.displayName
|
||||
|
||||
const MenubarShortcut = ({
|
||||
className,
|
||||
...props
|
||||
}) => {
|
||||
return (
|
||||
(<span
|
||||
className={cn("ml-auto text-xs tracking-widest text-muted-foreground", className)}
|
||||
{...props} />)
|
||||
);
|
||||
}
|
||||
MenubarShortcut.displayname = "MenubarShortcut"
|
||||
|
||||
export {
|
||||
Menubar,
|
||||
MenubarMenu,
|
||||
MenubarTrigger,
|
||||
MenubarContent,
|
||||
MenubarItem,
|
||||
MenubarSeparator,
|
||||
MenubarLabel,
|
||||
MenubarCheckboxItem,
|
||||
MenubarRadioGroup,
|
||||
MenubarRadioItem,
|
||||
MenubarPortal,
|
||||
MenubarSubContent,
|
||||
MenubarSubTrigger,
|
||||
MenubarGroup,
|
||||
MenubarSub,
|
||||
MenubarShortcut,
|
||||
}
|
||||
@@ -1,104 +0,0 @@
|
||||
import * as React from "react"
|
||||
import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu"
|
||||
import { cva } from "class-variance-authority"
|
||||
import { ChevronDown } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const NavigationMenu = React.forwardRef(({ className, children, ...props }, ref) => (
|
||||
<NavigationMenuPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative z-10 flex max-w-max flex-1 items-center justify-center",
|
||||
className
|
||||
)}
|
||||
{...props}>
|
||||
{children}
|
||||
<NavigationMenuViewport />
|
||||
</NavigationMenuPrimitive.Root>
|
||||
))
|
||||
NavigationMenu.displayName = NavigationMenuPrimitive.Root.displayName
|
||||
|
||||
const NavigationMenuList = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<NavigationMenuPrimitive.List
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"group flex flex-1 list-none items-center justify-center space-x-1",
|
||||
className
|
||||
)}
|
||||
{...props} />
|
||||
))
|
||||
NavigationMenuList.displayName = NavigationMenuPrimitive.List.displayName
|
||||
|
||||
const NavigationMenuItem = NavigationMenuPrimitive.Item
|
||||
|
||||
const navigationMenuTriggerStyle = cva(
|
||||
"group inline-flex h-9 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus:outline-none disabled:pointer-events-none disabled:opacity-50 data-[active]:bg-accent/50 data-[state=open]:bg-accent/50"
|
||||
)
|
||||
|
||||
const NavigationMenuTrigger = React.forwardRef(({ className, children, ...props }, ref) => (
|
||||
<NavigationMenuPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(navigationMenuTriggerStyle(), "group", className)}
|
||||
{...props}>
|
||||
{children}{" "}
|
||||
<ChevronDown
|
||||
className="relative top-[1px] ml-1 h-3 w-3 transition duration-300 group-data-[state=open]:rotate-180"
|
||||
aria-hidden="true" />
|
||||
</NavigationMenuPrimitive.Trigger>
|
||||
))
|
||||
NavigationMenuTrigger.displayName = NavigationMenuPrimitive.Trigger.displayName
|
||||
|
||||
const NavigationMenuContent = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<NavigationMenuPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"left-0 top-0 w-full data-[motion^=from-]:animate-in data-[motion^=to-]:animate-out data-[motion^=from-]:fade-in data-[motion^=to-]:fade-out data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 md:absolute md:w-auto ",
|
||||
className
|
||||
)}
|
||||
{...props} />
|
||||
))
|
||||
NavigationMenuContent.displayName = NavigationMenuPrimitive.Content.displayName
|
||||
|
||||
const NavigationMenuLink = NavigationMenuPrimitive.Link
|
||||
|
||||
const NavigationMenuViewport = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<div className={cn("absolute left-0 top-full flex justify-center")}>
|
||||
<NavigationMenuPrimitive.Viewport
|
||||
className={cn(
|
||||
"origin-top-center relative mt-1.5 h-[var(--radix-navigation-menu-viewport-height)] w-full overflow-hidden rounded-md border bg-popover text-popover-foreground shadow data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 md:w-[var(--radix-navigation-menu-viewport-width)]",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props} />
|
||||
</div>
|
||||
))
|
||||
NavigationMenuViewport.displayName =
|
||||
NavigationMenuPrimitive.Viewport.displayName
|
||||
|
||||
const NavigationMenuIndicator = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<NavigationMenuPrimitive.Indicator
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"top-full z-[1] flex h-1.5 items-end justify-center overflow-hidden data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in",
|
||||
className
|
||||
)}
|
||||
{...props}>
|
||||
<div
|
||||
className="relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm bg-border shadow-md" />
|
||||
</NavigationMenuPrimitive.Indicator>
|
||||
))
|
||||
NavigationMenuIndicator.displayName =
|
||||
NavigationMenuPrimitive.Indicator.displayName
|
||||
|
||||
export {
|
||||
navigationMenuTriggerStyle,
|
||||
NavigationMenu,
|
||||
NavigationMenuList,
|
||||
NavigationMenuItem,
|
||||
NavigationMenuContent,
|
||||
NavigationMenuTrigger,
|
||||
NavigationMenuLink,
|
||||
NavigationMenuIndicator,
|
||||
NavigationMenuViewport,
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
import * as React from "react"
|
||||
import { ChevronLeft, ChevronRight, MoreHorizontal } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
|
||||
const Pagination = ({
|
||||
className,
|
||||
...props
|
||||
}) => (
|
||||
<nav
|
||||
role="navigation"
|
||||
aria-label="pagination"
|
||||
className={cn("mx-auto flex w-full justify-center", className)}
|
||||
{...props} />
|
||||
)
|
||||
Pagination.displayName = "Pagination"
|
||||
|
||||
const PaginationContent = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<ul
|
||||
ref={ref}
|
||||
className={cn("flex flex-row items-center gap-1", className)}
|
||||
{...props} />
|
||||
))
|
||||
PaginationContent.displayName = "PaginationContent"
|
||||
|
||||
const PaginationItem = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<li ref={ref} className={cn("", className)} {...props} />
|
||||
))
|
||||
PaginationItem.displayName = "PaginationItem"
|
||||
|
||||
const PaginationLink = ({
|
||||
className,
|
||||
isActive,
|
||||
size = "icon",
|
||||
...props
|
||||
}) => (
|
||||
<a
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
className={cn(buttonVariants({
|
||||
variant: isActive ? "outline" : "ghost",
|
||||
size,
|
||||
}), className)}
|
||||
{...props} />
|
||||
)
|
||||
PaginationLink.displayName = "PaginationLink"
|
||||
|
||||
const PaginationPrevious = ({
|
||||
className,
|
||||
...props
|
||||
}) => (
|
||||
<PaginationLink
|
||||
aria-label="Go to previous page"
|
||||
size="default"
|
||||
className={cn("gap-1 pl-2.5", className)}
|
||||
{...props}>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
<span>Previous</span>
|
||||
</PaginationLink>
|
||||
)
|
||||
PaginationPrevious.displayName = "PaginationPrevious"
|
||||
|
||||
const PaginationNext = ({
|
||||
className,
|
||||
...props
|
||||
}) => (
|
||||
<PaginationLink
|
||||
aria-label="Go to next page"
|
||||
size="default"
|
||||
className={cn("gap-1 pr-2.5", className)}
|
||||
{...props}>
|
||||
<span>Next</span>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</PaginationLink>
|
||||
)
|
||||
PaginationNext.displayName = "PaginationNext"
|
||||
|
||||
const PaginationEllipsis = ({
|
||||
className,
|
||||
...props
|
||||
}) => (
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn("flex h-9 w-9 items-center justify-center", className)}
|
||||
{...props}>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
<span className="sr-only">More pages</span>
|
||||
</span>
|
||||
)
|
||||
PaginationEllipsis.displayName = "PaginationEllipsis"
|
||||
|
||||
export {
|
||||
Pagination,
|
||||
PaginationContent,
|
||||
PaginationLink,
|
||||
PaginationItem,
|
||||
PaginationPrevious,
|
||||
PaginationNext,
|
||||
PaginationEllipsis,
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as ProgressPrimitive from "@radix-ui/react-progress"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Progress = React.forwardRef(({ className, value, ...props }, ref) => (
|
||||
<ProgressPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative h-2 w-full overflow-hidden rounded-full bg-primary/20",
|
||||
className
|
||||
)}
|
||||
{...props}>
|
||||
<ProgressPrimitive.Indicator
|
||||
className="h-full w-full flex-1 bg-primary transition-all"
|
||||
style={{ transform: `translateX(-${100 - (value || 0)}%)` }} />
|
||||
</ProgressPrimitive.Root>
|
||||
))
|
||||
Progress.displayName = ProgressPrimitive.Root.displayName
|
||||
|
||||
export { Progress }
|
||||
@@ -1,42 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { GripVertical } from "lucide-react"
|
||||
import * as ResizablePrimitive from "react-resizable-panels"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const ResizablePanelGroup = ({
|
||||
className,
|
||||
...props
|
||||
}) => (
|
||||
<ResizablePrimitive.PanelGroup
|
||||
className={cn(
|
||||
"flex h-full w-full data-[panel-group-direction=vertical]:flex-col",
|
||||
className
|
||||
)}
|
||||
{...props} />
|
||||
)
|
||||
|
||||
const ResizablePanel = ResizablePrimitive.Panel
|
||||
|
||||
const ResizableHandle = ({
|
||||
withHandle,
|
||||
className,
|
||||
...props
|
||||
}) => (
|
||||
<ResizablePrimitive.PanelResizeHandle
|
||||
className={cn(
|
||||
"relative flex w-px items-center justify-center bg-border after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:-translate-y-1/2 data-[panel-group-direction=vertical]:after:translate-x-0 [&[data-panel-group-direction=vertical]>div]:rotate-90",
|
||||
className
|
||||
)}
|
||||
{...props}>
|
||||
{withHandle && (
|
||||
<div
|
||||
className="z-10 flex h-4 w-3 items-center justify-center rounded-sm border bg-border">
|
||||
<GripVertical className="h-2.5 w-2.5" />
|
||||
</div>
|
||||
)}
|
||||
</ResizablePrimitive.PanelResizeHandle>
|
||||
)
|
||||
|
||||
export { ResizablePanelGroup, ResizablePanel, ResizableHandle }
|
||||
@@ -1,38 +0,0 @@
|
||||
import * as React from "react"
|
||||
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const ScrollArea = React.forwardRef(({ className, children, ...props }, ref) => (
|
||||
<ScrollAreaPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn("relative overflow-hidden", className)}
|
||||
{...props}>
|
||||
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollBar />
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
))
|
||||
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
|
||||
|
||||
const ScrollBar = React.forwardRef(({ className, orientation = "vertical", ...props }, ref) => (
|
||||
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||
ref={ref}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"flex touch-none select-none transition-colors",
|
||||
orientation === "vertical" &&
|
||||
"h-full w-2.5 border-l border-l-transparent p-[1px]",
|
||||
orientation === "horizontal" &&
|
||||
"h-2.5 flex-col border-t border-t-transparent p-[1px]",
|
||||
className
|
||||
)}
|
||||
{...props}>
|
||||
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
|
||||
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
))
|
||||
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
|
||||
|
||||
export { ScrollArea, ScrollBar }
|
||||
@@ -1,23 +0,0 @@
|
||||
import * as React from "react"
|
||||
import * as SeparatorPrimitive from "@radix-ui/react-separator"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Separator = React.forwardRef((
|
||||
{ className, orientation = "horizontal", decorative = true, ...props },
|
||||
ref
|
||||
) => (
|
||||
<SeparatorPrimitive.Root
|
||||
ref={ref}
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"shrink-0 bg-border",
|
||||
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
|
||||
className
|
||||
)}
|
||||
{...props} />
|
||||
))
|
||||
Separator.displayName = SeparatorPrimitive.Root.displayName
|
||||
|
||||
export { Separator }
|
||||
@@ -1,29 +0,0 @@
|
||||
"use client";
|
||||
import { useTheme } from "next-themes"
|
||||
import { Toaster as Sonner } from "sonner"
|
||||
|
||||
const Toaster = ({
|
||||
...props
|
||||
}) => {
|
||||
const { theme = "system" } = useTheme()
|
||||
|
||||
return (
|
||||
(<Sonner
|
||||
theme={theme}
|
||||
className="toaster group"
|
||||
toastOptions={{
|
||||
classNames: {
|
||||
toast:
|
||||
"group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
|
||||
description: "group-[.toast]:text-muted-foreground",
|
||||
actionButton:
|
||||
"group-[.toast]:bg-primary group-[.toast]:text-primary-foreground",
|
||||
cancelButton:
|
||||
"group-[.toast]:bg-muted group-[.toast]:text-muted-foreground",
|
||||
},
|
||||
}}
|
||||
{...props} />)
|
||||
);
|
||||
}
|
||||
|
||||
export { Toaster }
|
||||
@@ -1,86 +0,0 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Table = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<div className="relative w-full overflow-auto">
|
||||
<table
|
||||
ref={ref}
|
||||
className={cn("w-full caption-bottom text-sm", className)}
|
||||
{...props} />
|
||||
</div>
|
||||
))
|
||||
Table.displayName = "Table"
|
||||
|
||||
const TableHeader = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
|
||||
))
|
||||
TableHeader.displayName = "TableHeader"
|
||||
|
||||
const TableBody = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<tbody
|
||||
ref={ref}
|
||||
className={cn("[&_tr:last-child]:border-0", className)}
|
||||
{...props} />
|
||||
))
|
||||
TableBody.displayName = "TableBody"
|
||||
|
||||
const TableFooter = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<tfoot
|
||||
ref={ref}
|
||||
className={cn("border-t bg-muted/50 font-medium [&>tr]:last:border-b-0", className)}
|
||||
{...props} />
|
||||
))
|
||||
TableFooter.displayName = "TableFooter"
|
||||
|
||||
const TableRow = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<tr
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
|
||||
className
|
||||
)}
|
||||
{...props} />
|
||||
))
|
||||
TableRow.displayName = "TableRow"
|
||||
|
||||
const TableHead = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<th
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"h-10 px-2 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||
className
|
||||
)}
|
||||
{...props} />
|
||||
))
|
||||
TableHead.displayName = "TableHead"
|
||||
|
||||
const TableCell = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<td
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"p-2 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||
className
|
||||
)}
|
||||
{...props} />
|
||||
))
|
||||
TableCell.displayName = "TableCell"
|
||||
|
||||
const TableCaption = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<caption
|
||||
ref={ref}
|
||||
className={cn("mt-4 text-sm text-muted-foreground", className)}
|
||||
{...props} />
|
||||
))
|
||||
TableCaption.displayName = "TableCaption"
|
||||
|
||||
export {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableFooter,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TableCell,
|
||||
TableCaption,
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
import * as React from "react"
|
||||
import * as TabsPrimitive from "@radix-ui/react-tabs"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Tabs = TabsPrimitive.Root
|
||||
|
||||
/** @type {React.ForwardRefExoticComponent<any>} */
|
||||
const TabsList = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.List
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props} />
|
||||
))
|
||||
TabsList.displayName = TabsPrimitive.List.displayName
|
||||
|
||||
/** @type {React.ForwardRefExoticComponent<any>} */
|
||||
const TabsTrigger = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow",
|
||||
className
|
||||
)}
|
||||
{...props} />
|
||||
))
|
||||
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
|
||||
|
||||
/** @type {React.ForwardRefExoticComponent<any>} */
|
||||
const TabsContent = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||
className
|
||||
)}
|
||||
{...props} />
|
||||
))
|
||||
TabsContent.displayName = TabsPrimitive.Content.displayName
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent }
|
||||
@@ -1,44 +0,0 @@
|
||||
"use client";
|
||||
import * as React from "react"
|
||||
import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { toggleVariants } from "@/components/ui/toggle"
|
||||
|
||||
const ToggleGroupContext = React.createContext({
|
||||
size: "default",
|
||||
variant: "default",
|
||||
})
|
||||
|
||||
const ToggleGroup = React.forwardRef(({ className, variant, size, children, ...props }, ref) => (
|
||||
<ToggleGroupPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn("flex items-center justify-center gap-1", className)}
|
||||
{...props}>
|
||||
<ToggleGroupContext.Provider value={{ variant, size }}>
|
||||
{children}
|
||||
</ToggleGroupContext.Provider>
|
||||
</ToggleGroupPrimitive.Root>
|
||||
))
|
||||
|
||||
ToggleGroup.displayName = ToggleGroupPrimitive.Root.displayName
|
||||
|
||||
const ToggleGroupItem = React.forwardRef(({ className, children, variant, size, ...props }, ref) => {
|
||||
const context = React.useContext(ToggleGroupContext)
|
||||
|
||||
return (
|
||||
(<ToggleGroupPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(toggleVariants({
|
||||
variant: context.variant || variant,
|
||||
size: context.size || size,
|
||||
}), className)}
|
||||
{...props}>
|
||||
{children}
|
||||
</ToggleGroupPrimitive.Item>)
|
||||
);
|
||||
})
|
||||
|
||||
ToggleGroupItem.displayName = ToggleGroupPrimitive.Item.displayName
|
||||
|
||||
export { ToggleGroup, ToggleGroupItem }
|
||||
@@ -1,38 +0,0 @@
|
||||
import * as React from "react"
|
||||
import * as TogglePrimitive from "@radix-ui/react-toggle"
|
||||
import { cva } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const toggleVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium transition-colors hover:bg-muted hover:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-transparent",
|
||||
outline:
|
||||
"border border-input bg-transparent shadow-sm hover:bg-accent hover:text-accent-foreground",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-2 min-w-9",
|
||||
sm: "h-8 px-1.5 min-w-8",
|
||||
lg: "h-10 px-2.5 min-w-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
const Toggle = React.forwardRef(({ className, variant, size, ...props }, ref) => (
|
||||
<TogglePrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(toggleVariants({ variant, size, className }))}
|
||||
{...props} />
|
||||
))
|
||||
|
||||
Toggle.displayName = TogglePrimitive.Root.displayName
|
||||
|
||||
export { Toggle, toggleVariants }
|
||||
Reference in New Issue
Block a user