Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c6f1239e07 | ||
|
|
50aa9a6b30 |
@@ -45,6 +45,7 @@
|
||||
"react-dom": "catalog:",
|
||||
"react-hook-form": "7.51.5",
|
||||
"react-router": "catalog:",
|
||||
"react-router-dom": "catalog:",
|
||||
"serve": "14.2.5",
|
||||
"swr": "catalog:",
|
||||
"uuid": "catalog:"
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
# Generated by Django 4.2.27 on 2026-01-13 10:38
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('db', '0115_auto_20260105_1406'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='profile',
|
||||
name='notification_view_mode',
|
||||
field=models.CharField(choices=[('full', 'Full'), ('compact', 'Compact')], default='full', max_length=255),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='user',
|
||||
name='is_password_reset_required',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='workspacemember',
|
||||
name='explored_features',
|
||||
field=models.JSONField(default=dict),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='workspacemember',
|
||||
name='getting_started_checklist',
|
||||
field=models.JSONField(default=dict),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='workspacemember',
|
||||
name='tips',
|
||||
field=models.JSONField(default=dict),
|
||||
),
|
||||
]
|
||||
@@ -84,7 +84,7 @@ class User(AbstractBaseUser, PermissionsMixin):
|
||||
is_staff = models.BooleanField(default=False)
|
||||
is_email_verified = models.BooleanField(default=False)
|
||||
is_password_autoset = models.BooleanField(default=False)
|
||||
is_password_reset_required = models.BooleanField(default=False)
|
||||
|
||||
# random token generated
|
||||
token = models.CharField(max_length=64, blank=True)
|
||||
|
||||
@@ -192,10 +192,6 @@ class Profile(TimeAuditModel):
|
||||
FRIDAY = 5
|
||||
SATURDAY = 6
|
||||
|
||||
class NotificationViewMode(models.TextChoices):
|
||||
FULL = "full", "Full"
|
||||
COMPACT = "compact", "Compact"
|
||||
|
||||
START_OF_THE_WEEK_CHOICES = (
|
||||
(SUNDAY, "Sunday"),
|
||||
(MONDAY, "Monday"),
|
||||
@@ -225,9 +221,7 @@ class Profile(TimeAuditModel):
|
||||
billing_address = models.JSONField(null=True)
|
||||
has_billing_address = models.BooleanField(default=False)
|
||||
company_name = models.CharField(max_length=255, blank=True)
|
||||
notification_view_mode = models.CharField(
|
||||
max_length=255, choices=NotificationViewMode.choices, default=NotificationViewMode.FULL
|
||||
)
|
||||
|
||||
is_smooth_cursor_enabled = models.BooleanField(default=False)
|
||||
# mobile
|
||||
is_mobile_onboarded = models.BooleanField(default=False)
|
||||
|
||||
@@ -214,9 +214,6 @@ class WorkspaceMember(BaseModel):
|
||||
default_props = models.JSONField(default=get_default_props)
|
||||
issue_props = models.JSONField(default=get_issue_props)
|
||||
is_active = models.BooleanField(default=True)
|
||||
getting_started_checklist = models.JSONField(default=dict)
|
||||
tips = models.JSONField(default=dict)
|
||||
explored_features = models.JSONField(default=dict)
|
||||
|
||||
class Meta:
|
||||
unique_together = ["workspace", "member", "deleted_at"]
|
||||
|
||||
@@ -98,7 +98,7 @@ export const AuthRoot = observer(function AuthRoot() {
|
||||
}
|
||||
|
||||
if (currentAuthMode === EAuthModes.SIGN_IN) {
|
||||
if (isSMTPConfigured && isMagicLoginEnabled && response.status === "MAGIC_CODE") {
|
||||
if (response.is_password_autoset && isSMTPConfigured && isMagicLoginEnabled) {
|
||||
setAuthStep(EAuthSteps.UNIQUE_CODE);
|
||||
generateEmailUniqueCode(data.email);
|
||||
} else if (isEmailPasswordEnabled) {
|
||||
@@ -109,7 +109,7 @@ export const AuthRoot = observer(function AuthRoot() {
|
||||
setErrorInfo(errorhandler);
|
||||
}
|
||||
} else {
|
||||
if (isSMTPConfigured && isMagicLoginEnabled && response.status === "MAGIC_CODE") {
|
||||
if (isSMTPConfigured && isMagicLoginEnabled) {
|
||||
setAuthStep(EAuthSteps.UNIQUE_CODE);
|
||||
generateEmailUniqueCode(data.email);
|
||||
} else if (isEmailPasswordEnabled) {
|
||||
@@ -119,7 +119,6 @@ export const AuthRoot = observer(function AuthRoot() {
|
||||
setErrorInfo(errorhandler);
|
||||
}
|
||||
}
|
||||
return;
|
||||
})
|
||||
.catch((error) => {
|
||||
const errorhandler = authErrorHandler(error?.error_code?.toString(), data?.email || undefined);
|
||||
|
||||
@@ -19,7 +19,6 @@ export interface IEmailCheckData {
|
||||
}
|
||||
|
||||
export interface IEmailCheckResponse {
|
||||
status: "MAGIC_CODE" | "CREDENTIAL";
|
||||
is_password_autoset: boolean;
|
||||
existing: boolean;
|
||||
}
|
||||
|
||||
@@ -50,6 +50,7 @@
|
||||
"react-hook-form": "7.51.5",
|
||||
"react-popper": "^2.3.0",
|
||||
"react-router": "catalog:",
|
||||
"react-router-dom": "catalog:",
|
||||
"swr": "catalog:",
|
||||
"uuid": "catalog:"
|
||||
},
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import useSWR from "swr";
|
||||
// plane imports
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
// component
|
||||
import { EmptyStateCompact } from "@plane/propel/empty-state";
|
||||
import { APITokenService } from "@plane/services";
|
||||
import { CreateApiTokenModal } from "@/components/api-token/modal/create-token-modal";
|
||||
import { ApiTokenListItem } from "@/components/api-token/token-list-item";
|
||||
import { PageHead } from "@/components/core/page-title";
|
||||
import { SettingsHeading } from "@/components/settings/heading";
|
||||
import { APITokenSettingsLoader } from "@/components/ui/loader/settings/api-token";
|
||||
import { API_TOKENS_LIST } from "@/constants/fetch-keys";
|
||||
// store hooks
|
||||
import { useWorkspace } from "@/hooks/store/use-workspace";
|
||||
|
||||
const apiTokenService = new APITokenService();
|
||||
|
||||
function ApiTokensPage() {
|
||||
// states
|
||||
const [isCreateTokenModalOpen, setIsCreateTokenModalOpen] = useState(false);
|
||||
// router
|
||||
// plane hooks
|
||||
const { t } = useTranslation();
|
||||
// store hooks
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
|
||||
const { data: tokens } = useSWR(API_TOKENS_LIST, () => apiTokenService.list());
|
||||
|
||||
const pageTitle = currentWorkspace?.name
|
||||
? `${currentWorkspace.name} - ${t("workspace_settings.settings.api_tokens.title")}`
|
||||
: undefined;
|
||||
|
||||
if (!tokens) {
|
||||
return <APITokenSettingsLoader />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<PageHead title={pageTitle} />
|
||||
<CreateApiTokenModal isOpen={isCreateTokenModalOpen} onClose={() => setIsCreateTokenModalOpen(false)} />
|
||||
<section className="w-full">
|
||||
{tokens.length > 0 ? (
|
||||
<>
|
||||
<SettingsHeading
|
||||
title={t("account_settings.api_tokens.heading")}
|
||||
description={t("account_settings.api_tokens.description")}
|
||||
button={{
|
||||
label: t("workspace_settings.settings.api_tokens.add_token"),
|
||||
onClick: () => {
|
||||
setIsCreateTokenModalOpen(true);
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<div>
|
||||
{tokens.map((token) => (
|
||||
<ApiTokenListItem key={token.id} token={token} />
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex h-full w-full flex-col py-">
|
||||
<SettingsHeading
|
||||
title={t("account_settings.api_tokens.heading")}
|
||||
description={t("account_settings.api_tokens.description")}
|
||||
button={{
|
||||
label: t("workspace_settings.settings.api_tokens.add_token"),
|
||||
onClick: () => {
|
||||
setIsCreateTokenModalOpen(true);
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
<EmptyStateCompact
|
||||
assetKey="token"
|
||||
assetClassName="size-20"
|
||||
title={t("settings_empty_state.tokens.title")}
|
||||
description={t("settings_empty_state.tokens.description")}
|
||||
actions={[
|
||||
{
|
||||
label: t("settings_empty_state.tokens.cta_primary"),
|
||||
onClick: () => {
|
||||
setIsCreateTokenModalOpen(true);
|
||||
},
|
||||
},
|
||||
]}
|
||||
align="start"
|
||||
rootClassName="py-20"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default observer(ApiTokensPage);
|
||||
@@ -1,32 +0,0 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { Outlet } from "react-router";
|
||||
// components
|
||||
import { SettingsContentWrapper } from "@/components/settings/content-wrapper";
|
||||
import { getProfileActivePath } from "@/components/settings/helper";
|
||||
import { SettingsMobileNav } from "@/components/settings/mobile";
|
||||
// local imports
|
||||
import { ProfileSidebar } from "./sidebar";
|
||||
|
||||
function ProfileSettingsLayout() {
|
||||
// router
|
||||
const pathname = usePathname();
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsMobileNav hamburgerContent={ProfileSidebar} activePath={getProfileActivePath(pathname) || ""} />
|
||||
<div className="relative flex h-full w-full">
|
||||
<div className="hidden md:block">
|
||||
<ProfileSidebar />
|
||||
</div>
|
||||
<div className="w-full h-full overflow-y-scroll md:pt-page-y">
|
||||
<SettingsContentWrapper>
|
||||
<Outlet />
|
||||
</SettingsContentWrapper>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default observer(ProfileSettingsLayout);
|
||||
@@ -1,39 +0,0 @@
|
||||
import { observer } from "mobx-react";
|
||||
// plane imports
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
// components
|
||||
import { PageHead } from "@/components/core/page-title";
|
||||
import { PreferencesList } from "@/components/preferences/list";
|
||||
import { LanguageTimezone } from "@/components/profile/preferences/language-timezone";
|
||||
import { ProfileSettingContentHeader } from "@/components/profile/profile-setting-content-header";
|
||||
import { SettingsHeading } from "@/components/settings/heading";
|
||||
// hooks
|
||||
import { useUserProfile } from "@/hooks/store/user";
|
||||
|
||||
const ProfileAppearancePage = observer(() => {
|
||||
const { t } = useTranslation();
|
||||
// hooks
|
||||
const { data: userProfile } = useUserProfile();
|
||||
|
||||
if (!userProfile) return <></>;
|
||||
return (
|
||||
<>
|
||||
<PageHead title={`${t("profile.label")} - ${t("preferences")}`} />
|
||||
<div className="flex flex-col gap-4 w-full">
|
||||
<div>
|
||||
<SettingsHeading
|
||||
title={t("account_settings.preferences.heading")}
|
||||
description={t("account_settings.preferences.description")}
|
||||
/>
|
||||
<PreferencesList />
|
||||
</div>
|
||||
<div>
|
||||
<ProfileSettingContentHeader title={t("language_and_time")} />
|
||||
<LanguageTimezone />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export default ProfileAppearancePage;
|
||||
@@ -1,262 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { Eye, EyeOff } from "lucide-react";
|
||||
// plane imports
|
||||
import { E_PASSWORD_STRENGTH } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { Button } from "@plane/propel/button";
|
||||
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
||||
import { Input, PasswordStrengthIndicator } from "@plane/ui";
|
||||
import { getPasswordStrength } from "@plane/utils";
|
||||
// components
|
||||
import { PageHead } from "@/components/core/page-title";
|
||||
import { ProfileSettingContentHeader } from "@/components/profile/profile-setting-content-header";
|
||||
// helpers
|
||||
import { authErrorHandler } from "@/helpers/authentication.helper";
|
||||
import type { EAuthenticationErrorCodes } from "@/helpers/authentication.helper";
|
||||
// hooks
|
||||
import { useUser } from "@/hooks/store/user";
|
||||
// services
|
||||
import { AuthService } from "@/services/auth.service";
|
||||
|
||||
export interface FormValues {
|
||||
old_password: string;
|
||||
new_password: string;
|
||||
confirm_password: string;
|
||||
}
|
||||
|
||||
const defaultValues: FormValues = {
|
||||
old_password: "",
|
||||
new_password: "",
|
||||
confirm_password: "",
|
||||
};
|
||||
|
||||
const authService = new AuthService();
|
||||
|
||||
const defaultShowPassword = {
|
||||
oldPassword: false,
|
||||
password: false,
|
||||
confirmPassword: false,
|
||||
};
|
||||
|
||||
function SecurityPage() {
|
||||
// store
|
||||
const { data: currentUser, changePassword } = useUser();
|
||||
// states
|
||||
const [showPassword, setShowPassword] = useState(defaultShowPassword);
|
||||
const [isPasswordInputFocused, setIsPasswordInputFocused] = useState(false);
|
||||
const [isRetryPasswordInputFocused, setIsRetryPasswordInputFocused] = useState(false);
|
||||
|
||||
// use form
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
watch,
|
||||
formState: { errors, isSubmitting },
|
||||
reset,
|
||||
} = useForm<FormValues>({ defaultValues });
|
||||
// derived values
|
||||
const oldPassword = watch("old_password");
|
||||
const password = watch("new_password");
|
||||
const confirmPassword = watch("confirm_password");
|
||||
const oldPasswordRequired = !currentUser?.is_password_autoset;
|
||||
// i18n
|
||||
const { t } = useTranslation();
|
||||
|
||||
const isNewPasswordSameAsOldPassword = oldPassword !== "" && password !== "" && password === oldPassword;
|
||||
|
||||
const handleShowPassword = (key: keyof typeof showPassword) =>
|
||||
setShowPassword((prev) => ({ ...prev, [key]: !prev[key] }));
|
||||
|
||||
const handleChangePassword = async (formData: FormValues) => {
|
||||
const { old_password, new_password } = formData;
|
||||
try {
|
||||
const csrfToken = await authService.requestCSRFToken().then((data) => data?.csrf_token);
|
||||
if (!csrfToken) throw new Error("csrf token not found");
|
||||
|
||||
await changePassword(csrfToken, {
|
||||
...(oldPasswordRequired && { old_password }),
|
||||
new_password,
|
||||
});
|
||||
|
||||
reset(defaultValues);
|
||||
setShowPassword(defaultShowPassword);
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: t("auth.common.password.toast.change_password.success.title"),
|
||||
message: t("auth.common.password.toast.change_password.success.message"),
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
let errorInfo = undefined;
|
||||
if (error instanceof Error) {
|
||||
const err = error as Error & { error_code?: string };
|
||||
const code = err.error_code?.toString();
|
||||
errorInfo = code ? authErrorHandler(code as EAuthenticationErrorCodes) : undefined;
|
||||
}
|
||||
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: errorInfo?.title ?? t("auth.common.password.toast.error.title"),
|
||||
message:
|
||||
typeof errorInfo?.message === "string" ? errorInfo.message : t("auth.common.password.toast.error.message"),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const isButtonDisabled =
|
||||
getPasswordStrength(password) != E_PASSWORD_STRENGTH.STRENGTH_VALID ||
|
||||
(oldPasswordRequired && oldPassword.trim() === "") ||
|
||||
password.trim() === "" ||
|
||||
confirmPassword.trim() === "" ||
|
||||
password !== confirmPassword ||
|
||||
password === oldPassword;
|
||||
|
||||
const passwordSupport = password.length > 0 &&
|
||||
getPasswordStrength(password) != E_PASSWORD_STRENGTH.STRENGTH_VALID && (
|
||||
<PasswordStrengthIndicator password={password} isFocused={isPasswordInputFocused} />
|
||||
);
|
||||
|
||||
const renderPasswordMatchError = !isRetryPasswordInputFocused || confirmPassword.length >= password.length;
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHead title="Profile - Security" />
|
||||
<ProfileSettingContentHeader title={t("auth.common.password.change_password.label.default")} />
|
||||
<form onSubmit={handleSubmit(handleChangePassword)} className="flex flex-col gap-8 w-full mt-8">
|
||||
<div className="flex flex-col gap-10 w-full">
|
||||
{oldPasswordRequired && (
|
||||
<div className="space-y-1">
|
||||
<h4 className="text-13">{t("auth.common.password.current_password.label")}</h4>
|
||||
<div className="relative flex items-center rounded-md">
|
||||
<Controller
|
||||
control={control}
|
||||
name="old_password"
|
||||
rules={{
|
||||
required: t("common.errors.required"),
|
||||
}}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<Input
|
||||
id="old_password"
|
||||
type={showPassword?.oldPassword ? "text" : "password"}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
placeholder={t("old_password")}
|
||||
className="w-full"
|
||||
hasError={Boolean(errors.old_password)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{showPassword?.oldPassword ? (
|
||||
<EyeOff
|
||||
className="absolute right-3 h-5 w-5 stroke-placeholder hover:cursor-pointer"
|
||||
onClick={() => handleShowPassword("oldPassword")}
|
||||
/>
|
||||
) : (
|
||||
<Eye
|
||||
className="absolute right-3 h-5 w-5 stroke-placeholder hover:cursor-pointer"
|
||||
onClick={() => handleShowPassword("oldPassword")}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{errors.old_password && (
|
||||
<span className="text-11 text-danger-primary">{errors.old_password.message}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-1">
|
||||
<h4 className="text-13">{t("auth.common.password.new_password.label")}</h4>
|
||||
<div className="relative flex items-center rounded-md">
|
||||
<Controller
|
||||
control={control}
|
||||
name="new_password"
|
||||
rules={{
|
||||
required: t("common.errors.required"),
|
||||
}}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<Input
|
||||
id="new_password"
|
||||
type={showPassword?.password ? "text" : "password"}
|
||||
value={value}
|
||||
placeholder={t("auth.common.password.new_password.placeholder")}
|
||||
onChange={onChange}
|
||||
className="w-full"
|
||||
hasError={Boolean(errors.new_password)}
|
||||
onFocus={() => setIsPasswordInputFocused(true)}
|
||||
onBlur={() => setIsPasswordInputFocused(false)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{showPassword?.password ? (
|
||||
<EyeOff
|
||||
className="absolute right-3 h-5 w-5 stroke-placeholder hover:cursor-pointer"
|
||||
onClick={() => handleShowPassword("password")}
|
||||
/>
|
||||
) : (
|
||||
<Eye
|
||||
className="absolute right-3 h-5 w-5 stroke-placeholder hover:cursor-pointer"
|
||||
onClick={() => handleShowPassword("password")}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{passwordSupport}
|
||||
{isNewPasswordSameAsOldPassword && !isPasswordInputFocused && (
|
||||
<span className="text-11 text-danger-primary">
|
||||
{t("new_password_must_be_different_from_old_password")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<h4 className="text-13">{t("auth.common.password.confirm_password.label")}</h4>
|
||||
<div className="relative flex items-center rounded-md">
|
||||
<Controller
|
||||
control={control}
|
||||
name="confirm_password"
|
||||
rules={{
|
||||
required: t("common.errors.required"),
|
||||
}}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<Input
|
||||
id="confirm_password"
|
||||
type={showPassword?.confirmPassword ? "text" : "password"}
|
||||
placeholder={t("auth.common.password.confirm_password.placeholder")}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
className="w-full"
|
||||
hasError={Boolean(errors.confirm_password)}
|
||||
onFocus={() => setIsRetryPasswordInputFocused(true)}
|
||||
onBlur={() => setIsRetryPasswordInputFocused(false)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{showPassword?.confirmPassword ? (
|
||||
<EyeOff
|
||||
className="absolute right-3 h-5 w-5 stroke-placeholder hover:cursor-pointer"
|
||||
onClick={() => handleShowPassword("confirmPassword")}
|
||||
/>
|
||||
) : (
|
||||
<Eye
|
||||
className="absolute right-3 h-5 w-5 stroke-placeholder hover:cursor-pointer"
|
||||
onClick={() => handleShowPassword("confirmPassword")}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{!!confirmPassword && password !== confirmPassword && renderPasswordMatchError && (
|
||||
<span className="text-13 text-danger-primary">{t("auth.common.password.errors.match")}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between py-2">
|
||||
<Button variant="primary" type="submit" loading={isSubmitting} disabled={isButtonDisabled}>
|
||||
{isSubmitting
|
||||
? `${t("auth.common.password.change_password.label.submitting")}`
|
||||
: t("auth.common.password.change_password.label.default")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default observer(SecurityPage);
|
||||
@@ -1,76 +0,0 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams, usePathname } from "next/navigation";
|
||||
import { CircleUser, Activity, Bell, CircleUserRound, KeyRound, Settings2, Blocks } from "lucide-react";
|
||||
// plane imports
|
||||
import { GROUPED_PROFILE_SETTINGS, PROFILE_SETTINGS_CATEGORIES } from "@plane/constants";
|
||||
import { LockIcon } from "@plane/propel/icons";
|
||||
import { getFileURL } from "@plane/utils";
|
||||
// components
|
||||
import { SettingsSidebar } from "@/components/settings/sidebar";
|
||||
// hooks
|
||||
import { useUser } from "@/hooks/store/user";
|
||||
|
||||
const ICONS = {
|
||||
profile: CircleUser,
|
||||
security: LockIcon,
|
||||
activity: Activity,
|
||||
preferences: Settings2,
|
||||
notifications: Bell,
|
||||
"api-tokens": KeyRound,
|
||||
connections: Blocks,
|
||||
};
|
||||
|
||||
export function ProjectActionIcons({ type, size, className }: { type: string; size?: number; className?: string }) {
|
||||
if (type === undefined) return null;
|
||||
const Icon = ICONS[type as keyof typeof ICONS];
|
||||
if (!Icon) return null;
|
||||
return <Icon size={size} className={className} strokeWidth={2} />;
|
||||
}
|
||||
|
||||
type TProfileSidebarProps = {
|
||||
isMobile?: boolean;
|
||||
};
|
||||
|
||||
export const ProfileSidebar = observer(function ProfileSidebar(props: TProfileSidebarProps) {
|
||||
const { isMobile = false } = props;
|
||||
// router
|
||||
const pathname = usePathname();
|
||||
const { workspaceSlug } = useParams();
|
||||
// store hooks
|
||||
const { data: currentUser } = useUser();
|
||||
|
||||
return (
|
||||
<SettingsSidebar
|
||||
isMobile={isMobile}
|
||||
categories={PROFILE_SETTINGS_CATEGORIES}
|
||||
groupedSettings={GROUPED_PROFILE_SETTINGS}
|
||||
workspaceSlug={workspaceSlug?.toString() ?? ""}
|
||||
isActive={(data: { href: string }) => pathname === `/${workspaceSlug}${data.href}/`}
|
||||
customHeader={
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-shrink-0">
|
||||
{!currentUser?.avatar_url || currentUser?.avatar_url === "" ? (
|
||||
<div className="h-8 w-8 rounded-full">
|
||||
<CircleUserRound className="h-full w-full text-secondary" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative h-8 w-8 overflow-hidden">
|
||||
<img
|
||||
src={getFileURL(currentUser?.avatar_url)}
|
||||
className="absolute left-0 top-0 h-full w-full rounded-lg object-cover"
|
||||
alt={currentUser?.display_name}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="w-full overflow-hidden">
|
||||
<div className="text-14 font-medium text-secondary truncate">{currentUser?.display_name}</div>
|
||||
<div className="text-13 text-tertiary truncate">{currentUser?.email}</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
actionIcons={ProjectActionIcons}
|
||||
shouldRender
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -2,14 +2,19 @@ import { Outlet } from "react-router";
|
||||
import { AuthenticationWrapper } from "@/lib/wrappers/authentication-wrapper";
|
||||
import { WorkspaceContentWrapper } from "@/plane-web/components/workspace/content-wrapper";
|
||||
import { AppRailVisibilityProvider } from "@/plane-web/hooks/app-rail";
|
||||
import { GlobalModals } from "@/plane-web/components/common/modal/global";
|
||||
import { WorkspaceAuthWrapper } from "@/plane-web/layouts/workspace-wrapper";
|
||||
import type { Route } from "./+types/layout";
|
||||
|
||||
export default function WorkspaceLayout(props: Route.ComponentProps) {
|
||||
const { workspaceSlug } = props.params;
|
||||
|
||||
export default function WorkspaceLayout() {
|
||||
return (
|
||||
<AuthenticationWrapper>
|
||||
<WorkspaceAuthWrapper>
|
||||
<AppRailVisibilityProvider>
|
||||
<WorkspaceContentWrapper>
|
||||
<GlobalModals workspaceSlug={workspaceSlug} />
|
||||
<Outlet />
|
||||
</WorkspaceContentWrapper>
|
||||
</AppRailVisibilityProvider>
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useTheme } from "next-themes";
|
||||
// plane imports
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { Button } from "@plane/propel/button";
|
||||
// assets
|
||||
import darkActivityAsset from "@/app/assets/empty-state/profile/activity-dark.webp?url";
|
||||
import lightActivityAsset from "@/app/assets/empty-state/profile/activity-light.webp?url";
|
||||
// components
|
||||
import { PageHead } from "@/components/core/page-title";
|
||||
import { DetailedEmptyState } from "@/components/empty-state/detailed-empty-state-root";
|
||||
import { ProfileActivityListPage } from "@/components/profile/activity/profile-activity-list";
|
||||
import { ProfileSettingContentHeader } from "@/components/profile/profile-setting-content-header";
|
||||
import { ProfileSettingContentWrapper } from "@/components/profile/profile-setting-content-wrapper";
|
||||
|
||||
const PER_PAGE = 100;
|
||||
|
||||
function ProfileActivityPage() {
|
||||
// states
|
||||
const [pageCount, setPageCount] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(0);
|
||||
const [resultsCount, setResultsCount] = useState(0);
|
||||
const [isEmpty, setIsEmpty] = useState(false);
|
||||
// theme hook
|
||||
const { resolvedTheme } = useTheme();
|
||||
// plane hooks
|
||||
const { t } = useTranslation();
|
||||
// derived values
|
||||
const resolvedPath = resolvedTheme === "light" ? lightActivityAsset : darkActivityAsset;
|
||||
|
||||
const updateTotalPages = (count: number) => setTotalPages(count);
|
||||
|
||||
const updateResultsCount = (count: number) => setResultsCount(count);
|
||||
|
||||
const updateEmptyState = (isEmpty: boolean) => setIsEmpty(isEmpty);
|
||||
|
||||
const handleLoadMore = () => setPageCount((prev) => prev + 1);
|
||||
|
||||
const activityPages: React.ReactNode[] = [];
|
||||
for (let i = 0; i < pageCount; i++)
|
||||
activityPages.push(
|
||||
<ProfileActivityListPage
|
||||
key={i}
|
||||
cursor={`${PER_PAGE}:${i}:0`}
|
||||
perPage={PER_PAGE}
|
||||
updateResultsCount={updateResultsCount}
|
||||
updateTotalPages={updateTotalPages}
|
||||
updateEmptyState={updateEmptyState}
|
||||
/>
|
||||
);
|
||||
|
||||
const isLoadMoreVisible = pageCount < totalPages && resultsCount !== 0;
|
||||
|
||||
if (isEmpty) {
|
||||
return (
|
||||
<DetailedEmptyState
|
||||
title={t("profile.empty_state.activity.title")}
|
||||
description={t("profile.empty_state.activity.description")}
|
||||
assetPath={resolvedPath}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHead title="Profile - Activity" />
|
||||
<ProfileSettingContentWrapper>
|
||||
<ProfileSettingContentHeader title={t("activity")} />
|
||||
{activityPages}
|
||||
{isLoadMoreVisible && (
|
||||
<div className="flex w-full items-center justify-center text-11">
|
||||
<Button variant="secondary" onClick={handleLoadMore}>
|
||||
{t("load_more")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</ProfileSettingContentWrapper>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default observer(ProfileActivityPage);
|
||||
@@ -1,101 +0,0 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useTheme } from "next-themes";
|
||||
// plane imports
|
||||
import type { I_THEME_OPTION } from "@plane/constants";
|
||||
import { THEME_OPTIONS } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { setPromiseToast } from "@plane/propel/toast";
|
||||
import { applyCustomTheme } from "@plane/utils";
|
||||
// components
|
||||
import { LogoSpinner } from "@/components/common/logo-spinner";
|
||||
import { PageHead } from "@/components/core/page-title";
|
||||
import { CustomThemeSelector } from "@/components/core/theme/custom-theme-selector";
|
||||
import { ThemeSwitch } from "@/components/core/theme/theme-switch";
|
||||
import { ProfileSettingContentHeader } from "@/components/profile/profile-setting-content-header";
|
||||
import { ProfileSettingContentWrapper } from "@/components/profile/profile-setting-content-wrapper";
|
||||
// hooks
|
||||
import { useUserProfile } from "@/hooks/store/user";
|
||||
|
||||
function ProfileAppearancePage() {
|
||||
// store hooks
|
||||
const { data: userProfile, updateUserTheme } = useUserProfile();
|
||||
// theme
|
||||
const { setTheme } = useTheme();
|
||||
// translation
|
||||
const { t } = useTranslation();
|
||||
// derived values
|
||||
const currentTheme = useMemo(() => {
|
||||
const userThemeOption = THEME_OPTIONS.find((t) => t.value === userProfile?.theme?.theme);
|
||||
return userThemeOption || null;
|
||||
}, [userProfile?.theme?.theme]);
|
||||
|
||||
const handleThemeChange = useCallback(
|
||||
async (themeOption: I_THEME_OPTION) => {
|
||||
setTheme(themeOption.value);
|
||||
|
||||
// If switching to custom theme and user has saved custom colors, apply them immediately
|
||||
if (
|
||||
themeOption.value === "custom" &&
|
||||
userProfile?.theme?.primary &&
|
||||
userProfile?.theme?.background &&
|
||||
userProfile?.theme?.darkPalette !== undefined
|
||||
) {
|
||||
applyCustomTheme(
|
||||
userProfile.theme.primary,
|
||||
userProfile.theme.background,
|
||||
userProfile.theme.darkPalette ? "dark" : "light"
|
||||
);
|
||||
}
|
||||
|
||||
const updateCurrentUserThemePromise = updateUserTheme({ theme: themeOption.value });
|
||||
setPromiseToast(updateCurrentUserThemePromise, {
|
||||
loading: "Updating theme...",
|
||||
success: {
|
||||
title: "Theme updated",
|
||||
message: () => "Reloading to apply changes...",
|
||||
},
|
||||
error: {
|
||||
title: "Error!",
|
||||
message: () => "Failed to update theme. Please try again.",
|
||||
},
|
||||
});
|
||||
// Wait for the promise to resolve, then reload after showing toast
|
||||
try {
|
||||
await updateCurrentUserThemePromise;
|
||||
window.location.reload();
|
||||
} catch (error) {
|
||||
// Error toast already shown by setPromiseToast
|
||||
console.error("Error updating theme:", error);
|
||||
}
|
||||
},
|
||||
[setTheme, updateUserTheme, userProfile]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHead title="Profile - Appearance" />
|
||||
{userProfile ? (
|
||||
<ProfileSettingContentWrapper>
|
||||
<ProfileSettingContentHeader title={t("appearance")} />
|
||||
<div className="grid grid-cols-12 gap-4 py-6 sm:gap-16">
|
||||
<div className="col-span-12 sm:col-span-6">
|
||||
<h4 className="text-16 font-semibold text-primary">{t("theme")}</h4>
|
||||
<p className="text-13 text-secondary">{t("select_or_customize_your_interface_color_scheme")}</p>
|
||||
</div>
|
||||
<div className="col-span-12 sm:col-span-6">
|
||||
<ThemeSwitch value={currentTheme} onChange={handleThemeChange} />
|
||||
</div>
|
||||
</div>
|
||||
{userProfile?.theme?.theme === "custom" && <CustomThemeSelector />}
|
||||
</ProfileSettingContentWrapper>
|
||||
) : (
|
||||
<div className="grid h-full w-full place-items-center px-4 sm:px-0">
|
||||
<LogoSpinner />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default observer(ProfileAppearancePage);
|
||||
@@ -1,37 +0,0 @@
|
||||
import useSWR from "swr";
|
||||
// components
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { PageHead } from "@/components/core/page-title";
|
||||
import { EmailNotificationForm } from "@/components/profile/notification/email-notification-form";
|
||||
import { ProfileSettingContentHeader } from "@/components/profile/profile-setting-content-header";
|
||||
import { ProfileSettingContentWrapper } from "@/components/profile/profile-setting-content-wrapper";
|
||||
import { EmailSettingsLoader } from "@/components/ui/loader/settings/email";
|
||||
// services
|
||||
import { UserService } from "@/services/user.service";
|
||||
|
||||
const userService = new UserService();
|
||||
|
||||
export default function ProfileNotificationPage() {
|
||||
const { t } = useTranslation();
|
||||
// fetching user email notification settings
|
||||
const { data, isLoading } = useSWR("CURRENT_USER_EMAIL_NOTIFICATION_SETTINGS", () =>
|
||||
userService.currentUserEmailNotificationSettings()
|
||||
);
|
||||
|
||||
if (!data || isLoading) {
|
||||
return <EmailSettingsLoader />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHead title={`${t("profile.label")} - ${t("notifications")}`} />
|
||||
<ProfileSettingContentWrapper>
|
||||
<ProfileSettingContentHeader
|
||||
title={t("email_notifications")}
|
||||
description={t("stay_in_the_loop_on_issues_you_are_subscribed_to_enable_this_to_get_notified")}
|
||||
/>
|
||||
<EmailNotificationForm data={data} />
|
||||
</ProfileSettingContentWrapper>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
import { observer } from "mobx-react";
|
||||
// plane imports
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
// components
|
||||
import { LogoSpinner } from "@/components/common/logo-spinner";
|
||||
import { PageHead } from "@/components/core/page-title";
|
||||
import { ProfileForm } from "@/components/profile/form";
|
||||
import { ProfileSettingContentWrapper } from "@/components/profile/profile-setting-content-wrapper";
|
||||
// hooks
|
||||
import { useUser } from "@/hooks/store/user";
|
||||
|
||||
function ProfileSettingsPage() {
|
||||
const { t } = useTranslation();
|
||||
// store hooks
|
||||
const { data: currentUser, userProfile } = useUser();
|
||||
|
||||
if (!currentUser)
|
||||
return (
|
||||
<div className="grid h-full w-full place-items-center px-4 sm:px-0">
|
||||
<LogoSpinner />
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHead title={`${t("profile.label")} - ${t("general_settings")}`} />
|
||||
<ProfileSettingContentWrapper>
|
||||
<ProfileForm user={currentUser} profile={userProfile.data} />
|
||||
</ProfileSettingContentWrapper>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default observer(ProfileSettingsPage);
|
||||
@@ -1,279 +0,0 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
// icons
|
||||
import { LogOut, MoveLeft, Activity, Bell, CircleUser, KeyRound, Settings2, CirclePlus, Mails } from "lucide-react";
|
||||
// plane imports
|
||||
import { PROFILE_ACTION_LINKS } from "@plane/constants";
|
||||
import { useOutsideClickDetector } from "@plane/hooks";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { ChevronLeftIcon } from "@plane/propel/icons";
|
||||
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
||||
import { Tooltip } from "@plane/propel/tooltip";
|
||||
import { cn, getFileURL } from "@plane/utils";
|
||||
// components
|
||||
import { SidebarNavItem } from "@/components/sidebar/sidebar-navigation";
|
||||
// hooks
|
||||
import { useAppTheme } from "@/hooks/store/use-app-theme";
|
||||
import { useWorkspace } from "@/hooks/store/use-workspace";
|
||||
import { useUser, useUserSettings } from "@/hooks/store/user";
|
||||
import { usePlatformOS } from "@/hooks/use-platform-os";
|
||||
|
||||
const WORKSPACE_ACTION_LINKS = [
|
||||
{
|
||||
key: "create_workspace",
|
||||
Icon: CirclePlus,
|
||||
i18n_label: "create_workspace",
|
||||
href: "/create-workspace",
|
||||
},
|
||||
{
|
||||
key: "invitations",
|
||||
Icon: Mails,
|
||||
i18n_label: "workspace_invites",
|
||||
href: "/invitations",
|
||||
},
|
||||
];
|
||||
|
||||
function ProjectActionIcons({ type, size, className = "" }: { type: string; size?: number; className?: string }) {
|
||||
const icons = {
|
||||
profile: CircleUser,
|
||||
security: KeyRound,
|
||||
activity: Activity,
|
||||
preferences: Settings2,
|
||||
notifications: Bell,
|
||||
"api-tokens": KeyRound,
|
||||
};
|
||||
|
||||
if (type === undefined) return null;
|
||||
const Icon = icons[type as keyof typeof icons];
|
||||
if (!Icon) return null;
|
||||
return <Icon size={size} className={className} />;
|
||||
}
|
||||
|
||||
export const ProfileLayoutSidebar = observer(function ProfileLayoutSidebar() {
|
||||
// states
|
||||
const [isSigningOut, setIsSigningOut] = useState(false);
|
||||
// router
|
||||
const pathname = usePathname();
|
||||
// store hooks
|
||||
const { sidebarCollapsed, toggleSidebar } = useAppTheme();
|
||||
const { data: currentUser, signOut } = useUser();
|
||||
const { data: currentUserSettings } = useUserSettings();
|
||||
const { workspaces } = useWorkspace();
|
||||
const { isMobile } = usePlatformOS();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const workspacesList = Object.values(workspaces ?? {});
|
||||
|
||||
// redirect url for normal mode
|
||||
const redirectWorkspaceSlug =
|
||||
currentUserSettings?.workspace?.last_workspace_slug ||
|
||||
currentUserSettings?.workspace?.fallback_workspace_slug ||
|
||||
"";
|
||||
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useOutsideClickDetector(ref, () => {
|
||||
if (sidebarCollapsed === false) {
|
||||
if (window.innerWidth < 768) {
|
||||
toggleSidebar();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
if (window.innerWidth <= 768) {
|
||||
toggleSidebar(true);
|
||||
}
|
||||
};
|
||||
handleResize();
|
||||
window.addEventListener("resize", handleResize);
|
||||
return () => {
|
||||
window.removeEventListener("resize", handleResize);
|
||||
};
|
||||
}, [toggleSidebar]);
|
||||
|
||||
const handleItemClick = () => {
|
||||
if (window.innerWidth < 768) {
|
||||
toggleSidebar();
|
||||
}
|
||||
};
|
||||
|
||||
const handleSignOut = async () => {
|
||||
setIsSigningOut(true);
|
||||
await signOut()
|
||||
.catch(() =>
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: t("sign_out.toast.error.title"),
|
||||
message: t("sign_out.toast.error.message"),
|
||||
})
|
||||
)
|
||||
.finally(() => setIsSigningOut(false));
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`fixed inset-y-0 z-20 flex h-full flex-shrink-0 flex-grow-0 flex-col border-r border-subtle bg-surface-1 duration-300 md:relative
|
||||
${sidebarCollapsed ? "-ml-[250px]" : ""}
|
||||
sm:${sidebarCollapsed ? "-ml-[250px]" : ""}
|
||||
md:ml-0 ${sidebarCollapsed ? "w-[70px]" : "w-[250px]"}
|
||||
`}
|
||||
>
|
||||
<div ref={ref} className="flex h-full w-full flex-col gap-y-4">
|
||||
<Link href={`/${redirectWorkspaceSlug}`} onClick={handleItemClick}>
|
||||
<div
|
||||
className={`flex flex-shrink-0 items-center gap-2 truncate px-4 pt-4 ${
|
||||
sidebarCollapsed ? "justify-center" : ""
|
||||
}`}
|
||||
>
|
||||
<span className="grid h-5 w-5 flex-shrink-0 place-items-center">
|
||||
<ChevronLeftIcon className="h-5 w-5" strokeWidth={1} />
|
||||
</span>
|
||||
{!sidebarCollapsed && (
|
||||
<h4 className="truncate text-16 font-semibold text-secondary">{t("profile_settings")}</h4>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
<div className="flex flex-shrink-0 flex-col overflow-x-hidden">
|
||||
{!sidebarCollapsed && (
|
||||
<h6 className="rounded-sm px-6 text-13 font-semibold text-placeholder">{t("your_account")}</h6>
|
||||
)}
|
||||
<div className="vertical-scrollbar scrollbar-sm mt-2 px-4 h-full space-y-1 overflow-y-auto">
|
||||
{PROFILE_ACTION_LINKS.map((link) => {
|
||||
if (link.key === "change-password" && currentUser?.is_password_autoset) return null;
|
||||
|
||||
return (
|
||||
<Link key={link.key} href={link.href} className="block w-full" onClick={handleItemClick}>
|
||||
<Tooltip
|
||||
tooltipContent={t(link.key)}
|
||||
position="right"
|
||||
className="ml-2"
|
||||
disabled={!sidebarCollapsed}
|
||||
isMobile={isMobile}
|
||||
>
|
||||
<SidebarNavItem
|
||||
key={link.key}
|
||||
className={`${sidebarCollapsed ? "p-0 size-8 aspect-square justify-center mx-auto" : ""}`}
|
||||
isActive={link.highlight(pathname)}
|
||||
>
|
||||
<div className="flex items-center gap-1.5 py-[1px]">
|
||||
<ProjectActionIcons type={link.key} size={16} />
|
||||
|
||||
{!sidebarCollapsed && <p className="text-13 leading-5 font-medium">{t(link.i18n_label)}</p>}
|
||||
</div>
|
||||
</SidebarNavItem>
|
||||
</Tooltip>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col overflow-x-hidden">
|
||||
{!sidebarCollapsed && (
|
||||
<h6 className="rounded-sm px-6 text-13 font-semibold text-placeholder">{t("workspaces")}</h6>
|
||||
)}
|
||||
{workspacesList && workspacesList.length > 0 && (
|
||||
<div
|
||||
className={cn("vertical-scrollbar scrollbar-xs mt-2 px-4 h-full space-y-1.5 overflow-y-auto", {
|
||||
"scrollbar-sm": !sidebarCollapsed,
|
||||
"ml-2.5 px-1": sidebarCollapsed,
|
||||
})}
|
||||
>
|
||||
{workspacesList.map((workspace) => (
|
||||
<Link
|
||||
key={workspace.id}
|
||||
href={`/${workspace.slug}`}
|
||||
className={`flex flex-grow cursor-pointer select-none items-center truncate text-left text-13 font-medium ${
|
||||
sidebarCollapsed ? "justify-center" : `justify-between`
|
||||
}`}
|
||||
onClick={handleItemClick}
|
||||
>
|
||||
<span
|
||||
className={`flex w-full flex-grow items-center gap-x-2 truncate rounded-md px-3 py-1 hover:bg-layer-1 ${
|
||||
sidebarCollapsed ? "justify-center" : ""
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`relative flex h-6 w-6 flex-shrink-0 items-center justify-center p-2 text-11 uppercase ${
|
||||
!workspace?.logo_url && "rounded-sm bg-accent-primary text-on-color"
|
||||
}`}
|
||||
>
|
||||
{workspace?.logo_url && workspace.logo_url !== "" ? (
|
||||
<img
|
||||
src={getFileURL(workspace.logo_url)}
|
||||
className="absolute left-0 top-0 h-full w-full rounded-sm object-cover"
|
||||
alt="Workspace Logo"
|
||||
/>
|
||||
) : (
|
||||
(workspace?.name?.charAt(0) ?? "...")
|
||||
)}
|
||||
</span>
|
||||
{!sidebarCollapsed && <p className="truncate text-13 text-secondary">{workspace.name}</p>}
|
||||
</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-1.5 px-4">
|
||||
{WORKSPACE_ACTION_LINKS.map((link) => (
|
||||
<Link className="block w-full" key={link.key} href={link.href} onClick={handleItemClick}>
|
||||
<Tooltip
|
||||
tooltipContent={t(link.key)}
|
||||
position="right"
|
||||
className="ml-2"
|
||||
disabled={!sidebarCollapsed}
|
||||
isMobile={isMobile}
|
||||
>
|
||||
<div
|
||||
className={`group flex w-full items-center gap-2.5 rounded-md px-3 py-2 text-13 font-medium text-secondary outline-none hover:bg-layer-1 focus:bg-layer-1 ${
|
||||
sidebarCollapsed ? "justify-center" : ""
|
||||
}`}
|
||||
>
|
||||
{<link.Icon className="flex-shrink-0 size-4" />}
|
||||
{!sidebarCollapsed && t(link.i18n_label)}
|
||||
</div>
|
||||
</Tooltip>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-shrink-0 flex-grow items-end px-6 py-2">
|
||||
<div
|
||||
className={`flex w-full ${
|
||||
sidebarCollapsed ? "flex-col justify-center gap-2" : "items-center justify-between gap-2"
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSignOut}
|
||||
className="flex items-center justify-center gap-2 text-13 font-medium text-danger-primary"
|
||||
disabled={isSigningOut}
|
||||
>
|
||||
<LogOut className="h-3.5 w-3.5" />
|
||||
{!sidebarCollapsed && <span>{isSigningOut ? `${t("signing_out")}...` : t("sign_out")}</span>}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="grid place-items-center rounded-md p-1.5 text-secondary outline-none hover:bg-surface-2 hover:text-primary md:hidden"
|
||||
onClick={() => toggleSidebar()}
|
||||
>
|
||||
<MoveLeft className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`ml-auto hidden place-items-center rounded-md p-1.5 text-secondary outline-none hover:bg-surface-2 hover:text-primary md:grid ${
|
||||
sidebarCollapsed ? "w-full" : ""
|
||||
}`}
|
||||
onClick={() => toggleSidebar()}
|
||||
>
|
||||
<MoveLeft className={`h-3.5 w-3.5 duration-300 ${sidebarCollapsed ? "rotate-180" : ""}`} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import { observer } from "mobx-react";
|
||||
// plane imports
|
||||
import { PROFILE_SETTINGS_TABS } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import type { TProfileSettingsTabs } from "@plane/types";
|
||||
// components
|
||||
import { LogoSpinner } from "@/components/common/logo-spinner";
|
||||
import { PageHead } from "@/components/core/page-title";
|
||||
import { ProfileSettingsContent } from "@/components/settings/profile/content";
|
||||
import { ProfileSettingsSidebarRoot } from "@/components/settings/profile/sidebar";
|
||||
// hooks
|
||||
import { useUser } from "@/hooks/store/user";
|
||||
import { useAppRouter } from "@/hooks/use-app-router";
|
||||
// local imports
|
||||
import type { Route } from "../+types/layout";
|
||||
|
||||
function ProfileSettingsPage(props: Route.ComponentProps) {
|
||||
const { profileTabId } = props.params;
|
||||
// router
|
||||
const router = useAppRouter();
|
||||
// store hooks
|
||||
const { data: currentUser } = useUser();
|
||||
// translation
|
||||
const { t } = useTranslation();
|
||||
// derived values
|
||||
const isAValidTab = PROFILE_SETTINGS_TABS.includes(profileTabId as TProfileSettingsTabs);
|
||||
|
||||
if (!currentUser || !isAValidTab)
|
||||
return (
|
||||
<div className="size-full grid place-items-center px-4">
|
||||
<LogoSpinner />
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHead title={`${t("profile.label")} - ${t("general_settings")}`} />
|
||||
<div className="relative size-full">
|
||||
<div className="size-full flex">
|
||||
<ProfileSettingsSidebarRoot
|
||||
activeTab={profileTabId as TProfileSettingsTabs}
|
||||
className="w-[250px]"
|
||||
updateActiveTab={(tab) => router.push(`/settings/profile/${tab}`)}
|
||||
/>
|
||||
<ProfileSettingsContent
|
||||
activeTab={profileTabId as TProfileSettingsTabs}
|
||||
className="grow py-20 px-page-x mx-auto w-fit max-w-225"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default observer(ProfileSettingsPage);
|
||||
+5
-8
@@ -1,20 +1,17 @@
|
||||
// components
|
||||
import { Outlet } from "react-router";
|
||||
// wrappers
|
||||
// components
|
||||
import { ProjectsAppPowerKProvider } from "@/components/power-k/projects-app-provider";
|
||||
// lib
|
||||
import { AuthenticationWrapper } from "@/lib/wrappers/authentication-wrapper";
|
||||
// layout
|
||||
import { ProfileLayoutSidebar } from "./sidebar";
|
||||
|
||||
export default function ProfileSettingsLayout() {
|
||||
return (
|
||||
<>
|
||||
<ProjectsAppPowerKProvider />
|
||||
<AuthenticationWrapper>
|
||||
<div className="relative flex h-full w-full overflow-hidden rounded-lg border border-subtle">
|
||||
<ProfileLayoutSidebar />
|
||||
<main className="relative flex h-full w-full flex-col overflow-hidden bg-surface-1">
|
||||
<div className="h-full w-full overflow-hidden">
|
||||
<div className="relative flex size-full overflow-hidden bg-canvas p-2">
|
||||
<main className="relative flex flex-col size-full overflow-hidden bg-surface-1 rounded-lg border border-subtle">
|
||||
<div className="size-full overflow-hidden">
|
||||
<Outlet />
|
||||
</div>
|
||||
</main>
|
||||
@@ -278,34 +278,6 @@ export const coreRoutes: RouteConfigEntry[] = [
|
||||
),
|
||||
]),
|
||||
|
||||
// --------------------------------------------------------------------
|
||||
// ACCOUNT SETTINGS
|
||||
// --------------------------------------------------------------------
|
||||
|
||||
layout("./(all)/[workspaceSlug]/(settings)/settings/account/layout.tsx", [
|
||||
route(":workspaceSlug/settings/account", "./(all)/[workspaceSlug]/(settings)/settings/account/page.tsx"),
|
||||
route(
|
||||
":workspaceSlug/settings/account/activity",
|
||||
"./(all)/[workspaceSlug]/(settings)/settings/account/activity/page.tsx"
|
||||
),
|
||||
route(
|
||||
":workspaceSlug/settings/account/preferences",
|
||||
"./(all)/[workspaceSlug]/(settings)/settings/account/preferences/page.tsx"
|
||||
),
|
||||
route(
|
||||
":workspaceSlug/settings/account/notifications",
|
||||
"./(all)/[workspaceSlug]/(settings)/settings/account/notifications/page.tsx"
|
||||
),
|
||||
route(
|
||||
":workspaceSlug/settings/account/security",
|
||||
"./(all)/[workspaceSlug]/(settings)/settings/account/security/page.tsx"
|
||||
),
|
||||
route(
|
||||
":workspaceSlug/settings/account/api-tokens",
|
||||
"./(all)/[workspaceSlug]/(settings)/settings/account/api-tokens/page.tsx"
|
||||
),
|
||||
]),
|
||||
|
||||
// --------------------------------------------------------------------
|
||||
// PROJECT SETTINGS
|
||||
// --------------------------------------------------------------------
|
||||
@@ -363,12 +335,8 @@ export const coreRoutes: RouteConfigEntry[] = [
|
||||
// PROFILE SETTINGS
|
||||
// --------------------------------------------------------------------
|
||||
|
||||
layout("./(all)/profile/layout.tsx", [
|
||||
route("profile", "./(all)/profile/page.tsx"),
|
||||
route("profile/activity", "./(all)/profile/activity/page.tsx"),
|
||||
route("profile/appearance", "./(all)/profile/appearance/page.tsx"),
|
||||
route("profile/notifications", "./(all)/profile/notifications/page.tsx"),
|
||||
route("profile/security", "./(all)/profile/security/page.tsx"),
|
||||
layout("./(all)/settings/profile/layout.tsx", [
|
||||
route("settings/profile/:profileTabId", "./(all)/settings/profile/[profileTabId]/page.tsx"),
|
||||
]),
|
||||
]),
|
||||
|
||||
@@ -389,7 +357,7 @@ export const coreRoutes: RouteConfigEntry[] = [
|
||||
route(":workspaceSlug/analytics", "routes/redirects/core/analytics.tsx"),
|
||||
|
||||
// API tokens redirect: /:workspaceSlug/settings/api-tokens
|
||||
// → /:workspaceSlug/settings/account/api-tokens
|
||||
// → /settings/profile/api-tokens
|
||||
route(":workspaceSlug/settings/api-tokens", "routes/redirects/core/api-tokens.tsx"),
|
||||
|
||||
// Inbox redirect: /:workspaceSlug/projects/:projectId/inbox
|
||||
@@ -406,4 +374,10 @@ export const coreRoutes: RouteConfigEntry[] = [
|
||||
|
||||
// Register redirect
|
||||
route("register", "routes/redirects/core/register.tsx"),
|
||||
|
||||
// Profile settings redirects
|
||||
route("profile/*", "routes/redirects/core/profile-settings.tsx"),
|
||||
|
||||
// Account settings redirects
|
||||
route(":workspaceSlug/settings/account/*", "routes/redirects/core/workspace-account-settings.tsx"),
|
||||
] satisfies RouteConfig;
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { redirect } from "react-router";
|
||||
import type { Route } from "./+types/api-tokens";
|
||||
|
||||
export const clientLoader = ({ params }: Route.ClientLoaderArgs) => {
|
||||
const { workspaceSlug } = params;
|
||||
throw redirect(`/${workspaceSlug}/settings/account/api-tokens/`);
|
||||
export const clientLoader = () => {
|
||||
throw redirect(`/settings/profile/api-tokens/`);
|
||||
};
|
||||
|
||||
export default function ApiTokens() {
|
||||
|
||||
@@ -14,7 +14,7 @@ export const coreRedirectRoutes: RouteConfigEntry[] = [
|
||||
route(":workspaceSlug/analytics", "routes/redirects/core/analytics.tsx"),
|
||||
|
||||
// API tokens redirect: /:workspaceSlug/settings/api-tokens
|
||||
// → /:workspaceSlug/settings/account/api-tokens
|
||||
// → /settings/profile/api-tokens
|
||||
route(":workspaceSlug/settings/api-tokens", "routes/redirects/core/api-tokens.tsx"),
|
||||
|
||||
// Inbox redirect: /:workspaceSlug/projects/:projectId/inbox
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { redirect } from "react-router";
|
||||
import type { Route } from "./+types/profile-settings";
|
||||
|
||||
export const clientLoader = ({ params, request }: Route.ClientLoaderArgs) => {
|
||||
const searchParams = new URL(request.url).searchParams;
|
||||
const splat = params["*"] || "";
|
||||
throw redirect(`/settings/profile/${splat || "general"}?${searchParams.toString()}`);
|
||||
};
|
||||
|
||||
export default function ProfileSettings() {
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { redirect } from "react-router";
|
||||
import type { Route } from "./+types/workspace-account-settings";
|
||||
|
||||
export const clientLoader = ({ params, request }: Route.ClientLoaderArgs) => {
|
||||
const searchParams = new URL(request.url).searchParams;
|
||||
const splat = params["*"] || "";
|
||||
throw redirect(`/settings/profile/${splat || "general"}?${searchParams.toString()}`);
|
||||
};
|
||||
|
||||
export default function WorkspaceAccountSettings() {
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { lazy, Suspense } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
|
||||
const ProfileSettingsModal = lazy(() =>
|
||||
import("@/components/settings/profile/modal").then((module) => ({
|
||||
default: module.ProfileSettingsModal,
|
||||
}))
|
||||
);
|
||||
|
||||
type TGlobalModalsProps = {
|
||||
workspaceSlug: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* GlobalModals component manages all workspace-level modals across Plane applications.
|
||||
*
|
||||
* This includes:
|
||||
* - Profile settings modal
|
||||
*/
|
||||
export const GlobalModals = observer(function GlobalModals(_props: TGlobalModalsProps) {
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<ProfileSettingsModal />
|
||||
</Suspense>
|
||||
);
|
||||
});
|
||||
@@ -74,7 +74,7 @@ export const TopNavigationRoot = observer(function TopNavigationRoot() {
|
||||
<HelpMenuRoot />
|
||||
<StarUsOnGitHubLink />
|
||||
<div className="flex items-center justify-center size-8 hover:bg-layer-1-hover rounded-md">
|
||||
<UserMenuRoot size="xs" />
|
||||
<UserMenuRoot />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
import { StartOfWeekPreference } from "@/components/profile/start-of-week-preference";
|
||||
import { ThemeSwitcher } from "./theme-switcher";
|
||||
|
||||
export const PREFERENCE_COMPONENTS = {
|
||||
theme: ThemeSwitcher,
|
||||
start_of_week: StartOfWeekPreference,
|
||||
};
|
||||
@@ -10,8 +10,7 @@ import { applyCustomTheme } from "@plane/utils";
|
||||
// components
|
||||
import { CustomThemeSelector } from "@/components/core/theme/custom-theme-selector";
|
||||
import { ThemeSwitch } from "@/components/core/theme/theme-switch";
|
||||
// helpers
|
||||
import { PreferencesSection } from "@/components/preferences/section";
|
||||
import { SettingsControlItem } from "@/components/settings/control-item";
|
||||
// hooks
|
||||
import { useUserProfile } from "@/hooks/store/user";
|
||||
|
||||
@@ -79,18 +78,16 @@ export const ThemeSwitcher = observer(function ThemeSwitcher(props: {
|
||||
|
||||
return (
|
||||
<>
|
||||
<PreferencesSection
|
||||
<SettingsControlItem
|
||||
title={t(props.option.title)}
|
||||
description={t(props.option.description)}
|
||||
control={
|
||||
<div>
|
||||
<ThemeSwitch
|
||||
value={currentTheme}
|
||||
onChange={(themeOption) => {
|
||||
void handleThemeChange(themeOption);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<ThemeSwitch
|
||||
value={currentTheme}
|
||||
onChange={(themeOption) => {
|
||||
void handleThemeChange(themeOption);
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
{userProfile.theme?.theme === "custom" && <CustomThemeSelector />}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./theme-switcher";
|
||||
@@ -0,0 +1,70 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useTheme } from "next-themes";
|
||||
// plane imports
|
||||
import type { I_THEME_OPTION } from "@plane/constants";
|
||||
import { THEME_OPTIONS } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { setPromiseToast } from "@plane/propel/toast";
|
||||
// components
|
||||
import { CustomThemeSelector } from "@/components/core/theme/custom-theme-selector";
|
||||
import { ThemeSwitch } from "@/components/core/theme/theme-switch";
|
||||
import { SettingsControlItem } from "@/components/settings/control-item";
|
||||
// hooks
|
||||
import { useUserProfile } from "@/hooks/store/user";
|
||||
|
||||
export const ThemeSwitcher = observer(function ThemeSwitcher(props: {
|
||||
option: {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
};
|
||||
}) {
|
||||
// store hooks
|
||||
const { data: userProfile, updateUserTheme } = useUserProfile();
|
||||
// theme
|
||||
const { setTheme } = useTheme();
|
||||
// translation
|
||||
const { t } = useTranslation();
|
||||
// derived values
|
||||
const currentTheme = useMemo(() => {
|
||||
const userThemeOption = THEME_OPTIONS.find((t) => t.value === userProfile?.theme?.theme);
|
||||
return userThemeOption || null;
|
||||
}, [userProfile?.theme?.theme]);
|
||||
|
||||
const handleThemeChange = useCallback(
|
||||
(themeOption: I_THEME_OPTION) => {
|
||||
try {
|
||||
setTheme(themeOption.value);
|
||||
const updatePromise = updateUserTheme({ theme: themeOption.value });
|
||||
setPromiseToast(updatePromise, {
|
||||
loading: "Updating theme...",
|
||||
success: {
|
||||
title: "Success!",
|
||||
message: () => "Theme updated successfully!",
|
||||
},
|
||||
error: {
|
||||
title: "Error!",
|
||||
message: () => "Failed to update the theme",
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error updating theme:", error);
|
||||
}
|
||||
},
|
||||
[updateUserTheme]
|
||||
);
|
||||
|
||||
if (!userProfile) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsControlItem
|
||||
title={t(props.option.title)}
|
||||
description={t(props.option.description)}
|
||||
control={<ThemeSwitch value={currentTheme} onChange={handleThemeChange} />}
|
||||
/>
|
||||
{userProfile.theme?.theme === "custom" && <CustomThemeSelector />}
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -50,6 +50,7 @@ export function ThemeSwitch(props: Props) {
|
||||
)
|
||||
}
|
||||
onChange={onChange}
|
||||
buttonClassName="border border-subtle-1"
|
||||
placement="bottom-end"
|
||||
input
|
||||
>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { FC } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// plane imports
|
||||
import { CustomSearchSelect } from "@plane/ui";
|
||||
import { cn } from "@plane/utils";
|
||||
// hooks
|
||||
@@ -38,13 +38,14 @@ export const TimezoneSelect = observer(function TimezoneSelect(props: TTimezoneS
|
||||
label={value && selectedValue ? selectedValue(value) : label}
|
||||
options={isDisabled || disabled ? [] : timezones}
|
||||
onChange={onChange}
|
||||
buttonClassName={cn(buttonClassName, {
|
||||
buttonClassName={cn(buttonClassName, "border border-subtle-1", {
|
||||
"border-danger-strong": error,
|
||||
})}
|
||||
className={cn("rounded-md border-[0.5px] !border-subtle", className)}
|
||||
className={cn("rounded-md", className)}
|
||||
optionsClassName={cn("w-72", optionsClassName)}
|
||||
input
|
||||
disabled={isDisabled || disabled}
|
||||
placement="bottom-end"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -108,7 +108,7 @@ export const NoProjectsEmptyState = observer(function NoProjectsEmptyState() {
|
||||
flag: "visited_profile",
|
||||
cta: {
|
||||
text: "home.empty.personalize_account.cta",
|
||||
link: `/${workspaceSlug}/settings/account`,
|
||||
link: `/settings/profile/general`,
|
||||
disabled: false,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
import { PREFERENCE_OPTIONS } from "@plane/constants";
|
||||
import { PREFERENCE_COMPONENTS } from "@/plane-web/components/preferences/config";
|
||||
|
||||
export function PreferencesList() {
|
||||
return (
|
||||
<div className="py-6 space-y-6">
|
||||
{PREFERENCE_OPTIONS.map((option) => {
|
||||
const Component = PREFERENCE_COMPONENTS[option.id as keyof typeof PREFERENCE_COMPONENTS];
|
||||
return <Component key={option.id} option={option} />;
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
interface SettingsSectionProps {
|
||||
title: string;
|
||||
description: string;
|
||||
control: React.ReactNode;
|
||||
}
|
||||
|
||||
export function PreferencesSection({ title, description, control }: SettingsSectionProps) {
|
||||
return (
|
||||
<div className="flex w-full justify-between gap-4 sm:gap-16">
|
||||
<div className="col-span-12 sm:col-span-6">
|
||||
<h4 className="text-14 font-medium text-primary">{title}</h4>
|
||||
<p className="text-13 text-secondary">{description}</p>
|
||||
</div>
|
||||
<div className="col-span-12 sm:col-span-6 my-auto">{control}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,168 +0,0 @@
|
||||
import { useEffect } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
||||
import type { IUserEmailNotificationSettings } from "@plane/types";
|
||||
// ui
|
||||
import { ToggleSwitch } from "@plane/ui";
|
||||
// services
|
||||
import { UserService } from "@/services/user.service";
|
||||
// types
|
||||
interface IEmailNotificationFormProps {
|
||||
data: IUserEmailNotificationSettings;
|
||||
}
|
||||
|
||||
// services
|
||||
const userService = new UserService();
|
||||
|
||||
export function EmailNotificationForm(props: IEmailNotificationFormProps) {
|
||||
const { data } = props;
|
||||
const { t } = useTranslation();
|
||||
// form data
|
||||
const { control, reset } = useForm<IUserEmailNotificationSettings>({
|
||||
defaultValues: {
|
||||
...data,
|
||||
},
|
||||
});
|
||||
|
||||
const handleSettingChange = async (key: keyof IUserEmailNotificationSettings, value: boolean) => {
|
||||
try {
|
||||
await userService.updateCurrentUserEmailNotificationSettings({
|
||||
[key]: value,
|
||||
});
|
||||
setToast({
|
||||
title: t("success"),
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
message: t("email_notification_setting_updated_successfully"),
|
||||
});
|
||||
} catch (_error) {
|
||||
setToast({
|
||||
title: t("error"),
|
||||
type: TOAST_TYPE.ERROR,
|
||||
message: t("failed_to_update_email_notification_setting"),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
reset(data);
|
||||
}, [reset, data]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Notification Settings */}
|
||||
<div className="flex flex-col py-2 w-full">
|
||||
<div className="flex gap-2 items-center pt-2">
|
||||
<div className="grow">
|
||||
<div className="pb-1 text-14 font-medium text-primary">{t("property_changes")}</div>
|
||||
<div className="text-13 font-regular text-tertiary">{t("property_changes_description")}</div>
|
||||
</div>
|
||||
<div className="shrink-0">
|
||||
<Controller
|
||||
control={control}
|
||||
name="property_change"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<ToggleSwitch
|
||||
value={value}
|
||||
onChange={(newValue) => {
|
||||
onChange(newValue);
|
||||
handleSettingChange("property_change", newValue);
|
||||
}}
|
||||
size="sm"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2 items-center pt-6 pb-2">
|
||||
<div className="grow">
|
||||
<div className="pb-1 text-14 font-medium text-primary">{t("state_change")}</div>
|
||||
<div className="text-13 font-regular text-tertiary">{t("state_change_description")}</div>
|
||||
</div>
|
||||
<div className="shrink-0">
|
||||
<Controller
|
||||
control={control}
|
||||
name="state_change"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<ToggleSwitch
|
||||
value={value}
|
||||
onChange={(newValue) => {
|
||||
onChange(newValue);
|
||||
handleSettingChange("state_change", newValue);
|
||||
}}
|
||||
size="sm"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2 items-center border-0 border-l-[3px] border-strong pl-3">
|
||||
<div className="grow">
|
||||
<div className="pb-1 text-14 font-medium text-primary">{t("issue_completed")}</div>
|
||||
<div className="text-13 font-regular text-tertiary">{t("issue_completed_description")}</div>
|
||||
</div>
|
||||
<div className="shrink-0">
|
||||
<Controller
|
||||
control={control}
|
||||
name="issue_completed"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<ToggleSwitch
|
||||
value={value}
|
||||
onChange={(newValue) => {
|
||||
onChange(newValue);
|
||||
handleSettingChange("issue_completed", newValue);
|
||||
}}
|
||||
size="sm"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2 items-center pt-6">
|
||||
<div className="grow">
|
||||
<div className="pb-1 text-14 font-medium text-primary">{t("comments")}</div>
|
||||
<div className="text-13 font-regular text-tertiary">{t("comments_description")}</div>
|
||||
</div>
|
||||
<div className="shrink-0">
|
||||
<Controller
|
||||
control={control}
|
||||
name="comment"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<ToggleSwitch
|
||||
value={value}
|
||||
onChange={(newValue) => {
|
||||
onChange(newValue);
|
||||
handleSettingChange("comment", newValue);
|
||||
}}
|
||||
size="sm"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2 items-center pt-6">
|
||||
<div className="grow">
|
||||
<div className="pb-1 text-14 font-medium text-primary">{t("mentions")}</div>
|
||||
<div className="text-13 font-regular text-tertiary">{t("mentions_description")}</div>
|
||||
</div>
|
||||
<div className="shrink-0">
|
||||
<Controller
|
||||
control={control}
|
||||
name="mention"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<ToggleSwitch
|
||||
value={value}
|
||||
onChange={(newValue) => {
|
||||
onChange(newValue);
|
||||
handleSettingChange("mention", newValue);
|
||||
}}
|
||||
size="sm"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { SUPPORTED_LANGUAGES, useTranslation } from "@plane/i18n";
|
||||
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
||||
import { CustomSelect } from "@plane/ui";
|
||||
import { TimezoneSelect } from "@/components/global";
|
||||
import { useUser, useUserProfile } from "@/hooks/store/user";
|
||||
|
||||
export const LanguageTimezone = observer(function LanguageTimezone() {
|
||||
// store hooks
|
||||
const {
|
||||
data: user,
|
||||
updateCurrentUser,
|
||||
userProfile: { data: profile },
|
||||
} = useUser();
|
||||
const { updateUserProfile } = useUserProfile();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleTimezoneChange = async (value: string) => {
|
||||
try {
|
||||
await updateCurrentUser({ user_timezone: value });
|
||||
setToast({
|
||||
title: "Success!",
|
||||
message: "Timezone updated successfully",
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
});
|
||||
} catch (_error) {
|
||||
setToast({
|
||||
title: "Error!",
|
||||
message: "Failed to update timezone",
|
||||
type: TOAST_TYPE.ERROR,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleLanguageChange = async (value: string) => {
|
||||
try {
|
||||
await updateUserProfile({ language: value });
|
||||
setToast({
|
||||
title: "Success!",
|
||||
message: "Language updated successfully",
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
});
|
||||
} catch (_error) {
|
||||
setToast({
|
||||
title: "Error!",
|
||||
message: "Failed to update language",
|
||||
type: TOAST_TYPE.ERROR,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const getLanguageLabel = (value: string) => {
|
||||
const selectedLanguage = SUPPORTED_LANGUAGES.find((l) => l.value === value);
|
||||
if (!selectedLanguage) return value;
|
||||
return selectedLanguage.label;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="py-6">
|
||||
<div className="flex flex-col gap-x-6 gap-y-6">
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex gap-4 sm:gap-16 w-full justify-between">
|
||||
<div className="col-span-12 sm:col-span-6">
|
||||
<h4 className="text-14 font-medium text-primary"> {t("timezone")} </h4>
|
||||
<p className="text-13 text-secondary">{t("timezone_setting")}</p>
|
||||
</div>
|
||||
<div className="col-span-12 sm:col-span-6 my-auto">
|
||||
<TimezoneSelect value={user?.user_timezone || "Asia/Kolkata"} onChange={handleTimezoneChange} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex gap-4 sm:gap-16 w-full justify-between">
|
||||
<div className="col-span-12 sm:col-span-6">
|
||||
<h4 className="text-14 font-medium text-primary"> {t("language")} </h4>
|
||||
<p className="text-13 text-secondary">{t("language_setting")}</p>
|
||||
</div>
|
||||
<div className="col-span-12 sm:col-span-6 my-auto">
|
||||
<CustomSelect
|
||||
value={profile?.language}
|
||||
label={profile?.language ? getLanguageLabel(profile?.language) : "Select a language"}
|
||||
onChange={handleLanguageChange}
|
||||
buttonClassName={"border-none"}
|
||||
className="rounded-md border !border-subtle"
|
||||
input
|
||||
>
|
||||
{SUPPORTED_LANGUAGES.map((item) => (
|
||||
<CustomSelect.Option key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</CustomSelect.Option>
|
||||
))}
|
||||
</CustomSelect>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -1,14 +0,0 @@
|
||||
type Props = {
|
||||
title: string;
|
||||
description?: string;
|
||||
};
|
||||
|
||||
export function ProfileSettingContentHeader(props: Props) {
|
||||
const { title, description } = props;
|
||||
return (
|
||||
<div className="flex flex-col gap-1 pb-4 border-b border-subtle w-full">
|
||||
<div className="text-18 font-medium text-primary">{title}</div>
|
||||
{description && <div className="text-13 font-regular text-tertiary">{description}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,26 +1,22 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import Link from "next/link";
|
||||
import { useParams } from "next/navigation";
|
||||
// icons
|
||||
|
||||
// headless ui
|
||||
import { Disclosure, Transition } from "@headlessui/react";
|
||||
// plane helpers
|
||||
// plane imports
|
||||
import { useOutsideClickDetector } from "@plane/hooks";
|
||||
// types
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { Logo } from "@plane/propel/emoji-icon-picker";
|
||||
import { IconButton } from "@plane/propel/icon-button";
|
||||
import { EditIcon, ChevronDownIcon } from "@plane/propel/icons";
|
||||
import { Tooltip } from "@plane/propel/tooltip";
|
||||
import type { IUserProfileProjectSegregation } from "@plane/types";
|
||||
// plane ui
|
||||
import { Loader } from "@plane/ui";
|
||||
import { cn, renderFormattedDate, getFileURL } from "@plane/utils";
|
||||
// components
|
||||
import { CoverImage } from "@/components/common/cover-image";
|
||||
// hooks
|
||||
import { useAppTheme } from "@/hooks/store/use-app-theme";
|
||||
import { useCommandPalette } from "@/hooks/store/use-command-palette";
|
||||
import { useProject } from "@/hooks/store/use-project";
|
||||
import { useUser } from "@/hooks/store/user";
|
||||
import { usePlatformOS } from "@/hooks/use-platform-os";
|
||||
@@ -37,11 +33,12 @@ export const ProfileSidebar = observer(function ProfileSidebar(props: TProfileSi
|
||||
// refs
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
// router
|
||||
const { userId, workspaceSlug } = useParams();
|
||||
const { userId } = useParams();
|
||||
// store hooks
|
||||
const { data: currentUser } = useUser();
|
||||
const { profileSidebarCollapsed, toggleProfileSidebar } = useAppTheme();
|
||||
const { getProjectById } = useProject();
|
||||
const { toggleProfileSettingsModal } = useCommandPalette();
|
||||
const { isMobile } = usePlatformOS();
|
||||
const { t } = useTranslation();
|
||||
// derived values
|
||||
@@ -84,7 +81,7 @@ export const ProfileSidebar = observer(function ProfileSidebar(props: TProfileSi
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
`vertical-scrollbar scrollbar-md fixed z-5 h-full w-full flex-shrink-0 overflow-hidden overflow-y-auto border-l border-subtle bg-surface-1 transition-all md:relative md:w-[300px] shadow-raised-200`,
|
||||
`vertical-scrollbar scrollbar-md fixed z-5 h-full w-full shrink-0 overflow-hidden overflow-y-auto border-l border-subtle bg-surface-1 transition-all md:relative md:w-[300px] shadow-raised-200`,
|
||||
className
|
||||
)}
|
||||
style={profileSidebarCollapsed ? { marginLeft: `${window?.innerWidth || 0}px` } : {}}
|
||||
@@ -93,12 +90,17 @@ export const ProfileSidebar = observer(function ProfileSidebar(props: TProfileSi
|
||||
<>
|
||||
<div className="relative h-[110px]">
|
||||
{currentUser?.id === userId && (
|
||||
<div className="absolute right-3.5 top-3.5 grid h-5 w-5 place-items-center rounded-sm bg-white">
|
||||
<Link href={`/${workspaceSlug}/settings/account`}>
|
||||
<span className="grid place-items-center text-black">
|
||||
<EditIcon className="h-3 w-3" />
|
||||
</span>
|
||||
</Link>
|
||||
<div className="absolute right-3.5 top-3.5">
|
||||
<IconButton
|
||||
variant="secondary"
|
||||
icon={EditIcon}
|
||||
onClick={() =>
|
||||
toggleProfileSettingsModal({
|
||||
activeTab: "general",
|
||||
isOpen: true,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<CoverImage
|
||||
|
||||
@@ -4,9 +4,10 @@ import { START_OF_THE_WEEK_OPTIONS } from "@plane/constants";
|
||||
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
||||
import type { EStartOfTheWeek } from "@plane/types";
|
||||
import { CustomSelect } from "@plane/ui";
|
||||
// components
|
||||
import { SettingsControlItem } from "@/components/settings/control-item";
|
||||
// hooks
|
||||
import { useUserProfile } from "@/hooks/store/user";
|
||||
import { PreferencesSection } from "../preferences/section";
|
||||
|
||||
const getStartOfWeekLabel = (startOfWeek: EStartOfTheWeek) =>
|
||||
START_OF_THE_WEEK_OPTIONS.find((option) => option.value === startOfWeek)?.label;
|
||||
@@ -27,27 +28,27 @@ export const StartOfWeekPreference = observer(function StartOfWeekPreference(pro
|
||||
};
|
||||
|
||||
return (
|
||||
<PreferencesSection
|
||||
<SettingsControlItem
|
||||
title={props.option.title}
|
||||
description={props.option.description}
|
||||
control={
|
||||
<div className="">
|
||||
<CustomSelect
|
||||
value={userProfile.start_of_the_week}
|
||||
label={getStartOfWeekLabel(userProfile.start_of_the_week)}
|
||||
onChange={handleStartOfWeekChange}
|
||||
input
|
||||
maxHeight="lg"
|
||||
>
|
||||
<>
|
||||
{START_OF_THE_WEEK_OPTIONS.map((day) => (
|
||||
<CustomSelect.Option key={day.value} value={day.value}>
|
||||
{day.label}
|
||||
</CustomSelect.Option>
|
||||
))}
|
||||
</>
|
||||
</CustomSelect>
|
||||
</div>
|
||||
<CustomSelect
|
||||
value={userProfile.start_of_the_week}
|
||||
label={getStartOfWeekLabel(userProfile.start_of_the_week)}
|
||||
onChange={handleStartOfWeekChange}
|
||||
buttonClassName="border border-subtle-1"
|
||||
input
|
||||
maxHeight="lg"
|
||||
placement="bottom-end"
|
||||
>
|
||||
<>
|
||||
{START_OF_THE_WEEK_OPTIONS.map((day) => (
|
||||
<CustomSelect.Option key={day.value} value={day.value}>
|
||||
{day.label}
|
||||
</CustomSelect.Option>
|
||||
))}
|
||||
</>
|
||||
</CustomSelect>
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
type Props = {
|
||||
control: React.ReactNode;
|
||||
description: string;
|
||||
title: string;
|
||||
};
|
||||
|
||||
export function SettingsControlItem(props: Props) {
|
||||
const { control, description, title } = props;
|
||||
|
||||
return (
|
||||
<div className="w-full py-3 flex flex-col md:flex-row items-start md:items-center md:justify-between gap-4 md:gap-8">
|
||||
<div className="flex flex-col gap-1">
|
||||
<h4 className="text-body-sm-medium text-primary">{title}</h4>
|
||||
<p className="text-caption-md-regular text-secondary">{description}</p>
|
||||
</div>
|
||||
<div className="shrink-0">{control}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -24,15 +24,10 @@ export function SettingsHeading({
|
||||
className,
|
||||
}: Props) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col md:flex-row gap-2 items-start md:items-center justify-between border-b border-subtle pb-3.5",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className={cn("flex flex-col md:flex-row gap-2 items-start md:items-center justify-between", className)}>
|
||||
<div className="flex flex-col items-start gap-1">
|
||||
{typeof title === "string" ? <h3 className="text-18 font-medium">{title}</h3> : title}
|
||||
{description && <div className="text-13 text-tertiary">{description}</div>}
|
||||
{typeof title === "string" ? <h6 className="text-h6-medium text-primary">{title}</h6> : title}
|
||||
{description && <p className="text-body-xs-regular text-tertiary">{description}</p>}
|
||||
</div>
|
||||
{showButton && customButton}
|
||||
{button && showButton && (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { GROUPED_PROFILE_SETTINGS, GROUPED_WORKSPACE_SETTINGS } from "@plane/constants";
|
||||
import { GROUPED_WORKSPACE_SETTINGS } from "@plane/constants";
|
||||
import { PROJECT_SETTINGS_LINKS } from "@/plane-web/constants/project";
|
||||
|
||||
const hrefToLabelMap = (options: Record<string, Array<{ href: string; i18n_label: string; [key: string]: any }>>) =>
|
||||
@@ -14,8 +14,6 @@ const hrefToLabelMap = (options: Record<string, Array<{ href: string; i18n_label
|
||||
|
||||
const workspaceHrefToLabelMap = hrefToLabelMap(GROUPED_WORKSPACE_SETTINGS);
|
||||
|
||||
const profiletHrefToLabelMap = hrefToLabelMap(GROUPED_PROFILE_SETTINGS);
|
||||
|
||||
const projectHrefToLabelMap = PROJECT_SETTINGS_LINKS.reduce(
|
||||
(acc, setting) => {
|
||||
acc[setting.href] = setting.i18n_label;
|
||||
@@ -39,14 +37,6 @@ export const getWorkspaceActivePath = (pathname: string) => {
|
||||
return workspaceHrefToLabelMap[subPath];
|
||||
};
|
||||
|
||||
export const getProfileActivePath = (pathname: string) => {
|
||||
const parts = pathname.split("/").filter(Boolean);
|
||||
const settingsIndex = parts.indexOf("settings");
|
||||
if (settingsIndex === -1) return null;
|
||||
const subPath = "/" + parts.slice(settingsIndex, settingsIndex + 3).join("/");
|
||||
return profiletHrefToLabelMap[subPath];
|
||||
};
|
||||
|
||||
export const getProjectActivePath = (pathname: string) => {
|
||||
const parts = pathname.split("/").filter(Boolean);
|
||||
const settingsIndex = parts.indexOf("settings");
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./root";
|
||||
@@ -0,0 +1,188 @@
|
||||
import { useEffect } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import Link from "next/link";
|
||||
import useSWR from "swr";
|
||||
// icons
|
||||
import { History, MessageSquare } from "lucide-react";
|
||||
import { calculateTimeAgo, getFileURL } from "@plane/utils";
|
||||
// hooks
|
||||
import { ActivityIcon, ActivityMessage } from "@/components/core/activity";
|
||||
import { RichTextEditor } from "@/components/editor/rich-text";
|
||||
import { ActivitySettingsLoader } from "@/components/ui/loader/settings/activity";
|
||||
// constants
|
||||
import { USER_ACTIVITY } from "@/constants/fetch-keys";
|
||||
// hooks
|
||||
import { useUserProfile } from "@/hooks/store/user/user-user-profile";
|
||||
// services
|
||||
import { UserService } from "@/services/user.service";
|
||||
const userService = new UserService();
|
||||
|
||||
type Props = {
|
||||
cursor: string;
|
||||
perPage: number;
|
||||
updateResultsCount: (count: number) => void;
|
||||
updateTotalPages: (count: number) => void;
|
||||
updateEmptyState: (state: boolean) => void;
|
||||
};
|
||||
|
||||
export const ActivityProfileSettingsList = observer(function ProfileActivityListPage(props: Props) {
|
||||
const { cursor, perPage, updateResultsCount, updateTotalPages, updateEmptyState } = props;
|
||||
// store hooks
|
||||
const { data: currentUser } = useUserProfile();
|
||||
|
||||
const { data: userProfileActivity } = useSWR(
|
||||
USER_ACTIVITY({
|
||||
cursor,
|
||||
}),
|
||||
() =>
|
||||
userService.getUserActivity({
|
||||
cursor,
|
||||
per_page: perPage,
|
||||
})
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!userProfileActivity) return;
|
||||
|
||||
// if no results found then show empty state
|
||||
if (userProfileActivity.total_results === 0) updateEmptyState(true);
|
||||
|
||||
updateTotalPages(userProfileActivity.total_pages);
|
||||
updateResultsCount(userProfileActivity.results.length);
|
||||
}, [updateResultsCount, updateTotalPages, userProfileActivity, updateEmptyState]);
|
||||
|
||||
// TODO: refactor this component
|
||||
return (
|
||||
<>
|
||||
{userProfileActivity ? (
|
||||
<ul>
|
||||
{userProfileActivity.results.map((activityItem: any) => {
|
||||
if (activityItem.field === "comment")
|
||||
return (
|
||||
<div key={activityItem.id} className="mt-2">
|
||||
<div className="relative flex items-start space-x-3">
|
||||
<div className="relative px-1">
|
||||
{activityItem.field ? (
|
||||
activityItem.new_value === "restore" && <History className="h-3.5 w-3.5 text-secondary" />
|
||||
) : activityItem.actor_detail.avatar_url && activityItem.actor_detail.avatar_url !== "" ? (
|
||||
<img
|
||||
src={getFileURL(activityItem.actor_detail.avatar_url)}
|
||||
alt={activityItem.actor_detail.display_name}
|
||||
height={30}
|
||||
width={30}
|
||||
className="grid h-7 w-7 place-items-center rounded-full border-2 border-subtle-1 bg-layer-3"
|
||||
/>
|
||||
) : (
|
||||
<div className="grid h-7 w-7 place-items-center rounded-full border-2 border-subtle-1 bg-layer-3 capitalize">
|
||||
{activityItem.actor_detail.display_name?.[0]}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<span className="flex h-6 w-6 p-2 items-center justify-center rounded-full bg-layer-3 text-secondary">
|
||||
<MessageSquare className="!text-20 text-secondary" aria-hidden="true" />
|
||||
</span>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div>
|
||||
<div className="text-11">
|
||||
{activityItem.actor_detail.is_bot
|
||||
? activityItem.actor_detail.first_name + " Bot"
|
||||
: activityItem.actor_detail.display_name}
|
||||
</div>
|
||||
<p className="mt-0.5 text-11 text-secondary">
|
||||
Commented {calculateTimeAgo(activityItem.created_at)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="issue-comments-section p-0">
|
||||
<RichTextEditor
|
||||
editable={false}
|
||||
id={activityItem.id}
|
||||
initialValue={
|
||||
activityItem?.new_value !== "" ? activityItem.new_value : activityItem.old_value
|
||||
}
|
||||
containerClassName="text-11 bg-surface-1"
|
||||
workspaceId={activityItem?.workspace_detail?.id?.toString() ?? ""}
|
||||
workspaceSlug={activityItem?.workspace_detail?.slug?.toString() ?? ""}
|
||||
projectId={activityItem.project ?? ""}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const message = <ActivityMessage activity={activityItem} showIssue />;
|
||||
|
||||
if ("field" in activityItem && activityItem.field !== "updated_by")
|
||||
return (
|
||||
<li key={activityItem.id}>
|
||||
<div className="relative pb-1">
|
||||
<div className="relative flex items-start space-x-2">
|
||||
<>
|
||||
<div>
|
||||
<div className="relative px-1.5 mt-4">
|
||||
<div className="mt-1.5">
|
||||
<div className="flex h-6 w-6 items-center justify-center border border-subtle rounded-lg shadow-raised-100">
|
||||
{activityItem.field ? (
|
||||
activityItem.new_value === "restore" ? (
|
||||
<History className="h-5 w-5 text-secondary" />
|
||||
) : (
|
||||
<ActivityIcon activity={activityItem} />
|
||||
)
|
||||
) : activityItem.actor_detail.avatar_url &&
|
||||
activityItem.actor_detail.avatar_url !== "" ? (
|
||||
<img
|
||||
src={getFileURL(activityItem.actor_detail.avatar_url)}
|
||||
alt={activityItem.actor_detail.display_name}
|
||||
height={24}
|
||||
width={24}
|
||||
className="h-full w-full rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="grid h-6 w-6 place-items-center rounded-full border-2 border-subtle-1 bg-layer-3 text-11 capitalize">
|
||||
{activityItem.actor_detail.display_name?.[0]}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 border-b border-subtle py-4">
|
||||
<div className="break-words text-caption-md-regular text-secondary">
|
||||
{activityItem.field === "archived_at" && activityItem.new_value !== "restore" ? (
|
||||
<span className="text-gray font-medium">Plane</span>
|
||||
) : activityItem.actor_detail.is_bot ? (
|
||||
<span className="text-gray font-medium">{activityItem.actor_detail.first_name} Bot</span>
|
||||
) : (
|
||||
<Link
|
||||
href={`/${activityItem.workspace_detail.slug}/profile/${activityItem.actor_detail.id}`}
|
||||
className="inline"
|
||||
>
|
||||
<span className="text-gray font-medium">
|
||||
{currentUser?.id === activityItem.actor_detail.id
|
||||
? "You"
|
||||
: activityItem.actor_detail.display_name}
|
||||
</span>
|
||||
</Link>
|
||||
)}{" "}
|
||||
<div className="inline gap-1">
|
||||
{message}{" "}
|
||||
<span className="flex-shrink-0 whitespace-nowrap">
|
||||
{calculateTimeAgo(activityItem.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
) : (
|
||||
<ActivitySettingsLoader />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./root";
|
||||
+11
-15
@@ -1,23 +1,22 @@
|
||||
import { useState } from "react";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useTheme } from "next-themes";
|
||||
// plane imports
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
// ui
|
||||
import { Button } from "@plane/propel/button";
|
||||
// assets
|
||||
import darkActivityAsset from "@/app/assets/empty-state/profile/activity-dark.webp?url";
|
||||
import lightActivityAsset from "@/app/assets/empty-state/profile/activity-light.webp?url";
|
||||
// components
|
||||
import { PageHead } from "@/components/core/page-title";
|
||||
import { DetailedEmptyState } from "@/components/empty-state/detailed-empty-state-root";
|
||||
import { ProfileActivityListPage } from "@/components/profile/activity/profile-activity-list";
|
||||
// hooks
|
||||
import { SettingsHeading } from "@/components/settings/heading";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
// local imports
|
||||
import { ActivityProfileSettingsList } from "./activity-list";
|
||||
|
||||
const PER_PAGE = 100;
|
||||
|
||||
function ProfileActivityPage() {
|
||||
export const ActivityProfileSettings = observer(function ActivityProfileSettings() {
|
||||
// states
|
||||
const [pageCount, setPageCount] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(0);
|
||||
@@ -41,7 +40,7 @@ function ProfileActivityPage() {
|
||||
const activityPages: React.ReactNode[] = [];
|
||||
for (let i = 0; i < pageCount; i++)
|
||||
activityPages.push(
|
||||
<ProfileActivityListPage
|
||||
<ActivityProfileSettingsList
|
||||
key={i}
|
||||
cursor={`${PER_PAGE}:${i}:0`}
|
||||
perPage={PER_PAGE}
|
||||
@@ -55,7 +54,7 @@ function ProfileActivityPage() {
|
||||
|
||||
if (isEmpty) {
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col">
|
||||
<div className="size-full flex flex-col gap-y-7">
|
||||
<SettingsHeading
|
||||
title={t("account_settings.activity.heading")}
|
||||
description={t("account_settings.activity.description")}
|
||||
@@ -72,13 +71,12 @@ function ProfileActivityPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHead title="Profile - Activity" />
|
||||
<div className="size-full">
|
||||
<SettingsHeading
|
||||
title={t("account_settings.activity.heading")}
|
||||
description={t("account_settings.activity.description")}
|
||||
/>
|
||||
<div className="w-full">{activityPages}</div>
|
||||
<div className="mt-7 w-full">{activityPages}</div>
|
||||
{isLoadMoreVisible && (
|
||||
<div className="flex w-full items-center justify-center mt-4">
|
||||
<Button variant="ghost" onClick={handleLoadMore} appendIcon={<ChevronDown />}>
|
||||
@@ -86,8 +84,6 @@ function ProfileActivityPage() {
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default observer(ProfileActivityPage);
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
import { useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import useSWR from "swr";
|
||||
// plane imports
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { EmptyStateCompact } from "@plane/propel/empty-state";
|
||||
import { APITokenService } from "@plane/services";
|
||||
// components
|
||||
import { CreateApiTokenModal } from "@/components/api-token/modal/create-token-modal";
|
||||
import { ApiTokenListItem } from "@/components/api-token/token-list-item";
|
||||
import { SettingsHeading } from "@/components/settings/heading";
|
||||
import { APITokenSettingsLoader } from "@/components/ui/loader/settings/api-token";
|
||||
// constants
|
||||
import { API_TOKENS_LIST } from "@/constants/fetch-keys";
|
||||
|
||||
const apiTokenService = new APITokenService();
|
||||
|
||||
export const APITokensProfileSettings = observer(function APITokensProfileSettings() {
|
||||
// states
|
||||
const [isCreateTokenModalOpen, setIsCreateTokenModalOpen] = useState(false);
|
||||
// store hooks
|
||||
const { data: tokens } = useSWR(API_TOKENS_LIST, () => apiTokenService.list());
|
||||
// translation
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!tokens) {
|
||||
return <APITokenSettingsLoader />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="size-full">
|
||||
<CreateApiTokenModal isOpen={isCreateTokenModalOpen} onClose={() => setIsCreateTokenModalOpen(false)} />
|
||||
<SettingsHeading
|
||||
title={t("account_settings.api_tokens.heading")}
|
||||
description={t("account_settings.api_tokens.description")}
|
||||
button={{
|
||||
label: t("workspace_settings.settings.api_tokens.add_token"),
|
||||
onClick: () => {
|
||||
setIsCreateTokenModalOpen(true);
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<div className="mt-7">
|
||||
{tokens.length > 0 ? (
|
||||
<>
|
||||
<div>
|
||||
{tokens.map((token) => (
|
||||
<ApiTokenListItem key={token.id} token={token} />
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<EmptyStateCompact
|
||||
assetKey="token"
|
||||
assetClassName="size-20"
|
||||
title={t("settings_empty_state.tokens.title")}
|
||||
description={t("settings_empty_state.tokens.description")}
|
||||
actions={[
|
||||
{
|
||||
label: t("settings_empty_state.tokens.cta_primary"),
|
||||
onClick: () => {
|
||||
setIsCreateTokenModalOpen(true);
|
||||
},
|
||||
},
|
||||
]}
|
||||
align="start"
|
||||
rootClassName="py-20"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,418 @@
|
||||
import { useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { CircleUserRound } from "lucide-react";
|
||||
import { Disclosure, Transition } from "@headlessui/react";
|
||||
// plane imports
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { Button } from "@plane/propel/button";
|
||||
import { ChevronDownIcon } from "@plane/propel/icons";
|
||||
import { TOAST_TYPE, setPromiseToast, setToast } from "@plane/propel/toast";
|
||||
import { EFileAssetType } from "@plane/types";
|
||||
import type { IUser, TUserProfile } from "@plane/types";
|
||||
import { Input } from "@plane/ui";
|
||||
import { getFileURL } from "@plane/utils";
|
||||
// components
|
||||
import { DeactivateAccountModal } from "@/components/account/deactivate-account-modal";
|
||||
import { ImagePickerPopover } from "@/components/core/image-picker-popover";
|
||||
import { ChangeEmailModal } from "@/components/core/modals/change-email-modal";
|
||||
import { UserImageUploadModal } from "@/components/core/modals/user-image-upload-modal";
|
||||
import { CoverImage } from "@/components/common/cover-image";
|
||||
// helpers
|
||||
import { handleCoverImageChange } from "@/helpers/cover-image.helper";
|
||||
// hooks
|
||||
import { useInstance } from "@/hooks/store/use-instance";
|
||||
import { useUser, useUserProfile } from "@/hooks/store/user";
|
||||
|
||||
type TUserProfileForm = {
|
||||
avatar_url: string;
|
||||
cover_image: string;
|
||||
cover_image_asset: any;
|
||||
cover_image_url: string;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
display_name: string;
|
||||
email: string;
|
||||
role: string;
|
||||
language: string;
|
||||
user_timezone: string;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
user: IUser;
|
||||
profile: TUserProfile;
|
||||
};
|
||||
|
||||
export const GeneralProfileSettingsForm = observer(function GeneralProfileSettingsForm(props: Props) {
|
||||
const { user, profile } = props;
|
||||
// states
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isImageUploadModalOpen, setIsImageUploadModalOpen] = useState(false);
|
||||
const [deactivateAccountModal, setDeactivateAccountModal] = useState(false);
|
||||
const [isChangeEmailModalOpen, setIsChangeEmailModalOpen] = useState(false);
|
||||
// language support
|
||||
const { t } = useTranslation();
|
||||
// form info
|
||||
const {
|
||||
handleSubmit,
|
||||
watch,
|
||||
control,
|
||||
setValue,
|
||||
formState: { errors },
|
||||
} = useForm<TUserProfileForm>({
|
||||
defaultValues: {
|
||||
avatar_url: user.avatar_url || "",
|
||||
cover_image_asset: null,
|
||||
cover_image_url: user.cover_image_url || "",
|
||||
first_name: user.first_name || "",
|
||||
last_name: user.last_name || "",
|
||||
display_name: user.display_name || "",
|
||||
email: user.email || "",
|
||||
role: profile.role || "Product / Project Manager",
|
||||
language: profile.language || "en",
|
||||
user_timezone: user.user_timezone || "Asia/Kolkata",
|
||||
},
|
||||
});
|
||||
// derived values
|
||||
const userAvatar = watch("avatar_url");
|
||||
const userCover = watch("cover_image_url");
|
||||
// store hooks
|
||||
const { data: currentUser, updateCurrentUser } = useUser();
|
||||
const { updateUserProfile } = useUserProfile();
|
||||
const { config } = useInstance();
|
||||
|
||||
const isSMTPConfigured = config?.is_smtp_configured || false;
|
||||
|
||||
const handleProfilePictureDelete = async (url: string | null | undefined) => {
|
||||
if (!url) return;
|
||||
await updateCurrentUser({
|
||||
avatar_url: "",
|
||||
})
|
||||
.then(() => {
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: "Success!",
|
||||
message: "Profile picture deleted successfully.",
|
||||
});
|
||||
setValue("avatar_url", "");
|
||||
return;
|
||||
})
|
||||
.catch(() => {
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Error!",
|
||||
message: "There was some error in deleting your profile picture. Please try again.",
|
||||
});
|
||||
})
|
||||
.finally(() => {
|
||||
setIsImageUploadModalOpen(false);
|
||||
});
|
||||
};
|
||||
|
||||
const onSubmit = async (formData: TUserProfileForm) => {
|
||||
setIsLoading(true);
|
||||
const userPayload: Partial<IUser> = {
|
||||
first_name: formData.first_name,
|
||||
last_name: formData.last_name,
|
||||
avatar_url: formData.avatar_url,
|
||||
display_name: formData?.display_name,
|
||||
};
|
||||
|
||||
try {
|
||||
const coverImagePayload = await handleCoverImageChange(user.cover_image_url, formData.cover_image_url, {
|
||||
entityIdentifier: "",
|
||||
entityType: EFileAssetType.USER_COVER,
|
||||
isUserAsset: true,
|
||||
});
|
||||
|
||||
if (coverImagePayload) {
|
||||
Object.assign(userPayload, coverImagePayload);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error handling cover image:", error);
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: t("toast.error"),
|
||||
message: error instanceof Error ? error.message : "Failed to process cover image",
|
||||
});
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const profilePayload: Partial<TUserProfile> = {
|
||||
role: formData.role,
|
||||
};
|
||||
|
||||
const updateCurrentUserDetail = updateCurrentUser(userPayload).finally(() => setIsLoading(false));
|
||||
const updateCurrentUserProfile = updateUserProfile(profilePayload).finally(() => setIsLoading(false));
|
||||
|
||||
const promises = [updateCurrentUserDetail, updateCurrentUserProfile];
|
||||
const updateUserAndProfile = Promise.all(promises);
|
||||
|
||||
setPromiseToast(updateUserAndProfile, {
|
||||
loading: "Updating...",
|
||||
success: {
|
||||
title: "Success!",
|
||||
message: () => `Profile updated successfully.`,
|
||||
},
|
||||
error: {
|
||||
title: "Error!",
|
||||
message: () => `There was some error in updating your profile. Please try again.`,
|
||||
},
|
||||
});
|
||||
updateUserAndProfile
|
||||
.then(() => {
|
||||
return;
|
||||
})
|
||||
.catch(() => {});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<DeactivateAccountModal isOpen={deactivateAccountModal} onClose={() => setDeactivateAccountModal(false)} />
|
||||
<ChangeEmailModal isOpen={isChangeEmailModalOpen} onClose={() => setIsChangeEmailModalOpen(false)} />
|
||||
<Controller
|
||||
control={control}
|
||||
name="avatar_url"
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<UserImageUploadModal
|
||||
isOpen={isImageUploadModalOpen}
|
||||
onClose={() => setIsImageUploadModalOpen(false)}
|
||||
handleRemove={async () => await handleProfilePictureDelete(currentUser?.avatar_url)}
|
||||
onSuccess={(url) => {
|
||||
onChange(url);
|
||||
handleSubmit(onSubmit)();
|
||||
setIsImageUploadModalOpen(false);
|
||||
}}
|
||||
value={value && value.trim() !== "" ? value : null}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="w-full">
|
||||
<div className="flex w-full flex-col gap-6">
|
||||
<div className="relative h-44 w-full">
|
||||
<CoverImage
|
||||
src={userCover}
|
||||
className="h-44 w-full rounded-lg"
|
||||
alt={currentUser?.first_name ?? "Cover image"}
|
||||
/>
|
||||
<div className="absolute -bottom-6 left-6 flex items-end justify-between">
|
||||
<div className="flex gap-3">
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-lg bg-surface-2">
|
||||
<button type="button" onClick={() => setIsImageUploadModalOpen(true)}>
|
||||
{!userAvatar || userAvatar === "" ? (
|
||||
<div className="h-16 w-16 rounded-md bg-layer-1 p-2">
|
||||
<CircleUserRound className="h-full w-full text-secondary" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative h-16 w-16 overflow-hidden">
|
||||
<img
|
||||
src={getFileURL(userAvatar)}
|
||||
className="absolute left-0 top-0 h-full w-full rounded-lg object-cover"
|
||||
onClick={() => setIsImageUploadModalOpen(true)}
|
||||
alt={currentUser?.display_name}
|
||||
role="button"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="absolute bottom-3 right-3 flex">
|
||||
<Controller
|
||||
control={control}
|
||||
name="cover_image_url"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<ImagePickerPopover
|
||||
label={t("change_cover")}
|
||||
control={control}
|
||||
onChange={(imageUrl) => onChange(imageUrl)}
|
||||
value={value}
|
||||
isProfileCover
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="item-center mt-6 flex justify-between">
|
||||
<div className="flex flex-col">
|
||||
<div className="item-center flex text-16 font-medium text-secondary">
|
||||
<span>{`${watch("first_name")} ${watch("last_name")}`}</span>
|
||||
</div>
|
||||
<span className="text-13 text-tertiary tracking-tight">{watch("email")}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-x-6 gap-y-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<h4 className="text-13 font-medium text-secondary">
|
||||
{t("first_name")}
|
||||
<span className="text-danger-primary">*</span>
|
||||
</h4>
|
||||
<Controller
|
||||
control={control}
|
||||
name="first_name"
|
||||
rules={{
|
||||
required: "Please enter first name",
|
||||
}}
|
||||
render={({ field: { value, onChange, ref } }) => (
|
||||
<Input
|
||||
id="first_name"
|
||||
name="first_name"
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
ref={ref}
|
||||
hasError={Boolean(errors.first_name)}
|
||||
placeholder="Enter your first name"
|
||||
className={`w-full rounded-md ${errors.first_name ? "border-danger-strong" : ""}`}
|
||||
maxLength={24}
|
||||
autoComplete="on"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{errors.first_name && <span className="text-11 text-danger-primary">{errors.first_name.message}</span>}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<h4 className="text-13 font-medium text-secondary">{t("last_name")}</h4>
|
||||
<Controller
|
||||
control={control}
|
||||
name="last_name"
|
||||
render={({ field: { value, onChange, ref } }) => (
|
||||
<Input
|
||||
id="last_name"
|
||||
name="last_name"
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
ref={ref}
|
||||
hasError={Boolean(errors.last_name)}
|
||||
placeholder="Enter your last name"
|
||||
className="w-full rounded-md"
|
||||
maxLength={24}
|
||||
autoComplete="on"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<h4 className="text-13 font-medium text-secondary">
|
||||
{t("display_name")}
|
||||
<span className="text-danger-primary">*</span>
|
||||
</h4>
|
||||
<Controller
|
||||
control={control}
|
||||
name="display_name"
|
||||
rules={{
|
||||
required: "Display name is required.",
|
||||
validate: (value) => {
|
||||
if (value.trim().length < 1) return "Display name can't be empty.";
|
||||
if (value.split(" ").length > 1) return "Display name can't have two consecutive spaces.";
|
||||
if (value.replace(/\s/g, "").length < 1) return "Display name must be at least 1 character long.";
|
||||
if (value.replace(/\s/g, "").length > 20)
|
||||
return "Display name must be less than 20 characters long.";
|
||||
return true;
|
||||
},
|
||||
}}
|
||||
render={({ field: { value, onChange, ref } }) => (
|
||||
<Input
|
||||
id="display_name"
|
||||
name="display_name"
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
ref={ref}
|
||||
hasError={Boolean(errors?.display_name)}
|
||||
placeholder="Enter your display name"
|
||||
className={`w-full ${errors?.display_name ? "border-danger-strong" : ""}`}
|
||||
maxLength={24}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{errors?.display_name && (
|
||||
<span className="text-11 text-danger-primary">{errors?.display_name?.message}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<h4 className="text-13 font-medium text-secondary">
|
||||
{t("auth.common.email.label")}
|
||||
<span className="text-danger-primary">*</span>
|
||||
</h4>
|
||||
<Controller
|
||||
control={control}
|
||||
name="email"
|
||||
rules={{
|
||||
required: "Email is required.",
|
||||
}}
|
||||
render={({ field: { value, ref } }) => (
|
||||
<Input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
value={value}
|
||||
ref={ref}
|
||||
hasError={Boolean(errors.email)}
|
||||
placeholder="Enter your email"
|
||||
className={`w-full cursor-not-allowed rounded-md !bg-surface-2 ${
|
||||
errors.email ? "border-danger-strong" : ""
|
||||
}`}
|
||||
autoComplete="on"
|
||||
disabled
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{isSMTPConfigured && (
|
||||
<button
|
||||
type="button"
|
||||
className="text-11 underline btn w-fit text-secondary"
|
||||
onClick={() => setIsChangeEmailModalOpen(true)}
|
||||
>
|
||||
{t("account_settings.profile.change_email_modal.title")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center justify-between pt-6 pb-8">
|
||||
<Button variant="primary" type="submit" loading={isLoading}>
|
||||
{isLoading ? t("saving") : t("save_changes")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
<Disclosure as="div" className="border-t border-subtle w-full">
|
||||
{({ open }) => (
|
||||
<>
|
||||
<Disclosure.Button as="button" type="button" className="flex w-full items-center justify-between py-4">
|
||||
<span className="text-16 font-medium tracking-tight">{t("deactivate_account")}</span>
|
||||
<ChevronDownIcon className={`h-5 w-5 transition-all ${open ? "rotate-180" : ""}`} />
|
||||
</Disclosure.Button>
|
||||
<Transition
|
||||
show={open}
|
||||
enter="transition duration-100 ease-out"
|
||||
enterFrom="transform opacity-0"
|
||||
enterTo="transform opacity-100"
|
||||
leave="transition duration-75 ease-out"
|
||||
leaveFrom="transform opacity-100"
|
||||
leaveTo="transform opacity-0"
|
||||
>
|
||||
<Disclosure.Panel>
|
||||
<div className="flex flex-col gap-8">
|
||||
<span className="text-13 tracking-tight">{t("deactivate_account_description")}</span>
|
||||
<div>
|
||||
<Button variant="error-fill" onClick={() => setDeactivateAccountModal(true)}>
|
||||
{t("deactivate_account")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Disclosure.Panel>
|
||||
</Transition>
|
||||
</>
|
||||
)}
|
||||
</Disclosure>
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./root";
|
||||
+7
-7
@@ -2,22 +2,22 @@ import { observer } from "mobx-react";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
// components
|
||||
import { PageHead } from "@/components/core/page-title";
|
||||
import { ProfileForm } from "@/components/profile/form";
|
||||
// hooks
|
||||
import { useUser } from "@/hooks/store/user";
|
||||
// local imports
|
||||
import { GeneralProfileSettingsForm } from "./form";
|
||||
|
||||
function ProfileSettingsPage() {
|
||||
export const GeneralProfileSettings = observer(function GeneralProfileSettings() {
|
||||
const { t } = useTranslation();
|
||||
// store hooks
|
||||
const { data: currentUser, userProfile } = useUser();
|
||||
|
||||
if (!currentUser) return <></>;
|
||||
if (!currentUser) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHead title={`${t("profile.label")} - ${t("general_settings")}`} />
|
||||
<ProfileForm user={currentUser} profile={userProfile.data} />
|
||||
<GeneralProfileSettingsForm user={currentUser} profile={userProfile.data} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default observer(ProfileSettingsPage);
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
import { lazy } from "react";
|
||||
// plane imports
|
||||
import type { TProfileSettingsTabs } from "@plane/types";
|
||||
|
||||
export const PROFILE_SETTINGS_PAGES_MAP: Record<TProfileSettingsTabs, React.LazyExoticComponent<React.FC>> = {
|
||||
general: lazy(() => import("./general").then((m) => ({ default: m.GeneralProfileSettings }))),
|
||||
preferences: lazy(() => import("./preferences").then((m) => ({ default: m.PreferencesProfileSettings }))),
|
||||
notifications: lazy(() => import("./notifications").then((m) => ({ default: m.NotificationsProfileSettings }))),
|
||||
security: lazy(() => import("./security").then((m) => ({ default: m.SecurityProfileSettings }))),
|
||||
activity: lazy(() => import("./activity").then((m) => ({ default: m.ActivityProfileSettings }))),
|
||||
"api-tokens": lazy(() => import("./api-tokens").then((m) => ({ default: m.APITokensProfileSettings }))),
|
||||
};
|
||||
+161
@@ -0,0 +1,161 @@
|
||||
import { useEffect } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
// plane imports
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
||||
import type { IUserEmailNotificationSettings } from "@plane/types";
|
||||
import { ToggleSwitch } from "@plane/ui";
|
||||
// components
|
||||
import { SettingsControlItem } from "@/components/settings/control-item";
|
||||
// services
|
||||
import { UserService } from "@/services/user.service";
|
||||
|
||||
type Props = {
|
||||
data: IUserEmailNotificationSettings;
|
||||
};
|
||||
|
||||
// services
|
||||
const userService = new UserService();
|
||||
|
||||
export const NotificationsProfileSettingsForm = observer(function NotificationsProfileSettingsForm(props: Props) {
|
||||
const { data } = props;
|
||||
// translation
|
||||
const { t } = useTranslation();
|
||||
// form data
|
||||
const { control, reset } = useForm<IUserEmailNotificationSettings>({
|
||||
defaultValues: {
|
||||
...data,
|
||||
},
|
||||
});
|
||||
|
||||
const handleSettingChange = async (key: keyof IUserEmailNotificationSettings, value: boolean) => {
|
||||
try {
|
||||
await userService.updateCurrentUserEmailNotificationSettings({
|
||||
[key]: value,
|
||||
});
|
||||
setToast({
|
||||
title: t("success"),
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
message: t("email_notification_setting_updated_successfully"),
|
||||
});
|
||||
} catch (_error) {
|
||||
setToast({
|
||||
title: t("error"),
|
||||
type: TOAST_TYPE.ERROR,
|
||||
message: t("failed_to_update_email_notification_setting"),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
reset(data);
|
||||
}, [reset, data]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-y-1">
|
||||
<SettingsControlItem
|
||||
title={t("property_changes")}
|
||||
description={t("property_changes_description")}
|
||||
control={
|
||||
<Controller
|
||||
control={control}
|
||||
name="property_change"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<ToggleSwitch
|
||||
value={value}
|
||||
onChange={(newValue) => {
|
||||
onChange(newValue);
|
||||
handleSettingChange("property_change", newValue);
|
||||
}}
|
||||
size="sm"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<SettingsControlItem
|
||||
title={t("state_change")}
|
||||
description={t("state_change_description")}
|
||||
control={
|
||||
<Controller
|
||||
control={control}
|
||||
name="state_change"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<ToggleSwitch
|
||||
value={value}
|
||||
onChange={(newValue) => {
|
||||
onChange(newValue);
|
||||
handleSettingChange("state_change", newValue);
|
||||
}}
|
||||
size="sm"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<div className="border-l-3 border-subtle-1 pl-3">
|
||||
<SettingsControlItem
|
||||
title={t("issue_completed")}
|
||||
description={t("issue_completed_description")}
|
||||
control={
|
||||
<Controller
|
||||
control={control}
|
||||
name="issue_completed"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<ToggleSwitch
|
||||
value={value}
|
||||
onChange={(newValue) => {
|
||||
onChange(newValue);
|
||||
handleSettingChange("issue_completed", newValue);
|
||||
}}
|
||||
size="sm"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<SettingsControlItem
|
||||
title={t("comments")}
|
||||
description={t("comments_description")}
|
||||
control={
|
||||
<Controller
|
||||
control={control}
|
||||
name="comment"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<ToggleSwitch
|
||||
value={value}
|
||||
onChange={(newValue) => {
|
||||
onChange(newValue);
|
||||
handleSettingChange("comment", newValue);
|
||||
}}
|
||||
size="sm"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<SettingsControlItem
|
||||
title={t("mentions")}
|
||||
description={t("mentions_description")}
|
||||
control={
|
||||
<Controller
|
||||
control={control}
|
||||
name="mention"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<ToggleSwitch
|
||||
value={value}
|
||||
onChange={(newValue) => {
|
||||
onChange(newValue);
|
||||
handleSettingChange("mention", newValue);
|
||||
}}
|
||||
size="sm"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./root";
|
||||
+10
-9
@@ -1,17 +1,18 @@
|
||||
import useSWR from "swr";
|
||||
import { observer } from "mobx-react";
|
||||
// plane imports
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
// components
|
||||
import { PageHead } from "@/components/core/page-title";
|
||||
import { EmailNotificationForm } from "@/components/profile/notification/email-notification-form";
|
||||
import { SettingsHeading } from "@/components/settings/heading";
|
||||
import { EmailSettingsLoader } from "@/components/ui/loader/settings/email";
|
||||
// services
|
||||
import { UserService } from "@/services/user.service";
|
||||
// local imports
|
||||
import { NotificationsProfileSettingsForm } from "./email-notification-form";
|
||||
|
||||
const userService = new UserService();
|
||||
|
||||
export default function ProfileNotificationPage() {
|
||||
export const NotificationsProfileSettings = observer(function NotificationsProfileSettings() {
|
||||
const { t } = useTranslation();
|
||||
// fetching user email notification settings
|
||||
const { data, isLoading } = useSWR("CURRENT_USER_EMAIL_NOTIFICATION_SETTINGS", () =>
|
||||
@@ -23,14 +24,14 @@ export default function ProfileNotificationPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHead title={`${t("profile.label")} - ${t("notifications")}`} />
|
||||
|
||||
<div className="size-full">
|
||||
<SettingsHeading
|
||||
title={t("account_settings.notifications.heading")}
|
||||
description={t("account_settings.notifications.description")}
|
||||
/>
|
||||
<EmailNotificationForm data={data} />
|
||||
</>
|
||||
<div className="mt-7">
|
||||
<NotificationsProfileSettingsForm data={data} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
import { observer } from "mobx-react";
|
||||
// components
|
||||
import { ThemeSwitcher } from "ce/components/preferences/theme-switcher";
|
||||
|
||||
export const ProfileSettingsDefaultPreferencesList = observer(function ProfileSettingsDefaultPreferencesList() {
|
||||
return (
|
||||
<div className="flex flex-col gap-y-1">
|
||||
<ThemeSwitcher
|
||||
option={{
|
||||
id: "theme",
|
||||
title: "theme",
|
||||
description: "select_or_customize_your_interface_color_scheme",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./root";
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
import { observer } from "mobx-react";
|
||||
// plane imports
|
||||
import { SUPPORTED_LANGUAGES, useTranslation } from "@plane/i18n";
|
||||
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
||||
import { CustomSelect } from "@plane/ui";
|
||||
// components
|
||||
import { TimezoneSelect } from "@/components/global";
|
||||
import { StartOfWeekPreference } from "@/components/profile/start-of-week-preference";
|
||||
import { SettingsControlItem } from "@/components/settings/control-item";
|
||||
// hooks
|
||||
import { useUser, useUserProfile } from "@/hooks/store/user";
|
||||
|
||||
export const ProfileSettingsLanguageAndTimezonePreferencesList = observer(
|
||||
function ProfileSettingsLanguageAndTimezonePreferencesList() {
|
||||
// store hooks
|
||||
const {
|
||||
data: user,
|
||||
updateCurrentUser,
|
||||
userProfile: { data: profile },
|
||||
} = useUser();
|
||||
const { updateUserProfile } = useUserProfile();
|
||||
// translation
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleTimezoneChange = async (value: string) => {
|
||||
try {
|
||||
await updateCurrentUser({ user_timezone: value });
|
||||
setToast({
|
||||
title: "Success!",
|
||||
message: "Timezone updated successfully",
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
});
|
||||
} catch (_error) {
|
||||
setToast({
|
||||
title: "Error!",
|
||||
message: "Failed to update timezone",
|
||||
type: TOAST_TYPE.ERROR,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleLanguageChange = async (value: string) => {
|
||||
try {
|
||||
await updateUserProfile({ language: value });
|
||||
setToast({
|
||||
title: "Success!",
|
||||
message: "Language updated successfully",
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
});
|
||||
} catch (_error) {
|
||||
setToast({
|
||||
title: "Error!",
|
||||
message: "Failed to update language",
|
||||
type: TOAST_TYPE.ERROR,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const getLanguageLabel = (value: string) => {
|
||||
const selectedLanguage = SUPPORTED_LANGUAGES.find((l) => l.value === value);
|
||||
if (!selectedLanguage) return value;
|
||||
return selectedLanguage.label;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-y-1">
|
||||
<SettingsControlItem
|
||||
title={t("timezone")}
|
||||
description={t("timezone_setting")}
|
||||
control={<TimezoneSelect value={user?.user_timezone || "Asia/Kolkata"} onChange={handleTimezoneChange} />}
|
||||
/>
|
||||
<SettingsControlItem
|
||||
title={t("language")}
|
||||
description={t("language_setting")}
|
||||
control={
|
||||
<CustomSelect
|
||||
value={profile?.language}
|
||||
label={profile?.language ? getLanguageLabel(profile?.language) : "Select a language"}
|
||||
onChange={handleLanguageChange}
|
||||
buttonClassName="border border-subtle-1"
|
||||
className="rounded-md"
|
||||
input
|
||||
placement="bottom-end"
|
||||
>
|
||||
{SUPPORTED_LANGUAGES.map((item) => (
|
||||
<CustomSelect.Option key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</CustomSelect.Option>
|
||||
))}
|
||||
</CustomSelect>
|
||||
}
|
||||
/>
|
||||
<StartOfWeekPreference
|
||||
option={{
|
||||
title: "First day of the week",
|
||||
description: "This will change how all calendars in your app look.",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,36 @@
|
||||
import { observer } from "mobx-react";
|
||||
// plane imports
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
// components
|
||||
import { SettingsHeading } from "@/components/settings/heading";
|
||||
// hooks
|
||||
import { useUserProfile } from "@/hooks/store/user";
|
||||
// local imports
|
||||
import { ProfileSettingsDefaultPreferencesList } from "./default-list";
|
||||
import { ProfileSettingsLanguageAndTimezonePreferencesList } from "./language-and-timezone-list";
|
||||
|
||||
export const PreferencesProfileSettings = observer(function PreferencesProfileSettings() {
|
||||
const { t } = useTranslation();
|
||||
// hooks
|
||||
const { data: userProfile } = useUserProfile();
|
||||
|
||||
if (!userProfile) return null;
|
||||
|
||||
return (
|
||||
<div className="size-full">
|
||||
<SettingsHeading
|
||||
title={t("account_settings.preferences.heading")}
|
||||
description={t("account_settings.preferences.description")}
|
||||
/>
|
||||
<div className="mt-7 flex flex-col gap-6 w-full">
|
||||
<section>
|
||||
<ProfileSettingsDefaultPreferencesList />
|
||||
</section>
|
||||
<section className="flex flex-col gap-y-3">
|
||||
<div className="text-h6-medium text-primary">{t("language_and_time")}</div>
|
||||
<ProfileSettingsLanguageAndTimezonePreferencesList />
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
+57
-60
@@ -8,11 +8,9 @@ import { useTranslation } from "@plane/i18n";
|
||||
import { Button } from "@plane/propel/button";
|
||||
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
||||
import { Input, PasswordStrengthIndicator } from "@plane/ui";
|
||||
// components
|
||||
import { getPasswordStrength } from "@plane/utils";
|
||||
import { PageHead } from "@/components/core/page-title";
|
||||
import { ProfileSettingContentHeader } from "@/components/profile/profile-setting-content-header";
|
||||
import { ProfileSettingContentWrapper } from "@/components/profile/profile-setting-content-wrapper";
|
||||
// components
|
||||
import { SettingsHeading } from "@/components/settings/heading";
|
||||
// helpers
|
||||
import { authErrorHandler } from "@/helpers/authentication.helper";
|
||||
import type { EAuthenticationErrorCodes } from "@/helpers/authentication.helper";
|
||||
@@ -41,7 +39,7 @@ const defaultShowPassword = {
|
||||
confirmPassword: false,
|
||||
};
|
||||
|
||||
function SecurityPage() {
|
||||
export const SecurityProfileSettings = observer(function SecurityProfileSettings() {
|
||||
// store
|
||||
const { data: currentUser, changePassword } = useUser();
|
||||
// states
|
||||
@@ -89,9 +87,12 @@ function SecurityPage() {
|
||||
message: t("auth.common.password.toast.change_password.success.message"),
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
const err = error as Error & { error_code?: string };
|
||||
const code = err.error_code?.toString();
|
||||
const errorInfo = code ? authErrorHandler(code as EAuthenticationErrorCodes) : undefined;
|
||||
let errorInfo = undefined;
|
||||
if (error instanceof Error) {
|
||||
const code = "error_code" in error ? error.error_code?.toString() : undefined;
|
||||
errorInfo = code ? authErrorHandler(code as EAuthenticationErrorCodes) : undefined;
|
||||
}
|
||||
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: errorInfo?.title ?? t("auth.common.password.toast.error.title"),
|
||||
@@ -117,52 +118,51 @@ function SecurityPage() {
|
||||
const renderPasswordMatchError = !isRetryPasswordInputFocused || confirmPassword.length >= password.length;
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHead title="Profile - Security" />
|
||||
<ProfileSettingContentWrapper>
|
||||
<ProfileSettingContentHeader title={t("auth.common.password.change_password.label.default")} />
|
||||
<form onSubmit={handleSubmit(handleChangePassword)} className="flex flex-col gap-8 py-6">
|
||||
<div className="flex flex-col gap-10 w-full max-w-96">
|
||||
{oldPasswordRequired && (
|
||||
<div className="space-y-1">
|
||||
<h4 className="text-13">{t("auth.common.password.current_password.label")}</h4>
|
||||
<div className="relative flex items-center rounded-md">
|
||||
<Controller
|
||||
control={control}
|
||||
name="old_password"
|
||||
rules={{
|
||||
required: t("common.errors.required"),
|
||||
}}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<Input
|
||||
id="old_password"
|
||||
type={showPassword?.oldPassword ? "text" : "password"}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
placeholder={t("old_password")}
|
||||
className="w-full"
|
||||
hasError={Boolean(errors.old_password)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{showPassword?.oldPassword ? (
|
||||
<EyeOff
|
||||
className="absolute right-3 h-5 w-5 stroke-placeholder hover:cursor-pointer"
|
||||
onClick={() => handleShowPassword("oldPassword")}
|
||||
/>
|
||||
) : (
|
||||
<Eye
|
||||
className="absolute right-3 h-5 w-5 stroke-placeholder hover:cursor-pointer"
|
||||
onClick={() => handleShowPassword("oldPassword")}
|
||||
<div className="size-full">
|
||||
<SettingsHeading title={t("auth.common.password.change_password.label.default")} />
|
||||
<form onSubmit={handleSubmit(handleChangePassword)} className="mt-7 flex flex-col gap-8">
|
||||
<div className="flex flex-col gap-y-7">
|
||||
{oldPasswordRequired && (
|
||||
<div className="flex flex-col gap-y-2">
|
||||
<h4 className="text-13">{t("auth.common.password.current_password.label")}</h4>
|
||||
<div className="relative flex items-center rounded-md">
|
||||
<Controller
|
||||
control={control}
|
||||
name="old_password"
|
||||
rules={{
|
||||
required: t("common.errors.required"),
|
||||
}}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<Input
|
||||
id="old_password"
|
||||
type={showPassword?.oldPassword ? "text" : "password"}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
placeholder={t("old_password")}
|
||||
className="w-full"
|
||||
hasError={Boolean(errors.old_password)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{errors.old_password && (
|
||||
<span className="text-11 text-danger-primary">{errors.old_password.message}</span>
|
||||
/>
|
||||
{showPassword?.oldPassword ? (
|
||||
<EyeOff
|
||||
className="absolute right-3 h-5 w-5 stroke-placeholder hover:cursor-pointer"
|
||||
onClick={() => handleShowPassword("oldPassword")}
|
||||
/>
|
||||
) : (
|
||||
<Eye
|
||||
className="absolute right-3 h-5 w-5 stroke-placeholder hover:cursor-pointer"
|
||||
onClick={() => handleShowPassword("oldPassword")}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-1">
|
||||
{errors.old_password && (
|
||||
<span className="text-11 text-danger-primary">{errors.old_password.message}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="grid sm:grid-cols-2 gap-y-7 gap-x-4">
|
||||
<div className="flex flex-col gap-y-2">
|
||||
<h4 className="text-13">{t("auth.common.password.new_password.label")}</h4>
|
||||
<div className="relative flex items-center rounded-md">
|
||||
<Controller
|
||||
@@ -204,7 +204,7 @@ function SecurityPage() {
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="flex flex-col gap-y-2">
|
||||
<h4 className="text-13">{t("auth.common.password.confirm_password.label")}</h4>
|
||||
<div className="relative flex items-center rounded-md">
|
||||
<Controller
|
||||
@@ -244,18 +244,15 @@ function SecurityPage() {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between py-2">
|
||||
<Button variant="primary" type="submit" loading={isSubmitting} disabled={isButtonDisabled}>
|
||||
<div>
|
||||
<Button variant="primary" size="xl" type="submit" loading={isSubmitting} disabled={isButtonDisabled}>
|
||||
{isSubmitting
|
||||
? `${t("auth.common.password.change_password.label.submitting")}`
|
||||
: t("auth.common.password.change_password.label.default")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</ProfileSettingContentWrapper>
|
||||
</>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default observer(SecurityPage);
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Suspense } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// plane imports
|
||||
import { ScrollArea } from "@plane/propel/scrollarea";
|
||||
import type { TProfileSettingsTabs } from "@plane/types";
|
||||
import { cn } from "@plane/utils";
|
||||
// local imports
|
||||
import { PROFILE_SETTINGS_PAGES_MAP } from "./pages";
|
||||
|
||||
type Props = {
|
||||
activeTab: TProfileSettingsTabs;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export const ProfileSettingsContent = observer(function ProfileSettingsContent(props: Props) {
|
||||
const { activeTab, className } = props;
|
||||
const PageComponent = PROFILE_SETTINGS_PAGES_MAP[activeTab];
|
||||
|
||||
return (
|
||||
<ScrollArea
|
||||
rootClassName={cn("shrink-0 bg-surface-1 overflow-y-scroll", className)}
|
||||
scrollType="hover"
|
||||
orientation="vertical"
|
||||
size="sm"
|
||||
>
|
||||
<Suspense>
|
||||
<PageComponent />
|
||||
</Suspense>
|
||||
</ScrollArea>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import { useCallback } from "react";
|
||||
import { X } from "lucide-react";
|
||||
import { observer } from "mobx-react";
|
||||
// plane imports
|
||||
import { IconButton } from "@plane/propel/icon-button";
|
||||
import { EModalPosition, EModalWidth, ModalCore } from "@plane/ui";
|
||||
// hooks
|
||||
import { useCommandPalette } from "@/hooks/store/use-command-palette";
|
||||
// local imports
|
||||
import { ProfileSettingsContent } from "./content";
|
||||
import { ProfileSettingsSidebarRoot } from "./sidebar";
|
||||
|
||||
export const ProfileSettingsModal = observer(function ProfileSettingsModal() {
|
||||
// store hooks
|
||||
const { profileSettingsModal, toggleProfileSettingsModal } = useCommandPalette();
|
||||
// derived values
|
||||
const activeTab = profileSettingsModal.activeTab ?? "general";
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
toggleProfileSettingsModal({
|
||||
isOpen: false,
|
||||
});
|
||||
setTimeout(() => {
|
||||
toggleProfileSettingsModal({
|
||||
activeTab: null,
|
||||
});
|
||||
}, 300);
|
||||
}, [toggleProfileSettingsModal]);
|
||||
|
||||
return (
|
||||
<ModalCore
|
||||
isOpen={profileSettingsModal.isOpen}
|
||||
handleClose={handleClose}
|
||||
position={EModalPosition.CENTER}
|
||||
width={EModalWidth.VIXL}
|
||||
className="h-175"
|
||||
>
|
||||
<div className="@container relative size-full">
|
||||
<div className="flex size-full">
|
||||
<ProfileSettingsSidebarRoot
|
||||
activeTab={activeTab}
|
||||
className="w-[250px] rounded-l-xl"
|
||||
updateActiveTab={(tab) => toggleProfileSettingsModal({ activeTab: tab })}
|
||||
/>
|
||||
<ProfileSettingsContent activeTab={activeTab} className="grow px-8 py-9 w-fit rounded-r-xl" />
|
||||
</div>
|
||||
<div className="absolute top-3.5 right-3.5">
|
||||
<IconButton size="base" variant="tertiary" icon={X} onClick={handleClose} />
|
||||
</div>
|
||||
</div>
|
||||
</ModalCore>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import { observer } from "mobx-react";
|
||||
// plane imports
|
||||
import { Avatar } from "@plane/ui";
|
||||
// hooks
|
||||
import { useUser } from "@/hooks/store/user";
|
||||
import { getFileURL } from "@plane/utils";
|
||||
|
||||
export const ProfileSettingsSidebarHeader = observer(function ProfileSettingsSidebarHeader() {
|
||||
// store hooks
|
||||
const { data: currentUser } = useUser();
|
||||
|
||||
return (
|
||||
<div className="shrink-0 flex items-center gap-2">
|
||||
<div className="shrink-0">
|
||||
<Avatar
|
||||
src={getFileURL(currentUser?.avatar_url ?? "")}
|
||||
name={currentUser?.display_name}
|
||||
size={32}
|
||||
shape="circle"
|
||||
className="text-16"
|
||||
/>
|
||||
</div>
|
||||
<div className="truncate">
|
||||
<p className="text-body-sm-medium truncate">
|
||||
{currentUser?.first_name} {currentUser?.last_name}
|
||||
</p>
|
||||
<p className="text-caption-md-regular truncate">{currentUser?.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./root";
|
||||
@@ -0,0 +1,66 @@
|
||||
import type React from "react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { Activity, Bell, CircleUser, KeyRound, LockIcon, Settings2 } from "lucide-react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "react-router";
|
||||
// plane imports
|
||||
import { GROUPED_PROFILE_SETTINGS, PROFILE_SETTINGS_CATEGORIES } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import type { ISvgIcons } from "@plane/propel/icons";
|
||||
import type { TProfileSettingsTabs } from "@plane/types";
|
||||
// local imports
|
||||
import { SettingsSidebarItem } from "../../sidebar/item";
|
||||
import { ProfileSettingsSidebarWorkspaceOptions } from "./workspace-options";
|
||||
|
||||
const ICONS: Record<TProfileSettingsTabs, LucideIcon | React.FC<ISvgIcons>> = {
|
||||
general: CircleUser,
|
||||
security: LockIcon,
|
||||
activity: Activity,
|
||||
preferences: Settings2,
|
||||
notifications: Bell,
|
||||
"api-tokens": KeyRound,
|
||||
};
|
||||
|
||||
type Props = {
|
||||
activeTab: TProfileSettingsTabs;
|
||||
updateActiveTab: (tab: TProfileSettingsTabs) => void;
|
||||
};
|
||||
|
||||
export const ProfileSettingsSidebarItemCategories = observer(function ProfileSettingsSidebarItemCategories(
|
||||
props: Props
|
||||
) {
|
||||
const { activeTab, updateActiveTab } = props;
|
||||
// params
|
||||
const { profileTabId } = useParams();
|
||||
// translation
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="mt-4 flex flex-col gap-y-4">
|
||||
{PROFILE_SETTINGS_CATEGORIES.map((category) => {
|
||||
const categoryItems = GROUPED_PROFILE_SETTINGS[category];
|
||||
|
||||
if (categoryItems.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div key={category} className="shrink-0">
|
||||
<div className="p-2 text-caption-md-medium text-tertiary capitalize">{t(category)}</div>
|
||||
<div className="flex flex-col">
|
||||
{categoryItems.map((item) => (
|
||||
<SettingsSidebarItem
|
||||
key={item.key}
|
||||
as="button"
|
||||
onClick={() => updateActiveTab(item.key)}
|
||||
isActive={activeTab === item.key}
|
||||
icon={ICONS[item.key]}
|
||||
label={t(item.i18n_label)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{profileTabId && <ProfileSettingsSidebarWorkspaceOptions />}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
// plane imports
|
||||
import { ScrollArea } from "@plane/propel/scrollarea";
|
||||
import type { TProfileSettingsTabs } from "@plane/types";
|
||||
import { cn } from "@plane/utils";
|
||||
// local imports
|
||||
import { ProfileSettingsSidebarHeader } from "./header";
|
||||
import { ProfileSettingsSidebarItemCategories } from "./item-categories";
|
||||
|
||||
type Props = {
|
||||
activeTab: TProfileSettingsTabs;
|
||||
className?: string;
|
||||
updateActiveTab: (tab: TProfileSettingsTabs) => void;
|
||||
};
|
||||
|
||||
export function ProfileSettingsSidebarRoot(props: Props) {
|
||||
const { activeTab, className, updateActiveTab } = props;
|
||||
|
||||
return (
|
||||
<ScrollArea
|
||||
scrollType="hover"
|
||||
orientation="vertical"
|
||||
size="sm"
|
||||
rootClassName={cn("shrink-0 py-4 px-3 bg-surface-2 border-r border-r-subtle overflow-y-scroll", className)}
|
||||
>
|
||||
<ProfileSettingsSidebarHeader />
|
||||
<ProfileSettingsSidebarItemCategories activeTab={activeTab} updateActiveTab={updateActiveTab} />
|
||||
</ScrollArea>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { CirclePlus, Mails } from "lucide-react";
|
||||
import { observer } from "mobx-react";
|
||||
// plane imports
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
// components
|
||||
import { SettingsSidebarItem } from "@/components/settings/sidebar/item";
|
||||
import { WorkspaceLogo } from "@/components/workspace/logo";
|
||||
// hooks
|
||||
import { useWorkspace } from "@/hooks/store/use-workspace";
|
||||
|
||||
export const ProfileSettingsSidebarWorkspaceOptions = observer(function ProfileSettingsSidebarWorkspaceOptions() {
|
||||
// store hooks
|
||||
const { workspaces } = useWorkspace();
|
||||
// translation
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="shrink-0">
|
||||
<div className="p-2 text-caption-md-medium text-tertiary capitalize">{t("workspace")}</div>
|
||||
<div className="flex flex-col">
|
||||
{Object.values(workspaces).map((workspace) => (
|
||||
<SettingsSidebarItem
|
||||
key={workspace.id}
|
||||
as="link"
|
||||
href={`/${workspace.slug}/`}
|
||||
iconNode={<WorkspaceLogo logo={workspace.logo_url} name={workspace.name} classNames="shrink-0" />}
|
||||
label={workspace.name}
|
||||
isActive={false}
|
||||
/>
|
||||
))}
|
||||
<div className="mt-1.5">
|
||||
<SettingsSidebarItem
|
||||
as="link"
|
||||
href="/create-workspace/"
|
||||
icon={CirclePlus}
|
||||
label={t("create_workspace")}
|
||||
isActive={false}
|
||||
/>
|
||||
<SettingsSidebarItem
|
||||
as="link"
|
||||
href="/invitations/"
|
||||
icon={Mails}
|
||||
label={t("workspace_invites")}
|
||||
isActive={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
import React from "react";
|
||||
import Link from "next/link";
|
||||
// plane imports
|
||||
import { cn } from "@plane/utils";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import type { ISvgIcons } from "@plane/propel/icons";
|
||||
|
||||
type Props = {
|
||||
isActive: boolean;
|
||||
label: string;
|
||||
} & ({ as: "button"; onClick: () => void } | { as: "link"; href: string }) &
|
||||
(
|
||||
| {
|
||||
icon: LucideIcon | React.FC<ISvgIcons>;
|
||||
}
|
||||
| { iconNode: React.ReactElement }
|
||||
);
|
||||
|
||||
export function SettingsSidebarItem(props: Props) {
|
||||
const { as, isActive, label } = props;
|
||||
// common class
|
||||
const className = cn(
|
||||
"flex items-center gap-2 py-1.5 px-2 rounded-lg text-body-sm-medium text-secondary text-left transition-colors",
|
||||
{
|
||||
"bg-layer-transparent-selected text-primary": isActive,
|
||||
"hover:bg-layer-transparent-hover": !isActive,
|
||||
}
|
||||
);
|
||||
// common content
|
||||
const content = (
|
||||
<>
|
||||
{"icon" in props ? (
|
||||
<span className="shrink-0 size-4 grid place-items-center">{<props.icon className="size-3.5" />}</span>
|
||||
) : (
|
||||
props.iconNode
|
||||
)}
|
||||
<span className="truncate">{label}</span>
|
||||
</>
|
||||
);
|
||||
|
||||
if (as === "button") {
|
||||
return (
|
||||
<button type="button" className={className} onClick={props.onClick}>
|
||||
{content}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Link className={className} href={props.href}>
|
||||
{content}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -5,11 +5,6 @@ import { cn } from "@plane/utils";
|
||||
import { useProject } from "@/hooks/store/use-project";
|
||||
|
||||
const TABS = {
|
||||
account: {
|
||||
key: "account",
|
||||
label: "Account",
|
||||
href: `/settings/account/`,
|
||||
},
|
||||
workspace: {
|
||||
key: "workspace",
|
||||
label: "Workspace",
|
||||
@@ -29,11 +24,7 @@ const SettingsTabs = observer(function SettingsTabs() {
|
||||
// store hooks
|
||||
const { joinedProjectIds } = useProject();
|
||||
|
||||
const currentTab = pathname.includes(TABS.projects.href)
|
||||
? TABS.projects
|
||||
: pathname.includes(TABS.account.href)
|
||||
? TABS.account
|
||||
: TABS.workspace;
|
||||
const currentTab = pathname.includes(TABS.projects.href) ? TABS.projects : TABS.workspace;
|
||||
|
||||
return (
|
||||
<div className="flex w-fit min-w-fit items-center justify-between gap-1.5 rounded-md text-13 p-0.5 bg-layer-2">
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
// icons
|
||||
import { useRouter } from "next/navigation";
|
||||
import { LogOut, Settings, Settings2 } from "lucide-react";
|
||||
// plane imports
|
||||
import { GOD_MODE_URL } from "@plane/constants";
|
||||
@@ -9,33 +8,31 @@ import { useTranslation } from "@plane/i18n";
|
||||
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
||||
import { Avatar, CustomMenu } from "@plane/ui";
|
||||
import { getFileURL } from "@plane/utils";
|
||||
// hooks
|
||||
// components
|
||||
import { CoverImage } from "@/components/common/cover-image";
|
||||
import { AppSidebarItem } from "@/components/sidebar/sidebar-item";
|
||||
// hooks
|
||||
import { useAppTheme } from "@/hooks/store/use-app-theme";
|
||||
import { useCommandPalette } from "@/hooks/store/use-command-palette";
|
||||
import { useUser } from "@/hooks/store/user";
|
||||
|
||||
type Props = {
|
||||
size?: "xs" | "sm" | "md";
|
||||
};
|
||||
|
||||
export const UserMenuRoot = observer(function UserMenuRoot(props: Props) {
|
||||
const { size = "sm" } = props;
|
||||
const { workspaceSlug } = useParams();
|
||||
export const UserMenuRoot = observer(function UserMenuRoot() {
|
||||
// states
|
||||
const [isUserMenuOpen, setIsUserMenuOpen] = useState(false);
|
||||
// router
|
||||
const router = useRouter();
|
||||
// store hooks
|
||||
const { toggleAnySidebarDropdown } = useAppTheme();
|
||||
const { data: currentUser } = useUser();
|
||||
const { signOut } = useUser();
|
||||
const { toggleProfileSettingsModal } = useCommandPalette();
|
||||
// derived values
|
||||
const isUserInstanceAdmin = false;
|
||||
// translation
|
||||
const { t } = useTranslation();
|
||||
// local state
|
||||
const [isUserMenuOpen, setIsUserMenuOpen] = useState(false);
|
||||
|
||||
const handleSignOut = async () => {
|
||||
await signOut().catch(() =>
|
||||
const handleSignOut = () => {
|
||||
signOut().catch(() =>
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: t("sign_out.toast.error.title"),
|
||||
@@ -48,7 +45,7 @@ export const UserMenuRoot = observer(function UserMenuRoot(props: Props) {
|
||||
useEffect(() => {
|
||||
if (isUserMenuOpen) toggleAnySidebarDropdown(true);
|
||||
else toggleAnySidebarDropdown(false);
|
||||
}, [isUserMenuOpen]);
|
||||
}, [isUserMenuOpen, toggleAnySidebarDropdown]);
|
||||
|
||||
return (
|
||||
<CustomMenu
|
||||
@@ -61,7 +58,7 @@ export const UserMenuRoot = observer(function UserMenuRoot(props: Props) {
|
||||
<Avatar
|
||||
name={currentUser?.display_name}
|
||||
src={getFileURL(currentUser?.avatar_url ?? "")}
|
||||
size={size === "xs" ? 20 : size === "sm" ? 24 : 28}
|
||||
size={20}
|
||||
shape="circle"
|
||||
/>
|
||||
),
|
||||
@@ -72,48 +69,75 @@ export const UserMenuRoot = observer(function UserMenuRoot(props: Props) {
|
||||
menuButtonOnClick={() => !isUserMenuOpen && setIsUserMenuOpen(true)}
|
||||
onMenuClose={() => setIsUserMenuOpen(false)}
|
||||
placement="bottom-end"
|
||||
maxHeight="lg"
|
||||
maxHeight="2xl"
|
||||
optionsClassName="w-72 p-3 flex flex-col gap-y-3"
|
||||
closeOnSelect
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="px-2 text-secondary truncate">{currentUser?.email}</span>
|
||||
<CustomMenu.MenuItem onClick={() => router.push(`/${workspaceSlug}/settings/account`)}>
|
||||
<div className="flex w-full items-center gap-2 rounded-sm text-11">
|
||||
<Settings className="h-4 w-4 stroke-[1.5]" />
|
||||
<span>{t("settings")}</span>
|
||||
<div className="relative h-29 w-full rounded-lg">
|
||||
<CoverImage
|
||||
src={currentUser?.cover_image_url ?? undefined}
|
||||
alt={currentUser?.display_name}
|
||||
className="h-29 w-full rounded-lg"
|
||||
showDefaultWhenEmpty
|
||||
/>
|
||||
<div className="absolute inset-0 bg-layer-1/50" />
|
||||
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2">
|
||||
<div className="flex flex-col items-center gap-y-2">
|
||||
<div>
|
||||
<Avatar
|
||||
name={currentUser?.display_name}
|
||||
src={getFileURL(currentUser?.avatar_url ?? "")}
|
||||
size={40}
|
||||
shape="circle"
|
||||
className="text-18 font-medium"
|
||||
/>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-body-sm-medium">
|
||||
{currentUser?.first_name} {currentUser?.last_name}
|
||||
</p>
|
||||
<p className="text-caption-md-regular">{currentUser?.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<CustomMenu.MenuItem
|
||||
onClick={() =>
|
||||
toggleProfileSettingsModal({
|
||||
activeTab: "general",
|
||||
isOpen: true,
|
||||
})
|
||||
}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Settings className="shrink-0 size-3.5" />
|
||||
{t("settings")}
|
||||
</CustomMenu.MenuItem>
|
||||
<CustomMenu.MenuItem onClick={() => router.push(`/${workspaceSlug}/settings/account/preferences`)}>
|
||||
<div className="flex w-full items-center gap-2 rounded-sm text-11">
|
||||
<Settings2 className="h-4 w-4 stroke-[1.5]" />
|
||||
<span>Preferences</span>
|
||||
</div>
|
||||
</CustomMenu.MenuItem>
|
||||
</div>
|
||||
<div className="my-1 border-t border-subtle" />
|
||||
<div className={`${isUserInstanceAdmin ? "pb-2" : ""}`}>
|
||||
<CustomMenu.MenuItem>
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center gap-2 rounded-sm text-11 hover:bg-layer-1"
|
||||
onClick={handleSignOut}
|
||||
>
|
||||
<LogOut className="size-4 stroke-[1.5]" />
|
||||
{t("sign_out")}
|
||||
</button>
|
||||
<CustomMenu.MenuItem
|
||||
onClick={() =>
|
||||
toggleProfileSettingsModal({
|
||||
activeTab: "preferences",
|
||||
isOpen: true,
|
||||
})
|
||||
}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Settings2 className="shrink-0 size-3.5" />
|
||||
{t("preferences")}
|
||||
</CustomMenu.MenuItem>
|
||||
</div>
|
||||
<CustomMenu.MenuItem onClick={handleSignOut} className="flex items-center gap-2">
|
||||
<LogOut className="shrink-0 size-3.5" />
|
||||
{t("sign_out")}
|
||||
</CustomMenu.MenuItem>
|
||||
{isUserInstanceAdmin && (
|
||||
<>
|
||||
<div className="my-1 border-t border-subtle" />
|
||||
<div className="px-1">
|
||||
<CustomMenu.MenuItem onClick={() => router.push(GOD_MODE_URL)}>
|
||||
<div className="flex w-full items-center justify-center rounded-sm bg-accent-primary/20 px-2 py-1 text-11 font-medium text-accent-primary hover:bg-accent-primary/30 hover:text-accent-secondary">
|
||||
{t("enter_god_mode")}
|
||||
</div>
|
||||
</CustomMenu.MenuItem>
|
||||
</div>
|
||||
</>
|
||||
<CustomMenu.MenuItem
|
||||
onClick={() => router.push(GOD_MODE_URL)}
|
||||
className="bg-accent-primary/20 text-accent-primary hover:bg-accent-primary/30 hover:text-accent-secondary"
|
||||
>
|
||||
{t("enter_god_mode")}
|
||||
</CustomMenu.MenuItem>
|
||||
)}
|
||||
</CustomMenu>
|
||||
);
|
||||
|
||||
@@ -178,15 +178,12 @@ export const WorkspaceAuthWrapper = observer(function WorkspaceAuthWrapper(props
|
||||
</Link>
|
||||
)}
|
||||
{allWorkspaces?.length > 0 && (
|
||||
<Link
|
||||
href={`/${allWorkspaces[0].slug}/settings/account`}
|
||||
className={cn(getButtonStyling("secondary", "base"))}
|
||||
>
|
||||
<Link href="/settings/profile/general/" className={cn(getButtonStyling("secondary", "base"))}>
|
||||
Visit Profile
|
||||
</Link>
|
||||
)}
|
||||
{allWorkspaces && allWorkspaces.length === 0 && (
|
||||
<Link href={`/`} className={cn(getButtonStyling("secondary", "base"))}>
|
||||
<Link href="/create-workspace/" className={cn(getButtonStyling("secondary", "base"))}>
|
||||
Create new workspace
|
||||
</Link>
|
||||
)}
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { observable, action, makeObservable } from "mobx";
|
||||
import { observable, action, makeObservable, runInAction } from "mobx";
|
||||
import { computedFn } from "mobx-utils";
|
||||
// plane imports
|
||||
import type { TCreateModalStoreTypes, TCreatePageModal } from "@plane/constants";
|
||||
import { DEFAULT_CREATE_PAGE_MODAL_DATA, EPageAccess } from "@plane/constants";
|
||||
import type { TProfileSettingsTabs } from "@plane/types";
|
||||
import { EIssuesStoreType } from "@plane/types";
|
||||
// lib
|
||||
import { store } from "@/lib/store-context";
|
||||
|
||||
export interface ModalData {
|
||||
@@ -22,6 +25,10 @@ export interface IBaseCommandPaletteStore {
|
||||
isBulkDeleteIssueModalOpen: boolean;
|
||||
createIssueStoreType: TCreateModalStoreTypes;
|
||||
createWorkItemAllowedProjectIds: string[] | undefined;
|
||||
profileSettingsModal: {
|
||||
activeTab: TProfileSettingsTabs | null;
|
||||
isOpen: boolean;
|
||||
};
|
||||
allStickiesModal: boolean;
|
||||
projectListOpenMap: Record<string, boolean>;
|
||||
getIsProjectListOpen: (projectId: string) => boolean;
|
||||
@@ -36,6 +43,7 @@ export interface IBaseCommandPaletteStore {
|
||||
toggleBulkDeleteIssueModal: (value?: boolean) => void;
|
||||
toggleAllStickiesModal: (value?: boolean) => void;
|
||||
toggleProjectListOpen: (projectId: string, value?: boolean) => void;
|
||||
toggleProfileSettingsModal: (value: { activeTab?: TProfileSettingsTabs | null; isOpen?: boolean }) => void;
|
||||
}
|
||||
|
||||
export abstract class BaseCommandPaletteStore implements IBaseCommandPaletteStore {
|
||||
@@ -50,6 +58,10 @@ export abstract class BaseCommandPaletteStore implements IBaseCommandPaletteStor
|
||||
createPageModal: TCreatePageModal = DEFAULT_CREATE_PAGE_MODAL_DATA;
|
||||
createIssueStoreType: TCreateModalStoreTypes = EIssuesStoreType.PROJECT;
|
||||
createWorkItemAllowedProjectIds: IBaseCommandPaletteStore["createWorkItemAllowedProjectIds"] = undefined;
|
||||
profileSettingsModal: IBaseCommandPaletteStore["profileSettingsModal"] = {
|
||||
activeTab: "general",
|
||||
isOpen: false,
|
||||
};
|
||||
allStickiesModal: boolean = false;
|
||||
projectListOpenMap: Record<string, boolean> = {};
|
||||
|
||||
@@ -66,6 +78,7 @@ export abstract class BaseCommandPaletteStore implements IBaseCommandPaletteStor
|
||||
createPageModal: observable,
|
||||
createIssueStoreType: observable,
|
||||
createWorkItemAllowedProjectIds: observable,
|
||||
profileSettingsModal: observable,
|
||||
allStickiesModal: observable,
|
||||
projectListOpenMap: observable,
|
||||
// toggle actions
|
||||
@@ -79,6 +92,7 @@ export abstract class BaseCommandPaletteStore implements IBaseCommandPaletteStor
|
||||
toggleBulkDeleteIssueModal: action,
|
||||
toggleAllStickiesModal: action,
|
||||
toggleProjectListOpen: action,
|
||||
toggleProfileSettingsModal: action,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -240,4 +254,20 @@ export abstract class BaseCommandPaletteStore implements IBaseCommandPaletteStor
|
||||
this.allStickiesModal = !this.allStickiesModal;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Toggles the profile settings modal
|
||||
* @param value
|
||||
* @returns
|
||||
*/
|
||||
toggleProfileSettingsModal: IBaseCommandPaletteStore["toggleProfileSettingsModal"] = (payload) => {
|
||||
const updatedSettings: IBaseCommandPaletteStore["profileSettingsModal"] = {
|
||||
...this.profileSettingsModal,
|
||||
...payload,
|
||||
};
|
||||
|
||||
runInAction(() => {
|
||||
this.profileSettingsModal = updatedSettings;
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
@@ -67,6 +67,7 @@
|
||||
"react-pdf-html": "^2.1.2",
|
||||
"react-popper": "^2.3.0",
|
||||
"react-router": "catalog:",
|
||||
"react-router-dom": "catalog:",
|
||||
"recharts": "^2.12.7",
|
||||
"serve": "14.2.5",
|
||||
"smooth-scroll-into-view-if-needed": "^2.0.2",
|
||||
|
||||
+1
-2
@@ -69,8 +69,7 @@
|
||||
"prosemirror-view": "1.40.0",
|
||||
"@types/express": "4.17.23",
|
||||
"typescript": "catalog:",
|
||||
"vite": "catalog:",
|
||||
"qs": "6.14.1"
|
||||
"vite": "catalog:"
|
||||
},
|
||||
"onlyBuiltDependencies": [
|
||||
"@sentry/cli",
|
||||
|
||||
@@ -62,9 +62,7 @@ Retrieves variable declarators from the current collection.
|
||||
- **Parameters**: `callback` (Function)
|
||||
- **Example**:
|
||||
```javascript
|
||||
const variableDeclarators = j
|
||||
.find(j.Identifier)
|
||||
.getVariableDeclarators((path) => path.value.name);
|
||||
const variableDeclarators = j.find(j.Identifier).getVariableDeclarators((path) => path.value.name);
|
||||
```
|
||||
|
||||
#### `findVariableDeclarators`
|
||||
@@ -84,9 +82,7 @@ Filters nodes based on a predicate function.
|
||||
- **Parameters**: `predicate` (Function)
|
||||
- **Example**:
|
||||
```javascript
|
||||
const constDeclarations = j
|
||||
.find(j.VariableDeclaration)
|
||||
.filter((path) => path.node.kind === "const");
|
||||
const constDeclarations = j.find(j.VariableDeclaration).filter((path) => path.node.kind === "const");
|
||||
```
|
||||
|
||||
#### `forEach`
|
||||
@@ -108,9 +104,7 @@ Checks if at least one element in the collection passes the test.
|
||||
- **Parameters**: `callback` (Function)
|
||||
- **Example**:
|
||||
```javascript
|
||||
const hasVariableA = root
|
||||
.find(j.VariableDeclarator)
|
||||
.some((path) => path.node.id.name === "a");
|
||||
const hasVariableA = root.find(j.VariableDeclarator).some((path) => path.node.id.name === "a");
|
||||
```
|
||||
|
||||
#### `every`
|
||||
@@ -120,9 +114,7 @@ Checks if all elements in the collection pass the test.
|
||||
- **Parameters**: `callback` (Function)
|
||||
- **Example**:
|
||||
```javascript
|
||||
const allAreConst = root
|
||||
.find(j.VariableDeclaration)
|
||||
.every((path) => path.node.kind === "const");
|
||||
const allAreConst = root.find(j.VariableDeclaration).every((path) => path.node.kind === "const");
|
||||
```
|
||||
|
||||
#### `map`
|
||||
@@ -132,9 +124,7 @@ Maps each node in the collection to a new value.
|
||||
- **Parameters**: `callback` (Function)
|
||||
- **Example**:
|
||||
```javascript
|
||||
const variableNames = j
|
||||
.find(j.VariableDeclaration)
|
||||
.map((path) => path.node.declarations.map((decl) => decl.id.name));
|
||||
const variableNames = j.find(j.VariableDeclaration).map((path) => path.node.declarations.map((decl) => decl.id.name));
|
||||
```
|
||||
|
||||
#### `size`
|
||||
@@ -217,10 +207,7 @@ Checks if the node in the collection is of a specific type.
|
||||
- **Parameters**: `type` (String)
|
||||
- **Example**:
|
||||
```javascript
|
||||
const isVariableDeclarator = root
|
||||
.find(j.VariableDeclarator)
|
||||
.at(0)
|
||||
.isOfType("VariableDeclarator");
|
||||
const isVariableDeclarator = root.find(j.VariableDeclarator).at(0).isOfType("VariableDeclarator");
|
||||
```
|
||||
|
||||
### Node Transformation APIs
|
||||
@@ -232,9 +219,7 @@ Replaces the current node(s) with a new node.
|
||||
- **Parameters**: `newNode` (Node or Function)
|
||||
- **Example**:
|
||||
```javascript
|
||||
j.find(j.Identifier).replaceWith((path) =>
|
||||
j.identifier(path.node.name.toUpperCase())
|
||||
);
|
||||
j.find(j.Identifier).replaceWith((path) => j.identifier(path.node.name.toUpperCase()));
|
||||
```
|
||||
|
||||
#### `insertBefore`
|
||||
@@ -244,9 +229,7 @@ Inserts a node before the current node.
|
||||
- **Parameters**: `newNode` (Node)
|
||||
- **Example**:
|
||||
```javascript
|
||||
j.find(j.FunctionDeclaration).insertBefore(
|
||||
j.expressionStatement(j.stringLiteral("Inserted before"))
|
||||
);
|
||||
j.find(j.FunctionDeclaration).insertBefore(j.expressionStatement(j.stringLiteral("Inserted before")));
|
||||
```
|
||||
|
||||
#### `insertAfter`
|
||||
@@ -256,9 +239,7 @@ Inserts a node after the current node.
|
||||
- **Parameters**: `newNode` (Node)
|
||||
- **Example**:
|
||||
```javascript
|
||||
j.find(j.FunctionDeclaration).insertAfter(
|
||||
j.expressionStatement(j.stringLiteral("Inserted after"))
|
||||
);
|
||||
j.find(j.FunctionDeclaration).insertAfter(j.expressionStatement(j.stringLiteral("Inserted after")));
|
||||
```
|
||||
|
||||
#### `remove`
|
||||
|
||||
@@ -1,56 +1,41 @@
|
||||
// plane imports
|
||||
import type { TProfileSettingsTabs } from "@plane/types";
|
||||
import { EStartOfTheWeek } from "@plane/types";
|
||||
|
||||
export const PROFILE_SETTINGS = {
|
||||
profile: {
|
||||
key: "profile",
|
||||
export const PROFILE_SETTINGS: Record<
|
||||
TProfileSettingsTabs,
|
||||
{
|
||||
key: TProfileSettingsTabs;
|
||||
i18n_label: string;
|
||||
}
|
||||
> = {
|
||||
general: {
|
||||
key: "general",
|
||||
i18n_label: "profile.actions.profile",
|
||||
href: `/settings/account`,
|
||||
highlight: (pathname: string) => pathname === "/settings/account/",
|
||||
},
|
||||
security: {
|
||||
key: "security",
|
||||
i18n_label: "profile.actions.security",
|
||||
href: `/settings/account/security`,
|
||||
highlight: (pathname: string) => pathname === "/settings/account/security/",
|
||||
},
|
||||
activity: {
|
||||
key: "activity",
|
||||
i18n_label: "profile.actions.activity",
|
||||
href: `/settings/account/activity`,
|
||||
highlight: (pathname: string) => pathname === "/settings/account/activity/",
|
||||
},
|
||||
preferences: {
|
||||
key: "preferences",
|
||||
i18n_label: "profile.actions.preferences",
|
||||
href: `/settings/account/preferences`,
|
||||
highlight: (pathname: string) => pathname === "/settings/account/preferences",
|
||||
},
|
||||
notifications: {
|
||||
key: "notifications",
|
||||
i18n_label: "profile.actions.notifications",
|
||||
href: `/settings/account/notifications`,
|
||||
highlight: (pathname: string) => pathname === "/settings/account/notifications/",
|
||||
},
|
||||
"api-tokens": {
|
||||
key: "api-tokens",
|
||||
i18n_label: "profile.actions.api-tokens",
|
||||
href: `/settings/account/api-tokens`,
|
||||
highlight: (pathname: string) => pathname === "/settings/account/api-tokens/",
|
||||
},
|
||||
};
|
||||
export const PROFILE_ACTION_LINKS: {
|
||||
key: string;
|
||||
i18n_label: string;
|
||||
href: string;
|
||||
highlight: (pathname: string) => boolean;
|
||||
}[] = [
|
||||
PROFILE_SETTINGS["profile"],
|
||||
PROFILE_SETTINGS["security"],
|
||||
PROFILE_SETTINGS["activity"],
|
||||
PROFILE_SETTINGS["preferences"],
|
||||
PROFILE_SETTINGS["notifications"],
|
||||
PROFILE_SETTINGS["api-tokens"],
|
||||
];
|
||||
|
||||
export const PROFILE_SETTINGS_TABS: TProfileSettingsTabs[] = Object.keys(PROFILE_SETTINGS) as TProfileSettingsTabs[];
|
||||
|
||||
export const PROFILE_VIEWER_TAB = [
|
||||
{
|
||||
@@ -98,11 +83,6 @@ export const PREFERENCE_OPTIONS: {
|
||||
title: "theme",
|
||||
description: "select_or_customize_your_interface_color_scheme",
|
||||
},
|
||||
{
|
||||
id: "start_of_week",
|
||||
title: "First day of the week",
|
||||
description: "This will change how all calendars in your app look.",
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// plane imports
|
||||
import type { TProfileSettingsTabs } from "@plane/types";
|
||||
// local imports
|
||||
import { PROFILE_SETTINGS } from "./profile";
|
||||
import { WORKSPACE_SETTINGS } from "./workspace";
|
||||
|
||||
@@ -22,7 +25,7 @@ export const WORKSPACE_SETTINGS_CATEGORIES = [
|
||||
WORKSPACE_SETTINGS_CATEGORY.DEVELOPER,
|
||||
];
|
||||
|
||||
export const PROFILE_SETTINGS_CATEGORIES = [
|
||||
export const PROFILE_SETTINGS_CATEGORIES: PROFILE_SETTINGS_CATEGORY[] = [
|
||||
PROFILE_SETTINGS_CATEGORY.YOUR_PROFILE,
|
||||
PROFILE_SETTINGS_CATEGORY.DEVELOPER,
|
||||
];
|
||||
@@ -40,9 +43,12 @@ export const GROUPED_WORKSPACE_SETTINGS = {
|
||||
[WORKSPACE_SETTINGS_CATEGORY.DEVELOPER]: [WORKSPACE_SETTINGS["webhooks"]],
|
||||
};
|
||||
|
||||
export const GROUPED_PROFILE_SETTINGS = {
|
||||
export const GROUPED_PROFILE_SETTINGS: Record<
|
||||
PROFILE_SETTINGS_CATEGORY,
|
||||
{ key: TProfileSettingsTabs; i18n_label: string }[]
|
||||
> = {
|
||||
[PROFILE_SETTINGS_CATEGORY.YOUR_PROFILE]: [
|
||||
PROFILE_SETTINGS["profile"],
|
||||
PROFILE_SETTINGS["general"],
|
||||
PROFILE_SETTINGS["preferences"],
|
||||
PROFILE_SETTINGS["notifications"],
|
||||
PROFILE_SETTINGS["security"],
|
||||
|
||||
@@ -1,459 +0,0 @@
|
||||
import { computePosition, flip, shift, autoUpdate } from "@floating-ui/dom";
|
||||
import type { Editor } from "@tiptap/core";
|
||||
import { TableMap } from "@tiptap/pm/tables";
|
||||
// constants
|
||||
import { CORE_EXTENSIONS } from "@/constants/extension";
|
||||
// icons
|
||||
import { DRAG_HANDLE_ICONS, createSvgElement } from "../icons";
|
||||
// dropdown
|
||||
import { createDropdownContent, handleDropdownAction } from "../dropdown-content";
|
||||
import type { DropdownOption } from "../dropdown-content";
|
||||
// extensions
|
||||
import {
|
||||
findTable,
|
||||
getTableHeightPx,
|
||||
getTableWidthPx,
|
||||
isCellSelection,
|
||||
selectColumn,
|
||||
getSelectedColumns,
|
||||
} from "@/extensions/table/table/utilities/helpers";
|
||||
// local imports
|
||||
import { moveSelectedColumns, duplicateColumns } from "../actions";
|
||||
import {
|
||||
DROP_MARKER_THICKNESS,
|
||||
getDropMarker,
|
||||
getColDragMarker,
|
||||
hideDragMarker,
|
||||
hideDropMarker,
|
||||
updateColDragMarker,
|
||||
updateColDropMarker,
|
||||
} from "../marker-utils";
|
||||
import { updateCellContentVisibility } from "../utils";
|
||||
import { calculateColumnDropIndex, constructColumnDragPreview, getTableColumnNodesInfo } from "./utils";
|
||||
|
||||
export type ColumnDragHandleConfig = {
|
||||
editor: Editor;
|
||||
col: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a vanilla JS column drag handle element with dropdown functionality
|
||||
*/
|
||||
export function createColumnDragHandle(config: ColumnDragHandleConfig): {
|
||||
element: HTMLElement;
|
||||
destroy: () => void;
|
||||
} {
|
||||
const { editor, col } = config;
|
||||
|
||||
// Create container
|
||||
const container = document.createElement("div");
|
||||
container.className =
|
||||
"table-col-handle-container absolute z-20 top-0 left-0 flex justify-center items-center w-full -translate-y-1/2";
|
||||
|
||||
// Create button
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = "default-state";
|
||||
|
||||
// Create icon (Ellipsis lucide icon as SVG)
|
||||
const icon = createSvgElement(DRAG_HANDLE_ICONS.ellipsis, "size-4 text-primary");
|
||||
|
||||
button.appendChild(icon);
|
||||
container.appendChild(button);
|
||||
|
||||
// State for dropdown
|
||||
let isDropdownOpen = false;
|
||||
let dropdownElement: HTMLElement | null = null;
|
||||
let backdropElement: HTMLElement | null = null;
|
||||
let cleanupFloating: (() => void) | null = null;
|
||||
let backdropClickHandler: (() => void) | null = null;
|
||||
|
||||
// Track drag event listeners for cleanup
|
||||
let dragListeners: {
|
||||
mouseup?: (e: MouseEvent) => void;
|
||||
mousemove?: (e: MouseEvent) => void;
|
||||
} = {};
|
||||
|
||||
// Dropdown toggle function
|
||||
const toggleDropdown = () => {
|
||||
if (isDropdownOpen) {
|
||||
closeDropdown();
|
||||
} else {
|
||||
openDropdown();
|
||||
}
|
||||
};
|
||||
|
||||
const closeDropdown = () => {
|
||||
if (!isDropdownOpen) return;
|
||||
|
||||
isDropdownOpen = false;
|
||||
|
||||
// Reset button to default state
|
||||
button.className = "default-state";
|
||||
|
||||
// Remove dropdown and backdrop
|
||||
if (dropdownElement) {
|
||||
dropdownElement.remove();
|
||||
dropdownElement = null;
|
||||
}
|
||||
if (backdropElement) {
|
||||
// Remove backdrop listener before removing element
|
||||
if (backdropClickHandler) {
|
||||
backdropElement.removeEventListener("click", backdropClickHandler);
|
||||
backdropClickHandler = null;
|
||||
}
|
||||
backdropElement.remove();
|
||||
backdropElement = null;
|
||||
}
|
||||
|
||||
// Cleanup floating UI (this also removes keydown listener)
|
||||
if (cleanupFloating) {
|
||||
cleanupFloating();
|
||||
cleanupFloating = null;
|
||||
}
|
||||
|
||||
// Remove active dropdown extension
|
||||
setTimeout(() => {
|
||||
editor.commands.removeActiveDropbarExtension(CORE_EXTENSIONS.TABLE);
|
||||
}, 0);
|
||||
};
|
||||
|
||||
const openDropdown = () => {
|
||||
if (isDropdownOpen) return;
|
||||
|
||||
isDropdownOpen = true;
|
||||
|
||||
// Update button to open state
|
||||
button.className = "open-state";
|
||||
|
||||
// Add active dropdown extension
|
||||
editor.commands.addActiveDropbarExtension(CORE_EXTENSIONS.TABLE);
|
||||
|
||||
// Create backdrop
|
||||
backdropElement = document.createElement("div");
|
||||
backdropElement.style.position = "fixed";
|
||||
backdropElement.style.inset = "0";
|
||||
backdropElement.style.zIndex = "99";
|
||||
backdropClickHandler = closeDropdown;
|
||||
backdropElement.addEventListener("click", backdropClickHandler);
|
||||
document.body.appendChild(backdropElement);
|
||||
|
||||
// Create dropdown
|
||||
dropdownElement = document.createElement("div");
|
||||
dropdownElement.className =
|
||||
"max-h-[90vh] w-[12rem] overflow-y-auto rounded-md border-[0.5px] border-strong bg-surface-1 px-2 py-2.5 shadow-raised-200";
|
||||
dropdownElement.style.position = "fixed";
|
||||
dropdownElement.style.zIndex = "100";
|
||||
|
||||
// Create and append dropdown content
|
||||
const content = createDropdownContent(getDropdownOptions());
|
||||
dropdownElement.appendChild(content);
|
||||
|
||||
document.body.appendChild(dropdownElement);
|
||||
|
||||
// Attach dropdown event listeners
|
||||
attachDropdownEventListeners(dropdownElement);
|
||||
|
||||
// Setup floating UI positioning
|
||||
cleanupFloating = autoUpdate(button, dropdownElement, () => {
|
||||
void computePosition(button, dropdownElement!, {
|
||||
placement: "bottom-start",
|
||||
middleware: [
|
||||
flip({
|
||||
fallbackPlacements: ["top-start", "bottom-start", "top-end", "bottom-end"],
|
||||
}),
|
||||
shift({
|
||||
padding: 8,
|
||||
}),
|
||||
],
|
||||
}).then(({ x, y }) => {
|
||||
if (dropdownElement) {
|
||||
dropdownElement.style.left = `${x}px`;
|
||||
dropdownElement.style.top = `${y}px`;
|
||||
}
|
||||
return;
|
||||
});
|
||||
});
|
||||
|
||||
// Handle keyboard events
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
closeDropdown();
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
};
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
|
||||
// Store cleanup for this specific dropdown instance
|
||||
const originalCleanup = cleanupFloating;
|
||||
cleanupFloating = () => {
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
if (originalCleanup) originalCleanup();
|
||||
};
|
||||
};
|
||||
|
||||
const getDropdownOptions = (): DropdownOption[] => [
|
||||
{
|
||||
key: "toggle-header",
|
||||
label: "Header column",
|
||||
icon: DRAG_HANDLE_ICONS.toggleRight,
|
||||
showRightIcon: true,
|
||||
},
|
||||
{
|
||||
key: "insert-left",
|
||||
label: "Insert left",
|
||||
icon: DRAG_HANDLE_ICONS.arrowLeft,
|
||||
showRightIcon: false,
|
||||
},
|
||||
{
|
||||
key: "insert-right",
|
||||
label: "Insert right",
|
||||
icon: DRAG_HANDLE_ICONS.arrowRight,
|
||||
showRightIcon: false,
|
||||
},
|
||||
{
|
||||
key: "duplicate",
|
||||
label: "Duplicate",
|
||||
icon: DRAG_HANDLE_ICONS.duplicate,
|
||||
showRightIcon: false,
|
||||
},
|
||||
{
|
||||
key: "clear-contents",
|
||||
label: "Clear contents",
|
||||
icon: DRAG_HANDLE_ICONS.close,
|
||||
showRightIcon: false,
|
||||
},
|
||||
{
|
||||
key: "delete",
|
||||
label: "Delete",
|
||||
icon: DRAG_HANDLE_ICONS.trash,
|
||||
showRightIcon: false,
|
||||
},
|
||||
];
|
||||
|
||||
const attachDropdownEventListeners = (dropdown: HTMLElement) => {
|
||||
const buttons = dropdown.querySelectorAll("button[data-action]");
|
||||
const colorPanel = dropdown.querySelector(".color-panel");
|
||||
const colorChevron = dropdown.querySelector(".color-chevron");
|
||||
|
||||
buttons.forEach((btn) => {
|
||||
const action = btn.getAttribute("data-action");
|
||||
if (!action) return;
|
||||
|
||||
btn.addEventListener("click", (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
// Handle common actions
|
||||
handleDropdownAction(action, editor, closeDropdown, colorPanel, colorChevron);
|
||||
|
||||
// Handle column-specific actions
|
||||
switch (action) {
|
||||
case "toggle-header":
|
||||
editor.chain().focus().toggleHeaderColumn().run();
|
||||
closeDropdown();
|
||||
break;
|
||||
case "set-bg-color": {
|
||||
const color = btn.getAttribute("data-color");
|
||||
if (color) {
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.updateAttributes(CORE_EXTENSIONS.TABLE_CELL, {
|
||||
background: color,
|
||||
})
|
||||
.run();
|
||||
}
|
||||
closeDropdown();
|
||||
break;
|
||||
}
|
||||
case "insert-left":
|
||||
editor.chain().focus().addColumnBefore().run();
|
||||
closeDropdown();
|
||||
break;
|
||||
case "insert-right":
|
||||
editor.chain().focus().addColumnAfter().run();
|
||||
closeDropdown();
|
||||
break;
|
||||
case "duplicate": {
|
||||
const table = findTable(editor.state.selection);
|
||||
if (table) {
|
||||
const tableMap = TableMap.get(table.node);
|
||||
let tr = editor.state.tr;
|
||||
const selectedColumns = getSelectedColumns(editor.state.selection, tableMap);
|
||||
tr = duplicateColumns(table, selectedColumns, tr);
|
||||
editor.view.dispatch(tr);
|
||||
}
|
||||
closeDropdown();
|
||||
break;
|
||||
}
|
||||
case "clear-contents":
|
||||
editor.chain().focus().clearSelectedCells().run();
|
||||
closeDropdown();
|
||||
break;
|
||||
case "delete":
|
||||
editor.chain().focus().deleteColumn().run();
|
||||
closeDropdown();
|
||||
break;
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
// Handle mousedown for dragging
|
||||
const handleMouseDown = (e: MouseEvent) => {
|
||||
// Prevent dropdown from opening during drag
|
||||
if (e.button !== 0) return; // Only left click
|
||||
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
|
||||
// Check if this is a click (will be determined by mouseup without much movement)
|
||||
const startX = e.clientX;
|
||||
const startY = e.clientY;
|
||||
let hasMoved = false;
|
||||
|
||||
// Clean up any existing drag listeners
|
||||
if (dragListeners.mouseup) {
|
||||
window.removeEventListener("mouseup", dragListeners.mouseup);
|
||||
}
|
||||
if (dragListeners.mousemove) {
|
||||
window.removeEventListener("mousemove", dragListeners.mousemove);
|
||||
}
|
||||
dragListeners = {};
|
||||
|
||||
const table = findTable(editor.state.selection);
|
||||
if (!table) return;
|
||||
|
||||
editor.view.dispatch(selectColumn(table, col, editor.state.tr));
|
||||
|
||||
// Drag column logic
|
||||
const tableWidthPx = getTableWidthPx(table, editor);
|
||||
const columns = getTableColumnNodesInfo(table, editor);
|
||||
|
||||
let dropIndex = col;
|
||||
const startLeft = columns[col].left ?? 0;
|
||||
const startXPos = e.clientX;
|
||||
const tableElement = editor.view.nodeDOM(table.pos);
|
||||
|
||||
const dropMarker = tableElement instanceof HTMLElement ? getDropMarker(tableElement) : null;
|
||||
const dragMarker = tableElement instanceof HTMLElement ? getColDragMarker(tableElement) : null;
|
||||
|
||||
const handleFinish = (): void => {
|
||||
// Clean up markers if they exist
|
||||
if (dropMarker && dragMarker) {
|
||||
hideDropMarker(dropMarker);
|
||||
hideDragMarker(dragMarker);
|
||||
}
|
||||
|
||||
if (isCellSelection(editor.state.selection)) {
|
||||
updateCellContentVisibility(editor, false);
|
||||
}
|
||||
|
||||
// Perform drag operation if user moved
|
||||
if (col !== dropIndex && hasMoved) {
|
||||
let tr = editor.state.tr;
|
||||
const selection = editor.state.selection;
|
||||
if (isCellSelection(selection)) {
|
||||
const table = findTable(selection);
|
||||
if (table) {
|
||||
tr = moveSelectedColumns(editor, table, selection, dropIndex, tr);
|
||||
}
|
||||
}
|
||||
editor.view.dispatch(tr);
|
||||
}
|
||||
|
||||
window.removeEventListener("mouseup", handleFinish);
|
||||
window.removeEventListener("mousemove", handleMove);
|
||||
dragListeners.mouseup = undefined;
|
||||
dragListeners.mousemove = undefined;
|
||||
|
||||
// If it was just a click (no movement), toggle dropdown
|
||||
if (!hasMoved) {
|
||||
toggleDropdown();
|
||||
}
|
||||
};
|
||||
|
||||
let pseudoColumn: HTMLElement | undefined;
|
||||
|
||||
const handleMove = (moveEvent: MouseEvent): void => {
|
||||
// Mark that we've moved
|
||||
const deltaX = Math.abs(moveEvent.clientX - startX);
|
||||
const deltaY = Math.abs(moveEvent.clientY - startY);
|
||||
if (deltaX > 3 || deltaY > 3) {
|
||||
hasMoved = true;
|
||||
}
|
||||
|
||||
// Calculate drop index
|
||||
const currentLeft = startLeft + moveEvent.clientX - startXPos;
|
||||
dropIndex = calculateColumnDropIndex(col, columns, currentLeft);
|
||||
|
||||
// Update visual markers if they exist
|
||||
if (dropMarker && dragMarker) {
|
||||
if (!pseudoColumn) {
|
||||
pseudoColumn = constructColumnDragPreview(editor, editor.state.selection, table);
|
||||
const tableHeightPx = getTableHeightPx(table, editor);
|
||||
if (pseudoColumn) {
|
||||
pseudoColumn.style.height = `${tableHeightPx}px`;
|
||||
}
|
||||
}
|
||||
|
||||
const dragMarkerWidthPx = columns[col].width;
|
||||
const dragMarkerLeftPx = Math.max(0, Math.min(currentLeft, tableWidthPx - dragMarkerWidthPx));
|
||||
const dropMarkerLeftPx =
|
||||
dropIndex <= col ? columns[dropIndex].left : columns[dropIndex].left + columns[dropIndex].width;
|
||||
|
||||
updateColDropMarker({
|
||||
element: dropMarker,
|
||||
left: dropMarkerLeftPx - Math.floor(DROP_MARKER_THICKNESS / 2) - 1,
|
||||
width: DROP_MARKER_THICKNESS,
|
||||
});
|
||||
updateColDragMarker({
|
||||
element: dragMarker,
|
||||
left: dragMarkerLeftPx,
|
||||
width: dragMarkerWidthPx,
|
||||
pseudoColumn,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
dragListeners.mouseup = handleFinish;
|
||||
dragListeners.mousemove = handleMove;
|
||||
window.addEventListener("mouseup", handleFinish);
|
||||
window.addEventListener("mousemove", handleMove);
|
||||
} catch (error) {
|
||||
console.error("Error in ColumnDragHandle:", error);
|
||||
handleFinish();
|
||||
}
|
||||
};
|
||||
|
||||
// Attach mousedown listener
|
||||
button.addEventListener("mousedown", handleMouseDown);
|
||||
|
||||
// Cleanup function
|
||||
const destroy = () => {
|
||||
// Close dropdown if open
|
||||
if (isDropdownOpen) {
|
||||
closeDropdown();
|
||||
}
|
||||
|
||||
// Remove drag listeners
|
||||
if (dragListeners.mouseup) {
|
||||
window.removeEventListener("mouseup", dragListeners.mouseup);
|
||||
}
|
||||
if (dragListeners.mousemove) {
|
||||
window.removeEventListener("mousemove", dragListeners.mousemove);
|
||||
}
|
||||
|
||||
// Remove mousedown listener
|
||||
button.removeEventListener("mousedown", handleMouseDown);
|
||||
|
||||
// Remove DOM elements
|
||||
container.remove();
|
||||
};
|
||||
|
||||
return {
|
||||
element: container,
|
||||
destroy,
|
||||
};
|
||||
}
|
||||
+258
@@ -0,0 +1,258 @@
|
||||
import {
|
||||
shift,
|
||||
flip,
|
||||
useDismiss,
|
||||
useFloating,
|
||||
useInteractions,
|
||||
autoUpdate,
|
||||
useClick,
|
||||
useRole,
|
||||
FloatingOverlay,
|
||||
FloatingPortal,
|
||||
} from "@floating-ui/react";
|
||||
import type { Editor } from "@tiptap/core";
|
||||
import { Ellipsis } from "lucide-react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
// plane imports
|
||||
import { cn } from "@plane/utils";
|
||||
// constants
|
||||
import { CORE_EXTENSIONS } from "@/constants/extension";
|
||||
// extensions
|
||||
import {
|
||||
findTable,
|
||||
getTableHeightPx,
|
||||
getTableWidthPx,
|
||||
isCellSelection,
|
||||
selectColumn,
|
||||
} from "@/extensions/table/table/utilities/helpers";
|
||||
// local imports
|
||||
import { moveSelectedColumns } from "../actions";
|
||||
import {
|
||||
DROP_MARKER_THICKNESS,
|
||||
getColDragMarker,
|
||||
getDropMarker,
|
||||
hideDragMarker,
|
||||
hideDropMarker,
|
||||
updateColDragMarker,
|
||||
updateColDropMarker,
|
||||
} from "../marker-utils";
|
||||
import { updateCellContentVisibility } from "../utils";
|
||||
import { ColumnOptionsDropdown } from "./dropdown";
|
||||
import { calculateColumnDropIndex, constructColumnDragPreview, getTableColumnNodesInfo } from "./utils";
|
||||
|
||||
export type ColumnDragHandleProps = {
|
||||
col: number;
|
||||
editor: Editor;
|
||||
};
|
||||
|
||||
export function ColumnDragHandle(props: ColumnDragHandleProps) {
|
||||
const { col, editor } = props;
|
||||
// states
|
||||
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
||||
// Track active event listeners for cleanup
|
||||
const activeListenersRef = useRef<{
|
||||
mouseup?: (e: MouseEvent) => void;
|
||||
mousemove?: (e: MouseEvent) => void;
|
||||
}>({});
|
||||
|
||||
// Cleanup window event listeners on unmount
|
||||
useEffect(() => {
|
||||
const listenersRef = activeListenersRef.current;
|
||||
return () => {
|
||||
// Remove any lingering window event listeners when component unmounts
|
||||
if (listenersRef.mouseup) {
|
||||
window.removeEventListener("mouseup", listenersRef.mouseup);
|
||||
}
|
||||
if (listenersRef.mousemove) {
|
||||
window.removeEventListener("mousemove", listenersRef.mousemove);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
// floating ui
|
||||
const { refs, floatingStyles, context } = useFloating({
|
||||
placement: "bottom-start",
|
||||
middleware: [
|
||||
flip({
|
||||
fallbackPlacements: ["top-start", "bottom-start", "top-end", "bottom-end"],
|
||||
}),
|
||||
shift({
|
||||
padding: 8,
|
||||
}),
|
||||
],
|
||||
open: isDropdownOpen,
|
||||
onOpenChange: (open) => {
|
||||
setIsDropdownOpen(open);
|
||||
if (open) {
|
||||
editor.commands.addActiveDropbarExtension(CORE_EXTENSIONS.TABLE);
|
||||
} else {
|
||||
setTimeout(() => {
|
||||
editor.commands.removeActiveDropbarExtension(CORE_EXTENSIONS.TABLE);
|
||||
}, 0);
|
||||
}
|
||||
},
|
||||
whileElementsMounted: autoUpdate,
|
||||
});
|
||||
const click = useClick(context);
|
||||
const dismiss = useDismiss(context);
|
||||
const role = useRole(context);
|
||||
const { getReferenceProps, getFloatingProps } = useInteractions([dismiss, click, role]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDropdownOpen) return;
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
context.onOpenChange(false);
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
};
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, [isDropdownOpen, context]);
|
||||
|
||||
const handleMouseDown = useCallback(
|
||||
(e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
|
||||
// Prevent multiple simultaneous drag operations
|
||||
// If there are already listeners attached, remove them first
|
||||
if (activeListenersRef.current.mouseup) {
|
||||
window.removeEventListener("mouseup", activeListenersRef.current.mouseup);
|
||||
}
|
||||
if (activeListenersRef.current.mousemove) {
|
||||
window.removeEventListener("mousemove", activeListenersRef.current.mousemove);
|
||||
}
|
||||
activeListenersRef.current.mouseup = undefined;
|
||||
activeListenersRef.current.mousemove = undefined;
|
||||
|
||||
const table = findTable(editor.state.selection);
|
||||
if (!table) return;
|
||||
|
||||
editor.view.dispatch(selectColumn(table, col, editor.state.tr));
|
||||
|
||||
// drag column
|
||||
const tableWidthPx = getTableWidthPx(table, editor);
|
||||
const columns = getTableColumnNodesInfo(table, editor);
|
||||
|
||||
let dropIndex = col;
|
||||
const startLeft = columns[col].left ?? 0;
|
||||
const startX = e.clientX;
|
||||
const tableElement = editor.view.nodeDOM(table.pos);
|
||||
|
||||
const dropMarker = tableElement instanceof HTMLElement ? getDropMarker(tableElement) : null;
|
||||
const dragMarker = tableElement instanceof HTMLElement ? getColDragMarker(tableElement) : null;
|
||||
|
||||
const handleFinish = () => {
|
||||
if (!dropMarker || !dragMarker) return;
|
||||
hideDropMarker(dropMarker);
|
||||
hideDragMarker(dragMarker);
|
||||
|
||||
if (isCellSelection(editor.state.selection)) {
|
||||
updateCellContentVisibility(editor, false);
|
||||
}
|
||||
|
||||
if (col !== dropIndex) {
|
||||
let tr = editor.state.tr;
|
||||
const selection = editor.state.selection;
|
||||
if (isCellSelection(selection)) {
|
||||
const table = findTable(selection);
|
||||
if (table) {
|
||||
tr = moveSelectedColumns(editor, table, selection, dropIndex, tr);
|
||||
}
|
||||
}
|
||||
editor.view.dispatch(tr);
|
||||
}
|
||||
window.removeEventListener("mouseup", handleFinish);
|
||||
window.removeEventListener("mousemove", handleMove);
|
||||
// Clear the ref
|
||||
activeListenersRef.current.mouseup = undefined;
|
||||
activeListenersRef.current.mousemove = undefined;
|
||||
};
|
||||
|
||||
let pseudoColumn: HTMLElement | undefined;
|
||||
|
||||
const handleMove = (moveEvent: MouseEvent) => {
|
||||
if (!dropMarker || !dragMarker) return;
|
||||
const currentLeft = startLeft + moveEvent.clientX - startX;
|
||||
dropIndex = calculateColumnDropIndex(col, columns, currentLeft);
|
||||
|
||||
if (!pseudoColumn) {
|
||||
pseudoColumn = constructColumnDragPreview(editor, editor.state.selection, table);
|
||||
const tableHeightPx = getTableHeightPx(table, editor);
|
||||
if (pseudoColumn) {
|
||||
pseudoColumn.style.height = `${tableHeightPx}px`;
|
||||
}
|
||||
}
|
||||
|
||||
const dragMarkerWidthPx = columns[col].width;
|
||||
const dragMarkerLeftPx = Math.max(0, Math.min(currentLeft, tableWidthPx - dragMarkerWidthPx));
|
||||
const dropMarkerLeftPx =
|
||||
dropIndex <= col ? columns[dropIndex].left : columns[dropIndex].left + columns[dropIndex].width;
|
||||
|
||||
updateColDropMarker({
|
||||
element: dropMarker,
|
||||
left: dropMarkerLeftPx - Math.floor(DROP_MARKER_THICKNESS / 2) - 1,
|
||||
width: DROP_MARKER_THICKNESS,
|
||||
});
|
||||
updateColDragMarker({
|
||||
element: dragMarker,
|
||||
left: dragMarkerLeftPx,
|
||||
width: dragMarkerWidthPx,
|
||||
pseudoColumn,
|
||||
});
|
||||
};
|
||||
|
||||
try {
|
||||
// Store references for cleanup
|
||||
activeListenersRef.current.mouseup = handleFinish;
|
||||
activeListenersRef.current.mousemove = handleMove;
|
||||
window.addEventListener("mouseup", handleFinish);
|
||||
window.addEventListener("mousemove", handleMove);
|
||||
} catch (error) {
|
||||
console.error("Error in ColumnDragHandle:", error);
|
||||
handleFinish();
|
||||
}
|
||||
},
|
||||
[col, editor]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="table-col-handle-container absolute z-20 top-0 left-0 flex justify-center items-center w-full -translate-y-1/2">
|
||||
<button
|
||||
ref={refs.setReference}
|
||||
{...getReferenceProps()}
|
||||
type="button"
|
||||
onMouseDown={handleMouseDown}
|
||||
className={cn("px-1 bg-layer-1 border border-strong-1 rounded-sm outline-none transition-all duration-200", {
|
||||
"!opacity-100 bg-accent-primary border-accent-strong": isDropdownOpen,
|
||||
"hover:bg-layer-1-hover": !isDropdownOpen,
|
||||
})}
|
||||
>
|
||||
<Ellipsis className="size-4 text-primary" />
|
||||
</button>
|
||||
</div>
|
||||
{isDropdownOpen && (
|
||||
<FloatingPortal>
|
||||
{/* Backdrop */}
|
||||
<FloatingOverlay
|
||||
style={{
|
||||
zIndex: 99,
|
||||
}}
|
||||
lockScroll
|
||||
/>
|
||||
<div
|
||||
className="max-h-[90vh] w-[12rem] overflow-y-auto rounded-md border-[0.5px] border-strong bg-surface-1 px-2 py-2.5 shadow-raised-200"
|
||||
ref={refs.setFloating}
|
||||
{...getFloatingProps()}
|
||||
style={{
|
||||
...floatingStyles,
|
||||
zIndex: 100,
|
||||
}}
|
||||
>
|
||||
<ColumnOptionsDropdown editor={editor} onClose={() => context.onOpenChange(false)} />
|
||||
</div>
|
||||
</FloatingPortal>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import type { Editor } from "@tiptap/core";
|
||||
import { Plugin, PluginKey } from "@tiptap/pm/state";
|
||||
import { TableMap } from "@tiptap/pm/tables";
|
||||
import { Decoration, DecorationSet } from "@tiptap/pm/view";
|
||||
import { ReactRenderer } from "@tiptap/react";
|
||||
// extensions
|
||||
import {
|
||||
findTable,
|
||||
@@ -9,20 +10,16 @@ import {
|
||||
haveTableRelatedChanges,
|
||||
} from "@/extensions/table/table/utilities/helpers";
|
||||
// local imports
|
||||
import { createColumnDragHandle } from "./drag-handle";
|
||||
|
||||
type DragHandleInstance = {
|
||||
element: HTMLElement;
|
||||
destroy: () => void;
|
||||
};
|
||||
import type { ColumnDragHandleProps } from "./drag-handle";
|
||||
import { ColumnDragHandle } from "./drag-handle";
|
||||
|
||||
type TableColumnDragHandlePluginState = {
|
||||
decorations?: DecorationSet;
|
||||
// track table structure to detect changes
|
||||
tableWidth?: number;
|
||||
tableNodePos?: number;
|
||||
// track drag handle instances for cleanup
|
||||
dragHandles?: DragHandleInstance[];
|
||||
// track renderers for cleanup
|
||||
renderers?: ReactRenderer[];
|
||||
};
|
||||
|
||||
const TABLE_COLUMN_DRAG_HANDLE_PLUGIN_KEY = new PluginKey("tableColumnHandlerDecorationPlugin");
|
||||
@@ -63,40 +60,43 @@ export const TableColumnDragHandlePlugin = (editor: Editor): Plugin<TableColumnD
|
||||
decorations: mapped,
|
||||
tableWidth: tableMap.width,
|
||||
tableNodePos: table.pos,
|
||||
dragHandles: prev.dragHandles,
|
||||
renderers: prev.renderers,
|
||||
};
|
||||
}
|
||||
|
||||
// Clean up old drag handles before creating new ones
|
||||
prev.dragHandles?.forEach((handle) => {
|
||||
// Clean up old renderers before creating new ones
|
||||
prev.renderers?.forEach((renderer) => {
|
||||
try {
|
||||
handle.destroy();
|
||||
renderer.destroy();
|
||||
} catch (error) {
|
||||
console.error("Error destroying drag handle:", error);
|
||||
console.error("Error destroying renderer:", error);
|
||||
}
|
||||
});
|
||||
|
||||
// recreate all decorations
|
||||
const decorations: Decoration[] = [];
|
||||
const dragHandles: DragHandleInstance[] = [];
|
||||
const renderers: ReactRenderer[] = [];
|
||||
|
||||
for (let col = 0; col < tableMap.width; col++) {
|
||||
const pos = getTableCellWidgetDecorationPos(table, tableMap, col);
|
||||
|
||||
const dragHandle = createColumnDragHandle({
|
||||
const dragHandleComponent = new ReactRenderer(ColumnDragHandle, {
|
||||
props: {
|
||||
col,
|
||||
editor,
|
||||
} satisfies ColumnDragHandleProps,
|
||||
editor,
|
||||
col,
|
||||
});
|
||||
|
||||
dragHandles.push(dragHandle);
|
||||
decorations.push(Decoration.widget(pos, () => dragHandle.element));
|
||||
renderers.push(dragHandleComponent);
|
||||
decorations.push(Decoration.widget(pos, () => dragHandleComponent.element));
|
||||
}
|
||||
|
||||
return {
|
||||
decorations: DecorationSet.create(newState.doc, decorations),
|
||||
tableWidth: tableMap.width,
|
||||
tableNodePos: table.pos,
|
||||
dragHandles,
|
||||
renderers,
|
||||
};
|
||||
},
|
||||
},
|
||||
@@ -107,15 +107,15 @@ export const TableColumnDragHandlePlugin = (editor: Editor): Plugin<TableColumnD
|
||||
},
|
||||
},
|
||||
destroy() {
|
||||
// Clean up all drag handles when plugin is destroyed
|
||||
// Clean up all renderers when plugin is destroyed
|
||||
const state =
|
||||
editor.state &&
|
||||
(TABLE_COLUMN_DRAG_HANDLE_PLUGIN_KEY.getState(editor.state) as TableColumnDragHandlePluginState | undefined);
|
||||
state?.dragHandles?.forEach((handle: DragHandleInstance) => {
|
||||
state?.renderers?.forEach((renderer: ReactRenderer) => {
|
||||
try {
|
||||
handle.destroy();
|
||||
renderer.destroy();
|
||||
} catch (error) {
|
||||
console.error("Error destroying drag handle:", error);
|
||||
console.error("Error destroying renderer:", error);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
@@ -1,179 +0,0 @@
|
||||
import type { Editor } from "@tiptap/core";
|
||||
// constants
|
||||
import { COLORS_LIST } from "@/constants/common";
|
||||
import { CORE_EXTENSIONS } from "@/constants/extension";
|
||||
// icons
|
||||
import { DRAG_HANDLE_ICONS, createSvgElement } from "./icons";
|
||||
|
||||
export type DropdownOption = {
|
||||
key: string;
|
||||
label: string;
|
||||
icon: string;
|
||||
showRightIcon: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates the color selector section for the dropdown
|
||||
*/
|
||||
export function createColorSelector(): HTMLElement {
|
||||
const container = document.createElement("div");
|
||||
container.className = "mb-2";
|
||||
|
||||
// Create toggle button
|
||||
const toggleBtn = document.createElement("button");
|
||||
toggleBtn.type = "button";
|
||||
toggleBtn.className =
|
||||
"flex items-center justify-between gap-2 w-full rounded-sm px-1 py-1.5 text-11 text-left truncate text-secondary hover:bg-layer-transparent-hover";
|
||||
toggleBtn.setAttribute("data-action", "toggle-color-selector");
|
||||
|
||||
// Left span with icon and text
|
||||
const leftSpan = document.createElement("span");
|
||||
leftSpan.className = "flex items-center gap-2";
|
||||
const paletteIcon = createSvgElement(DRAG_HANDLE_ICONS.palette, "shrink-0 size-3");
|
||||
leftSpan.appendChild(paletteIcon);
|
||||
leftSpan.appendChild(document.createTextNode("Color"));
|
||||
|
||||
// Right chevron icon
|
||||
const chevronIcon = createSvgElement(
|
||||
DRAG_HANDLE_ICONS.chevronRight,
|
||||
"shrink-0 size-3 transition-transform duration-200 color-chevron"
|
||||
);
|
||||
|
||||
toggleBtn.appendChild(leftSpan);
|
||||
toggleBtn.appendChild(chevronIcon);
|
||||
container.appendChild(toggleBtn);
|
||||
|
||||
// Create color panel
|
||||
const colorPanel = document.createElement("div");
|
||||
colorPanel.className = "p-1 space-y-2 mb-1.5 hidden color-panel";
|
||||
|
||||
const innerDiv = document.createElement("div");
|
||||
innerDiv.className = "space-y-1";
|
||||
|
||||
const title = document.createElement("p");
|
||||
title.className = "text-11 text-tertiary font-semibold";
|
||||
title.textContent = "Background colors";
|
||||
innerDiv.appendChild(title);
|
||||
|
||||
const colorsContainer = document.createElement("div");
|
||||
colorsContainer.className = "flex items-center flex-wrap gap-2";
|
||||
|
||||
// Create color buttons
|
||||
COLORS_LIST.forEach((color) => {
|
||||
const colorBtn = document.createElement("button");
|
||||
colorBtn.type = "button";
|
||||
colorBtn.className =
|
||||
"flex-shrink-0 size-6 rounded-sm border-[0.5px] border-strong-1 hover:opacity-60 transition-opacity";
|
||||
colorBtn.style.backgroundColor = color.backgroundColor;
|
||||
colorBtn.setAttribute("data-action", "set-bg-color");
|
||||
colorBtn.setAttribute("data-color", color.backgroundColor);
|
||||
colorsContainer.appendChild(colorBtn);
|
||||
});
|
||||
|
||||
// Create clear color button
|
||||
const clearBtn = document.createElement("button");
|
||||
clearBtn.type = "button";
|
||||
clearBtn.className =
|
||||
"flex-shrink-0 size-6 grid place-items-center rounded-sm text-tertiary border-[0.5px] border-strong-1 hover:bg-layer-transparent-hover transition-colors";
|
||||
clearBtn.setAttribute("data-action", "clear-bg-color");
|
||||
const banIcon = createSvgElement(DRAG_HANDLE_ICONS.ban, "size-4");
|
||||
clearBtn.appendChild(banIcon);
|
||||
colorsContainer.appendChild(clearBtn);
|
||||
|
||||
innerDiv.appendChild(colorsContainer);
|
||||
colorPanel.appendChild(innerDiv);
|
||||
container.appendChild(colorPanel);
|
||||
|
||||
return container;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the dropdown content with options and color selector
|
||||
*/
|
||||
export function createDropdownContent(options: DropdownOption[]): DocumentFragment {
|
||||
const fragment = document.createDocumentFragment();
|
||||
|
||||
// Create option buttons
|
||||
options.forEach((option, index) => {
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.className = `flex items-center ${option.showRightIcon ? "justify-between" : ""} gap-2 w-full rounded-sm px-1 py-1.5 text-11 text-left truncate text-secondary hover:bg-layer-transparent-hover`;
|
||||
btn.setAttribute("data-action", option.key);
|
||||
|
||||
// Create icon element
|
||||
const iconElement = createSvgElement(option.icon, "shrink-0 size-3");
|
||||
|
||||
// Create label
|
||||
const labelDiv = document.createElement("div");
|
||||
labelDiv.className = "flex-grow truncate";
|
||||
labelDiv.textContent = option.label;
|
||||
|
||||
// Append in correct order
|
||||
if (option.showRightIcon) {
|
||||
btn.appendChild(labelDiv);
|
||||
btn.appendChild(iconElement);
|
||||
} else {
|
||||
btn.appendChild(iconElement);
|
||||
btn.appendChild(labelDiv);
|
||||
}
|
||||
|
||||
fragment.appendChild(btn);
|
||||
|
||||
// Add divider after first option (header toggle)
|
||||
if (index === 0) {
|
||||
const hr = document.createElement("hr");
|
||||
hr.className = "my-2 border-subtle";
|
||||
fragment.appendChild(hr);
|
||||
|
||||
// Add color selector after divider
|
||||
const colorSection = createColorSelector();
|
||||
fragment.appendChild(colorSection);
|
||||
}
|
||||
});
|
||||
|
||||
return fragment;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles dropdown action events
|
||||
*/
|
||||
export function handleDropdownAction(
|
||||
action: string,
|
||||
editor: Editor,
|
||||
onClose: () => void,
|
||||
colorPanel?: Element | null,
|
||||
colorChevron?: Element | null
|
||||
): void {
|
||||
switch (action) {
|
||||
case "toggle-color-selector":
|
||||
if (colorPanel && colorChevron) {
|
||||
const isHidden = colorPanel.classList.contains("hidden");
|
||||
if (isHidden) {
|
||||
colorPanel.classList.remove("hidden");
|
||||
colorChevron.classList.add("rotate-90");
|
||||
} else {
|
||||
colorPanel.classList.add("hidden");
|
||||
colorChevron.classList.remove("rotate-90");
|
||||
}
|
||||
}
|
||||
break;
|
||||
case "set-bg-color": {
|
||||
// Color is handled by the button's data-color attribute
|
||||
// This case is handled in the event listener
|
||||
break;
|
||||
}
|
||||
case "clear-bg-color":
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.updateAttributes(CORE_EXTENSIONS.TABLE_CELL, {
|
||||
background: null,
|
||||
})
|
||||
.run();
|
||||
onClose();
|
||||
break;
|
||||
default:
|
||||
// Other actions are handled by specific implementations
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
/**
|
||||
* SVG icon paths for table drag handle dropdowns
|
||||
*/
|
||||
|
||||
export const DRAG_HANDLE_ICONS = {
|
||||
// Toggle icons
|
||||
toggleRight: '<rect width="20" height="12" x="2" y="6" rx="6" ry="6"/><circle cx="16" cy="12" r="2"/>',
|
||||
|
||||
// Arrow icons
|
||||
arrowUp: '<path d="m5 12 7-7 7 7"/><path d="M12 19V5"/>',
|
||||
arrowDown: '<path d="M12 5v14"/><path d="m19 12-7 7-7-7"/>',
|
||||
arrowLeft: '<path d="m12 19-7-7 7-7"/><path d="M19 12H5"/>',
|
||||
arrowRight: '<path d="M5 12h14"/><path d="m12 5 7 7-7 7"/>',
|
||||
|
||||
// Action icons
|
||||
duplicate:
|
||||
'<rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/>',
|
||||
close: '<path d="M18 6 6 18"/><path d="m6 6 12 12"/>',
|
||||
trash:
|
||||
'<path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/>',
|
||||
|
||||
// Color icons
|
||||
palette:
|
||||
'<circle cx="13.5" cy="6.5" r=".5" fill="currentColor"/><circle cx="17.5" cy="10.5" r=".5" fill="currentColor"/><circle cx="8.5" cy="7.5" r=".5" fill="currentColor"/><circle cx="6.5" cy="12.5" r=".5" fill="currentColor"/><path d="M12 2C6.5 2 2 6.5 2 12s4.5 10 10 10c.926 0 1.648-.746 1.648-1.688 0-.437-.18-.835-.437-1.125-.29-.289-.438-.652-.438-1.125a1.64 1.64 0 0 1 1.668-1.668h1.996c3.051 0 5.555-2.503 5.555-5.554C21.965 6.012 17.461 2 12 2z"/>',
|
||||
chevronRight: '<path d="m9 18 6-6-6-6"/>',
|
||||
ban: '<circle cx="12" cy="12" r="10"/><path d="m4.9 4.9 14.2 14.2"/>',
|
||||
|
||||
// Ellipsis (drag handle button)
|
||||
ellipsis: '<circle cx="12" cy="12" r="1"/><circle cx="12" cy="5" r="1"/><circle cx="12" cy="19" r="1"/>',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Creates an SVG element (DOM element) with the given icon path
|
||||
*/
|
||||
export function createSvgElement(iconPath: string, className = "size-3"): SVGSVGElement {
|
||||
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
|
||||
svg.setAttribute("class", className);
|
||||
svg.setAttribute("xmlns", "http://www.w3.org/2000/svg");
|
||||
svg.setAttribute("width", "24");
|
||||
svg.setAttribute("height", "24");
|
||||
svg.setAttribute("viewBox", "0 0 24 24");
|
||||
svg.setAttribute("fill", "none");
|
||||
svg.setAttribute("stroke", "currentColor");
|
||||
svg.setAttribute("stroke-width", "2");
|
||||
svg.setAttribute("stroke-linecap", "round");
|
||||
svg.setAttribute("stroke-linejoin", "round");
|
||||
svg.innerHTML = iconPath;
|
||||
return svg;
|
||||
}
|
||||
@@ -1,458 +0,0 @@
|
||||
import { computePosition, flip, shift, autoUpdate } from "@floating-ui/dom";
|
||||
import type { Editor } from "@tiptap/core";
|
||||
import { TableMap } from "@tiptap/pm/tables";
|
||||
// constants
|
||||
import { CORE_EXTENSIONS } from "@/constants/extension";
|
||||
// icons
|
||||
import { DRAG_HANDLE_ICONS, createSvgElement } from "../icons";
|
||||
// dropdown
|
||||
import { createDropdownContent, handleDropdownAction } from "../dropdown-content";
|
||||
import type { DropdownOption } from "../dropdown-content";
|
||||
// extensions
|
||||
import {
|
||||
findTable,
|
||||
getTableHeightPx,
|
||||
getTableWidthPx,
|
||||
isCellSelection,
|
||||
selectRow,
|
||||
getSelectedRows,
|
||||
} from "@/extensions/table/table/utilities/helpers";
|
||||
// local imports
|
||||
import { moveSelectedRows, duplicateRows } from "../actions";
|
||||
import {
|
||||
DROP_MARKER_THICKNESS,
|
||||
getDropMarker,
|
||||
getRowDragMarker,
|
||||
hideDragMarker,
|
||||
hideDropMarker,
|
||||
updateRowDragMarker,
|
||||
updateRowDropMarker,
|
||||
} from "../marker-utils";
|
||||
import { updateCellContentVisibility } from "../utils";
|
||||
import { calculateRowDropIndex, constructRowDragPreview, getTableRowNodesInfo } from "./utils";
|
||||
|
||||
export type RowDragHandleConfig = {
|
||||
editor: Editor;
|
||||
row: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a vanilla JS row drag handle element with dropdown functionality
|
||||
*/
|
||||
export function createRowDragHandle(config: RowDragHandleConfig): {
|
||||
element: HTMLElement;
|
||||
destroy: () => void;
|
||||
} {
|
||||
const { editor, row } = config;
|
||||
|
||||
// Create container
|
||||
const container = document.createElement("div");
|
||||
container.className =
|
||||
"table-row-handle-container absolute z-20 top-0 left-0 flex justify-center items-center h-full -translate-x-1/2";
|
||||
|
||||
// Create button
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = "default-state";
|
||||
|
||||
// Create icon (Ellipsis lucide icon as SVG)
|
||||
const icon = createSvgElement(DRAG_HANDLE_ICONS.ellipsis, "size-4 text-primary");
|
||||
|
||||
button.appendChild(icon);
|
||||
container.appendChild(button);
|
||||
|
||||
// State for dropdown
|
||||
let isDropdownOpen = false;
|
||||
let dropdownElement: HTMLElement | null = null;
|
||||
let backdropElement: HTMLElement | null = null;
|
||||
let cleanupFloating: (() => void) | null = null;
|
||||
let backdropClickHandler: (() => void) | null = null;
|
||||
|
||||
// Track drag event listeners for cleanup
|
||||
let dragListeners: {
|
||||
mouseup?: (e: MouseEvent) => void;
|
||||
mousemove?: (e: MouseEvent) => void;
|
||||
} = {};
|
||||
|
||||
// Dropdown toggle function
|
||||
const toggleDropdown = () => {
|
||||
if (isDropdownOpen) {
|
||||
closeDropdown();
|
||||
} else {
|
||||
openDropdown();
|
||||
}
|
||||
};
|
||||
|
||||
const closeDropdown = () => {
|
||||
if (!isDropdownOpen) return;
|
||||
|
||||
isDropdownOpen = false;
|
||||
|
||||
// Reset button to default state
|
||||
button.className = "default-state";
|
||||
|
||||
// Remove dropdown and backdrop
|
||||
if (dropdownElement) {
|
||||
dropdownElement.remove();
|
||||
dropdownElement = null;
|
||||
}
|
||||
if (backdropElement) {
|
||||
// Remove backdrop listener before removing element
|
||||
if (backdropClickHandler) {
|
||||
backdropElement.removeEventListener("click", backdropClickHandler);
|
||||
backdropClickHandler = null;
|
||||
}
|
||||
backdropElement.remove();
|
||||
backdropElement = null;
|
||||
}
|
||||
|
||||
// Cleanup floating UI (this also removes keydown listener)
|
||||
if (cleanupFloating) {
|
||||
cleanupFloating();
|
||||
cleanupFloating = null;
|
||||
}
|
||||
|
||||
// Remove active dropdown extension
|
||||
setTimeout(() => {
|
||||
editor.commands.removeActiveDropbarExtension(CORE_EXTENSIONS.TABLE);
|
||||
}, 0);
|
||||
};
|
||||
|
||||
const openDropdown = () => {
|
||||
if (isDropdownOpen) return;
|
||||
|
||||
isDropdownOpen = true;
|
||||
|
||||
// Update button to open state
|
||||
button.className = "open-state";
|
||||
|
||||
// Add active dropdown extension
|
||||
editor.commands.addActiveDropbarExtension(CORE_EXTENSIONS.TABLE);
|
||||
|
||||
// Create backdrop
|
||||
backdropElement = document.createElement("div");
|
||||
backdropElement.style.position = "fixed";
|
||||
backdropElement.style.inset = "0";
|
||||
backdropElement.style.zIndex = "99";
|
||||
backdropClickHandler = closeDropdown;
|
||||
backdropElement.addEventListener("click", backdropClickHandler);
|
||||
document.body.appendChild(backdropElement);
|
||||
|
||||
// Create dropdown
|
||||
dropdownElement = document.createElement("div");
|
||||
dropdownElement.className =
|
||||
"max-h-[90vh] w-[12rem] overflow-y-auto rounded-md border-[0.5px] border-strong bg-surface-1 px-2 py-2.5 shadow-raised-200";
|
||||
dropdownElement.style.position = "fixed";
|
||||
dropdownElement.style.zIndex = "100";
|
||||
|
||||
// Create and append dropdown content
|
||||
const content = createDropdownContent(getDropdownOptions());
|
||||
dropdownElement.appendChild(content);
|
||||
|
||||
document.body.appendChild(dropdownElement);
|
||||
|
||||
// Attach dropdown event listeners
|
||||
attachDropdownEventListeners(dropdownElement);
|
||||
|
||||
// Setup floating UI positioning
|
||||
cleanupFloating = autoUpdate(button, dropdownElement, () => {
|
||||
void computePosition(button, dropdownElement!, {
|
||||
placement: "bottom-start",
|
||||
middleware: [
|
||||
flip({
|
||||
fallbackPlacements: ["top-start", "bottom-start", "top-end", "bottom-end"],
|
||||
}),
|
||||
shift({
|
||||
padding: 8,
|
||||
}),
|
||||
],
|
||||
}).then(({ x, y }) => {
|
||||
if (dropdownElement) {
|
||||
dropdownElement.style.left = `${x}px`;
|
||||
dropdownElement.style.top = `${y}px`;
|
||||
}
|
||||
return;
|
||||
});
|
||||
});
|
||||
|
||||
// Handle keyboard events
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
closeDropdown();
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
};
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
|
||||
// Store cleanup for this specific dropdown instance
|
||||
const originalCleanup = cleanupFloating;
|
||||
cleanupFloating = () => {
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
if (originalCleanup) originalCleanup();
|
||||
};
|
||||
};
|
||||
|
||||
const getDropdownOptions = (): DropdownOption[] => [
|
||||
{
|
||||
key: "toggle-header",
|
||||
label: "Header row",
|
||||
icon: DRAG_HANDLE_ICONS.toggleRight,
|
||||
showRightIcon: true,
|
||||
},
|
||||
{
|
||||
key: "insert-above",
|
||||
label: "Insert above",
|
||||
icon: DRAG_HANDLE_ICONS.arrowUp,
|
||||
showRightIcon: false,
|
||||
},
|
||||
{
|
||||
key: "insert-below",
|
||||
label: "Insert below",
|
||||
icon: DRAG_HANDLE_ICONS.arrowDown,
|
||||
showRightIcon: false,
|
||||
},
|
||||
{
|
||||
key: "duplicate",
|
||||
label: "Duplicate",
|
||||
icon: DRAG_HANDLE_ICONS.duplicate,
|
||||
showRightIcon: false,
|
||||
},
|
||||
{
|
||||
key: "clear-contents",
|
||||
label: "Clear contents",
|
||||
icon: DRAG_HANDLE_ICONS.close,
|
||||
showRightIcon: false,
|
||||
},
|
||||
{
|
||||
key: "delete",
|
||||
label: "Delete",
|
||||
icon: DRAG_HANDLE_ICONS.trash,
|
||||
showRightIcon: false,
|
||||
},
|
||||
];
|
||||
|
||||
const attachDropdownEventListeners = (dropdown: HTMLElement) => {
|
||||
const buttons = dropdown.querySelectorAll("button[data-action]");
|
||||
const colorPanel = dropdown.querySelector(".color-panel");
|
||||
const colorChevron = dropdown.querySelector(".color-chevron");
|
||||
|
||||
buttons.forEach((btn) => {
|
||||
const action = btn.getAttribute("data-action");
|
||||
if (!action) return;
|
||||
|
||||
btn.addEventListener("click", (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
// Handle common actions
|
||||
handleDropdownAction(action, editor, closeDropdown, colorPanel, colorChevron);
|
||||
|
||||
// Handle row-specific actions
|
||||
switch (action) {
|
||||
case "toggle-header":
|
||||
editor.chain().focus().toggleHeaderRow().run();
|
||||
closeDropdown();
|
||||
break;
|
||||
case "set-bg-color": {
|
||||
const color = btn.getAttribute("data-color");
|
||||
if (color) {
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.updateAttributes(CORE_EXTENSIONS.TABLE_CELL, {
|
||||
background: color,
|
||||
})
|
||||
.run();
|
||||
}
|
||||
closeDropdown();
|
||||
break;
|
||||
}
|
||||
case "insert-above":
|
||||
editor.chain().focus().addRowBefore().run();
|
||||
closeDropdown();
|
||||
break;
|
||||
case "insert-below":
|
||||
editor.chain().focus().addRowAfter().run();
|
||||
closeDropdown();
|
||||
break;
|
||||
case "duplicate": {
|
||||
const table = findTable(editor.state.selection);
|
||||
if (table) {
|
||||
const tableMap = TableMap.get(table.node);
|
||||
let tr = editor.state.tr;
|
||||
const selectedRows = getSelectedRows(editor.state.selection, tableMap);
|
||||
tr = duplicateRows(table, selectedRows, tr);
|
||||
editor.view.dispatch(tr);
|
||||
}
|
||||
closeDropdown();
|
||||
break;
|
||||
}
|
||||
case "clear-contents":
|
||||
editor.chain().focus().clearSelectedCells().run();
|
||||
closeDropdown();
|
||||
break;
|
||||
case "delete":
|
||||
editor.chain().focus().deleteRow().run();
|
||||
closeDropdown();
|
||||
break;
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
// Handle mousedown for dragging
|
||||
const handleMouseDown = (e: MouseEvent) => {
|
||||
// Prevent dropdown from opening during drag
|
||||
if (e.button !== 0) return; // Only left click
|
||||
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
|
||||
// Check if this is a click (will be determined by mouseup without much movement)
|
||||
const startX = e.clientX;
|
||||
const startY = e.clientY;
|
||||
let hasMoved = false;
|
||||
|
||||
// Clean up any existing drag listeners
|
||||
if (dragListeners.mouseup) {
|
||||
window.removeEventListener("mouseup", dragListeners.mouseup);
|
||||
}
|
||||
if (dragListeners.mousemove) {
|
||||
window.removeEventListener("mousemove", dragListeners.mousemove);
|
||||
}
|
||||
dragListeners = {};
|
||||
|
||||
const table = findTable(editor.state.selection);
|
||||
if (!table) return;
|
||||
|
||||
editor.view.dispatch(selectRow(table, row, editor.state.tr));
|
||||
|
||||
// Drag row logic
|
||||
const tableHeightPx = getTableHeightPx(table, editor);
|
||||
const rows = getTableRowNodesInfo(table, editor);
|
||||
|
||||
let dropIndex = row;
|
||||
const startTop = rows[row].top ?? 0;
|
||||
const startYPos = e.clientY;
|
||||
const tableElement = editor.view.nodeDOM(table.pos);
|
||||
|
||||
const dropMarker = tableElement instanceof HTMLElement ? getDropMarker(tableElement) : null;
|
||||
const dragMarker = tableElement instanceof HTMLElement ? getRowDragMarker(tableElement) : null;
|
||||
|
||||
const handleFinish = (): void => {
|
||||
// Clean up markers if they exist
|
||||
if (dropMarker && dragMarker) {
|
||||
hideDropMarker(dropMarker);
|
||||
hideDragMarker(dragMarker);
|
||||
}
|
||||
|
||||
if (isCellSelection(editor.state.selection)) {
|
||||
updateCellContentVisibility(editor, false);
|
||||
}
|
||||
|
||||
// Perform drag operation if user moved
|
||||
if (row !== dropIndex && hasMoved) {
|
||||
let tr = editor.state.tr;
|
||||
const selection = editor.state.selection;
|
||||
if (isCellSelection(selection)) {
|
||||
const table = findTable(selection);
|
||||
if (table) {
|
||||
tr = moveSelectedRows(editor, table, selection, dropIndex, tr);
|
||||
}
|
||||
}
|
||||
editor.view.dispatch(tr);
|
||||
}
|
||||
|
||||
window.removeEventListener("mouseup", handleFinish);
|
||||
window.removeEventListener("mousemove", handleMove);
|
||||
dragListeners.mouseup = undefined;
|
||||
dragListeners.mousemove = undefined;
|
||||
|
||||
// If it was just a click (no movement), toggle dropdown
|
||||
if (!hasMoved) {
|
||||
toggleDropdown();
|
||||
}
|
||||
};
|
||||
|
||||
let pseudoRow: HTMLElement | undefined;
|
||||
|
||||
const handleMove = (moveEvent: MouseEvent): void => {
|
||||
// Mark that we've moved
|
||||
const deltaX = Math.abs(moveEvent.clientX - startX);
|
||||
const deltaY = Math.abs(moveEvent.clientY - startY);
|
||||
if (deltaX > 3 || deltaY > 3) {
|
||||
hasMoved = true;
|
||||
}
|
||||
|
||||
// Calculate drop index
|
||||
const cursorTop = startTop + moveEvent.clientY - startYPos;
|
||||
dropIndex = calculateRowDropIndex(row, rows, cursorTop);
|
||||
|
||||
// Update visual markers if they exist
|
||||
if (dropMarker && dragMarker) {
|
||||
if (!pseudoRow) {
|
||||
pseudoRow = constructRowDragPreview(editor, editor.state.selection, table);
|
||||
const tableWidthPx = getTableWidthPx(table, editor);
|
||||
if (pseudoRow) {
|
||||
pseudoRow.style.width = `${tableWidthPx}px`;
|
||||
}
|
||||
}
|
||||
|
||||
const dragMarkerHeightPx = rows[row].height;
|
||||
const dragMarkerTopPx = Math.max(0, Math.min(cursorTop, tableHeightPx - dragMarkerHeightPx));
|
||||
const dropMarkerTopPx = dropIndex <= row ? rows[dropIndex].top : rows[dropIndex].top + rows[dropIndex].height;
|
||||
|
||||
updateRowDropMarker({
|
||||
element: dropMarker,
|
||||
top: dropMarkerTopPx - DROP_MARKER_THICKNESS / 2,
|
||||
height: DROP_MARKER_THICKNESS,
|
||||
});
|
||||
updateRowDragMarker({
|
||||
element: dragMarker,
|
||||
top: dragMarkerTopPx,
|
||||
height: dragMarkerHeightPx,
|
||||
pseudoRow,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
dragListeners.mouseup = handleFinish;
|
||||
dragListeners.mousemove = handleMove;
|
||||
window.addEventListener("mouseup", handleFinish);
|
||||
window.addEventListener("mousemove", handleMove);
|
||||
} catch (error) {
|
||||
console.error("Error in RowDragHandle:", error);
|
||||
handleFinish();
|
||||
}
|
||||
};
|
||||
|
||||
// Attach mousedown listener
|
||||
button.addEventListener("mousedown", handleMouseDown);
|
||||
|
||||
// Cleanup function
|
||||
const destroy = () => {
|
||||
// Close dropdown if open
|
||||
if (isDropdownOpen) {
|
||||
closeDropdown();
|
||||
}
|
||||
|
||||
// Remove drag listeners
|
||||
if (dragListeners.mouseup) {
|
||||
window.removeEventListener("mouseup", dragListeners.mouseup);
|
||||
}
|
||||
if (dragListeners.mousemove) {
|
||||
window.removeEventListener("mousemove", dragListeners.mousemove);
|
||||
}
|
||||
|
||||
// Remove mousedown listener
|
||||
button.removeEventListener("mousedown", handleMouseDown);
|
||||
|
||||
// Remove DOM elements
|
||||
container.remove();
|
||||
};
|
||||
|
||||
return {
|
||||
element: container,
|
||||
destroy,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
import {
|
||||
autoUpdate,
|
||||
flip,
|
||||
FloatingOverlay,
|
||||
FloatingPortal,
|
||||
shift,
|
||||
useClick,
|
||||
useDismiss,
|
||||
useFloating,
|
||||
useInteractions,
|
||||
useRole,
|
||||
} from "@floating-ui/react";
|
||||
import type { Editor } from "@tiptap/core";
|
||||
import { Ellipsis } from "lucide-react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
// plane imports
|
||||
import { cn } from "@plane/utils";
|
||||
// constants
|
||||
import { CORE_EXTENSIONS } from "@/constants/extension";
|
||||
// extensions
|
||||
import {
|
||||
findTable,
|
||||
getTableHeightPx,
|
||||
getTableWidthPx,
|
||||
isCellSelection,
|
||||
selectRow,
|
||||
} from "@/extensions/table/table/utilities/helpers";
|
||||
// local imports
|
||||
import { moveSelectedRows } from "../actions";
|
||||
import {
|
||||
DROP_MARKER_THICKNESS,
|
||||
getDropMarker,
|
||||
getRowDragMarker,
|
||||
hideDragMarker,
|
||||
hideDropMarker,
|
||||
updateRowDragMarker,
|
||||
updateRowDropMarker,
|
||||
} from "../marker-utils";
|
||||
import { updateCellContentVisibility } from "../utils";
|
||||
import { RowOptionsDropdown } from "./dropdown";
|
||||
import { calculateRowDropIndex, constructRowDragPreview, getTableRowNodesInfo } from "./utils";
|
||||
|
||||
export type RowDragHandleProps = {
|
||||
editor: Editor;
|
||||
row: number;
|
||||
};
|
||||
|
||||
export function RowDragHandle(props: RowDragHandleProps) {
|
||||
const { editor, row } = props;
|
||||
// states
|
||||
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
||||
// Track active event listeners for cleanup
|
||||
const activeListenersRef = useRef<{
|
||||
mouseup?: (e: MouseEvent) => void;
|
||||
mousemove?: (e: MouseEvent) => void;
|
||||
}>({});
|
||||
|
||||
// Cleanup window event listeners on unmount
|
||||
useEffect(() => {
|
||||
const listenersRef = activeListenersRef.current;
|
||||
return () => {
|
||||
// Remove any lingering window event listeners when component unmounts
|
||||
if (listenersRef.mouseup) {
|
||||
window.removeEventListener("mouseup", listenersRef.mouseup);
|
||||
}
|
||||
if (listenersRef.mousemove) {
|
||||
window.removeEventListener("mousemove", listenersRef.mousemove);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
// floating ui
|
||||
const { refs, floatingStyles, context } = useFloating({
|
||||
placement: "bottom-start",
|
||||
middleware: [
|
||||
flip({
|
||||
fallbackPlacements: ["top-start", "bottom-start", "top-end", "bottom-end"],
|
||||
}),
|
||||
shift({
|
||||
padding: 8,
|
||||
}),
|
||||
],
|
||||
open: isDropdownOpen,
|
||||
onOpenChange: (open) => {
|
||||
setIsDropdownOpen(open);
|
||||
if (open) {
|
||||
editor.commands.addActiveDropbarExtension(CORE_EXTENSIONS.TABLE);
|
||||
} else {
|
||||
setTimeout(() => {
|
||||
editor.commands.removeActiveDropbarExtension(CORE_EXTENSIONS.TABLE);
|
||||
}, 0);
|
||||
}
|
||||
},
|
||||
whileElementsMounted: autoUpdate,
|
||||
});
|
||||
const click = useClick(context);
|
||||
const dismiss = useDismiss(context);
|
||||
const role = useRole(context);
|
||||
const { getReferenceProps, getFloatingProps } = useInteractions([dismiss, click, role]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDropdownOpen) return;
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
context.onOpenChange(false);
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
};
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, [isDropdownOpen, context]);
|
||||
|
||||
const handleMouseDown = useCallback(
|
||||
(e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
|
||||
// Prevent multiple simultaneous drag operations
|
||||
// If there are already listeners attached, remove them first
|
||||
if (activeListenersRef.current.mouseup) {
|
||||
window.removeEventListener("mouseup", activeListenersRef.current.mouseup);
|
||||
}
|
||||
if (activeListenersRef.current.mousemove) {
|
||||
window.removeEventListener("mousemove", activeListenersRef.current.mousemove);
|
||||
}
|
||||
activeListenersRef.current.mouseup = undefined;
|
||||
activeListenersRef.current.mousemove = undefined;
|
||||
|
||||
const table = findTable(editor.state.selection);
|
||||
if (!table) return;
|
||||
|
||||
editor.view.dispatch(selectRow(table, row, editor.state.tr));
|
||||
|
||||
// drag row
|
||||
const tableHeightPx = getTableHeightPx(table, editor);
|
||||
const rows = getTableRowNodesInfo(table, editor);
|
||||
|
||||
let dropIndex = row;
|
||||
const startTop = rows[row].top ?? 0;
|
||||
const startY = e.clientY;
|
||||
const tableElement = editor.view.nodeDOM(table.pos);
|
||||
|
||||
const dropMarker = tableElement instanceof HTMLElement ? getDropMarker(tableElement) : null;
|
||||
const dragMarker = tableElement instanceof HTMLElement ? getRowDragMarker(tableElement) : null;
|
||||
|
||||
const handleFinish = (): void => {
|
||||
if (!dropMarker || !dragMarker) return;
|
||||
hideDropMarker(dropMarker);
|
||||
hideDragMarker(dragMarker);
|
||||
|
||||
if (isCellSelection(editor.state.selection)) {
|
||||
updateCellContentVisibility(editor, false);
|
||||
}
|
||||
|
||||
if (row !== dropIndex) {
|
||||
let tr = editor.state.tr;
|
||||
const selection = editor.state.selection;
|
||||
if (isCellSelection(selection)) {
|
||||
const table = findTable(selection);
|
||||
if (table) {
|
||||
tr = moveSelectedRows(editor, table, selection, dropIndex, tr);
|
||||
}
|
||||
}
|
||||
editor.view.dispatch(tr);
|
||||
}
|
||||
window.removeEventListener("mouseup", handleFinish);
|
||||
window.removeEventListener("mousemove", handleMove);
|
||||
// Clear the ref
|
||||
activeListenersRef.current.mouseup = undefined;
|
||||
activeListenersRef.current.mousemove = undefined;
|
||||
};
|
||||
|
||||
let pseudoRow: HTMLElement | undefined;
|
||||
|
||||
const handleMove = (moveEvent: MouseEvent): void => {
|
||||
if (!dropMarker || !dragMarker) return;
|
||||
const cursorTop = startTop + moveEvent.clientY - startY;
|
||||
dropIndex = calculateRowDropIndex(row, rows, cursorTop);
|
||||
|
||||
if (!pseudoRow) {
|
||||
pseudoRow = constructRowDragPreview(editor, editor.state.selection, table);
|
||||
const tableWidthPx = getTableWidthPx(table, editor);
|
||||
if (pseudoRow) {
|
||||
pseudoRow.style.width = `${tableWidthPx}px`;
|
||||
}
|
||||
}
|
||||
|
||||
const dragMarkerHeightPx = rows[row].height;
|
||||
const dragMarkerTopPx = Math.max(0, Math.min(cursorTop, tableHeightPx - dragMarkerHeightPx));
|
||||
const dropMarkerTopPx = dropIndex <= row ? rows[dropIndex].top : rows[dropIndex].top + rows[dropIndex].height;
|
||||
|
||||
updateRowDropMarker({
|
||||
element: dropMarker,
|
||||
top: dropMarkerTopPx - DROP_MARKER_THICKNESS / 2,
|
||||
height: DROP_MARKER_THICKNESS,
|
||||
});
|
||||
updateRowDragMarker({
|
||||
element: dragMarker,
|
||||
top: dragMarkerTopPx,
|
||||
height: dragMarkerHeightPx,
|
||||
pseudoRow,
|
||||
});
|
||||
};
|
||||
|
||||
try {
|
||||
// Store references for cleanup
|
||||
activeListenersRef.current.mouseup = handleFinish;
|
||||
activeListenersRef.current.mousemove = handleMove;
|
||||
window.addEventListener("mouseup", handleFinish);
|
||||
window.addEventListener("mousemove", handleMove);
|
||||
} catch (error) {
|
||||
console.error("Error in RowDragHandle:", error);
|
||||
handleFinish();
|
||||
}
|
||||
},
|
||||
[editor, row]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="table-row-handle-container absolute z-20 top-0 left-0 flex justify-center items-center h-full -translate-x-1/2">
|
||||
<button
|
||||
ref={refs.setReference}
|
||||
{...getReferenceProps()}
|
||||
type="button"
|
||||
onMouseDown={handleMouseDown}
|
||||
className={cn("py-1 bg-layer-1 border border-strong-1 rounded-sm outline-none transition-all duration-200", {
|
||||
"!opacity-100 bg-accent-primary border-accent-strong": isDropdownOpen,
|
||||
"hover:bg-layer-1-hover": !isDropdownOpen,
|
||||
})}
|
||||
>
|
||||
<Ellipsis className="size-4 text-primary rotate-90" />
|
||||
</button>
|
||||
</div>
|
||||
{isDropdownOpen && (
|
||||
<FloatingPortal>
|
||||
{/* Backdrop */}
|
||||
<FloatingOverlay
|
||||
style={{
|
||||
zIndex: 99,
|
||||
}}
|
||||
lockScroll
|
||||
/>
|
||||
<div
|
||||
className="max-h-[90vh] w-[12rem] overflow-y-auto rounded-md border-[0.5px] border-strong bg-surface-1 px-2 py-2.5 shadow-raised-200"
|
||||
ref={refs.setFloating}
|
||||
{...getFloatingProps()}
|
||||
style={{
|
||||
...floatingStyles,
|
||||
zIndex: 100,
|
||||
}}
|
||||
>
|
||||
<RowOptionsDropdown editor={editor} onClose={() => context.onOpenChange(false)} />
|
||||
</div>
|
||||
</FloatingPortal>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import type { Editor } from "@tiptap/core";
|
||||
import { Plugin, PluginKey } from "@tiptap/pm/state";
|
||||
import { TableMap } from "@tiptap/pm/tables";
|
||||
import { Decoration, DecorationSet } from "@tiptap/pm/view";
|
||||
import { ReactRenderer } from "@tiptap/react";
|
||||
// extensions
|
||||
import {
|
||||
findTable,
|
||||
@@ -9,20 +10,16 @@ import {
|
||||
haveTableRelatedChanges,
|
||||
} from "@/extensions/table/table/utilities/helpers";
|
||||
// local imports
|
||||
import { createRowDragHandle } from "./drag-handle";
|
||||
|
||||
type DragHandleInstance = {
|
||||
element: HTMLElement;
|
||||
destroy: () => void;
|
||||
};
|
||||
import type { RowDragHandleProps } from "./drag-handle";
|
||||
import { RowDragHandle } from "./drag-handle";
|
||||
|
||||
type TableRowDragHandlePluginState = {
|
||||
decorations?: DecorationSet;
|
||||
// track table structure to detect changes
|
||||
tableHeight?: number;
|
||||
tableNodePos?: number;
|
||||
// track drag handle instances for cleanup
|
||||
dragHandles?: DragHandleInstance[];
|
||||
// track renderers for cleanup
|
||||
renderers?: ReactRenderer[];
|
||||
};
|
||||
|
||||
const TABLE_ROW_DRAG_HANDLE_PLUGIN_KEY = new PluginKey("tableRowDragHandlePlugin");
|
||||
@@ -63,40 +60,43 @@ export const TableRowDragHandlePlugin = (editor: Editor): Plugin<TableRowDragHan
|
||||
decorations: mapped,
|
||||
tableHeight: tableMap.height,
|
||||
tableNodePos: table.pos,
|
||||
dragHandles: prev.dragHandles,
|
||||
renderers: prev.renderers,
|
||||
};
|
||||
}
|
||||
|
||||
// Clean up old drag handles before creating new ones
|
||||
prev.dragHandles?.forEach((handle) => {
|
||||
// Clean up old renderers before creating new ones
|
||||
prev.renderers?.forEach((renderer) => {
|
||||
try {
|
||||
handle.destroy();
|
||||
renderer.destroy();
|
||||
} catch (error) {
|
||||
console.error("Error destroying drag handle:", error);
|
||||
console.error("Error destroying renderer:", error);
|
||||
}
|
||||
});
|
||||
|
||||
// recreate all decorations
|
||||
const decorations: Decoration[] = [];
|
||||
const dragHandles: DragHandleInstance[] = [];
|
||||
const renderers: ReactRenderer[] = [];
|
||||
|
||||
for (let row = 0; row < tableMap.height; row++) {
|
||||
const pos = getTableCellWidgetDecorationPos(table, tableMap, row * tableMap.width);
|
||||
|
||||
const dragHandle = createRowDragHandle({
|
||||
const dragHandleComponent = new ReactRenderer(RowDragHandle, {
|
||||
props: {
|
||||
editor,
|
||||
row,
|
||||
} satisfies RowDragHandleProps,
|
||||
editor,
|
||||
row,
|
||||
});
|
||||
|
||||
dragHandles.push(dragHandle);
|
||||
decorations.push(Decoration.widget(pos, () => dragHandle.element));
|
||||
renderers.push(dragHandleComponent);
|
||||
decorations.push(Decoration.widget(pos, () => dragHandleComponent.element));
|
||||
}
|
||||
|
||||
return {
|
||||
decorations: DecorationSet.create(newState.doc, decorations),
|
||||
tableHeight: tableMap.height,
|
||||
tableNodePos: table.pos,
|
||||
dragHandles,
|
||||
renderers,
|
||||
};
|
||||
},
|
||||
},
|
||||
@@ -107,15 +107,15 @@ export const TableRowDragHandlePlugin = (editor: Editor): Plugin<TableRowDragHan
|
||||
},
|
||||
},
|
||||
destroy() {
|
||||
// Clean up all drag handles when plugin is destroyed
|
||||
// Clean up all renderers when plugin is destroyed
|
||||
const state =
|
||||
editor.state &&
|
||||
(TABLE_ROW_DRAG_HANDLE_PLUGIN_KEY.getState(editor.state) as TableRowDragHandlePluginState | undefined);
|
||||
state?.dragHandles?.forEach((handle: DragHandleInstance) => {
|
||||
state?.renderers?.forEach((renderer: ReactRenderer) => {
|
||||
try {
|
||||
handle.destroy();
|
||||
renderer.destroy();
|
||||
} catch (error) {
|
||||
console.error("Error destroying drag handle:", error);
|
||||
console.error("Error destroying renderer:", error);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
@@ -72,18 +72,6 @@ export const TableInsertPlugin = (editor: Editor): Plugin => {
|
||||
});
|
||||
};
|
||||
|
||||
let updateScheduled = false;
|
||||
|
||||
const scheduleUpdate = () => {
|
||||
if (updateScheduled) return;
|
||||
updateScheduled = true;
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
updateScheduled = false;
|
||||
updateAllTables();
|
||||
});
|
||||
};
|
||||
|
||||
return new Plugin({
|
||||
key: TABLE_INSERT_PLUGIN_KEY,
|
||||
|
||||
@@ -92,9 +80,9 @@ export const TableInsertPlugin = (editor: Editor): Plugin => {
|
||||
|
||||
return {
|
||||
update(view, prevState) {
|
||||
// Debounce updates using RAF to batch multiple changes
|
||||
// Update when document changes
|
||||
if (!prevState.doc.eq(view.state.doc)) {
|
||||
scheduleUpdate();
|
||||
updateAllTables();
|
||||
}
|
||||
},
|
||||
destroy() {
|
||||
|
||||
@@ -219,11 +219,16 @@ export const createRowInsertButton = (editor: Editor, tableInfo: TableInfo): HTM
|
||||
|
||||
export const findAllTables = (editor: Editor): TableInfo[] => {
|
||||
const tables: TableInfo[] = [];
|
||||
const tableElements = editor.view.dom.querySelectorAll("table");
|
||||
|
||||
// Iterate through document to look for tables
|
||||
editor.state.doc.descendants((node, pos) => {
|
||||
if (node.type.spec.tableRole === "table") {
|
||||
try {
|
||||
tableElements.forEach((tableElement) => {
|
||||
// Find the table's ProseMirror position
|
||||
let tablePos = -1;
|
||||
let tableNode: ProseMirrorNode | null = null;
|
||||
|
||||
// Walk through the document to find matching table nodes
|
||||
editor.state.doc.descendants((node, pos) => {
|
||||
if (node.type.spec.tableRole === "table") {
|
||||
const domAtPos = editor.view.domAtPos(pos + 1);
|
||||
let domTable = domAtPos.node;
|
||||
|
||||
@@ -236,17 +241,20 @@ export const findAllTables = (editor: Editor): TableInfo[] => {
|
||||
domTable = domTable.parentNode;
|
||||
}
|
||||
|
||||
if (domTable instanceof HTMLElement && domTable.tagName === "TABLE") {
|
||||
tables.push({
|
||||
tableElement: domTable,
|
||||
tableNode: node,
|
||||
tablePos: pos,
|
||||
});
|
||||
if (domTable === tableElement) {
|
||||
tablePos = pos;
|
||||
tableNode = node;
|
||||
return false; // Stop iteration
|
||||
}
|
||||
} catch (error) {
|
||||
// Skip tables that fail to resolve
|
||||
console.error("Error finding table:", error);
|
||||
}
|
||||
});
|
||||
|
||||
if (tablePos !== -1 && tableNode) {
|
||||
tables.push({
|
||||
tableElement,
|
||||
tableNode,
|
||||
tablePos,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -57,31 +57,7 @@
|
||||
.table-col-handle-container,
|
||||
.table-row-handle-container {
|
||||
& > button {
|
||||
@apply opacity-0 rounded-sm outline-none;
|
||||
|
||||
&.default-state {
|
||||
@apply bg-layer-1 hover:bg-layer-1-hover border border-strong-1;
|
||||
}
|
||||
|
||||
&.open-state {
|
||||
@apply opacity-100! bg-accent-primary border-accent-strong;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.table-col-handle-container {
|
||||
& > button {
|
||||
@apply px-1;
|
||||
|
||||
svg {
|
||||
@apply rotate-90;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.table-row-handle-container {
|
||||
& > button {
|
||||
@apply py-1;
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -184,7 +160,7 @@
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
outline: none;
|
||||
z-index: 9;
|
||||
z-index: 10;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
|
||||
@@ -321,9 +321,9 @@
|
||||
--txt-link-primary-hover: var(--brand-900);
|
||||
--txt-link-secondary: var(--neutral-900);
|
||||
/* Label colors */
|
||||
--label-indigo-bg: var(--extended-color-indigo-50);
|
||||
--label-indigo-bg: var(--extended-color-indigo-100);
|
||||
--label-indigo-bg-strong: var(--extended-color-indigo-700);
|
||||
--label-indigo-hover: var(--extended-color-indigo-100);
|
||||
--label-indigo-hover: var(--extended-color-indigo-300);
|
||||
--label-indigo-icon: var(--extended-color-indigo-700);
|
||||
--label-indigo-text: var(--extended-color-indigo-700);
|
||||
--label-indigo-border: var(--extended-color-indigo-700);
|
||||
@@ -331,7 +331,7 @@
|
||||
--label-emerald-bg: var(--extended-color-emerald-50);
|
||||
--label-emerald-bg-strong: var(--extended-color-emerald-600);
|
||||
--label-emerald-hover: var(--extended-color-emerald-200);
|
||||
--label-emerald-icon: var(--extended-color-emerald-800);
|
||||
--label-emerald-icon: var(--extended-color-emerald-600);
|
||||
--label-emerald-text: var(--extended-color-emerald-800);
|
||||
--label-emerald-border: var(--extended-color-emerald-800);
|
||||
--label-emerald-focus: var(--extended-color-emerald-700);
|
||||
@@ -352,31 +352,10 @@
|
||||
--label-yellow-bg: var(--extended-color-yellow-50);
|
||||
--label-yellow-bg-strong: var(--extended-color-yellow-600);
|
||||
--label-yellow-hover: var(--extended-color-yellow-100);
|
||||
--label-yellow-icon: var(--extended-color-yellow-700);
|
||||
--label-yellow-text: var(--extended-color-yellow-700);
|
||||
--label-yellow-icon: var(--extended-color-yellow-600);
|
||||
--label-yellow-text: var(--extended-color-yellow-600);
|
||||
--label-yellow-border: var(--extended-color-yellow-600);
|
||||
--label-yellow-focus: var(--extended-color-yellow-400);
|
||||
--label-orange-bg: var(--extended-color-orange-50);
|
||||
--label-orange-bg-strong: var(--extended-color-orange-600);
|
||||
--label-orange-hover: var(--extended-color-orange-100);
|
||||
--label-orange-icon: var(--extended-color-orange-600);
|
||||
--label-orange-text: var(--extended-color-orange-600);
|
||||
--label-orange-border: var(--extended-color-orange-600);
|
||||
--label-orange-focus: var(--extended-color-orange-300);
|
||||
--label-pink-bg: var(--extended-color-pink-50);
|
||||
--label-pink-bg-strong: var(--extended-color-pink-600);
|
||||
--label-pink-hover: var(--extended-color-pink-100);
|
||||
--label-pink-icon: var(--extended-color-pink-600);
|
||||
--label-pink-text: var(--extended-color-pink-600);
|
||||
--label-pink-border: var(--extended-color-pink-600);
|
||||
--label-pink-focus: var(--extended-color-pink-400);
|
||||
--label-purple-bg: var(--extended-color-purple-50);
|
||||
--label-purple-bg-strong: var(--extended-color-purple-500);
|
||||
--label-purple-hover: var(--extended-color-purple-100);
|
||||
--label-purple-icon: var(--extended-color-purple-500);
|
||||
--label-purple-text: var(--extended-color-purple-600);
|
||||
--label-purple-border: var(--extended-color-purple-600);
|
||||
--label-purple-focus: var(--extended-color-purple-300);
|
||||
/* Illustration colors */
|
||||
--illustration-fill-primary: var(--neutral-white);
|
||||
--illustration-fill-secondary: var(--neutral-200);
|
||||
@@ -571,62 +550,41 @@
|
||||
--txt-link-primary-hover: var(--brand-700);
|
||||
--txt-link-secondary: var(--neutral-1100);
|
||||
/* Label colors */
|
||||
--label-indigo-bg: var(--extended-color-indigo-900);
|
||||
--label-indigo-bg: var(--extended-color-indigo-800);
|
||||
--label-indigo-bg-strong: var(--extended-color-indigo-500);
|
||||
--label-indigo-hover: var(--extended-color-indigo-800);
|
||||
--label-indigo-icon: var(--extended-color-indigo-400);
|
||||
--label-indigo-text: var(--extended-color-indigo-400);
|
||||
--label-indigo-border: var(--extended-color-indigo-400);
|
||||
--label-indigo-hover: var(--extended-color-indigo-700);
|
||||
--label-indigo-icon: var(--extended-color-indigo-500);
|
||||
--label-indigo-text: var(--extended-color-indigo-500);
|
||||
--label-indigo-border: var(--extended-color-indigo-500);
|
||||
--label-indigo-focus: var(--extended-color-indigo-400);
|
||||
--label-emerald-bg: var(--extended-color-emerald-950);
|
||||
--label-emerald-bg: var(--extended-color-emerald-700);
|
||||
--label-emerald-bg-strong: var(--extended-color-emerald-600);
|
||||
--label-emerald-hover: var(--extended-color-emerald-900);
|
||||
--label-emerald-icon: var(--extended-color-emerald-400);
|
||||
--label-emerald-hover: var(--extended-color-emerald-800);
|
||||
--label-emerald-icon: var(--extended-color-emerald-600);
|
||||
--label-emerald-text: var(--extended-color-emerald-400);
|
||||
--label-emerald-border: var(--extended-color-emerald-400);
|
||||
--label-emerald-focus: var(--extended-color-emerald-700);
|
||||
--label-grey-bg: var(--extended-color-grey-900);
|
||||
--label-grey-bg: var(--extended-color-grey-800);
|
||||
--label-grey-bg-strong: var(--extended-color-grey-500);
|
||||
--label-grey-hover: var(--extended-color-grey-800);
|
||||
--label-grey-icon: var(--extended-color-grey-400);
|
||||
--label-grey-text: var(--extended-color-grey-400);
|
||||
--label-grey-border: var(--extended-color-grey-400);
|
||||
--label-grey-hover: var(--extended-color-grey-700);
|
||||
--label-grey-icon: var(--extended-color-grey-500);
|
||||
--label-grey-text: var(--extended-color-grey-500);
|
||||
--label-grey-border: var(--extended-color-grey-500);
|
||||
--label-grey-focus: var(--extended-color-grey-400);
|
||||
--label-crimson-bg: var(--extended-color-crimson-950);
|
||||
--label-crimson-bg: var(--extended-color-crimson-800);
|
||||
--label-crimson-bg-strong: var(--extended-color-crimson-500);
|
||||
--label-crimson-hover: var(--extended-color-crimson-900);
|
||||
--label-crimson-icon: var(--extended-color-crimson-400);
|
||||
--label-crimson-text: var(--extended-color-crimson-400);
|
||||
--label-crimson-border: var(--extended-color-crimson-400);
|
||||
--label-crimson-hover: var(--extended-color-crimson-700);
|
||||
--label-crimson-icon: var(--extended-color-crimson-500);
|
||||
--label-crimson-text: var(--extended-color-crimson-500);
|
||||
--label-crimson-border: var(--extended-color-crimson-500);
|
||||
--label-crimson-focus: var(--extended-color-crimson-400);
|
||||
--label-yellow-bg: var(--extended-color-yellow-950);
|
||||
--label-yellow-bg: var(--extended-color-yellow-900);
|
||||
--label-yellow-bg-strong: var(--extended-color-yellow-500);
|
||||
--label-yellow-hover: var(--extended-color-yellow-900);
|
||||
--label-yellow-hover: var(--extended-color-yellow-800);
|
||||
--label-yellow-icon: var(--extended-color-yellow-500);
|
||||
--label-yellow-text: var(--extended-color-yellow-500);
|
||||
--label-yellow-border: var(--extended-color-yellow-500);
|
||||
--label-yellow-focus: var(--extended-color-yellow-400);
|
||||
--label-orange-bg: var(--extended-color-orange-950);
|
||||
--label-orange-bg-strong: var(--extended-color-orange-400);
|
||||
--label-orange-hover: var(--extended-color-orange-900);
|
||||
--label-orange-icon: var(--extended-color-orange-300);
|
||||
--label-orange-text: var(--extended-color-orange-300);
|
||||
--label-orange-border: var(--extended-color-orange-300);
|
||||
--label-orange-focus: var(--extended-color-orange-300);
|
||||
--label-pink-bg: var(--extended-color-pink-900);
|
||||
--label-pink-bg-strong: var(--extended-color-pink-500);
|
||||
--label-pink-hover: var(--extended-color-pink-800);
|
||||
--label-pink-icon: var(--extended-color-pink-400);
|
||||
--label-pink-text: var(--extended-color-pink-400);
|
||||
--label-pink-border: var(--extended-color-pink-400);
|
||||
--label-pink-focus: var(--extended-color-pink-400);
|
||||
--label-purple-bg: var(--extended-color-purple-900);
|
||||
--label-purple-bg-strong: var(--extended-color-purple-400);
|
||||
--label-purple-hover: var(--extended-color-purple-800);
|
||||
--label-purple-icon: var(--extended-color-purple-400);
|
||||
--label-purple-text: var(--extended-color-purple-400);
|
||||
--label-purple-border: var(--extended-color-purple-400);
|
||||
--label-purple-focus: var(--extended-color-purple-300);
|
||||
/* Illustration colors */
|
||||
--illustration-fill-primary: var(--neutral-400);
|
||||
--illustration-fill-secondary: var(--neutral-500);
|
||||
@@ -1101,42 +1059,42 @@
|
||||
--text-h4-bold--letter-spacing: var(--tracking-default);
|
||||
--text-h4-bold--font-weight: var(--font-weight-bold);
|
||||
|
||||
--text-h5-regular: var(--text-18);
|
||||
--text-h5-regular: var(--text-16);
|
||||
--text-h5-regular--line-height: 1.2;
|
||||
--text-h5-regular--letter-spacing: var(--tracking-default);
|
||||
--text-h5-regular--font-weight: var(--font-weight-regular);
|
||||
|
||||
--text-h5-medium: var(--text-18);
|
||||
--text-h5-medium: var(--text-16);
|
||||
--text-h5-medium--line-height: 1.2;
|
||||
--text-h5-medium--letter-spacing: var(--tracking-default);
|
||||
--text-h5-medium--font-weight: var(--font-weight-medium);
|
||||
|
||||
--text-h5-semibold: var(--text-18);
|
||||
--text-h5-semibold: var(--text-16);
|
||||
--text-h5-semibold--line-height: 1.2;
|
||||
--text-h5-semibold--letter-spacing: var(--tracking-default);
|
||||
--text-h5-semibold--font-weight: var(--font-weight-semibold);
|
||||
|
||||
--text-h5-bold: var(--text-18);
|
||||
--text-h5-bold: var(--text-16);
|
||||
--text-h5-bold--line-height: 1.2;
|
||||
--text-h5-bold--letter-spacing: var(--tracking-default);
|
||||
--text-h5-bold--font-weight: var(--font-weight-bold);
|
||||
|
||||
--text-h6-regular: var(--text-16);
|
||||
--text-h6-regular: var(--text-14);
|
||||
--text-h6-regular--line-height: 1.2;
|
||||
--text-h6-regular--letter-spacing: var(--tracking-default);
|
||||
--text-h6-regular--font-weight: var(--font-weight-regular);
|
||||
|
||||
--text-h6-medium: var(--text-16);
|
||||
--text-h6-medium: var(--text-14);
|
||||
--text-h6-medium--line-height: 1.2;
|
||||
--text-h6-medium--letter-spacing: var(--tracking-default);
|
||||
--text-h6-medium--font-weight: var(--font-weight-medium);
|
||||
|
||||
--text-h6-semibold: var(--text-16);
|
||||
--text-h6-semibold: var(--text-14);
|
||||
--text-h6-semibold--line-height: 1.2;
|
||||
--text-h6-semibold--letter-spacing: var(--tracking-default);
|
||||
--text-h6-semibold--font-weight: var(--font-weight-semibold);
|
||||
|
||||
--text-h6-bold: var(--text-16);
|
||||
--text-h6-bold: var(--text-14);
|
||||
--text-h6-bold--line-height: 1.2;
|
||||
--text-h6-bold--letter-spacing: var(--tracking-default);
|
||||
--text-h6-bold--font-weight: var(--font-weight-bold);
|
||||
|
||||
@@ -36,6 +36,7 @@ export * from "./reaction";
|
||||
export * from "./intake";
|
||||
export * from "./rich-filters";
|
||||
export * from "./search";
|
||||
export * from "./settings";
|
||||
export * from "./state";
|
||||
export * from "./stickies";
|
||||
export * from "./timezone";
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export type TProfileSettingsTabs = "general" | "preferences" | "activity" | "notifications" | "security" | "api-tokens";
|
||||
Generated
+73
-41
@@ -82,8 +82,11 @@ catalogs:
|
||||
specifier: 18.3.1
|
||||
version: 18.3.1
|
||||
react-router:
|
||||
specifier: 7.12.0
|
||||
version: 7.12.0
|
||||
specifier: 7.9.5
|
||||
version: 7.9.5
|
||||
react-router-dom:
|
||||
specifier: 7.9.5
|
||||
version: 7.9.5
|
||||
swr:
|
||||
specifier: 2.2.4
|
||||
version: 2.2.4
|
||||
@@ -112,7 +115,6 @@ overrides:
|
||||
'@types/express': 4.17.23
|
||||
typescript: 5.8.3
|
||||
vite: 7.1.11
|
||||
qs: 6.14.1
|
||||
|
||||
importers:
|
||||
|
||||
@@ -225,10 +227,10 @@ importers:
|
||||
version: link:../../packages/utils
|
||||
'@react-router/node':
|
||||
specifier: 'catalog:'
|
||||
version: 7.9.5(react-router@7.12.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(typescript@5.8.3)
|
||||
version: 7.9.5(react-router@7.9.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(typescript@5.8.3)
|
||||
'@sentry/react-router':
|
||||
specifier: 'catalog:'
|
||||
version: 10.27.0(@react-router/node@7.9.5(react-router@7.12.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(typescript@5.8.3))(react-router@7.12.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)
|
||||
version: 10.27.0(@react-router/node@7.9.5(react-router@7.9.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(typescript@5.8.3))(react-router@7.9.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)
|
||||
'@tanstack/react-virtual':
|
||||
specifier: ^3.13.12
|
||||
version: 3.13.12(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
@@ -267,7 +269,10 @@ importers:
|
||||
version: 7.51.5(react@18.3.1)
|
||||
react-router:
|
||||
specifier: 'catalog:'
|
||||
version: 7.12.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
version: 7.9.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
react-router-dom:
|
||||
specifier: 'catalog:'
|
||||
version: 7.9.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
serve:
|
||||
specifier: 14.2.5
|
||||
version: 14.2.5
|
||||
@@ -289,7 +294,7 @@ importers:
|
||||
version: link:../../packages/typescript-config
|
||||
'@react-router/dev':
|
||||
specifier: 'catalog:'
|
||||
version: 7.9.5(@react-router/serve@7.9.5(react-router@7.12.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(typescript@5.8.3))(@types/node@22.12.0)(babel-plugin-macros@3.1.0)(jiti@2.6.1)(lightningcss@1.30.2)(react-router@7.12.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(terser@5.43.1)(typescript@5.8.3)(vite@7.1.11(@types/node@22.12.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.43.1)(yaml@2.8.1))(yaml@2.8.1)
|
||||
version: 7.9.5(@react-router/serve@7.9.5(react-router@7.9.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(typescript@5.8.3))(@types/node@22.12.0)(babel-plugin-macros@3.1.0)(jiti@2.6.1)(lightningcss@1.30.2)(react-router@7.9.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(terser@5.43.1)(typescript@5.8.3)(vite@7.1.11(@types/node@22.12.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.43.1)(yaml@2.8.1))(yaml@2.8.1)
|
||||
'@types/lodash-es':
|
||||
specifier: 'catalog:'
|
||||
version: 4.17.12
|
||||
@@ -470,13 +475,13 @@ importers:
|
||||
version: 2.11.8
|
||||
'@react-router/node':
|
||||
specifier: 'catalog:'
|
||||
version: 7.9.5(react-router@7.12.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(typescript@5.8.3)
|
||||
version: 7.9.5(react-router@7.9.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(typescript@5.8.3)
|
||||
'@react-router/serve':
|
||||
specifier: 'catalog:'
|
||||
version: 7.9.5(react-router@7.12.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(typescript@5.8.3)
|
||||
version: 7.9.5(react-router@7.9.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(typescript@5.8.3)
|
||||
'@sentry/react-router':
|
||||
specifier: 'catalog:'
|
||||
version: 10.27.0(@react-router/node@7.9.5(react-router@7.12.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(typescript@5.8.3))(react-router@7.12.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)
|
||||
version: 10.27.0(@react-router/node@7.9.5(react-router@7.9.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(typescript@5.8.3))(react-router@7.9.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)
|
||||
axios:
|
||||
specifier: 'catalog:'
|
||||
version: 1.12.0
|
||||
@@ -524,7 +529,10 @@ importers:
|
||||
version: 2.3.0(@popperjs/core@2.11.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
react-router:
|
||||
specifier: 'catalog:'
|
||||
version: 7.12.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
version: 7.9.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
react-router-dom:
|
||||
specifier: 'catalog:'
|
||||
version: 7.9.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
swr:
|
||||
specifier: 'catalog:'
|
||||
version: 2.2.4(react@18.3.1)
|
||||
@@ -543,7 +551,7 @@ importers:
|
||||
version: link:../../packages/typescript-config
|
||||
'@react-router/dev':
|
||||
specifier: 'catalog:'
|
||||
version: 7.9.5(@react-router/serve@7.9.5(react-router@7.12.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(typescript@5.8.3))(@types/node@22.12.0)(babel-plugin-macros@3.1.0)(jiti@2.6.1)(lightningcss@1.30.2)(react-router@7.12.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(terser@5.43.1)(typescript@5.8.3)(vite@7.1.11(@types/node@22.12.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.43.1)(yaml@2.8.1))(yaml@2.8.1)
|
||||
version: 7.9.5(@react-router/serve@7.9.5(react-router@7.9.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(typescript@5.8.3))(@types/node@22.12.0)(babel-plugin-macros@3.1.0)(jiti@2.6.1)(lightningcss@1.30.2)(react-router@7.9.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(terser@5.43.1)(typescript@5.8.3)(vite@7.1.11(@types/node@22.12.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.43.1)(yaml@2.8.1))(yaml@2.8.1)
|
||||
'@tailwindcss/typography':
|
||||
specifier: 0.5.19
|
||||
version: 0.5.19
|
||||
@@ -636,10 +644,10 @@ importers:
|
||||
version: 3.4.5(react@18.3.1)
|
||||
'@react-router/node':
|
||||
specifier: 'catalog:'
|
||||
version: 7.9.5(react-router@7.12.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(typescript@5.8.3)
|
||||
version: 7.9.5(react-router@7.9.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(typescript@5.8.3)
|
||||
'@sentry/react-router':
|
||||
specifier: 'catalog:'
|
||||
version: 10.27.0(@react-router/node@7.9.5(react-router@7.12.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(typescript@5.8.3))(react-router@7.12.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)
|
||||
version: 10.27.0(@react-router/node@7.9.5(react-router@7.9.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(typescript@5.8.3))(react-router@7.9.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)
|
||||
'@tanstack/react-table':
|
||||
specifier: ^8.21.3
|
||||
version: 8.21.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
@@ -720,7 +728,10 @@ importers:
|
||||
version: 2.3.0(@popperjs/core@2.11.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
react-router:
|
||||
specifier: 'catalog:'
|
||||
version: 7.12.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
version: 7.9.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
react-router-dom:
|
||||
specifier: 'catalog:'
|
||||
version: 7.9.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
recharts:
|
||||
specifier: ^2.12.7
|
||||
version: 2.15.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
@@ -751,7 +762,7 @@ importers:
|
||||
version: link:../../packages/typescript-config
|
||||
'@react-router/dev':
|
||||
specifier: 'catalog:'
|
||||
version: 7.9.5(@react-router/serve@7.9.5(react-router@7.12.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(typescript@5.8.3))(@types/node@22.12.0)(babel-plugin-macros@3.1.0)(jiti@2.6.1)(lightningcss@1.30.2)(react-router@7.12.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(terser@5.43.1)(typescript@5.8.3)(vite@7.1.11(@types/node@22.12.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.43.1)(yaml@2.8.1))(yaml@2.8.1)
|
||||
version: 7.9.5(@react-router/serve@7.9.5(react-router@7.9.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(typescript@5.8.3))(@types/node@22.12.0)(babel-plugin-macros@3.1.0)(jiti@2.6.1)(lightningcss@1.30.2)(react-router@7.9.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(terser@5.43.1)(typescript@5.8.3)(vite@7.1.11(@types/node@22.12.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.43.1)(yaml@2.8.1))(yaml@2.8.1)
|
||||
'@tailwindcss/typography':
|
||||
specifier: 0.5.19
|
||||
version: 0.5.19
|
||||
@@ -7860,8 +7871,12 @@ packages:
|
||||
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
qs@6.14.1:
|
||||
resolution: {integrity: sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==}
|
||||
qs@6.13.0:
|
||||
resolution: {integrity: sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==}
|
||||
engines: {node: '>=0.6'}
|
||||
|
||||
qs@6.14.0:
|
||||
resolution: {integrity: sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==}
|
||||
engines: {node: '>=0.6'}
|
||||
|
||||
quansync@0.2.11:
|
||||
@@ -8005,8 +8020,15 @@ packages:
|
||||
'@types/react':
|
||||
optional: true
|
||||
|
||||
react-router@7.12.0:
|
||||
resolution: {integrity: sha512-kTPDYPFzDVGIIGNLS5VJykK0HfHLY5MF3b+xj0/tTyNYL1gF1qs7u67Z9jEhQk2sQ98SUaHxlG31g1JtF7IfVw==}
|
||||
react-router-dom@7.9.5:
|
||||
resolution: {integrity: sha512-mkEmq/K8tKN63Ae2M7Xgz3c9l9YNbY+NHH6NNeUmLA3kDkhKXRsNb/ZpxaEunvGo2/3YXdk5EJU3Hxp3ocaBPw==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
peerDependencies:
|
||||
react: '>=18'
|
||||
react-dom: '>=18'
|
||||
|
||||
react-router@7.9.5:
|
||||
resolution: {integrity: sha512-JmxqrnBZ6E9hWmf02jzNn9Jm3UqyeimyiwzD69NjxGySG6lIz/1LVPsoTCwN7NBX2XjCEa1LIX5EMz1j2b6u6A==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
peerDependencies:
|
||||
react: '>=18'
|
||||
@@ -10983,7 +11005,7 @@ snapshots:
|
||||
'@react-pdf/primitives': 4.1.1
|
||||
'@react-pdf/stylesheet': 6.1.0
|
||||
|
||||
'@react-router/dev@7.9.5(@react-router/serve@7.9.5(react-router@7.12.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(typescript@5.8.3))(@types/node@22.12.0)(babel-plugin-macros@3.1.0)(jiti@2.6.1)(lightningcss@1.30.2)(react-router@7.12.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(terser@5.43.1)(typescript@5.8.3)(vite@7.1.11(@types/node@22.12.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.43.1)(yaml@2.8.1))(yaml@2.8.1)':
|
||||
'@react-router/dev@7.9.5(@react-router/serve@7.9.5(react-router@7.9.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(typescript@5.8.3))(@types/node@22.12.0)(babel-plugin-macros@3.1.0)(jiti@2.6.1)(lightningcss@1.30.2)(react-router@7.9.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(terser@5.43.1)(typescript@5.8.3)(vite@7.1.11(@types/node@22.12.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.43.1)(yaml@2.8.1))(yaml@2.8.1)':
|
||||
dependencies:
|
||||
'@babel/core': 7.28.4
|
||||
'@babel/generator': 7.28.5
|
||||
@@ -10993,7 +11015,7 @@ snapshots:
|
||||
'@babel/traverse': 7.28.4
|
||||
'@babel/types': 7.28.5
|
||||
'@npmcli/package-json': 4.0.1
|
||||
'@react-router/node': 7.9.5(react-router@7.12.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(typescript@5.8.3)
|
||||
'@react-router/node': 7.9.5(react-router@7.9.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(typescript@5.8.3)
|
||||
'@remix-run/node-fetch-server': 0.9.0
|
||||
arg: 5.0.2
|
||||
babel-dead-code-elimination: 1.0.10
|
||||
@@ -11009,14 +11031,14 @@ snapshots:
|
||||
picocolors: 1.1.1
|
||||
prettier: 3.7.4
|
||||
react-refresh: 0.14.2
|
||||
react-router: 7.12.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
react-router: 7.9.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
semver: 7.7.3
|
||||
tinyglobby: 0.2.15
|
||||
valibot: 1.2.0(typescript@5.8.3)
|
||||
vite: 7.1.11(@types/node@22.12.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.43.1)(yaml@2.8.1)
|
||||
vite-node: 3.2.4(@types/node@22.12.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.43.1)(yaml@2.8.1)
|
||||
optionalDependencies:
|
||||
'@react-router/serve': 7.9.5(react-router@7.12.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(typescript@5.8.3)
|
||||
'@react-router/serve': 7.9.5(react-router@7.9.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(typescript@5.8.3)
|
||||
typescript: 5.8.3
|
||||
transitivePeerDependencies:
|
||||
- '@types/node'
|
||||
@@ -11034,31 +11056,31 @@ snapshots:
|
||||
- tsx
|
||||
- yaml
|
||||
|
||||
'@react-router/express@7.9.5(express@4.22.0)(react-router@7.12.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(typescript@5.8.3)':
|
||||
'@react-router/express@7.9.5(express@4.22.0)(react-router@7.9.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(typescript@5.8.3)':
|
||||
dependencies:
|
||||
'@react-router/node': 7.9.5(react-router@7.12.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(typescript@5.8.3)
|
||||
'@react-router/node': 7.9.5(react-router@7.9.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(typescript@5.8.3)
|
||||
express: 4.22.0
|
||||
react-router: 7.12.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
react-router: 7.9.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
optionalDependencies:
|
||||
typescript: 5.8.3
|
||||
|
||||
'@react-router/node@7.9.5(react-router@7.12.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(typescript@5.8.3)':
|
||||
'@react-router/node@7.9.5(react-router@7.9.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(typescript@5.8.3)':
|
||||
dependencies:
|
||||
'@mjackson/node-fetch-server': 0.2.0
|
||||
react-router: 7.12.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
react-router: 7.9.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
optionalDependencies:
|
||||
typescript: 5.8.3
|
||||
|
||||
'@react-router/serve@7.9.5(react-router@7.12.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(typescript@5.8.3)':
|
||||
'@react-router/serve@7.9.5(react-router@7.9.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(typescript@5.8.3)':
|
||||
dependencies:
|
||||
'@mjackson/node-fetch-server': 0.2.0
|
||||
'@react-router/express': 7.9.5(express@4.22.0)(react-router@7.12.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(typescript@5.8.3)
|
||||
'@react-router/node': 7.9.5(react-router@7.12.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(typescript@5.8.3)
|
||||
'@react-router/express': 7.9.5(express@4.22.0)(react-router@7.9.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(typescript@5.8.3)
|
||||
'@react-router/node': 7.9.5(react-router@7.9.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(typescript@5.8.3)
|
||||
compression: 1.8.1
|
||||
express: 4.22.0
|
||||
get-port: 5.1.1
|
||||
morgan: 1.10.1
|
||||
react-router: 7.12.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
react-router: 7.9.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
source-map-support: 0.5.21
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
@@ -11356,13 +11378,13 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@sentry/react-router@10.27.0(@react-router/node@7.9.5(react-router@7.12.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(typescript@5.8.3))(react-router@7.12.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)':
|
||||
'@sentry/react-router@10.27.0(@react-router/node@7.9.5(react-router@7.9.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(typescript@5.8.3))(react-router@7.9.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)':
|
||||
dependencies:
|
||||
'@opentelemetry/api': 1.9.0
|
||||
'@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.0)
|
||||
'@opentelemetry/instrumentation': 0.208.0(@opentelemetry/api@1.9.0)
|
||||
'@opentelemetry/semantic-conventions': 1.37.0
|
||||
'@react-router/node': 7.9.5(react-router@7.12.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(typescript@5.8.3)
|
||||
'@react-router/node': 7.9.5(react-router@7.9.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(typescript@5.8.3)
|
||||
'@sentry/browser': 10.27.0
|
||||
'@sentry/cli': 2.58.2
|
||||
'@sentry/core': 10.27.0
|
||||
@@ -11371,7 +11393,7 @@ snapshots:
|
||||
'@sentry/vite-plugin': 4.6.0
|
||||
glob: 11.1.0
|
||||
react: 18.3.1
|
||||
react-router: 7.12.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
react-router: 7.9.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
transitivePeerDependencies:
|
||||
- encoding
|
||||
- supports-color
|
||||
@@ -13158,7 +13180,7 @@ snapshots:
|
||||
http-errors: 2.0.0
|
||||
iconv-lite: 0.4.24
|
||||
on-finished: 2.4.1
|
||||
qs: 6.14.1
|
||||
qs: 6.13.0
|
||||
raw-body: 2.5.2
|
||||
type-is: 1.6.18
|
||||
unpipe: 1.0.0
|
||||
@@ -14335,7 +14357,7 @@ snapshots:
|
||||
parseurl: 1.3.3
|
||||
path-to-regexp: 0.1.12
|
||||
proxy-addr: 2.0.7
|
||||
qs: 6.14.1
|
||||
qs: 6.14.0
|
||||
range-parser: 1.2.1
|
||||
safe-buffer: 5.2.1
|
||||
send: 0.19.0
|
||||
@@ -16724,7 +16746,11 @@ snapshots:
|
||||
|
||||
punycode@2.3.1: {}
|
||||
|
||||
qs@6.14.1:
|
||||
qs@6.13.0:
|
||||
dependencies:
|
||||
side-channel: 1.1.0
|
||||
|
||||
qs@6.14.0:
|
||||
dependencies:
|
||||
side-channel: 1.1.0
|
||||
|
||||
@@ -16919,7 +16945,13 @@ snapshots:
|
||||
optionalDependencies:
|
||||
'@types/react': 18.3.11
|
||||
|
||||
react-router@7.12.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
|
||||
react-router-dom@7.9.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
|
||||
dependencies:
|
||||
react: 18.3.1
|
||||
react-dom: 18.3.1(react@18.3.1)
|
||||
react-router: 7.9.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
|
||||
react-router@7.9.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
|
||||
dependencies:
|
||||
cookie: 1.0.2
|
||||
react: 18.3.1
|
||||
@@ -18104,7 +18136,7 @@ snapshots:
|
||||
url@0.11.4:
|
||||
dependencies:
|
||||
punycode: 1.4.1
|
||||
qs: 6.14.1
|
||||
qs: 6.14.0
|
||||
|
||||
use-callback-ref@1.3.3(@types/react@18.3.11)(react@18.3.1):
|
||||
dependencies:
|
||||
|
||||
+2
-1
@@ -30,7 +30,8 @@ catalog:
|
||||
mobx-utils: 6.0.8
|
||||
mobx: 6.12.0
|
||||
react-dom: 18.3.1
|
||||
react-router: 7.12.0
|
||||
react-router-dom: 7.9.5
|
||||
react-router: 7.9.5
|
||||
react: 18.3.1
|
||||
swr: 2.2.4
|
||||
tsdown: 0.16.0
|
||||
|
||||
Reference in New Issue
Block a user