Skip to content

Commit bc98ab6

Browse files
commrelayunitC-3PO
andauthored
Fix RefHub UI issues 130-133 (#134)
* Fix RefHub UI issue batch * Keep collaborator suggestions non-disruptive * Show collaborator lookup errors inline --------- Co-authored-by: C-3PO <c3po.bot@gmail.com>
1 parent b0fbb6e commit bc98ab6

5 files changed

Lines changed: 166 additions & 95 deletions

File tree

src/components/publications/PublicationDialog.tsx

Lines changed: 63 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { useHotkeys } from '@/hooks/useKeyboardNavigation';
77
import { useKeyboardContext } from '@/contexts/KeyboardContext';
88
import { KbdHint } from '@/components/ui/KbdHint';
99
import { formatTimeAgo } from '@/lib/utils';
10-
import { Maximize, Minimize, Save, X, Plus, Loader2, Upload, CheckCircle2, AlertCircle } from 'lucide-react';
10+
import { Maximize, Minimize, Save, X, Plus, Loader2, Upload, CheckCircle2, AlertCircle, ExternalLink } from 'lucide-react';
1111
import {
1212
Dialog,
1313
DialogContent,
@@ -34,6 +34,19 @@ import { useAuth } from '@/hooks/useAuth';
3434
import { uploadPublicationDrivePdf, uploadVaultPublicationDrivePdf } from '@/lib/pdfUpload';
3535
import { fetchGoogleDriveStatus, GoogleDriveStatus } from '@/lib/googleDrive';
3636

37+
38+
function getValidHttpUrl(value: string | null | undefined): string | null {
39+
const trimmed = value?.trim();
40+
if (!trimmed) return null;
41+
42+
try {
43+
const url = new URL(trimmed);
44+
return url.protocol === 'http:' || url.protocol === 'https:' ? url.toString() : null;
45+
} catch {
46+
return null;
47+
}
48+
}
49+
3750
interface PublicationDialogProps {
3851
open: boolean;
3952
onOpenChange: (open: boolean) => void;
@@ -137,6 +150,28 @@ export function PublicationDialog({
137150
const [driveUploadError, setDriveUploadError] = useState('');
138151
const [driveStatus, setDriveStatus] = useState<GoogleDriveStatus | null>(null);
139152
const [driveStatusLoading, setDriveStatusLoading] = useState(false);
153+
154+
const renderOpenLinkButton = useCallback((value: string, label: string) => {
155+
const href = getValidHttpUrl(value);
156+
157+
return (
158+
<Button
159+
type="button"
160+
variant="outline"
161+
size="icon"
162+
disabled={!href}
163+
aria-label={`Open ${label} in a new tab`}
164+
title={href ? `Open ${label}` : `Enter a valid http(s) URL to open ${label}`}
165+
onClick={() => {
166+
if (!href) return;
167+
window.open(href, '_blank', 'noopener,noreferrer');
168+
}}
169+
className="shrink-0"
170+
>
171+
<ExternalLink className="w-3.5 h-3.5" />
172+
</Button>
173+
);
174+
}, []);
140175
const [driveStatusError, setDriveStatusError] = useState('');
141176
const drivePdfFileInputRef = useRef<HTMLInputElement | null>(null);
142177
const driveUploadResetRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -1077,32 +1112,38 @@ export function PublicationDialog({
10771112
</div>
10781113
<div className="space-y-1 sm:space-y-2 w-full overflow-hidden">
10791114
<Label htmlFor="url" className="font-semibold font-mono text-sm block">url</Label>
1080-
<Input
1081-
id="url"
1082-
value={formData.url}
1083-
onChange={(e) => {
1084-
setFormData({ ...formData, url: e.target.value });
1085-
trackFieldModification('url');
1086-
}}
1087-
placeholder="https://..."
1088-
className="font-mono text-xs sm:text-sm w-full break-all h-9 sm:h-10 box-border"
1089-
/>
1115+
<div className="flex gap-2">
1116+
<Input
1117+
id="url"
1118+
value={formData.url}
1119+
onChange={(e) => {
1120+
setFormData({ ...formData, url: e.target.value });
1121+
trackFieldModification('url');
1122+
}}
1123+
placeholder="https://..."
1124+
className="font-mono text-xs sm:text-sm w-full break-all h-9 sm:h-10 box-border"
1125+
/>
1126+
{renderOpenLinkButton(formData.url, 'publication URL')}
1127+
</div>
10901128
</div>
10911129
</div>
10921130

10931131
{/* PDF URL */}
10941132
<div className="space-y-1 sm:space-y-2 w-full box-border overflow-hidden">
10951133
<Label htmlFor="pdf_url" className="font-semibold font-mono text-sm block">publisher_pdf</Label>
1096-
<Input
1097-
id="pdf_url"
1098-
value={formData.pdf_url}
1099-
onChange={(e) => {
1100-
setFormData({ ...formData, pdf_url: e.target.value });
1101-
trackFieldModification('pdf_url');
1102-
}}
1103-
placeholder="link_to_pdf"
1104-
className="font-mono text-xs sm:text-sm w-full break-all h-9 sm:h-10 box-border"
1105-
/>
1134+
<div className="flex gap-2">
1135+
<Input
1136+
id="pdf_url"
1137+
value={formData.pdf_url}
1138+
onChange={(e) => {
1139+
setFormData({ ...formData, pdf_url: e.target.value });
1140+
trackFieldModification('pdf_url');
1141+
}}
1142+
placeholder="link_to_pdf"
1143+
className="font-mono text-xs sm:text-sm w-full break-all h-9 sm:h-10 box-border"
1144+
/>
1145+
{renderOpenLinkButton(formData.pdf_url, 'publisher PDF')}
1146+
</div>
11061147
</div>
11071148

11081149
{/* Drive PDF */}
@@ -1126,6 +1167,7 @@ export function PublicationDialog({
11261167
className="hidden"
11271168
onChange={(e) => void handleDrivePdfFileSelected(e.target.files?.[0] ?? null)}
11281169
/>
1170+
{renderOpenLinkButton(drivePdfInput, 'Google Drive PDF')}
11291171
<Button
11301172
type="button"
11311173
variant="outline"

src/components/vaults/VaultDialog.tsx

Lines changed: 86 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,6 @@ import { Input } from '@/components/ui/input';
1616
import { Label } from '@/components/ui/label';
1717
import { Textarea } from '@/components/ui/textarea';
1818
import { Badge } from '@/components/ui/badge';
19-
import { Command, CommandEmpty, CommandGroup, CommandItem, CommandList } from '@/components/ui/command';
20-
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
2119
import { KbdHint } from '@/components/ui/KbdHint';
2220
import {
2321
Select,
@@ -107,6 +105,7 @@ export function VaultDialog({ open, onOpenChange, vault, initialRequestId, onSav
107105
const [suggestionsOpen, setSuggestionsOpen] = useState(false);
108106
const [loadingSuggestions, setLoadingSuggestions] = useState(false);
109107
const [selectedProfile, setSelectedProfile] = useState<UserSuggestion | null>(null);
108+
const [shareUserError, setShareUserError] = useState('');
110109
const [sharePermission, setSharePermission] = useState<'viewer' | 'editor'>('viewer');
111110
const [publicSlug, setPublicSlug] = useState('');
112111
const [slugAvailable, setSlugAvailable] = useState<boolean | null>(null);
@@ -334,12 +333,14 @@ export function VaultDialog({ open, onOpenChange, vault, initialRequestId, onSav
334333
const handleSelectUserSuggestion = useCallback((profile: UserSuggestion) => {
335334
setSelectedProfile(profile);
336335
setEmail(profile.email || '');
336+
setShareUserError('');
337337
setSuggestionsOpen(false);
338338
}, []);
339339

340340
const handleShareEmailChange = useCallback((value: string) => {
341341
setEmail(value);
342342
setSelectedProfile(null);
343+
setShareUserError('');
343344
}, []);
344345

345346
useEffect(() => {
@@ -672,44 +673,62 @@ export function VaultDialog({ open, onOpenChange, vault, initialRequestId, onSav
672673

673674
setSaving(true);
674675
try {
675-
// Use the selected autocomplete profile when available; otherwise fall back to email lookup.
676+
// Use the selected autocomplete profile when available; otherwise require an exact
677+
// platform-user match by email or username before inserting a share.
676678
let profile = selectedProfile;
679+
const query = email.trim().toLowerCase();
677680
if (!profile) {
678-
const { data: profileData } = await supabase
681+
const { data: emailMatch, error: emailLookupError } = await supabase
679682
.from('profiles')
680683
.select('user_id, display_name, username, email')
681-
.eq('email', email.trim().toLowerCase())
684+
.eq('email', query)
682685
.maybeSingle();
683-
profile = profileData as UserSuggestion | null;
686+
687+
if (emailLookupError) throw emailLookupError;
688+
689+
if (emailMatch) {
690+
profile = emailMatch as UserSuggestion;
691+
} else {
692+
const { data: usernameMatch, error: usernameLookupError } = await supabase
693+
.from('profiles')
694+
.select('user_id, display_name, username, email')
695+
.eq('username', query)
696+
.maybeSingle();
697+
698+
if (usernameLookupError) throw usernameLookupError;
699+
profile = usernameMatch as UserSuggestion | null;
700+
}
701+
}
702+
703+
if (!profile?.user_id) {
704+
setShareUserError('// error no user found');
705+
return;
684706
}
685707

686708
const shareData: {
687709
vault_id: string;
688710
shared_with_email: string;
689711
shared_by: string;
690712
role: 'viewer' | 'editor';
691-
shared_with_user_id?: string;
713+
shared_with_user_id: string;
692714
shared_with_name?: string | null;
693715
} = {
694716
vault_id: vault.id,
695-
shared_with_email: email.trim().toLowerCase(),
717+
shared_with_email: (profile.email || query).toLowerCase(),
696718
shared_by: user.id,
697719
role: sharePermission,
720+
shared_with_user_id: profile.user_id,
721+
shared_with_name: profile.display_name || profile.username || profile.email,
698722
};
699723

700-
// If we found a profile, add the user_id and display name
701-
if (profile) {
702-
shareData.shared_with_user_id = profile.user_id;
703-
shareData.shared_with_name = profile.display_name || profile.username || profile.email;
704-
}
705-
706724
const { error } = await supabase.from('vault_shares').insert(shareData);
707725

708726
if (error) throw error;
709727

710728
toast({ title: 'user_added ✨' });
711729
setEmail('');
712730
setSelectedProfile(null);
731+
setShareUserError('');
713732
setUserSuggestions([]);
714733
setSuggestionsOpen(false);
715734
setSharePermission('viewer');
@@ -1054,59 +1073,52 @@ export function VaultDialog({ open, onOpenChange, vault, initialRequestId, onSav
10541073

10551074
<div className="space-y-3">
10561075
<div className="flex gap-2">
1057-
<Popover open={suggestionsOpen} onOpenChange={setSuggestionsOpen}>
1058-
<PopoverTrigger asChild>
1059-
<div className="relative flex-1">
1060-
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
1061-
<Input
1062-
type="text"
1063-
value={email}
1064-
onChange={(e) => handleShareEmailChange(e.target.value)}
1065-
onFocus={() => {
1066-
if (email.trim().length >= 2) setSuggestionsOpen(true);
1067-
}}
1068-
placeholder="name, username, or email"
1069-
className="pl-10 font-mono text-sm"
1070-
autoComplete="off"
1071-
autoCapitalize="none"
1072-
spellCheck={false}
1073-
/>
1076+
<div className="relative flex-1">
1077+
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground pointer-events-none" />
1078+
<Input
1079+
type="text"
1080+
value={email}
1081+
onChange={(e) => handleShareEmailChange(e.target.value)}
1082+
onFocus={() => {
1083+
if (email.trim().length >= 2) setSuggestionsOpen(true);
1084+
}}
1085+
onBlur={() => window.setTimeout(() => setSuggestionsOpen(false), 120)}
1086+
placeholder="name, username, or email"
1087+
className="pl-10 font-mono text-sm"
1088+
autoComplete="off"
1089+
autoCapitalize="none"
1090+
spellCheck={false}
1091+
/>
1092+
{suggestionsOpen && (loadingSuggestions || userSuggestions.length > 0) && (
1093+
<div className="absolute left-0 right-0 top-[calc(100%+0.25rem)] z-50 rounded-md border border-border bg-popover p-1 text-popover-foreground shadow-md">
1094+
{loadingSuggestions ? (
1095+
<div className="px-3 py-2 text-sm text-muted-foreground font-mono">loading_users…</div>
1096+
) : (
1097+
<div className="space-y-1">
1098+
<div className="px-2 py-1 text-xs uppercase tracking-wide text-muted-foreground font-mono">matching_users</div>
1099+
{userSuggestions.map((profile) => (
1100+
<button
1101+
key={profile.user_id}
1102+
type="button"
1103+
onMouseDown={(event) => event.preventDefault()}
1104+
onClick={() => handleSelectUserSuggestion(profile)}
1105+
className="flex w-full flex-col rounded-sm px-2 py-2 text-left font-mono text-sm hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus:outline-none"
1106+
>
1107+
<span className="truncate">
1108+
{profile.display_name || profile.username || profile.email}
1109+
</span>
1110+
{profile.email && (
1111+
<span className="text-xs text-muted-foreground truncate">
1112+
{profile.email}
1113+
</span>
1114+
)}
1115+
</button>
1116+
))}
1117+
</div>
1118+
)}
10741119
</div>
1075-
</PopoverTrigger>
1076-
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0" align="start">
1077-
<Command shouldFilter={false}>
1078-
<CommandList>
1079-
{loadingSuggestions ? (
1080-
<CommandEmpty>loading_users…</CommandEmpty>
1081-
) : userSuggestions.length === 0 ? (
1082-
<CommandEmpty>no_matching_users</CommandEmpty>
1083-
) : (
1084-
<CommandGroup heading="matching_users">
1085-
{userSuggestions.map((profile) => (
1086-
<CommandItem
1087-
key={profile.user_id}
1088-
value={`${profile.email || ''} ${profile.display_name || ''} ${profile.username || ''}`}
1089-
onSelect={() => handleSelectUserSuggestion(profile)}
1090-
className="font-mono text-sm"
1091-
>
1092-
<div className="flex flex-col min-w-0">
1093-
<span className="truncate">
1094-
{profile.display_name || profile.username || profile.email}
1095-
</span>
1096-
{profile.email && (
1097-
<span className="text-xs text-muted-foreground truncate">
1098-
{profile.email}
1099-
</span>
1100-
)}
1101-
</div>
1102-
</CommandItem>
1103-
))}
1104-
</CommandGroup>
1105-
)}
1106-
</CommandList>
1107-
</Command>
1108-
</PopoverContent>
1109-
</Popover>
1120+
)}
1121+
</div>
11101122
<Select value={sharePermission} onValueChange={(value: 'viewer' | 'editor') => setSharePermission(value)}>
11111123
<SelectTrigger className="w-[130px] font-mono text-sm">
11121124
<SelectValue />
@@ -1124,15 +1136,19 @@ export function VaultDialog({ open, onOpenChange, vault, initialRequestId, onSav
11241136
type="button"
11251137
variant="outline"
11261138
onClick={handleShareWithUser}
1127-
disabled={saving || !email.trim()}
1139+
disabled={saving || !email.trim() || loadingSuggestions}
11281140
className="font-mono"
11291141
>
11301142
add
11311143
</Button>
11321144
</div>
1133-
<p className="text-xs text-muted-foreground font-mono">
1134-
// {sharePermission === 'viewer' ? 'can_view_publications' : 'can_view_and_edit_publications'}
1135-
</p>
1145+
{shareUserError ? (
1146+
<p className="text-xs text-destructive font-mono">{shareUserError}</p>
1147+
) : (
1148+
<p className="text-xs text-muted-foreground font-mono">
1149+
// choose an existing RefHub user; {sharePermission === 'viewer' ? 'can_view_publications' : 'can_view_and_edit_publications'}
1150+
</p>
1151+
)}
11361152
</div>
11371153

11381154
{shares.length > 0 && (

src/pages/Dashboard.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1600,11 +1600,13 @@ export default function Dashboard() {
16001600

16011601
if (error) throw error;
16021602

1603-
// Replace temporary vault with real one from database
1603+
// Replace temporary vault with real one from database and go straight to it.
16041604
if (newVault) {
1605+
const createdVault = newVault as Vault;
16051606
setVaults(prev => prev.map(v =>
1606-
v.id === tempId ? newVault as Vault : v
1607+
v.id === tempId ? createdVault : v
16071608
).sort((a, b) => a.name.localeCompare(b.name)));
1609+
navigate(`/vault/${createdVault.id}`);
16081610
}
16091611

16101612
toast({ title: 'vault_created ✨' });

src/pages/ProfileEdit.tsx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -202,7 +202,11 @@ export default function ProfileEdit() {
202202
};
203203

204204
const handleBack = () => {
205-
navigate(resolvePostAuthRedirect(profile, { fallbackPath: '/dashboard' }));
205+
const target = resolvePostAuthRedirect(
206+
profile ? { ...profile, is_setup: true } : profile,
207+
{ fallbackPath: '/dashboard' }
208+
);
209+
navigate(target);
206210
};
207211

208212
const handleTabChange = (value: string) => {
@@ -218,7 +222,7 @@ export default function ProfileEdit() {
218222
<div className="mx-auto max-w-6xl p-4 sm:p-8">
219223
{/* Header */}
220224
<div className="mb-8 flex flex-wrap items-start gap-3 sm:items-center sm:gap-4">
221-
<Button variant="ghost" size="icon" onClick={handleBack} className="shrink-0">
225+
<Button variant="ghost" size="icon" onClick={handleBack} className="shrink-0" aria-label="Back to app">
222226
<ArrowLeft className="w-5 h-5" />
223227
</Button>
224228
<div className="min-w-0 flex-1">

0 commit comments

Comments
 (0)