feat: P2.9 VNCtalk + P2.10 Collabora + P2.11 Calendar Enhancements + P2.13 VNCdirectory Admin
- P2.9: VNCtalk video meeting — create/update meeting from event modal, 'Join Meeting' link in event detail. Admin config vnctalkServerUrl. - P2.10: Collabora online editing — 'Edit with Collabora' for office files, WOPI discovery + edit URL. Admin config collaboraServerUrl. - P2.11: Calendar enhancements — clickable links in descriptions, participant contact popover, Reply/Reply All from event, timezone picker, map links for locations. - P2.13: VNCdirectory IDP admin panel — Connection, SAML/IDP, LDAP, Authentication, Federated Apps configuration. Secret masking on display.
This commit is contained in:
@@ -6,7 +6,7 @@ import { createPortal } from "react-dom";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
X, Clock, MapPin, Video, Users, Repeat, Bell, AlignLeft,
|
||||
Pencil, Trash2, Copy, Send, Check,
|
||||
Pencil, Trash2, Copy, Send, Check, ExternalLink, Globe,
|
||||
} from "lucide-react";
|
||||
import { format, isSameDay } from "date-fns";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -107,6 +107,25 @@ function getRecurrenceLabel(event: CalendarEvent, t: ReturnType<typeof useTransl
|
||||
return buildRecurrenceSummary(event.recurrenceRules[0], t, locale);
|
||||
}
|
||||
|
||||
const URL_REGEX = /(https?:\/\/[^\s<]+[^\s<.,;:!?'")\]}>])/g;
|
||||
|
||||
function linkifyText(text: string): (string | { url: string })[] {
|
||||
const parts: (string | { url: string })[] = [];
|
||||
let lastIndex = 0;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = URL_REGEX.exec(text)) !== null) {
|
||||
if (match.index > lastIndex) {
|
||||
parts.push(text.slice(lastIndex, match.index));
|
||||
}
|
||||
parts.push({ url: match[1] });
|
||||
lastIndex = match.index + match[1].length;
|
||||
}
|
||||
if (lastIndex < text.length) {
|
||||
parts.push(text.slice(lastIndex));
|
||||
}
|
||||
return parts;
|
||||
}
|
||||
|
||||
export function EventDetailPopover({
|
||||
event,
|
||||
calendar,
|
||||
@@ -383,21 +402,34 @@ export function EventDetailPopover({
|
||||
{locationName && (
|
||||
<div className="flex items-start gap-2.5">
|
||||
<MapPin className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
|
||||
{/^https?:\/\//i.test(locationName) ? (
|
||||
<a
|
||||
href={locationName}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-sm text-primary hover:underline truncate"
|
||||
title={locationName}
|
||||
>
|
||||
{(() => {
|
||||
try { return new URL(locationName).hostname; } catch { return locationName; }
|
||||
})()}
|
||||
</a>
|
||||
) : (
|
||||
<span className="text-sm text-foreground">{locationName}</span>
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
{/^https?:\/\//i.test(locationName) ? (
|
||||
<a
|
||||
href={locationName}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-sm text-primary hover:underline truncate block"
|
||||
title={locationName}
|
||||
>
|
||||
{(() => {
|
||||
try { return new URL(locationName).hostname; } catch { return locationName; }
|
||||
})()}
|
||||
</a>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-sm text-foreground">{locationName}</span>
|
||||
<a
|
||||
href={`https://maps.google.com/?q=${encodeURIComponent(locationName)}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-xs text-primary hover:underline mt-0.5 inline-flex items-center gap-1"
|
||||
>
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
View on Map
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -413,6 +445,8 @@ export function EventDetailPopover({
|
||||
title={virtualLocation}
|
||||
>
|
||||
{(() => {
|
||||
const isVncMeeting = event.links?.["vnctalk-meeting"];
|
||||
if (isVncMeeting) return "Join VNCtalk Meeting";
|
||||
try {
|
||||
return new URL(virtualLocation).hostname;
|
||||
} catch {
|
||||
@@ -423,6 +457,30 @@ export function EventDetailPopover({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* VNCtalk Meeting "Join" button (when meeting via links) */}
|
||||
{!virtualLocation && event.links?.["vnctalk-meeting"] && (
|
||||
<div className="flex items-start gap-2.5">
|
||||
<Video className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
|
||||
<a
|
||||
href={event.links["vnctalk-meeting"].href}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-sm text-primary hover:underline inline-flex items-center gap-1"
|
||||
>
|
||||
<ExternalLink className="w-3.5 h-3.5" />
|
||||
Join VNCtalk Meeting
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Timezone */}
|
||||
{!event.showWithoutTime && event.timeZone && (
|
||||
<div className="flex items-start gap-2.5">
|
||||
<Globe className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
|
||||
<span className="text-sm text-muted-foreground">{event.timeZone}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Participants */}
|
||||
{hasParticipants && (
|
||||
<div className="flex items-start gap-2.5">
|
||||
@@ -476,7 +534,21 @@ export function EventDetailPopover({
|
||||
<div className="flex items-start gap-2.5">
|
||||
<AlignLeft className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
|
||||
<p className="text-sm text-muted-foreground whitespace-pre-line line-clamp-3">
|
||||
{event.description}
|
||||
{linkifyText(event.description).map((part, i) =>
|
||||
typeof part === "string" ? (
|
||||
<span key={i}>{part}</span>
|
||||
) : (
|
||||
<a
|
||||
key={i}
|
||||
href={part.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
{part.url}
|
||||
</a>
|
||||
)
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useState, useEffect, useCallback, useRef, useMemo } from "react";
|
||||
import { useTranslations, useLocale } from "next-intl";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { X, Trash2, Check, Users, CalendarDays, Copy, Pencil, Clock, MapPin, Video, Repeat, Bell, AlignLeft, Plus, Eye, EyeOff } from "lucide-react";
|
||||
import { X, Trash2, Check, Users, CalendarDays, Copy, Pencil, Clock, MapPin, Video, Repeat, Bell, AlignLeft, Plus, Eye, EyeOff, ExternalLink, Reply, ReplyAll, Globe } from "lucide-react";
|
||||
import { format, parseISO, addHours, addDays, isSameDay } from "date-fns";
|
||||
import type { CalendarEvent, Calendar, CalendarParticipant, CalendarEventAlert, CalendarRecurrenceRule } from "@/lib/jmap/types";
|
||||
import { RecurrenceEditor, buildRecurrenceSummary, isSimpleRecurrenceRule } from "./recurrence-editor";
|
||||
@@ -26,6 +26,8 @@ import { generateUUID } from "@/lib/utils";
|
||||
import { useFormatEventDate } from "@/hooks/use-format-event-date";
|
||||
import { calendarHooks } from "@/lib/plugin-hooks";
|
||||
import type { ConflictWarning } from "@/lib/plugin-types";
|
||||
import { RecipientPopover } from "@/components/email/recipient-popover";
|
||||
import { useProTabStore } from "@/stores/pro-tab-store";
|
||||
|
||||
export interface PendingEventPreview {
|
||||
start: Date;
|
||||
@@ -64,6 +66,25 @@ function formatTimeInput(d: Date): string {
|
||||
return format(d, "HH:mm");
|
||||
}
|
||||
|
||||
const URL_REGEX = /(https?:\/\/[^\s<]+[^\s<.,;:!?'")\]}>])/g;
|
||||
|
||||
function linkifyText(text: string): (string | { url: string })[] {
|
||||
const parts: (string | { url: string })[] = [];
|
||||
let lastIndex = 0;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = URL_REGEX.exec(text)) !== null) {
|
||||
if (match.index > lastIndex) {
|
||||
parts.push(text.slice(lastIndex, match.index));
|
||||
}
|
||||
parts.push({ url: match[1] });
|
||||
lastIndex = match.index + match[1].length;
|
||||
}
|
||||
if (lastIndex < text.length) {
|
||||
parts.push(text.slice(lastIndex));
|
||||
}
|
||||
return parts;
|
||||
}
|
||||
|
||||
function buildDuration(startDate: Date, endDate: Date): string {
|
||||
const diffMs = endDate.getTime() - startDate.getTime();
|
||||
const totalMinutes = Math.max(0, Math.floor(diffMs / 60000));
|
||||
@@ -354,6 +375,13 @@ export function EventModal({
|
||||
const [sendInvitations, setSendInvitations] = useState(true);
|
||||
const [showFreeBusy, setShowFreeBusy] = useState(false);
|
||||
const participantInputRef = useRef<ParticipantInputHandle>(null);
|
||||
const [createVncMeeting, setCreateVncMeeting] = useState(false);
|
||||
const [meetingCreating, setMeetingCreating] = useState(false);
|
||||
const [timezone, setTimezone] = useState(() => {
|
||||
if (event?.timeZone) return event.timeZone;
|
||||
try { return Intl.DateTimeFormat().resolvedOptions().timeZone; } catch { return "UTC"; }
|
||||
});
|
||||
const openComposeTab = useProTabStore((s) => s.openComposeTab);
|
||||
|
||||
// Plugin transform: collect conflict warnings for the current event form.
|
||||
// Re-runs (debounced) whenever fields that affect scheduling change.
|
||||
@@ -435,7 +463,7 @@ export function EventModal({
|
||||
duration = buildDuration(start, end);
|
||||
}
|
||||
|
||||
const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
const timeZone = timezone;
|
||||
|
||||
const data: Partial<CalendarEvent> = {
|
||||
title: trimmedTitle,
|
||||
@@ -553,6 +581,54 @@ export function EventModal({
|
||||
data.organizerCalendarAddress = null;
|
||||
}
|
||||
|
||||
// VNCtalk meeting creation
|
||||
if (createVncMeeting && effectiveAttendees.length > 0 && !allDay) {
|
||||
setMeetingCreating(true);
|
||||
try {
|
||||
const vncRes = await fetch("/api/vnctalk/meeting", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
name: trimmedTitle,
|
||||
start: startStr,
|
||||
end: allDay
|
||||
? `${endDate}T23:59:59`
|
||||
: `${endDate}T${endTime}:00`,
|
||||
invitees: effectiveAttendees.map((a: { email: string }) => a.email),
|
||||
description: description.trim() || undefined,
|
||||
}),
|
||||
});
|
||||
if (vncRes.ok) {
|
||||
const { meetingUrl, meetingId } = await vncRes.json();
|
||||
data.virtualLocations = {
|
||||
vl1: {
|
||||
"@type": "VirtualLocation",
|
||||
name: "VNCtalk Meeting",
|
||||
description: `Meeting ID: ${meetingId}`,
|
||||
uri: meetingUrl,
|
||||
features: null,
|
||||
},
|
||||
};
|
||||
data.links = {
|
||||
"vnctalk-meeting": {
|
||||
"@type": "Link",
|
||||
href: meetingUrl,
|
||||
cid: meetingId,
|
||||
contentType: null,
|
||||
size: null,
|
||||
rel: "vnctalk-meeting",
|
||||
display: null,
|
||||
title: "VNCtalk Meeting",
|
||||
},
|
||||
};
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to create VNCtalk meeting:", err);
|
||||
} finally {
|
||||
setMeetingCreating(false);
|
||||
}
|
||||
}
|
||||
|
||||
const shouldSendScheduling = effectiveAttendees.length > 0 && sendInvitations;
|
||||
setIsSaving(true);
|
||||
try {
|
||||
@@ -560,7 +636,7 @@ export function EventModal({
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [title, description, location, virtualLocation, startDate, startTime, endDate, endTime, allDay, calendarId, recurrence, customRule, alertRows, attendees, sendInvitations, currentUserEmails, existingParticipants, event, onSave, isSaving]);
|
||||
}, [title, description, location, virtualLocation, startDate, startTime, endDate, endTime, allDay, calendarId, recurrence, customRule, alertRows, attendees, sendInvitations, currentUserEmails, existingParticipants, event, onSave, isSaving, createVncMeeting, timezone]);
|
||||
|
||||
const handleRsvp = useCallback((status: CalendarParticipant['participationStatus']) => {
|
||||
if (!event || !userParticipantId || !onRsvp) return;
|
||||
@@ -594,6 +670,30 @@ export function EventModal({
|
||||
onDuplicate(data);
|
||||
}, [event, onDuplicate]);
|
||||
|
||||
const handleReply = useCallback((replyAll: boolean) => {
|
||||
if (!event) return;
|
||||
const participants = getParticipantList(event);
|
||||
const recipientEmails = replyAll
|
||||
? participants.map((p) => ({ email: p.email, name: p.name }))
|
||||
: (() => {
|
||||
const org = participants.find((p) => p.isOrganizer);
|
||||
return org ? [{ email: org.email, name: org.name }] : [];
|
||||
})();
|
||||
if (recipientEmails.length === 0) return;
|
||||
openComposeTab({
|
||||
sessionId: Date.now(),
|
||||
mode: replyAll ? "replyAll" : "reply",
|
||||
title: `Re: ${event.title}`,
|
||||
replyTo: {
|
||||
subject: `Re: ${event.title}`,
|
||||
to: recipientEmails,
|
||||
},
|
||||
});
|
||||
}, [event, openComposeTab]);
|
||||
|
||||
const handleReplyAll = useCallback(() => handleReply(true), [handleReply]);
|
||||
const handleReplySingle = useCallback(() => handleReply(false), [handleReply]);
|
||||
|
||||
const modalRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -874,22 +974,57 @@ export function EventModal({
|
||||
{locationName && (
|
||||
<div className="flex items-start gap-2.5">
|
||||
<MapPin className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
|
||||
{/^https?:\/\//i.test(locationName) ? (
|
||||
<a href={locationName} target="_blank" rel="noreferrer" className="text-sm text-primary hover:underline truncate" title={locationName}>
|
||||
{(() => { try { return new URL(locationName).hostname; } catch { return locationName; } })()}
|
||||
</a>
|
||||
) : (
|
||||
<span className="text-sm text-foreground">{locationName}</span>
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
{/^https?:\/\//i.test(locationName) ? (
|
||||
<a href={locationName} target="_blank" rel="noreferrer" className="text-sm text-primary hover:underline truncate block" title={locationName}>
|
||||
{(() => { try { return new URL(locationName).hostname; } catch { return locationName; } })()}
|
||||
</a>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-sm text-foreground">{locationName}</span>
|
||||
<a
|
||||
href={`https://maps.google.com/?q=${encodeURIComponent(locationName)}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-xs text-primary hover:underline mt-0.5 inline-flex items-center gap-1"
|
||||
>
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
View on Map
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Virtual Location */}
|
||||
{/* Virtual Location / Meeting Link */}
|
||||
{virtualLoc && (
|
||||
<div className="flex items-start gap-2.5">
|
||||
<Video className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
|
||||
<a href={virtualLoc} target="_blank" rel="noreferrer" className="text-sm text-primary hover:underline truncate" title={virtualLoc}>
|
||||
{(() => { try { return new URL(virtualLoc).hostname; } catch { return virtualLoc; } })()}
|
||||
<div className="min-w-0">
|
||||
<a href={virtualLoc} target="_blank" rel="noreferrer" className="text-sm text-primary hover:underline truncate block" title={virtualLoc}>
|
||||
{(() => {
|
||||
const isVncMeeting = event.links?.["vnctalk-meeting"];
|
||||
if (isVncMeeting) return "Join VNCtalk Meeting";
|
||||
try { return new URL(virtualLoc).hostname; } catch { return virtualLoc; }
|
||||
})()}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* VNCtalk Meeting "Join" button (when meeting via links) */}
|
||||
{!virtualLoc && event.links?.["vnctalk-meeting"] && (
|
||||
<div className="flex items-start gap-2.5">
|
||||
<Video className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
|
||||
<a
|
||||
href={event.links["vnctalk-meeting"].href}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-sm text-primary hover:underline inline-flex items-center gap-1"
|
||||
>
|
||||
<ExternalLink className="w-3.5 h-3.5" />
|
||||
Join VNCtalk Meeting
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
@@ -906,7 +1041,7 @@ export function EventModal({
|
||||
{viewParticipants.map((p) => (
|
||||
<div key={p.id} className="flex items-center justify-between gap-2 text-xs">
|
||||
<span className="truncate text-foreground">
|
||||
{p.name || p.email}
|
||||
<RecipientPopover name={p.name} email={p.email} />
|
||||
{p.isOrganizer && (
|
||||
<span className="text-muted-foreground ms-1">({t("participants.organizer").toLowerCase()})</span>
|
||||
)}
|
||||
@@ -919,6 +1054,14 @@ export function EventModal({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Timezone */}
|
||||
{!event.showWithoutTime && event.timeZone && (
|
||||
<div className="flex items-start gap-2.5">
|
||||
<Globe className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
|
||||
<span className="text-sm text-muted-foreground">{event.timeZone}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Recurrence */}
|
||||
{recurrenceLabel && (
|
||||
<div className="flex items-start gap-2.5">
|
||||
@@ -939,7 +1082,23 @@ export function EventModal({
|
||||
{event.description && (
|
||||
<div className="flex items-start gap-2.5">
|
||||
<AlignLeft className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
|
||||
<p className="text-sm text-muted-foreground whitespace-pre-line">{event.description}</p>
|
||||
<p className="text-sm text-muted-foreground whitespace-pre-line">
|
||||
{linkifyText(event.description).map((part, i) =>
|
||||
typeof part === "string" ? (
|
||||
<span key={i}>{part}</span>
|
||||
) : (
|
||||
<a
|
||||
key={i}
|
||||
href={part.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
{part.url}
|
||||
</a>
|
||||
)
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -972,6 +1131,18 @@ export function EventModal({
|
||||
{t("events.duplicate")}
|
||||
</Button>
|
||||
)}
|
||||
{hasParticipants && !showDeleteConfirm && (
|
||||
<>
|
||||
<Button variant="ghost" size="sm" onClick={handleReplySingle} aria-label="Reply to organizer">
|
||||
<Reply className="w-4 h-4 me-1" />
|
||||
Reply
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={handleReplyAll} aria-label="Reply All">
|
||||
<ReplyAll className="w-4 h-4 me-1" />
|
||||
Reply All
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{!showDeleteConfirm && (
|
||||
<Button onClick={() => setMode("edit")}>
|
||||
@@ -1061,6 +1232,21 @@ export function EventModal({
|
||||
setVirtualLocation,
|
||||
}}
|
||||
/>
|
||||
{attendees.length > 0 && !allDay && (
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="createVncMeeting"
|
||||
checked={createVncMeeting}
|
||||
onChange={(e) => setCreateVncMeeting(e.target.checked)}
|
||||
className="rounded border-input"
|
||||
disabled={meetingCreating}
|
||||
/>
|
||||
<label htmlFor="createVncMeeting" className="text-sm">
|
||||
{meetingCreating ? "Creating meeting..." : "Create VNCtalk Meeting"}
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -1178,6 +1364,32 @@ export function EventModal({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!allDay && (
|
||||
<div>
|
||||
<label className="text-sm font-medium mb-1 block">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Globe className="w-4 h-4" />
|
||||
Timezone
|
||||
</span>
|
||||
</label>
|
||||
<select
|
||||
value={timezone}
|
||||
onChange={(e) => setTimezone(e.target.value)}
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
>
|
||||
{(() => {
|
||||
try {
|
||||
return Intl.supportedValuesOf("timeZone");
|
||||
} catch {
|
||||
return [timezone || "UTC"];
|
||||
}
|
||||
})().map((tz: string) => (
|
||||
<option key={tz} value={tz}>{tz}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{pluginConflictWarnings.length > 0 && (
|
||||
<div className="space-y-1.5">
|
||||
{pluginConflictWarnings.map(w => (
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
AlertCircle, Star, Clock, FolderUp,
|
||||
FileArchive, FileSpreadsheet, Presentation, FileCode,
|
||||
Box, PenTool, Terminal as TerminalIcon, Database, Type as TypeIcon,
|
||||
Menu, Users, Share2, MailPlus, Paperclip,
|
||||
Menu, Users, Share2, MailPlus, Paperclip, ExternalLink,
|
||||
} from "lucide-react";
|
||||
import { useIsDesktop } from "@/hooks/use-media-query";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -208,6 +208,14 @@ function isDatabaseFile(name: string): boolean {
|
||||
return DATABASE_EXTENSIONS.has(ext);
|
||||
}
|
||||
|
||||
const OFFICE_EXTENSIONS = new Set([
|
||||
"docx", "xlsx", "pptx", "odt", "ods", "odp", "doc", "xls", "ppt",
|
||||
]);
|
||||
function isOfficeFile(name: string): boolean {
|
||||
const ext = name.split(".").pop()?.toLowerCase() || "";
|
||||
return OFFICE_EXTENSIONS.has(ext);
|
||||
}
|
||||
|
||||
function isPreviewable(name: string): boolean {
|
||||
return isImageFile(name) || isTextFile(name) || isPdfFile(name) || isAudioFile(name) || isVideoFile(name);
|
||||
}
|
||||
@@ -1075,6 +1083,33 @@ export function FileBrowser({
|
||||
{t("send_as_attachment")} {fileNames.length > 1 && `(${fileNames.length})`}
|
||||
</Button>
|
||||
)}
|
||||
{!showBatch && hasFiles && fileNames.length === 1 && isOfficeFile(fileNames[0]) && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8"
|
||||
onClick={async () => {
|
||||
const file = resources.find((r) => r.name === fileNames[0]);
|
||||
if (!file) return;
|
||||
try {
|
||||
const res = await fetch("/api/collabora/edit", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ fileId: file.id, fileName: file.name }),
|
||||
});
|
||||
if (res.ok) {
|
||||
const { url } = await res.json();
|
||||
window.open(url, "_blank", "noopener,noreferrer");
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Collabora edit failed:", err);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Pencil className="w-4 h-4 me-1" />
|
||||
Edit with Collabora
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
@@ -1797,6 +1832,32 @@ export function FileBrowser({
|
||||
{t("download")}
|
||||
</button>
|
||||
)}
|
||||
{!resources.find(r => r.name === contextMenu.name)?.isDirectory && isOfficeFile(contextMenu.name) && (
|
||||
<button
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-muted transition-colors text-start"
|
||||
onClick={async () => {
|
||||
const file = resources.find((r) => r.name === contextMenu.name);
|
||||
if (!file) { setContextMenu(null); return; }
|
||||
try {
|
||||
const res = await fetch("/api/collabora/edit", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ fileId: file.id, fileName: file.name }),
|
||||
});
|
||||
if (res.ok) {
|
||||
const { url } = await res.json();
|
||||
window.open(url, "_blank", "noopener,noreferrer");
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Collabora edit failed:", err);
|
||||
}
|
||||
setContextMenu(null);
|
||||
}}
|
||||
>
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
Edit with Collabora
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-muted transition-colors text-start"
|
||||
onClick={() => {
|
||||
|
||||
Reference in New Issue
Block a user