Merge pull request #3 from virgilvox/v0/hackbuildvideo-3367-fc7113bd
Enhance mobile responsiveness and stabilize authentication flows
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { createClient } from '@/lib/supabase/client'
|
||||
import { Hash, Users2, Send } from 'lucide-react'
|
||||
import { Hash, Users2, Send, ChevronLeft } from 'lucide-react'
|
||||
|
||||
interface Props {
|
||||
member: any
|
||||
@@ -15,6 +15,7 @@ export default function CommsClient({ member, space, channels }: Props) {
|
||||
const [messages, setMessages] = useState<any[]>([])
|
||||
const [newMessage, setNewMessage] = useState('')
|
||||
const [sending, setSending] = useState(false)
|
||||
const [showChannelList, setShowChannelList] = useState(true)
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -140,26 +141,32 @@ export default function CommsClient({ member, space, channels }: Props) {
|
||||
const projectChannels = channels.filter(c => c.channel_type === 'project')
|
||||
|
||||
return (
|
||||
<div className="h-screen bg-background flex flex-col">
|
||||
<div className="bg-sidebar px-6 py-3 flex items-center justify-between border-b border-sidebar-border">
|
||||
<div className="h-[calc(100vh-52px)] md:h-screen bg-background flex flex-col">
|
||||
<div className="bg-sidebar px-4 md:px-6 py-3 flex items-center justify-between border-b border-sidebar-border">
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-white font-sans text-lg font-semibold">Comms</h1>
|
||||
{/* Mobile back to channels */}
|
||||
{!showChannelList && selectedChannel && (
|
||||
<button
|
||||
onClick={() => setShowChannelList(true)}
|
||||
className="md:hidden text-sidebar-foreground/70 hover:text-sidebar-foreground mr-1"
|
||||
>
|
||||
<ChevronLeft className="w-5 h-5" />
|
||||
</button>
|
||||
)}
|
||||
<h1 className="text-white font-sans text-lg font-semibold">
|
||||
{!showChannelList && selectedChannel ? `#${selectedChannel.name}` : 'Comms'}
|
||||
</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button className="text-sidebar-foreground/70 hover:text-sidebar-foreground text-sm">
|
||||
<Users2 className="w-4 h-4" />
|
||||
</button>
|
||||
<button className="text-sidebar-foreground/70 hover:text-sidebar-foreground text-sm">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
{/* Channels sidebar */}
|
||||
<aside className="w-[220px] bg-card border-r border-border overflow-y-auto">
|
||||
{/* Channels sidebar — hidden on mobile when a channel is selected */}
|
||||
<aside className={`${showChannelList ? 'flex' : 'hidden'} md:flex w-full md:w-[220px] flex-col bg-card border-r border-border overflow-y-auto`}>
|
||||
<div className="p-3">
|
||||
{generalChannels.length > 0 && (
|
||||
<div className="mb-4">
|
||||
@@ -167,7 +174,7 @@ export default function CommsClient({ member, space, channels }: Props) {
|
||||
{generalChannels.map(channel => (
|
||||
<button
|
||||
key={channel.id}
|
||||
onClick={() => setSelectedChannel(channel)}
|
||||
onClick={() => { setSelectedChannel(channel); setShowChannelList(false) }}
|
||||
className={`w-full flex items-center gap-2 px-2 py-1.5 rounded text-sm font-sans transition ${
|
||||
selectedChannel?.id === channel.id
|
||||
? 'bg-primary/10 text-primary'
|
||||
@@ -192,7 +199,7 @@ export default function CommsClient({ member, space, channels }: Props) {
|
||||
{areaChannels.map(channel => (
|
||||
<button
|
||||
key={channel.id}
|
||||
onClick={() => setSelectedChannel(channel)}
|
||||
onClick={() => { setSelectedChannel(channel); setShowChannelList(false) }}
|
||||
className={`w-full flex items-center gap-2 px-2 py-1.5 rounded text-sm font-sans transition ${
|
||||
selectedChannel?.id === channel.id
|
||||
? 'bg-primary/10 text-primary'
|
||||
@@ -212,7 +219,7 @@ export default function CommsClient({ member, space, channels }: Props) {
|
||||
{projectChannels.map(channel => (
|
||||
<button
|
||||
key={channel.id}
|
||||
onClick={() => setSelectedChannel(channel)}
|
||||
onClick={() => { setSelectedChannel(channel); setShowChannelList(false) }}
|
||||
className={`w-full flex items-center gap-2 px-2 py-1.5 rounded text-sm font-sans transition ${
|
||||
selectedChannel?.id === channel.id
|
||||
? 'bg-primary/10 text-primary'
|
||||
@@ -232,7 +239,7 @@ export default function CommsClient({ member, space, channels }: Props) {
|
||||
|
||||
{/* Main chat area */}
|
||||
{selectedChannel ? (
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
<div className={`${showChannelList ? 'hidden md:flex' : 'flex'} flex-1 flex-col overflow-hidden`}>
|
||||
{/* Channel header */}
|
||||
<div className="bg-card border-b border-border px-4 py-3 flex items-center gap-2">
|
||||
<Hash className="w-4 h-4 text-muted-foreground" />
|
||||
|
||||
@@ -7,7 +7,7 @@ export default async function CommsPage() {
|
||||
if (!user) return null
|
||||
|
||||
const { data: member } = await supabase
|
||||
.from('space_members').select('*, spaces(id, name, slug)').eq('user_id', user.id).eq('status', 'current').single()
|
||||
.from('space_members').select('*, spaces(id, name, slug)').eq('user_id', user.id).in('status', ['current', 'unverified', 'late']).single()
|
||||
if (!member) return null
|
||||
|
||||
const { data: channels } = await supabase
|
||||
|
||||
@@ -114,7 +114,7 @@ export function ContactsClient({ contacts: initialContacts }: { contacts: Contac
|
||||
|
||||
const ContactForm = ({ onSubmit, submitLabel }: { onSubmit: (e: React.FormEvent) => void, submitLabel: string }) => (
|
||||
<form onSubmit={onSubmit} className="p-6 space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Name *</label>
|
||||
<input type="text" required value={form.name} onChange={e => setForm(f => ({ ...f, name: e.target.value }))}
|
||||
@@ -128,7 +128,7 @@ export function ContactsClient({ contacts: initialContacts }: { contacts: Contac
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Email</label>
|
||||
<input type="email" value={form.email} onChange={e => setForm(f => ({ ...f, email: e.target.value }))}
|
||||
@@ -145,7 +145,7 @@ export function ContactsClient({ contacts: initialContacts }: { contacts: Contac
|
||||
<textarea value={form.details} onChange={e => setForm(f => ({ ...f, details: e.target.value }))}
|
||||
rows={2} className="w-full bg-background border border-border rounded px-3 py-2 font-sans text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:border-primary transition resize-none" />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Group Label</label>
|
||||
<input type="text" value={form.group_label} onChange={e => setForm(f => ({ ...f, group_label: e.target.value }))}
|
||||
@@ -173,7 +173,7 @@ export function ContactsClient({ contacts: initialContacts }: { contacts: Contac
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<div className="bg-sidebar px-6 py-3 flex items-center justify-between">
|
||||
<div className="bg-sidebar px-4 md:px-6 py-3 flex items-center justify-between">
|
||||
<h1 className="text-white font-sans text-lg font-semibold">Contacts</h1>
|
||||
<button
|
||||
onClick={() => { resetForm(); setShowCreate(true) }}
|
||||
@@ -183,7 +183,7 @@ export function ContactsClient({ contacts: initialContacts }: { contacts: Contac
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-6 space-y-4">
|
||||
<div className="p-4 md:p-6 space-y-4">
|
||||
<div className="relative">
|
||||
<svg className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
|
||||
@@ -7,7 +7,7 @@ export default async function ContactsPage() {
|
||||
if (!user) return null
|
||||
|
||||
const { data: member } = await supabase
|
||||
.from('space_members').select('space_id').eq('user_id', user.id).eq('status', 'current').single()
|
||||
.from('space_members').select('space_id').eq('user_id', user.id).in('status', ['current', 'unverified', 'late']).single()
|
||||
if (!member) return null
|
||||
|
||||
const { data: contacts } = await supabase
|
||||
|
||||
@@ -61,18 +61,19 @@ export default async function DashboardPage() {
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<div className="bg-sidebar px-6 py-3 flex items-center justify-between">
|
||||
<div className="bg-sidebar px-4 md:px-6 py-3 flex items-center justify-between">
|
||||
<h1 className="text-white font-sans text-lg font-semibold">Dashboard</h1>
|
||||
<Link href="/tasks" className="flex items-center gap-1.5 bg-primary text-white text-xs font-sans px-3 py-1.5 rounded hover:bg-primary/90 transition">
|
||||
<IcoPlus className="w-3.5 h-3.5" />
|
||||
Quick Task
|
||||
<span className="hidden sm:inline">Quick Task</span>
|
||||
<span className="sm:hidden">Task</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div className="p-4 md:p-6 space-y-6">
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 md:gap-4">
|
||||
{stats.map(({ label, value, Ico, sub, warn }) => (
|
||||
<div key={label} className="bg-card rounded border border-border p-5">
|
||||
<div key={label} className="bg-card rounded border border-border p-4 md:p-5">
|
||||
<p className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase mb-2 flex items-center gap-1.5">
|
||||
<Ico className="w-3 h-3" /> {label}
|
||||
</p>
|
||||
@@ -82,7 +83,7 @@ export default async function DashboardPage() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid lg:grid-cols-[1fr_300px] gap-6">
|
||||
<div className="grid lg:grid-cols-[1fr_280px] gap-6">
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
|
||||
@@ -7,7 +7,7 @@ export default async function ImportPage() {
|
||||
if (!user) return null
|
||||
|
||||
const { data: member } = await supabase
|
||||
.from('space_members').select('space_id, role').eq('user_id', user.id).eq('status', 'current').single()
|
||||
.from('space_members').select('space_id, role').eq('user_id', user.id).in('status', ['current', 'unverified', 'late']).single()
|
||||
|
||||
const isAdmin = member?.role === 'admin' || member?.role === 'board' || member?.role === 'treasurer'
|
||||
|
||||
|
||||
@@ -45,7 +45,7 @@ export default async function AppLayout({ children }: { children: React.ReactNod
|
||||
taskBadge={taskCount ?? 0}
|
||||
paymentBadge={paymentCount ?? 0}
|
||||
/>
|
||||
<main className="flex-1 overflow-y-auto">
|
||||
<main className="flex-1 overflow-y-auto pt-[52px] md:pt-0">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -129,7 +129,7 @@ export function MembersClient({ members: initialMembers, currentRole }: Props) {
|
||||
|
||||
const MemberFormFields = () => (
|
||||
<>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Full Name *</label>
|
||||
<input type="text" required value={form.display_name} onChange={e => setForm(f => ({ ...f, display_name: e.target.value }))}
|
||||
@@ -141,7 +141,7 @@ export function MembersClient({ members: initialMembers, currentRole }: Props) {
|
||||
className="w-full bg-background border border-border rounded px-3 py-2 font-sans text-sm text-foreground focus:outline-none focus:border-primary transition" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Phone</label>
|
||||
<input type="tel" value={form.phone} onChange={e => setForm(f => ({ ...f, phone: e.target.value }))}
|
||||
@@ -153,7 +153,7 @@ export function MembersClient({ members: initialMembers, currentRole }: Props) {
|
||||
className="w-full bg-background border border-border rounded px-3 py-2 font-mono text-sm text-foreground focus:outline-none focus:border-primary transition" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Tier</label>
|
||||
<select value={form.tier} onChange={e => setForm(f => ({ ...f, tier: e.target.value }))}
|
||||
@@ -175,7 +175,7 @@ export function MembersClient({ members: initialMembers, currentRole }: Props) {
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Joined At</label>
|
||||
<input type="date" value={form.joined_at?.slice(0, 10)} onChange={e => setForm(f => ({ ...f, joined_at: e.target.value }))}
|
||||
@@ -194,7 +194,7 @@ export function MembersClient({ members: initialMembers, currentRole }: Props) {
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<div className="bg-sidebar px-6 py-3 flex items-center justify-between">
|
||||
<div className="bg-sidebar px-4 md:px-6 py-3 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-white font-sans text-lg font-semibold">Members</h1>
|
||||
<span className="font-mono text-xs text-white/50">{total} total</span>
|
||||
@@ -209,7 +209,7 @@ export function MembersClient({ members: initialMembers, currentRole }: Props) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="bg-card border-b border-border px-6 flex gap-6">
|
||||
<div className="bg-card border-b border-border px-4 md:px-6 flex gap-4 md:gap-6 overflow-x-auto">
|
||||
{[
|
||||
{ key: 'all', label: `All ${total}` },
|
||||
{ key: 'payment_issues', label: `Payment Issues ${paymentIssues}` },
|
||||
@@ -228,7 +228,7 @@ export function MembersClient({ members: initialMembers, currentRole }: Props) {
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="p-6">
|
||||
<div className="p-4 md:p-6">
|
||||
<div className="flex gap-3 mb-4">
|
||||
<div className="flex-1 relative">
|
||||
<svg className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -236,7 +236,7 @@ export function MembersClient({ members: initialMembers, currentRole }: Props) {
|
||||
</svg>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search by name, email, handle..."
|
||||
placeholder="Search..."
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
className="w-full bg-card border border-border rounded pl-9 pr-4 py-2 font-sans text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:border-primary transition"
|
||||
@@ -245,7 +245,7 @@ export function MembersClient({ members: initialMembers, currentRole }: Props) {
|
||||
<select
|
||||
value={tierFilter}
|
||||
onChange={e => setTierFilter(e.target.value)}
|
||||
className="bg-card border border-border text-foreground text-sm font-sans rounded px-3 py-2 focus:outline-none focus:border-primary"
|
||||
className="bg-card border border-border text-foreground text-sm font-sans rounded px-2 md:px-3 py-2 focus:outline-none focus:border-primary"
|
||||
>
|
||||
<option value="">All Tiers</option>
|
||||
<option value="plus">Plus</option>
|
||||
@@ -255,7 +255,8 @@ export function MembersClient({ members: initialMembers, currentRole }: Props) {
|
||||
</div>
|
||||
|
||||
<div className="bg-card rounded border border-border overflow-hidden">
|
||||
<table className="w-full">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[600px]">
|
||||
<thead>
|
||||
<tr className="border-b border-border">
|
||||
<th className="px-4 py-3 text-left font-mono text-[10px] tracking-widest text-muted-foreground">MEMBER</th>
|
||||
@@ -354,6 +355,7 @@ export function MembersClient({ members: initialMembers, currentRole }: Props) {
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ export default async function MembersPage() {
|
||||
.from('space_members')
|
||||
.select('space_id, role')
|
||||
.eq('user_id', user.id)
|
||||
.eq('status', 'current')
|
||||
.in('status', ['current', 'unverified', 'late'])
|
||||
.single()
|
||||
|
||||
if (!self) return null
|
||||
|
||||
@@ -11,7 +11,7 @@ export default async function OpsEntryPage({ params }: { params: Promise<{ id: s
|
||||
if (!user) return null
|
||||
|
||||
const { data: member } = await supabase
|
||||
.from('space_members').select('space_id, role, display_name').eq('user_id', user.id).eq('status', 'current').single()
|
||||
.from('space_members').select('space_id, role, display_name').eq('user_id', user.id).in('status', ['current', 'unverified', 'late']).single()
|
||||
if (!member) return null
|
||||
|
||||
const { data: entry } = await supabase
|
||||
|
||||
@@ -7,7 +7,7 @@ export default async function NewOpsEntryPage() {
|
||||
if (!user) return null
|
||||
|
||||
const { data: member } = await supabase
|
||||
.from('space_members').select('space_id, role, display_name').eq('user_id', user.id).eq('status', 'current').single()
|
||||
.from('space_members').select('space_id, role, display_name').eq('user_id', user.id).in('status', ['current', 'unverified', 'late']).single()
|
||||
if (!member) return null
|
||||
|
||||
return <KbEntryEditor member={member} />
|
||||
|
||||
@@ -137,7 +137,7 @@ function KbModal({
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="font-sans text-xs font-medium text-muted-foreground block mb-1.5">Area</label>
|
||||
<input
|
||||
@@ -531,7 +531,7 @@ export function OpsClient({ member, spaceId, kbEntries: initial, areaLeads: init
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
{/* Header */}
|
||||
<div className="bg-sidebar px-6 py-3 flex items-center justify-between">
|
||||
<div className="bg-sidebar px-4 md:px-6 py-3 flex items-center justify-between">
|
||||
<h1 className="text-white font-sans text-lg font-semibold">Ops & Facilities</h1>
|
||||
<div className="flex items-center gap-2">
|
||||
{(activeTab === 'kb' || activeTab === 'processes') && (
|
||||
@@ -562,7 +562,7 @@ export function OpsClient({ member, spaceId, kbEntries: initial, areaLeads: init
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="bg-card border-b border-border px-6 flex gap-6">
|
||||
<div className="bg-card border-b border-border px-4 md:px-6 flex gap-4 md:gap-6 overflow-x-auto">
|
||||
{TABS.map(tab => (
|
||||
<button
|
||||
key={tab.id}
|
||||
@@ -577,7 +577,7 @@ export function OpsClient({ member, spaceId, kbEntries: initial, areaLeads: init
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="p-6 max-w-5xl">
|
||||
<div className="p-4 md:p-6 max-w-5xl">
|
||||
{/* Search */}
|
||||
{(activeTab === 'kb' || activeTab === 'processes' || activeTab === 'secrets') && (
|
||||
<div className="relative mb-6">
|
||||
|
||||
@@ -7,7 +7,7 @@ export default async function OpsPage() {
|
||||
if (!user) return null
|
||||
|
||||
const { data: member } = await supabase
|
||||
.from('space_members').select('space_id, role, display_name, user_id').eq('user_id', user.id).eq('status', 'current').single()
|
||||
.from('space_members').select('space_id, role, display_name, user_id').eq('user_id', user.id).in('status', ['current', 'unverified', 'late']).single()
|
||||
if (!member) return null
|
||||
|
||||
const canSeeSecrets = member.role === 'admin' || member.role === 'board'
|
||||
|
||||
@@ -7,7 +7,7 @@ export default async function PaymentsPage() {
|
||||
if (!user) return null
|
||||
|
||||
const { data: member } = await supabase
|
||||
.from('space_members').select('space_id, role').eq('user_id', user.id).eq('status', 'current').single()
|
||||
.from('space_members').select('space_id, role').eq('user_id', user.id).in('status', ['current', 'unverified', 'late']).single()
|
||||
if (!member) return null
|
||||
|
||||
const [{ data: payments }, { data: members }, { data: integrations }] = await Promise.all([
|
||||
@@ -19,7 +19,7 @@ export default async function PaymentsPage() {
|
||||
supabase.from('space_members')
|
||||
.select('id, display_name, email')
|
||||
.eq('space_id', member.space_id)
|
||||
.eq('status', 'current'),
|
||||
.in('status', ['current', 'late']),
|
||||
supabase.from('integrations')
|
||||
.select('platform, is_connected, config')
|
||||
.eq('space_id', member.space_id),
|
||||
|
||||
@@ -121,9 +121,9 @@ export function PaymentsClient({ payments: initialPayments, members, integration
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<div className="bg-sidebar px-6 py-3 flex items-center justify-between">
|
||||
<div className="bg-sidebar px-4 md:px-6 py-3 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-white font-sans text-lg font-semibold">Payment Reconciliation</h1>
|
||||
<h1 className="text-white font-sans text-lg font-semibold">Payments</h1>
|
||||
{unlinkedCount > 0 && (
|
||||
<span className="font-mono text-xs text-orange-400">{unlinkedCount} unlinked</span>
|
||||
)}
|
||||
@@ -140,9 +140,9 @@ export function PaymentsClient({ payments: initialPayments, members, integration
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="p-4 md:p-6 space-y-6">
|
||||
{/* Platform summary cards */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 md:gap-4">
|
||||
{platforms.map(platform => {
|
||||
const txs = byPlatform[platform] ?? []
|
||||
const total = txs.reduce((sum, t) => sum + (t.amount ?? 0), 0)
|
||||
@@ -203,7 +203,8 @@ export function PaymentsClient({ payments: initialPayments, members, integration
|
||||
</div>
|
||||
|
||||
<div className="bg-card rounded border border-border overflow-hidden">
|
||||
<table className="w-full">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[600px]">
|
||||
<thead>
|
||||
<tr className="border-b border-border">
|
||||
{['PLATFORM', 'AMOUNT', 'FROM / NOTE', 'DATE', 'MEMBER'].map(h => (
|
||||
@@ -253,6 +254,7 @@ export function PaymentsClient({ payments: initialPayments, members, integration
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -266,7 +268,7 @@ export function PaymentsClient({ payments: initialPayments, members, integration
|
||||
<button onClick={() => setShowLogCash(false)} className="text-muted-foreground hover:text-foreground"><X className="w-4 h-4" /></button>
|
||||
</div>
|
||||
<form onSubmit={handleLogCash} className="p-6 space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Amount *</label>
|
||||
<input type="number" step="0.01" min="0" required value={cashForm.amount}
|
||||
|
||||
@@ -7,7 +7,7 @@ export default async function ProjectsPage() {
|
||||
if (!user) return null
|
||||
|
||||
const { data: member } = await supabase
|
||||
.from('space_members').select('space_id').eq('user_id', user.id).eq('status', 'current').single()
|
||||
.from('space_members').select('space_id').eq('user_id', user.id).in('status', ['current', 'unverified', 'late']).single()
|
||||
if (!member) return null
|
||||
|
||||
const { data: projects } = await supabase
|
||||
|
||||
@@ -68,7 +68,7 @@ export function ProjectsClient({ projects: initialProjects, spaceId }: { project
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<div className="bg-sidebar px-6 py-3 flex items-center justify-between">
|
||||
<div className="bg-sidebar px-4 md:px-6 py-3 flex items-center justify-between">
|
||||
<h1 className="text-white font-sans text-lg font-semibold">Projects</h1>
|
||||
<button
|
||||
onClick={() => setShowCreate(true)}
|
||||
@@ -78,8 +78,9 @@ export function ProjectsClient({ projects: initialProjects, spaceId }: { project
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-6">
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
<div className="p-4 md:p-6">
|
||||
<div className="overflow-x-auto -mx-4 md:mx-0 px-4 md:px-0">
|
||||
<div className="grid grid-cols-4 gap-4 min-w-[640px]">
|
||||
{STATUS_COLS.map(col => {
|
||||
const items = columns[col.key] ?? []
|
||||
return (
|
||||
@@ -141,6 +142,7 @@ export function ProjectsClient({ projects: initialProjects, spaceId }: { project
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showCreate && (
|
||||
@@ -162,7 +164,7 @@ export function ProjectsClient({ projects: initialProjects, spaceId }: { project
|
||||
<textarea value={form.description} onChange={e => setForm(f => ({ ...f, description: e.target.value }))}
|
||||
rows={2} className="w-full bg-background border border-border rounded px-3 py-2 font-sans text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:border-primary transition resize-none" />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Area</label>
|
||||
<select value={form.area} onChange={e => setForm(f => ({ ...f, area: e.target.value }))}
|
||||
|
||||
@@ -8,7 +8,7 @@ export default async function SettingsPage() {
|
||||
if (!user) redirect('/login')
|
||||
|
||||
const { data: member } = await supabase
|
||||
.from('space_members').select('space_id, role').eq('user_id', user.id).eq('status', 'current').single()
|
||||
.from('space_members').select('space_id, role').eq('user_id', user.id).in('status', ['current', 'unverified', 'late']).single()
|
||||
|
||||
if (!member) redirect('/login')
|
||||
|
||||
|
||||
@@ -193,15 +193,15 @@ export default function SettingsClient({ space, isAdmin, integrations, currentRo
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
{/* Header */}
|
||||
<div className="bg-sidebar px-6 py-3 flex items-center">
|
||||
<div className="bg-sidebar px-4 md:px-6 py-3 flex items-center">
|
||||
<h1 className="text-white font-sans text-lg font-semibold">Settings & Admin</h1>
|
||||
</div>
|
||||
|
||||
<div className="flex">
|
||||
{/* Main content */}
|
||||
<div className="flex-1 p-6">
|
||||
<div className="flex-1 p-4 md:p-6">
|
||||
{/* Tabs */}
|
||||
<div className="bg-card border-b border-border flex gap-6 px-2 mb-6 rounded-t">
|
||||
<div className="bg-card border-b border-border flex gap-4 md:gap-6 px-2 mb-6 rounded-t overflow-x-auto">
|
||||
{(['space', 'roles', 'integrations', 'webhooks'] as const).map(tab => (
|
||||
<button
|
||||
key={tab}
|
||||
@@ -229,7 +229,7 @@ export default function SettingsClient({ space, isAdmin, integrations, currentRo
|
||||
<p className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase mb-6">Space Settings</p>
|
||||
|
||||
<form onSubmit={handleSaveSpace} className="space-y-6">
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label className="font-sans text-sm text-foreground block mb-1.5">Space Name</label>
|
||||
<input
|
||||
|
||||
@@ -53,10 +53,13 @@ export function TasksClient({ tasks: initialTasks, members, currentUserId, space
|
||||
const [formError, setFormError] = useState('')
|
||||
const [formLoading, setFormLoading] = useState(false)
|
||||
|
||||
// Ongoing = tasks with a recurrence (not 'none')
|
||||
// Ongoing = recurring tasks (recurrence != 'none')
|
||||
// Chores = chore type OR regular tasks without recurrence (the general open pool)
|
||||
// Mine = tasks claimed/assigned to me
|
||||
// Done = completed
|
||||
const isDone = (t: any) => t.status === 'done' || t.status === 'completed'
|
||||
const isOpen = (t: any) => !isDone(t)
|
||||
const chores = tasks.filter(t => (t.type === 'chore' || t.task_type === 'chore') && isOpen(t) && (!t.recurrence || t.recurrence === 'none'))
|
||||
const chores = tasks.filter(t => isOpen(t) && (!t.recurrence || t.recurrence === 'none'))
|
||||
const ongoing = tasks.filter(t => t.recurrence && t.recurrence !== 'none' && isOpen(t))
|
||||
const mine = tasks.filter(t => (t.claimed_by === currentUserId || t.assigned_to === currentUserId) && isOpen(t))
|
||||
const done = tasks.filter(isDone)
|
||||
@@ -122,7 +125,7 @@ export function TasksClient({ tasks: initialTasks, members, currentUserId, space
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<div className="bg-sidebar px-6 py-3 flex items-center justify-between">
|
||||
<div className="bg-sidebar px-4 md:px-6 py-3 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-white font-sans text-lg font-semibold">Tasks & Chores</h1>
|
||||
<span className="font-mono text-xs text-white/50">{tasks.filter(t => !isDone(t)).length} open</span>
|
||||
@@ -131,7 +134,7 @@ export function TasksClient({ tasks: initialTasks, members, currentUserId, space
|
||||
<select
|
||||
value={filterArea}
|
||||
onChange={e => setFilterArea(e.target.value)}
|
||||
className="bg-sidebar-accent border border-sidebar-border text-sidebar-foreground text-xs font-sans rounded px-2 py-1.5"
|
||||
className="hidden sm:block bg-sidebar-accent border border-sidebar-border text-sidebar-foreground text-xs font-sans rounded px-2 py-1.5"
|
||||
>
|
||||
<option value="">All Areas</option>
|
||||
{AREAS.map(a => <option key={a}>{a}</option>)}
|
||||
@@ -140,12 +143,12 @@ export function TasksClient({ tasks: initialTasks, members, currentUserId, space
|
||||
onClick={() => setShowCreate(true)}
|
||||
className="flex items-center gap-1.5 bg-primary text-white text-xs font-sans px-3 py-1.5 rounded hover:bg-primary/90 transition"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" /> New Task
|
||||
<Plus className="w-3.5 h-3.5" /> <span className="hidden sm:inline">New Task</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-card border-b border-border px-6 flex gap-6">
|
||||
<div className="bg-card border-b border-border px-4 md:px-6 flex gap-4 md:gap-6 overflow-x-auto">
|
||||
{(['chores', 'ongoing', 'mine', 'done'] as const).map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
@@ -154,7 +157,7 @@ export function TasksClient({ tasks: initialTasks, members, currentUserId, space
|
||||
activeTab === tab ? 'border-primary text-primary' : 'border-transparent text-muted-foreground hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
{tab === 'mine' ? 'My Tasks' : tab.charAt(0).toUpperCase() + tab.slice(1)}{' '}
|
||||
{tab === 'mine' ? 'My Tasks' : tab === 'chores' ? 'Open Tasks' : tab.charAt(0).toUpperCase() + tab.slice(1)}{' '}
|
||||
{tabCounts[tab] > 0 && (
|
||||
<span className={`ml-1 text-[10px] font-mono px-1.5 py-0.5 rounded ${
|
||||
activeTab === tab ? 'bg-primary/10 text-primary' : 'bg-muted text-muted-foreground'
|
||||
@@ -166,7 +169,7 @@ export function TasksClient({ tasks: initialTasks, members, currentUserId, space
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="p-6">
|
||||
<div className="p-4 md:p-6">
|
||||
<div className="bg-card rounded border border-border divide-y divide-border">
|
||||
{currentList.length > 0 ? currentList.map(task => (
|
||||
<div key={task.id} className={`flex items-center gap-3 px-4 py-3 ${
|
||||
@@ -235,7 +238,7 @@ export function TasksClient({ tasks: initialTasks, members, currentUserId, space
|
||||
</div>
|
||||
)) : (
|
||||
<div className="px-4 py-10 text-center">
|
||||
<p className="font-sans text-sm text-muted-foreground">No {activeTab === 'done' ? 'completed' : 'open'} tasks in this view</p>
|
||||
<p className="font-sans text-sm text-muted-foreground">No {activeTab === 'done' ? 'completed' : activeTab === 'chores' ? 'open' : activeTab} tasks in this view</p>
|
||||
<button
|
||||
onClick={() => setShowCreate(true)}
|
||||
className="font-mono text-xs text-primary mt-2 hover:underline"
|
||||
@@ -283,7 +286,7 @@ export function TasksClient({ tasks: initialTasks, members, currentUserId, space
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Type</label>
|
||||
<select
|
||||
@@ -308,7 +311,7 @@ export function TasksClient({ tasks: initialTasks, members, currentUserId, space
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Recurrence</label>
|
||||
<select
|
||||
|
||||
@@ -55,7 +55,7 @@ export async function POST(req: NextRequest) {
|
||||
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
|
||||
const { data: member } = await supabase
|
||||
.from('space_members').select('space_id, role').eq('user_id', user.id).eq('status', 'current').single()
|
||||
.from('space_members').select('space_id, role').eq('user_id', user.id).in('status', ['current', 'unverified', 'late']).single()
|
||||
if (!member || !['admin', 'board', 'treasurer'].includes(member.role)) {
|
||||
return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 })
|
||||
}
|
||||
|
||||
+2
-6
@@ -79,12 +79,8 @@ export default function SignupPage() {
|
||||
const supabase = createClient()
|
||||
|
||||
if (mode === 'join') {
|
||||
const { data: space, error: lookupErr } = await supabase
|
||||
.from('spaces')
|
||||
.select('id')
|
||||
.eq('invite_code', inviteCode.trim().toUpperCase())
|
||||
.single()
|
||||
if (lookupErr || !space) { setError('Invalid invite code'); setLoading(false); return }
|
||||
// Invite code is validated server-side in joinSpace (uses admin client, bypasses RLS).
|
||||
// Do NOT pre-validate here with the regular client — RLS blocks unauthenticated lookups.
|
||||
}
|
||||
|
||||
const metadata: Record<string, string> = {
|
||||
|
||||
+99
-154
@@ -1,76 +1,48 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { usePathname, useRouter } from 'next/navigation'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import { useState } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { signOut } from '@/lib/auth-actions'
|
||||
import type { SpaceMember, Space } from '@/lib/types'
|
||||
import {
|
||||
Menu, X, LayoutDashboard, ListChecks, FolderKanban, Settings2,
|
||||
MessageSquare, Users, CreditCard, BookUser, Download, LogOut,
|
||||
} from 'lucide-react'
|
||||
|
||||
interface NavItem {
|
||||
label: string
|
||||
interface NavLinkProps {
|
||||
href: string
|
||||
icon: React.ReactNode
|
||||
label: string
|
||||
icon: React.ElementType
|
||||
badge?: number
|
||||
section: 'workspace' | 'people' | 'admin'
|
||||
active: boolean
|
||||
onClick?: () => void
|
||||
}
|
||||
|
||||
function navIcon(path: string) {
|
||||
const icons: Record<string, React.ReactNode> = {
|
||||
dashboard: (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
|
||||
</svg>
|
||||
),
|
||||
tasks: (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
|
||||
</svg>
|
||||
),
|
||||
projects: (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 5a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM14 5a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1V5zM4 15a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H5a1 1 0 01-1-1v-4zM14 15a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1v-4z" />
|
||||
</svg>
|
||||
),
|
||||
ops: (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
),
|
||||
comms: (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
|
||||
</svg>
|
||||
),
|
||||
members: (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
),
|
||||
payments: (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9 17V7m0 10a2 2 0 01-2 2H5a2 2 0 01-2-2V7a2 2 0 012-2h2a2 2 0 012 2m0 10a2 2 0 002 2h2a2 2 0 002-2M9 7a2 2 0 012-2h2a2 2 0 012 2m0 10V7m0 10a2 2 0 002 2h2a2 2 0 002-2V7a2 2 0 00-2-2h-2a2 2 0 00-2 2" />
|
||||
</svg>
|
||||
),
|
||||
contacts: (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
|
||||
</svg>
|
||||
),
|
||||
import: (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||
</svg>
|
||||
),
|
||||
settings: (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
),
|
||||
}
|
||||
return icons[path] ?? null
|
||||
function NavLink({ href, label, icon: Icon, badge, active, onClick }: NavLinkProps) {
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
'flex items-center justify-between mx-2 px-3 py-2 rounded-md text-sm transition-colors',
|
||||
active
|
||||
? 'bg-[var(--sidebar-primary)]/15 text-[var(--sidebar-primary)]'
|
||||
: 'text-[var(--sidebar-foreground)]/70 hover:bg-[var(--sidebar-accent)] hover:text-[var(--sidebar-foreground)]',
|
||||
)}
|
||||
>
|
||||
<span className="flex items-center gap-2.5">
|
||||
<Icon className={cn('w-4 h-4', active ? 'text-[var(--sidebar-primary)]' : 'text-[var(--sidebar-foreground)]/50')} />
|
||||
{label}
|
||||
</span>
|
||||
{badge && badge > 0 ? (
|
||||
<span className="text-[10px] bg-red-500 text-white rounded-full w-4 h-4 flex items-center justify-center font-medium">
|
||||
{badge}
|
||||
</span>
|
||||
) : null}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
interface AppSidebarProps {
|
||||
@@ -82,27 +54,26 @@ interface AppSidebarProps {
|
||||
|
||||
export function AppSidebar({ member, taskBadge = 0, commsBadge = 0, paymentBadge = 0 }: AppSidebarProps) {
|
||||
const pathname = usePathname()
|
||||
const router = useRouter()
|
||||
const space = member.spaces
|
||||
const initials = member.display_name.split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2)
|
||||
|
||||
const initials = (member.display_name || 'U').split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2)
|
||||
const isAdmin = member.role === 'admin' || member.role === 'board'
|
||||
const [drawerOpen, setDrawerOpen] = useState(false)
|
||||
|
||||
function isActive(href: string) {
|
||||
return pathname === href || pathname.startsWith(href + '/')
|
||||
}
|
||||
const isActive = (href: string) => pathname === href || pathname.startsWith(href + '/')
|
||||
|
||||
async function handleSignOut() {
|
||||
const handleSignOut = async () => {
|
||||
await signOut()
|
||||
}
|
||||
|
||||
return (
|
||||
<aside className="w-[230px] shrink-0 bg-[var(--sidebar)] flex flex-col h-screen sticky top-0 border-r border-[var(--sidebar-border)]">
|
||||
const closeDrawer = () => setDrawerOpen(false)
|
||||
|
||||
const renderNav = (onNav?: () => void) => (
|
||||
<>
|
||||
{/* Logo */}
|
||||
<div className="px-5 py-5 border-b border-[var(--sidebar-border)]">
|
||||
<Link href="/dashboard" className="flex items-center gap-2.5">
|
||||
<Link href="/dashboard" onClick={onNav} className="flex items-center gap-2.5">
|
||||
<div className="w-8 h-8 rounded bg-[var(--sidebar-primary)] flex items-center justify-center">
|
||||
<span className="text-white font-mono text-xs font-bold">{'{'}<span className="text-white">hs</span>{'}'}</span>
|
||||
<span className="text-white font-mono text-xs font-bold">{'{'}<span>hs</span>{'}'}</span>
|
||||
</div>
|
||||
</Link>
|
||||
<p className="text-[var(--sidebar-foreground)]/60 text-xs mt-1.5 font-mono truncate">{space?.name || 'My Space'}</p>
|
||||
@@ -110,100 +81,29 @@ export function AppSidebar({ member, taskBadge = 0, commsBadge = 0, paymentBadge
|
||||
|
||||
{/* Nav */}
|
||||
<nav className="flex-1 py-4 overflow-y-auto">
|
||||
{/* WORKSPACE */}
|
||||
<div className="px-3 mb-1">
|
||||
<p className="text-[10px] uppercase tracking-widest text-[var(--sidebar-foreground)]/30 px-2 py-1 font-mono">Workspace</p>
|
||||
</div>
|
||||
{[
|
||||
{ label: 'Dashboard', href: '/dashboard', key: 'dashboard', badge: 0 },
|
||||
{ label: 'Tasks & Chores', href: '/tasks', key: 'tasks', badge: taskBadge },
|
||||
{ label: 'Projects', href: '/projects', key: 'projects', badge: 0 },
|
||||
{ label: 'Ops & Facilities', href: '/ops', key: 'ops', badge: 0 },
|
||||
{ label: 'Comms', href: '/comms', key: 'comms', badge: commsBadge },
|
||||
].map(item => (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={cn(
|
||||
'flex items-center justify-between mx-2 px-3 py-2 rounded-md text-sm transition-colors group',
|
||||
isActive(item.href)
|
||||
? 'bg-[var(--sidebar-primary)]/15 text-[var(--sidebar-primary)]'
|
||||
: 'text-[var(--sidebar-foreground)]/70 hover:bg-[var(--sidebar-accent)] hover:text-[var(--sidebar-foreground)]'
|
||||
)}
|
||||
>
|
||||
<span className="flex items-center gap-2.5">
|
||||
<span className={cn(isActive(item.href) ? 'text-[var(--sidebar-primary)]' : 'text-[var(--sidebar-foreground)]/50')}>
|
||||
{navIcon(item.key)}
|
||||
</span>
|
||||
{item.label}
|
||||
</span>
|
||||
{item.badge > 0 && (
|
||||
<span className="text-[10px] bg-red-500 text-white rounded-full w-4 h-4 flex items-center justify-center font-medium">
|
||||
{item.badge}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
))}
|
||||
<NavLink href="/dashboard" label="Dashboard" icon={LayoutDashboard} active={isActive('/dashboard')} onClick={onNav} />
|
||||
<NavLink href="/tasks" label="Tasks & Chores" icon={ListChecks} active={isActive('/tasks')} badge={taskBadge} onClick={onNav} />
|
||||
<NavLink href="/projects" label="Projects" icon={FolderKanban} active={isActive('/projects')} onClick={onNav} />
|
||||
<NavLink href="/ops" label="Ops & Facilities" icon={Settings2} active={isActive('/ops')} onClick={onNav} />
|
||||
<NavLink href="/comms" label="Comms" icon={MessageSquare} active={isActive('/comms')} badge={commsBadge} onClick={onNav} />
|
||||
|
||||
{/* PEOPLE */}
|
||||
<div className="px-3 mt-4 mb-1">
|
||||
<p className="text-[10px] uppercase tracking-widest text-[var(--sidebar-foreground)]/30 px-2 py-1 font-mono">People</p>
|
||||
</div>
|
||||
{[
|
||||
{ label: 'Members', href: '/members', key: 'members', badge: 0 },
|
||||
{ label: 'Payments', href: '/payments', key: 'payments', badge: paymentBadge },
|
||||
{ label: 'Contacts', href: '/contacts', key: 'contacts', badge: 0 },
|
||||
].map(item => (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={cn(
|
||||
'flex items-center justify-between mx-2 px-3 py-2 rounded-md text-sm transition-colors',
|
||||
isActive(item.href)
|
||||
? 'bg-[var(--sidebar-primary)]/15 text-[var(--sidebar-primary)]'
|
||||
: 'text-[var(--sidebar-foreground)]/70 hover:bg-[var(--sidebar-accent)] hover:text-[var(--sidebar-foreground)]'
|
||||
)}
|
||||
>
|
||||
<span className="flex items-center gap-2.5">
|
||||
<span className={cn(isActive(item.href) ? 'text-[var(--sidebar-primary)]' : 'text-[var(--sidebar-foreground)]/50')}>
|
||||
{navIcon(item.key)}
|
||||
</span>
|
||||
{item.label}
|
||||
</span>
|
||||
{item.badge > 0 && (
|
||||
<span className="text-[10px] bg-red-500 text-white rounded-full w-4 h-4 flex items-center justify-center font-medium">
|
||||
{item.badge}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
))}
|
||||
<NavLink href="/members" label="Members" icon={Users} active={isActive('/members')} onClick={onNav} />
|
||||
<NavLink href="/payments" label="Payments" icon={CreditCard} active={isActive('/payments')} badge={paymentBadge} onClick={onNav} />
|
||||
<NavLink href="/contacts" label="Contacts" icon={BookUser} active={isActive('/contacts')} onClick={onNav} />
|
||||
|
||||
{/* ADMIN */}
|
||||
{isAdmin && (
|
||||
<>
|
||||
<div className="px-3 mt-4 mb-1">
|
||||
<p className="text-[10px] uppercase tracking-widest text-[var(--sidebar-foreground)]/30 px-2 py-1 font-mono">Admin</p>
|
||||
</div>
|
||||
{[
|
||||
{ label: 'Import / Sync', href: '/import', key: 'import' },
|
||||
{ label: 'Settings', href: '/settings', key: 'settings' },
|
||||
].map(item => (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={cn(
|
||||
'flex items-center gap-2.5 mx-2 px-3 py-2 rounded-md text-sm transition-colors',
|
||||
isActive(item.href)
|
||||
? 'bg-[var(--sidebar-primary)]/15 text-[var(--sidebar-primary)]'
|
||||
: 'text-[var(--sidebar-foreground)]/70 hover:bg-[var(--sidebar-accent)] hover:text-[var(--sidebar-foreground)]'
|
||||
)}
|
||||
>
|
||||
<span className={cn(isActive(item.href) ? 'text-[var(--sidebar-primary)]' : 'text-[var(--sidebar-foreground)]/50')}>
|
||||
{navIcon(item.key)}
|
||||
</span>
|
||||
{item.label}
|
||||
</Link>
|
||||
))}
|
||||
<NavLink href="/import" label="Import / Sync" icon={Download} active={isActive('/import')} onClick={onNav} />
|
||||
<NavLink href="/settings" label="Settings" icon={Settings2} active={isActive('/settings')} onClick={onNav} />
|
||||
</>
|
||||
)}
|
||||
</nav>
|
||||
@@ -221,8 +121,53 @@ export function AppSidebar({ member, taskBadge = 0, commsBadge = 0, paymentBadge
|
||||
<p className="text-[var(--sidebar-foreground)] text-xs font-medium truncate">{member.display_name}</p>
|
||||
<p className="text-[var(--sidebar-foreground)]/40 text-[10px] font-mono">{member.role} · {member.tier}</p>
|
||||
</div>
|
||||
<LogOut className="w-3.5 h-3.5 text-[var(--sidebar-foreground)]/30" />
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
</>
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Desktop sidebar */}
|
||||
<aside className="hidden md:flex w-[230px] shrink-0 bg-[var(--sidebar)] flex-col h-screen sticky top-0 border-r border-[var(--sidebar-border)]">
|
||||
{renderNav()}
|
||||
</aside>
|
||||
|
||||
{/* Mobile: top bar */}
|
||||
<div className="md:hidden fixed top-0 left-0 right-0 z-40 bg-[var(--sidebar)] border-b border-[var(--sidebar-border)] flex items-center justify-between px-4 h-[52px]">
|
||||
<Link href="/dashboard" className="flex items-center gap-2">
|
||||
<div className="w-7 h-7 rounded bg-[var(--sidebar-primary)] flex items-center justify-center">
|
||||
<span className="text-white font-mono text-[10px] font-bold">hs</span>
|
||||
</div>
|
||||
<span className="text-[var(--sidebar-foreground)]/60 text-xs font-mono truncate max-w-[150px]">{space?.name}</span>
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => setDrawerOpen(true)}
|
||||
className="text-[var(--sidebar-foreground)]/70 hover:text-[var(--sidebar-foreground)] p-1"
|
||||
aria-label="Open menu"
|
||||
>
|
||||
<Menu className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Mobile: drawer overlay */}
|
||||
{drawerOpen && (
|
||||
<div className="md:hidden fixed inset-0 z-50 flex">
|
||||
<div className="absolute inset-0 bg-black/50" onClick={closeDrawer} />
|
||||
<div className="relative w-[280px] bg-[var(--sidebar)] flex flex-col h-full shadow-xl">
|
||||
<div className="flex items-center justify-between px-4 h-[52px] border-b border-[var(--sidebar-border)]">
|
||||
<span className="text-[var(--sidebar-foreground)] text-sm font-mono font-bold">Menu</span>
|
||||
<button onClick={closeDrawer} className="text-[var(--sidebar-foreground)]/50 hover:text-[var(--sidebar-foreground)]">
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
{renderNav(closeDrawer)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
+40
-41
@@ -25,18 +25,27 @@ export async function getUser() {
|
||||
return user
|
||||
}
|
||||
|
||||
// ─── Helper: get current member (any non-inactive status) ────────────────────
|
||||
async function getMember(supabase: any, userId: string) {
|
||||
const { data } = await supabase
|
||||
.from('space_members')
|
||||
.select('space_id, role, display_name')
|
||||
.eq('user_id', userId)
|
||||
.in('status', ['current', 'unverified', 'late'])
|
||||
.single()
|
||||
return data
|
||||
}
|
||||
|
||||
export async function getCurrentMember() {
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (!user) return null
|
||||
|
||||
const { data } = await supabase
|
||||
.from('space_members')
|
||||
.select('*, spaces(*)')
|
||||
.eq('user_id', user.id)
|
||||
.eq('status', 'current')
|
||||
.in('status', ['current', 'unverified', 'late'])
|
||||
.single()
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
@@ -54,8 +63,7 @@ export async function createTask(formData: {
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (!user) return { error: 'Not authenticated' }
|
||||
|
||||
const { data: member } = await supabase
|
||||
.from('space_members').select('space_id, display_name').eq('user_id', user.id).eq('status', 'current').single()
|
||||
const member = await getMember(supabase, user.id)
|
||||
if (!member) return { error: 'No active membership' }
|
||||
|
||||
const { data, error } = await supabase.from('tasks').insert({
|
||||
@@ -94,8 +102,7 @@ export async function claimTask(taskId: string) {
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (!user) return { error: 'Not authenticated' }
|
||||
|
||||
const { data: member } = await supabase
|
||||
.from('space_members').select('space_id, display_name').eq('user_id', user.id).eq('status', 'current').single()
|
||||
const member = await getMember(supabase, user.id)
|
||||
if (!member) return { error: 'No active membership' }
|
||||
|
||||
const { error } = await supabase.from('tasks').update({
|
||||
@@ -125,8 +132,7 @@ export async function completeTask(taskId: string) {
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (!user) return { error: 'Not authenticated' }
|
||||
|
||||
const { data: member } = await supabase
|
||||
.from('space_members').select('space_id, display_name').eq('user_id', user.id).eq('status', 'current').single()
|
||||
const member = await getMember(supabase, user.id)
|
||||
if (!member) return { error: 'No active membership' }
|
||||
|
||||
const { error } = await supabase.from('tasks').update({
|
||||
@@ -156,8 +162,7 @@ export async function deleteTask(taskId: string) {
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (!user) return { error: 'Not authenticated' }
|
||||
|
||||
const { data: member } = await supabase
|
||||
.from('space_members').select('space_id').eq('user_id', user.id).eq('status', 'current').single()
|
||||
const member = await getMember(supabase, user.id)
|
||||
if (!member) return { error: 'No active membership' }
|
||||
|
||||
const { error } = await supabase.from('tasks').delete()
|
||||
@@ -181,8 +186,7 @@ export async function createProject(formData: {
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (!user) return { error: 'Not authenticated' }
|
||||
|
||||
const { data: member } = await supabase
|
||||
.from('space_members').select('space_id, display_name').eq('user_id', user.id).eq('status', 'current').single()
|
||||
const member = await getMember(supabase, user.id)
|
||||
if (!member) return { error: 'No active membership' }
|
||||
|
||||
const { data, error } = await supabase.from('projects').insert({
|
||||
@@ -216,8 +220,7 @@ export async function updateProjectStatus(projectId: string, status: string) {
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (!user) return { error: 'Not authenticated' }
|
||||
|
||||
const { data: member } = await supabase
|
||||
.from('space_members').select('space_id').eq('user_id', user.id).eq('status', 'current').single()
|
||||
const member = await getMember(supabase, user.id)
|
||||
if (!member) return { error: 'No active membership' }
|
||||
|
||||
const { error } = await supabase.from('projects').update({ status, updated_at: new Date().toISOString() })
|
||||
@@ -234,8 +237,7 @@ export async function deleteProject(projectId: string) {
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (!user) return { error: 'Not authenticated' }
|
||||
|
||||
const { data: member } = await supabase
|
||||
.from('space_members').select('space_id').eq('user_id', user.id).eq('status', 'current').single()
|
||||
const member = await getMember(supabase, user.id)
|
||||
if (!member) return { error: 'No active membership' }
|
||||
|
||||
const { error } = await supabase.from('projects').delete()
|
||||
@@ -263,7 +265,7 @@ export async function addMember(formData: {
|
||||
if (!user) return { error: 'Not authenticated' }
|
||||
|
||||
const { data: self } = await supabase
|
||||
.from('space_members').select('space_id, role').eq('user_id', user.id).eq('status', 'current').single()
|
||||
.from('space_members').select('space_id, role').eq('user_id', user.id).in('status', ['current', 'unverified', 'late']).single()
|
||||
if (!self || (self.role !== 'admin' && self.role !== 'board')) return { error: 'Admin access required' }
|
||||
|
||||
const { data, error } = await supabase.from('space_members').insert({
|
||||
@@ -302,7 +304,7 @@ export async function updateMember(memberId: string, updates: {
|
||||
if (!user) return { error: 'Not authenticated' }
|
||||
|
||||
const { data: self } = await supabase
|
||||
.from('space_members').select('space_id, role').eq('user_id', user.id).eq('status', 'current').single()
|
||||
.from('space_members').select('space_id, role').eq('user_id', user.id).in('status', ['current', 'unverified', 'late']).single()
|
||||
if (!self || (self.role !== 'admin' && self.role !== 'board')) return { error: 'Admin access required' }
|
||||
|
||||
const { error } = await supabase.from('space_members').update(updates)
|
||||
@@ -319,7 +321,7 @@ export async function approveMember(memberId: string) {
|
||||
if (!user) return { error: 'Not authenticated' }
|
||||
|
||||
const { data: self } = await supabase
|
||||
.from('space_members').select('space_id, role, display_name').eq('user_id', user.id).eq('status', 'current').single()
|
||||
.from('space_members').select('space_id, role, display_name').eq('user_id', user.id).in('status', ['current', 'unverified', 'late']).single()
|
||||
if (!self || (self.role !== 'admin' && self.role !== 'board')) return { error: 'Admin access required' }
|
||||
|
||||
const { error } = await supabase.from('space_members').update({ status: 'current', approved: true })
|
||||
@@ -346,7 +348,7 @@ export async function removeMember(memberId: string) {
|
||||
if (!user) return { error: 'Not authenticated' }
|
||||
|
||||
const { data: self } = await supabase
|
||||
.from('space_members').select('space_id, role').eq('user_id', user.id).eq('status', 'current').single()
|
||||
.from('space_members').select('space_id, role').eq('user_id', user.id).in('status', ['current', 'unverified', 'late']).single()
|
||||
if (!self || self.role !== 'admin') return { error: 'Admin access required' }
|
||||
|
||||
const { error } = await supabase.from('space_members').delete()
|
||||
@@ -373,8 +375,7 @@ export async function createContact(formData: {
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (!user) return { error: 'Not authenticated' }
|
||||
|
||||
const { data: member } = await supabase
|
||||
.from('space_members').select('space_id').eq('user_id', user.id).eq('status', 'current').single()
|
||||
const member = await getMember(supabase, user.id)
|
||||
if (!member) return { error: 'No active membership' }
|
||||
|
||||
const code = formData.name.slice(0, 3).toUpperCase() + Math.floor(Math.random() * 900 + 100)
|
||||
@@ -411,8 +412,7 @@ export async function updateContact(contactId: string, updates: {
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (!user) return { error: 'Not authenticated' }
|
||||
|
||||
const { data: member } = await supabase
|
||||
.from('space_members').select('space_id').eq('user_id', user.id).eq('status', 'current').single()
|
||||
const member = await getMember(supabase, user.id)
|
||||
if (!member) return { error: 'No active membership' }
|
||||
|
||||
const { error } = await supabase.from('contacts').update({ ...updates, updated_at: new Date().toISOString() })
|
||||
@@ -428,8 +428,7 @@ export async function deleteContact(contactId: string) {
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (!user) return { error: 'Not authenticated' }
|
||||
|
||||
const { data: member } = await supabase
|
||||
.from('space_members').select('space_id').eq('user_id', user.id).eq('status', 'current').single()
|
||||
const member = await getMember(supabase, user.id)
|
||||
if (!member) return { error: 'No active membership' }
|
||||
|
||||
const { error } = await supabase.from('contacts').delete()
|
||||
@@ -453,7 +452,7 @@ export async function logCashPayment(formData: {
|
||||
if (!user) return { error: 'Not authenticated' }
|
||||
|
||||
const { data: member } = await supabase
|
||||
.from('space_members').select('space_id, role, display_name').eq('user_id', user.id).eq('status', 'current').single()
|
||||
.from('space_members').select('space_id, role, display_name').eq('user_id', user.id).in('status', ['current', 'unverified', 'late']).single()
|
||||
if (!member || (member.role !== 'admin' && member.role !== 'board' && member.role !== 'treasurer')) {
|
||||
return { error: 'Treasurer access required' }
|
||||
}
|
||||
@@ -500,7 +499,7 @@ export async function linkPaymentToMember(paymentId: string, memberId: string) {
|
||||
if (!user) return { error: 'Not authenticated' }
|
||||
|
||||
const { data: self } = await supabase
|
||||
.from('space_members').select('space_id, role').eq('user_id', user.id).eq('status', 'current').single()
|
||||
.from('space_members').select('space_id, role').eq('user_id', user.id).in('status', ['current', 'unverified', 'late']).single()
|
||||
if (!self || (self.role !== 'admin' && self.role !== 'board' && self.role !== 'treasurer')) {
|
||||
return { error: 'Treasurer access required' }
|
||||
}
|
||||
@@ -542,7 +541,7 @@ export async function importPaymentsCsv(rows: Array<{
|
||||
if (!user) return { error: 'Not authenticated' }
|
||||
|
||||
const { data: member } = await supabase
|
||||
.from('space_members').select('space_id, role').eq('user_id', user.id).eq('status', 'current').single()
|
||||
.from('space_members').select('space_id, role').eq('user_id', user.id).in('status', ['current', 'unverified', 'late']).single()
|
||||
if (!member || (member.role !== 'admin' && member.role !== 'board' && member.role !== 'treasurer')) {
|
||||
return { error: 'Treasurer access required' }
|
||||
}
|
||||
@@ -582,7 +581,7 @@ export async function createKbEntry(formData: {
|
||||
if (!user) return { error: 'Not authenticated' }
|
||||
|
||||
const { data: member } = await supabase
|
||||
.from('space_members').select('space_id, display_name, handle').eq('user_id', user.id).eq('status', 'current').single()
|
||||
.from('space_members').select('space_id, display_name, handle').eq('user_id', user.id).in('status', ['current', 'unverified', 'late']).single()
|
||||
if (!member) return { error: 'No active membership' }
|
||||
|
||||
const { data, error } = await supabase.from('knowledge_base').insert({
|
||||
@@ -616,7 +615,7 @@ export async function updateKbEntry(entryId: string, updates: {
|
||||
if (!user) return { error: 'Not authenticated' }
|
||||
|
||||
const { data: member } = await supabase
|
||||
.from('space_members').select('space_id, display_name').eq('user_id', user.id).eq('status', 'current').single()
|
||||
.from('space_members').select('space_id, display_name').eq('user_id', user.id).in('status', ['current', 'unverified', 'late']).single()
|
||||
if (!member) return { error: 'No active membership' }
|
||||
|
||||
// Only pass columns that actually exist in the DB
|
||||
@@ -640,7 +639,7 @@ export async function deleteKbEntry(entryId: string) {
|
||||
if (!user) return { error: 'Not authenticated' }
|
||||
|
||||
const { data: member } = await supabase
|
||||
.from('space_members').select('space_id').eq('user_id', user.id).eq('status', 'current').single()
|
||||
.from('space_members').select('space_id').eq('user_id', user.id).in('status', ['current', 'unverified', 'late']).single()
|
||||
if (!member) return { error: 'No active membership' }
|
||||
|
||||
const { error } = await supabase.from('knowledge_base').delete()
|
||||
@@ -665,7 +664,7 @@ export async function createSecret(formData: {
|
||||
if (!user) return { error: 'Not authenticated' }
|
||||
|
||||
const { data: member } = await supabase
|
||||
.from('space_members').select('space_id, role').eq('user_id', user.id).eq('status', 'current').single()
|
||||
.from('space_members').select('space_id, role').eq('user_id', user.id).in('status', ['current', 'unverified', 'late']).single()
|
||||
if (!member || (member.role !== 'admin' && member.role !== 'board')) return { error: 'Admin access required' }
|
||||
|
||||
const { data, error } = await supabase.from('secrets').insert({
|
||||
@@ -688,7 +687,7 @@ export async function deleteSecret(secretId: string) {
|
||||
if (!user) return { error: 'Not authenticated' }
|
||||
|
||||
const { data: member } = await supabase
|
||||
.from('space_members').select('space_id, role').eq('user_id', user.id).eq('status', 'current').single()
|
||||
.from('space_members').select('space_id, role').eq('user_id', user.id).in('status', ['current', 'unverified', 'late']).single()
|
||||
if (!member || member.role !== 'admin') return { error: 'Admin access required' }
|
||||
|
||||
const { error } = await supabase.from('secrets').delete()
|
||||
@@ -713,7 +712,7 @@ export async function upsertAreaLead(formData: {
|
||||
if (!user) return { error: 'Not authenticated' }
|
||||
|
||||
const { data: member } = await supabase
|
||||
.from('space_members').select('space_id, role').eq('user_id', user.id).eq('status', 'current').single()
|
||||
.from('space_members').select('space_id, role').eq('user_id', user.id).in('status', ['current', 'unverified', 'late']).single()
|
||||
if (!member || (member.role !== 'admin' && member.role !== 'board')) return { error: 'Admin access required' }
|
||||
|
||||
const { data, error } = await supabase.from('area_leads').upsert({
|
||||
@@ -745,7 +744,7 @@ export async function updateSpaceSettings(updates: {
|
||||
if (!user) return { error: 'Not authenticated' }
|
||||
|
||||
const { data: member } = await supabase
|
||||
.from('space_members').select('space_id, role').eq('user_id', user.id).eq('status', 'current').single()
|
||||
.from('space_members').select('space_id, role').eq('user_id', user.id).in('status', ['current', 'unverified', 'late']).single()
|
||||
if (!member || member.role !== 'admin') return { error: 'Admin access required' }
|
||||
|
||||
const { error } = await supabase.from('spaces').update({
|
||||
@@ -764,7 +763,7 @@ export async function rotateWebhookSecret() {
|
||||
if (!user) return { error: 'Not authenticated' }
|
||||
|
||||
const { data: member } = await supabase
|
||||
.from('space_members').select('space_id, role').eq('user_id', user.id).eq('status', 'current').single()
|
||||
.from('space_members').select('space_id, role').eq('user_id', user.id).in('status', ['current', 'unverified', 'late']).single()
|
||||
if (!member || member.role !== 'admin') return { error: 'Admin access required' }
|
||||
|
||||
// Generate a new webhook secret
|
||||
@@ -785,7 +784,7 @@ export async function saveIntegration(platform: string, config: Record<string, s
|
||||
if (!user) return { error: 'Not authenticated' }
|
||||
|
||||
const { data: member } = await supabase
|
||||
.from('space_members').select('space_id, role').eq('user_id', user.id).eq('status', 'current').single()
|
||||
.from('space_members').select('space_id, role').eq('user_id', user.id).in('status', ['current', 'unverified', 'late']).single()
|
||||
if (!member || member.role !== 'admin') return { error: 'Admin access required' }
|
||||
|
||||
// Mask sensitive values — store but indicate they're set
|
||||
@@ -815,7 +814,7 @@ export async function disconnectIntegration(platform: string) {
|
||||
if (!user) return { error: 'Not authenticated' }
|
||||
|
||||
const { data: member } = await supabase
|
||||
.from('space_members').select('space_id, role').eq('user_id', user.id).eq('status', 'current').single()
|
||||
.from('space_members').select('space_id, role').eq('user_id', user.id).in('status', ['current', 'unverified', 'late']).single()
|
||||
if (!member || member.role !== 'admin') return { error: 'Admin access required' }
|
||||
|
||||
const { error } = await supabase.from('integrations').update({
|
||||
@@ -845,7 +844,7 @@ export async function importMembers(rows: Array<{
|
||||
if (!user) return { error: 'Not authenticated' }
|
||||
|
||||
const { data: member } = await supabase
|
||||
.from('space_members').select('space_id, role').eq('user_id', user.id).eq('status', 'current').single()
|
||||
.from('space_members').select('space_id, role').eq('user_id', user.id).in('status', ['current', 'unverified', 'late']).single()
|
||||
if (!member || (member.role !== 'admin' && member.role !== 'board')) return { error: 'Admin access required' }
|
||||
|
||||
const inserts = rows.map(r => ({
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
|
||||
const filePath = '/vercel/share/v0-project/lib/actions.ts'
|
||||
console.log('Patching:', filePath)
|
||||
let content = fs.readFileSync(filePath, 'utf8')
|
||||
|
||||
// Replace ALL .eq('user_id', user.id).eq('status', 'current').single() patterns
|
||||
content = content.replaceAll(
|
||||
`.eq('user_id', user.id).eq('status', 'current').single()`,
|
||||
`.eq('user_id', user.id).in('status', ['current', 'unverified', 'late']).single()`
|
||||
)
|
||||
|
||||
// Also the multi-line variant
|
||||
content = content.replaceAll(
|
||||
`.eq('user_id', user.id)\n .eq('status', 'current')\n .single()`,
|
||||
`.eq('user_id', user.id)\n .in('status', ['current', 'unverified', 'late'])\n .single()`
|
||||
)
|
||||
|
||||
fs.writeFileSync(filePath, content, 'utf8')
|
||||
console.log('Done. File length:', content.length)
|
||||
const remaining = (content.match(/eq\('status', 'current'\)/g) || []).length
|
||||
console.log('Remaining eq(status,current) occurrences:', remaining)
|
||||
@@ -0,0 +1,258 @@
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
|
||||
// Find the actual project root by looking for package.json
|
||||
const candidates = [
|
||||
'/vercel/share/v0-project',
|
||||
process.cwd(),
|
||||
]
|
||||
|
||||
let root = null
|
||||
for (const c of candidates) {
|
||||
if (fs.existsSync(path.join(c, 'package.json'))) {
|
||||
root = c
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (!root) {
|
||||
// Walk up from cwd
|
||||
let dir = process.cwd()
|
||||
while (dir !== '/') {
|
||||
if (fs.existsSync(path.join(dir, 'package.json'))) { root = dir; break }
|
||||
dir = path.dirname(dir)
|
||||
}
|
||||
}
|
||||
|
||||
console.log('Project root:', root)
|
||||
const target = path.join(root, 'components', 'app-sidebar.tsx')
|
||||
console.log('Target file:', target)
|
||||
console.log('Exists:', fs.existsSync(target))
|
||||
|
||||
if (!fs.existsSync(target)) {
|
||||
// Try to find it
|
||||
function findFile(dir, name) {
|
||||
try {
|
||||
const entries = fs.readdirSync(dir, { withFileTypes: true })
|
||||
for (const e of entries) {
|
||||
if (e.name === 'node_modules' || e.name === '.git') continue
|
||||
const full = path.join(dir, e.name)
|
||||
if (e.isDirectory()) {
|
||||
const found = findFile(full, name)
|
||||
if (found) return found
|
||||
} else if (e.name === name) return full
|
||||
}
|
||||
} catch {}
|
||||
return null
|
||||
}
|
||||
const found = findFile(root || '/', 'app-sidebar.tsx')
|
||||
console.log('Found at:', found)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
let src = fs.readFileSync(target, 'utf8')
|
||||
|
||||
// Fix 1: add useEffect to import
|
||||
src = src.replace(
|
||||
`import { useState } from 'react'`,
|
||||
`import { useState, useEffect } from 'react'`
|
||||
)
|
||||
|
||||
// Fix 2: add useEffect after useState call
|
||||
src = src.replace(
|
||||
`const [drawerOpen, setDrawerOpen] = useState(false)`,
|
||||
`const [drawerOpen, setDrawerOpen] = useState(false)
|
||||
|
||||
// Auto-close drawer on route change — no onClick needed on nav links
|
||||
useEffect(() => { setDrawerOpen(false) }, [pathname])`
|
||||
)
|
||||
|
||||
// Fix 3: remove onClick from NavLink interface
|
||||
src = src.replace(` onClick?: () => void\n`, '')
|
||||
|
||||
// Fix 4: remove onClick from NavLink function params
|
||||
src = src.replace(
|
||||
`function NavLink({ href, label, icon: Icon, badge, active, onClick }: NavLinkProps) {`,
|
||||
`function NavLink({ href, label, icon: Icon, badge, active }: NavLinkProps) {`
|
||||
)
|
||||
|
||||
// Fix 5: remove onClick={onClick} from <Link>
|
||||
src = src.replace(` onClick={onClick}\n`, '')
|
||||
|
||||
// Fix 6: remove onClick from all NavLink usages
|
||||
src = src.replace(/ onClick=\{onNav\}/g, '')
|
||||
|
||||
fs.writeFileSync(target, src, 'utf8')
|
||||
console.log('Done! Wrote', src.length, 'bytes to', target)
|
||||
|
||||
|
||||
import Link from 'next/link'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { signOut } from '@/lib/auth-actions'
|
||||
import type { SpaceMember, Space } from '@/lib/types'
|
||||
import {
|
||||
Menu, X, LayoutDashboard, ListChecks, FolderKanban, Settings2,
|
||||
MessageSquare, Users, CreditCard, BookUser, Download, LogOut,
|
||||
} from 'lucide-react'
|
||||
|
||||
function NavLink({
|
||||
href, label, icon: Icon, badge, active,
|
||||
}: {
|
||||
href: string
|
||||
label: string
|
||||
icon: React.ElementType
|
||||
badge?: number
|
||||
active: boolean
|
||||
}) {
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
className={cn(
|
||||
'flex items-center justify-between mx-2 px-3 py-2 rounded-md text-sm transition-colors',
|
||||
active
|
||||
? 'bg-[var(--sidebar-primary)]/15 text-[var(--sidebar-primary)]'
|
||||
: 'text-[var(--sidebar-foreground)]/70 hover:bg-[var(--sidebar-accent)] hover:text-[var(--sidebar-foreground)]',
|
||||
)}
|
||||
>
|
||||
<span className="flex items-center gap-2.5">
|
||||
<Icon className={cn('w-4 h-4', active ? 'text-[var(--sidebar-primary)]' : 'text-[var(--sidebar-foreground)]/50')} />
|
||||
{label}
|
||||
</span>
|
||||
{badge && badge > 0 ? (
|
||||
<span className="text-[10px] bg-red-500 text-white rounded-full w-4 h-4 flex items-center justify-center font-medium">
|
||||
{badge}
|
||||
</span>
|
||||
) : null}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
interface AppSidebarProps {
|
||||
member: SpaceMember & { spaces: Space }
|
||||
taskBadge?: number
|
||||
commsBadge?: number
|
||||
paymentBadge?: number
|
||||
}
|
||||
|
||||
export function AppSidebar({ member, taskBadge = 0, commsBadge = 0, paymentBadge = 0 }: AppSidebarProps) {
|
||||
const pathname = usePathname()
|
||||
const space = member.spaces
|
||||
const initials = member.display_name?.split(' ').map((n) => n[0]).join('').toUpperCase().slice(0, 2) || '??'
|
||||
const isAdmin = member.role === 'admin' || member.role === 'board'
|
||||
const [drawerOpen, setDrawerOpen] = useState(false)
|
||||
|
||||
// Auto-close the drawer whenever the route changes
|
||||
useEffect(() => {
|
||||
setDrawerOpen(false)
|
||||
}, [pathname])
|
||||
|
||||
function isActive(href) {
|
||||
return pathname === href || pathname.startsWith(href + '/')
|
||||
}
|
||||
|
||||
async function handleSignOut() {
|
||||
await signOut()
|
||||
}
|
||||
|
||||
const navJsx = (
|
||||
<>
|
||||
<div className="px-5 py-5 border-b border-[var(--sidebar-border)]">
|
||||
<Link href="/dashboard" className="flex items-center gap-2.5">
|
||||
<div className="w-8 h-8 rounded bg-[var(--sidebar-primary)] flex items-center justify-center">
|
||||
<span className="text-white font-mono text-xs font-bold">{'{'}hs{'}'}</span>
|
||||
</div>
|
||||
</Link>
|
||||
<p className="text-[var(--sidebar-foreground)]/60 text-xs mt-1.5 font-mono truncate">{space?.name || 'My Space'}</p>
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 py-4 overflow-y-auto">
|
||||
<div className="px-3 mb-1">
|
||||
<p className="text-[10px] uppercase tracking-widest text-[var(--sidebar-foreground)]/30 px-2 py-1 font-mono">Workspace</p>
|
||||
</div>
|
||||
<NavLink href="/dashboard" label="Dashboard" icon={LayoutDashboard} active={isActive('/dashboard')} />
|
||||
<NavLink href="/tasks" label="Tasks and Chores" icon={ListChecks} active={isActive('/tasks')} badge={taskBadge} />
|
||||
<NavLink href="/projects" label="Projects" icon={FolderKanban} active={isActive('/projects')} />
|
||||
<NavLink href="/ops" label="Ops and Facilities" icon={Settings2} active={isActive('/ops')} />
|
||||
<NavLink href="/comms" label="Comms" icon={MessageSquare} active={isActive('/comms')} badge={commsBadge} />
|
||||
|
||||
<div className="px-3 mt-4 mb-1">
|
||||
<p className="text-[10px] uppercase tracking-widest text-[var(--sidebar-foreground)]/30 px-2 py-1 font-mono">People</p>
|
||||
</div>
|
||||
<NavLink href="/members" label="Members" icon={Users} active={isActive('/members')} />
|
||||
<NavLink href="/payments" label="Payments" icon={CreditCard} active={isActive('/payments')} badge={paymentBadge} />
|
||||
<NavLink href="/contacts" label="Contacts" icon={BookUser} active={isActive('/contacts')} />
|
||||
|
||||
{isAdmin && (
|
||||
<>
|
||||
<div className="px-3 mt-4 mb-1">
|
||||
<p className="text-[10px] uppercase tracking-widest text-[var(--sidebar-foreground)]/30 px-2 py-1 font-mono">Admin</p>
|
||||
</div>
|
||||
<NavLink href="/import" label="Import and Sync" icon={Download} active={isActive('/import')} />
|
||||
<NavLink href="/settings" label="Settings" icon={Settings2} active={isActive('/settings')} />
|
||||
</>
|
||||
)}
|
||||
</nav>
|
||||
|
||||
<div className="border-t border-[var(--sidebar-border)] p-3">
|
||||
<button
|
||||
onClick={handleSignOut}
|
||||
className="w-full flex items-center gap-3 px-2 py-2 rounded-md hover:bg-[var(--sidebar-accent)] transition-colors group"
|
||||
>
|
||||
<div className="w-7 h-7 rounded bg-[var(--sidebar-primary)]/80 flex items-center justify-center text-white text-xs font-bold shrink-0">
|
||||
{initials}
|
||||
</div>
|
||||
<div className="flex-1 text-left min-w-0">
|
||||
<p className="text-[var(--sidebar-foreground)] text-xs font-medium truncate">{member.display_name}</p>
|
||||
<p className="text-[var(--sidebar-foreground)]/40 text-[10px] font-mono">{member.role} and {member.tier}</p>
|
||||
</div>
|
||||
<LogOut className="w-3.5 h-3.5 text-[var(--sidebar-foreground)]/30" />
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<aside className="hidden md:flex w-[230px] shrink-0 bg-[var(--sidebar)] flex-col h-screen sticky top-0 border-r border-[var(--sidebar-border)]">
|
||||
{navJsx}
|
||||
</aside>
|
||||
|
||||
<div className="md:hidden fixed top-0 left-0 right-0 z-40 h-[52px] bg-[var(--sidebar)] border-b border-[var(--sidebar-border)] flex items-center justify-between px-4">
|
||||
<Link href="/dashboard" className="flex items-center gap-2">
|
||||
<div className="w-7 h-7 rounded bg-[var(--sidebar-primary)] flex items-center justify-center">
|
||||
<span className="text-white font-mono text-[10px] font-bold">hs</span>
|
||||
</div>
|
||||
<span className="text-[var(--sidebar-foreground)]/60 text-xs font-mono truncate max-w-[150px]">{space?.name}</span>
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => setDrawerOpen(true)}
|
||||
className="text-[var(--sidebar-foreground)]/70 hover:text-[var(--sidebar-foreground)] p-1"
|
||||
aria-label="Open navigation menu"
|
||||
>
|
||||
<Menu className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{drawerOpen && (
|
||||
<div className="md:hidden fixed inset-0 z-50 flex">
|
||||
<div className="absolute inset-0 bg-black/50" onClick={() => setDrawerOpen(false)} />
|
||||
<div className="relative w-[280px] bg-[var(--sidebar)] flex flex-col h-full shadow-xl overflow-y-auto">
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-[var(--sidebar-border)]">
|
||||
<span className="text-[var(--sidebar-foreground)] text-sm font-mono font-bold">{space?.name}</span>
|
||||
<button onClick={() => setDrawerOpen(false)} className="text-[var(--sidebar-foreground)]/50 hover:text-[var(--sidebar-foreground)]">
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
{navJsx}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
`
|
||||
|
||||
fs.writeFileSync('/vercel/share/v0-project/components/app-sidebar.tsx', content, 'utf8')
|
||||
console.log('Done. Wrote', content.length, 'bytes')
|
||||
@@ -0,0 +1,180 @@
|
||||
import fs from 'fs'
|
||||
|
||||
const content = `'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { signOut } from '@/lib/auth-actions'
|
||||
import type { SpaceMember, Space } from '@/lib/types'
|
||||
import {
|
||||
Menu, X, LayoutDashboard, ListChecks, FolderKanban, Settings2,
|
||||
MessageSquare, Users, CreditCard, BookUser, Download, LogOut,
|
||||
} from 'lucide-react'
|
||||
|
||||
function NavLink({
|
||||
href, label, icon: Icon, badge, active,
|
||||
}: {
|
||||
href: string
|
||||
label: string
|
||||
icon: React.ElementType
|
||||
badge?: number
|
||||
active: boolean
|
||||
}) {
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
className={cn(
|
||||
'flex items-center justify-between mx-2 px-3 py-2 rounded-md text-sm transition-colors',
|
||||
active
|
||||
? 'bg-[var(--sidebar-primary)]/15 text-[var(--sidebar-primary)]'
|
||||
: 'text-[var(--sidebar-foreground)]/70 hover:bg-[var(--sidebar-accent)] hover:text-[var(--sidebar-foreground)]',
|
||||
)}
|
||||
>
|
||||
<span className="flex items-center gap-2.5">
|
||||
<Icon className={cn('w-4 h-4', active ? 'text-[var(--sidebar-primary)]' : 'text-[var(--sidebar-foreground)]/50')} />
|
||||
{label}
|
||||
</span>
|
||||
{badge && badge > 0 ? (
|
||||
<span className="text-[10px] bg-red-500 text-white rounded-full w-4 h-4 flex items-center justify-center font-medium">
|
||||
{badge}
|
||||
</span>
|
||||
) : null}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
interface AppSidebarProps {
|
||||
member: SpaceMember & { spaces: Space }
|
||||
taskBadge?: number
|
||||
commsBadge?: number
|
||||
paymentBadge?: number
|
||||
}
|
||||
|
||||
export function AppSidebar({ member, taskBadge = 0, commsBadge = 0, paymentBadge = 0 }: AppSidebarProps) {
|
||||
const pathname = usePathname()
|
||||
const space = member.spaces
|
||||
const initials = member.display_name?.split(' ').map((n: string) => n[0]).join('').toUpperCase().slice(0, 2) || '??'
|
||||
const isAdmin = member.role === 'admin' || member.role === 'board'
|
||||
const [drawerOpen, setDrawerOpen] = useState(false)
|
||||
|
||||
// Auto-close the drawer whenever the route changes
|
||||
useEffect(() => {
|
||||
setDrawerOpen(false)
|
||||
}, [pathname])
|
||||
|
||||
function isActive(href: string) {
|
||||
return pathname === href || pathname.startsWith(href + '/')
|
||||
}
|
||||
|
||||
async function handleSignOut() {
|
||||
await signOut()
|
||||
}
|
||||
|
||||
// navJsx is JSX stored in a variable — NOT a nested component.
|
||||
// This avoids React remounting it on every parent render, which was
|
||||
// causing the drawer to unmount before Next.js <Link> could navigate.
|
||||
const navJsx = (
|
||||
<>
|
||||
<div className="px-5 py-5 border-b border-[var(--sidebar-border)]">
|
||||
<Link href="/dashboard" className="flex items-center gap-2.5">
|
||||
<div className="w-8 h-8 rounded bg-[var(--sidebar-primary)] flex items-center justify-center">
|
||||
<span className="text-white font-mono text-xs font-bold">{'{'}<span>hs</span>{'}'}</span>
|
||||
</div>
|
||||
</Link>
|
||||
<p className="text-[var(--sidebar-foreground)]/60 text-xs mt-1.5 font-mono truncate">{space?.name || 'My Space'}</p>
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 py-4 overflow-y-auto">
|
||||
<div className="px-3 mb-1">
|
||||
<p className="text-[10px] uppercase tracking-widest text-[var(--sidebar-foreground)]/30 px-2 py-1 font-mono">Workspace</p>
|
||||
</div>
|
||||
<NavLink href="/dashboard" label="Dashboard" icon={LayoutDashboard} active={isActive('/dashboard')} />
|
||||
<NavLink href="/tasks" label="Tasks & Chores" icon={ListChecks} active={isActive('/tasks')} badge={taskBadge} />
|
||||
<NavLink href="/projects" label="Projects" icon={FolderKanban} active={isActive('/projects')} />
|
||||
<NavLink href="/ops" label="Ops & Facilities" icon={Settings2} active={isActive('/ops')} />
|
||||
<NavLink href="/comms" label="Comms" icon={MessageSquare} active={isActive('/comms')} badge={commsBadge} />
|
||||
|
||||
<div className="px-3 mt-4 mb-1">
|
||||
<p className="text-[10px] uppercase tracking-widest text-[var(--sidebar-foreground)]/30 px-2 py-1 font-mono">People</p>
|
||||
</div>
|
||||
<NavLink href="/members" label="Members" icon={Users} active={isActive('/members')} />
|
||||
<NavLink href="/payments" label="Payments" icon={CreditCard} active={isActive('/payments')} badge={paymentBadge} />
|
||||
<NavLink href="/contacts" label="Contacts" icon={BookUser} active={isActive('/contacts')} />
|
||||
|
||||
{isAdmin && (
|
||||
<>
|
||||
<div className="px-3 mt-4 mb-1">
|
||||
<p className="text-[10px] uppercase tracking-widest text-[var(--sidebar-foreground)]/30 px-2 py-1 font-mono">Admin</p>
|
||||
</div>
|
||||
<NavLink href="/import" label="Import / Sync" icon={Download} active={isActive('/import')} />
|
||||
<NavLink href="/settings" label="Settings" icon={Settings2} active={isActive('/settings')} />
|
||||
</>
|
||||
)}
|
||||
</nav>
|
||||
|
||||
<div className="border-t border-[var(--sidebar-border)] p-3">
|
||||
<button
|
||||
onClick={handleSignOut}
|
||||
className="w-full flex items-center gap-3 px-2 py-2 rounded-md hover:bg-[var(--sidebar-accent)] transition-colors group"
|
||||
>
|
||||
<div className="w-7 h-7 rounded bg-[var(--sidebar-primary)]/80 flex items-center justify-center text-white text-xs font-bold shrink-0">
|
||||
{initials}
|
||||
</div>
|
||||
<div className="flex-1 text-left min-w-0">
|
||||
<p className="text-[var(--sidebar-foreground)] text-xs font-medium truncate">{member.display_name}</p>
|
||||
<p className="text-[var(--sidebar-foreground)]/40 text-[10px] font-mono">{member.role} · {member.tier}</p>
|
||||
</div>
|
||||
<LogOut className="w-3.5 h-3.5 text-[var(--sidebar-foreground)]/30" />
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Desktop sidebar */}
|
||||
<aside className="hidden md:flex w-[230px] shrink-0 bg-[var(--sidebar)] flex-col h-screen sticky top-0 border-r border-[var(--sidebar-border)]">
|
||||
{navJsx}
|
||||
</aside>
|
||||
|
||||
{/* Mobile: fixed top bar */}
|
||||
<div className="md:hidden fixed top-0 left-0 right-0 z-40 h-[52px] bg-[var(--sidebar)] border-b border-[var(--sidebar-border)] flex items-center justify-between px-4">
|
||||
<Link href="/dashboard" className="flex items-center gap-2">
|
||||
<div className="w-7 h-7 rounded bg-[var(--sidebar-primary)] flex items-center justify-center">
|
||||
<span className="text-white font-mono text-[10px] font-bold">hs</span>
|
||||
</div>
|
||||
<span className="text-[var(--sidebar-foreground)]/60 text-xs font-mono truncate max-w-[150px]">{space?.name}</span>
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => setDrawerOpen(true)}
|
||||
className="text-[var(--sidebar-foreground)]/70 hover:text-[var(--sidebar-foreground)] p-1"
|
||||
aria-label="Open navigation menu"
|
||||
>
|
||||
<Menu className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Mobile: slide-out drawer */}
|
||||
{drawerOpen && (
|
||||
<div className="md:hidden fixed inset-0 z-50 flex">
|
||||
<div className="absolute inset-0 bg-black/50" onClick={() => setDrawerOpen(false)} aria-hidden="true" />
|
||||
<div className="relative w-[280px] bg-[var(--sidebar)] flex flex-col h-full shadow-xl overflow-y-auto">
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-[var(--sidebar-border)]">
|
||||
<span className="text-[var(--sidebar-foreground)] text-sm font-mono font-bold">{space?.name}</span>
|
||||
<button onClick={() => setDrawerOpen(false)} className="text-[var(--sidebar-foreground)]/50 hover:text-[var(--sidebar-foreground)]" aria-label="Close menu">
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
{navJsx}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
`
|
||||
|
||||
fs.writeFileSync('/vercel/share/v0-project/components/app-sidebar.tsx', content, 'utf8')
|
||||
console.log('Wrote app-sidebar.tsx successfully, length:', content.length)
|
||||
Reference in New Issue
Block a user