mirror of
https://github.com/docmost/docmost.git
synced 2026-06-10 10:13:01 +08:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 779f9604a7 |
+1
-6
@@ -10,7 +10,7 @@ JWT_TOKEN_EXPIRES_IN=30d
|
|||||||
DATABASE_URL="postgresql://postgres:password@localhost:5432/docmost?schema=public"
|
DATABASE_URL="postgresql://postgres:password@localhost:5432/docmost?schema=public"
|
||||||
REDIS_URL=redis://127.0.0.1:6379
|
REDIS_URL=redis://127.0.0.1:6379
|
||||||
|
|
||||||
# options: local | s3 | azure
|
# options: local | s3
|
||||||
STORAGE_DRIVER=local
|
STORAGE_DRIVER=local
|
||||||
|
|
||||||
# S3 driver config
|
# S3 driver config
|
||||||
@@ -21,11 +21,6 @@ AWS_S3_BUCKET=
|
|||||||
AWS_S3_ENDPOINT=
|
AWS_S3_ENDPOINT=
|
||||||
AWS_S3_FORCE_PATH_STYLE=
|
AWS_S3_FORCE_PATH_STYLE=
|
||||||
|
|
||||||
# Azure Blob Storage driver config
|
|
||||||
AZURE_STORAGE_ACCOUNT_NAME=
|
|
||||||
AZURE_STORAGE_ACCOUNT_KEY=
|
|
||||||
AZURE_STORAGE_CONTAINER=
|
|
||||||
|
|
||||||
# default: 50mb
|
# default: 50mb
|
||||||
FILE_UPLOAD_SIZE_LIMIT=
|
FILE_UPLOAD_SIZE_LIMIT=
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "client",
|
"name": "client",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.90.1",
|
"version": "0.90.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc && vite build",
|
"build": "tsc && vite build",
|
||||||
@@ -43,7 +43,7 @@
|
|||||||
"i18next-http-backend": "3.0.6",
|
"i18next-http-backend": "3.0.6",
|
||||||
"jotai": "2.18.1",
|
"jotai": "2.18.1",
|
||||||
"jotai-optics": "0.4.0",
|
"jotai-optics": "0.4.0",
|
||||||
"js-cookie": "3.0.7",
|
"js-cookie": "3.0.5",
|
||||||
"jwt-decode": "4.0.0",
|
"jwt-decode": "4.0.0",
|
||||||
"katex": "0.16.40",
|
"katex": "0.16.40",
|
||||||
"lowlight": "3.3.0",
|
"lowlight": "3.3.0",
|
||||||
|
|||||||
@@ -424,7 +424,6 @@
|
|||||||
"Names do not match": "Names do not match",
|
"Names do not match": "Names do not match",
|
||||||
"Today, {{time}}": "Today, {{time}}",
|
"Today, {{time}}": "Today, {{time}}",
|
||||||
"Yesterday, {{time}}": "Yesterday, {{time}}",
|
"Yesterday, {{time}}": "Yesterday, {{time}}",
|
||||||
"now": "now",
|
|
||||||
"Space created successfully": "Space created successfully",
|
"Space created successfully": "Space created successfully",
|
||||||
"Space updated successfully": "Space updated successfully",
|
"Space updated successfully": "Space updated successfully",
|
||||||
"Space deleted successfully": "Space deleted successfully",
|
"Space deleted successfully": "Space deleted successfully",
|
||||||
@@ -978,7 +977,7 @@
|
|||||||
"Search pages and spaces...": "Search pages and spaces...",
|
"Search pages and spaces...": "Search pages and spaces...",
|
||||||
"No results found": "No results found",
|
"No results found": "No results found",
|
||||||
"You don't have permission to create pages here": "You don't have permission to create pages here",
|
"You don't have permission to create pages here": "You don't have permission to create pages here",
|
||||||
"Chat menu for {{title}}": "Chat menu for {{title}}",
|
"Chat menu": "Chat menu",
|
||||||
"API key menu": "API key menu",
|
"API key menu": "API key menu",
|
||||||
"Jump to comment selection": "Jump to comment selection",
|
"Jump to comment selection": "Jump to comment selection",
|
||||||
"Slash commands": "Slash commands",
|
"Slash commands": "Slash commands",
|
||||||
@@ -1064,7 +1063,7 @@
|
|||||||
"Filter": "Filter",
|
"Filter": "Filter",
|
||||||
"Page title": "Page title",
|
"Page title": "Page title",
|
||||||
"Page content": "Page content",
|
"Page content": "Page content",
|
||||||
"Member actions for {{name}}": "Member actions for {{name}}",
|
"Member actions": "Member actions",
|
||||||
"Toggle password visibility": "Toggle password visibility",
|
"Toggle password visibility": "Toggle password visibility",
|
||||||
"Send comment": "Send comment",
|
"Send comment": "Send comment",
|
||||||
"Token actions": "Token actions",
|
"Token actions": "Token actions",
|
||||||
|
|||||||
@@ -105,7 +105,7 @@ export default function GlobalSidebar() {
|
|||||||
|
|
||||||
<Divider my="xs" />
|
<Divider my="xs" />
|
||||||
<div className={classes.section}>
|
<div className={classes.section}>
|
||||||
<Text component="h2" className={classes.sectionHeader}>{t("Favorite spaces")}</Text>
|
<Text className={classes.sectionHeader}>{t("Favorite spaces")}</Text>
|
||||||
{!isFavoritesPending && sortedFavoriteSpaces.length === 0 ? (
|
{!isFavoritesPending && sortedFavoriteSpaces.length === 0 ? (
|
||||||
<Text size="xs" c="dimmed" pl="xs" py={4}>
|
<Text size="xs" c="dimmed" pl="xs" py={4}>
|
||||||
{t("Favorite spaces appear here")}
|
{t("Favorite spaces appear here")}
|
||||||
|
|||||||
@@ -16,10 +16,13 @@ interface CustomAvatarProps {
|
|||||||
mt?: string | number;
|
mt?: string | number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// color.shade picks whose FILLED variant (white text on the shade) meets WCAG AA 4.5:1.
|
// `color.shade` pairs whose contrast meets WCAG AA (4.5:1) in BOTH variants:
|
||||||
// Avoids lime/yellow/green/orange, too light even at dark shades.
|
// - filled: white text on the shade as bg
|
||||||
// For non-filled variants, initials text is forced to the .9 shade at render time:
|
// - light: shade as text on the color's light-bg (10% color.6 over white)
|
||||||
// Mantine otherwise caps light-variant placeholder text at .6, dropping contrast to ~3:1.
|
// Avoids lime/yellow/green/orange — even their dark shades have weak
|
||||||
|
// contrast. grape and indigo were bumped from .7 to darker shades because
|
||||||
|
// the original picks failed: grape.7 was 4.02/3.61 (both fail) and
|
||||||
|
// indigo.7 was 4.98/4.39 (light fails by a hair).
|
||||||
const SAFE_INITIALS_COLORS: MantineColor[] = [
|
const SAFE_INITIALS_COLORS: MantineColor[] = [
|
||||||
"blue.8",
|
"blue.8",
|
||||||
"cyan.9",
|
"cyan.9",
|
||||||
@@ -51,21 +54,12 @@ function sanitizeInitialsSource(name: string) {
|
|||||||
export const CustomAvatar = React.forwardRef<
|
export const CustomAvatar = React.forwardRef<
|
||||||
HTMLInputElement,
|
HTMLInputElement,
|
||||||
CustomAvatarProps
|
CustomAvatarProps
|
||||||
>(({ avatarUrl, name, type, color, variant, ...props }: CustomAvatarProps, ref) => {
|
>(({ avatarUrl, name, type, color, ...props }: CustomAvatarProps, ref) => {
|
||||||
const avatarLink = getAvatarUrl(avatarUrl, type);
|
const avatarLink = getAvatarUrl(avatarUrl, type);
|
||||||
const isInitials = !color || color === "initials";
|
const resolvedColor =
|
||||||
const resolvedColor = isInitials ? pickInitialsColor(name ?? "") : color;
|
!color || color === "initials" ? pickInitialsColor(name ?? "") : color;
|
||||||
const initialsSource = sanitizeInitialsSource(name ?? "");
|
const initialsSource = sanitizeInitialsSource(name ?? "");
|
||||||
|
|
||||||
const placeholderStyles =
|
|
||||||
isInitials && variant !== "filled"
|
|
||||||
? {
|
|
||||||
placeholder: {
|
|
||||||
color: `var(--mantine-color-${resolvedColor.split(".")[0]}-9)`,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Avatar
|
<Avatar
|
||||||
ref={ref}
|
ref={ref}
|
||||||
@@ -73,8 +67,6 @@ export const CustomAvatar = React.forwardRef<
|
|||||||
name={initialsSource}
|
name={initialsSource}
|
||||||
alt={name}
|
alt={name}
|
||||||
color={resolvedColor}
|
color={resolvedColor}
|
||||||
variant={variant}
|
|
||||||
styles={placeholderStyles}
|
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { Modal, Button, Group, Divider } from "@mantine/core";
|
import { Modal, Button, Group } from "@mantine/core";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { DestinationPicker } from "./destination-picker";
|
import { DestinationPicker } from "./destination-picker";
|
||||||
import {
|
import {
|
||||||
@@ -52,9 +52,7 @@ export function DestinationPickerModal({
|
|||||||
searchSpacesOnly={searchSpacesOnly}
|
searchSpacesOnly={searchSpacesOnly}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Divider my="md" />
|
<Group justify="flex-end" mt="md">
|
||||||
|
|
||||||
<Group justify="flex-end">
|
|
||||||
<Button variant="default" onClick={onClose}>
|
<Button variant="default" onClick={onClose}>
|
||||||
{t("Close")}
|
{t("Close")}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -89,6 +89,14 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.selectedIndicator {
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-size: var(--mantine-font-size-sm);
|
||||||
|
color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-2));
|
||||||
|
border-top: 1px solid light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-4));
|
||||||
|
margin-top: var(--mantine-spacing-xs);
|
||||||
|
}
|
||||||
|
|
||||||
.emptyState {
|
.emptyState {
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
|||||||
@@ -221,6 +221,14 @@ export function DestinationPicker({
|
|||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
|
|
||||||
|
{selection && (
|
||||||
|
<div className={classes.selectedIndicator}>
|
||||||
|
{selection.type === "space"
|
||||||
|
? selection.space.name
|
||||||
|
: `${selection.space.name} / ${selection.page.title || t("Untitled")}`}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +0,0 @@
|
|||||||
import { UnstyledButton } from "@mantine/core";
|
|
||||||
import { type ComponentPropsWithoutRef, forwardRef } from "react";
|
|
||||||
|
|
||||||
// Menu.Item hard-codes role="menuitem"; use as its `component` to restore role="menuitemradio" so aria-checked works.
|
|
||||||
export const RadioMenuItem = forwardRef<
|
|
||||||
HTMLButtonElement,
|
|
||||||
ComponentPropsWithoutRef<"button">
|
|
||||||
>((props, ref) => (
|
|
||||||
<UnstyledButton ref={ref} {...props} role="menuitemradio" />
|
|
||||||
));
|
|
||||||
|
|
||||||
RadioMenuItem.displayName = "RadioMenuItem";
|
|
||||||
@@ -66,8 +66,6 @@ export default function AiChatSidebarItem({
|
|||||||
[chat.updatedAt, i18n.language],
|
[chat.updatedAt, i18n.language],
|
||||||
);
|
);
|
||||||
|
|
||||||
const chatTitle = chat.title || t("Untitled chat");
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (renaming) {
|
if (renaming) {
|
||||||
// Wait for the input to be mounted before selecting.
|
// Wait for the input to be mounted before selecting.
|
||||||
@@ -122,7 +120,9 @@ export default function AiChatSidebarItem({
|
|||||||
className={classes.chatItem}
|
className={classes.chatItem}
|
||||||
data-active={isActive || undefined}
|
data-active={isActive || undefined}
|
||||||
>
|
>
|
||||||
<span className={classes.chatItemTitle}>{chatTitle}</span>
|
<span className={classes.chatItemTitle}>
|
||||||
|
{chat.title || t("Untitled chat")}
|
||||||
|
</span>
|
||||||
<span className={classes.chatItemDate}>{formattedDate}</span>
|
<span className={classes.chatItemDate}>{formattedDate}</span>
|
||||||
<div className={classes.chatItemActions}>
|
<div className={classes.chatItemActions}>
|
||||||
<Menu position="bottom-end" withinPortal>
|
<Menu position="bottom-end" withinPortal>
|
||||||
@@ -132,7 +132,7 @@ export default function AiChatSidebarItem({
|
|||||||
size="xs"
|
size="xs"
|
||||||
color="gray"
|
color="gray"
|
||||||
onClick={(e) => e.preventDefault()}
|
onClick={(e) => e.preventDefault()}
|
||||||
aria-label={t("Chat menu for {{title}}", { title: chatTitle })}
|
aria-label={t("Chat menu")}
|
||||||
>
|
>
|
||||||
<IconDots size={14} />
|
<IconDots size={14} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useId, useRef, useEffect, useState } from "react";
|
import { useCallback, useRef, useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { IconArrowUp, IconPaperclip, IconPlayerStopFilled, IconX, IconFile, IconPhoto, IconPlus, IconAt, IconFileText } from "@tabler/icons-react";
|
import { IconArrowUp, IconPaperclip, IconPlayerStopFilled, IconX, IconFile, IconPhoto, IconPlus, IconAt, IconFileText } from "@tabler/icons-react";
|
||||||
import { Popover } from "@mantine/core";
|
import { Popover } from "@mantine/core";
|
||||||
@@ -107,7 +107,6 @@ export default function ChatInput({
|
|||||||
const [isEmpty, setIsEmpty] = useState(true);
|
const [isEmpty, setIsEmpty] = useState(true);
|
||||||
const [pendingAttachments, setPendingAttachments] = useState<PendingAttachment[]>([]);
|
const [pendingAttachments, setPendingAttachments] = useState<PendingAttachment[]>([]);
|
||||||
const [plusMenuOpen, setPlusMenuOpen] = useState(false);
|
const [plusMenuOpen, setPlusMenuOpen] = useState(false);
|
||||||
const plusMenuId = useId();
|
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const onSendRef = useRef(onSend);
|
const onSendRef = useRef(onSend);
|
||||||
onSendRef.current = onSend;
|
onSendRef.current = onSend;
|
||||||
@@ -343,7 +342,6 @@ export default function ChatInput({
|
|||||||
position="top-start"
|
position="top-start"
|
||||||
width={220}
|
width={220}
|
||||||
shadow="md"
|
shadow="md"
|
||||||
withRoles={false}
|
|
||||||
trapFocus
|
trapFocus
|
||||||
returnFocus
|
returnFocus
|
||||||
>
|
>
|
||||||
@@ -353,17 +351,13 @@ export default function ChatInput({
|
|||||||
className={classes.plusButton}
|
className={classes.plusButton}
|
||||||
onClick={() => setPlusMenuOpen((o) => !o)}
|
onClick={() => setPlusMenuOpen((o) => !o)}
|
||||||
aria-label="Add content"
|
aria-label="Add content"
|
||||||
aria-haspopup="menu"
|
|
||||||
aria-expanded={plusMenuOpen}
|
|
||||||
aria-controls={plusMenuOpen ? plusMenuId : undefined}
|
|
||||||
>
|
>
|
||||||
<IconPlus size={14} />
|
<IconPlus size={14} />
|
||||||
</button>
|
</button>
|
||||||
</Popover.Target>
|
</Popover.Target>
|
||||||
<Popover.Dropdown id={plusMenuId} role="menu" p={4}>
|
<Popover.Dropdown p={4}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
role="menuitem"
|
|
||||||
className={classes.plusMenuItem}
|
className={classes.plusMenuItem}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
fileInputRef.current?.click();
|
fileInputRef.current?.click();
|
||||||
@@ -383,7 +377,6 @@ export default function ChatInput({
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
role="menuitem"
|
|
||||||
className={classes.plusMenuItem}
|
className={classes.plusMenuItem}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
editor?.commands.insertContent("@");
|
editor?.commands.insertContent("@");
|
||||||
@@ -392,7 +385,7 @@ export default function ChatInput({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<IconAt size={16} className={classes.plusMenuIcon} />
|
<IconAt size={16} className={classes.plusMenuIcon} />
|
||||||
{t("Mention a page")}
|
Mention a page
|
||||||
</button>
|
</button>
|
||||||
</Popover.Dropdown>
|
</Popover.Dropdown>
|
||||||
</Popover>
|
</Popover>
|
||||||
|
|||||||
@@ -17,26 +17,14 @@ import ChatToolGroup from "./chat-tool-group";
|
|||||||
import classes from "../styles/chat-message.module.css";
|
import classes from "../styles/chat-message.module.css";
|
||||||
import CopyTextButton from "@/components/common/copy.tsx";
|
import CopyTextButton from "@/components/common/copy.tsx";
|
||||||
|
|
||||||
const PAGE_PATH_RE = /\/s\/[^/?#]+\/p\/[^/?#]+/;
|
|
||||||
|
|
||||||
const chatSanitizer = DOMPurify();
|
const chatSanitizer = DOMPurify();
|
||||||
chatSanitizer.addHook("afterSanitizeAttributes", (node) => {
|
chatSanitizer.addHook("afterSanitizeAttributes", (node) => {
|
||||||
if (node.tagName !== "A") return;
|
if (node.tagName === "A") {
|
||||||
const href = node.getAttribute("href") || "";
|
const href = node.getAttribute("href") || "";
|
||||||
|
if (href.startsWith("http://") || href.startsWith("https://")) {
|
||||||
// Recover the canonical /s/{slug}/p/{slugId} path if the model wrapped it
|
node.setAttribute("target", "_blank");
|
||||||
// in a fabricated host (https://s/..., https://yoursite.com/s/..., //s/...).
|
node.setAttribute("rel", "noopener noreferrer");
|
||||||
const m = href.match(PAGE_PATH_RE);
|
}
|
||||||
if (m) {
|
|
||||||
node.setAttribute("href", m[0]);
|
|
||||||
node.removeAttribute("target");
|
|
||||||
node.removeAttribute("rel");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (href.startsWith("http://") || href.startsWith("https://")) {
|
|
||||||
node.setAttribute("target", "_blank");
|
|
||||||
node.setAttribute("rel", "noopener noreferrer");
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -76,6 +76,7 @@
|
|||||||
padding: var(--mantine-spacing-xs) var(--mantine-spacing-lg) var(--mantine-spacing-lg);
|
padding: var(--mantine-spacing-xs) var(--mantine-spacing-lg) var(--mantine-spacing-lg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Empty state - Notion AI style centered layout */
|
||||||
.emptyState {
|
.emptyState {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { ActionIcon, Group, Menu, Table, Text } from "@mantine/core";
|
import { ActionIcon, Group, Menu, Table, Text } from "@mantine/core";
|
||||||
import { IconDots, IconEdit, IconTrash } from "@tabler/icons-react";
|
import { IconDots, IconEdit, IconTrash } from "@tabler/icons-react";
|
||||||
|
import { format } from "date-fns";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { IApiKey } from "@/ee/api-key";
|
import { IApiKey } from "@/ee/api-key";
|
||||||
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import NoTableResults from "@/components/common/no-table-results";
|
import NoTableResults from "@/components/common/no-table-results";
|
||||||
import { formatLocalized, useDateFnsLocale } from "@/lib/date-locale.ts";
|
|
||||||
|
|
||||||
interface ApiKeyTableProps {
|
interface ApiKeyTableProps {
|
||||||
apiKeys: IApiKey[];
|
apiKeys: IApiKey[];
|
||||||
@@ -23,11 +23,10 @@ export function ApiKeyTable({
|
|||||||
onRevoke,
|
onRevoke,
|
||||||
}: ApiKeyTableProps) {
|
}: ApiKeyTableProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const locale = useDateFnsLocale();
|
|
||||||
|
|
||||||
const formatDate = (date: Date | string | null) => {
|
const formatDate = (date: Date | string | null) => {
|
||||||
if (!date) return t("Never");
|
if (!date) return t("Never");
|
||||||
return formatLocalized(date, "MMM dd, yyyy", "PP", locale);
|
return format(new Date(date), "MMM dd, yyyy");
|
||||||
};
|
};
|
||||||
|
|
||||||
const isExpired = (expiresAt: string | null) => {
|
const isExpired = (expiresAt: string | null) => {
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ export function CreateApiKeyModal({
|
|||||||
onClose,
|
onClose,
|
||||||
onSuccess,
|
onSuccess,
|
||||||
}: CreateApiKeyModalProps) {
|
}: CreateApiKeyModalProps) {
|
||||||
const { t, i18n } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [expirationOption, setExpirationOption] = useState<string>("30");
|
const [expirationOption, setExpirationOption] = useState<string>("30");
|
||||||
const createApiKeyMutation = useCreateApiKeyMutation();
|
const createApiKeyMutation = useCreateApiKeyMutation();
|
||||||
|
|
||||||
@@ -59,7 +59,7 @@ export function CreateApiKeyModal({
|
|||||||
const getExpirationLabel = (days: number) => {
|
const getExpirationLabel = (days: number) => {
|
||||||
const date = new Date();
|
const date = new Date();
|
||||||
date.setDate(date.getDate() + days);
|
date.setDate(date.getDate() + days);
|
||||||
const formatted = date.toLocaleDateString(i18n.language, {
|
const formatted = date.toLocaleDateString("en-US", {
|
||||||
month: "short",
|
month: "short",
|
||||||
day: "2-digit",
|
day: "2-digit",
|
||||||
year: "numeric",
|
year: "numeric",
|
||||||
|
|||||||
@@ -4,13 +4,12 @@ import {
|
|||||||
} from "@/ee/billing/queries/billing-query.ts";
|
} from "@/ee/billing/queries/billing-query.ts";
|
||||||
import { Group, Text, SimpleGrid, Paper } from "@mantine/core";
|
import { Group, Text, SimpleGrid, Paper } from "@mantine/core";
|
||||||
import classes from "./billing.module.css";
|
import classes from "./billing.module.css";
|
||||||
|
import { format } from "date-fns";
|
||||||
import { formatInterval } from "@/ee/billing/utils.ts";
|
import { formatInterval } from "@/ee/billing/utils.ts";
|
||||||
import { formatLocalized, useDateFnsLocale } from "@/lib/date-locale.ts";
|
|
||||||
|
|
||||||
export default function BillingDetails() {
|
export default function BillingDetails() {
|
||||||
const { data: billing } = useBillingQuery();
|
const { data: billing } = useBillingQuery();
|
||||||
const { data: plans } = useBillingPlans();
|
const { data: plans } = useBillingPlans();
|
||||||
const locale = useDateFnsLocale();
|
|
||||||
|
|
||||||
if (!billing || !plans) {
|
if (!billing || !plans) {
|
||||||
return null;
|
return null;
|
||||||
@@ -76,12 +75,7 @@ export default function BillingDetails() {
|
|||||||
: "Renewal date"}
|
: "Renewal date"}
|
||||||
</Text>
|
</Text>
|
||||||
<Text fw={700} fz="lg">
|
<Text fw={700} fz="lg">
|
||||||
{formatLocalized(
|
{format(billing.periodEndAt, "dd MMM, yyyy")}
|
||||||
billing.periodEndAt,
|
|
||||||
"dd MMM, yyyy",
|
|
||||||
"PP",
|
|
||||||
locale,
|
|
||||||
)}
|
|
||||||
</Text>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
</Group>
|
</Group>
|
||||||
|
|||||||
@@ -1,14 +1,13 @@
|
|||||||
import { Badge, Table } from "@mantine/core";
|
import { Badge, Table } from "@mantine/core";
|
||||||
|
import { format } from "date-fns";
|
||||||
import { useLicenseInfo } from "@/ee/licence/queries/license-query.ts";
|
import { useLicenseInfo } from "@/ee/licence/queries/license-query.ts";
|
||||||
import { isLicenseExpired } from "@/ee/licence/license.utils.ts";
|
import { isLicenseExpired } from "@/ee/licence/license.utils.ts";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
|
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
|
||||||
import { formatLocalized, useDateFnsLocale } from "@/lib/date-locale.ts";
|
|
||||||
|
|
||||||
export default function LicenseDetails() {
|
export default function LicenseDetails() {
|
||||||
const { data: license, isError } = useLicenseInfo();
|
const { data: license, isError } = useLicenseInfo();
|
||||||
const [workspace] = useAtom(workspaceAtom);
|
const [workspace] = useAtom(workspaceAtom);
|
||||||
const locale = useDateFnsLocale();
|
|
||||||
|
|
||||||
if (!license) {
|
if (!license) {
|
||||||
return null;
|
return null;
|
||||||
@@ -51,16 +50,12 @@ export default function LicenseDetails() {
|
|||||||
|
|
||||||
<Table.Tr>
|
<Table.Tr>
|
||||||
<Table.Th>Issued at</Table.Th>
|
<Table.Th>Issued at</Table.Th>
|
||||||
<Table.Td>
|
<Table.Td>{format(license.issuedAt, "dd MMMM, yyyy")}</Table.Td>
|
||||||
{formatLocalized(license.issuedAt, "dd MMMM, yyyy", "PPP", locale)}
|
|
||||||
</Table.Td>
|
|
||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
|
|
||||||
<Table.Tr>
|
<Table.Tr>
|
||||||
<Table.Th>Expires at</Table.Th>
|
<Table.Th>Expires at</Table.Th>
|
||||||
<Table.Td>
|
<Table.Td>{format(license.expiresAt, "dd MMMM, yyyy")}</Table.Td>
|
||||||
{formatLocalized(license.expiresAt, "dd MMMM, yyyy", "PPP", locale)}
|
|
||||||
</Table.Td>
|
|
||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
<Table.Tr>
|
<Table.Tr>
|
||||||
<Table.Th>License ID</Table.Th>
|
<Table.Th>License ID</Table.Th>
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { Group, NumberInput, Select, Text } from "@mantine/core";
|
import { Group, NumberInput, Select, Text } from "@mantine/core";
|
||||||
import { DateInput } from "@mantine/dates";
|
import { DateInput } from "@mantine/dates";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import i18n from "@/i18n.ts";
|
|
||||||
import {
|
import {
|
||||||
ExpirationMode,
|
ExpirationMode,
|
||||||
PeriodUnit,
|
PeriodUnit,
|
||||||
@@ -31,7 +30,7 @@ export function addDays(days: number, from?: Date): Date {
|
|||||||
|
|
||||||
function formatShortDate(date: Date): string {
|
function formatShortDate(date: Date): string {
|
||||||
const crossesYear = date.getFullYear() !== new Date().getFullYear();
|
const crossesYear = date.getFullYear() !== new Date().getFullYear();
|
||||||
return date.toLocaleDateString(i18n.language, {
|
return date.toLocaleDateString(undefined, {
|
||||||
month: "short",
|
month: "short",
|
||||||
day: "numeric",
|
day: "numeric",
|
||||||
...(crossesYear && { year: "numeric" }),
|
...(crossesYear && { year: "numeric" }),
|
||||||
@@ -39,7 +38,7 @@ function formatShortDate(date: Date): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function formatLongDate(date: Date): string {
|
function formatLongDate(date: Date): string {
|
||||||
return date.toLocaleDateString(i18n.language, {
|
return date.toLocaleDateString(undefined, {
|
||||||
month: "long",
|
month: "long",
|
||||||
day: "numeric",
|
day: "numeric",
|
||||||
year: "numeric",
|
year: "numeric",
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import {
|
|||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { modals } from "@mantine/modals";
|
import { modals } from "@mantine/modals";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import i18n from "@/i18n.ts";
|
|
||||||
import {
|
import {
|
||||||
useMarkObsoleteMutation,
|
useMarkObsoleteMutation,
|
||||||
usePageVerificationInfoQuery,
|
usePageVerificationInfoQuery,
|
||||||
@@ -198,14 +197,11 @@ function ExpiringManageContent({ pageId, info, onClose }: ManageContentProps) {
|
|||||||
{info.expiresAt && (
|
{info.expiresAt && (
|
||||||
<Text size="xs" c="dimmed">
|
<Text size="xs" c="dimmed">
|
||||||
{t(status === "expired" ? "Expired {{date}}" : "Expires {{date}}", {
|
{t(status === "expired" ? "Expired {{date}}" : "Expires {{date}}", {
|
||||||
date: new Date(info.expiresAt).toLocaleDateString(
|
date: new Date(info.expiresAt).toLocaleDateString(undefined, {
|
||||||
i18n.language,
|
month: "long",
|
||||||
{
|
day: "numeric",
|
||||||
month: "long",
|
year: "numeric",
|
||||||
day: "numeric",
|
}),
|
||||||
year: "numeric",
|
|
||||||
},
|
|
||||||
),
|
|
||||||
})}
|
})}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import {
|
|||||||
IconShieldCheck,
|
IconShieldCheck,
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import i18n from "@/i18n.ts";
|
|
||||||
import { useParams } from "react-router-dom";
|
import { useParams } from "react-router-dom";
|
||||||
import { extractPageSlugId } from "@/lib";
|
import { extractPageSlugId } from "@/lib";
|
||||||
import { usePageQuery } from "@/features/page/queries/page-query";
|
import { usePageQuery } from "@/features/page/queries/page-query";
|
||||||
@@ -128,7 +127,7 @@ export function PageVerificationBadge({
|
|||||||
status === "verified" && verificationInfo?.expiresAt
|
status === "verified" && verificationInfo?.expiresAt
|
||||||
? t("Verified until {{date}}", {
|
? t("Verified until {{date}}", {
|
||||||
date: new Date(verificationInfo.expiresAt).toLocaleDateString(
|
date: new Date(verificationInfo.expiresAt).toLocaleDateString(
|
||||||
i18n.language,
|
undefined,
|
||||||
{ month: "long", day: "numeric", year: "numeric" },
|
{ month: "long", day: "numeric", year: "numeric" },
|
||||||
),
|
),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -16,10 +16,9 @@ import {
|
|||||||
} from "@/ee/page-verification/types/page-verification.types";
|
} from "@/ee/page-verification/types/page-verification.types";
|
||||||
import { CustomAvatar } from "@/components/ui/custom-avatar";
|
import { CustomAvatar } from "@/components/ui/custom-avatar";
|
||||||
import { buildPageUrl } from "@/features/page/page.utils";
|
import { buildPageUrl } from "@/features/page/page.utils";
|
||||||
|
import { format } from "date-fns";
|
||||||
import NoTableResults from "@/components/common/no-table-results";
|
import NoTableResults from "@/components/common/no-table-results";
|
||||||
import rowClasses from "@/components/ui/clickable-table-row.module.css";
|
import rowClasses from "@/components/ui/clickable-table-row.module.css";
|
||||||
import { formatLocalized, useDateFnsLocale } from "@/lib/date-locale.ts";
|
|
||||||
import type { Locale } from "date-fns";
|
|
||||||
|
|
||||||
const MAX_VISIBLE_VERIFIERS = 5;
|
const MAX_VISIBLE_VERIFIERS = 5;
|
||||||
|
|
||||||
@@ -49,11 +48,7 @@ function statusBadge(status: VerificationStatus | null, t: (s: string) => string
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function verifiedUntilText(
|
function verifiedUntilText(item: IVerificationListItem, t: (s: string) => string): string {
|
||||||
item: IVerificationListItem,
|
|
||||||
t: (s: string) => string,
|
|
||||||
locale: Locale,
|
|
||||||
): string {
|
|
||||||
if (item.type === "qms") {
|
if (item.type === "qms") {
|
||||||
if (item.status === "approved") return t("Indefinitely");
|
if (item.status === "approved") return t("Indefinitely");
|
||||||
return "—";
|
return "—";
|
||||||
@@ -65,7 +60,7 @@ function verifiedUntilText(
|
|||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
|
||||||
if (expires <= now) return t("Expired");
|
if (expires <= now) return t("Expired");
|
||||||
return formatLocalized(expires, "MMM d, yyyy", "PP", locale);
|
return format(expires, "MMM d, yyyy");
|
||||||
}
|
}
|
||||||
|
|
||||||
function TableSkeleton() {
|
function TableSkeleton() {
|
||||||
@@ -103,7 +98,6 @@ export default function VerificationListTable({
|
|||||||
isLoading,
|
isLoading,
|
||||||
}: VerificationListTableProps) {
|
}: VerificationListTableProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const locale = useDateFnsLocale();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Table.ScrollContainer minWidth={600}>
|
<Table.ScrollContainer minWidth={600}>
|
||||||
@@ -206,7 +200,7 @@ export default function VerificationListTable({
|
|||||||
|
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
<Text fz="sm" style={{ whiteSpace: "nowrap" }}>
|
<Text fz="sm" style={{ whiteSpace: "nowrap" }}>
|
||||||
{verifiedUntilText(item, t, locale)}
|
{verifiedUntilText(item, t)}
|
||||||
</Text>
|
</Text>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { ActionIcon, Group, Menu, Table, Text } from "@mantine/core";
|
import { ActionIcon, Group, Menu, Table, Text } from "@mantine/core";
|
||||||
import { IconDots, IconEdit, IconTrash } from "@tabler/icons-react";
|
import { IconDots, IconEdit, IconTrash } from "@tabler/icons-react";
|
||||||
|
import { format } from "date-fns";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import NoTableResults from "@/components/common/no-table-results";
|
import NoTableResults from "@/components/common/no-table-results";
|
||||||
import { IScimToken } from "@/ee/scim/types/scim-token.types";
|
import { IScimToken } from "@/ee/scim/types/scim-token.types";
|
||||||
import { formatLocalized, useDateFnsLocale } from "@/lib/date-locale.ts";
|
|
||||||
|
|
||||||
interface ScimTokenTableProps {
|
interface ScimTokenTableProps {
|
||||||
tokens: IScimToken[];
|
tokens: IScimToken[];
|
||||||
@@ -21,11 +21,10 @@ export function ScimTokenTable({
|
|||||||
onRevoke,
|
onRevoke,
|
||||||
}: ScimTokenTableProps) {
|
}: ScimTokenTableProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const locale = useDateFnsLocale();
|
|
||||||
|
|
||||||
const formatDate = (date: Date | string | null) => {
|
const formatDate = (date: Date | string | null) => {
|
||||||
if (!date) return t("Never");
|
if (!date) return t("Never");
|
||||||
return formatLocalized(date, "MMM dd, yyyy", "PP", locale);
|
return format(new Date(date), "MMM dd, yyyy");
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -32,12 +32,6 @@
|
|||||||
margin-bottom: 0.25em;
|
margin-bottom: 0.25em;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The emoji glyph renders larger than its font-size box; let the transparent
|
|
||||||
ActionIcon overflow so it isn't clipped on the edges. */
|
|
||||||
.emojiButton button {
|
|
||||||
overflow: visible;
|
|
||||||
}
|
|
||||||
|
|
||||||
.titleInput {
|
.titleInput {
|
||||||
font-size: 2.5rem;
|
font-size: 2.5rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
|
|||||||
@@ -32,12 +32,6 @@ import {
|
|||||||
} from "../queries/template-query";
|
} from "../queries/template-query";
|
||||||
import { useGetSpacesQuery } from "@/features/space/queries/space-query";
|
import { useGetSpacesQuery } from "@/features/space/queries/space-query";
|
||||||
import useUserRole from "@/hooks/use-user-role";
|
import useUserRole from "@/hooks/use-user-role";
|
||||||
import { useAtomValue } from "jotai";
|
|
||||||
import { userAtom } from "@/features/user/atoms/current-user-atom";
|
|
||||||
import { FixedToolbar } from "@/features/editor/components/fixed-toolbar/fixed-toolbar";
|
|
||||||
import { EditorLinkMenu } from "@/features/editor/components/link/link-menu";
|
|
||||||
import { EditorBubbleMenu } from "@/features/editor/components/bubble-menu/bubble-menu";
|
|
||||||
import { EditorAiMenu } from "@/ee/ai/components/editor/ai-menu/ai-menu";
|
|
||||||
|
|
||||||
import classes from "./template-editor.module.css";
|
import classes from "./template-editor.module.css";
|
||||||
|
|
||||||
@@ -45,9 +39,6 @@ export default function TemplateEditor() {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { templateId } = useParams<{ templateId: string }>();
|
const { templateId } = useParams<{ templateId: string }>();
|
||||||
const { isAdmin: isWorkspaceAdmin } = useUserRole();
|
const { isAdmin: isWorkspaceAdmin } = useUserRole();
|
||||||
const user = useAtomValue(userAtom);
|
|
||||||
const editorToolbarEnabled =
|
|
||||||
user?.settings?.preferences?.editorToolbar ?? false;
|
|
||||||
|
|
||||||
const { data: existingTemplate } = useGetTemplateByIdQuery(templateId || "");
|
const { data: existingTemplate } = useGetTemplateByIdQuery(templateId || "");
|
||||||
const { data: spaces } = useGetSpacesQuery({ limit: 100 });
|
const { data: spaces } = useGetSpacesQuery({ limit: 100 });
|
||||||
@@ -247,10 +238,6 @@ export default function TemplateEditor() {
|
|||||||
</title>
|
</title>
|
||||||
</Helmet>
|
</Helmet>
|
||||||
|
|
||||||
{editorToolbarEnabled && editor && (
|
|
||||||
<FixedToolbar editor={editor} templateMode />
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className={classes.header}>
|
<div className={classes.header}>
|
||||||
<Container size={900} h="100%" px={0}>
|
<Container size={900} h="100%" px={0}>
|
||||||
<Group justify="space-between" h="100%" wrap="nowrap">
|
<Group justify="space-between" h="100%" wrap="nowrap">
|
||||||
@@ -392,13 +379,6 @@ export default function TemplateEditor() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<EditorContent editor={editor} />
|
<EditorContent editor={editor} />
|
||||||
{editor && (
|
|
||||||
<>
|
|
||||||
<EditorAiMenu editor={editor} />
|
|
||||||
<EditorBubbleMenu editor={editor} templateMode />
|
|
||||||
<EditorLinkMenu editor={editor} />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<div style={{ paddingBottom: "20vh" }} />
|
<div style={{ paddingBottom: "20vh" }} />
|
||||||
</Container>
|
</Container>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import {
|
|||||||
useQueryClient,
|
useQueryClient,
|
||||||
UseQueryResult,
|
UseQueryResult,
|
||||||
InfiniteData,
|
InfiniteData,
|
||||||
keepPreviousData,
|
|
||||||
} from "@tanstack/react-query";
|
} from "@tanstack/react-query";
|
||||||
import { useAtom, useStore } from "jotai";
|
import { useAtom, useStore } from "jotai";
|
||||||
import {
|
import {
|
||||||
@@ -36,7 +35,6 @@ export function useGetTemplatesQuery(params?: { spaceId?: string }) {
|
|||||||
initialPageParam: undefined as string | undefined,
|
initialPageParam: undefined as string | undefined,
|
||||||
getNextPageParam: (lastPage) =>
|
getNextPageParam: (lastPage) =>
|
||||||
lastPage.meta.hasNextPage ? lastPage.meta.nextCursor : undefined,
|
lastPage.meta.hasNextPage ? lastPage.meta.nextCursor : undefined,
|
||||||
placeholderData: keepPreviousData,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -38,11 +38,9 @@ export interface BubbleMenuItem {
|
|||||||
|
|
||||||
type EditorBubbleMenuProps = Omit<BubbleMenuProps, "children" | "editor"> & {
|
type EditorBubbleMenuProps = Omit<BubbleMenuProps, "children" | "editor"> & {
|
||||||
editor: Editor | null;
|
editor: Editor | null;
|
||||||
templateMode?: boolean;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
|
export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
|
||||||
const { templateMode = false } = props;
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [showAiMenu, setShowAiMenu] = useAtom(showAiMenuAtom);
|
const [showAiMenu, setShowAiMenu] = useAtom(showAiMenuAtom);
|
||||||
const [showCommentPopup, setShowCommentPopup] = useAtom(showCommentPopupAtom);
|
const [showCommentPopup, setShowCommentPopup] = useAtom(showCommentPopupAtom);
|
||||||
@@ -234,6 +232,8 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
|
|||||||
))}
|
))}
|
||||||
</ActionIcon.Group>
|
</ActionIcon.Group>
|
||||||
|
|
||||||
|
<LinkSelector />
|
||||||
|
|
||||||
<ColorSelector
|
<ColorSelector
|
||||||
editor={props.editor}
|
editor={props.editor}
|
||||||
isOpen={isColorSelectorOpen}
|
isOpen={isColorSelectorOpen}
|
||||||
@@ -246,22 +246,18 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<LinkSelector />
|
<Tooltip label={t(commentItem.name)} withArrow withinPortal={false}>
|
||||||
|
<ActionIcon
|
||||||
{!templateMode && (
|
variant="default"
|
||||||
<Tooltip label={t(commentItem.name)} withArrow withinPortal={false}>
|
size="lg"
|
||||||
<ActionIcon
|
radius="6px"
|
||||||
variant="default"
|
aria-label={t(commentItem.name)}
|
||||||
size="lg"
|
style={{ border: "none" }}
|
||||||
radius="6px"
|
onClick={commentItem.command}
|
||||||
aria-label={t(commentItem.name)}
|
>
|
||||||
style={{ border: "none" }}
|
<IconMessage size={16} stroke={2} />
|
||||||
onClick={commentItem.command}
|
</ActionIcon>
|
||||||
>
|
</Tooltip>
|
||||||
<IconMessage size={16} stroke={2} />
|
|
||||||
</ActionIcon>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</BubbleMenu>
|
</BubbleMenu>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { FC } from "react";
|
import { FC } from "react";
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import type { Editor } from "@tiptap/react";
|
|
||||||
import { pageEditorAtom } from "@/features/editor/atoms/editor-atoms";
|
import { pageEditorAtom } from "@/features/editor/atoms/editor-atoms";
|
||||||
import { useToolbarState } from "./use-toolbar-state";
|
import { useToolbarState } from "./use-toolbar-state";
|
||||||
import { BlockTypeGroup } from "./groups/block-type-group";
|
import { BlockTypeGroup } from "./groups/block-type-group";
|
||||||
import { InlineMarksGroup } from "./groups/inline-marks-group";
|
import { InlineMarksGroup } from "./groups/inline-marks-group";
|
||||||
import { ColorGroup } from "./groups/color-group";
|
import { ColorGroup } from "./groups/color-group";
|
||||||
import { ListsGroup } from "./groups/lists-group";
|
import { ListsGroup } from "./groups/lists-group";
|
||||||
|
import { LinkGroup } from "./groups/link-group";
|
||||||
import { AlignmentGroup } from "./groups/alignment-group";
|
import { AlignmentGroup } from "./groups/alignment-group";
|
||||||
import { MediaGroup } from "./groups/media-group";
|
import { MediaGroup } from "./groups/media-group";
|
||||||
import { QuickInsertsGroup } from "./groups/quick-inserts-group";
|
import { QuickInsertsGroup } from "./groups/quick-inserts-group";
|
||||||
@@ -16,17 +16,8 @@ import { AskAiGroup } from "./groups/ask-ai-group";
|
|||||||
import { workspaceAtom } from "@/features/user/atoms/current-user-atom";
|
import { workspaceAtom } from "@/features/user/atoms/current-user-atom";
|
||||||
import classes from "./fixed-toolbar.module.css";
|
import classes from "./fixed-toolbar.module.css";
|
||||||
|
|
||||||
type FixedToolbarProps = {
|
export const FixedToolbar: FC = () => {
|
||||||
editor?: Editor | null;
|
const editor = useAtomValue(pageEditorAtom);
|
||||||
templateMode?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const FixedToolbar: FC<FixedToolbarProps> = ({
|
|
||||||
editor: editorProp,
|
|
||||||
templateMode = false,
|
|
||||||
}) => {
|
|
||||||
const editorFromAtom = useAtomValue(pageEditorAtom);
|
|
||||||
const editor = editorProp ?? editorFromAtom;
|
|
||||||
const state = useToolbarState(editor);
|
const state = useToolbarState(editor);
|
||||||
const workspace = useAtomValue(workspaceAtom);
|
const workspace = useAtomValue(workspaceAtom);
|
||||||
const isGenerativeAiEnabled = workspace?.settings?.ai?.generative === true;
|
const isGenerativeAiEnabled = workspace?.settings?.ai?.generative === true;
|
||||||
@@ -57,12 +48,14 @@ export const FixedToolbar: FC<FixedToolbarProps> = ({
|
|||||||
<div className={classes.divider} />
|
<div className={classes.divider} />
|
||||||
<ListsGroup editor={editor} state={state} />
|
<ListsGroup editor={editor} state={state} />
|
||||||
<div className={classes.divider} />
|
<div className={classes.divider} />
|
||||||
|
<LinkGroup />
|
||||||
|
<div className={classes.divider} />
|
||||||
<AlignmentGroup editor={editor} />
|
<AlignmentGroup editor={editor} />
|
||||||
<div className={classes.divider} />
|
<div className={classes.divider} />
|
||||||
<MediaGroup editor={editor} templateMode={templateMode} />
|
<MediaGroup editor={editor} />
|
||||||
<div className={classes.divider} />
|
<div className={classes.divider} />
|
||||||
<QuickInsertsGroup editor={editor} />
|
<QuickInsertsGroup editor={editor} />
|
||||||
<MoreInsertsGroup editor={editor} templateMode={templateMode} />
|
<MoreInsertsGroup editor={editor} />
|
||||||
<div className={classes.divider} />
|
<div className={classes.divider} />
|
||||||
<HistoryGroup editor={editor} state={state} />
|
<HistoryGroup editor={editor} state={state} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { FC } from "react";
|
||||||
|
import { LinkSelector } from "@/features/editor/components/bubble-menu/link-selector";
|
||||||
|
|
||||||
|
export const LinkGroup: FC = () => {
|
||||||
|
return <LinkSelector />;
|
||||||
|
};
|
||||||
@@ -17,7 +17,6 @@ import { uploadPdfAction } from "@/features/editor/components/pdf/upload-pdf-act
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
editor: Editor;
|
editor: Editor;
|
||||||
templateMode?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type UploadFn = (
|
type UploadFn = (
|
||||||
@@ -61,7 +60,7 @@ function pickFile(
|
|||||||
input.click();
|
input.click();
|
||||||
}
|
}
|
||||||
|
|
||||||
export const MediaGroup: FC<Props> = ({ editor, templateMode }) => {
|
export const MediaGroup: FC<Props> = ({ editor }) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -79,30 +78,24 @@ export const MediaGroup: FC<Props> = ({ editor, templateMode }) => {
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
</Menu.Target>
|
</Menu.Target>
|
||||||
<Menu.Dropdown>
|
<Menu.Dropdown>
|
||||||
{!templateMode && (
|
<Menu.Item
|
||||||
<Menu.Item
|
leftSection={<IconPhoto size={16} />}
|
||||||
leftSection={<IconPhoto size={16} />}
|
onClick={() => pickFile(editor, "image/*", true, uploadImageAction)}
|
||||||
onClick={() => pickFile(editor, "image/*", true, uploadImageAction)}
|
>
|
||||||
>
|
{t("Image")}
|
||||||
{t("Image")}
|
</Menu.Item>
|
||||||
</Menu.Item>
|
<Menu.Item
|
||||||
)}
|
leftSection={<IconMovie size={16} />}
|
||||||
{!templateMode && (
|
onClick={() => pickFile(editor, "video/*", true, uploadVideoAction)}
|
||||||
<Menu.Item
|
>
|
||||||
leftSection={<IconMovie size={16} />}
|
{t("Video")}
|
||||||
onClick={() => pickFile(editor, "video/*", true, uploadVideoAction)}
|
</Menu.Item>
|
||||||
>
|
<Menu.Item
|
||||||
{t("Video")}
|
leftSection={<IconMusic size={16} />}
|
||||||
</Menu.Item>
|
onClick={() => pickFile(editor, "audio/*", true, uploadAudioAction)}
|
||||||
)}
|
>
|
||||||
{!templateMode && (
|
{t("Audio")}
|
||||||
<Menu.Item
|
</Menu.Item>
|
||||||
leftSection={<IconMusic size={16} />}
|
|
||||||
onClick={() => pickFile(editor, "audio/*", true, uploadAudioAction)}
|
|
||||||
>
|
|
||||||
{t("Audio")}
|
|
||||||
</Menu.Item>
|
|
||||||
)}
|
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
leftSection={<IconFileTypePdf size={16} />}
|
leftSection={<IconFileTypePdf size={16} />}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
@@ -111,16 +104,14 @@ export const MediaGroup: FC<Props> = ({ editor, templateMode }) => {
|
|||||||
>
|
>
|
||||||
PDF
|
PDF
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
{!templateMode && (
|
<Menu.Item
|
||||||
<Menu.Item
|
leftSection={<IconPaperclip size={16} />}
|
||||||
leftSection={<IconPaperclip size={16} />}
|
onClick={() =>
|
||||||
onClick={() =>
|
pickFile(editor, "", true, uploadAttachmentAction, true)
|
||||||
pickFile(editor, "", true, uploadAttachmentAction, true)
|
}
|
||||||
}
|
>
|
||||||
>
|
{t("File attachment")}
|
||||||
{t("File attachment")}
|
</Menu.Item>
|
||||||
</Menu.Item>
|
|
||||||
)}
|
|
||||||
</Menu.Dropdown>
|
</Menu.Dropdown>
|
||||||
</Menu>
|
</Menu>
|
||||||
);
|
);
|
||||||
|
|||||||
+23
-30
@@ -32,17 +32,16 @@ import { useTranslation } from "react-i18next";
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
editor: Editor;
|
editor: Editor;
|
||||||
templateMode?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const MoreInsertsGroup: FC<Props> = ({ editor, templateMode }) => {
|
export const MoreInsertsGroup: FC<Props> = ({ editor }) => {
|
||||||
const { t, i18n } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const setEmbed = (provider: string) =>
|
const setEmbed = (provider: string) =>
|
||||||
editor.chain().focus().setEmbed({ provider }).run();
|
editor.chain().focus().setEmbed({ provider }).run();
|
||||||
|
|
||||||
const insertDate = () => {
|
const insertDate = () => {
|
||||||
const currentDate = new Date().toLocaleDateString(i18n.language, {
|
const currentDate = new Date().toLocaleDateString("en-US", {
|
||||||
year: "numeric",
|
year: "numeric",
|
||||||
month: "long",
|
month: "long",
|
||||||
day: "numeric",
|
day: "numeric",
|
||||||
@@ -92,16 +91,14 @@ export const MoreInsertsGroup: FC<Props> = ({ editor, templateMode }) => {
|
|||||||
>
|
>
|
||||||
{t("Subpages")}
|
{t("Subpages")}
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
{!templateMode && (
|
<Menu.Item
|
||||||
<Menu.Item
|
leftSection={<IconRotate2 size={16} />}
|
||||||
leftSection={<IconRotate2 size={16} />}
|
onClick={() =>
|
||||||
onClick={() =>
|
editor.chain().focus().insertTransclusionSource().run()
|
||||||
editor.chain().focus().insertTransclusionSource().run()
|
}
|
||||||
}
|
>
|
||||||
>
|
{t("Synced block")}
|
||||||
{t("Synced block")}
|
</Menu.Item>
|
||||||
</Menu.Item>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Menu.Divider />
|
<Menu.Divider />
|
||||||
<Menu.Label>{t("Diagrams")}</Menu.Label>
|
<Menu.Label>{t("Diagrams")}</Menu.Label>
|
||||||
@@ -118,22 +115,18 @@ export const MoreInsertsGroup: FC<Props> = ({ editor, templateMode }) => {
|
|||||||
>
|
>
|
||||||
{t("Mermaid diagram")}
|
{t("Mermaid diagram")}
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
{!templateMode && (
|
<Menu.Item
|
||||||
<Menu.Item
|
leftSection={<IconDrawio size={16} />}
|
||||||
leftSection={<IconDrawio size={16} />}
|
onClick={() => editor.chain().focus().setDrawio().run()}
|
||||||
onClick={() => editor.chain().focus().setDrawio().run()}
|
>
|
||||||
>
|
Draw.io
|
||||||
Draw.io
|
</Menu.Item>
|
||||||
</Menu.Item>
|
<Menu.Item
|
||||||
)}
|
leftSection={<IconExcalidraw size={16} />}
|
||||||
{!templateMode && (
|
onClick={() => editor.chain().focus().setExcalidraw().run()}
|
||||||
<Menu.Item
|
>
|
||||||
leftSection={<IconExcalidraw size={16} />}
|
Excalidraw
|
||||||
onClick={() => editor.chain().focus().setExcalidraw().run()}
|
</Menu.Item>
|
||||||
>
|
|
||||||
Excalidraw
|
|
||||||
</Menu.Item>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Menu.Divider />
|
<Menu.Divider />
|
||||||
<Menu.Label>{t("Embeds")}</Menu.Label>
|
<Menu.Label>{t("Embeds")}</Menu.Label>
|
||||||
|
|||||||
@@ -43,7 +43,6 @@ import IconMermaid from "@/components/icons/icon-mermaid";
|
|||||||
import IconDrawio from "@/components/icons/icon-drawio";
|
import IconDrawio from "@/components/icons/icon-drawio";
|
||||||
import { IconColumns4 } from "@/components/icons/icon-columns-4";
|
import { IconColumns4 } from "@/components/icons/icon-columns-4";
|
||||||
import { IconColumns5 } from "@/components/icons/icon-columns-5";
|
import { IconColumns5 } from "@/components/icons/icon-columns-5";
|
||||||
import i18n from "@/i18n.ts";
|
|
||||||
import {
|
import {
|
||||||
AirtableIcon,
|
AirtableIcon,
|
||||||
FigmaIcon,
|
FigmaIcon,
|
||||||
@@ -460,7 +459,7 @@ const CommandGroups: SlashMenuGroupedItemsType = {
|
|||||||
searchTerms: ["date", "today"],
|
searchTerms: ["date", "today"],
|
||||||
icon: IconCalendar,
|
icon: IconCalendar,
|
||||||
command: ({ editor, range }: CommandProps) => {
|
command: ({ editor, range }: CommandProps) => {
|
||||||
const currentDate = new Date().toLocaleDateString(i18n.language, {
|
const currentDate = new Date().toLocaleDateString("en-US", {
|
||||||
year: "numeric",
|
year: "numeric",
|
||||||
month: "long",
|
month: "long",
|
||||||
day: "numeric",
|
day: "numeric",
|
||||||
|
|||||||
@@ -1,20 +0,0 @@
|
|||||||
import { Extension } from "@tiptap/core";
|
|
||||||
import { Plugin, PluginKey } from "@tiptap/pm/state";
|
|
||||||
|
|
||||||
export const CleanStyles = Extension.create({
|
|
||||||
name: "cleanStyles",
|
|
||||||
priority: 80,
|
|
||||||
|
|
||||||
addProseMirrorPlugins() {
|
|
||||||
return [
|
|
||||||
new Plugin({
|
|
||||||
key: new PluginKey("cleanStyles"),
|
|
||||||
props: {
|
|
||||||
transformPastedHTML(html) {
|
|
||||||
return html.replace(/\s+style="[^"]*"/gi, "");
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
];
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -3,7 +3,7 @@ import { StarterKit } from "@tiptap/starter-kit";
|
|||||||
import { Code } from "@tiptap/extension-code";
|
import { Code } from "@tiptap/extension-code";
|
||||||
import { TextAlign } from "@tiptap/extension-text-align";
|
import { TextAlign } from "@tiptap/extension-text-align";
|
||||||
import { TaskList, TaskItem } from "@tiptap/extension-list";
|
import { TaskList, TaskItem } from "@tiptap/extension-list";
|
||||||
import { Placeholder, CharacterCount, UndoRedo } from "@tiptap/extensions";
|
import { Placeholder, CharacterCount } from "@tiptap/extensions";
|
||||||
import { Superscript } from "@tiptap/extension-superscript";
|
import { Superscript } from "@tiptap/extension-superscript";
|
||||||
import SubScript from "@tiptap/extension-subscript";
|
import SubScript from "@tiptap/extension-subscript";
|
||||||
import { Typography } from "@tiptap/extension-typography";
|
import { Typography } from "@tiptap/extension-typography";
|
||||||
@@ -112,7 +112,6 @@ import EmojiCommand from "./emoji-command";
|
|||||||
import { countWords } from "alfaaz";
|
import { countWords } from "alfaaz";
|
||||||
import AutoJoiner from "@/features/editor/extensions/autojoiner.ts";
|
import AutoJoiner from "@/features/editor/extensions/autojoiner.ts";
|
||||||
import GlobalDragHandle from "@/features/editor/extensions/drag-handle.ts";
|
import GlobalDragHandle from "@/features/editor/extensions/drag-handle.ts";
|
||||||
import { CleanStyles } from "@/features/editor/extensions/clean-styles.ts";
|
|
||||||
|
|
||||||
const lowlight = createLowlight(common);
|
const lowlight = createLowlight(common);
|
||||||
lowlight.register("mermaid", plaintext);
|
lowlight.register("mermaid", plaintext);
|
||||||
@@ -384,7 +383,6 @@ export const mainExtensions = [
|
|||||||
MarkdownClipboard.configure({
|
MarkdownClipboard.configure({
|
||||||
transformPastedText: true,
|
transformPastedText: true,
|
||||||
}),
|
}),
|
||||||
CleanStyles,
|
|
||||||
CharacterCount.configure({
|
CharacterCount.configure({
|
||||||
wordCounter: (text) => countWords(text),
|
wordCounter: (text) => countWords(text),
|
||||||
}),
|
}),
|
||||||
@@ -437,7 +435,6 @@ const TemplateSlashCommand = Command.configure({
|
|||||||
export const templateExtensions = [
|
export const templateExtensions = [
|
||||||
...mainExtensions.filter((ext: any) => ext !== SlashCommand),
|
...mainExtensions.filter((ext: any) => ext !== SlashCommand),
|
||||||
TemplateSlashCommand,
|
TemplateSlashCommand,
|
||||||
UndoRedo,
|
|
||||||
] as any;
|
] as any;
|
||||||
|
|
||||||
export const collabExtensions: CollabExtensions = (provider, user) => [
|
export const collabExtensions: CollabExtensions = (provider, user) => [
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import {
|
|||||||
WebSocketStatus,
|
WebSocketStatus,
|
||||||
HocuspocusProviderWebsocket,
|
HocuspocusProviderWebsocket,
|
||||||
onSyncedParameters,
|
onSyncedParameters,
|
||||||
onStatelessParameters,
|
|
||||||
} from "@hocuspocus/provider";
|
} from "@hocuspocus/provider";
|
||||||
import {
|
import {
|
||||||
Editor,
|
Editor,
|
||||||
@@ -146,24 +145,6 @@ export default function PageEditor({
|
|||||||
const onSyncedHandler = (event: onSyncedParameters) => {
|
const onSyncedHandler = (event: onSyncedParameters) => {
|
||||||
setIsRemoteSynced(event.state);
|
setIsRemoteSynced(event.state);
|
||||||
};
|
};
|
||||||
const onStatelessHandler = ({ payload }: onStatelessParameters) => {
|
|
||||||
try {
|
|
||||||
const message = JSON.parse(payload);
|
|
||||||
if (message?.type !== "page.updated" || !message.updatedAt) return;
|
|
||||||
const pageData = queryClient.getQueryData<IPage>(["pages", slugId]);
|
|
||||||
if (pageData) {
|
|
||||||
queryClient.setQueryData(["pages", slugId], {
|
|
||||||
...pageData,
|
|
||||||
updatedAt: message.updatedAt,
|
|
||||||
...(message.lastUpdatedBy && {
|
|
||||||
lastUpdatedBy: message.lastUpdatedBy,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// ignore unrelated stateless messages
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const onAuthenticationFailedHandler = () => {
|
const onAuthenticationFailedHandler = () => {
|
||||||
const payload = jwtDecode(collabQuery?.token);
|
const payload = jwtDecode(collabQuery?.token);
|
||||||
const now = Date.now().valueOf() / 1000;
|
const now = Date.now().valueOf() / 1000;
|
||||||
@@ -188,7 +169,6 @@ export default function PageEditor({
|
|||||||
onAuthenticationFailed: onAuthenticationFailedHandler,
|
onAuthenticationFailed: onAuthenticationFailedHandler,
|
||||||
onStatus: onStatusHandler,
|
onStatus: onStatusHandler,
|
||||||
onSynced: onSyncedHandler,
|
onSynced: onSyncedHandler,
|
||||||
onStateless: onStatelessHandler,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
local.on("synced", onLocalSyncedHandler);
|
local.on("synced", onLocalSyncedHandler);
|
||||||
@@ -338,6 +318,7 @@ export default function PageEditor({
|
|||||||
queryClient.setQueryData(["pages", slugId], {
|
queryClient.setQueryData(["pages", slugId], {
|
||||||
...pageData,
|
...pageData,
|
||||||
content: newContent,
|
content: newContent,
|
||||||
|
updatedAt: new Date(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, 3000);
|
}, 3000);
|
||||||
|
|||||||
@@ -103,13 +103,13 @@
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
|
|
||||||
@mixin where-light {
|
@mixin where-light {
|
||||||
background-color: var(--mantine-color-gray-1);
|
background-color: var(--code-bg, var(--mantine-color-gray-1));
|
||||||
color: var(--mantine-color-text);
|
color: var(--mantine-color-pink-7);
|
||||||
}
|
}
|
||||||
|
|
||||||
@mixin where-dark {
|
@mixin where-dark {
|
||||||
background-color: var(--mantine-color-dark-5) !important;
|
background-color: var(--mantine-color-dark-8);
|
||||||
color: var(--mantine-color-text);
|
color: var(--mantine-color-pink-7);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -204,6 +204,10 @@
|
|||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ProseMirror table th:has(.tableReadonlySortChevron) {
|
||||||
|
padding-right: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
.tableReadonlySortChevron:hover {
|
.tableReadonlySortChevron:hover {
|
||||||
background: light-dark(
|
background: light-dark(
|
||||||
rgba(55, 53, 47, 0.16),
|
rgba(55, 53, 47, 0.16),
|
||||||
|
|||||||
@@ -91,9 +91,7 @@ export default function GroupMembersList() {
|
|||||||
<ActionIcon
|
<ActionIcon
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
c="gray"
|
c="gray"
|
||||||
aria-label={t("Member actions for {{name}}", {
|
aria-label={t("Member actions")}
|
||||||
name: user.name,
|
|
||||||
})}
|
|
||||||
>
|
>
|
||||||
<IconDots size={20} stroke={2} />
|
<IconDots size={20} stroke={2} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
|
|||||||
@@ -1,27 +1,15 @@
|
|||||||
import { isThisYear, isToday, isYesterday } from "date-fns";
|
import { format, isThisYear, isToday, isYesterday } from "date-fns";
|
||||||
import i18n from "@/i18n.ts";
|
import i18n from "@/i18n.ts";
|
||||||
import { formatLocalized, getDateFnsLocale } from "@/lib/date-locale.ts";
|
|
||||||
|
|
||||||
export function formatLabelListDate(date: Date): string {
|
export function formatLabelListDate(date: Date): string {
|
||||||
const locale = getDateFnsLocale();
|
|
||||||
if (isToday(date)) {
|
if (isToday(date)) {
|
||||||
return i18n.t("Today, {{time}}", {
|
return i18n.t("Today, {{time}}", { time: format(date, "h:mma") });
|
||||||
time: formatLocalized(date, "h:mma", "p", locale),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
if (isYesterday(date)) {
|
if (isYesterday(date)) {
|
||||||
return i18n.t("Yesterday, {{time}}", {
|
return i18n.t("Yesterday, {{time}}", { time: format(date, "h:mma") });
|
||||||
time: formatLocalized(date, "h:mma", "p", locale),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
if (isThisYear(date)) {
|
if (isThisYear(date)) {
|
||||||
if (locale.code?.startsWith("en")) {
|
return format(date, "MMM dd");
|
||||||
return formatLocalized(date, "MMM dd", "MMM dd", locale);
|
|
||||||
}
|
|
||||||
return new Intl.DateTimeFormat(i18n.language, {
|
|
||||||
month: "short",
|
|
||||||
day: "numeric",
|
|
||||||
}).format(date);
|
|
||||||
}
|
}
|
||||||
return formatLocalized(date, "MMM dd, yyyy", "PP", locale);
|
return format(date, "MMM dd, yyyy");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useId, useState } from "react";
|
import { useState } from "react";
|
||||||
import {
|
import {
|
||||||
ActionIcon,
|
ActionIcon,
|
||||||
Group,
|
Group,
|
||||||
@@ -31,7 +31,6 @@ import classes from "../notification.module.css";
|
|||||||
|
|
||||||
export function NotificationPopover() {
|
export function NotificationPopover() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const titleId = useId();
|
|
||||||
const [opened, setOpened] = useState(false);
|
const [opened, setOpened] = useState(false);
|
||||||
const [tab, setTab] = useState<NotificationTab>("direct");
|
const [tab, setTab] = useState<NotificationTab>("direct");
|
||||||
const [filter, setFilter] = useState<NotificationFilter>("all");
|
const [filter, setFilter] = useState<NotificationFilter>("all");
|
||||||
@@ -84,11 +83,10 @@ export function NotificationPopover() {
|
|||||||
|
|
||||||
<Popover.Dropdown
|
<Popover.Dropdown
|
||||||
p={0}
|
p={0}
|
||||||
aria-labelledby={titleId}
|
|
||||||
style={{ width: "min(420px, calc(100vw - 24px))" }}
|
style={{ width: "min(420px, calc(100vw - 24px))" }}
|
||||||
>
|
>
|
||||||
<Group justify="space-between" px="md" py="sm">
|
<Group justify="space-between" px="md" py="sm">
|
||||||
<Title id={titleId} order={2} fz="sm" fw={600}>
|
<Title order={2} fz="sm" fw={600}>
|
||||||
{t("Notifications")}
|
{t("Notifications")}
|
||||||
</Title>
|
</Title>
|
||||||
<Group gap={4}>
|
<Group gap={4}>
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import i18n from "@/i18n.ts";
|
|
||||||
import { INotification } from "./types/notification.types";
|
import { INotification } from "./types/notification.types";
|
||||||
|
|
||||||
export function formatRelativeTime(dateStr: string): string {
|
export function formatRelativeTime(dateStr: string): string {
|
||||||
@@ -9,15 +8,15 @@ export function formatRelativeTime(dateStr: string): string {
|
|||||||
const diffHours = Math.floor(diffMs / 3_600_000);
|
const diffHours = Math.floor(diffMs / 3_600_000);
|
||||||
const diffDays = Math.floor(diffMs / 86_400_000);
|
const diffDays = Math.floor(diffMs / 86_400_000);
|
||||||
|
|
||||||
if (diffMin < 1) return i18n.t("now");
|
if (diffMin < 1) return "now";
|
||||||
if (diffMin < 60) return `${diffMin}m`;
|
if (diffMin < 60) return `${diffMin}m`;
|
||||||
if (diffHours < 24) return `${diffHours}h`;
|
if (diffHours < 24) return `${diffHours}h`;
|
||||||
if (diffDays < 7) return `${diffDays}d`;
|
if (diffDays < 7) return `${diffDays}d`;
|
||||||
|
|
||||||
return new Intl.DateTimeFormat(i18n.language, {
|
return date.toLocaleDateString(undefined, {
|
||||||
month: "short",
|
month: "short",
|
||||||
day: "numeric",
|
day: "numeric",
|
||||||
}).format(date);
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
type TimeGroup = "today" | "yesterday" | "this_week" | "older";
|
type TimeGroup = "today" | "yesterday" | "this_week" | "older";
|
||||||
|
|||||||
@@ -16,8 +16,7 @@ import { usePageQuery } from "@/features/page/queries/page-query.ts";
|
|||||||
import { pageEditorAtom } from "@/features/editor/atoms/editor-atoms.ts";
|
import { pageEditorAtom } from "@/features/editor/atoms/editor-atoms.ts";
|
||||||
import { useBacklinksCountQuery } from "@/features/page-details/queries/backlinks-query.ts";
|
import { useBacklinksCountQuery } from "@/features/page-details/queries/backlinks-query.ts";
|
||||||
import { BacklinksModal } from "./backlinks-modal";
|
import { BacklinksModal } from "./backlinks-modal";
|
||||||
import { formattedDate } from "@/lib/time.ts";
|
import { formattedDate, timeAgo } from "@/lib/time.ts";
|
||||||
import { useTimeAgo } from "@/hooks/use-time-ago.tsx";
|
|
||||||
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
||||||
import { LabelsSection } from "@/features/label/components/labels-section.tsx";
|
import { LabelsSection } from "@/features/label/components/labels-section.tsx";
|
||||||
|
|
||||||
@@ -140,7 +139,6 @@ function StatsSection({
|
|||||||
updatedAt: Date | string;
|
updatedAt: Date | string;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const lastUpdated = useTimeAgo(updatedAt);
|
|
||||||
return (
|
return (
|
||||||
<Stack gap="xs">
|
<Stack gap="xs">
|
||||||
<Text size="xs" fw={500} c="dimmed">
|
<Text size="xs" fw={500} c="dimmed">
|
||||||
@@ -152,7 +150,10 @@ function StatsSection({
|
|||||||
label={t("Created")}
|
label={t("Created")}
|
||||||
value={formattedDate(new Date(createdAt))}
|
value={formattedDate(new Date(createdAt))}
|
||||||
/>
|
/>
|
||||||
<StatRow label={t("Last updated")} value={lastUpdated} />
|
<StatRow
|
||||||
|
label={t("Last updated")}
|
||||||
|
value={timeAgo(new Date(updatedAt))}
|
||||||
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,7 +34,6 @@ import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom.ts";
|
|||||||
import { treeModel } from "@/features/page/tree/model/tree-model";
|
import { treeModel } from "@/features/page/tree/model/tree-model";
|
||||||
import { useTreeMutation } from "@/features/page/tree/hooks/use-tree-mutation.ts";
|
import { useTreeMutation } from "@/features/page/tree/hooks/use-tree-mutation.ts";
|
||||||
import type { SpaceTreeNode } from "@/features/page/tree/types.ts";
|
import type { SpaceTreeNode } from "@/features/page/tree/types.ts";
|
||||||
import classes from "@/features/page/tree/styles/tree.module.css";
|
|
||||||
|
|
||||||
export interface NodeMenuProps {
|
export interface NodeMenuProps {
|
||||||
node: SpaceTreeNode;
|
node: SpaceTreeNode;
|
||||||
@@ -124,9 +123,8 @@ export function NodeMenu({ node, canEdit }: NodeMenuProps) {
|
|||||||
<Menu shadow="md" width={200}>
|
<Menu shadow="md" width={200}>
|
||||||
<Menu.Target>
|
<Menu.Target>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
variant="subtle"
|
variant="transparent"
|
||||||
color="gray"
|
c="gray"
|
||||||
className={classes.actionIcon}
|
|
||||||
aria-label={t("Page menu for {{name}}", { name: node.name || t("untitled") })}
|
aria-label={t("Page menu for {{name}}", { name: node.name || t("untitled") })}
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
|
|||||||
@@ -201,13 +201,13 @@ function PageArrow({ isOpen, hasChildren, onToggle }: PageArrowProps) {
|
|||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
aria-hidden
|
aria-hidden
|
||||||
className={classes.actionIcon}
|
|
||||||
style={{
|
style={{
|
||||||
width: 20,
|
width: 20,
|
||||||
height: 20,
|
height: 20,
|
||||||
display: "inline-flex",
|
display: "inline-flex",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
|
color: "var(--mantine-color-gray-6)",
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -220,8 +220,7 @@ function PageArrow({ isOpen, hasChildren, onToggle }: PageArrowProps) {
|
|||||||
<ActionIcon
|
<ActionIcon
|
||||||
size={20}
|
size={20}
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
color="gray"
|
c="gray"
|
||||||
className={classes.actionIcon}
|
|
||||||
aria-label={isOpen ? t("Collapse") : t("Expand")}
|
aria-label={isOpen ? t("Collapse") : t("Expand")}
|
||||||
aria-expanded={isOpen}
|
aria-expanded={isOpen}
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
@@ -273,9 +272,8 @@ function CreateNode({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
variant="subtle"
|
variant="transparent"
|
||||||
color="gray"
|
c="gray"
|
||||||
className={classes.actionIcon}
|
|
||||||
aria-label={t("Create subpage of {{name}}", { name: node.name || t("untitled") })}
|
aria-label={t("Create subpage of {{name}}", { name: node.name || t("untitled") })}
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
|
|||||||
@@ -57,10 +57,6 @@
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.actionIcon {
|
|
||||||
color: light-dark(var(--mantine-color-dark-3), var(--mantine-color-gray-4));
|
|
||||||
}
|
|
||||||
|
|
||||||
.text {
|
.text {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
/* min-width: 0 lets a flex child shrink below its content size — required
|
/* min-width: 0 lets a flex child shrink below its content size — required
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ import {
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useGetSpacesQuery } from "@/features/space/queries/space-query";
|
import { useGetSpacesQuery } from "@/features/space/queries/space-query";
|
||||||
import { SpaceFilterMenu } from "@/features/space/components/space-filter-menu";
|
import { SpaceFilterMenu } from "@/features/space/components/space-filter-menu";
|
||||||
import { RadioMenuItem } from "@/components/ui/radio-menu-item";
|
|
||||||
import { useHasFeature } from "@/ee/hooks/use-feature";
|
import { useHasFeature } from "@/ee/hooks/use-feature";
|
||||||
import { Feature } from "@/ee/features";
|
import { Feature } from "@/ee/features";
|
||||||
import classes from "./search-spotlight-filters.module.css";
|
import classes from "./search-spotlight-filters.module.css";
|
||||||
@@ -176,7 +175,7 @@ export function SearchSpotlightFilters({
|
|||||||
{contentTypeOptions.map((option) => (
|
{contentTypeOptions.map((option) => (
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
key={option.value}
|
key={option.value}
|
||||||
component={RadioMenuItem}
|
role="menuitemradio"
|
||||||
aria-checked={contentType === option.value}
|
aria-checked={contentType === option.value}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
!option.disabled &&
|
!option.disabled &&
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ import Paginate from "@/components/common/paginate.tsx";
|
|||||||
import { useCursorPaginate } from "@/hooks/use-cursor-paginate";
|
import { useCursorPaginate } from "@/hooks/use-cursor-paginate";
|
||||||
import { useGetSharesQuery } from "@/features/share/queries/share-query.ts";
|
import { useGetSharesQuery } from "@/features/share/queries/share-query.ts";
|
||||||
import { ISharedItem } from "@/features/share/types/share.types.ts";
|
import { ISharedItem } from "@/features/share/types/share.types.ts";
|
||||||
|
import { format } from "date-fns";
|
||||||
import ShareActionMenu from "@/features/share/components/share-action-menu.tsx";
|
import ShareActionMenu from "@/features/share/components/share-action-menu.tsx";
|
||||||
import { formatLocalized, useDateFnsLocale } from "@/lib/date-locale.ts";
|
|
||||||
import { buildSharedPageUrl } from "@/features/page/page.utils.ts";
|
import { buildSharedPageUrl } from "@/features/page/page.utils.ts";
|
||||||
import { getPageIcon } from "@/lib";
|
import { getPageIcon } from "@/lib";
|
||||||
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
||||||
@@ -20,7 +20,6 @@ export default function ShareList() {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { cursor, goNext, goPrev } = useCursorPaginate();
|
const { cursor, goNext, goPrev } = useCursorPaginate();
|
||||||
const { data, isLoading } = useGetSharesQuery({ cursor });
|
const { data, isLoading } = useGetSharesQuery({ cursor });
|
||||||
const locale = useDateFnsLocale();
|
|
||||||
|
|
||||||
if (!isLoading && data?.items.length === 0) {
|
if (!isLoading && data?.items.length === 0) {
|
||||||
return <EmptyState icon={IconWorld} title={t("No shared pages")} />;
|
return <EmptyState icon={IconWorld} title={t("No shared pages")} />;
|
||||||
@@ -82,12 +81,7 @@ export default function ShareList() {
|
|||||||
</Table.Td>
|
</Table.Td>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
<Text fz="sm" style={{ whiteSpace: "nowrap" }}>
|
<Text fz="sm" style={{ whiteSpace: "nowrap" }}>
|
||||||
{formatLocalized(
|
{format(new Date(share.createdAt), "MMM dd, yyyy")}
|
||||||
share.createdAt,
|
|
||||||
"MMM dd, yyyy",
|
|
||||||
"PP",
|
|
||||||
locale,
|
|
||||||
)}
|
|
||||||
</Text>
|
</Text>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import { useDebouncedValue } from "@mantine/hooks";
|
|||||||
import { IconCheck, IconSearch } from "@tabler/icons-react";
|
import { IconCheck, IconSearch } from "@tabler/icons-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useGetSpacesQuery } from "@/features/space/queries/space-query";
|
import { useGetSpacesQuery } from "@/features/space/queries/space-query";
|
||||||
import { RadioMenuItem } from "@/components/ui/radio-menu-item";
|
|
||||||
|
|
||||||
type SpaceFilterMenuProps = {
|
type SpaceFilterMenuProps = {
|
||||||
value: string | null;
|
value: string | null;
|
||||||
@@ -76,7 +75,7 @@ export function SpaceFilterMenu({
|
|||||||
|
|
||||||
<ScrollArea.Autosize mah={280}>
|
<ScrollArea.Autosize mah={280}>
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
component={RadioMenuItem}
|
role="menuitemradio"
|
||||||
aria-checked={!value}
|
aria-checked={!value}
|
||||||
onClick={() => onChange(null)}
|
onClick={() => onChange(null)}
|
||||||
>
|
>
|
||||||
@@ -104,7 +103,7 @@ export function SpaceFilterMenu({
|
|||||||
{orderedSpaces.map((space) => (
|
{orderedSpaces.map((space) => (
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
key={space.id}
|
key={space.id}
|
||||||
component={RadioMenuItem}
|
role="menuitemradio"
|
||||||
aria-checked={value === space.id}
|
aria-checked={value === space.id}
|
||||||
onClick={() => onChange(space.id)}
|
onClick={() => onChange(space.id)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -210,9 +210,7 @@ export default function SpaceMembersList({
|
|||||||
<ActionIcon
|
<ActionIcon
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
c="gray"
|
c="gray"
|
||||||
aria-label={t("Member actions for {{name}}", {
|
aria-label={t("Member actions")}
|
||||||
name: member.name,
|
|
||||||
})}
|
|
||||||
>
|
>
|
||||||
<IconDots size={20} stroke={2} />
|
<IconDots size={20} stroke={2} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
|
|||||||
+2
-7
@@ -12,14 +12,9 @@ import useUserRole from "@/hooks/use-user-role.tsx";
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
userId: string;
|
userId: string;
|
||||||
name: string;
|
|
||||||
deactivatedAt: Date | null;
|
deactivatedAt: Date | null;
|
||||||
}
|
}
|
||||||
export default function MemberActionMenu({
|
export default function MemberActionMenu({ userId, deactivatedAt }: Props) {
|
||||||
userId,
|
|
||||||
name,
|
|
||||||
deactivatedAt,
|
|
||||||
}: Props) {
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const deleteWorkspaceMemberMutation = useDeleteWorkspaceMemberMutation();
|
const deleteWorkspaceMemberMutation = useDeleteWorkspaceMemberMutation();
|
||||||
const deactivateMutation = useDeactivateWorkspaceMemberMutation();
|
const deactivateMutation = useDeactivateWorkspaceMemberMutation();
|
||||||
@@ -91,7 +86,7 @@ export default function MemberActionMenu({
|
|||||||
<ActionIcon
|
<ActionIcon
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
c="gray"
|
c="gray"
|
||||||
aria-label={t("Member actions for {{name}}", { name })}
|
aria-label={t("Member actions")}
|
||||||
>
|
>
|
||||||
<IconDots size={20} stroke={2} />
|
<IconDots size={20} stroke={2} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
|
|||||||
-1
@@ -111,7 +111,6 @@ export default function WorkspaceMembersTable() {
|
|||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<MemberActionMenu
|
<MemberActionMenu
|
||||||
userId={user.id}
|
userId={user.id}
|
||||||
name={user.name}
|
|
||||||
deactivatedAt={user.deactivatedAt}
|
deactivatedAt={user.deactivatedAt}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,62 +0,0 @@
|
|||||||
import { format as dateFnsFormat, type Locale } from "date-fns";
|
|
||||||
import {
|
|
||||||
de,
|
|
||||||
enUS,
|
|
||||||
es,
|
|
||||||
fr,
|
|
||||||
it,
|
|
||||||
ja,
|
|
||||||
ko,
|
|
||||||
nl,
|
|
||||||
ptBR,
|
|
||||||
ru,
|
|
||||||
uk,
|
|
||||||
zhCN,
|
|
||||||
} from "date-fns/locale";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import i18n from "@/i18n.ts";
|
|
||||||
|
|
||||||
const LOCALE_MAP: Record<string, Locale> = {
|
|
||||||
"de-DE": de,
|
|
||||||
"en-US": enUS,
|
|
||||||
"es-ES": es,
|
|
||||||
"fr-FR": fr,
|
|
||||||
"it-IT": it,
|
|
||||||
"ja-JP": ja,
|
|
||||||
"ko-KR": ko,
|
|
||||||
"nl-NL": nl,
|
|
||||||
"pt-BR": ptBR,
|
|
||||||
"ru-RU": ru,
|
|
||||||
"uk-UA": uk,
|
|
||||||
"zh-CN": zhCN,
|
|
||||||
};
|
|
||||||
|
|
||||||
export function getDateFnsLocale(language?: string): Locale {
|
|
||||||
const lang = language ?? i18n.language ?? "en-US";
|
|
||||||
return LOCALE_MAP[lang] ?? LOCALE_MAP[lang.split("-")[0]] ?? enUS;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useDateFnsLocale(): Locale {
|
|
||||||
const { i18n: instance } = useTranslation();
|
|
||||||
return getDateFnsLocale(instance.language);
|
|
||||||
}
|
|
||||||
|
|
||||||
function isEnglishLocale(locale: Locale): boolean {
|
|
||||||
return locale.code === "en-US" || locale.code?.startsWith("en") === true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Picks `enUSPattern` for the English locale and `localizedPattern` for every
|
|
||||||
* other locale. Keeps existing en-US output byte-identical while letting other
|
|
||||||
* languages use date-fns localized format tokens (P, PP, p, PPp, …).
|
|
||||||
*/
|
|
||||||
export function formatLocalized(
|
|
||||||
date: Date | number | string,
|
|
||||||
enUSPattern: string,
|
|
||||||
localizedPattern: string,
|
|
||||||
locale?: Locale,
|
|
||||||
): string {
|
|
||||||
const effective = locale ?? getDateFnsLocale();
|
|
||||||
const pattern = isEnglishLocale(effective) ? enUSPattern : localizedPattern;
|
|
||||||
return dateFnsFormat(new Date(date), pattern, { locale: effective });
|
|
||||||
}
|
|
||||||
@@ -1,25 +1,17 @@
|
|||||||
import { formatDistanceStrict, isToday, isYesterday } from "date-fns";
|
import { formatDistanceStrict } from "date-fns";
|
||||||
|
import { format, isToday, isYesterday } from "date-fns";
|
||||||
import i18n from "@/i18n.ts";
|
import i18n from "@/i18n.ts";
|
||||||
import { formatLocalized, getDateFnsLocale } from "@/lib/date-locale.ts";
|
|
||||||
|
|
||||||
export function timeAgo(date: Date) {
|
export function timeAgo(date: Date) {
|
||||||
return formatDistanceStrict(new Date(date), new Date(), {
|
return formatDistanceStrict(new Date(date), new Date(), { addSuffix: true });
|
||||||
addSuffix: true,
|
|
||||||
locale: getDateFnsLocale(),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formattedDate(date: Date) {
|
export function formattedDate(date: Date) {
|
||||||
const locale = getDateFnsLocale();
|
|
||||||
if (isToday(date)) {
|
if (isToday(date)) {
|
||||||
return i18n.t("Today, {{time}}", {
|
return i18n.t("Today, {{time}}", { time: format(date, "h:mma") });
|
||||||
time: formatLocalized(date, "h:mma", "p", locale),
|
|
||||||
});
|
|
||||||
} else if (isYesterday(date)) {
|
} else if (isYesterday(date)) {
|
||||||
return i18n.t("Yesterday, {{time}}", {
|
return i18n.t("Yesterday, {{time}}", { time: format(date, "h:mma") });
|
||||||
time: formatLocalized(date, "h:mma", "p", locale),
|
|
||||||
});
|
|
||||||
} else {
|
} else {
|
||||||
return formatLocalized(date, "MMM dd, yyyy, h:mma", "PPp", locale);
|
return format(date, "MMM dd, yyyy, h:mma");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "server",
|
"name": "server",
|
||||||
"version": "0.90.1",
|
"version": "0.90.0",
|
||||||
"description": "",
|
"description": "",
|
||||||
"author": "",
|
"author": "",
|
||||||
"private": true,
|
"private": true,
|
||||||
@@ -36,9 +36,8 @@
|
|||||||
"@aws-sdk/client-s3": "3.1050.0",
|
"@aws-sdk/client-s3": "3.1050.0",
|
||||||
"@aws-sdk/lib-storage": "3.1050.0",
|
"@aws-sdk/lib-storage": "3.1050.0",
|
||||||
"@aws-sdk/s3-request-presigner": "3.1050.0",
|
"@aws-sdk/s3-request-presigner": "3.1050.0",
|
||||||
"@azure/storage-blob": "12.31.0",
|
|
||||||
"@clickhouse/client": "^1.18.2",
|
"@clickhouse/client": "^1.18.2",
|
||||||
"@docmost/pdf-inspector": "1.9.6",
|
"@docmost/pdf-inspector": "1.9.4",
|
||||||
"@fastify/cookie": "^11.0.2",
|
"@fastify/cookie": "^11.0.2",
|
||||||
"@fastify/multipart": "^10.0.0",
|
"@fastify/multipart": "^10.0.0",
|
||||||
"@fastify/static": "^9.1.3",
|
"@fastify/static": "^9.1.3",
|
||||||
@@ -164,21 +163,7 @@
|
|||||||
"rootDir": "src",
|
"rootDir": "src",
|
||||||
"testRegex": ".*\\.spec\\.ts$",
|
"testRegex": ".*\\.spec\\.ts$",
|
||||||
"transform": {
|
"transform": {
|
||||||
"happy-dom.+\\.js$": [
|
"happy-dom.+\\.js$": ["babel-jest", { "presets": [["@babel/preset-env", { "targets": { "node": "current" } }]] }],
|
||||||
"babel-jest",
|
|
||||||
{
|
|
||||||
"presets": [
|
|
||||||
[
|
|
||||||
"@babel/preset-env",
|
|
||||||
{
|
|
||||||
"targets": {
|
|
||||||
"node": "current"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"^.+\\.(t|j)s$": "ts-jest"
|
"^.+\\.(t|j)s$": "ts-jest"
|
||||||
},
|
},
|
||||||
"transformIgnorePatterns": [
|
"transformIgnorePatterns": [
|
||||||
|
|||||||
@@ -165,21 +165,6 @@ export class PersistenceExtension implements Extension {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (page) {
|
if (page) {
|
||||||
document.broadcastStateless(
|
|
||||||
JSON.stringify({
|
|
||||||
type: 'page.updated',
|
|
||||||
updatedAt: new Date().toISOString(),
|
|
||||||
lastUpdatedById: context?.user?.id,
|
|
||||||
lastUpdatedBy: context?.user
|
|
||||||
? {
|
|
||||||
id: context.user?.id,
|
|
||||||
name: context.user?.name,
|
|
||||||
avatarUrl: context.user?.avatarUrl,
|
|
||||||
}
|
|
||||||
: undefined,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
await this.syncTransclusion(pageId, page.workspaceId, tiptapJson);
|
await this.syncTransclusion(pageId, page.workspaceId, tiptapJson);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,11 +4,6 @@ export enum UserRole {
|
|||||||
MEMBER = 'member',
|
MEMBER = 'member',
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum InviteUserRole {
|
|
||||||
ADMIN = 'admin', // can have owner permissions but cannot delete workspace
|
|
||||||
MEMBER = 'member',
|
|
||||||
}
|
|
||||||
|
|
||||||
export enum SpaceRole {
|
export enum SpaceRole {
|
||||||
ADMIN = 'admin', // can manage space settings, members, and delete space
|
ADMIN = 'admin', // can manage space settings, members, and delete space
|
||||||
WRITER = 'writer', // can read and write pages in space
|
WRITER = 'writer', // can read and write pages in space
|
||||||
|
|||||||
@@ -310,7 +310,6 @@ export class PageService {
|
|||||||
expression: 'position',
|
expression: 'position',
|
||||||
direction: 'asc',
|
direction: 'asc',
|
||||||
orderModifier: (ob) => ob.collate('C').asc(),
|
orderModifier: (ob) => ob.collate('C').asc(),
|
||||||
cursorExpression: sql`position collate "C"`,
|
|
||||||
},
|
},
|
||||||
{ expression: 'id', direction: 'asc' },
|
{ expression: 'id', direction: 'asc' },
|
||||||
],
|
],
|
||||||
@@ -482,7 +481,7 @@ export class PageService {
|
|||||||
);
|
);
|
||||||
|
|
||||||
await this.aiQueue.add(QueueJob.PAGE_MOVED_TO_SPACE, {
|
await this.aiQueue.add(QueueJob.PAGE_MOVED_TO_SPACE, {
|
||||||
pageIds: pageIdsToMove,
|
pageId: pageIdsToMove,
|
||||||
workspaceId: rootPage.workspaceId,
|
workspaceId: rootPage.workspaceId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,320 @@
|
|||||||
|
import { Test } from '@nestjs/testing';
|
||||||
|
import { TransclusionService } from '../transclusion.service';
|
||||||
|
import { PageTransclusionsRepo } from '@docmost/db/repos/page-transclusions/page-transclusions.repo';
|
||||||
|
import { PageTransclusionReferencesRepo } from '@docmost/db/repos/page-transclusions/page-transclusion-references.repo';
|
||||||
|
import { PageRepo } from '@docmost/db/repos/page/page.repo';
|
||||||
|
import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo';
|
||||||
|
import { AttachmentRepo } from '@docmost/db/repos/attachment/attachment.repo';
|
||||||
|
import { StorageService } from '../../../../integrations/storage/storage.service';
|
||||||
|
import { PageAccessService } from '../../page-access/page-access.service';
|
||||||
|
|
||||||
|
describe('TransclusionService.syncPageTransclusions', () => {
|
||||||
|
let service: TransclusionService;
|
||||||
|
let repo: jest.Mocked<PageTransclusionsRepo>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const mockRepo: jest.Mocked<Partial<PageTransclusionsRepo>> = {
|
||||||
|
findByPageId: jest.fn(),
|
||||||
|
insert: jest.fn(),
|
||||||
|
update: jest.fn(),
|
||||||
|
deleteByPageAndTransclusionIds: jest.fn(),
|
||||||
|
};
|
||||||
|
const module = await Test.createTestingModule({
|
||||||
|
providers: [
|
||||||
|
TransclusionService,
|
||||||
|
{ provide: PageTransclusionsRepo, useValue: mockRepo },
|
||||||
|
{ provide: PageTransclusionReferencesRepo, useValue: {} },
|
||||||
|
{ provide: PageRepo, useValue: {} },
|
||||||
|
{ provide: PagePermissionRepo, useValue: {} },
|
||||||
|
{ provide: AttachmentRepo, useValue: {} },
|
||||||
|
{ provide: StorageService, useValue: {} },
|
||||||
|
{ provide: PageAccessService, useValue: {} },
|
||||||
|
],
|
||||||
|
}).compile();
|
||||||
|
service = module.get(TransclusionService);
|
||||||
|
repo = module.get(PageTransclusionsRepo);
|
||||||
|
});
|
||||||
|
|
||||||
|
const pageId = '00000000-0000-0000-0000-000000000001';
|
||||||
|
const workspaceId = '00000000-0000-0000-0000-000000000099';
|
||||||
|
|
||||||
|
it('inserts new transclusions that did not exist before', async () => {
|
||||||
|
repo.findByPageId.mockResolvedValue([]);
|
||||||
|
const pm = {
|
||||||
|
type: 'doc',
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'transclusionSource',
|
||||||
|
attrs: { id: 'a' },
|
||||||
|
content: [{ type: 'paragraph' }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await service.syncPageTransclusions(pageId, workspaceId, pm);
|
||||||
|
|
||||||
|
expect(result).toEqual({ inserted: 1, updated: 0, deleted: 0 });
|
||||||
|
expect(repo.insert).toHaveBeenCalledTimes(1);
|
||||||
|
expect(repo.insert).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
pageId,
|
||||||
|
transclusionId: 'a',
|
||||||
|
}),
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
expect(repo.update).not.toHaveBeenCalled();
|
||||||
|
expect(repo.deleteByPageAndTransclusionIds).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates transclusions whose content changed', async () => {
|
||||||
|
repo.findByPageId.mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: 'row1',
|
||||||
|
pageId,
|
||||||
|
transclusionId: 'a',
|
||||||
|
content: { type: 'doc', content: [{ type: 'paragraph' }] },
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
} as any,
|
||||||
|
]);
|
||||||
|
const newContent = {
|
||||||
|
type: 'doc',
|
||||||
|
content: [
|
||||||
|
{ type: 'paragraph', content: [{ type: 'text', text: 'X' }] },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const pm = {
|
||||||
|
type: 'doc',
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'transclusionSource',
|
||||||
|
attrs: { id: 'a' },
|
||||||
|
content: newContent.content,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await service.syncPageTransclusions(pageId, workspaceId, pm);
|
||||||
|
|
||||||
|
expect(result).toEqual({ inserted: 0, updated: 1, deleted: 0 });
|
||||||
|
expect(repo.update).toHaveBeenCalledWith(
|
||||||
|
pageId,
|
||||||
|
'a',
|
||||||
|
expect.objectContaining({ content: newContent }),
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips update when content is unchanged', async () => {
|
||||||
|
const sameContent = {
|
||||||
|
type: 'doc',
|
||||||
|
content: [{ type: 'paragraph' }],
|
||||||
|
};
|
||||||
|
repo.findByPageId.mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: 'row1',
|
||||||
|
pageId,
|
||||||
|
transclusionId: 'a',
|
||||||
|
content: sameContent,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
} as any,
|
||||||
|
]);
|
||||||
|
const pm = {
|
||||||
|
type: 'doc',
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'transclusionSource',
|
||||||
|
attrs: { id: 'a' },
|
||||||
|
content: sameContent.content,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await service.syncPageTransclusions(pageId, workspaceId, pm);
|
||||||
|
|
||||||
|
expect(result).toEqual({ inserted: 0, updated: 0, deleted: 0 });
|
||||||
|
expect(repo.update).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deletes transclusions that no longer appear in the doc', async () => {
|
||||||
|
repo.findByPageId.mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: 'r',
|
||||||
|
pageId,
|
||||||
|
transclusionId: 'gone',
|
||||||
|
content: { type: 'doc', content: [] },
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
} as any,
|
||||||
|
]);
|
||||||
|
const pm = { type: 'doc', content: [{ type: 'paragraph' }] };
|
||||||
|
|
||||||
|
const result = await service.syncPageTransclusions(pageId, workspaceId, pm);
|
||||||
|
|
||||||
|
expect(result).toEqual({ inserted: 0, updated: 0, deleted: 1 });
|
||||||
|
expect(repo.deleteByPageAndTransclusionIds).toHaveBeenCalledWith(
|
||||||
|
pageId,
|
||||||
|
['gone'],
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles empty doc → noop', async () => {
|
||||||
|
repo.findByPageId.mockResolvedValue([]);
|
||||||
|
const result = await service.syncPageTransclusions(pageId, workspaceId, null);
|
||||||
|
expect(result).toEqual({ inserted: 0, updated: 0, deleted: 0 });
|
||||||
|
expect(repo.insert).not.toHaveBeenCalled();
|
||||||
|
expect(repo.update).not.toHaveBeenCalled();
|
||||||
|
expect(repo.deleteByPageAndTransclusionIds).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('TransclusionService.syncPageReferences', () => {
|
||||||
|
let service: TransclusionService;
|
||||||
|
let refRepo: jest.Mocked<PageTransclusionReferencesRepo>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const mockTransclusionsRepo: Partial<PageTransclusionsRepo> = {};
|
||||||
|
const mockRefRepo: jest.Mocked<Partial<PageTransclusionReferencesRepo>> = {
|
||||||
|
findByReferencePageId: jest.fn(),
|
||||||
|
insertMany: jest.fn(),
|
||||||
|
deleteByReferenceAndKeys: jest.fn(),
|
||||||
|
};
|
||||||
|
const module = await Test.createTestingModule({
|
||||||
|
providers: [
|
||||||
|
TransclusionService,
|
||||||
|
{ provide: PageTransclusionsRepo, useValue: mockTransclusionsRepo },
|
||||||
|
{ provide: PageTransclusionReferencesRepo, useValue: mockRefRepo },
|
||||||
|
{ provide: PageRepo, useValue: {} },
|
||||||
|
{ provide: PagePermissionRepo, useValue: {} },
|
||||||
|
{ provide: AttachmentRepo, useValue: {} },
|
||||||
|
{ provide: StorageService, useValue: {} },
|
||||||
|
{ provide: PageAccessService, useValue: {} },
|
||||||
|
],
|
||||||
|
}).compile();
|
||||||
|
service = module.get(TransclusionService);
|
||||||
|
refRepo = module.get(PageTransclusionReferencesRepo);
|
||||||
|
});
|
||||||
|
|
||||||
|
const referencePageId = '00000000-0000-0000-0000-000000000001';
|
||||||
|
const workspaceId = '00000000-0000-0000-0000-000000000099';
|
||||||
|
|
||||||
|
it('inserts new loose references, no deletes when none existed', async () => {
|
||||||
|
refRepo.findByReferencePageId.mockResolvedValue([]);
|
||||||
|
const pm = {
|
||||||
|
type: 'doc',
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'transclusionReference',
|
||||||
|
attrs: { sourcePageId: 'p1', transclusionId: 'e1' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'transclusionReference',
|
||||||
|
attrs: { sourcePageId: 'p2', transclusionId: 'e2' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await service.syncPageReferences(referencePageId, workspaceId, pm);
|
||||||
|
|
||||||
|
expect(result).toEqual({ inserted: 2, deleted: 0 });
|
||||||
|
expect(refRepo.insertMany).toHaveBeenCalledWith(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
workspaceId,
|
||||||
|
referencePageId,
|
||||||
|
sourcePageId: 'p1',
|
||||||
|
transclusionId: 'e1',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
workspaceId,
|
||||||
|
referencePageId,
|
||||||
|
sourcePageId: 'p2',
|
||||||
|
transclusionId: 'e2',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
expect(refRepo.deleteByReferenceAndKeys).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores references nested inside a source (schema-forbidden)', async () => {
|
||||||
|
refRepo.findByReferencePageId.mockResolvedValue([]);
|
||||||
|
const pm = {
|
||||||
|
type: 'doc',
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'transclusionSource',
|
||||||
|
attrs: { id: 's1' },
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'transclusionReference',
|
||||||
|
attrs: { sourcePageId: 'p2', transclusionId: 'e2' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await service.syncPageReferences(referencePageId, workspaceId, pm);
|
||||||
|
|
||||||
|
expect(result).toEqual({ inserted: 0, deleted: 0 });
|
||||||
|
expect(refRepo.insertMany).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deletes references that no longer appear', async () => {
|
||||||
|
refRepo.findByReferencePageId.mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: 'r1',
|
||||||
|
referencePageId,
|
||||||
|
sourcePageId: 'p1',
|
||||||
|
transclusionId: 'e1',
|
||||||
|
createdAt: new Date(),
|
||||||
|
} as any,
|
||||||
|
]);
|
||||||
|
const pm = { type: 'doc', content: [{ type: 'paragraph' }] };
|
||||||
|
|
||||||
|
const result = await service.syncPageReferences(referencePageId, workspaceId, pm);
|
||||||
|
|
||||||
|
expect(result).toEqual({ inserted: 0, deleted: 1 });
|
||||||
|
expect(refRepo.deleteByReferenceAndKeys).toHaveBeenCalledWith(
|
||||||
|
referencePageId,
|
||||||
|
[
|
||||||
|
{
|
||||||
|
sourcePageId: 'p1',
|
||||||
|
transclusionId: 'e1',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
expect(refRepo.insertMany).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is a no-op when desired matches existing exactly', async () => {
|
||||||
|
refRepo.findByReferencePageId.mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: 'r',
|
||||||
|
referencePageId,
|
||||||
|
sourcePageId: 'p1',
|
||||||
|
transclusionId: 'e1',
|
||||||
|
createdAt: new Date(),
|
||||||
|
} as any,
|
||||||
|
]);
|
||||||
|
const pm = {
|
||||||
|
type: 'doc',
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'transclusionReference',
|
||||||
|
attrs: { sourcePageId: 'p1', transclusionId: 'e1' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await service.syncPageReferences(referencePageId, workspaceId, pm);
|
||||||
|
|
||||||
|
expect(result).toEqual({ inserted: 0, deleted: 0 });
|
||||||
|
expect(refRepo.insertMany).not.toHaveBeenCalled();
|
||||||
|
expect(refRepo.deleteByReferenceAndKeys).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -6,13 +6,11 @@ import {
|
|||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { isDeepStrictEqual } from 'node:util';
|
import { isDeepStrictEqual } from 'node:util';
|
||||||
import { v7 as uuid7 } from 'uuid';
|
import { v7 as uuid7 } from 'uuid';
|
||||||
import { InjectKysely } from 'nestjs-kysely';
|
import { KyselyTransaction } from '@docmost/db/types/kysely.types';
|
||||||
import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types';
|
|
||||||
import { PageTransclusionsRepo } from '@docmost/db/repos/page-transclusions/page-transclusions.repo';
|
import { PageTransclusionsRepo } from '@docmost/db/repos/page-transclusions/page-transclusions.repo';
|
||||||
import { PageTransclusionReferencesRepo } from '@docmost/db/repos/page-transclusions/page-transclusion-references.repo';
|
import { PageTransclusionReferencesRepo } from '@docmost/db/repos/page-transclusions/page-transclusion-references.repo';
|
||||||
import { PageRepo } from '@docmost/db/repos/page/page.repo';
|
import { PageRepo } from '@docmost/db/repos/page/page.repo';
|
||||||
import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo';
|
import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo';
|
||||||
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
|
|
||||||
import { AttachmentRepo } from '@docmost/db/repos/attachment/attachment.repo';
|
import { AttachmentRepo } from '@docmost/db/repos/attachment/attachment.repo';
|
||||||
import { StorageService } from '../../../integrations/storage/storage.service';
|
import { StorageService } from '../../../integrations/storage/storage.service';
|
||||||
import {
|
import {
|
||||||
@@ -38,12 +36,10 @@ export class TransclusionService {
|
|||||||
private readonly logger = new Logger(TransclusionService.name);
|
private readonly logger = new Logger(TransclusionService.name);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@InjectKysely() private readonly db: KyselyDB,
|
|
||||||
private readonly pageTransclusionsRepo: PageTransclusionsRepo,
|
private readonly pageTransclusionsRepo: PageTransclusionsRepo,
|
||||||
private readonly pageTransclusionReferencesRepo: PageTransclusionReferencesRepo,
|
private readonly pageTransclusionReferencesRepo: PageTransclusionReferencesRepo,
|
||||||
private readonly pageRepo: PageRepo,
|
private readonly pageRepo: PageRepo,
|
||||||
private readonly pagePermissionRepo: PagePermissionRepo,
|
private readonly pagePermissionRepo: PagePermissionRepo,
|
||||||
private readonly spaceMemberRepo: SpaceMemberRepo,
|
|
||||||
private readonly attachmentRepo: AttachmentRepo,
|
private readonly attachmentRepo: AttachmentRepo,
|
||||||
private readonly storageService: StorageService,
|
private readonly storageService: StorageService,
|
||||||
private readonly pageAccessService: PageAccessService,
|
private readonly pageAccessService: PageAccessService,
|
||||||
@@ -217,40 +213,6 @@ export class TransclusionService {
|
|||||||
return { inserted: rows.length };
|
return { inserted: rows.length };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolve viewer access for source page IDs supplied by an authenticated
|
|
||||||
* caller. Restricts candidates to pages the viewer can see at the space
|
|
||||||
* level before applying page-level restrictions, so a workspace member
|
|
||||||
* cannot read a sync block from a private space they don't belong to via
|
|
||||||
* an unrestricted source page.
|
|
||||||
*/
|
|
||||||
private async filterViewerAccessiblePageIds(
|
|
||||||
pageIds: string[],
|
|
||||||
viewerUserId: string,
|
|
||||||
workspaceId: string,
|
|
||||||
): Promise<string[]> {
|
|
||||||
if (pageIds.length === 0) return [];
|
|
||||||
|
|
||||||
const spaceVisible = await this.db
|
|
||||||
.selectFrom('pages')
|
|
||||||
.select('id')
|
|
||||||
.where('id', 'in', pageIds)
|
|
||||||
.where('workspaceId', '=', workspaceId)
|
|
||||||
.where('deletedAt', 'is', null)
|
|
||||||
.where(
|
|
||||||
'spaceId',
|
|
||||||
'in',
|
|
||||||
this.spaceMemberRepo.getUserSpaceIdsQuery(viewerUserId),
|
|
||||||
)
|
|
||||||
.execute();
|
|
||||||
if (spaceVisible.length === 0) return [];
|
|
||||||
|
|
||||||
return this.pagePermissionRepo.filterAccessiblePageIds({
|
|
||||||
pageIds: spaceVisible.map((r) => r.id),
|
|
||||||
userId: viewerUserId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async lookup(
|
async lookup(
|
||||||
references: Array<{ sourcePageId: string; transclusionId: string }>,
|
references: Array<{ sourcePageId: string; transclusionId: string }>,
|
||||||
viewerUserId: string,
|
viewerUserId: string,
|
||||||
@@ -262,11 +224,10 @@ export class TransclusionService {
|
|||||||
new Set(references.map((r) => r.sourcePageId)),
|
new Set(references.map((r) => r.sourcePageId)),
|
||||||
);
|
);
|
||||||
const accessibleSet = new Set(
|
const accessibleSet = new Set(
|
||||||
await this.filterViewerAccessiblePageIds(
|
await this.pagePermissionRepo.filterAccessiblePageIds({
|
||||||
candidatePageIds,
|
pageIds: candidatePageIds,
|
||||||
viewerUserId,
|
userId: viewerUserId,
|
||||||
workspaceId,
|
}),
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return this.lookupWithAccessSet(references, accessibleSet, workspaceId);
|
return this.lookupWithAccessSet(references, accessibleSet, workspaceId);
|
||||||
@@ -375,11 +336,10 @@ export class TransclusionService {
|
|||||||
new Set([sourcePageId, ...referencePageIds]),
|
new Set([sourcePageId, ...referencePageIds]),
|
||||||
);
|
);
|
||||||
const accessibleSet = new Set(
|
const accessibleSet = new Set(
|
||||||
await this.filterViewerAccessiblePageIds(
|
await this.pagePermissionRepo.filterAccessiblePageIds({
|
||||||
candidatePageIds,
|
pageIds: candidatePageIds,
|
||||||
viewerUserId,
|
userId: viewerUserId,
|
||||||
workspaceId,
|
}),
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const accessibleIds = candidatePageIds.filter((id) =>
|
const accessibleIds = candidatePageIds.filter((id) =>
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
MaxLength,
|
MaxLength,
|
||||||
MinLength,
|
MinLength,
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
import { InviteUserRole } from '../../../common/helpers/types/permission';
|
import { UserRole } from '../../../common/helpers/types/permission';
|
||||||
import { NoUrls } from '../../../common/validators/no-urls.validator';
|
import { NoUrls } from '../../../common/validators/no-urls.validator';
|
||||||
|
|
||||||
export class InviteUserDto {
|
export class InviteUserDto {
|
||||||
@@ -32,7 +32,7 @@ export class InviteUserDto {
|
|||||||
@IsUUID('all', { each: true })
|
@IsUUID('all', { each: true })
|
||||||
groupIds: string[];
|
groupIds: string[];
|
||||||
|
|
||||||
@IsEnum(InviteUserRole)
|
@IsEnum(UserRole)
|
||||||
role: string;
|
role: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
BadRequestException,
|
BadRequestException,
|
||||||
ForbiddenException,
|
|
||||||
Inject,
|
Inject,
|
||||||
Injectable,
|
Injectable,
|
||||||
Logger,
|
Logger,
|
||||||
@@ -41,7 +40,6 @@ import {
|
|||||||
AUDIT_SERVICE,
|
AUDIT_SERVICE,
|
||||||
IAuditService,
|
IAuditService,
|
||||||
} from '../../../integrations/audit/audit.service';
|
} from '../../../integrations/audit/audit.service';
|
||||||
import { isAdminActingOnOwner } from '../workspace.util';
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class WorkspaceInvitationService {
|
export class WorkspaceInvitationService {
|
||||||
@@ -121,10 +119,6 @@ export class WorkspaceInvitationService {
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const { emails, role, groupIds } = inviteUserDto;
|
const { emails, role, groupIds } = inviteUserDto;
|
||||||
|
|
||||||
if (isAdminActingOnOwner(authUser.role, role)) {
|
|
||||||
throw new ForbiddenException();
|
|
||||||
}
|
|
||||||
|
|
||||||
let invites: WorkspaceInvitation[] = [];
|
let invites: WorkspaceInvitation[] = [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -30,7 +30,6 @@ import { DomainService } from '../../../integrations/environment/domain.service'
|
|||||||
import { jsonArrayFrom } from 'kysely/helpers/postgres';
|
import { jsonArrayFrom } from 'kysely/helpers/postgres';
|
||||||
import { addDays } from 'date-fns';
|
import { addDays } from 'date-fns';
|
||||||
import { DISALLOWED_HOSTNAMES, WorkspaceStatus } from '../workspace.constants';
|
import { DISALLOWED_HOSTNAMES, WorkspaceStatus } from '../workspace.constants';
|
||||||
import { isAdminActingOnOwner } from '../workspace.util';
|
|
||||||
import { v4 } from 'uuid';
|
import { v4 } from 'uuid';
|
||||||
import { InjectQueue } from '@nestjs/bullmq';
|
import { InjectQueue } from '@nestjs/bullmq';
|
||||||
import { QueueJob, QueueName } from '../../../integrations/queue/constants';
|
import { QueueJob, QueueName } from '../../../integrations/queue/constants';
|
||||||
@@ -591,8 +590,8 @@ export class WorkspaceService {
|
|||||||
|
|
||||||
// prevent ADMIN from managing OWNER role
|
// prevent ADMIN from managing OWNER role
|
||||||
if (
|
if (
|
||||||
isAdminActingOnOwner(authUser.role, newRole) ||
|
(authUser.role === UserRole.ADMIN && newRole === UserRole.OWNER) ||
|
||||||
isAdminActingOnOwner(authUser.role, user.role)
|
(authUser.role === UserRole.ADMIN && user.role === UserRole.OWNER)
|
||||||
) {
|
) {
|
||||||
throw new ForbiddenException();
|
throw new ForbiddenException();
|
||||||
}
|
}
|
||||||
@@ -696,7 +695,7 @@ export class WorkspaceService {
|
|||||||
throw new BadRequestException('You cannot deactivate yourself');
|
throw new BadRequestException('You cannot deactivate yourself');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isAdminActingOnOwner(authUser.role, user.role)) {
|
if (authUser.role === UserRole.ADMIN && user.role === UserRole.OWNER) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
'You cannot deactivate a user with owner role',
|
'You cannot deactivate a user with owner role',
|
||||||
);
|
);
|
||||||
@@ -754,7 +753,7 @@ export class WorkspaceService {
|
|||||||
throw new BadRequestException('User is not deactivated');
|
throw new BadRequestException('User is not deactivated');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isAdminActingOnOwner(authUser.role, user.role)) {
|
if (authUser.role === UserRole.ADMIN && user.role === UserRole.OWNER) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
'You cannot activate a user with owner role',
|
'You cannot activate a user with owner role',
|
||||||
);
|
);
|
||||||
@@ -806,7 +805,7 @@ export class WorkspaceService {
|
|||||||
throw new BadRequestException('You cannot delete yourself');
|
throw new BadRequestException('You cannot delete yourself');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isAdminActingOnOwner(authUser.role, user.role)) {
|
if (authUser.role === UserRole.ADMIN && user.role === UserRole.OWNER) {
|
||||||
throw new BadRequestException('You cannot delete a user with owner role');
|
throw new BadRequestException('You cannot delete a user with owner role');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +0,0 @@
|
|||||||
import { UserRole } from '../../common/helpers/types/permission';
|
|
||||||
|
|
||||||
export function isAdminActingOnOwner(
|
|
||||||
authUserRole: string,
|
|
||||||
targetRole: string,
|
|
||||||
): boolean {
|
|
||||||
return authUserRole === UserRole.ADMIN && targetRole === UserRole.OWNER;
|
|
||||||
}
|
|
||||||
@@ -14,14 +14,12 @@ type SortField<DB, TB extends keyof DB, O> =
|
|||||||
| (StringReference<DB, TB> & `${string}.${keyof O & string}`);
|
| (StringReference<DB, TB> & `${string}.${keyof O & string}`);
|
||||||
direction: OrderByDirection;
|
direction: OrderByDirection;
|
||||||
orderModifier?: OrderByModifiers;
|
orderModifier?: OrderByModifiers;
|
||||||
cursorExpression?: ReferenceExpression<DB, TB>;
|
|
||||||
key?: keyof O & string;
|
key?: keyof O & string;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
expression: ReferenceExpression<DB, TB>;
|
expression: ReferenceExpression<DB, TB>;
|
||||||
direction: OrderByDirection;
|
direction: OrderByDirection;
|
||||||
orderModifier?: OrderByModifiers;
|
orderModifier?: OrderByModifiers;
|
||||||
cursorExpression?: ReferenceExpression<DB, TB>;
|
|
||||||
key: keyof O & string;
|
key: keyof O & string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -204,12 +202,11 @@ export async function executeWithCursorPagination<
|
|||||||
|
|
||||||
const comparison = field.direction === defaultDirection ? '>' : '<';
|
const comparison = field.direction === defaultDirection ? '>' : '<';
|
||||||
const value = cursor[field.key as keyof typeof cursor];
|
const value = cursor[field.key as keyof typeof cursor];
|
||||||
const compareExpr = field.cursorExpression ?? field.expression;
|
|
||||||
|
|
||||||
const conditions = [eb(compareExpr, comparison, value)];
|
const conditions = [eb(field.expression, comparison, value)];
|
||||||
|
|
||||||
if (expression) {
|
if (expression) {
|
||||||
conditions.push(and([eb(compareExpr, '=', value), expression]));
|
conditions.push(and([eb(field.expression, '=', value), expression]));
|
||||||
}
|
}
|
||||||
|
|
||||||
expression = or(conditions);
|
expression = or(conditions);
|
||||||
|
|||||||
+1
-1
Submodule apps/server/src/ee updated: e7320a5a0f...b30e92f6a0
@@ -122,26 +122,6 @@ export class EnvironmentService {
|
|||||||
return this.configService.get<string>('AWS_S3_URL');
|
return this.configService.get<string>('AWS_S3_URL');
|
||||||
}
|
}
|
||||||
|
|
||||||
getAzureStorageAccountName(): string {
|
|
||||||
return this.configService.get<string>('AZURE_STORAGE_ACCOUNT_NAME');
|
|
||||||
}
|
|
||||||
|
|
||||||
getAzureStorageContainer(): string {
|
|
||||||
return this.configService.get<string>('AZURE_STORAGE_CONTAINER');
|
|
||||||
}
|
|
||||||
|
|
||||||
getAzureStorageAccountKey(): string {
|
|
||||||
return this.configService.get<string>('AZURE_STORAGE_ACCOUNT_KEY');
|
|
||||||
}
|
|
||||||
|
|
||||||
getAzureStorageEndpoint(): string {
|
|
||||||
return this.configService.get<string>('AZURE_STORAGE_ENDPOINT');
|
|
||||||
}
|
|
||||||
|
|
||||||
getAzureStorageUrl(): string {
|
|
||||||
return this.configService.get<string>('AZURE_STORAGE_URL');
|
|
||||||
}
|
|
||||||
|
|
||||||
getMailDriver(): string {
|
getMailDriver(): string {
|
||||||
return this.configService.get<string>('MAIL_DRIVER', 'log');
|
return this.configService.get<string>('MAIL_DRIVER', 'log');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ export class EnvironmentVariables {
|
|||||||
MAIL_DRIVER: string;
|
MAIL_DRIVER: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsIn(['local', 's3', 'azure'])
|
@IsIn(['local', 's3'])
|
||||||
STORAGE_DRIVER: string;
|
STORAGE_DRIVER: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
|
|||||||
@@ -66,11 +66,8 @@ export class FileTaskProcessor extends WorkerHost implements OnModuleDestroy {
|
|||||||
|
|
||||||
@OnWorkerEvent('failed')
|
@OnWorkerEvent('failed')
|
||||||
async onFailed(job: Job) {
|
async onFailed(job: Job) {
|
||||||
const fileTaskId = job.data?.fileTaskId;
|
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
fileTaskId
|
`Error processing ${job.name} job. File Task ID: ${job.data?.fileTaskId}. Reason: ${job.failedReason}`,
|
||||||
? `Error processing ${job.name} job. File Task ID: ${fileTaskId}. Reason: ${job.failedReason}`
|
|
||||||
: `Error processing ${job.name} job. Reason: ${job.failedReason}`,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (job.name === QueueJob.IMPORT_TASK) {
|
if (job.name === QueueJob.IMPORT_TASK) {
|
||||||
@@ -82,11 +79,8 @@ export class FileTaskProcessor extends WorkerHost implements OnModuleDestroy {
|
|||||||
|
|
||||||
@OnWorkerEvent('completed')
|
@OnWorkerEvent('completed')
|
||||||
async onCompleted(job: Job) {
|
async onCompleted(job: Job) {
|
||||||
const fileTaskId = job.data?.fileTaskId;
|
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
fileTaskId
|
`Completed ${job.name} job for File task ID ${job.data?.fileTaskId}`,
|
||||||
? `Completed ${job.name} job for File task ID ${fileTaskId}`
|
|
||||||
: `Completed ${job.name} job`,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (job.name === QueueJob.IMPORT_TASK) {
|
if (job.name === QueueJob.IMPORT_TASK) {
|
||||||
|
|||||||
@@ -1,192 +0,0 @@
|
|||||||
import { Readable } from 'stream';
|
|
||||||
import {
|
|
||||||
AzureStorageConfig,
|
|
||||||
StorageDriver,
|
|
||||||
StorageOption,
|
|
||||||
} from '../interfaces';
|
|
||||||
import {
|
|
||||||
BlobSASPermissions,
|
|
||||||
BlobServiceClient,
|
|
||||||
BlockBlobClient,
|
|
||||||
ContainerClient,
|
|
||||||
generateBlobSASQueryParameters,
|
|
||||||
SASProtocol,
|
|
||||||
StorageSharedKeyCredential,
|
|
||||||
} from '@azure/storage-blob';
|
|
||||||
import { Logger } from '@nestjs/common';
|
|
||||||
import { getMimeType } from '../../../common/helpers';
|
|
||||||
|
|
||||||
export class AzureDriver implements StorageDriver {
|
|
||||||
private readonly config: AzureStorageConfig;
|
|
||||||
private readonly blobServiceClient: BlobServiceClient;
|
|
||||||
private readonly containerClient: ContainerClient;
|
|
||||||
private readonly sharedKeyCredential: StorageSharedKeyCredential;
|
|
||||||
private readonly accountUrl: string;
|
|
||||||
|
|
||||||
constructor(config: AzureStorageConfig) {
|
|
||||||
this.config = config;
|
|
||||||
|
|
||||||
if (!config.accountName) {
|
|
||||||
throw new Error('AzureDriver: accountName is required');
|
|
||||||
}
|
|
||||||
if (!config.container) {
|
|
||||||
throw new Error('AzureDriver: container is required');
|
|
||||||
}
|
|
||||||
if (!config.accountKey) {
|
|
||||||
throw new Error('AzureDriver: accountKey is required');
|
|
||||||
}
|
|
||||||
|
|
||||||
this.accountUrl =
|
|
||||||
config.endpoint ??
|
|
||||||
`https://${config.accountName}.blob.core.windows.net`;
|
|
||||||
|
|
||||||
this.sharedKeyCredential = new StorageSharedKeyCredential(
|
|
||||||
config.accountName,
|
|
||||||
config.accountKey,
|
|
||||||
);
|
|
||||||
|
|
||||||
this.blobServiceClient = this.createBlobServiceClient();
|
|
||||||
this.containerClient = this.blobServiceClient.getContainerClient(
|
|
||||||
config.container,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private blockBlob(filePath: string): BlockBlobClient {
|
|
||||||
return this.containerClient.getBlockBlobClient(filePath);
|
|
||||||
}
|
|
||||||
|
|
||||||
async upload(filePath: string, file: Buffer | Readable): Promise<void> {
|
|
||||||
const stream: Readable = Buffer.isBuffer(file) ? Readable.from(file) : file;
|
|
||||||
await this.uploadStream(filePath, stream);
|
|
||||||
}
|
|
||||||
|
|
||||||
async uploadStream(
|
|
||||||
filePath: string,
|
|
||||||
file: Readable,
|
|
||||||
options?: { recreateClient?: boolean },
|
|
||||||
): Promise<void> {
|
|
||||||
const clientToUse = options?.recreateClient
|
|
||||||
? this.createBlobServiceClient()
|
|
||||||
.getContainerClient(this.config.container)
|
|
||||||
.getBlockBlobClient(filePath)
|
|
||||||
: this.blockBlob(filePath);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const contentType = getMimeType(filePath);
|
|
||||||
await clientToUse.uploadStream(file, undefined, undefined, {
|
|
||||||
blobHTTPHeaders: { blobContentType: contentType },
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
Logger.error(err);
|
|
||||||
throw new Error(`Failed to upload file: ${(err as Error).message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async copy(fromFilePath: string, toFilePath: string): Promise<void> {
|
|
||||||
try {
|
|
||||||
if (!(await this.exists(fromFilePath))) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const sourceUrl = await this.getSignedUrl(fromFilePath, 60);
|
|
||||||
const dest = this.blockBlob(toFilePath);
|
|
||||||
await dest.syncCopyFromURL(sourceUrl);
|
|
||||||
} catch (err) {
|
|
||||||
throw new Error(`Failed to copy file: ${(err as Error).message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async read(filePath: string): Promise<Buffer> {
|
|
||||||
try {
|
|
||||||
return await this.blockBlob(filePath).downloadToBuffer();
|
|
||||||
} catch (err) {
|
|
||||||
throw new Error(
|
|
||||||
`Failed to read file from Azure: ${(err as Error).message}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async readStream(filePath: string): Promise<Readable> {
|
|
||||||
try {
|
|
||||||
const response = await this.blockBlob(filePath).download();
|
|
||||||
return response.readableStreamBody as Readable;
|
|
||||||
} catch (err) {
|
|
||||||
throw new Error(
|
|
||||||
`Failed to read file from Azure: ${(err as Error).message}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async readRangeStream(
|
|
||||||
filePath: string,
|
|
||||||
range: { start: number; end: number },
|
|
||||||
): Promise<Readable> {
|
|
||||||
try {
|
|
||||||
const count = range.end - range.start + 1;
|
|
||||||
const response = await this.blockBlob(filePath).download(
|
|
||||||
range.start,
|
|
||||||
count,
|
|
||||||
);
|
|
||||||
return response.readableStreamBody as Readable;
|
|
||||||
} catch (err) {
|
|
||||||
throw new Error(
|
|
||||||
`Failed to read file from Azure: ${(err as Error).message}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async exists(filePath: string): Promise<boolean> {
|
|
||||||
try {
|
|
||||||
return await this.blockBlob(filePath).exists();
|
|
||||||
} catch (err) {
|
|
||||||
throw new Error(
|
|
||||||
`Failed to check existence in Azure: ${(err as Error).message}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
getUrl(filePath: string): string {
|
|
||||||
const base = this.config.baseUrl ?? this.accountUrl;
|
|
||||||
return `${base}/${this.config.container}/${filePath}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
async getSignedUrl(filePath: string, expiresIn: number): Promise<string> {
|
|
||||||
const expiresOn = new Date(Date.now() + expiresIn * 1000);
|
|
||||||
const sas = generateBlobSASQueryParameters(
|
|
||||||
{
|
|
||||||
containerName: this.config.container,
|
|
||||||
blobName: filePath,
|
|
||||||
permissions: BlobSASPermissions.parse('r'),
|
|
||||||
expiresOn,
|
|
||||||
protocol: SASProtocol.HttpsAndHttp,
|
|
||||||
},
|
|
||||||
this.sharedKeyCredential,
|
|
||||||
).toString();
|
|
||||||
return `${this.accountUrl}/${this.config.container}/${filePath}?${sas}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
async delete(filePath: string): Promise<void> {
|
|
||||||
try {
|
|
||||||
await this.blockBlob(filePath).delete();
|
|
||||||
} catch (err) {
|
|
||||||
throw new Error(
|
|
||||||
`Error deleting file ${filePath} from Azure: ${(err as Error).message}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
getDriver(): BlobServiceClient {
|
|
||||||
return this.blobServiceClient;
|
|
||||||
}
|
|
||||||
|
|
||||||
getDriverName(): string {
|
|
||||||
return StorageOption.AZURE;
|
|
||||||
}
|
|
||||||
|
|
||||||
getConfig(): Record<string, any> {
|
|
||||||
return this.config;
|
|
||||||
}
|
|
||||||
|
|
||||||
private createBlobServiceClient(): BlobServiceClient {
|
|
||||||
return new BlobServiceClient(this.accountUrl, this.sharedKeyCredential);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,3 +1,2 @@
|
|||||||
export { LocalDriver } from './local.driver';
|
export { LocalDriver } from './local.driver';
|
||||||
export { S3Driver } from './s3.driver';
|
export { S3Driver } from './s3.driver';
|
||||||
export { AzureDriver } from './azure.driver';
|
|
||||||
|
|||||||
@@ -3,13 +3,11 @@ import { S3ClientConfig } from '@aws-sdk/client-s3';
|
|||||||
export enum StorageOption {
|
export enum StorageOption {
|
||||||
LOCAL = 'local',
|
LOCAL = 'local',
|
||||||
S3 = 's3',
|
S3 = 's3',
|
||||||
AZURE = 'azure',
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type StorageConfig =
|
export type StorageConfig =
|
||||||
| { driver: StorageOption.LOCAL; config: LocalStorageConfig }
|
| { driver: StorageOption.LOCAL; config: LocalStorageConfig }
|
||||||
| { driver: StorageOption.S3; config: S3StorageConfig }
|
| { driver: StorageOption.S3; config: S3StorageConfig };
|
||||||
| { driver: StorageOption.AZURE; config: AzureStorageConfig };
|
|
||||||
|
|
||||||
export interface LocalStorageConfig {
|
export interface LocalStorageConfig {
|
||||||
storagePath: string;
|
storagePath: string;
|
||||||
@@ -22,14 +20,6 @@ export interface S3StorageConfig
|
|||||||
baseUrl?: string; // Optional CDN URL for assets
|
baseUrl?: string; // Optional CDN URL for assets
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AzureStorageConfig {
|
|
||||||
accountName: string;
|
|
||||||
container: string;
|
|
||||||
accountKey: string;
|
|
||||||
endpoint?: string;
|
|
||||||
baseUrl?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface StorageOptions {
|
export interface StorageOptions {
|
||||||
disk: StorageConfig;
|
disk: StorageConfig;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,14 +4,13 @@ import {
|
|||||||
} from '../constants/storage.constants';
|
} from '../constants/storage.constants';
|
||||||
import { EnvironmentService } from '../../environment/environment.service';
|
import { EnvironmentService } from '../../environment/environment.service';
|
||||||
import {
|
import {
|
||||||
AzureStorageConfig,
|
|
||||||
LocalStorageConfig,
|
LocalStorageConfig,
|
||||||
S3StorageConfig,
|
S3StorageConfig,
|
||||||
StorageConfig,
|
StorageConfig,
|
||||||
StorageDriver,
|
StorageDriver,
|
||||||
StorageOption,
|
StorageOption,
|
||||||
} from '../interfaces';
|
} from '../interfaces';
|
||||||
import { AzureDriver, LocalDriver, S3Driver } from '../drivers';
|
import { LocalDriver, S3Driver } from '../drivers';
|
||||||
import * as process from 'node:process';
|
import * as process from 'node:process';
|
||||||
import { LOCAL_STORAGE_PATH } from '../../../common/helpers';
|
import { LOCAL_STORAGE_PATH } from '../../../common/helpers';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
@@ -22,8 +21,6 @@ function createStorageDriver(disk: StorageConfig): StorageDriver {
|
|||||||
return new LocalDriver(disk.config as LocalStorageConfig);
|
return new LocalDriver(disk.config as LocalStorageConfig);
|
||||||
case StorageOption.S3:
|
case StorageOption.S3:
|
||||||
return new S3Driver(disk.config as S3StorageConfig);
|
return new S3Driver(disk.config as S3StorageConfig);
|
||||||
case StorageOption.AZURE:
|
|
||||||
return new AzureDriver(disk.config as AzureStorageConfig);
|
|
||||||
default:
|
default:
|
||||||
throw new Error(`Unknown storage driver`);
|
throw new Error(`Unknown storage driver`);
|
||||||
}
|
}
|
||||||
@@ -73,18 +70,6 @@ export const storageDriverConfigProvider = {
|
|||||||
|
|
||||||
return s3Config; }
|
return s3Config; }
|
||||||
|
|
||||||
case StorageOption.AZURE:
|
|
||||||
return {
|
|
||||||
driver,
|
|
||||||
config: {
|
|
||||||
accountName: environmentService.getAzureStorageAccountName(),
|
|
||||||
container: environmentService.getAzureStorageContainer(),
|
|
||||||
accountKey: environmentService.getAzureStorageAccountKey(),
|
|
||||||
endpoint: environmentService.getAzureStorageEndpoint() || undefined,
|
|
||||||
baseUrl: environmentService.getAzureStorageUrl() || undefined,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
default:
|
default:
|
||||||
throw new Error(`Unknown storage driver: ${driver}`);
|
throw new Error(`Unknown storage driver: ${driver}`);
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-2
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "docmost",
|
"name": "docmost",
|
||||||
"homepage": "https://docmost.com",
|
"homepage": "https://docmost.com",
|
||||||
"version": "0.90.1",
|
"version": "0.90.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "nx run-many -t build",
|
"build": "nx run-many -t build",
|
||||||
@@ -103,7 +103,7 @@
|
|||||||
"glob": "13.0.6",
|
"glob": "13.0.6",
|
||||||
"ws": "8.20.1",
|
"ws": "8.20.1",
|
||||||
"dompurify": "3.4.1",
|
"dompurify": "3.4.1",
|
||||||
"tmp": "0.2.6",
|
"tmp": "0.2.5",
|
||||||
"hono": "4.12.18",
|
"hono": "4.12.18",
|
||||||
"mermaid": "11.15.0",
|
"mermaid": "11.15.0",
|
||||||
"nanoid@^3": "3.3.8",
|
"nanoid@^3": "3.3.8",
|
||||||
|
|||||||
Generated
+17
-200
@@ -10,7 +10,7 @@ overrides:
|
|||||||
glob: 13.0.6
|
glob: 13.0.6
|
||||||
ws: 8.20.1
|
ws: 8.20.1
|
||||||
dompurify: 3.4.1
|
dompurify: 3.4.1
|
||||||
tmp: 0.2.6
|
tmp: 0.2.5
|
||||||
hono: 4.12.18
|
hono: 4.12.18
|
||||||
mermaid: 11.15.0
|
mermaid: 11.15.0
|
||||||
nanoid@^3: 3.3.8
|
nanoid@^3: 3.3.8
|
||||||
@@ -342,8 +342,8 @@ importers:
|
|||||||
specifier: 0.4.0
|
specifier: 0.4.0
|
||||||
version: 0.4.0(jotai@2.18.1(@babel/core@7.28.5)(@babel/template@7.27.2)(@types/react@18.3.12)(react@18.3.1))(optics-ts@2.4.1)
|
version: 0.4.0(jotai@2.18.1(@babel/core@7.28.5)(@babel/template@7.27.2)(@types/react@18.3.12)(react@18.3.1))(optics-ts@2.4.1)
|
||||||
js-cookie:
|
js-cookie:
|
||||||
specifier: 3.0.7
|
specifier: 3.0.5
|
||||||
version: 3.0.7
|
version: 3.0.5
|
||||||
jwt-decode:
|
jwt-decode:
|
||||||
specifier: 4.0.0
|
specifier: 4.0.0
|
||||||
version: 4.0.0
|
version: 4.0.0
|
||||||
@@ -501,15 +501,12 @@ importers:
|
|||||||
'@aws-sdk/s3-request-presigner':
|
'@aws-sdk/s3-request-presigner':
|
||||||
specifier: 3.1050.0
|
specifier: 3.1050.0
|
||||||
version: 3.1050.0
|
version: 3.1050.0
|
||||||
'@azure/storage-blob':
|
|
||||||
specifier: 12.31.0
|
|
||||||
version: 12.31.0
|
|
||||||
'@clickhouse/client':
|
'@clickhouse/client':
|
||||||
specifier: ^1.18.2
|
specifier: ^1.18.2
|
||||||
version: 1.18.2
|
version: 1.18.2
|
||||||
'@docmost/pdf-inspector':
|
'@docmost/pdf-inspector':
|
||||||
specifier: 1.9.6
|
specifier: 1.9.4
|
||||||
version: 1.9.6
|
version: 1.9.4
|
||||||
'@fastify/cookie':
|
'@fastify/cookie':
|
||||||
specifier: ^11.0.2
|
specifier: ^11.0.2
|
||||||
version: 11.0.2
|
version: 11.0.2
|
||||||
@@ -1117,61 +1114,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-oLvsaPMTBejkkmHhjf09xTgk71mOqyr/409NKhRIL08If7AhVfUsJhVsx386uJaqNd42v9kWamQ9lFbkoC2dYw==}
|
resolution: {integrity: sha512-oLvsaPMTBejkkmHhjf09xTgk71mOqyr/409NKhRIL08If7AhVfUsJhVsx386uJaqNd42v9kWamQ9lFbkoC2dYw==}
|
||||||
engines: {node: '>=18.0.0'}
|
engines: {node: '>=18.0.0'}
|
||||||
|
|
||||||
'@azure/abort-controller@2.1.2':
|
|
||||||
resolution: {integrity: sha512-nBrLsEWm4J2u5LpAPjxADTlq3trDgVZZXHNKabeXZtpq3d3AbN/KGO82R87rdDz5/lYB024rtEf10/q0urNgsA==}
|
|
||||||
engines: {node: '>=18.0.0'}
|
|
||||||
|
|
||||||
'@azure/core-auth@1.10.1':
|
|
||||||
resolution: {integrity: sha512-ykRMW8PjVAn+RS6ww5cmK9U2CyH9p4Q88YJwvUslfuMmN98w/2rdGRLPqJYObapBCdzBVeDgYWdJnFPFb7qzpg==}
|
|
||||||
engines: {node: '>=20.0.0'}
|
|
||||||
|
|
||||||
'@azure/core-client@1.10.1':
|
|
||||||
resolution: {integrity: sha512-Nh5PhEOeY6PrnxNPsEHRr9eimxLwgLlpmguQaHKBinFYA/RU9+kOYVOQqOrTsCL+KSxrLLl1gD8Dk5BFW/7l/w==}
|
|
||||||
engines: {node: '>=20.0.0'}
|
|
||||||
|
|
||||||
'@azure/core-http-compat@2.4.0':
|
|
||||||
resolution: {integrity: sha512-f1P96IB399YiN2ARYHP7EpZi3Bf3wH4SN2lGzrw7JVwm7bbsVYtf2iKSBwTywD2P62NOPZGHFSZi+6jjb75JuA==}
|
|
||||||
engines: {node: '>=20.0.0'}
|
|
||||||
peerDependencies:
|
|
||||||
'@azure/core-client': ^1.10.0
|
|
||||||
'@azure/core-rest-pipeline': ^1.22.0
|
|
||||||
|
|
||||||
'@azure/core-lro@2.7.2':
|
|
||||||
resolution: {integrity: sha512-0YIpccoX8m/k00O7mDDMdJpbr6mf1yWo2dfmxt5A8XVZVVMz2SSKaEbMCeJRvgQ0IaSlqhjT47p4hVIRRy90xw==}
|
|
||||||
engines: {node: '>=18.0.0'}
|
|
||||||
|
|
||||||
'@azure/core-paging@1.6.2':
|
|
||||||
resolution: {integrity: sha512-YKWi9YuCU04B55h25cnOYZHxXYtEvQEbKST5vqRga7hWY9ydd3FZHdeQF8pyh+acWZvppw13M/LMGx0LABUVMA==}
|
|
||||||
engines: {node: '>=18.0.0'}
|
|
||||||
|
|
||||||
'@azure/core-rest-pipeline@1.23.0':
|
|
||||||
resolution: {integrity: sha512-Evs1INHo+jUjwHi1T6SG6Ua/LHOQBCLuKEEE6efIpt4ZOoNonaT1kP32GoOcdNDbfqsD2445CPri3MubBy5DEQ==}
|
|
||||||
engines: {node: '>=20.0.0'}
|
|
||||||
|
|
||||||
'@azure/core-tracing@1.3.1':
|
|
||||||
resolution: {integrity: sha512-9MWKevR7Hz8kNzzPLfX4EAtGM2b8mr50HPDBvio96bURP/9C+HjdH3sBlLSNNrvRAr5/k/svoH457gB5IKpmwQ==}
|
|
||||||
engines: {node: '>=20.0.0'}
|
|
||||||
|
|
||||||
'@azure/core-util@1.13.1':
|
|
||||||
resolution: {integrity: sha512-XPArKLzsvl0Hf0CaGyKHUyVgF7oDnhKoP85Xv6M4StF/1AhfORhZudHtOyf2s+FcbuQ9dPRAjB8J2KvRRMUK2A==}
|
|
||||||
engines: {node: '>=20.0.0'}
|
|
||||||
|
|
||||||
'@azure/core-xml@1.5.1':
|
|
||||||
resolution: {integrity: sha512-xcNRHqCoSp4AunOALEae6A8f3qATb83gSrm31Iqb01OzblvC3/W/bfXozcq78EzIdzZzuH1bZ2NvRR0TdX709w==}
|
|
||||||
engines: {node: '>=20.0.0'}
|
|
||||||
|
|
||||||
'@azure/logger@1.3.0':
|
|
||||||
resolution: {integrity: sha512-fCqPIfOcLE+CGqGPd66c8bZpwAji98tZ4JI9i/mlTNTlsIWslCfpg48s/ypyLxZTump5sypjrKn2/kY7q8oAbA==}
|
|
||||||
engines: {node: '>=20.0.0'}
|
|
||||||
|
|
||||||
'@azure/storage-blob@12.31.0':
|
|
||||||
resolution: {integrity: sha512-DBgNv10aCSxopt92DkTDD0o9xScXeBqPKGmR50FPZQaEcH4JLQ+GEOGEDv19V5BMkB7kxr+m4h6il/cCDPvmHg==}
|
|
||||||
engines: {node: '>=20.0.0'}
|
|
||||||
|
|
||||||
'@azure/storage-common@12.3.0':
|
|
||||||
resolution: {integrity: sha512-/OFHhy86aG5Pe8dP5tsp+BuJ25JOAl9yaMU3WZbkeoiFMHFtJ7tu5ili7qEdBXNW9G5lDB19trwyI6V49F/8iQ==}
|
|
||||||
engines: {node: '>=20.0.0'}
|
|
||||||
|
|
||||||
'@babel/code-frame@7.27.1':
|
'@babel/code-frame@7.27.1':
|
||||||
resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==}
|
resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==}
|
||||||
engines: {node: '>=6.9.0'}
|
engines: {node: '>=6.9.0'}
|
||||||
@@ -1900,8 +1842,8 @@ packages:
|
|||||||
resolution: {integrity: sha512-UJnjoFsmxfKUdNYdWgOB0mWUypuLvAfQPH1+pyvRJs6euowbFkFC6P13w1l8mJyi3vxYMxc9kld5jZEGRQs6bw==}
|
resolution: {integrity: sha512-UJnjoFsmxfKUdNYdWgOB0mWUypuLvAfQPH1+pyvRJs6euowbFkFC6P13w1l8mJyi3vxYMxc9kld5jZEGRQs6bw==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
'@docmost/pdf-inspector@1.9.6':
|
'@docmost/pdf-inspector@1.9.4':
|
||||||
resolution: {integrity: sha512-8k8N8Mwu9xbpRC1jLcz4sFv88ev2oBnW56a/2WLbrOBkfXzyZV2Tml5PikUwEWT4cUXfYfk2dGnJpWQYgCESCQ==}
|
resolution: {integrity: sha512-G5DNyDtLNxybTXWakqi7PuOEuSb/A2ZjDlv2WCkOkiHszPeILdrC+G0a4e4UP10yxvzuLfb23pJ5jy8fUSYZPw==}
|
||||||
|
|
||||||
'@emnapi/core@1.8.1':
|
'@emnapi/core@1.8.1':
|
||||||
resolution: {integrity: sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==}
|
resolution: {integrity: sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==}
|
||||||
@@ -5023,10 +4965,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-YWnmJkXbofiz9KbnbbwuA2rpGkFPLbAIetcCNO6mJ8gdhdZ/v7WDXsoGFAJuM6ikUFKTlSQnjWnVO4ux+UzS6A==}
|
resolution: {integrity: sha512-YWnmJkXbofiz9KbnbbwuA2rpGkFPLbAIetcCNO6mJ8gdhdZ/v7WDXsoGFAJuM6ikUFKTlSQnjWnVO4ux+UzS6A==}
|
||||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||||
|
|
||||||
'@typespec/ts-http-runtime@0.3.5':
|
|
||||||
resolution: {integrity: sha512-yURCknZhvywvQItHMMmFSo+fq5arCUIyz/CVk7jD89MSai7dkaX8ufjCWp3NttLojoTVbcE72ri+be/TnEbMHw==}
|
|
||||||
engines: {node: '>=20.0.0'}
|
|
||||||
|
|
||||||
'@ucast/core@1.10.2':
|
'@ucast/core@1.10.2':
|
||||||
resolution: {integrity: sha512-ons5CwXZ/51wrUPfoduC+cO7AS1/wRb0ybpQJ9RrssossDxVy4t49QxWoWgfBDvVKsz9VXzBk9z0wqTdZ+Cq8g==}
|
resolution: {integrity: sha512-ons5CwXZ/51wrUPfoduC+cO7AS1/wRb0ybpQJ9RrssossDxVy4t49QxWoWgfBDvVKsz9VXzBk9z0wqTdZ+Cq8g==}
|
||||||
|
|
||||||
@@ -7544,9 +7482,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==}
|
resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
|
|
||||||
js-cookie@3.0.7:
|
js-cookie@3.0.5:
|
||||||
resolution: {integrity: sha512-z/wZZgDrkNV1eA0ULjM/F9/50Ya8fbzgKneSpoPsXSGd0KnpdtHfOZWK+GcwLk+EZbS4F9RBhU+K2RgzuDaItw==}
|
resolution: {integrity: sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==}
|
||||||
engines: {node: '>=20'}
|
engines: {node: '>=14'}
|
||||||
|
|
||||||
js-tiktoken@1.0.21:
|
js-tiktoken@1.0.21:
|
||||||
resolution: {integrity: sha512-biOj/6M5qdgx5TKjDnFT1ymSpM5tbd3ylwDtrQvFQSu0Z7bBYko2dF+W/aUkXUPuk6IVpRxk/3Q2sHOzGlS36g==}
|
resolution: {integrity: sha512-biOj/6M5qdgx5TKjDnFT1ymSpM5tbd3ylwDtrQvFQSu0Z7bBYko2dF+W/aUkXUPuk6IVpRxk/3Q2sHOzGlS36g==}
|
||||||
@@ -9657,8 +9595,8 @@ packages:
|
|||||||
tmp-promise@3.0.3:
|
tmp-promise@3.0.3:
|
||||||
resolution: {integrity: sha512-RwM7MoPojPxsOBYnyd2hy0bxtIlVrihNs9pj5SUvY8Zz1sQcQG2tG1hSr8PDxfgEB8RNKDhqbIlroIarSNDNsQ==}
|
resolution: {integrity: sha512-RwM7MoPojPxsOBYnyd2hy0bxtIlVrihNs9pj5SUvY8Zz1sQcQG2tG1hSr8PDxfgEB8RNKDhqbIlroIarSNDNsQ==}
|
||||||
|
|
||||||
tmp@0.2.6:
|
tmp@0.2.5:
|
||||||
resolution: {integrity: sha512-5sJPdPjfI5Kx+qbrDesxkglRBxW//g7hCsqspEjwkewGvBMGIKMOTKzLt1hFVJzyadba3lDUN20O9qhvbQUSTA==}
|
resolution: {integrity: sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==}
|
||||||
engines: {node: '>=14.14'}
|
engines: {node: '>=14.14'}
|
||||||
|
|
||||||
tmpl@1.0.5:
|
tmpl@1.0.5:
|
||||||
@@ -10916,119 +10854,6 @@ snapshots:
|
|||||||
|
|
||||||
'@aws/lambda-invoke-store@0.2.3': {}
|
'@aws/lambda-invoke-store@0.2.3': {}
|
||||||
|
|
||||||
'@azure/abort-controller@2.1.2':
|
|
||||||
dependencies:
|
|
||||||
tslib: 2.8.1
|
|
||||||
|
|
||||||
'@azure/core-auth@1.10.1':
|
|
||||||
dependencies:
|
|
||||||
'@azure/abort-controller': 2.1.2
|
|
||||||
'@azure/core-util': 1.13.1
|
|
||||||
tslib: 2.8.1
|
|
||||||
transitivePeerDependencies:
|
|
||||||
- supports-color
|
|
||||||
|
|
||||||
'@azure/core-client@1.10.1':
|
|
||||||
dependencies:
|
|
||||||
'@azure/abort-controller': 2.1.2
|
|
||||||
'@azure/core-auth': 1.10.1
|
|
||||||
'@azure/core-rest-pipeline': 1.23.0
|
|
||||||
'@azure/core-tracing': 1.3.1
|
|
||||||
'@azure/core-util': 1.13.1
|
|
||||||
'@azure/logger': 1.3.0
|
|
||||||
tslib: 2.8.1
|
|
||||||
transitivePeerDependencies:
|
|
||||||
- supports-color
|
|
||||||
|
|
||||||
'@azure/core-http-compat@2.4.0(@azure/core-client@1.10.1)(@azure/core-rest-pipeline@1.23.0)':
|
|
||||||
dependencies:
|
|
||||||
'@azure/abort-controller': 2.1.2
|
|
||||||
'@azure/core-client': 1.10.1
|
|
||||||
'@azure/core-rest-pipeline': 1.23.0
|
|
||||||
|
|
||||||
'@azure/core-lro@2.7.2':
|
|
||||||
dependencies:
|
|
||||||
'@azure/abort-controller': 2.1.2
|
|
||||||
'@azure/core-util': 1.13.1
|
|
||||||
'@azure/logger': 1.3.0
|
|
||||||
tslib: 2.8.1
|
|
||||||
transitivePeerDependencies:
|
|
||||||
- supports-color
|
|
||||||
|
|
||||||
'@azure/core-paging@1.6.2':
|
|
||||||
dependencies:
|
|
||||||
tslib: 2.8.1
|
|
||||||
|
|
||||||
'@azure/core-rest-pipeline@1.23.0':
|
|
||||||
dependencies:
|
|
||||||
'@azure/abort-controller': 2.1.2
|
|
||||||
'@azure/core-auth': 1.10.1
|
|
||||||
'@azure/core-tracing': 1.3.1
|
|
||||||
'@azure/core-util': 1.13.1
|
|
||||||
'@azure/logger': 1.3.0
|
|
||||||
'@typespec/ts-http-runtime': 0.3.5
|
|
||||||
tslib: 2.8.1
|
|
||||||
transitivePeerDependencies:
|
|
||||||
- supports-color
|
|
||||||
|
|
||||||
'@azure/core-tracing@1.3.1':
|
|
||||||
dependencies:
|
|
||||||
tslib: 2.8.1
|
|
||||||
|
|
||||||
'@azure/core-util@1.13.1':
|
|
||||||
dependencies:
|
|
||||||
'@azure/abort-controller': 2.1.2
|
|
||||||
'@typespec/ts-http-runtime': 0.3.5
|
|
||||||
tslib: 2.8.1
|
|
||||||
transitivePeerDependencies:
|
|
||||||
- supports-color
|
|
||||||
|
|
||||||
'@azure/core-xml@1.5.1':
|
|
||||||
dependencies:
|
|
||||||
fast-xml-parser: 5.7.3
|
|
||||||
tslib: 2.8.1
|
|
||||||
|
|
||||||
'@azure/logger@1.3.0':
|
|
||||||
dependencies:
|
|
||||||
'@typespec/ts-http-runtime': 0.3.5
|
|
||||||
tslib: 2.8.1
|
|
||||||
transitivePeerDependencies:
|
|
||||||
- supports-color
|
|
||||||
|
|
||||||
'@azure/storage-blob@12.31.0':
|
|
||||||
dependencies:
|
|
||||||
'@azure/abort-controller': 2.1.2
|
|
||||||
'@azure/core-auth': 1.10.1
|
|
||||||
'@azure/core-client': 1.10.1
|
|
||||||
'@azure/core-http-compat': 2.4.0(@azure/core-client@1.10.1)(@azure/core-rest-pipeline@1.23.0)
|
|
||||||
'@azure/core-lro': 2.7.2
|
|
||||||
'@azure/core-paging': 1.6.2
|
|
||||||
'@azure/core-rest-pipeline': 1.23.0
|
|
||||||
'@azure/core-tracing': 1.3.1
|
|
||||||
'@azure/core-util': 1.13.1
|
|
||||||
'@azure/core-xml': 1.5.1
|
|
||||||
'@azure/logger': 1.3.0
|
|
||||||
'@azure/storage-common': 12.3.0(@azure/core-client@1.10.1)
|
|
||||||
events: 3.3.0
|
|
||||||
tslib: 2.8.1
|
|
||||||
transitivePeerDependencies:
|
|
||||||
- supports-color
|
|
||||||
|
|
||||||
'@azure/storage-common@12.3.0(@azure/core-client@1.10.1)':
|
|
||||||
dependencies:
|
|
||||||
'@azure/abort-controller': 2.1.2
|
|
||||||
'@azure/core-auth': 1.10.1
|
|
||||||
'@azure/core-http-compat': 2.4.0(@azure/core-client@1.10.1)(@azure/core-rest-pipeline@1.23.0)
|
|
||||||
'@azure/core-rest-pipeline': 1.23.0
|
|
||||||
'@azure/core-tracing': 1.3.1
|
|
||||||
'@azure/core-util': 1.13.1
|
|
||||||
'@azure/logger': 1.3.0
|
|
||||||
events: 3.3.0
|
|
||||||
tslib: 2.8.1
|
|
||||||
transitivePeerDependencies:
|
|
||||||
- '@azure/core-client'
|
|
||||||
- supports-color
|
|
||||||
|
|
||||||
'@babel/code-frame@7.27.1':
|
'@babel/code-frame@7.27.1':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/helper-validator-identifier': 7.28.5
|
'@babel/helper-validator-identifier': 7.28.5
|
||||||
@@ -11898,7 +11723,7 @@ snapshots:
|
|||||||
|
|
||||||
'@csstools/css-tokenizer@3.0.3': {}
|
'@csstools/css-tokenizer@3.0.3': {}
|
||||||
|
|
||||||
'@docmost/pdf-inspector@1.9.6': {}
|
'@docmost/pdf-inspector@1.9.4': {}
|
||||||
|
|
||||||
'@emnapi/core@1.8.1':
|
'@emnapi/core@1.8.1':
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -15281,14 +15106,6 @@ snapshots:
|
|||||||
'@typescript-eslint/types': 8.57.1
|
'@typescript-eslint/types': 8.57.1
|
||||||
eslint-visitor-keys: 5.0.1
|
eslint-visitor-keys: 5.0.1
|
||||||
|
|
||||||
'@typespec/ts-http-runtime@0.3.5':
|
|
||||||
dependencies:
|
|
||||||
http-proxy-agent: 7.0.2
|
|
||||||
https-proxy-agent: 7.0.6
|
|
||||||
tslib: 2.8.1
|
|
||||||
transitivePeerDependencies:
|
|
||||||
- supports-color
|
|
||||||
|
|
||||||
'@ucast/core@1.10.2': {}
|
'@ucast/core@1.10.2': {}
|
||||||
|
|
||||||
'@ucast/js@3.0.4':
|
'@ucast/js@3.0.4':
|
||||||
@@ -18286,7 +18103,7 @@ snapshots:
|
|||||||
|
|
||||||
joycon@3.1.1: {}
|
joycon@3.1.1: {}
|
||||||
|
|
||||||
js-cookie@3.0.7: {}
|
js-cookie@3.0.5: {}
|
||||||
|
|
||||||
js-tiktoken@1.0.21:
|
js-tiktoken@1.0.21:
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -18992,7 +18809,7 @@ snapshots:
|
|||||||
semver: 7.7.4
|
semver: 7.7.4
|
||||||
string-width: 4.2.3
|
string-width: 4.2.3
|
||||||
tar-stream: 2.2.0
|
tar-stream: 2.2.0
|
||||||
tmp: 0.2.6
|
tmp: 0.2.5
|
||||||
tree-kill: 1.2.2
|
tree-kill: 1.2.2
|
||||||
tsconfig-paths: 4.2.0
|
tsconfig-paths: 4.2.0
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
@@ -20615,9 +20432,9 @@ snapshots:
|
|||||||
|
|
||||||
tmp-promise@3.0.3:
|
tmp-promise@3.0.3:
|
||||||
dependencies:
|
dependencies:
|
||||||
tmp: 0.2.6
|
tmp: 0.2.5
|
||||||
|
|
||||||
tmp@0.2.6: {}
|
tmp@0.2.5: {}
|
||||||
|
|
||||||
tmpl@1.0.5: {}
|
tmpl@1.0.5: {}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user