[WEB-2870]feat: language support (#6215)

* fix: adding language support package

* fix: language support implementation using mobx

* fix: adding more languages for support

* fix: profile settings translations

* feat: added language support for sidebar and user settings

* feat: added language support for deactivation modal

* fix: added project sync after transfer issues (#6200)

* code refactor and improvement (#6203)

* chore: package code refactoring

* chore: component restructuring and refactor

* chore: comment create improvement

* refactor: enhance workspace and project wrapper modularity (#6207)

* [WEB-2678]feat: added functionality to add labels directly from dropdown (#6211)

* enhancement:added functionality to add features directly from dropdown

* fix: fixed import order

* fix: fixed lint errors

* chore: added common component for project activity (#6212)

* chore: added common component for project activity

* fix: added enum

* fix: added enum for initiatives

* - Do not clear temp files that are locked. (#6214)

- Handle edge cases in sync workspace

* fix: labels empty state for drop down (#6216)

* refactor: remove cn helper function from the editor package (#6217)

* * feat: added language support to issue create modal in sidebar
* fix: project activity type

* * fix: added missing translations
* fix: modified translation for plurals

* fix: fixed spanish translation

* dev: language type error in space user profile types

* fix: type fixes

* chore: added alpha tag

---------

Co-authored-by: sriram veeraghanta <veeraghanta.sriram@gmail.com>
Co-authored-by: Anmol Singh Bhatia <121005188+anmolsinghbhatia@users.noreply.github.com>
Co-authored-by: Prateek Shourya <prateekshourya29@gmail.com>
Co-authored-by: Akshita Goyal <36129505+gakshita@users.noreply.github.com>
Co-authored-by: Satish Gandham <satish.iitg@gmail.com>
Co-authored-by: Aaryan Khandelwal <65252264+aaryan610@users.noreply.github.com>
Co-authored-by: gurusinath <gurusainath007@gmail.com>
This commit is contained in:
Vamsi Krishna 2025-01-03 14:16:26 +05:30 committed by GitHub
parent ade0aa1643
commit 873e4330bc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
84 changed files with 2588 additions and 873 deletions

View File

@ -1,5 +1,5 @@
export const ORGANIZATION_SIZE = [ export const ORGANIZATION_SIZE = [
"Just myself", "Just myself", // TODO: translate
"2-10", "2-10",
"11-50", "11-50",
"51-200", "51-200",

View File

@ -0,0 +1,3 @@
build/*
dist/*
out/*

View File

@ -0,0 +1,9 @@
/** @type {import("eslint").Linter.Config} */
module.exports = {
root: true,
extends: ["@plane/eslint-config/library.js"],
parser: "@typescript-eslint/parser",
parserOptions: {
project: true,
},
};

View File

@ -0,0 +1,4 @@
.turbo
out/
dist/
build/

View File

@ -0,0 +1,5 @@
{
"printWidth": 120,
"tabWidth": 2,
"trailingComma": "es5"
}

View File

@ -0,0 +1,20 @@
{
"name": "@plane/i18n",
"version": "0.24.1",
"description": "I18n shared across multiple apps internally",
"private": true,
"main": "./src/index.ts",
"types": "./src/index.ts",
"scripts": {
"lint": "eslint src --ext .ts,.tsx",
"lint:errors": "eslint src --ext .ts,.tsx --quiet"
},
"dependencies": {
"@plane/utils": "*"
},
"devDependencies": {
"@plane/eslint-config": "*",
"@types/node": "^22.5.4",
"typescript": "^5.3.3"
}
}

View File

@ -0,0 +1,29 @@
import { observer } from "mobx-react";
import React, { createContext, useEffect } from "react";
import { Language, languages } from "../config";
import { TranslationStore } from "./store";
// Create the store instance
const translationStore = new TranslationStore();
// Create Context
export const TranslationContext = createContext<TranslationStore>(translationStore);
export const TranslationProvider = observer(({ children }: { children: React.ReactNode }) => {
// Handle storage events for cross-tab synchronization
useEffect(() => {
const handleStorageChange = (event: StorageEvent) => {
if (event.key === "userLanguage" && event.newValue) {
const newLang = event.newValue as Language;
if (languages.includes(newLang)) {
translationStore.setLanguage(newLang);
}
}
};
window.addEventListener("storage", handleStorageChange);
return () => window.removeEventListener("storage", handleStorageChange);
}, []);
return <TranslationContext.Provider value={translationStore}>{children}</TranslationContext.Provider>;
});

View File

@ -0,0 +1,42 @@
import { makeObservable, observable } from "mobx";
import { Language, fallbackLng, languages, translations } from "../config";
export class TranslationStore {
currentLocale: Language = fallbackLng;
constructor() {
makeObservable(this, {
currentLocale: observable.ref,
});
this.initializeLanguage();
}
get availableLanguages() {
return languages;
}
t(key: string) {
return translations[this.currentLocale]?.[key] || translations[fallbackLng][key] || key;
}
setLanguage(lng: Language) {
try {
localStorage.setItem("userLanguage", lng);
this.currentLocale = lng;
} catch (error) {
console.error(error);
}
}
initializeLanguage() {
if (typeof window === "undefined") return;
const savedLocale = localStorage.getItem("userLanguage") as Language;
if (savedLocale && languages.includes(savedLocale)) {
this.setLanguage(savedLocale);
} else {
const browserLang = navigator.language.split("-")[0] as Language;
const newLocale = languages.includes(browserLang as Language) ? (browserLang as Language) : fallbackLng;
this.setLanguage(newLocale);
}
}
}

View File

@ -0,0 +1,39 @@
import en from "../locales/en/translations.json";
import fr from "../locales/fr/translations.json";
import es from "../locales/es/translations.json";
import ja from "../locales/ja/translations.json";
export type Language = (typeof languages)[number];
export type Translations = {
[key: string]: {
[key: string]: string;
};
};
export const fallbackLng = "en";
export const languages = ["en", "fr", "es", "ja"] as const;
export const translations: Translations = {
en,
fr,
es,
ja,
};
export const SUPPORTED_LANGUAGES = [
{
label: "English",
value: "en",
},
{
label: "French",
value: "fr",
},
{
label: "Spanish",
value: "es",
},
{
label: "Japanese",
value: "ja",
},
];

View File

@ -0,0 +1 @@
export * from "./use-translation";

View File

@ -0,0 +1,17 @@
import { useContext } from "react";
import { TranslationContext } from "../components";
import { Language } from "../config";
export function useTranslation() {
const store = useContext(TranslationContext);
if (!store) {
throw new Error("useTranslation must be used within a TranslationProvider");
}
return {
t: (key: string) => store.t(key),
currentLocale: store.currentLocale,
changeLanguage: (lng: Language) => store.setLanguage(lng),
languages: store.availableLanguages,
};
}

View File

@ -0,0 +1,3 @@
export * from "./config";
export * from "./components";
export * from "./hooks";

View File

@ -0,0 +1,318 @@
{
"submit": "Submit",
"cancel": "Cancel",
"loading": "Loading",
"error": "Error",
"success": "Success",
"warning": "Warning",
"info": "Info",
"close": "Close",
"yes": "Yes",
"no": "No",
"ok": "OK",
"name": "Name",
"description": "Description",
"search": "Search",
"add_member": "Add member",
"remove_member": "Remove member",
"add_members": "Add members",
"remove_members": "Remove members",
"add": "Add",
"remove": "Remove",
"add_new": "Add new",
"remove_selected": "Remove selected",
"first_name": "First name",
"last_name": "Last name",
"email": "Email",
"display_name": "Display name",
"role": "Role",
"timezone": "Timezone",
"avatar": "Avatar",
"cover_image": "Cover image",
"password": "Password",
"change_cover": "Change cover",
"language": "Language",
"saving": "Saving...",
"save_changes": "Save changes",
"deactivate_account": "Deactivate account",
"deactivate_account_description": "When deactivating an account, all of the data and resources within that account will be permanently removed and cannot be recovered.",
"profile_settings": "Profile settings",
"your_account": "Your account",
"profile": "Profile",
"security": "Security",
"activity": "Activity",
"appearance": "Appearance",
"notifications": "Notifications",
"workspaces": "Workspaces",
"create_workspace": "Create workspace",
"invitations": "Invitations",
"summary": "Summary",
"assigned": "Assigned",
"created": "Created",
"subscribed": "Subscribed",
"you_do_not_have_the_permission_to_access_this_page": "You do not have the permission to access this page.",
"failed_to_sign_out_please_try_again": "Failed to sign out. Please try again.",
"password_changed_successfully": "Password changed successfully.",
"something_went_wrong_please_try_again": "Something went wrong. Please try again.",
"change_password": "Change password",
"passwords_dont_match": "Passwords don't match",
"current_password": "Current password",
"new_password": "New password",
"confirm_password": "Confirm password",
"this_field_is_required": "This field is required",
"changing_password": "Changing password",
"please_enter_your_password": "Please enter your password.",
"password_length_should_me_more_than_8_characters": "Password length should me more than 8 characters.",
"password_is_weak": "Password is weak.",
"password_is_strong": "Password is strong.",
"load_more": "Load more",
"select_or_customize_your_interface_color_scheme": "Select or customize your interface color scheme.",
"theme": "Theme",
"system_preference": "System preference",
"light": "Light",
"dark": "Dark",
"light_contrast": "Light high contrast",
"dark_contrast": "Dark high contrast",
"custom": "Custom theme",
"select_your_theme": "Select your theme",
"customize_your_theme": "Customize your theme",
"background_color": "Background color",
"text_color": "Text color",
"primary_color": "Primary(Theme) color",
"sidebar_background_color": "Sidebar background color",
"sidebar_text_color": "Sidebar text color",
"set_theme": "Set theme",
"enter_a_valid_hex_code_of_6_characters": "Enter a valid hex code of 6 characters",
"background_color_is_required": "Background color is required",
"text_color_is_required": "Text color is required",
"primary_color_is_required": "Primary color is required",
"sidebar_background_color_is_required": "Sidebar background color is required",
"sidebar_text_color_is_required": "Sidebar text color is required",
"updating_theme": "Updating theme",
"theme_updated_successfully": "Theme updated successfully",
"failed_to_update_the_theme": "Failed to update the theme",
"email_notifications": "Email notifications",
"stay_in_the_loop_on_issues_you_are_subscribed_to_enable_this_to_get_notified": "Stay in the loop on Issues you are subscribed to. Enable this to get notified.",
"email_notification_setting_updated_successfully": "Email notification setting updated successfully",
"failed_to_update_email_notification_setting": "Failed to update email notification setting",
"notify_me_when": "Notify me when",
"property_changes": "Property changes",
"property_changes_description": "Notify me when issue's properties like assignees, priority, estimates or anything else changes.",
"state_change": "State change",
"state_change_description": "Notify me when the issues moves to a different state",
"issue_completed": "Issue completed",
"issue_completed_description": "Notify me only when an issue is completed",
"comments": "Comments",
"comments_description": "Notify me when someone leaves a comment on the issue",
"mentions": "Mentions",
"mentions_description": "Notify me only when someone mentions me in the comments or description",
"create_your_workspace": "Create your workspace",
"only_your_instance_admin_can_create_workspaces": "Only your instance admin can create workspaces",
"only_your_instance_admin_can_create_workspaces_description": "If you know your instance admin's email address, click the button below to get in touch with them.",
"go_back": "Go back",
"request_instance_admin": "Request instance admin",
"plane_logo": "Plane logo",
"workspace_creation_disabled": "Workspace creation disabled",
"workspace_request_subject": "Requesting a new workspace",
"workspace_request_body": "Hi instance admin(s),\n\nPlease create a new workspace with the URL [/workspace-name] for [purpose of creating the workspace].\n\nThanks,\n{{firstName}} {{lastName}}\n{{email}}",
"creating_workspace": "Creating workspace",
"workspace_created_successfully": "Workspace created successfully",
"create_workspace_page": "Create workspace page",
"workspace_could_not_be_created_please_try_again": "Workspace could not be created. Please try again.",
"workspace_could_not_be_created_please_try_again_description": "Some error occurred while creating workspace. Please try again.",
"this_is_a_required_field": "This is a required field.",
"name_your_workspace": "Name your workspace",
"workspaces_names_can_contain_only_space_dash_and_alphanumeric_characters": "Workspaces names can contain only (' '), ('-'), ('_') and alphanumeric characters.",
"limit_your_name_to_80_characters": "Limit your name to 80 characters.",
"set_your_workspace_url": "Set your workspace's URL",
"limit_your_url_to_48_characters": "Limit your URL to 48 characters.",
"how_many_people_will_use_this_workspace": "How many people will use this workspace?",
"how_many_people_will_use_this_workspace_description": "This will help us to determine the number of seats you need to purchase.",
"select_a_range": "Select a range",
"urls_can_contain_only_dash_and_alphanumeric_characters": "URLs can contain only ('-') and alphanumeric characters.",
"something_familiar_and_recognizable_is_always_best": "Something familiar and recognizable is always best.",
"workspace_url_is_already_taken": "Workspace URL is already taken!",
"old_password": "Old password",
"general_settings": "General settings",
"sign_out": "Sign out",
"signing_out": "Signing out",
"active_cycles": "Active cycles",
"active_cycles_description": "Monitor cycles across projects, track high-priority issues, and zoom in cycles that need attention.",
"on_demand_snapshots_of_all_your_cycles": "On-demand snapshots of all your cycles",
"upgrade": "Upgrade",
"10000_feet_view": "10,000-feet view of all active cycles.",
"10000_feet_view_description": "Zoom out to see running cycles across all your projects at once instead of going from Cycle to Cycle in each project.",
"get_snapshot_of_each_active_cycle": "Get a snapshot of each active cycle.",
"get_snapshot_of_each_active_cycle_description": "Track high-level metrics for all active cycles, see their state of progress, and get a sense of scope against deadlines.",
"compare_burndowns": "Compare burndowns.",
"compare_burndowns_description": "Monitor how each of your teams are performing with a peek into each cycle's burndown report.",
"quickly_see_make_or_break_issues": "Quickly see make-or-break issues.",
"quickly_see_make_or_break_issues_description": "Preview high-priority issues for each cycle against due dates. See all of them per cycle in one click.",
"zoom_into_cycles_that_need_attention": "Zoom into cycles that need attention.",
"zoom_into_cycles_that_need_attention_description": "Investigate the state of any cycle that doesn't conform to expectations in one click.",
"stay_ahead_of_blockers": "Stay ahead of blockers.",
"stay_ahead_of_blockers_description": "Spot challenges from one project to another and see inter-cycle dependencies that aren't obvious from any other view.",
"analytics": "Analytics",
"workspace_invites": "Workspace invites",
"workspace_settings": "Workspace settings",
"enter_god_mode": "Enter god mode",
"workspace_logo": "Workspace logo",
"new_issue": "New issue",
"home": "Home",
"your_work": "Your work",
"drafts": "Drafts",
"projects": "Projects",
"views": "Views",
"workspace": "Workspace",
"archives": "Archives",
"settings": "Settings",
"failed_to_move_favorite": "Failed to move favorite",
"your_favorites": "Your favorites",
"no_favorites_yet": "No favorites yet",
"create_folder": "Create folder",
"new_folder": "New folder",
"favorite_updated_successfully": "Favorite updated successfully",
"favorite_created_successfully": "Favorite created successfully",
"folder_already_exists": "Folder already exists",
"folder_name_cannot_be_empty": "Folder name cannot be empty",
"something_went_wrong": "Something went wrong",
"failed_to_reorder_favorite": "Failed to reorder favorite",
"favorite_removed_successfully": "Favorite removed successfully",
"failed_to_create_favorite": "Failed to create favorite",
"failed_to_rename_favorite": "Failed to rename favorite",
"project_link_copied_to_clipboard": "Project link copied to clipboard",
"link_copied": "Link copied",
"your_projects": "Your projects",
"add_project": "Add project",
"create_project": "Create project",
"failed_to_remove_project_from_favorites": "Couldn't remove the project from favorites. Please try again.",
"project_created_successfully": "Project created successfully",
"project_created_successfully_description": "Project created successfully. You can now start adding issues to it.",
"project_cover_image_alt": "Project cover image",
"name_is_required": "Name is required",
"title_should_be_less_than_255_characters": "Title should be less than 255 characters",
"project_name": "Project name",
"project_id_must_be_at_least_1_character": "Project ID must at least be of 1 character",
"project_id_must_be_at_most_5_characters": "Project ID must at most be of 5 characters",
"project_id": "Project ID",
"project_id_tooltip_content": "Helps you identify issues in the project uniquely. Max 5 characters.",
"description_placeholder": "Description...",
"only_alphanumeric_non_latin_characters_allowed": "Only Alphanumeric & Non-latin characters are allowed.",
"project_id_is_required": "Project ID is required",
"select_network": "Select network",
"lead": "Lead",
"private": "Private",
"public": "Public",
"accessible_only_by_invite": "Accessible only by invite",
"anyone_in_the_workspace_except_guests_can_join": "Anyone in the workspace except Guests can join",
"creating": "Creating",
"creating_project": "Creating project",
"adding_project_to_favorites": "Adding project to favorites",
"project_added_to_favorites": "Project added to favorites",
"couldnt_add_the_project_to_favorites": "Couldn't add the project to favorites. Please try again.",
"removing_project_from_favorites": "Removing project from favorites",
"project_removed_from_favorites": "Project removed from favorites",
"couldnt_remove_the_project_from_favorites": "Couldn't remove the project from favorites. Please try again.",
"add_to_favorites": "Add to favorites",
"remove_from_favorites": "Remove from favorites",
"publish_settings": "Publish settings",
"publish": "Publish",
"copy_link": "Copy link",
"leave_project": "Leave project",
"join_the_project_to_rearrange": "Join the project to rearrange",
"drag_to_rearrange": "Drag to rearrange",
"congrats": "Congrats!",
"project": "Project",
"open_project": "Open project",
"issues": "Issues",
"cycles": "Cycles",
"modules": "Modules",
"pages": "Pages",
"intake": "Intake",
"time_tracking": "Time Tracking",
"work_management": "Work management",
"projects_and_issues": "Projects and issues",
"projects_and_issues_description": "Toggle these on or off this project.",
"cycles_description": "Timebox work as you see fit per project and change frequency from one period to the next.",
"modules_description": "Group work into sub-project-like set-ups with their own leads and assignees.",
"views_description": "Save sorts, filters, and display options for later or share them.",
"pages_description": "Write anything like you write anything.",
"intake_description": "Stay in the loop on Issues you are subscribed to. Enable this to get notified.",
"time_tracking_description": "Track time spent on issues and projects.",
"work_management_description": "Manage your work and projects with ease.",
"documentation": "Documentation",
"message_support": "Message support",
"contact_sales": "Contact sales",
"hyper_mode": "Hyper Mode",
"keyboard_shortcuts": "Keyboard shortcuts",
"whats_new": "What's new?",
"version": "Version",
"we_are_having_trouble_fetching_the_updates": "We are having trouble fetching the updates.",
"our_changelogs": "our changelogs",
"for_the_latest_updates": "for the latest updates.",
"please_visit": "Please visit",
"docs": "Docs",
"full_changelog": "Full changelog",
"support": "Support",
"discord": "Discord",
"powered_by_plane_pages": "Powered by Plane Pages",
"please_select_at_least_one_invitation": "Please select at least one invitation.",
"please_select_at_least_one_invitation_description": "Please select at least one invitation to join the workspace.",
"we_see_that_someone_has_invited_you_to_join_a_workspace": "We see that someone has invited you to join a workspace",
"join_a_workspace": "Join a workspace",
"we_see_that_someone_has_invited_you_to_join_a_workspace_description": "We see that someone has invited you to join a workspace",
"join_a_workspace_description": "Join a workspace",
"accept_and_join": "Accept & Join",
"go_home": "Go Home",
"no_pending_invites": "No pending invites",
"you_can_see_here_if_someone_invites_you_to_a_workspace": "You can see here if someone invites you to a workspace",
"back_to_home": "Back to home",
"workspace_name": "workspace-name",
"deactivate_your_account": "Deactivate your account",
"deactivate_your_account_description": "Once deactivated, you can't be assigned issues and be billed for your workspace. To reactivate your account, you will need an invite to a workspace at this email address.",
"deactivating": "Deactivating",
"confirm": "Confirm",
"draft_created": "Draft created",
"issue_created_successfully": "Issue created successfully",
"draft_creation_failed": "Draft creation failed",
"issue_creation_failed": "Issue creation failed",
"draft_issue": "Draft issue",
"issue_updated_successfully": "Issue updated successfully",
"issue_could_not_be_updated": "Issue could not be updated",
"create_a_draft": "Create a draft",
"save_to_drafts": "Save to Drafts",
"save": "Save",
"updating": "Updating",
"create_new_issue": "Create new issue",
"editor_is_not_ready_to_discard_changes": "Editor is not ready to discard changes",
"failed_to_move_issue_to_project": "Failed to move issue to project",
"create_more": "Create more",
"add_to_project": "Add to project",
"discard": "Discard",
"duplicate_issue_found": "Duplicate issue found",
"duplicate_issues_found": "Duplicate issues found",
"no_matching_results": "No matching results",
"title_is_required": "Title is required",
"title": "Title",
"state": "State",
"priority": "Priority",
"none": "None",
"urgent": "Urgent",
"high": "High",
"medium": "Medium",
"low": "Low",
"members": "Members",
"assignee": "Assignee",
"assignees": "Assignees",
"you": "You",
"labels": "Labels",
"create_new_label": "Create new label",
"start_date": "Start date",
"due_date": "Due date",
"cycle": "Cycle",
"estimate": "Estimate",
"change_parent_issue": "Change parent issue",
"remove_parent_issue": "Remove parent issue",
"add_parent": "Add parent",
"loading_members": "Loading members..."
}

View File

@ -0,0 +1,318 @@
{
"submit": "Enviar",
"cancel": "Cancelar",
"loading": "Cargando",
"error": "Error",
"success": "Éxito",
"warning": "Advertencia",
"info": "Información",
"close": "Cerrar",
"yes": "Sí",
"no": "No",
"ok": "OK",
"name": "Nombre",
"description": "Descripción",
"search": "Buscar",
"add_member": "Agregar miembro",
"remove_member": "Eliminar miembro",
"add_members": "Agregar miembros",
"remove_members": "Eliminar miembros",
"add": "Agregar",
"remove": "Eliminar",
"add_new": "Agregar nuevo",
"remove_selected": "Eliminar seleccionados",
"first_name": "Nombre",
"last_name": "Apellido",
"email": "Correo electrónico",
"display_name": "Nombre para mostrar",
"role": "Rol",
"timezone": "Zona horaria",
"avatar": "Avatar",
"cover_image": "Imagen de portada",
"password": "Contraseña",
"change_cover": "Cambiar portada",
"language": "Idioma",
"saving": "Guardando...",
"save_changes": "Guardar cambios",
"deactivate_account": "Desactivar cuenta",
"deactivate_account_description": "Al desactivar una cuenta, todos los datos y recursos dentro de esa cuenta se eliminarán permanentemente y no se podrán recuperar.",
"profile_settings": "Configuración de perfil",
"your_account": "Tu cuenta",
"profile": "Perfil",
"security": "Seguridad",
"activity": "Actividad",
"appearance": "Apariencia",
"notifications": "Notificaciones",
"workspaces": "Espacios de trabajo",
"create_workspace": "Crear espacio de trabajo",
"invitations": "Invitaciones",
"summary": "Resumen",
"assigned": "Asignado",
"created": "Creado",
"subscribed": "Suscrito",
"you_do_not_have_the_permission_to_access_this_page": "No tienes permiso para acceder a esta página.",
"failed_to_sign_out_please_try_again": "Error al cerrar sesión. Por favor, inténtalo de nuevo.",
"password_changed_successfully": "Contraseña cambiada con éxito.",
"something_went_wrong_please_try_again": "Algo salió mal. Por favor, inténtalo de nuevo.",
"change_password": "Cambiar contraseña",
"passwords_dont_match": "Las contraseñas no coinciden",
"current_password": "Contraseña actual",
"new_password": "Nueva contraseña",
"confirm_password": "Confirmar contraseña",
"this_field_is_required": "Este campo es obligatorio",
"changing_password": "Cambiando contraseña",
"please_enter_your_password": "Por favor, introduce tu contraseña.",
"password_length_should_me_more_than_8_characters": "La longitud de la contraseña debe ser más de 8 caracteres.",
"password_is_weak": "La contraseña es débil.",
"password_is_strong": "La contraseña es fuerte.",
"load_more": "Cargar más",
"select_or_customize_your_interface_color_scheme": "Selecciona o personaliza el esquema de color de tu interfaz.",
"theme": "Tema",
"system_preference": "Preferencia del sistema",
"light": "Claro",
"dark": "Oscuro",
"light_contrast": "Alto contraste claro",
"dark_contrast": "Alto contraste oscuro",
"custom": "Tema personalizado",
"select_your_theme": "Selecciona tu tema",
"customize_your_theme": "Personaliza tu tema",
"background_color": "Color de fondo",
"text_color": "Color del texto",
"primary_color": "Color primario (Tema)",
"sidebar_background_color": "Color de fondo de la barra lateral",
"sidebar_text_color": "Color del texto de la barra lateral",
"set_theme": "Establecer tema",
"enter_a_valid_hex_code_of_6_characters": "Introduce un código hexadecimal válido de 6 caracteres",
"background_color_is_required": "El color de fondo es obligatorio",
"text_color_is_required": "El color del texto es obligatorio",
"primary_color_is_required": "El color primario es obligatorio",
"sidebar_background_color_is_required": "El color de fondo de la barra lateral es obligatorio",
"sidebar_text_color_is_required": "El color del texto de la barra lateral es obligatorio",
"updating_theme": "Actualizando tema",
"theme_updated_successfully": "Tema actualizado con éxito",
"failed_to_update_the_theme": "Error al actualizar el tema",
"email_notifications": "Notificaciones por correo electrónico",
"stay_in_the_loop_on_issues_you_are_subscribed_to_enable_this_to_get_notified": "Mantente al tanto de los problemas a los que estás suscrito. Activa esto para recibir notificaciones.",
"email_notification_setting_updated_successfully": "Configuración de notificaciones por correo electrónico actualizada con éxito",
"failed_to_update_email_notification_setting": "Error al actualizar la configuración de notificaciones por correo electrónico",
"notify_me_when": "Notifícame cuando",
"property_changes": "Cambios de propiedad",
"property_changes_description": "Notifícame cuando cambien las propiedades del problema como asignados, prioridad, estimaciones o cualquier otra cosa.",
"state_change": "Cambio de estado",
"state_change_description": "Notifícame cuando el problema se mueva a un estado diferente",
"issue_completed": "Problema completado",
"issue_completed_description": "Notifícame solo cuando un problema esté completado",
"comments": "Comentarios",
"comments_description": "Notifícame cuando alguien deje un comentario en el problema",
"mentions": "Menciones",
"mentions_description": "Notifícame solo cuando alguien me mencione en los comentarios o en la descripción",
"create_your_workspace": "Crea tu espacio de trabajo",
"only_your_instance_admin_can_create_workspaces": "Solo tu administrador de instancia puede crear espacios de trabajo",
"only_your_instance_admin_can_create_workspaces_description": "Si conoces el correo electrónico de tu administrador de instancia, haz clic en el botón de abajo para ponerte en contacto con él.",
"go_back": "Regresar",
"request_instance_admin": "Solicitar administrador de instancia",
"plane_logo": "Logo de Plane",
"workspace_creation_disabled": "Creación de espacio de trabajo deshabilitada",
"workspace_request_subject": "Solicitando un nuevo espacio de trabajo",
"workspace_request_body": "Hola administrador(es) de instancia,\n\nPor favor, crea un nuevo espacio de trabajo con la URL [/nombre-del-espacio-de-trabajo] para [propósito de crear el espacio de trabajo].\n\nGracias,\n{{firstName}} {{lastName}}\n{{email}}",
"creating_workspace": "Creando espacio de trabajo",
"workspace_created_successfully": "Espacio de trabajo creado con éxito",
"create_workspace_page": "Página de creación de espacio de trabajo",
"workspace_could_not_be_created_please_try_again": "No se pudo crear el espacio de trabajo. Por favor, inténtalo de nuevo.",
"workspace_could_not_be_created_please_try_again_description": "Ocurrió un error al crear el espacio de trabajo. Por favor, inténtalo de nuevo.",
"this_is_a_required_field": "Este es un campo obligatorio.",
"name_your_workspace": "Nombra tu espacio de trabajo",
"workspaces_names_can_contain_only_space_dash_and_alphanumeric_characters": "Los nombres de los espacios de trabajo solo pueden contener (' '), ('-'), ('_') y caracteres alfanuméricos.",
"limit_your_name_to_80_characters": "Limita tu nombre a 80 caracteres.",
"set_your_workspace_url": "Establece la URL de tu espacio de trabajo",
"limit_your_url_to_48_characters": "Limita tu URL a 48 caracteres.",
"how_many_people_will_use_this_workspace": "¿Cuántas personas usarán este espacio de trabajo?",
"how_many_people_will_use_this_workspace_description": "Esto nos ayudará a determinar el número de asientos que necesitas comprar.",
"select_a_range": "Selecciona un rango",
"urls_can_contain_only_dash_and_alphanumeric_characters": "Las URLs solo pueden contener ('-') y caracteres alfanuméricos.",
"something_familiar_and_recognizable_is_always_best": "Algo familiar y reconocible siempre es mejor.",
"workspace_url_is_already_taken": "¡La URL del espacio de trabajo ya está tomada!",
"old_password": "Contraseña antigua",
"general_settings": "Configuración general",
"sign_out": "Cerrar sesión",
"signing_out": "Cerrando sesión",
"active_cycles": "Ciclos activos",
"active_cycles_description": "Monitorea ciclos a través de proyectos, rastrea problemas de alta prioridad y enfócate en ciclos que necesitan atención.",
"on_demand_snapshots_of_all_your_cycles": "Instantáneas bajo demanda de todos tus ciclos",
"upgrade": "Actualizar",
"10000_feet_view": "Vista de 10,000 pies de todos los ciclos activos.",
"10000_feet_view_description": "Amplía para ver ciclos en ejecución en todos tus proyectos a la vez en lugar de ir de ciclo en ciclo en cada proyecto.",
"get_snapshot_of_each_active_cycle": "Obtén una instantánea de cada ciclo activo.",
"get_snapshot_of_each_active_cycle_description": "Rastrea métricas de alto nivel para todos los ciclos activos, ve su estado de progreso y obtén una idea del alcance frente a los plazos.",
"compare_burndowns": "Compara burndowns.",
"compare_burndowns_description": "Monitorea cómo se están desempeñando cada uno de tus equipos con un vistazo al informe de burndown de cada ciclo.",
"quickly_see_make_or_break_issues": "Ve rápidamente problemas críticos.",
"quickly_see_make_or_break_issues_description": "Previsualiza problemas de alta prioridad para cada ciclo contra fechas de vencimiento. Vélos todos por ciclo en un clic.",
"zoom_into_cycles_that_need_attention": "Enfócate en ciclos que necesitan atención.",
"zoom_into_cycles_that_need_attention_description": "Investiga el estado de cualquier ciclo que no cumpla con las expectativas en un clic.",
"stay_ahead_of_blockers": "Anticípate a los bloqueadores.",
"stay_ahead_of_blockers_description": "Detecta desafíos de un proyecto a otro y ve dependencias entre ciclos que no son obvias desde ninguna otra vista.",
"analytics": "Analítica",
"workspace_invites": "Invitaciones al espacio de trabajo",
"workspace_settings": "Configuración del espacio de trabajo",
"enter_god_mode": "Entrar en modo dios",
"workspace_logo": "Logo del espacio de trabajo",
"new_issue": "Nuevo problema",
"home": "Inicio",
"your_work": "Tu trabajo",
"drafts": "Borradores",
"projects": "Proyectos",
"views": "Vistas",
"workspace": "Espacio de trabajo",
"archives": "Archivos",
"settings": "Configuración",
"failed_to_move_favorite": "Error al mover favorito",
"your_favorites": "Tus favoritos",
"no_favorites_yet": "Aún no hay favoritos",
"create_folder": "Crear carpeta",
"new_folder": "Nueva carpeta",
"favorite_updated_successfully": "Favorito actualizado con éxito",
"favorite_created_successfully": "Favorito creado con éxito",
"folder_already_exists": "La carpeta ya existe",
"folder_name_cannot_be_empty": "El nombre de la carpeta no puede estar vacío",
"something_went_wrong": "Algo salió mal",
"failed_to_reorder_favorite": "Error al reordenar favorito",
"favorite_removed_successfully": "Favorito eliminado con éxito",
"failed_to_create_favorite": "Error al crear favorito",
"failed_to_rename_favorite": "Error al renombrar favorito",
"project_link_copied_to_clipboard": "Enlace del proyecto copiado al portapapeles",
"link_copied": "Enlace copiado",
"your_projects": "Tus proyectos",
"add_project": "Agregar proyecto",
"create_project": "Crear proyecto",
"failed_to_remove_project_from_favorites": "No se pudo eliminar el proyecto de favoritos. Por favor, inténtalo de nuevo.",
"project_created_successfully": "Proyecto creado con éxito",
"project_created_successfully_description": "Proyecto creado con éxito. Ahora puedes comenzar a agregar problemas a él.",
"project_cover_image_alt": "Imagen de portada del proyecto",
"name_is_required": "El nombre es obligatorio",
"title_should_be_less_than_255_characters": "El título debe tener menos de 255 caracteres",
"project_name": "Nombre del proyecto",
"project_id_must_be_at_least_1_character": "El ID del proyecto debe tener al menos 1 carácter",
"project_id_must_be_at_most_5_characters": "El ID del proyecto debe tener como máximo 5 caracteres",
"project_id": "ID del proyecto",
"project_id_tooltip_content": "Te ayuda a identificar problemas en el proyecto de manera única. Máximo 5 caracteres.",
"description_placeholder": "Descripción...",
"only_alphanumeric_non_latin_characters_allowed": "Solo se permiten caracteres alfanuméricos y no latinos.",
"project_id_is_required": "El ID del proyecto es obligatorio",
"select_network": "Seleccionar red",
"lead": "Líder",
"private": "Privado",
"public": "Público",
"accessible_only_by_invite": "Accesible solo por invitación",
"anyone_in_the_workspace_except_guests_can_join": "Cualquiera en el espacio de trabajo excepto invitados puede unirse",
"creating": "Creando",
"creating_project": "Creando proyecto",
"adding_project_to_favorites": "Agregando proyecto a favoritos",
"project_added_to_favorites": "Proyecto agregado a favoritos",
"couldnt_add_the_project_to_favorites": "No se pudo agregar el proyecto a favoritos. Por favor, inténtalo de nuevo.",
"removing_project_from_favorites": "Eliminando proyecto de favoritos",
"project_removed_from_favorites": "Proyecto eliminado de favoritos",
"couldnt_remove_the_project_from_favorites": "No se pudo eliminar el proyecto de favoritos. Por favor, inténtalo de nuevo.",
"add_to_favorites": "Agregar a favoritos",
"remove_from_favorites": "Eliminar de favoritos",
"publish_settings": "Configuración de publicación",
"publish": "Publicar",
"copy_link": "Copiar enlace",
"leave_project": "Abandonar proyecto",
"join_the_project_to_rearrange": "Únete al proyecto para reordenar",
"drag_to_rearrange": "Arrastra para reordenar",
"congrats": "¡Felicitaciones!",
"project": "Proyecto",
"open_project": "Abrir proyecto",
"issues": "Problemas",
"cycles": "Ciclos",
"modules": "Módulos",
"pages": "Páginas",
"intake": "Entrada",
"time_tracking": "Seguimiento de tiempo",
"work_management": "Gestión del trabajo",
"projects_and_issues": "Proyectos y problemas",
"projects_and_issues_description": "Activa o desactiva estos en este proyecto.",
"cycles_description": "Organiza el trabajo como mejor te parezca por proyecto y cambia la frecuencia de un período a otro.",
"modules_description": "Agrupa el trabajo en configuraciones similares a subproyectos con sus propios líderes y asignados.",
"views_description": "Guarda ordenamientos, filtros y opciones de visualización para más tarde o compártelos.",
"pages_description": "Escribe cualquier cosa como escribes cualquier cosa.",
"intake_description": "Mantente al tanto de los problemas a los que estás suscrito. Activa esto para recibir notificaciones.",
"time_tracking_description": "Rastrea el tiempo dedicado a problemas y proyectos.",
"work_management_description": "Gestiona tu trabajo y proyectos con facilidad.",
"documentation": "Documentación",
"message_support": "Mensaje al soporte",
"contact_sales": "Contactar ventas",
"hyper_mode": "Modo Hyper",
"keyboard_shortcuts": "Atajos de teclado",
"whats_new": "¿Qué hay de nuevo?",
"version": "Versión",
"we_are_having_trouble_fetching_the_updates": "Estamos teniendo problemas para obtener las actualizaciones.",
"our_changelogs": "nuestros registros de cambios",
"for_the_latest_updates": "para las últimas actualizaciones.",
"please_visit": "Por favor, visita",
"docs": "Documentos",
"full_changelog": "Registro de cambios completo",
"support": "Soporte",
"discord": "Discord",
"powered_by_plane_pages": "Desarrollado por Plane Pages",
"please_select_at_least_one_invitation": "Por favor, selecciona al menos una invitación.",
"please_select_at_least_one_invitation_description": "Por favor, selecciona al menos una invitación para unirte al espacio de trabajo.",
"we_see_that_someone_has_invited_you_to_join_a_workspace": "Vemos que alguien te ha invitado a unirte a un espacio de trabajo",
"join_a_workspace": "Unirse a un espacio de trabajo",
"we_see_that_someone_has_invited_you_to_join_a_workspace_description": "Vemos que alguien te ha invitado a unirte a un espacio de trabajo",
"join_a_workspace_description": "Unirse a un espacio de trabajo",
"accept_and_join": "Aceptar y unirse",
"go_home": "Ir a inicio",
"no_pending_invites": "No hay invitaciones pendientes",
"you_can_see_here_if_someone_invites_you_to_a_workspace": "Puedes ver aquí si alguien te invita a un espacio de trabajo",
"back_to_home": "Volver al inicio",
"workspace_name": "nombre-del-espacio-de-trabajo",
"deactivate_your_account": "Desactivar tu cuenta",
"deactivate_your_account_description": "Una vez desactivada, no podrás ser asignado a problemas ni se te facturará por tu espacio de trabajo. Para reactivar tu cuenta, necesitarás una invitación a un espacio de trabajo con esta dirección de correo electrónico.",
"deactivating": "Desactivando",
"confirm": "Confirmar",
"draft_created": "Borrador creado",
"issue_created_successfully": "Problema creado con éxito",
"draft_creation_failed": "Creación del borrador fallida",
"issue_creation_failed": "Creación del problema fallida",
"draft_issue": "Borrador de problema",
"issue_updated_successfully": "Problema actualizado con éxito",
"issue_could_not_be_updated": "No se pudo actualizar el problema",
"create_a_draft": "Crear un borrador",
"save_to_drafts": "Guardar en borradores",
"save": "Guardar",
"updating": "Actualizando",
"create_new_issue": "Crear nuevo problema",
"editor_is_not_ready_to_discard_changes": "El editor no está listo para descartar los cambios",
"failed_to_move_issue_to_project": "Error al mover el problema al proyecto",
"create_more": "Crear más",
"add_to_project": "Agregar al proyecto",
"discard": "Descartar",
"duplicate_issue_found": "Problema duplicado encontrado",
"duplicate_issues_found": "Problemas duplicados encontrados",
"no_matching_results": "No hay resultados coincidentes",
"title_is_required": "El título es obligatorio",
"title": "Título",
"state": "Estado",
"priority": "Prioridad",
"none": "Ninguno",
"urgent": "Urgente",
"high": "Alta",
"medium": "Media",
"low": "Baja",
"members": "Miembros",
"assignee": "Asignado",
"assignees": "Asignados",
"you": "Tú",
"labels": "Etiquetas",
"create_new_label": "Crear nueva etiqueta",
"start_date": "Fecha de inicio",
"due_date": "Fecha de vencimiento",
"cycle": "Ciclo",
"estimate": "Estimación",
"change_parent_issue": "Cambiar problema padre",
"remove_parent_issue": "Eliminar problema padre",
"add_parent": "Agregar padre",
"loading_members": "Cargando miembros..."
}

View File

@ -0,0 +1,318 @@
{
"submit": "Soumettre",
"cancel": "Annuler",
"loading": "Chargement",
"error": "Erreur",
"success": "Succès",
"warning": "Avertissement",
"info": "Info",
"close": "Fermer",
"yes": "Oui",
"no": "Non",
"ok": "OK",
"name": "Nom",
"description": "Description",
"search": "Rechercher",
"add_member": "Ajouter un membre",
"remove_member": "Supprimer un membre",
"add_members": "Ajouter des membres",
"remove_members": "Supprimer des membres",
"add": "Ajouter",
"remove": "Supprimer",
"add_new": "Ajouter nouveau",
"remove_selected": "Supprimer la sélection",
"first_name": "Prénom",
"last_name": "Nom de famille",
"email": "Email",
"display_name": "Nom d'affichage",
"role": "Rôle",
"timezone": "Fuseau horaire",
"avatar": "Avatar",
"cover_image": "Image de couverture",
"password": "Mot de passe",
"change_cover": "Modifier la couverture",
"language": "Langue",
"saving": "Enregistrement...",
"save_changes": "Enregistrer les modifications",
"deactivate_account": "Désactiver le compte",
"deactivate_account_description": "Lors de la désactivation d'un compte, toutes les données et ressources de ce compte seront définitivement supprimées et ne pourront pas être récupérées.",
"profile_settings": "Paramètres du profil",
"your_account": "Votre compte",
"profile": "Profil",
"security": " Sécurité",
"activity": "Activité",
"appearance": "Apparence",
"notifications": "Notifications",
"workspaces": "Workspaces",
"create_workspace": "Créer un workspace",
"invitations": "Invitations",
"summary": "Résumé",
"assigned": "Assigné",
"created": "Créé",
"subscribed": "Souscrit",
"you_do_not_have_the_permission_to_access_this_page": "Vous n'avez pas les permissions pour accéder à cette page.",
"failed_to_sign_out_please_try_again": "Impossible de se déconnecter. Veuillez réessayer.",
"password_changed_successfully": "Mot de passe changé avec succès.",
"something_went_wrong_please_try_again": "Quelque chose s'est mal passé. Veuillez réessayer.",
"change_password": "Changer le mot de passe",
"changing_password": "Changement de mot de passe",
"current_password": "Mot de passe actuel",
"new_password": "Nouveau mot de passe",
"confirm_password": "Confirmer le mot de passe",
"this_field_is_required": "Ce champ est requis",
"passwords_dont_match": "Les mots de passe ne correspondent pas",
"please_enter_your_password": "Veuillez entrer votre mot de passe.",
"password_length_should_me_more_than_8_characters": "La longueur du mot de passe doit être supérieure à 8 caractères.",
"password_is_weak": "Le mot de passe est faible.",
"password_is_strong": "Le mot de passe est fort.",
"load_more": "Charger plus",
"select_or_customize_your_interface_color_scheme": "Sélectionnez ou personnalisez votre schéma de couleurs de l'interface.",
"theme": "Thème",
"system_preference": "Préférence du système",
"light": "Clair",
"dark": "Foncé",
"light_contrast": "Clair de haut contraste",
"dark_contrast": "Foncé de haut contraste",
"custom": "Thème personnalisé",
"select_your_theme": "Sélectionnez votre thème",
"customize_your_theme": "Personnalisez votre thème",
"background_color": "Couleur de fond",
"text_color": "Couleur de texte",
"primary_color": "Couleur primaire (thème)",
"sidebar_background_color": "Couleur de fond du sidebar",
"sidebar_text_color": "Couleur de texte du sidebar",
"set_theme": "Définir le thème",
"enter_a_valid_hex_code_of_6_characters": "Entrez un code hexadécimal valide de 6 caractères",
"background_color_is_required": "La couleur de fond est requise",
"text_color_is_required": "La couleur de texte est requise",
"primary_color_is_required": "La couleur primaire est requise",
"sidebar_background_color_is_required": "La couleur de fond du sidebar est requise",
"sidebar_text_color_is_required": "La couleur de texte du sidebar est requise",
"updating_theme": "Mise à jour du thème",
"theme_updated_successfully": "Thème mis à jour avec succès",
"failed_to_update_the_theme": "Impossible de mettre à jour le thème",
"email_notifications": "Notifications par email",
"stay_in_the_loop_on_issues_you_are_subscribed_to_enable_this_to_get_notified": "Restez dans la boucle sur les problèmes auxquels vous êtes abonné. Activez cela pour être notifié.",
"email_notification_setting_updated_successfully": "Paramètres de notification par email mis à jour avec succès",
"failed_to_update_email_notification_setting": "Impossible de mettre à jour les paramètres de notification par email",
"notify_me_when": "Me notifier lorsque",
"property_changes": "Changements de propriété",
"property_changes_description": "Me notifier lorsque les propriétés du problème comme les assignés, la priorité, les estimations ou tout autre chose changent.",
"state_change": "Changement d'état",
"state_change_description": "Me notifier lorsque le problème passe à un autre état",
"issue_completed": "Problème terminé",
"issue_completed_description": "Me notifier uniquement lorsqu'un problème est terminé",
"comments": "Commentaires",
"comments_description": "Me notifier lorsqu'un utilisateur commente un problème",
"mentions": "Mention",
"mentions_description": "Me notifier uniquement lorsqu'un utilisateur mentionne un problème",
"create_your_workspace": "Créer votre workspace",
"only_your_instance_admin_can_create_workspaces": "Seuls les administrateurs de votre instance peuvent créer des workspaces",
"only_your_instance_admin_can_create_workspaces_description": "Si vous connaissez l'adresse email de votre administrateur d'instance, cliquez sur le bouton ci-dessous pour les contacter.",
"go_back": "Retour",
"request_instance_admin": "Demander à l'administrateur de l'instance",
"plane_logo": "Logo de Plane",
"workspace_creation_disabled": "Création d'espace de travail désactivée",
"workspace_request_subject": "Demande de création d'un espace de travail",
"workspace_request_body": "Bonjour administrateur(s) de l'instance,\n\nVeuillez créer un nouveau espace de travail avec l'URL [/workspace-name] pour [raison de la création de l'espace de travail].\n\nMerci,\n{{firstName}} {{lastName}}\n{{email}}",
"creating_workspace": "Création de l'espace de travail",
"workspace_created_successfully": "Espace de travail créé avec succès",
"create_workspace_page": "Page de création d'espace de travail",
"workspace_could_not_be_created_please_try_again": "L'espace de travail ne peut pas être créé. Veuillez réessayer.",
"workspace_could_not_be_created_please_try_again_description": "Une erreur est survenue lors de la création de l'espace de travail. Veuillez réessayer.",
"this_is_a_required_field": "Ce champ est requis.",
"name_your_workspace": "Nommez votre espace de travail",
"workspaces_names_can_contain_only_space_dash_and_alphanumeric_characters": "Les noms des espaces de travail peuvent contenir uniquement des espaces, des tirets et des caractères alphanumériques.",
"limit_your_name_to_80_characters": "Limitez votre nom à 80 caractères.",
"set_your_workspace_url": "Définir l'URL de votre espace de travail",
"limit_your_url_to_48_characters": "Limitez votre URL à 48 caractères.",
"how_many_people_will_use_this_workspace": "Combien de personnes utiliseront cet espace de travail ?",
"how_many_people_will_use_this_workspace_description": "Cela nous aidera à déterminer le nombre de sièges que vous devez acheter.",
"select_a_range": "Sélectionner une plage",
"urls_can_contain_only_dash_and_alphanumeric_characters": "Les URLs peuvent contenir uniquement des tirets et des caractères alphanumériques.",
"something_familiar_and_recognizable_is_always_best": "Ce qui est familier et reconnaissable est toujours le meilleur.",
"workspace_url_is_already_taken": "L'URL de l'espace de travail est déjà utilisée !",
"old_password": "Mot de passe actuel",
"general_settings": "Paramètres généraux",
"sign_out": "Déconnexion",
"signing_out": "Déconnexion",
"active_cycles": "Cycles actifs",
"active_cycles_description": "Surveillez les cycles dans les projets, suivez les issues de haute priorité et zoomez sur les cycles qui nécessitent attention.",
"on_demand_snapshots_of_all_your_cycles": "Captures instantanées sur demande de tous vos cycles",
"upgrade": "Mettre à niveau",
"10000_feet_view": "Vue d'ensemble de tous les cycles actifs",
"10000_feet_view_description": "Prenez du recul pour voir les cycles en cours dans tous vos projets en même temps au lieu de passer d'un cycle à l'autre dans chaque projet.",
"get_snapshot_of_each_active_cycle": "Obtenez un aperçu de chaque cycle actif",
"get_snapshot_of_each_active_cycle_description": "Suivez les métriques de haut niveau pour tous les cycles actifs, observez leur état d'avancement et évaluez leur portée par rapport aux échéances.",
"compare_burndowns": "Comparez les graphiques d'avancement",
"compare_burndowns_description": "Surveillez les performances de chacune de vos équipes en consultant le rapport d'avancement de chaque cycle.",
"quickly_see_make_or_break_issues": "Identifiez rapidement les problèmes critiques",
"quickly_see_make_or_break_issues_description": "Visualisez les problèmes hautement prioritaires de chaque cycle par rapport aux dates d'échéance. Consultez-les tous par cycle en un seul clic.",
"zoom_into_cycles_that_need_attention": "Concentrez-vous sur les cycles nécessitant attention",
"zoom_into_cycles_that_need_attention_description": "Examinez en un clic l'état de tout cycle qui ne répond pas aux attentes.",
"stay_ahead_of_blockers": "Anticipez les blocages",
"stay_ahead_of_blockers_description": "Repérez les défis d'un projet à l'autre et identifiez les dépendances entre cycles qui ne sont pas évidentes depuis d'autres vues.",
"analytics": "Analyse",
"workspace_invites": "Invitations de l'espace de travail",
"workspace_settings": "Paramètres de l'espace de travail",
"enter_god_mode": "Entrer en mode dieu",
"workspace_logo": "Logo de l'espace de travail",
"new_issue": "Nouveau problème",
"home": "Accueil",
"your_work": "Votre travail",
"drafts": "Brouillons",
"projects": "Projets",
"views": "Vues",
"workspace": "Espace de travail",
"archives": "Archives",
"settings": "Paramètres",
"failed_to_move_favorite": "Impossible de déplacer le favori",
"your_favorites": "Vos favoris",
"no_favorites_yet": "Aucun favori pour le moment",
"create_folder": "Créer un dossier",
"new_folder": "Nouveau dossier",
"favorite_updated_successfully": "Favori mis à jour avec succès",
"favorite_created_successfully": "Favori créé avec succès",
"folder_already_exists": "Le dossier existe déjà",
"folder_name_cannot_be_empty": "Le nom du dossier ne peut pas être vide",
"something_went_wrong": "Quelque chose s'est mal passé",
"failed_to_reorder_favorite": "Impossible de réordonner le favori",
"favorite_removed_successfully": "Favori supprimé avec succès",
"failed_to_create_favorite": "Impossible de créer le favori",
"failed_to_rename_favorite": "Impossible de renommer le favori",
"project_link_copied_to_clipboard": "Lien du projet copié dans le presse-papiers",
"link_copied": "Lien copié",
"your_projects": "Vos projets",
"add_project": "Ajouter un projet",
"create_project": "Créer un projet",
"failed_to_remove_project_from_favorites": "Impossible de supprimer le projet des favoris. Veuillez réessayer.",
"project_created_successfully": "Projet créé avec succès",
"project_created_successfully_description": "Projet créé avec succès. Vous pouvez maintenant ajouter des issues à ce projet.",
"project_cover_image_alt": "Image de couverture du projet",
"name_is_required": "Le nom est requis",
"title_should_be_less_than_255_characters": "Le titre doit être inférieur à 255 caractères",
"project_name": "Nom du projet",
"project_id_must_be_at_least_1_character": "Le projet ID doit être au moins de 1 caractère",
"project_id_must_be_at_most_5_characters": "Le projet ID doit être au plus de 5 caractères",
"project_id": "ID du projet",
"project_id_tooltip_content": "Aide à identifier les issues du projet de manière unique. Max 5 caractères.",
"description_placeholder": "Description...",
"only_alphanumeric_non_latin_characters_allowed": "Seuls les caractères alphanumériques et non latins sont autorisés.",
"project_id_is_required": "Le projet ID est requis",
"select_network": "Sélectionner le réseau",
"lead": "Lead",
"private": "Privé",
"public": "Public",
"accessible_only_by_invite": "Accessible uniquement par invitation",
"anyone_in_the_workspace_except_guests_can_join": "Tout le monde dans l'espace de travail, sauf les invités, peut rejoindre",
"creating": "Création",
"creating_project": "Création du projet",
"adding_project_to_favorites": "Ajout du projet aux favoris",
"project_added_to_favorites": "Projet ajouté aux favoris",
"couldnt_add_the_project_to_favorites": "Impossible d'ajouter le projet aux favoris. Veuillez réessayer.",
"removing_project_from_favorites": "Suppression du projet des favoris",
"project_removed_from_favorites": "Projet supprimé des favoris",
"couldnt_remove_the_project_from_favorites": "Impossible de supprimer le projet des favoris. Veuillez réessayer.",
"add_to_favorites": "Ajouter aux favoris",
"remove_from_favorites": "Supprimer des favoris",
"publish_settings": "Paramètres de publication",
"publish": "Publier",
"copy_link": "Copier le lien",
"leave_project": "Quitter le projet",
"join_the_project_to_rearrange": "Rejoindre le projet pour réorganiser",
"drag_to_rearrange": "Glisser pour réorganiser",
"congrats": "Félicitations !",
"project": "Projet",
"open_project": "Ouvrir le projet",
"issues": "Problèmes",
"cycles": "Cycles",
"modules": "Modules",
"pages": "Pages",
"intake": "Intake",
"time_tracking": "Suivi du temps",
"work_management": "Gestion du travail",
"projects_and_issues": "Projets et problèmes",
"projects_and_issues_description": "Activer ou désactiver ces fonctionnalités pour ce projet.",
"cycles_description": "Organisez votre travail en périodes définies selon vos besoins par projet et modifiez la fréquence d'une période à l'autre.",
"modules_description": "Regroupez le travail en sous-projets avec leurs propres responsables et assignés.",
"views_description": "Enregistrez vos tris, filtres et options d'affichage pour plus tard ou partagez-les.",
"pages_description": "Rédigez tout type de contenu librement.",
"intake_description": "Restez informé des tickets auxquels vous êtes abonné. Activez cette option pour recevoir des notifications.",
"time_tracking_description": "Suivez le temps passé sur les tickets et les projets.",
"work_management_description": "Gérez votre travail et vos projets en toute simplicité.",
"documentation": "Documentation",
"message_support": "Contacter le support",
"contact_sales": "Contacter les ventes",
"hyper_mode": "Mode hyper",
"keyboard_shortcuts": "Raccourcis clavier",
"whats_new": "Nouveautés?",
"version": "Version",
"we_are_having_trouble_fetching_the_updates": "Nous avons des difficultés à récupérer les mises à jour.",
"our_changelogs": "nos changelogs",
"for_the_latest_updates": "pour les dernières mises à jour.",
"please_visit": "Veuillez visiter",
"docs": "Documentation",
"full_changelog": "Journal complet",
"support": "Support",
"discord": "Discord",
"powered_by_plane_pages": "Propulsé par Plane Pages",
"please_select_at_least_one_invitation": "Veuillez sélectionner au moins une invitation.",
"please_select_at_least_one_invitation_description": "Veuillez sélectionner au moins une invitation pour rejoindre l'espace de travail.",
"we_see_that_someone_has_invited_you_to_join_a_workspace": "Nous voyons que quelqu'un vous a invité à rejoindre un espace de travail",
"join_a_workspace": "Rejoindre un espace de travail",
"we_see_that_someone_has_invited_you_to_join_a_workspace_description": "Nous voyons que quelqu'un vous a invité à rejoindre un espace de travail",
"join_a_workspace_description": "Rejoindre un espace de travail",
"accept_and_join": "Accepter et rejoindre",
"go_home": "Retour à l'accueil",
"no_pending_invites": "Aucune invitation en attente",
"you_can_see_here_if_someone_invites_you_to_a_workspace": "Vous pouvez voir ici si quelqu'un vous invite à rejoindre un espace de travail",
"back_to_home": "Retour à l'accueil",
"workspace_name": "espace-de-travail",
"deactivate_your_account": "Désactiver votre compte",
"deactivate_your_account_description": "Une fois désactivé, vous ne pourrez pas être assigné à des problèmes et être facturé pour votre espace de travail. Pour réactiver votre compte, vous aurez besoin d'une invitation à un espace de travail à cette adresse email.",
"deactivating": "Désactivation",
"confirm": "Confirmer",
"draft_created": "Brouillon créé",
"issue_created_successfully": "Problème créé avec succès",
"draft_creation_failed": "Création du brouillon échouée",
"issue_creation_failed": "Création du problème échouée",
"draft_issue": "Problème en brouillon",
"issue_updated_successfully": "Problème mis à jour avec succès",
"issue_could_not_be_updated": "Le problème n'a pas pu être mis à jour",
"create_a_draft": "Créer un brouillon",
"save_to_drafts": "Enregistrer dans les brouillons",
"save": "Enregistrer",
"updating": "Mise à jour",
"create_new_issue": "Créer un nouveau problème",
"editor_is_not_ready_to_discard_changes": "L'éditeur n'est pas prêt à annuler les modifications",
"failed_to_move_issue_to_project": "Impossible de déplacer le problème vers le projet",
"create_more": "Créer plus",
"add_to_project": "Ajouter au projet",
"discard": "Annuler",
"duplicate_issue_found": "Problème en double trouvé",
"duplicate_issues_found": "Problèmes en double trouvés",
"no_matching_results": "Aucun résultat correspondant",
"title_is_required": "Le titre est requis",
"title": "Titre",
"state": "État",
"priority": "Priorité",
"none": "Aucune",
"urgent": "Urgent",
"high": "Haute",
"medium": "Moyenne",
"low": "Basse",
"members": "Membres",
"assignee": "Assigné",
"assignees": "Assignés",
"you": "Vous",
"labels": "Étiquettes",
"create_new_label": "Créer une nouvelle étiquette",
"start_date": "Date de début",
"due_date": "Date d'échéance",
"cycle": "Cycle",
"estimate": "Estimation",
"change_parent_issue": "Modifier le problème parent",
"remove_parent_issue": "Supprimer le problème parent",
"add_parent": "Ajouter un parent",
"loading_members": "Chargement des membres..."
}

View File

@ -0,0 +1,318 @@
{
"submit": "送信",
"cancel": "キャンセル",
"loading": "読み込み中",
"error": "エラー",
"success": "成功",
"warning": "警告",
"info": "情報",
"close": "閉じる",
"yes": "はい",
"no": "いいえ",
"ok": "OK",
"name": "名前",
"description": "説明",
"search": "検索",
"add_member": "メンバーを追加",
"remove_member": "メンバーを削除",
"add_members": "メンバーを追加",
"remove_members": "メンバーを削除",
"add": "追加",
"remove": "削除",
"add_new": "新規追加",
"remove_selected": "選択項目を削除",
"first_name": "名",
"last_name": "姓",
"email": "メールアドレス",
"display_name": "表示名",
"role": "役割",
"timezone": "タイムゾーン",
"avatar": "アバター",
"cover_image": "カバー画像",
"password": "パスワード",
"change_cover": "カバーを変更",
"language": "言語",
"saving": "保存中...",
"save_changes": "変更を保存",
"deactivate_account": "アカウントを無効化",
"deactivate_account_description": "アカウントを無効化すると、そのアカウント内のすべてのデータとリソースが完全に削除され、復元することはできません。",
"profile_settings": "プロフィール設定",
"your_account": "アカウント",
"profile": "プロフィール",
"security": "セキュリティ",
"activity": "アクティビティ",
"appearance": "アピアンス",
"notifications": "通知",
"workspaces": "ワークスペース",
"create_workspace": "ワークスペースを作成",
"invitations": "招待",
"summary": "概要",
"assigned": "割り当て済み",
"created": "作成済み",
"subscribed": "購読済み",
"you_do_not_have_the_permission_to_access_this_page": "このページにアクセスする権限がありません。",
"failed_to_sign_out_please_try_again": "サインアウトに失敗しました。もう一度お試しください。",
"password_changed_successfully": "パスワードが正常に変更されました。",
"something_went_wrong_please_try_again": "何かがうまくいきませんでした。もう一度お試しください。",
"change_password": "パスワードを変更",
"passwords_dont_match": "パスワードが一致しません",
"current_password": "現在のパスワード",
"new_password": "新しいパスワード",
"confirm_password": "パスワードを確認",
"this_field_is_required": "このフィールドは必須です",
"changing_password": "パスワードを変更中",
"please_enter_your_password": "パスワードを入力してください。",
"password_length_should_me_more_than_8_characters": "パスワードの長さは8文字以上である必要があります。",
"password_is_weak": "パスワードが弱いです。",
"password_is_strong": "パスワードが強いです。",
"load_more": "もっと読み込む",
"select_or_customize_your_interface_color_scheme": "インターフェースのカラースキームを選択またはカスタマイズしてください。",
"theme": "テーマ",
"system_preference": "システム設定",
"light": "ライト",
"dark": "ダーク",
"light_contrast": "ライト高コントラスト",
"dark_contrast": "ダーク高コントラスト",
"custom": "カスタムテーマ",
"select_your_theme": "テーマを選択",
"customize_your_theme": "テーマをカスタマイズ",
"background_color": "背景色",
"text_color": "テキスト色",
"primary_color": "プライマリ(テーマ)色",
"sidebar_background_color": "サイドバー背景色",
"sidebar_text_color": "サイドバーテキスト色",
"set_theme": "テーマを設定",
"enter_a_valid_hex_code_of_6_characters": "6文字の有効な16進コードを入力してください",
"background_color_is_required": "背景色は必須です",
"text_color_is_required": "テキスト色は必須です",
"primary_color_is_required": "プライマリ色は必須です",
"sidebar_background_color_is_required": "サイドバー背景色は必須です",
"sidebar_text_color_is_required": "サイドバーテキスト色は必須です",
"updating_theme": "テーマを更新中",
"theme_updated_successfully": "テーマが正常に更新されました",
"failed_to_update_the_theme": "テーマの更新に失敗しました",
"email_notifications": "メール通知",
"stay_in_the_loop_on_issues_you_are_subscribed_to_enable_this_to_get_notified": "購読している問題についての通知を受け取るには、これを有効にしてください。",
"email_notification_setting_updated_successfully": "メール通知設定が正常に更新されました",
"failed_to_update_email_notification_setting": "メール通知設定の更新に失敗しました",
"notify_me_when": "通知する条件",
"property_changes": "プロパティの変更",
"property_changes_description": "担当者、優先度、見積もりなどのプロパティが変更されたときに通知します。",
"state_change": "状態の変更",
"state_change_description": "問題が別の状態に移動したときに通知します",
"issue_completed": "問題が完了",
"issue_completed_description": "問題が完了したときのみ通知します",
"comments": "コメント",
"comments_description": "誰かが問題にコメントを残したときに通知します",
"mentions": "メンション",
"mentions_description": "コメントや説明で誰かが自分をメンションしたときのみ通知します",
"create_your_workspace": "ワークスペースを作成",
"only_your_instance_admin_can_create_workspaces": "ワークスペースを作成できるのはインスタンス管理者のみです",
"only_your_instance_admin_can_create_workspaces_description": "インスタンス管理者のメールアドレスを知っている場合は、以下のボタンをクリックして連絡を取ってください。",
"go_back": "戻る",
"request_instance_admin": "インスタンス管理者にリクエスト",
"plane_logo": "プレーンロゴ",
"workspace_creation_disabled": "ワークスペースの作成が無効化されています",
"workspace_request_subject": "新しいワークスペースのリクエスト",
"workspace_request_body": "インスタンス管理者様\n\nURL [/workspace-name] で新しいワークスペースを作成してください。[ワークスペース作成の目的]\n\nありがとうございます。\n{{firstName}} {{lastName}}\n{{email}}",
"creating_workspace": "ワークスペースを作成中",
"workspace_created_successfully": "ワークスペースが正常に作成されました",
"create_workspace_page": "ワークスペース作成ページ",
"workspace_could_not_be_created_please_try_again": "ワークスペースを作成できませんでした。もう一度お試しください。",
"workspace_could_not_be_created_please_try_again_description": "ワークスペースの作成中にエラーが発生しました。もう一度お試しください。",
"this_is_a_required_field": "これは必須フィールドです。",
"name_your_workspace": "ワークスペースに名前を付ける",
"workspaces_names_can_contain_only_space_dash_and_alphanumeric_characters": "ワークスペース名にはスペース、ダッシュ、アンダースコア、英数字のみを含めることができます。",
"limit_your_name_to_80_characters": "名前は80文字以内にしてください。",
"set_your_workspace_url": "ワークスペースのURLを設定",
"limit_your_url_to_48_characters": "URLは48文字以内にしてください。",
"how_many_people_will_use_this_workspace": "このワークスペースを使用する人数は?",
"how_many_people_will_use_this_workspace_description": "購入するシート数を決定するのに役立ちます。",
"select_a_range": "範囲を選択",
"urls_can_contain_only_dash_and_alphanumeric_characters": "URLにはダッシュと英数字のみを含めることができます。",
"something_familiar_and_recognizable_is_always_best": "親しみやすく認識しやすいものが常に最適です。",
"workspace_url_is_already_taken": "ワークスペースのURLは既に使用されています",
"old_password": "古いパスワード",
"general_settings": "一般設定",
"sign_out": "サインアウト",
"signing_out": "サインアウト中",
"active_cycles": "アクティブサイクル",
"active_cycles_description": "プロジェクト全体のサイクルを監視し、高優先度の問題を追跡し、注意が必要なサイクルにズームインします。",
"on_demand_snapshots_of_all_your_cycles": "すべてのサイクルのオンデマンドスナップショット",
"upgrade": "アップグレード",
"10000_feet_view": "すべてのアクティブサイクルの10,000フィートビュー。",
"10000_feet_view_description": "各プロジェクトのサイクルを個別に見るのではなく、すべてのプロジェクトのサイクルを一度に見るためにズームアウトします。",
"get_snapshot_of_each_active_cycle": "各アクティブサイクルのスナップショットを取得します。",
"get_snapshot_of_each_active_cycle_description": "すべてのアクティブサイクルの高レベルのメトリクスを追跡し、進捗状況を確認し、期限に対するスコープの感覚を得ます。",
"compare_burndowns": "バーンダウンを比較します。",
"compare_burndowns_description": "各チームのパフォーマンスを監視し、各サイクルのバーンダウンレポートを覗き見します。",
"quickly_see_make_or_break_issues": "重要な問題をすばやく確認します。",
"quickly_see_make_or_break_issues_description": "各サイクルの期限に対する高優先度の問題をプレビューします。1クリックでサイクルごとにすべてを確認できます。",
"zoom_into_cycles_that_need_attention": "注意が必要なサイクルにズームインします。",
"zoom_into_cycles_that_need_attention_description": "期待に沿わないサイクルの状態を1クリックで調査します。",
"stay_ahead_of_blockers": "ブロッカーを先取りします。",
"stay_ahead_of_blockers_description": "プロジェクト間の課題を見つけ、他のビューからは明らかでないサイクル間の依存関係を確認します。",
"analytics": "分析",
"workspace_invites": "ワークスペースの招待",
"workspace_settings": "ワークスペース設定",
"enter_god_mode": "ゴッドモードに入る",
"workspace_logo": "ワークスペースロゴ",
"new_issue": "新しい問題",
"home": "ホーム",
"your_work": "あなたの作業",
"drafts": "下書き",
"projects": "プロジェクト",
"views": "ビュー",
"workspace": "ワークスペース",
"archives": "アーカイブ",
"settings": "設定",
"failed_to_move_favorite": "お気に入りの移動に失敗しました",
"your_favorites": "あなたのお気に入り",
"no_favorites_yet": "まだお気に入りはありません",
"create_folder": "フォルダーを作成",
"new_folder": "新しいフォルダー",
"favorite_updated_successfully": "お気に入りが正常に更新されました",
"favorite_created_successfully": "お気に入りが正常に作成されました",
"folder_already_exists": "フォルダーは既に存在します",
"folder_name_cannot_be_empty": "フォルダー名を空にすることはできません",
"something_went_wrong": "何かがうまくいきませんでした",
"failed_to_reorder_favorite": "お気に入りの並べ替えに失敗しました",
"favorite_removed_successfully": "お気に入りが正常に削除されました",
"failed_to_create_favorite": "お気に入りの作成に失敗しました",
"failed_to_rename_favorite": "お気に入りの名前変更に失敗しました",
"project_link_copied_to_clipboard": "プロジェクトリンクがクリップボードにコピーされました",
"link_copied": "リンクがコピーされました",
"your_projects": "あなたのプロジェクト",
"add_project": "プロジェクトを追加",
"create_project": "プロジェクトを作成",
"failed_to_remove_project_from_favorites": "お気に入りからプロジェクトを削除できませんでした。もう一度お試しください。",
"project_created_successfully": "プロジェクトが正常に作成されました",
"project_created_successfully_description": "プロジェクトが正常に作成されました。今すぐ問題を追加し始めることができます。",
"project_cover_image_alt": "プロジェクトカバー画像",
"name_is_required": "名前は必須です",
"title_should_be_less_than_255_characters": "タイトルは255文字未満である必要があります",
"project_name": "プロジェクト名",
"project_id_must_be_at_least_1_character": "プロジェクトIDは少なくとも1文字である必要があります",
"project_id_must_be_at_most_5_characters": "プロジェクトIDは最大5文字である必要があります",
"project_id": "プロジェクトID",
"project_id_tooltip_content": "プロジェクト内の問題を一意に識別するのに役立ちます。最大5文字。",
"description_placeholder": "説明...",
"only_alphanumeric_non_latin_characters_allowed": "英数字と非ラテン文字のみが許可されます。",
"project_id_is_required": "プロジェクトIDは必須です",
"select_network": "ネットワークを選択",
"lead": "リード",
"private": "プライベート",
"public": "パブリック",
"accessible_only_by_invite": "招待によってのみアクセス可能",
"anyone_in_the_workspace_except_guests_can_join": "ゲストを除くワークスペース内の誰でも参加できます",
"creating": "作成中",
"creating_project": "プロジェクトを作成中",
"adding_project_to_favorites": "プロジェクトをお気に入りに追加中",
"project_added_to_favorites": "プロジェクトがお気に入りに追加されました",
"couldnt_add_the_project_to_favorites": "プロジェクトをお気に入りに追加できませんでした。もう一度お試しください。",
"removing_project_from_favorites": "お気に入りからプロジェクトを削除中",
"project_removed_from_favorites": "プロジェクトがお気に入りから削除されました",
"couldnt_remove_the_project_from_favorites": "お気に入りからプロジェクトを削除できませんでした。もう一度お試しください。",
"add_to_favorites": "お気に入りに追加",
"remove_from_favorites": "お気に入りから削除",
"publish_settings": "公開設定",
"publish": "公開",
"copy_link": "リンクをコピー",
"leave_project": "プロジェクトを離れる",
"join_the_project_to_rearrange": "プロジェクトに参加して並べ替え",
"drag_to_rearrange": "ドラッグして並べ替え",
"congrats": "おめでとうございます!",
"project": "プロジェクト",
"open_project": "プロジェクトを開く",
"issues": "問題",
"cycles": "サイクル",
"modules": "モジュール",
"pages": "ページ",
"intake": "インテーク",
"time_tracking": "時間追跡",
"work_management": "作業管理",
"projects_and_issues": "プロジェクトと問題",
"projects_and_issues_description": "このプロジェクトでオンまたはオフに切り替えます。",
"cycles_description": "プロジェクトごとに作業をタイムボックス化し、期間ごとに頻度を変更します。",
"modules_description": "独自のリードと担当者を持つサブプロジェクトのようなセットアップに作業をグループ化します。",
"views_description": "後で使用するために、または共有するためにソート、フィルター、表示オプションを保存します。",
"pages_description": "何かを書くように何かを書く。",
"intake_description": "購読している問題についての通知を受け取るには、これを有効にしてください。",
"time_tracking_description": "問題とプロジェクトに費やした時間を追跡します。",
"work_management_description": "作業とプロジェクトを簡単に管理します。",
"documentation": "ドキュメント",
"message_support": "サポートにメッセージを送る",
"contact_sales": "営業に連絡",
"hyper_mode": "ハイパーモード",
"keyboard_shortcuts": "キーボードショートカット",
"whats_new": "新着情報",
"version": "バージョン",
"we_are_having_trouble_fetching_the_updates": "更新の取得に問題が発生しています。",
"our_changelogs": "私たちの変更履歴",
"for_the_latest_updates": "最新の更新情報については",
"please_visit": "訪問してください",
"docs": "ドキュメント",
"full_changelog": "完全な変更履歴",
"support": "サポート",
"discord": "ディスコード",
"powered_by_plane_pages": "Plane Pagesによって提供されています",
"please_select_at_least_one_invitation": "少なくとも1つの招待を選択してください。",
"please_select_at_least_one_invitation_description": "ワークスペースに参加するために少なくとも1つの招待を選択してください。",
"we_see_that_someone_has_invited_you_to_join_a_workspace": "誰かがワークスペースに参加するようにあなたを招待したことがわかります",
"join_a_workspace": "ワークスペースに参加",
"we_see_that_someone_has_invited_you_to_join_a_workspace_description": "誰かがワークスペースに参加するようにあなたを招待したことがわかります",
"join_a_workspace_description": "ワークスペースに参加",
"accept_and_join": "受け入れて参加",
"go_home": "ホームに戻る",
"no_pending_invites": "保留中の招待はありません",
"you_can_see_here_if_someone_invites_you_to_a_workspace": "誰かがワークスペースに招待した場合、ここで確認できます",
"back_to_home": "ホームに戻る",
"workspace_name": "ワークスペース名",
"deactivate_your_account": "アカウントを無効化",
"deactivate_your_account_description": "無効化すると、問題を割り当てられなくなり、ワークスペースの請求もされなくなります。アカウントを再有効化するには、このメールアドレスに招待されたワークスペースが必要です。",
"deactivating": "無効化中",
"confirm": "確認",
"draft_created": "下書きが作成されました",
"issue_created_successfully": "問題が正常に作成されました",
"draft_creation_failed": "下書き作成に失敗しました",
"issue_creation_failed": "問題作成に失敗しました",
"draft_issue": "下書き問題",
"issue_updated_successfully": "問題が正常に更新されました",
"issue_could_not_be_updated": "問題を更新できませんでした",
"create_a_draft": "下書きを作成",
"save_to_drafts": "下書きに保存",
"save": "保存",
"updating": "更新中",
"create_new_issue": "新しい問題を作成",
"editor_is_not_ready_to_discard_changes": "エディターは変更を破棄する準備ができていません",
"failed_to_move_issue_to_project": "問題をプロジェクトに移動できませんでした",
"create_more": "作成する",
"add_to_project": "プロジェクトに追加",
"discard": "破棄",
"duplicate_issue_found": "重複問題が見つかりました",
"duplicate_issues_found": "重複問題が見つかりました",
"no_matching_results": "一致する結果はありません",
"title_is_required": "タイトルは必須です",
"title": "タイトル",
"state": "ステータス",
"priority": "優先度",
"none": "なし",
"urgent": "緊急",
"high": "高",
"medium": "中",
"low": "低",
"members": "メンバー",
"assignee": "アサイン者",
"assignees": "アサイン者",
"you": "あなた",
"labels": "ラベル",
"create_new_label": "新しいラベルを作成",
"start_date": "開始日",
"due_date": "期限日",
"cycle": "サイクル",
"estimate": "見積もり",
"change_parent_issue": "親問題を変更",
"remove_parent_issue": "親問題を削除",
"add_parent": "親問題を追加",
"loading_members": "メンバーを読み込んでいます..."
}

View File

@ -0,0 +1,10 @@
{
"extends": "@plane/typescript-config/react-library.json",
"compilerOptions": {
"jsx": "react",
"lib": ["esnext", "dom"],
"resolveJsonModule": true
},
"include": ["./src"],
"exclude": ["dist", "build", "node_modules"]
}

View File

@ -25,7 +25,6 @@ export interface IUser extends IUserLite {
is_password_autoset: boolean; is_password_autoset: boolean;
is_tour_completed: boolean; is_tour_completed: boolean;
mobile_number: string | null; mobile_number: string | null;
role: string | null;
last_workspace_id: string; last_workspace_id: string;
user_timezone: string; user_timezone: string;
username: string; username: string;
@ -62,6 +61,7 @@ export type TUserProfile = {
billing_address_country: string | undefined; billing_address_country: string | undefined;
billing_address: string | undefined; billing_address: string | undefined;
has_billing_address: boolean; has_billing_address: boolean;
language: string;
created_at: Date | string; created_at: Date | string;
updated_at: Date | string; updated_at: Date | string;
}; };

View File

@ -54,6 +54,7 @@ export class ProfileStore implements IProfileStore {
has_billing_address: false, has_billing_address: false,
created_at: "", created_at: "",
updated_at: "", updated_at: "",
language: "",
}; };
// services // services

View File

@ -1,6 +1,6 @@
"use client"; "use client";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { useTranslation } from "@plane/i18n";
// ui // ui
import { Breadcrumbs, ContrastIcon, Header } from "@plane/ui"; import { Breadcrumbs, ContrastIcon, Header } from "@plane/ui";
// components // components
@ -8,7 +8,9 @@ import { BreadcrumbLink } from "@/components/common";
// plane web components // plane web components
import { UpgradeBadge } from "@/plane-web/components/workspace"; import { UpgradeBadge } from "@/plane-web/components/workspace";
export const WorkspaceActiveCycleHeader = observer(() => ( export const WorkspaceActiveCycleHeader = observer(() => {
const { t } = useTranslation();
return (
<Header> <Header>
<Header.LeftItem> <Header.LeftItem>
<Breadcrumbs> <Breadcrumbs>
@ -16,7 +18,7 @@ export const WorkspaceActiveCycleHeader = observer(() => (
type="text" type="text"
link={ link={
<BreadcrumbLink <BreadcrumbLink
label="Active cycles" label={t("active_cycles")}
icon={<ContrastIcon className="h-4 w-4 text-custom-text-300 rotate-180" />} icon={<ContrastIcon className="h-4 w-4 text-custom-text-300 rotate-180" />}
/> />
} }
@ -25,4 +27,5 @@ export const WorkspaceActiveCycleHeader = observer(() => (
<UpgradeBadge size="md" /> <UpgradeBadge size="md" />
</Header.LeftItem> </Header.LeftItem>
</Header> </Header>
)); );
});

View File

@ -3,8 +3,8 @@
import { useEffect } from "react"; import { useEffect } from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { useSearchParams } from "next/navigation"; import { useSearchParams } from "next/navigation";
// icons
import { BarChart2, PanelRight } from "lucide-react"; import { BarChart2, PanelRight } from "lucide-react";
import { useTranslation } from "@plane/i18n";
// ui // ui
import { Breadcrumbs, Header } from "@plane/ui"; import { Breadcrumbs, Header } from "@plane/ui";
// components // components
@ -13,8 +13,8 @@ import { BreadcrumbLink } from "@/components/common";
import { cn } from "@/helpers/common.helper"; import { cn } from "@/helpers/common.helper";
// hooks // hooks
import { useAppTheme } from "@/hooks/store"; import { useAppTheme } from "@/hooks/store";
export const WorkspaceAnalyticsHeader = observer(() => { export const WorkspaceAnalyticsHeader = observer(() => {
const { t } = useTranslation();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const analytics_tab = searchParams.get("analytics_tab"); const analytics_tab = searchParams.get("analytics_tab");
// store hooks // store hooks
@ -41,7 +41,7 @@ export const WorkspaceAnalyticsHeader = observer(() => {
<Breadcrumbs> <Breadcrumbs>
<Breadcrumbs.BreadcrumbItem <Breadcrumbs.BreadcrumbItem
type="text" type="text"
link={<BreadcrumbLink label="Analytics" icon={<BarChart2 className="h-4 w-4 text-custom-text-300" />} />} link={<BreadcrumbLink label={t("analytics")} icon={<BarChart2 className="h-4 w-4 text-custom-text-300" />} />}
/> />
</Breadcrumbs> </Breadcrumbs>
{analytics_tab === "custom" ? ( {analytics_tab === "custom" ? (

View File

@ -4,6 +4,7 @@ import { observer } from "mobx-react";
import { useParams, usePathname } from "next/navigation"; import { useParams, usePathname } from "next/navigation";
import useSWR from "swr"; import useSWR from "swr";
// components // components
import { useTranslation } from "@plane/i18n";
import { AppHeader, ContentWrapper } from "@/components/core"; import { AppHeader, ContentWrapper } from "@/components/core";
import { ProfileSidebar } from "@/components/profile"; import { ProfileSidebar } from "@/components/profile";
// constants // constants
@ -32,6 +33,7 @@ const UseProfileLayout: React.FC<Props> = observer((props) => {
const pathname = usePathname(); const pathname = usePathname();
// store hooks // store hooks
const { allowPermissions } = useUserPermissions(); const { allowPermissions } = useUserPermissions();
const { t } = useTranslation();
// derived values // derived values
const isAuthorized = allowPermissions( const isAuthorized = allowPermissions(
[EUserPermissions.ADMIN, EUserPermissions.MEMBER], [EUserPermissions.ADMIN, EUserPermissions.MEMBER],
@ -79,7 +81,7 @@ const UseProfileLayout: React.FC<Props> = observer((props) => {
<div className={`w-full overflow-hidden h-full`}>{children}</div> <div className={`w-full overflow-hidden h-full`}>{children}</div>
) : ( ) : (
<div className="grid h-full w-full place-items-center text-custom-text-200"> <div className="grid h-full w-full place-items-center text-custom-text-200">
You do not have the permission to access this page. {t("you_do_not_have_the_permission_to_access_this_page")}
</div> </div>
)} )}
</div> </div>

View File

@ -2,6 +2,7 @@ import React from "react";
import Link from "next/link"; import Link from "next/link";
import { useParams, usePathname } from "next/navigation"; import { useParams, usePathname } from "next/navigation";
import { useTranslation } from "@plane/i18n";
// components // components
// constants // constants
@ -14,7 +15,7 @@ type Props = {
export const ProfileNavbar: React.FC<Props> = (props) => { export const ProfileNavbar: React.FC<Props> = (props) => {
const { isAuthorized } = props; const { isAuthorized } = props;
const { t } = useTranslation();
const { workspaceSlug, userId } = useParams(); const { workspaceSlug, userId } = useParams();
const pathname = usePathname(); const pathname = usePathname();
@ -32,7 +33,7 @@ export const ProfileNavbar: React.FC<Props> = (props) => {
: "border-transparent" : "border-transparent"
}`} }`}
> >
{tab.label} {t(tab.label)}
</span> </span>
</Link> </Link>
))} ))}

View File

@ -5,6 +5,7 @@ import { observer } from "mobx-react";
import Image from "next/image"; import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import { useTheme } from "next-themes"; import { useTheme } from "next-themes";
import { useTranslation } from "@plane/i18n";
import { IWorkspace } from "@plane/types"; import { IWorkspace } from "@plane/types";
// components // components
import { Button, getButtonStyling } from "@plane/ui"; import { Button, getButtonStyling } from "@plane/ui";
@ -22,6 +23,7 @@ import WhiteHorizontalLogo from "@/public/plane-logos/white-horizontal-with-blue
import WorkspaceCreationDisabled from "@/public/workspace/workspace-creation-disabled.png"; import WorkspaceCreationDisabled from "@/public/workspace/workspace-creation-disabled.png";
const CreateWorkspacePage = observer(() => { const CreateWorkspacePage = observer(() => {
const { t } = useTranslation();
// router // router
const router = useAppRouter(); const router = useAppRouter();
// store hooks // store hooks
@ -38,6 +40,17 @@ const CreateWorkspacePage = observer(() => {
// derived values // derived values
const isWorkspaceCreationDisabled = getIsWorkspaceCreationDisabled(); const isWorkspaceCreationDisabled = getIsWorkspaceCreationDisabled();
// methods
const getMailtoHref = () => {
const subject = t("workspace_request_subject");
const body = t("workspace_request_body")
.replace("{{firstName}}", currentUser?.first_name || "")
.replace("{{lastName}}", currentUser?.last_name || "")
.replace("{{email}}", currentUser?.email || "");
return `mailto:?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(body)}`;
};
const onSubmit = async (workspace: IWorkspace) => { const onSubmit = async (workspace: IWorkspace) => {
await updateUserProfile({ last_workspace_id: workspace.id }).then(() => router.push(`/${workspace.slug}`)); await updateUserProfile({ last_workspace_id: workspace.id }).then(() => router.push(`/${workspace.slug}`));
}; };
@ -54,7 +67,7 @@ const CreateWorkspacePage = observer(() => {
href="/" href="/"
> >
<div className="h-[30px] w-[133px]"> <div className="h-[30px] w-[133px]">
<Image src={logo} alt="Plane logo" /> <Image src={logo} alt={t("plane_logo")} />
</div> </div>
</Link> </Link>
<div className="absolute right-4 top-1/4 -translate-y-1/2 text-sm text-custom-text-100 sm:fixed sm:right-16 sm:top-12 sm:translate-y-0 sm:py-5"> <div className="absolute right-4 top-1/4 -translate-y-1/2 text-sm text-custom-text-100 sm:fixed sm:right-16 sm:top-12 sm:translate-y-0 sm:py-5">
@ -64,27 +77,30 @@ const CreateWorkspacePage = observer(() => {
<div className="relative flex h-full justify-center px-8 pb-8 sm:w-10/12 sm:items-center sm:justify-start sm:p-0 sm:pr-[8.33%] md:w-9/12 lg:w-4/5"> <div className="relative flex h-full justify-center px-8 pb-8 sm:w-10/12 sm:items-center sm:justify-start sm:p-0 sm:pr-[8.33%] md:w-9/12 lg:w-4/5">
{isWorkspaceCreationDisabled ? ( {isWorkspaceCreationDisabled ? (
<div className="w-4/5 h-full flex flex-col items-center justify-center text-lg font-medium gap-1"> <div className="w-4/5 h-full flex flex-col items-center justify-center text-lg font-medium gap-1">
<Image src={WorkspaceCreationDisabled} width={200} alt="Workspace creation disabled" className="mb-4" /> <Image
<div className="text-lg font-medium text-center">Only your instance admin can create workspaces</div> src={WorkspaceCreationDisabled}
<p className="text-sm text-custom-text-300 text-center"> width={200}
If you know your instance admin&apos;s email address, <br /> click the button below to get in touch with alt={t("workspace_creation_disabled")}
them. className="mb-4"
/>
<div className="text-lg font-medium text-center">
{t("only_your_instance_admin_can_create_workspaces")}
</div>
<p className="text-sm text-custom-text-300 break-words text-center">
{t("only_your_instance_admin_can_create_workspaces_description")}
</p> </p>
<div className="flex gap-4 mt-6"> <div className="flex gap-4 mt-6">
<Button variant="primary" onClick={() => router.back()}> <Button variant="primary" onClick={() => router.back()}>
Go back {t("go_back")}
</Button> </Button>
<a <a href={getMailtoHref()} className={getButtonStyling("outline-primary", "md")}>
href={`mailto:?subject=${encodeURIComponent("Requesting a new workspace")}&body=${encodeURIComponent(`Hi instance admin(s),\n\nPlease create a new workspace with the URL [/workspace-name] for [purpose of creating the workspace].\n\nThanks,\n${currentUser?.first_name} ${currentUser?.last_name}\n${currentUser?.email}`)}`} {t("request_instance_admin")}
className={getButtonStyling("outline-primary", "md")}
>
Request instance admin
</a> </a>
</div> </div>
</div> </div>
) : ( ) : (
<div className="w-full space-y-7 sm:space-y-10"> <div className="w-full space-y-7 sm:space-y-10">
<h4 className="text-2xl font-semibold">Create your workspace</h4> <h4 className="text-2xl font-semibold">{t("create_your_workspace")}</h4>
<div className="sm:w-3/4 md:w-2/5"> <div className="sm:w-3/4 md:w-2/5">
<CreateWorkspaceForm <CreateWorkspaceForm
onSubmit={onSubmit} onSubmit={onSubmit}

View File

@ -7,8 +7,8 @@ import Link from "next/link";
import { useTheme } from "next-themes"; import { useTheme } from "next-themes";
import useSWR, { mutate } from "swr"; import useSWR, { mutate } from "swr";
// icons
import { CheckCircle2 } from "lucide-react"; import { CheckCircle2 } from "lucide-react";
import { useTranslation } from "@plane/i18n";
// types // types
import type { IWorkspaceMemberInvitation } from "@plane/types"; import type { IWorkspaceMemberInvitation } from "@plane/types";
// ui // ui
@ -45,6 +45,7 @@ const UserInvitationsPage = observer(() => {
// router // router
const router = useAppRouter(); const router = useAppRouter();
// store hooks // store hooks
const { t } = useTranslation();
const { captureEvent, joinWorkspaceMetricGroup } = useEventTracker(); const { captureEvent, joinWorkspaceMetricGroup } = useEventTracker();
const { data: currentUser } = useUser(); const { data: currentUser } = useUser();
const { updateUserProfile } = useUserProfile(); const { updateUserProfile } = useUserProfile();
@ -72,8 +73,8 @@ const UserInvitationsPage = observer(() => {
if (invitationsRespond.length === 0) { if (invitationsRespond.length === 0) {
setToast({ setToast({
type: TOAST_TYPE.ERROR, type: TOAST_TYPE.ERROR,
title: "Error!", title: t("error"),
message: "Please select at least one invitation.", message: t("please_select_at_least_one_invitation"),
}); });
return; return;
} }
@ -107,8 +108,8 @@ const UserInvitationsPage = observer(() => {
.catch(() => { .catch(() => {
setToast({ setToast({
type: TOAST_TYPE.ERROR, type: TOAST_TYPE.ERROR,
title: "Error!", title: t("error"),
message: "Something went wrong, Please try again.", message: t("something_went_wrong_please_try_again"),
}); });
setIsJoiningWorkspaces(false); setIsJoiningWorkspaces(false);
}); });
@ -122,8 +123,8 @@ const UserInvitationsPage = observer(() => {
}); });
setToast({ setToast({
type: TOAST_TYPE.ERROR, type: TOAST_TYPE.ERROR,
title: "Error!", title: t("error"),
message: "Something went wrong, Please try again.", message: t("something_went_wrong_please_try_again"),
}); });
setIsJoiningWorkspaces(false); setIsJoiningWorkspaces(false);
}); });
@ -152,8 +153,8 @@ const UserInvitationsPage = observer(() => {
invitations.length > 0 ? ( invitations.length > 0 ? (
<div className="relative flex h-full justify-center px-8 pb-8 sm:w-10/12 sm:items-center sm:justify-start sm:p-0 sm:pr-[8.33%] md:w-9/12 lg:w-4/5"> <div className="relative flex h-full justify-center px-8 pb-8 sm:w-10/12 sm:items-center sm:justify-start sm:p-0 sm:pr-[8.33%] md:w-9/12 lg:w-4/5">
<div className="w-full space-y-10"> <div className="w-full space-y-10">
<h5 className="text-lg">We see that someone has invited you to</h5> <h5 className="text-lg">{t("we_see_that_someone_has_invited_you_to_join_a_workspace")}</h5>
<h4 className="text-2xl font-semibold">Join a workspace</h4> <h4 className="text-2xl font-semibold">{t("join_a_workspace")}</h4>
<div className="max-h-[37vh] space-y-4 overflow-y-auto md:w-3/5"> <div className="max-h-[37vh] space-y-4 overflow-y-auto md:w-3/5">
{invitations.map((invitation) => { {invitations.map((invitation) => {
const isSelected = invitationsRespond.includes(invitation.id); const isSelected = invitationsRespond.includes(invitation.id);
@ -207,12 +208,12 @@ const UserInvitationsPage = observer(() => {
disabled={isJoiningWorkspaces || invitationsRespond.length === 0} disabled={isJoiningWorkspaces || invitationsRespond.length === 0}
loading={isJoiningWorkspaces} loading={isJoiningWorkspaces}
> >
Accept & Join {t("accept_and_join")}
</Button> </Button>
<Link href={`/${redirectWorkspaceSlug}`}> <Link href={`/${redirectWorkspaceSlug}`}>
<span> <span>
<Button variant="neutral-primary" size="md"> <Button variant="neutral-primary" size="md">
Go Home {t("go_home")}
</Button> </Button>
</span> </span>
</Link> </Link>
@ -222,11 +223,11 @@ const UserInvitationsPage = observer(() => {
) : ( ) : (
<div className="fixed left-0 top-0 grid h-full w-full place-items-center"> <div className="fixed left-0 top-0 grid h-full w-full place-items-center">
<EmptyState <EmptyState
title="No pending invites" title={t("no_pending_invites")}
description="You can see here if someone invites you to a workspace." description={t("you_can_see_here_if_someone_invites_you_to_a_workspace")}
image={emptyInvitation} image={emptyInvitation}
primaryButton={{ primaryButton={{
text: "Back to home", text: t("back_to_home"),
onClick: () => router.push("/"), onClick: () => router.push("/"),
}} }}
/> />

View File

@ -2,6 +2,7 @@
import { useState } from "react"; import { useState } from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { useTranslation } from "@plane/i18n";
// ui // ui
import { Button } from "@plane/ui"; import { Button } from "@plane/ui";
// components // components
@ -18,6 +19,7 @@ import { EmptyStateType } from "@/constants/empty-state";
const PER_PAGE = 100; const PER_PAGE = 100;
const ProfileActivityPage = observer(() => { const ProfileActivityPage = observer(() => {
const { t } = useTranslation();
// states // states
const [pageCount, setPageCount] = useState(1); const [pageCount, setPageCount] = useState(1);
const [totalPages, setTotalPages] = useState(0); const [totalPages, setTotalPages] = useState(0);
@ -55,12 +57,12 @@ const ProfileActivityPage = observer(() => {
<> <>
<PageHead title="Profile - Activity" /> <PageHead title="Profile - Activity" />
<ProfileSettingContentWrapper> <ProfileSettingContentWrapper>
<ProfileSettingContentHeader title="Activity" /> <ProfileSettingContentHeader title={t("activity")} />
{activityPages} {activityPages}
{isLoadMoreVisible && ( {isLoadMoreVisible && (
<div className="flex w-full items-center justify-center text-xs"> <div className="flex w-full items-center justify-center text-xs">
<Button variant="accent-primary" size="sm" onClick={handleLoadMore}> <Button variant="accent-primary" size="sm" onClick={handleLoadMore}>
Load more {t("load_more")}
</Button> </Button>
</div> </div>
)} )}

View File

@ -3,6 +3,7 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { useTheme } from "next-themes"; import { useTheme } from "next-themes";
import { useTranslation } from "@plane/i18n";
import { IUserTheme } from "@plane/types"; import { IUserTheme } from "@plane/types";
import { setPromiseToast } from "@plane/ui"; import { setPromiseToast } from "@plane/ui";
// components // components
@ -15,8 +16,8 @@ import { I_THEME_OPTION, THEME_OPTIONS } from "@/constants/themes";
import { applyTheme, unsetCustomCssVariables } from "@/helpers/theme.helper"; import { applyTheme, unsetCustomCssVariables } from "@/helpers/theme.helper";
// hooks // hooks
import { useUserProfile } from "@/hooks/store"; import { useUserProfile } from "@/hooks/store";
const ProfileAppearancePage = observer(() => { const ProfileAppearancePage = observer(() => {
const { t } = useTranslation();
const { setTheme } = useTheme(); const { setTheme } = useTheme();
// states // states
const [currentTheme, setCurrentTheme] = useState<I_THEME_OPTION | null>(null); const [currentTheme, setCurrentTheme] = useState<I_THEME_OPTION | null>(null);
@ -62,11 +63,11 @@ const ProfileAppearancePage = observer(() => {
<PageHead title="Profile - Appearance" /> <PageHead title="Profile - Appearance" />
{userProfile ? ( {userProfile ? (
<ProfileSettingContentWrapper> <ProfileSettingContentWrapper>
<ProfileSettingContentHeader title="Appearance" /> <ProfileSettingContentHeader title={t("appearance")} />
<div className="grid grid-cols-12 gap-4 py-6 sm:gap-16"> <div className="grid grid-cols-12 gap-4 py-6 sm:gap-16">
<div className="col-span-12 sm:col-span-6"> <div className="col-span-12 sm:col-span-6">
<h4 className="text-lg font-semibold text-custom-text-100">Theme</h4> <h4 className="text-lg font-semibold text-custom-text-100">{t("theme")}</h4>
<p className="text-sm text-custom-text-200">Select or customize your interface color scheme.</p> <p className="text-sm text-custom-text-200">{t("select_or_customize_your_interface_color_scheme")}</p>
</div> </div>
<div className="col-span-12 sm:col-span-6"> <div className="col-span-12 sm:col-span-6">
<ThemeSwitch value={currentTheme} onChange={handleThemeChange} /> <ThemeSwitch value={currentTheme} onChange={handleThemeChange} />

View File

@ -2,6 +2,7 @@
import useSWR from "swr"; import useSWR from "swr";
// components // components
import { useTranslation } from "@plane/i18n";
import { PageHead } from "@/components/core"; import { PageHead } from "@/components/core";
import { ProfileSettingContentHeader, ProfileSettingContentWrapper } from "@/components/profile"; import { ProfileSettingContentHeader, ProfileSettingContentWrapper } from "@/components/profile";
import { EmailNotificationForm } from "@/components/profile/notification"; import { EmailNotificationForm } from "@/components/profile/notification";
@ -12,6 +13,7 @@ import { UserService } from "@/services/user.service";
const userService = new UserService(); const userService = new UserService();
export default function ProfileNotificationPage() { export default function ProfileNotificationPage() {
const { t } = useTranslation();
// fetching user email notification settings // fetching user email notification settings
const { data, isLoading } = useSWR("CURRENT_USER_EMAIL_NOTIFICATION_SETTINGS", () => const { data, isLoading } = useSWR("CURRENT_USER_EMAIL_NOTIFICATION_SETTINGS", () =>
userService.currentUserEmailNotificationSettings() userService.currentUserEmailNotificationSettings()
@ -23,11 +25,11 @@ export default function ProfileNotificationPage() {
return ( return (
<> <>
<PageHead title="Profile - Notifications" /> <PageHead title={`${t("profile")} - ${t("notifications")}`} />
<ProfileSettingContentWrapper> <ProfileSettingContentWrapper>
<ProfileSettingContentHeader <ProfileSettingContentHeader
title="Email notifications" title={t("email_notifications")}
description="Stay in the loop on Issues you are subscribed to. Enable this to get notified." description={t("stay_in_the_loop_on_issues_you_are_subscribed_to_enable_this_to_get_notified")}
/> />
<EmailNotificationForm data={data} /> <EmailNotificationForm data={data} />
</ProfileSettingContentWrapper> </ProfileSettingContentWrapper>

View File

@ -1,129 +1,18 @@
"use client"; "use client";
import React, { useEffect, useState } from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { Controller, useForm } from "react-hook-form"; import { useTranslation } from "@plane/i18n";
import { ChevronDown, CircleUserRound } from "lucide-react";
import { Disclosure, Transition } from "@headlessui/react";
import type { IUser, TUserProfile } from "@plane/types";
import {
Button,
CustomSelect,
CustomSearchSelect,
Input,
TOAST_TYPE,
setPromiseToast,
setToast,
Tooltip,
} from "@plane/ui";
// components // components
import { DeactivateAccountModal } from "@/components/account";
import { LogoSpinner } from "@/components/common"; import { LogoSpinner } from "@/components/common";
import { ImagePickerPopover, UserImageUploadModal, PageHead } from "@/components/core"; import { PageHead } from "@/components/core";
import { TimezoneSelect } from "@/components/global"; import { ProfileSettingContentWrapper, ProfileForm } from "@/components/profile";
import { ProfileSettingContentWrapper } from "@/components/profile";
// constants
import { USER_ROLES } from "@/constants/workspace";
// helpers
import { getFileURL } from "@/helpers/file.helper";
// hooks // hooks
import { useUser, useUserProfile } from "@/hooks/store"; import { useUser } from "@/hooks/store";
const defaultValues: Partial<IUser> = {
avatar_url: "",
cover_image_url: "",
first_name: "",
last_name: "",
display_name: "",
email: "",
role: "Product / Project Manager",
user_timezone: "Asia/Kolkata",
};
const ProfileSettingsPage = observer(() => { const ProfileSettingsPage = observer(() => {
// states const { t } = useTranslation();
const [isLoading, setIsLoading] = useState(false);
const [isImageUploadModalOpen, setIsImageUploadModalOpen] = useState(false);
const [deactivateAccountModal, setDeactivateAccountModal] = useState(false);
// form info
const {
handleSubmit,
reset,
watch,
control,
setValue,
formState: { errors },
} = useForm<IUser>({ defaultValues });
// derived values
const userAvatar = watch("avatar_url");
const userCover = watch("cover_image_url");
// store hooks // store hooks
const { data: currentUser, updateCurrentUser } = useUser(); const { data: currentUser, userProfile } = useUser();
const { updateUserProfile, data: currentUserProfile } = useUserProfile();
useEffect(() => {
reset({ ...defaultValues, ...currentUser, ...currentUserProfile });
}, [currentUser, currentUserProfile, reset]);
const onSubmit = async (formData: IUser) => {
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,
user_timezone: formData.user_timezone,
};
const userProfilePayload: Partial<TUserProfile> = {
role: formData.role ?? undefined,
};
// if unsplash or a pre-defined image is uploaded, delete the old uploaded asset
if (formData.cover_image_url?.startsWith("http")) {
userPayload.cover_image = formData.cover_image_url;
userPayload.cover_image_asset = null;
}
const updateUser = Promise.all([updateCurrentUser(userPayload), updateUserProfile(userProfilePayload)]).finally(
() => setIsLoading(false)
);
setPromiseToast(updateUser, {
loading: "Updating...",
success: {
title: "Success!",
message: () => `Profile updated successfully.`,
},
error: {
title: "Error!",
message: () => `There was some error in updating your profile. Please try again.`,
},
});
};
const handleDelete = 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", "");
})
.catch(() => {
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "There was some error in deleting your profile picture. Please try again.",
});
})
.finally(() => {
setIsImageUploadModalOpen(false);
});
};
if (!currentUser) if (!currentUser)
return ( return (
@ -134,307 +23,9 @@ const ProfileSettingsPage = observer(() => {
return ( return (
<> <>
<PageHead title="Profile - General Settings" /> <PageHead title={`${t("profile")} - ${t("general_settings")}`} />
<ProfileSettingContentWrapper> <ProfileSettingContentWrapper>
<Controller <ProfileForm user={currentUser} profile={userProfile.data} />
control={control}
name="avatar_url"
render={({ field: { onChange, value } }) => (
<UserImageUploadModal
isOpen={isImageUploadModalOpen}
onClose={() => setIsImageUploadModalOpen(false)}
handleRemove={async () => await handleDelete(currentUser?.avatar_url)}
onSuccess={(url) => {
onChange(url);
handleSubmit(onSubmit)();
setIsImageUploadModalOpen(false);
}}
value={value && value.trim() !== "" ? value : null}
/>
)}
/>
<DeactivateAccountModal isOpen={deactivateAccountModal} onClose={() => setDeactivateAccountModal(false)} />
<form onSubmit={handleSubmit(onSubmit)}>
<div className="flex w-full flex-col gap-6">
<div className="relative h-44 w-full">
<img
src={userCover ? getFileURL(userCover) : "https://images.unsplash.com/photo-1506383796573-caf02b4a79ab"}
className="h-44 w-full rounded-lg object-cover"
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-custom-background-90">
<button type="button" onClick={() => setIsImageUploadModalOpen(true)}>
{!userAvatar || userAvatar === "" ? (
<div className="h-16 w-16 rounded-md bg-custom-background-80 p-2">
<CircleUserRound className="h-full w-full text-custom-text-200" />
</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={"Change cover"}
onChange={(imageUrl) => onChange(imageUrl)}
control={control}
value={value ?? "https://images.unsplash.com/photo-1506383796573-caf02b4a79ab"}
isProfileCover
/>
)}
/>
</div>
</div>
<div className="item-center mt-6 flex justify-between">
<div className="flex flex-col">
<div className="item-center flex text-lg font-medium text-custom-text-200">
<span>{`${watch("first_name")} ${watch("last_name")}`}</span>
</div>
<span className="text-sm text-custom-text-300 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-sm font-medium text-custom-text-200">
First name<span className="text-red-500">*</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-red-500" : ""}`}
maxLength={24}
autoComplete="on"
/>
)}
/>
{errors.first_name && <span className="text-xs text-red-500">{errors.first_name.message}</span>}
</div>
<div className="flex flex-col gap-1">
<h4 className="text-sm font-medium text-custom-text-200">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-sm font-medium text-custom-text-200">
Display name<span className="text-red-500">*</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-red-500" : ""}`}
maxLength={24}
/>
)}
/>
{errors?.display_name && (
<span className="text-xs text-red-500">{errors?.display_name?.message}</span>
)}
</div>
<div className="flex flex-col gap-1">
<h4 className="text-sm font-medium text-custom-text-200">
Email<span className="text-red-500">*</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-custom-background-90 ${
errors.email ? "border-red-500" : ""
}`}
autoComplete="on"
disabled
/>
)}
/>
</div>
<div className="flex flex-col gap-1">
<h4 className="text-sm font-medium text-custom-text-200">
Role<span className="text-red-500">*</span>
</h4>
<Controller
name="role"
control={control}
rules={{ required: "Role is required." }}
render={({ field: { value, onChange } }) => (
<CustomSelect
value={value}
onChange={onChange}
label={value ? value.toString() : "Select your role"}
buttonClassName={errors.role ? "border-red-500" : "border-none"}
className="rounded-md border-[0.5px] !border-custom-border-200"
optionsClassName="w-full"
input
>
{USER_ROLES.map((item) => (
<CustomSelect.Option key={item.value} value={item.value}>
{item.label}
</CustomSelect.Option>
))}
</CustomSelect>
)}
/>
{errors.role && <span className="text-xs text-red-500">Please select a role</span>}
</div>
</div>
</div>
<div className="flex flex-col gap-2 pt-4">
<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-sm font-medium text-custom-text-200">
Timezone<span className="text-red-500">*</span>
</h4>
<Controller
name="user_timezone"
control={control}
rules={{ required: "Please select a timezone" }}
render={({ field: { value, onChange } }) => (
<TimezoneSelect
value={value}
onChange={(value: string) => {
onChange(value);
}}
error={Boolean(errors.user_timezone)}
/>
)}
/>
{errors.user_timezone && <span className="text-xs text-red-500">{errors.user_timezone.message}</span>}
</div>
<Tooltip tooltipContent="Coming soon" position="bottom">
<div className="flex flex-col gap-1">
<h4 className="text-sm font-medium text-custom-text-200">Language</h4>
<CustomSearchSelect
value="English (US)"
label="English (US)"
options={[]}
onChange={() => {}}
className="rounded-md bg-custom-background-90"
input
disabled
/>
</div>
</Tooltip>
</div>
<div className="flex items-center justify-between pt-6 pb-8">
<Button variant="primary" type="submit" loading={isLoading}>
{isLoading ? "Saving..." : "Save changes"}
</Button>
</div>
</div>
</div>
</form>
<Disclosure as="div" className="border-t border-custom-border-100">
{({ open }) => (
<>
<Disclosure.Button as="button" type="button" className="flex w-full items-center justify-between py-4">
<span className="text-lg font-medium tracking-tight">Deactivate account</span>
<ChevronDown 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-sm tracking-tight">
When deactivating an account, all of the data and resources within that account will be
permanently removed and cannot be recovered.
</span>
<div>
<Button variant="danger" onClick={() => setDeactivateAccountModal(true)}>
Deactivate account
</Button>
</div>
</div>
</Disclosure.Panel>
</Transition>
</>
)}
</Disclosure>
</ProfileSettingContentWrapper> </ProfileSettingContentWrapper>
</> </>
); );

View File

@ -4,6 +4,7 @@ import { useState } from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { Controller, useForm } from "react-hook-form"; import { Controller, useForm } from "react-hook-form";
import { Eye, EyeOff } from "lucide-react"; import { Eye, EyeOff } from "lucide-react";
import { useTranslation } from "@plane/i18n";
// ui // ui
import { Button, Input, TOAST_TYPE, setToast } from "@plane/ui"; import { Button, Input, TOAST_TYPE, setToast } from "@plane/ui";
// components // components
@ -55,6 +56,8 @@ const SecurityPage = observer(() => {
const oldPassword = watch("old_password"); const oldPassword = watch("old_password");
const password = watch("new_password"); const password = watch("new_password");
const confirmPassword = watch("confirm_password"); const confirmPassword = watch("confirm_password");
// i18n
const { t } = useTranslation();
const isNewPasswordSameAsOldPassword = oldPassword !== "" && password !== "" && password === oldPassword; const isNewPasswordSameAsOldPassword = oldPassword !== "" && password !== "" && password === oldPassword;
@ -76,8 +79,8 @@ const SecurityPage = observer(() => {
setShowPassword(defaultShowPassword); setShowPassword(defaultShowPassword);
setToast({ setToast({
type: TOAST_TYPE.SUCCESS, type: TOAST_TYPE.SUCCESS,
title: "Success!", title: t("success"),
message: "Password changed successfully.", message: t("password_changed_successfully"),
}); });
} catch (err: any) { } catch (err: any) {
const errorInfo = authErrorHandler(err.error_code?.toString()); const errorInfo = authErrorHandler(err.error_code?.toString());
@ -85,7 +88,7 @@ const SecurityPage = observer(() => {
type: TOAST_TYPE.ERROR, type: TOAST_TYPE.ERROR,
title: errorInfo?.title ?? "Error!", title: errorInfo?.title ?? "Error!",
message: message:
typeof errorInfo?.message === "string" ? errorInfo.message : "Something went wrong. Please try again 2.", typeof errorInfo?.message === "string" ? errorInfo.message : t("something_went_wrong_please_try_again"),
}); });
} }
}; };
@ -109,17 +112,17 @@ const SecurityPage = observer(() => {
<> <>
<PageHead title="Profile - Security" /> <PageHead title="Profile - Security" />
<ProfileSettingContentWrapper> <ProfileSettingContentWrapper>
<ProfileSettingContentHeader title="Change password" /> <ProfileSettingContentHeader title={t("change_password")} />
<form onSubmit={handleSubmit(handleChangePassword)} className="flex flex-col gap-8 py-6"> <form onSubmit={handleSubmit(handleChangePassword)} className="flex flex-col gap-8 py-6">
<div className="flex flex-col gap-10 w-full max-w-96"> <div className="flex flex-col gap-10 w-full max-w-96">
<div className="space-y-1"> <div className="space-y-1">
<h4 className="text-sm">Current password</h4> <h4 className="text-sm">{t("current_password")}</h4>
<div className="relative flex items-center rounded-md"> <div className="relative flex items-center rounded-md">
<Controller <Controller
control={control} control={control}
name="old_password" name="old_password"
rules={{ rules={{
required: "This field is required", required: t("this_field_is_required"),
}} }}
render={({ field: { value, onChange } }) => ( render={({ field: { value, onChange } }) => (
<Input <Input
@ -127,7 +130,7 @@ const SecurityPage = observer(() => {
type={showPassword?.oldPassword ? "text" : "password"} type={showPassword?.oldPassword ? "text" : "password"}
value={value} value={value}
onChange={onChange} onChange={onChange}
placeholder="Old password" placeholder={t("old_password")}
className="w-full" className="w-full"
hasError={Boolean(errors.old_password)} hasError={Boolean(errors.old_password)}
/> />
@ -148,20 +151,20 @@ const SecurityPage = observer(() => {
{errors.old_password && <span className="text-xs text-red-500">{errors.old_password.message}</span>} {errors.old_password && <span className="text-xs text-red-500">{errors.old_password.message}</span>}
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<h4 className="text-sm">New password</h4> <h4 className="text-sm">{t("new_password")}</h4>
<div className="relative flex items-center rounded-md"> <div className="relative flex items-center rounded-md">
<Controller <Controller
control={control} control={control}
name="new_password" name="new_password"
rules={{ rules={{
required: "This field is required", required: t("this_field_is_required"),
}} }}
render={({ field: { value, onChange } }) => ( render={({ field: { value, onChange } }) => (
<Input <Input
id="new_password" id="new_password"
type={showPassword?.password ? "text" : "password"} type={showPassword?.password ? "text" : "password"}
value={value} value={value}
placeholder="New password" placeholder={t("new_password")}
onChange={onChange} onChange={onChange}
className="w-full" className="w-full"
hasError={Boolean(errors.new_password)} hasError={Boolean(errors.new_password)}
@ -184,23 +187,23 @@ const SecurityPage = observer(() => {
</div> </div>
{passwordSupport} {passwordSupport}
{isNewPasswordSameAsOldPassword && !isPasswordInputFocused && ( {isNewPasswordSameAsOldPassword && !isPasswordInputFocused && (
<span className="text-xs text-red-500">New password must be different from old password</span> <span className="text-xs text-red-500">{t("new_password_must_be_different_from_old_password")}</span>
)} )}
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<h4 className="text-sm">Confirm password</h4> <h4 className="text-sm">{t("confirm_password")}</h4>
<div className="relative flex items-center rounded-md"> <div className="relative flex items-center rounded-md">
<Controller <Controller
control={control} control={control}
name="confirm_password" name="confirm_password"
rules={{ rules={{
required: "This field is required", required: t("this_field_is_required"),
}} }}
render={({ field: { value, onChange } }) => ( render={({ field: { value, onChange } }) => (
<Input <Input
id="confirm_password" id="confirm_password"
type={showPassword?.confirmPassword ? "text" : "password"} type={showPassword?.confirmPassword ? "text" : "password"}
placeholder="Confirm password" placeholder={t("confirm_password")}
value={value} value={value}
onChange={onChange} onChange={onChange}
className="w-full" className="w-full"
@ -223,14 +226,14 @@ const SecurityPage = observer(() => {
)} )}
</div> </div>
{!!confirmPassword && password !== confirmPassword && renderPasswordMatchError && ( {!!confirmPassword && password !== confirmPassword && renderPasswordMatchError && (
<span className="text-sm text-red-500">Passwords don{"'"}t match</span> <span className="text-sm text-red-500">{t("passwords_dont_match")}</span>
)} )}
</div> </div>
</div> </div>
<div className="flex items-center justify-between py-2"> <div className="flex items-center justify-between py-2">
<Button variant="primary" type="submit" loading={isSubmitting} disabled={isButtonDisabled}> <Button variant="primary" type="submit" loading={isSubmitting} disabled={isButtonDisabled}>
{isSubmitting ? "Changing password..." : "Change password"} {isSubmitting ? `${t("changing_password")}...` : t("change_password")}
</Button> </Button>
</div> </div>
</form> </form>

View File

@ -6,9 +6,9 @@ import Link from "next/link";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
// icons // icons
import { ChevronLeft, LogOut, MoveLeft, Plus, UserPlus } from "lucide-react"; import { ChevronLeft, LogOut, MoveLeft, Plus, UserPlus } from "lucide-react";
// plane helpers // plane imports
import { useOutsideClickDetector } from "@plane/hooks"; import { useOutsideClickDetector } from "@plane/hooks";
// ui import { useTranslation } from "@plane/i18n";
import { TOAST_TYPE, Tooltip, setToast } from "@plane/ui"; import { TOAST_TYPE, Tooltip, setToast } from "@plane/ui";
// components // components
import { SidebarNavItem } from "@/components/sidebar"; import { SidebarNavItem } from "@/components/sidebar";
@ -23,7 +23,7 @@ import { usePlatformOS } from "@/hooks/use-platform-os";
const WORKSPACE_ACTION_LINKS = [ const WORKSPACE_ACTION_LINKS = [
{ {
key: "create-workspace", key: "create_workspace",
Icon: Plus, Icon: Plus,
label: "Create workspace", label: "Create workspace",
href: "/create-workspace", href: "/create-workspace",
@ -47,6 +47,7 @@ export const ProfileLayoutSidebar = observer(() => {
const { data: currentUserSettings } = useUserSettings(); const { data: currentUserSettings } = useUserSettings();
const { workspaces } = useWorkspace(); const { workspaces } = useWorkspace();
const { isMobile } = usePlatformOS(); const { isMobile } = usePlatformOS();
const { t } = useTranslation();
const workspacesList = Object.values(workspaces ?? {}); const workspacesList = Object.values(workspaces ?? {});
@ -91,8 +92,8 @@ export const ProfileLayoutSidebar = observer(() => {
.catch(() => .catch(() =>
setToast({ setToast({
type: TOAST_TYPE.ERROR, type: TOAST_TYPE.ERROR,
title: "Error!", title: t("error"),
message: "Failed to sign out. Please try again.", message: t("failed_to_sign_out_please_try_again"),
}) })
) )
.finally(() => setIsSigningOut(false)); .finally(() => setIsSigningOut(false));
@ -117,13 +118,13 @@ export const ProfileLayoutSidebar = observer(() => {
<ChevronLeft className="h-5 w-5" strokeWidth={1} /> <ChevronLeft className="h-5 w-5" strokeWidth={1} />
</span> </span>
{!sidebarCollapsed && ( {!sidebarCollapsed && (
<h4 className="truncate text-lg font-semibold text-custom-text-200">Profile settings</h4> <h4 className="truncate text-lg font-semibold text-custom-text-200">{t("profile_settings")}</h4>
)} )}
</div> </div>
</Link> </Link>
<div className="flex flex-shrink-0 flex-col overflow-x-hidden"> <div className="flex flex-shrink-0 flex-col overflow-x-hidden">
{!sidebarCollapsed && ( {!sidebarCollapsed && (
<h6 className="rounded px-6 text-sm font-semibold text-custom-sidebar-text-400">Your account</h6> <h6 className="rounded px-6 text-sm font-semibold text-custom-sidebar-text-400">{t("your_account")}</h6>
)} )}
<div className="vertical-scrollbar scrollbar-sm mt-2 px-4 h-full space-y-1 overflow-y-auto"> <div className="vertical-scrollbar scrollbar-sm mt-2 px-4 h-full space-y-1 overflow-y-auto">
{PROFILE_ACTION_LINKS.map((link) => { {PROFILE_ACTION_LINKS.map((link) => {
@ -132,7 +133,7 @@ export const ProfileLayoutSidebar = observer(() => {
return ( return (
<Link key={link.key} href={link.href} className="block w-full" onClick={handleItemClick}> <Link key={link.key} href={link.href} className="block w-full" onClick={handleItemClick}>
<Tooltip <Tooltip
tooltipContent={link.label} tooltipContent={t(link.key)}
position="right" position="right"
className="ml-2" className="ml-2"
disabled={!sidebarCollapsed} disabled={!sidebarCollapsed}
@ -145,7 +146,7 @@ export const ProfileLayoutSidebar = observer(() => {
> >
<div className="flex items-center gap-1.5 py-[1px]"> <div className="flex items-center gap-1.5 py-[1px]">
<link.Icon className="size-4" /> <link.Icon className="size-4" />
{!sidebarCollapsed && <p className="text-sm leading-5 font-medium">{link.label}</p>} {!sidebarCollapsed && <p className="text-sm leading-5 font-medium">{t(link.key)}</p>}
</div> </div>
</SidebarNavItem> </SidebarNavItem>
</Tooltip> </Tooltip>
@ -156,7 +157,7 @@ export const ProfileLayoutSidebar = observer(() => {
</div> </div>
<div className="flex flex-col overflow-x-hidden"> <div className="flex flex-col overflow-x-hidden">
{!sidebarCollapsed && ( {!sidebarCollapsed && (
<h6 className="rounded px-6 text-sm font-semibold text-custom-sidebar-text-400">Workspaces</h6> <h6 className="rounded px-6 text-sm font-semibold text-custom-sidebar-text-400">{t("workspaces")}</h6>
)} )}
{workspacesList && workspacesList.length > 0 && ( {workspacesList && workspacesList.length > 0 && (
<div <div
@ -206,7 +207,7 @@ export const ProfileLayoutSidebar = observer(() => {
{WORKSPACE_ACTION_LINKS.map((link) => ( {WORKSPACE_ACTION_LINKS.map((link) => (
<Link className="block w-full" key={link.key} href={link.href} onClick={handleItemClick}> <Link className="block w-full" key={link.key} href={link.href} onClick={handleItemClick}>
<Tooltip <Tooltip
tooltipContent={link.label} tooltipContent={t(link.key)}
position="right" position="right"
className="ml-2" className="ml-2"
disabled={!sidebarCollapsed} disabled={!sidebarCollapsed}
@ -218,7 +219,7 @@ export const ProfileLayoutSidebar = observer(() => {
}`} }`}
> >
{<link.Icon className="h-4 w-4" />} {<link.Icon className="h-4 w-4" />}
{!sidebarCollapsed && link.label} {!sidebarCollapsed && t(link.key)}
</div> </div>
</Tooltip> </Tooltip>
</Link> </Link>
@ -238,7 +239,7 @@ export const ProfileLayoutSidebar = observer(() => {
disabled={isSigningOut} disabled={isSigningOut}
> >
<LogOut className="h-3.5 w-3.5" /> <LogOut className="h-3.5 w-3.5" />
{!sidebarCollapsed && <span>{isSigningOut ? "Signing out..." : "Sign out"}</span>} {!sidebarCollapsed && <span>{isSigningOut ? `${t("signing_out")}...` : t("sign_out")}</span>}
</button> </button>
<button <button
type="button" type="button"

View File

@ -4,7 +4,8 @@ import { FC, ReactNode } from "react";
import dynamic from "next/dynamic"; import dynamic from "next/dynamic";
import { useTheme, ThemeProvider } from "next-themes"; import { useTheme, ThemeProvider } from "next-themes";
import { SWRConfig } from "swr"; import { SWRConfig } from "swr";
// ui // Plane Imports
import { TranslationProvider } from "@plane/i18n";
import { Toast } from "@plane/ui"; import { Toast } from "@plane/ui";
// constants // constants
import { SWR_CONFIG } from "@/constants/swr-config"; import { SWR_CONFIG } from "@/constants/swr-config";
@ -41,6 +42,7 @@ export const AppProvider: FC<IAppProvider> = (props) => {
<StoreProvider> <StoreProvider>
<ThemeProvider themes={["light", "dark", "light-contrast", "dark-contrast", "custom"]} defaultTheme="system"> <ThemeProvider themes={["light", "dark", "light-contrast", "dark-contrast", "custom"]} defaultTheme="system">
<ToastWithTheme /> <ToastWithTheme />
<TranslationProvider>
<StoreWrapper> <StoreWrapper>
<InstanceWrapper> <InstanceWrapper>
<IntercomProvider> <IntercomProvider>
@ -50,6 +52,7 @@ export const AppProvider: FC<IAppProvider> = (props) => {
</IntercomProvider> </IntercomProvider>
</InstanceWrapper> </InstanceWrapper>
</StoreWrapper> </StoreWrapper>
</TranslationProvider>
</ThemeProvider> </ThemeProvider>
</StoreProvider> </StoreProvider>
</> </>

View File

@ -3,6 +3,7 @@
import React from "react"; import React from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import Image from "next/image"; import Image from "next/image";
import { useTranslation } from "@plane/i18n";
// ui // ui
import { ContentWrapper, getButtonStyling } from "@plane/ui"; import { ContentWrapper, getButtonStyling } from "@plane/ui";
// components // components
@ -16,6 +17,7 @@ import { cn } from "@/helpers/common.helper";
import { useUser } from "@/hooks/store"; import { useUser } from "@/hooks/store";
export const WorkspaceActiveCyclesUpgrade = observer(() => { export const WorkspaceActiveCyclesUpgrade = observer(() => {
const { t } = useTranslation();
// store hooks // store hooks
const { const {
userProfile: { data: userProfile }, userProfile: { data: userProfile },
@ -33,10 +35,8 @@ export const WorkspaceActiveCyclesUpgrade = observer(() => {
> >
<div className="relative flex flex-col justify-center gap-7 px-14 lg:w-1/2"> <div className="relative flex flex-col justify-center gap-7 px-14 lg:w-1/2">
<div className="flex max-w-64 flex-col gap-2"> <div className="flex max-w-64 flex-col gap-2">
<h2 className="text-2xl font-semibold">On-demand snapshots of all your cycles</h2> <h2 className="text-2xl font-semibold">{t("on_demand_snapshots_of_all_your_cycles")}</h2>
<p className="text-base font-medium text-custom-text-300"> <p className="text-base font-medium text-custom-text-300">{t("active_cycles_description")}</p>
Monitor cycles across projects, track high-priority issues, and zoom in cycles that need attention.
</p>
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<a <a
@ -46,7 +46,7 @@ export const WorkspaceActiveCyclesUpgrade = observer(() => {
rel="noreferrer" rel="noreferrer"
> >
<ProIcon className="h-3.5 w-3.5 text-white" /> <ProIcon className="h-3.5 w-3.5 text-white" />
Upgrade {t("upgrade")}
</a> </a>
</div> </div>
<span className="absolute left-0 top-0"> <span className="absolute left-0 top-0">
@ -81,11 +81,11 @@ export const WorkspaceActiveCyclesUpgrade = observer(() => {
<div className="grid h-full grid-cols-1 gap-5 pb-8 lg:grid-cols-2 xl:grid-cols-3"> <div className="grid h-full grid-cols-1 gap-5 pb-8 lg:grid-cols-2 xl:grid-cols-3">
{WORKSPACE_ACTIVE_CYCLES_DETAILS.map((item) => ( {WORKSPACE_ACTIVE_CYCLES_DETAILS.map((item) => (
<div key={item.title} className="flex min-h-32 w-full flex-col gap-2 rounded-md bg-custom-background-80 p-4"> <div key={item.title} className="flex min-h-32 w-full flex-col gap-2 rounded-md bg-custom-background-80 p-4">
<div className="flex items-center gap-2 justify-between"> <div className="flex gap-2 justify-between">
<h3 className="font-medium">{item.title}</h3> <h3 className="font-medium">{t(item.key)}</h3>
<item.icon className="h-4 w-4 text-blue-500" /> <item.icon className="mt-1 h-4 w-4 text-blue-500" />
</div> </div>
<span className="text-sm text-custom-text-300">{item.description}</span> <span className="text-sm text-custom-text-300">{t(`${item.key}_description`)}</span>
</div> </div>
))} ))}
</div> </div>

View File

@ -1,5 +1,6 @@
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import Image from "next/image"; import Image from "next/image";
import { useTranslation } from "@plane/i18n";
// helpers // helpers
import { cn } from "@/helpers/common.helper"; import { cn } from "@/helpers/common.helper";
// assets // assets
@ -7,20 +8,23 @@ import PlaneLogo from "@/public/plane-logos/blue-without-text.png";
// package.json // package.json
import packageJson from "package.json"; import packageJson from "package.json";
export const ProductUpdatesHeader = observer(() => ( export const ProductUpdatesHeader = observer(() => {
const { t } = useTranslation();
return (
<div className="flex gap-2 mx-6 my-4 items-center justify-between flex-shrink-0"> <div className="flex gap-2 mx-6 my-4 items-center justify-between flex-shrink-0">
<div className="flex w-full items-center"> <div className="flex w-full items-center">
<div className="flex gap-2 text-xl font-medium">What&apos;s new</div> <div className="flex gap-2 text-xl font-medium">{t("whats_new")}</div>
<div <div
className={cn( className={cn(
"px-2 mx-2 py-0.5 text-center text-xs font-medium rounded-full bg-custom-primary-100/20 text-custom-primary-100" "px-2 mx-2 py-0.5 text-center text-xs font-medium rounded-full bg-custom-primary-100/20 text-custom-primary-100"
)} )}
> >
Version: v{packageJson.version} {t("version")}: v{packageJson.version}
</div> </div>
</div> </div>
<div className="flex flex-shrink-0 items-center gap-8"> <div className="flex flex-shrink-0 items-center gap-8">
<Image src={PlaneLogo} alt="Plane" width={24} height={24} /> <Image src={PlaneLogo} alt="Plane" width={24} height={24} />
</div> </div>
</div> </div>
)); );
});

View File

@ -1,4 +1,8 @@
// assets // assets
import { useTranslation } from "@plane/i18n";
import packageJson from "package.json"; import packageJson from "package.json";
export const PlaneVersionNumber: React.FC = () => <span>Version: v{packageJson.version}</span>; export const PlaneVersionNumber: React.FC = () => {
const { t } = useTranslation();
return <span>{t("version")}: v{packageJson.version}</span>;
};

View File

@ -1,6 +1,7 @@
"use client"; "use client";
import { FC } from "react"; import { FC } from "react";
import { Controller, useFormContext } from "react-hook-form"; import { Controller, useFormContext } from "react-hook-form";
import { useTranslation } from "@plane/i18n";
import { IProject } from "@plane/types"; import { IProject } from "@plane/types";
// ui // ui
import { CustomSelect } from "@plane/ui"; import { CustomSelect } from "@plane/ui";
@ -18,6 +19,7 @@ type Props = {
const ProjectAttributes: FC<Props> = (props) => { const ProjectAttributes: FC<Props> = (props) => {
const { isMobile = false } = props; const { isMobile = false } = props;
const { t } = useTranslation();
const { control } = useFormContext<IProject>(); const { control } = useFormContext<IProject>();
const { getIndex } = getTabIndex(ETabIndices.PROJECT_CREATE, isMobile); const { getIndex } = getTabIndex(ETabIndices.PROJECT_CREATE, isMobile);
return ( return (
@ -41,7 +43,7 @@ const ProjectAttributes: FC<Props> = (props) => {
{currentNetwork.label} {currentNetwork.label}
</> </>
) : ( ) : (
<span className="text-custom-text-400">Select network</span> <span className="text-custom-text-400">{t("select_network")}</span>
)} )}
</div> </div>
} }
@ -77,7 +79,7 @@ const ProjectAttributes: FC<Props> = (props) => {
<MemberDropdown <MemberDropdown
value={value} value={value}
onChange={(lead) => onChange(lead === value ? null : lead)} onChange={(lead) => onChange(lead === value ? null : lead)}
placeholder="Lead" placeholder={t("lead")}
multiple={false} multiple={false}
buttonVariant="border-with-text" buttonVariant="border-with-text"
tabIndex={5} tabIndex={5}

View File

@ -3,6 +3,7 @@
import { useState, FC } from "react"; import { useState, FC } from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { FormProvider, useForm } from "react-hook-form"; import { FormProvider, useForm } from "react-hook-form";
import { useTranslation } from "@plane/i18n";
// ui // ui
import { setToast, TOAST_TYPE } from "@plane/ui"; import { setToast, TOAST_TYPE } from "@plane/ui";
// constants // constants
@ -47,6 +48,7 @@ const defaultValues: Partial<TProject> = {
export const CreateProjectForm: FC<TCreateProjectFormProps> = observer((props) => { export const CreateProjectForm: FC<TCreateProjectFormProps> = observer((props) => {
const { setToFavorite, workspaceSlug, onClose, handleNextStep, updateCoverImageStatus } = props; const { setToFavorite, workspaceSlug, onClose, handleNextStep, updateCoverImageStatus } = props;
// store // store
const { t } = useTranslation();
const { captureProjectEvent } = useEventTracker(); const { captureProjectEvent } = useEventTracker();
const { addProjectToFavorites, createProject } = useProject(); const { addProjectToFavorites, createProject } = useProject();
// states // states
@ -64,8 +66,8 @@ export const CreateProjectForm: FC<TCreateProjectFormProps> = observer((props) =
addProjectToFavorites(workspaceSlug.toString(), projectId).catch(() => { addProjectToFavorites(workspaceSlug.toString(), projectId).catch(() => {
setToast({ setToast({
type: TOAST_TYPE.ERROR, type: TOAST_TYPE.ERROR,
title: "Error!", title: t("error"),
message: "Couldn't remove the project from favorites. Please try again.", message: t("failed_to_remove_project_from_favorites"),
}); });
}); });
}; };
@ -95,8 +97,8 @@ export const CreateProjectForm: FC<TCreateProjectFormProps> = observer((props) =
}); });
setToast({ setToast({
type: TOAST_TYPE.SUCCESS, type: TOAST_TYPE.SUCCESS,
title: "Success!", title: t("success"),
message: "Project created successfully.", message: t("project_created_successfully"),
}); });
if (setToFavorite) { if (setToFavorite) {
handleAddToFavorites(res.id); handleAddToFavorites(res.id);
@ -107,7 +109,7 @@ export const CreateProjectForm: FC<TCreateProjectFormProps> = observer((props) =
Object.keys(err.data).map((key) => { Object.keys(err.data).map((key) => {
setToast({ setToast({
type: TOAST_TYPE.ERROR, type: TOAST_TYPE.ERROR,
title: "Error!", title: t("error"),
message: err.data[key], message: err.data[key],
}); });
captureProjectEvent({ captureProjectEvent({

View File

@ -1,5 +1,6 @@
import { useState } from "react"; import { useState } from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { useTranslation } from "@plane/i18n";
// ui // ui
import { Button, Tooltip } from "@plane/ui"; import { Button, Tooltip } from "@plane/ui";
// hooks // hooks
@ -11,6 +12,7 @@ import { PaidPlanUpgradeModal } from "./upgrade";
export const WorkspaceEditionBadge = observer(() => { export const WorkspaceEditionBadge = observer(() => {
const { isMobile } = usePlatformOS(); const { isMobile } = usePlatformOS();
const { t } = useTranslation();
// states // states
const [isPaidPlanPurchaseModalOpen, setIsPaidPlanPurchaseModalOpen] = useState(false); const [isPaidPlanPurchaseModalOpen, setIsPaidPlanPurchaseModalOpen] = useState(false);
@ -27,7 +29,7 @@ export const WorkspaceEditionBadge = observer(() => {
className="w-fit min-w-24 cursor-pointer rounded-2xl px-2 py-1 text-center text-sm font-medium outline-none" className="w-fit min-w-24 cursor-pointer rounded-2xl px-2 py-1 text-center text-sm font-medium outline-none"
onClick={() => setIsPaidPlanPurchaseModalOpen(true)} onClick={() => setIsPaidPlanPurchaseModalOpen(true)}
> >
Upgrade {t("upgrade")}
</Button> </Button>
</Tooltip> </Tooltip>
</> </>

View File

@ -13,8 +13,9 @@ import { EUserPermissions } from "@/plane-web/constants/user-permissions";
import { TSidebarUserMenuItemKeys, TSidebarWorkspaceMenuItemKeys } from "@/plane-web/types/dashboard"; import { TSidebarUserMenuItemKeys, TSidebarWorkspaceMenuItemKeys } from "@/plane-web/types/dashboard";
export type TSidebarMenuItems<T extends TSidebarUserMenuItemKeys | TSidebarWorkspaceMenuItemKeys> = { export type TSidebarMenuItems<T extends TSidebarUserMenuItemKeys | TSidebarWorkspaceMenuItemKeys> = {
key: T; value: T;
label: string; label: string;
key: string;
href: string; href: string;
access: EUserPermissions[]; access: EUserPermissions[];
highlight: (pathname: string, baseUrl: string, options?: TLinkOptions) => boolean; highlight: (pathname: string, baseUrl: string, options?: TLinkOptions) => boolean;
@ -25,16 +26,18 @@ export type TSidebarUserMenuItems = TSidebarMenuItems<TSidebarUserMenuItemKeys>;
export const SIDEBAR_USER_MENU_ITEMS: TSidebarUserMenuItems[] = [ export const SIDEBAR_USER_MENU_ITEMS: TSidebarUserMenuItems[] = [
{ {
key: "home", value: "home",
label: "Home", label: "Home",
key: "home",
href: ``, href: ``,
access: [EUserPermissions.ADMIN, EUserPermissions.MEMBER, EUserPermissions.GUEST], access: [EUserPermissions.ADMIN, EUserPermissions.MEMBER, EUserPermissions.GUEST],
highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/`, highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/`,
Icon: Home, Icon: Home,
}, },
{ {
key: "your-work", value: "your-work",
label: "Your work", label: "Your work",
key: "your_work",
href: "/profile", href: "/profile",
access: [EUserPermissions.ADMIN, EUserPermissions.MEMBER], access: [EUserPermissions.ADMIN, EUserPermissions.MEMBER],
highlight: (pathname: string, baseUrl: string, options?: TLinkOptions) => highlight: (pathname: string, baseUrl: string, options?: TLinkOptions) =>
@ -42,16 +45,18 @@ export const SIDEBAR_USER_MENU_ITEMS: TSidebarUserMenuItems[] = [
Icon: UserActivityIcon, Icon: UserActivityIcon,
}, },
{ {
key: "notifications", value: "notifications",
label: "Inbox", label: "Inbox",
key: "notifications",
href: `/notifications`, href: `/notifications`,
access: [EUserPermissions.ADMIN, EUserPermissions.MEMBER, EUserPermissions.GUEST], access: [EUserPermissions.ADMIN, EUserPermissions.MEMBER, EUserPermissions.GUEST],
highlight: (pathname: string, baseUrl: string) => pathname.includes(`${baseUrl}/notifications/`), highlight: (pathname: string, baseUrl: string) => pathname.includes(`${baseUrl}/notifications/`),
Icon: Inbox, Icon: Inbox,
}, },
{ {
key: "drafts", value: "drafts",
label: "Drafts", label: "Drafts",
key: "drafts",
href: `/drafts`, href: `/drafts`,
access: [EUserPermissions.ADMIN, EUserPermissions.MEMBER], access: [EUserPermissions.ADMIN, EUserPermissions.MEMBER],
highlight: (pathname: string, baseUrl: string) => pathname.includes(`${baseUrl}/drafts/`), highlight: (pathname: string, baseUrl: string) => pathname.includes(`${baseUrl}/drafts/`),
@ -63,6 +68,7 @@ export type TSidebarWorkspaceMenuItems = TSidebarMenuItems<TSidebarWorkspaceMenu
export const SIDEBAR_WORKSPACE_MENU: Partial<Record<TSidebarWorkspaceMenuItemKeys, TSidebarWorkspaceMenuItems>> = { export const SIDEBAR_WORKSPACE_MENU: Partial<Record<TSidebarWorkspaceMenuItemKeys, TSidebarWorkspaceMenuItems>> = {
projects: { projects: {
value: "projects",
key: "projects", key: "projects",
label: "Projects", label: "Projects",
href: `/projects`, href: `/projects`,
@ -71,7 +77,8 @@ export const SIDEBAR_WORKSPACE_MENU: Partial<Record<TSidebarWorkspaceMenuItemKey
Icon: Briefcase, Icon: Briefcase,
}, },
"all-issues": { "all-issues": {
key: "all-issues", value: "all-issues",
key: "views",
label: "Views", label: "Views",
href: `/workspace-views/all-issues`, href: `/workspace-views/all-issues`,
access: [EUserPermissions.ADMIN, EUserPermissions.MEMBER, EUserPermissions.GUEST], access: [EUserPermissions.ADMIN, EUserPermissions.MEMBER, EUserPermissions.GUEST],
@ -79,7 +86,8 @@ export const SIDEBAR_WORKSPACE_MENU: Partial<Record<TSidebarWorkspaceMenuItemKey
Icon: Layers, Icon: Layers,
}, },
"active-cycles": { "active-cycles": {
key: "active-cycles", value: "active-cycles",
key: "active_cycles",
label: "Cycles", label: "Cycles",
href: `/active-cycles`, href: `/active-cycles`,
access: [EUserPermissions.ADMIN, EUserPermissions.MEMBER], access: [EUserPermissions.ADMIN, EUserPermissions.MEMBER],
@ -87,6 +95,7 @@ export const SIDEBAR_WORKSPACE_MENU: Partial<Record<TSidebarWorkspaceMenuItemKey
Icon: ContrastIcon, Icon: ContrastIcon,
}, },
analytics: { analytics: {
value: "analytics",
key: "analytics", key: "analytics",
label: "Analytics", label: "Analytics",
href: `/analytics`, href: `/analytics`,

View File

@ -4,6 +4,7 @@ import { IProject } from "@plane/types";
import { ContrastIcon, DiceIcon, Intake } from "@plane/ui"; import { ContrastIcon, DiceIcon, Intake } from "@plane/ui";
export type TProperties = { export type TProperties = {
key: string;
property: string; property: string;
title: string; title: string;
description: string; description: string;
@ -23,6 +24,7 @@ export type TFeatureList = {
export type TProjectFeatures = { export type TProjectFeatures = {
[key: string]: { [key: string]: {
key: string;
title: string; title: string;
description: string; description: string;
featureList: TFeatureList; featureList: TFeatureList;
@ -31,10 +33,12 @@ export type TProjectFeatures = {
export const PROJECT_FEATURES_LIST: TProjectFeatures = { export const PROJECT_FEATURES_LIST: TProjectFeatures = {
project_features: { project_features: {
key: "projects_and_issues",
title: "Projects and issues", title: "Projects and issues",
description: "Toggle these on or off this project.", description: "Toggle these on or off this project.",
featureList: { featureList: {
cycles: { cycles: {
key: "cycles",
property: "cycle_view", property: "cycle_view",
title: "Cycles", title: "Cycles",
description: "Timebox work as you see fit per project and change frequency from one period to the next.", description: "Timebox work as you see fit per project and change frequency from one period to the next.",
@ -43,6 +47,7 @@ export const PROJECT_FEATURES_LIST: TProjectFeatures = {
isEnabled: true, isEnabled: true,
}, },
modules: { modules: {
key: "modules",
property: "module_view", property: "module_view",
title: "Modules", title: "Modules",
description: "Group work into sub-project-like set-ups with their own leads and assignees.", description: "Group work into sub-project-like set-ups with their own leads and assignees.",
@ -51,6 +56,7 @@ export const PROJECT_FEATURES_LIST: TProjectFeatures = {
isEnabled: true, isEnabled: true,
}, },
views: { views: {
key: "views",
property: "issue_views_view", property: "issue_views_view",
title: "Views", title: "Views",
description: "Save sorts, filters, and display options for later or share them.", description: "Save sorts, filters, and display options for later or share them.",
@ -59,6 +65,7 @@ export const PROJECT_FEATURES_LIST: TProjectFeatures = {
isEnabled: true, isEnabled: true,
}, },
pages: { pages: {
key: "pages",
property: "page_view", property: "page_view",
title: "Pages", title: "Pages",
description: "Write anything like you write anything.", description: "Write anything like you write anything.",
@ -67,6 +74,7 @@ export const PROJECT_FEATURES_LIST: TProjectFeatures = {
isEnabled: true, isEnabled: true,
}, },
inbox: { inbox: {
key: "intake",
property: "inbox_view", property: "inbox_view",
title: "Intake", title: "Intake",
description: "Consider and discuss issues before you add them to your project.", description: "Consider and discuss issues before you add them to your project.",
@ -77,10 +85,12 @@ export const PROJECT_FEATURES_LIST: TProjectFeatures = {
}, },
}, },
project_others: { project_others: {
key: "work_management",
title: "Work management", title: "Work management",
description: "Available only on some plans as indicated by the label next to the feature below.", description: "Available only on some plans as indicated by the label next to the feature below.",
featureList: { featureList: {
is_time_tracking_enabled: { is_time_tracking_enabled: {
key: "time_tracking",
property: "is_time_tracking_enabled", property: "is_time_tracking_enabled",
title: "Time Tracking", title: "Time Tracking",
description: "Log time, see timesheets, and download full CSVs for your entire workspace.", description: "Log time, see timesheets, and download full CSVs for your entire workspace.",

View File

@ -1,3 +1,21 @@
import { TProjectBaseActivity } from "@plane/types"; import { TProjectBaseActivity } from "@plane/types";
export type TProjectActivity = TProjectBaseActivity; export type TProjectActivity = TProjectBaseActivity & {
content: string;
userId: string;
projectId: string;
actor_detail: {
display_name: string;
id: string;
};
workspace_detail: {
slug: string;
};
project_detail: {
name: string;
};
createdAt: string;
updatedAt: string;
};

View File

@ -3,6 +3,7 @@
import React, { useState } from "react"; import React, { useState } from "react";
import { Trash2 } from "lucide-react"; import { Trash2 } from "lucide-react";
import { Dialog, Transition } from "@headlessui/react"; import { Dialog, Transition } from "@headlessui/react";
import { useTranslation } from "@plane/i18n";
// ui // ui
import { Button, TOAST_TYPE, setToast } from "@plane/ui"; import { Button, TOAST_TYPE, setToast } from "@plane/ui";
// hooks // hooks
@ -18,6 +19,7 @@ export const DeactivateAccountModal: React.FC<Props> = (props) => {
const router = useAppRouter(); const router = useAppRouter();
const { isOpen, onClose } = props; const { isOpen, onClose } = props;
// hooks // hooks
const { t } = useTranslation();
const { deactivateAccount, signOut } = useUser(); const { deactivateAccount, signOut } = useUser();
// states // states
@ -90,11 +92,10 @@ export const DeactivateAccountModal: React.FC<Props> = (props) => {
</div> </div>
<div> <div>
<Dialog.Title as="h3" className="my-4 text-2xl font-medium leading-6 text-custom-text-100"> <Dialog.Title as="h3" className="my-4 text-2xl font-medium leading-6 text-custom-text-100">
Deactivate your account? {t("deactivate_your_account")}
</Dialog.Title> </Dialog.Title>
<p className="mt-6 list-disc pr-4 text-base font-normal text-custom-text-200"> <p className="mt-6 list-disc pr-4 text-base font-normal text-custom-text-200">
Once deactivated, you can{"'"}t be assigned issues and be billed for your workspace.To {t("deactivate_your_account_description")}
reactivate your account, you will need an invite to a workspace at this email address.
</p> </p>
</div> </div>
</div> </div>
@ -102,10 +103,10 @@ export const DeactivateAccountModal: React.FC<Props> = (props) => {
</div> </div>
<div className="mb-2 flex items-center justify-end gap-2 p-4 sm:px-6"> <div className="mb-2 flex items-center justify-end gap-2 p-4 sm:px-6">
<Button variant="neutral-primary" onClick={onClose}> <Button variant="neutral-primary" onClick={onClose}>
Cancel {t("cancel")}
</Button> </Button>
<Button variant="danger" onClick={handleDeleteAccount}> <Button variant="danger" onClick={handleDeleteAccount}>
{isDeactivating ? "Deactivating..." : "Confirm"} {isDeactivating ? t("deactivating") : t("confirm")}
</Button> </Button>
</div> </div>
</Dialog.Panel> </Dialog.Panel>

View File

@ -1,6 +1,7 @@
"use client"; "use client";
import { FC, useMemo } from "react"; import { FC, useMemo } from "react";
import { useTranslation } from "@plane/i18n";
// import { CircleCheck } from "lucide-react"; // import { CircleCheck } from "lucide-react";
// helpers // helpers
import { cn } from "@/helpers/common.helper"; import { cn } from "@/helpers/common.helper";
@ -17,6 +18,7 @@ type TPasswordStrengthMeter = {
export const PasswordStrengthMeter: FC<TPasswordStrengthMeter> = (props) => { export const PasswordStrengthMeter: FC<TPasswordStrengthMeter> = (props) => {
const { password, isFocused = false } = props; const { password, isFocused = false } = props;
const { t } = useTranslation();
// derived values // derived values
const strength = useMemo(() => getPasswordStrength(password), [password]); const strength = useMemo(() => getPasswordStrength(password), [password]);
const strengthBars = useMemo(() => { const strengthBars = useMemo(() => {
@ -24,40 +26,40 @@ export const PasswordStrengthMeter: FC<TPasswordStrengthMeter> = (props) => {
case E_PASSWORD_STRENGTH.EMPTY: { case E_PASSWORD_STRENGTH.EMPTY: {
return { return {
bars: [`bg-custom-text-100`, `bg-custom-text-100`, `bg-custom-text-100`], bars: [`bg-custom-text-100`, `bg-custom-text-100`, `bg-custom-text-100`],
text: "Please enter your password.", text: t("please_enter_your_password"),
textColor: "text-custom-text-100", textColor: "text-custom-text-100",
}; };
} }
case E_PASSWORD_STRENGTH.LENGTH_NOT_VALID: { case E_PASSWORD_STRENGTH.LENGTH_NOT_VALID: {
return { return {
bars: [`bg-red-500`, `bg-custom-text-100`, `bg-custom-text-100`], bars: [`bg-red-500`, `bg-custom-text-100`, `bg-custom-text-100`],
text: "Password length should me more than 8 characters.", text: t("password_length_should_me_more_than_8_characters"),
textColor: "text-red-500", textColor: "text-red-500",
}; };
} }
case E_PASSWORD_STRENGTH.STRENGTH_NOT_VALID: { case E_PASSWORD_STRENGTH.STRENGTH_NOT_VALID: {
return { return {
bars: [`bg-red-500`, `bg-custom-text-100`, `bg-custom-text-100`], bars: [`bg-red-500`, `bg-custom-text-100`, `bg-custom-text-100`],
text: "Password is weak.", text: t("password_is_weak"),
textColor: "text-red-500", textColor: "text-red-500",
}; };
} }
case E_PASSWORD_STRENGTH.STRENGTH_VALID: { case E_PASSWORD_STRENGTH.STRENGTH_VALID: {
return { return {
bars: [`bg-green-500`, `bg-green-500`, `bg-green-500`], bars: [`bg-green-500`, `bg-green-500`, `bg-green-500`],
text: "Password is strong.", text: t("password_is_strong"),
textColor: "text-green-500", textColor: "text-green-500",
}; };
} }
default: { default: {
return { return {
bars: [`bg-custom-text-100`, `bg-custom-text-100`, `bg-custom-text-100`], bars: [`bg-custom-text-100`, `bg-custom-text-100`, `bg-custom-text-100`],
text: "Please enter your password.", text: t("please_enter_your_password"),
textColor: "text-custom-text-100", textColor: "text-custom-text-100",
}; };
} }
} }
}, [strength]); }, [strength,t]);
const isPasswordMeterVisible = isFocused ? true : strength === E_PASSWORD_STRENGTH.STRENGTH_VALID ? false : true; const isPasswordMeterVisible = isFocused ? true : strength === E_PASSWORD_STRENGTH.STRENGTH_VALID ? false : true;

View File

@ -1,29 +1,16 @@
"use client"; "use client";
import { useMemo } from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { Controller, useForm } from "react-hook-form"; import { Controller, useForm } from "react-hook-form";
// types // types
import { useTranslation } from "@plane/i18n";
import { IUserTheme } from "@plane/types"; import { IUserTheme } from "@plane/types";
// ui // ui
import { Button, InputColorPicker, setPromiseToast } from "@plane/ui"; import { Button, InputColorPicker, setPromiseToast } from "@plane/ui";
// hooks // hooks
import { useUserProfile } from "@/hooks/store"; import { useUserProfile } from "@/hooks/store";
const inputRules = {
minLength: {
value: 7,
message: "Enter a valid hex code of 6 characters",
},
maxLength: {
value: 7,
message: "Enter a valid hex code of 6 characters",
},
pattern: {
value: /^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/,
message: "Enter a valid hex code of 6 characters",
},
};
type TCustomThemeSelector = { type TCustomThemeSelector = {
applyThemeChange: (theme: Partial<IUserTheme>) => void; applyThemeChange: (theme: Partial<IUserTheme>) => void;
}; };
@ -32,7 +19,7 @@ export const CustomThemeSelector: React.FC<TCustomThemeSelector> = observer((pro
const { applyThemeChange } = props; const { applyThemeChange } = props;
// hooks // hooks
const { data: userProfile, updateUserTheme } = useUserProfile(); const { data: userProfile, updateUserTheme } = useUserProfile();
const { t } = useTranslation();
const { const {
control, control,
formState: { errors, isSubmitting }, formState: { errors, isSubmitting },
@ -51,6 +38,24 @@ export const CustomThemeSelector: React.FC<TCustomThemeSelector> = observer((pro
}, },
}); });
const inputRules = useMemo(
() => ({
minLength: {
value: 7,
message: t("enter_a_valid_hex_code_of_6_characters"),
},
maxLength: {
value: 7,
message: t("enter_a_valid_hex_code_of_6_characters"),
},
pattern: {
value: /^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/,
message: t("enter_a_valid_hex_code_of_6_characters"),
},
}),
[t] // Empty dependency array since these rules never change
);
const handleUpdateTheme = async (formData: Partial<IUserTheme>) => { const handleUpdateTheme = async (formData: Partial<IUserTheme>) => {
const payload: IUserTheme = { const payload: IUserTheme = {
background: formData.background, background: formData.background,
@ -66,14 +71,14 @@ export const CustomThemeSelector: React.FC<TCustomThemeSelector> = observer((pro
const updateCurrentUserThemePromise = updateUserTheme(payload); const updateCurrentUserThemePromise = updateUserTheme(payload);
setPromiseToast(updateCurrentUserThemePromise, { setPromiseToast(updateCurrentUserThemePromise, {
loading: "Updating theme...", loading: t("updating_theme"),
success: { success: {
title: "Success!", title: t("success"),
message: () => "Theme updated successfully!", message: () => t("theme_updated_successfully"),
}, },
error: { error: {
title: "Error!", title: t("error"),
message: () => "Failed to Update the theme", message: () => t("failed_to_update_the_theme"),
}, },
}); });
@ -91,16 +96,16 @@ export const CustomThemeSelector: React.FC<TCustomThemeSelector> = observer((pro
return ( return (
<form onSubmit={handleSubmit(handleUpdateTheme)}> <form onSubmit={handleSubmit(handleUpdateTheme)}>
<div className="space-y-5"> <div className="space-y-5">
<h3 className="text-lg font-semibold text-custom-text-100">Customize your theme</h3> <h3 className="text-lg font-semibold text-custom-text-100">{t("customize_your_theme")}</h3>
<div className="space-y-4"> <div className="space-y-4">
<div className="grid grid-cols-1 gap-x-8 gap-y-4 sm:grid-cols-2 md:grid-cols-3"> <div className="grid grid-cols-1 gap-x-8 gap-y-4 sm:grid-cols-2 md:grid-cols-3">
<div className="flex flex-col items-start gap-2"> <div className="flex flex-col items-start gap-2">
<h3 className="text-left text-sm font-medium text-custom-text-200">Background color</h3> <h3 className="text-left text-sm font-medium text-custom-text-200">{t("background_color")}</h3>
<div className="w-full"> <div className="w-full">
<Controller <Controller
control={control} control={control}
name="background" name="background"
rules={{ ...inputRules, required: "Background color is required" }} rules={{ ...inputRules, required: t("background_color_is_required") }}
render={({ field: { value, onChange } }) => ( render={({ field: { value, onChange } }) => (
<InputColorPicker <InputColorPicker
name="background" name="background"
@ -121,12 +126,12 @@ export const CustomThemeSelector: React.FC<TCustomThemeSelector> = observer((pro
</div> </div>
<div className="flex flex-col items-start gap-2"> <div className="flex flex-col items-start gap-2">
<h3 className="text-left text-sm font-medium text-custom-text-200">Text color</h3> <h3 className="text-left text-sm font-medium text-custom-text-200">{t("text_color")}</h3>
<div className="w-full"> <div className="w-full">
<Controller <Controller
control={control} control={control}
name="text" name="text"
rules={{ ...inputRules, required: "Text color is required" }} rules={{ ...inputRules, required: t("text_color_is_required") }}
render={({ field: { value, onChange } }) => ( render={({ field: { value, onChange } }) => (
<InputColorPicker <InputColorPicker
name="text" name="text"
@ -147,12 +152,12 @@ export const CustomThemeSelector: React.FC<TCustomThemeSelector> = observer((pro
</div> </div>
<div className="flex flex-col items-start gap-2"> <div className="flex flex-col items-start gap-2">
<h3 className="text-left text-sm font-medium text-custom-text-200">Primary(Theme) color</h3> <h3 className="text-left text-sm font-medium text-custom-text-200">{t("primary_color")}</h3>
<div className="w-full"> <div className="w-full">
<Controller <Controller
control={control} control={control}
name="primary" name="primary"
rules={{ ...inputRules, required: "Primary color is required" }} rules={{ ...inputRules, required: t("primary_color_is_required") }}
render={({ field: { value, onChange } }) => ( render={({ field: { value, onChange } }) => (
<InputColorPicker <InputColorPicker
name="primary" name="primary"
@ -173,12 +178,12 @@ export const CustomThemeSelector: React.FC<TCustomThemeSelector> = observer((pro
</div> </div>
<div className="flex flex-col items-start gap-2"> <div className="flex flex-col items-start gap-2">
<h3 className="text-left text-sm font-medium text-custom-text-200">Sidebar background color</h3> <h3 className="text-left text-sm font-medium text-custom-text-200">{t("sidebar_background_color")}</h3>
<div className="w-full"> <div className="w-full">
<Controller <Controller
control={control} control={control}
name="sidebarBackground" name="sidebarBackground"
rules={{ ...inputRules, required: "Sidebar background color is required" }} rules={{ ...inputRules, required: t("sidebar_background_color_is_required") }}
render={({ field: { value, onChange } }) => ( render={({ field: { value, onChange } }) => (
<InputColorPicker <InputColorPicker
name="sidebarBackground" name="sidebarBackground"
@ -201,12 +206,12 @@ export const CustomThemeSelector: React.FC<TCustomThemeSelector> = observer((pro
</div> </div>
<div className="flex flex-col items-start gap-2"> <div className="flex flex-col items-start gap-2">
<h3 className="text-left text-sm font-medium text-custom-text-200">Sidebar text color</h3> <h3 className="text-left text-sm font-medium text-custom-text-200">{t("sidebar_text_color")}</h3>
<div className="w-full"> <div className="w-full">
<Controller <Controller
control={control} control={control}
name="sidebarText" name="sidebarText"
rules={{ ...inputRules, required: "Sidebar text color is required" }} rules={{ ...inputRules, required: t("sidebar_text_color_is_required") }}
render={({ field: { value, onChange } }) => ( render={({ field: { value, onChange } }) => (
<InputColorPicker <InputColorPicker
name="sidebarText" name="sidebarText"
@ -230,7 +235,7 @@ export const CustomThemeSelector: React.FC<TCustomThemeSelector> = observer((pro
</div> </div>
<div className="mt-5 flex justify-end gap-2"> <div className="mt-5 flex justify-end gap-2">
<Button variant="primary" type="submit" loading={isSubmitting}> <Button variant="primary" type="submit" loading={isSubmitting}>
{isSubmitting ? "Creating Theme..." : "Set Theme"} {isSubmitting ? t("creating_theme") : t("set_theme")}
</Button> </Button>
</div> </div>
</form> </form>

View File

@ -1,6 +1,7 @@
"use client"; "use client";
import { FC } from "react"; import { FC } from "react";
import { useTranslation } from "@plane/i18n";
// constants // constants
import { CustomSelect } from "@plane/ui"; import { CustomSelect } from "@plane/ui";
import { THEME_OPTIONS, I_THEME_OPTION } from "@/constants/themes"; import { THEME_OPTIONS, I_THEME_OPTION } from "@/constants/themes";
@ -13,7 +14,7 @@ type Props = {
export const ThemeSwitch: FC<Props> = (props) => { export const ThemeSwitch: FC<Props> = (props) => {
const { value, onChange } = props; const { value, onChange } = props;
const { t } = useTranslation();
return ( return (
<CustomSelect <CustomSelect
value={value} value={value}
@ -40,10 +41,10 @@ export const ThemeSwitch: FC<Props> = (props) => {
}} }}
/> />
</div> </div>
{value.label} {t(value.key)}
</div> </div>
) : ( ) : (
"Select your theme" t("select_your_theme")
) )
} }
onChange={onChange} onChange={onChange}
@ -72,7 +73,7 @@ export const ThemeSwitch: FC<Props> = (props) => {
}} }}
/> />
</div> </div>
{themeOption.label} {t(themeOption.key)}
</div> </div>
</CustomSelect.Option> </CustomSelect.Option>
))} ))}

View File

@ -1,6 +1,7 @@
import { useRef, useState } from "react"; import { useRef, useState } from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { ChevronDown, LucideIcon } from "lucide-react"; import { ChevronDown, LucideIcon } from "lucide-react";
import { useTranslation } from "@plane/i18n";
// ui // ui
import { ComboDropDown } from "@plane/ui"; import { ComboDropDown } from "@plane/ui";
// helpers // helpers
@ -26,6 +27,7 @@ type Props = {
} & MemberDropdownProps; } & MemberDropdownProps;
export const MemberDropdown: React.FC<Props> = observer((props) => { export const MemberDropdown: React.FC<Props> = observer((props) => {
const { t } = useTranslation();
const { const {
button, button,
buttonClassName, buttonClassName,
@ -40,7 +42,7 @@ export const MemberDropdown: React.FC<Props> = observer((props) => {
multiple, multiple,
onChange, onChange,
onClose, onClose,
placeholder = "Members", placeholder = t("members"),
tooltipContent, tooltipContent,
placement, placement,
projectId, projectId,
@ -86,7 +88,7 @@ export const MemberDropdown: React.FC<Props> = observer((props) => {
if (value.length === 1) { if (value.length === 1) {
return getUserDetails(value[0])?.display_name || placeholder; return getUserDetails(value[0])?.display_name || placeholder;
} else { } else {
return showUserDetails ? `${value.length} members` : ""; return showUserDetails ? `${value.length} ${t("members").toLocaleLowerCase()}` : "";
} }
} else { } else {
return placeholder; return placeholder;
@ -131,7 +133,9 @@ export const MemberDropdown: React.FC<Props> = observer((props) => {
className={cn("text-xs", buttonClassName)} className={cn("text-xs", buttonClassName)}
isActive={isOpen} isActive={isOpen}
tooltipHeading={placeholder} tooltipHeading={placeholder}
tooltipContent={tooltipContent ?? `${value?.length ?? 0} assignee${value?.length !== 1 ? "s" : ""}`} tooltipContent={
tooltipContent ?? `${value?.length ?? 0} ${value?.length !== 1 ? t("assignees") : t("assignee")}`
}
showTooltip={showTooltip} showTooltip={showTooltip}
variant={buttonVariant} variant={buttonVariant}
renderToolTipByDefault={renderByDefault} renderToolTipByDefault={renderByDefault}

View File

@ -8,6 +8,7 @@ import { createPortal } from "react-dom";
import { usePopper } from "react-popper"; import { usePopper } from "react-popper";
import { Check, Search } from "lucide-react"; import { Check, Search } from "lucide-react";
import { Combobox } from "@headlessui/react"; import { Combobox } from "@headlessui/react";
import { useTranslation } from "@plane/i18n";
// plane ui // plane ui
import { Avatar } from "@plane/ui"; import { Avatar } from "@plane/ui";
// helpers // helpers
@ -34,6 +35,7 @@ export const MemberOptions: React.FC<Props> = observer((props: Props) => {
// refs // refs
const inputRef = useRef<HTMLInputElement | null>(null); const inputRef = useRef<HTMLInputElement | null>(null);
// store hooks // store hooks
const { t } = useTranslation();
const { workspaceSlug } = useParams(); const { workspaceSlug } = useParams();
const { const {
getUserDetails, getUserDetails,
@ -85,7 +87,7 @@ export const MemberOptions: React.FC<Props> = observer((props: Props) => {
content: ( content: (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Avatar name={userDetails?.display_name} src={getFileURL(userDetails?.avatar_url ?? "")} /> <Avatar name={userDetails?.display_name} src={getFileURL(userDetails?.avatar_url ?? "")} />
<span className="flex-grow truncate">{currentUser?.id === userId ? "You" : userDetails?.display_name}</span> <span className="flex-grow truncate">{currentUser?.id === userId ? t("you") : userDetails?.display_name}</span>
</div> </div>
), ),
}; };
@ -115,7 +117,7 @@ export const MemberOptions: React.FC<Props> = observer((props: Props) => {
className="w-full bg-transparent py-1 text-xs text-custom-text-200 placeholder:text-custom-text-400 focus:outline-none" className="w-full bg-transparent py-1 text-xs text-custom-text-200 placeholder:text-custom-text-400 focus:outline-none"
value={query} value={query}
onChange={(e) => setQuery(e.target.value)} onChange={(e) => setQuery(e.target.value)}
placeholder="Search" placeholder={t("search")}
displayValue={(assigned: any) => assigned?.name} displayValue={(assigned: any) => assigned?.name}
onKeyDown={searchInputKeyDown} onKeyDown={searchInputKeyDown}
/> />
@ -142,10 +144,10 @@ export const MemberOptions: React.FC<Props> = observer((props: Props) => {
</Combobox.Option> </Combobox.Option>
)) ))
) : ( ) : (
<p className="px-1.5 py-1 italic text-custom-text-400">No matching results</p> <p className="px-1.5 py-1 italic text-custom-text-400">{t("no_matching_results")}</p>
) )
) : ( ) : (
<p className="px-1.5 py-1 italic text-custom-text-400">Loading...</p> <p className="px-1.5 py-1 italic text-custom-text-400">{t("loading")}</p>
)} )}
</div> </div>
</div> </div>

View File

@ -5,6 +5,7 @@ import { useTheme } from "next-themes";
import { usePopper } from "react-popper"; import { usePopper } from "react-popper";
import { Check, ChevronDown, Search, SignalHigh } from "lucide-react"; import { Check, ChevronDown, Search, SignalHigh } from "lucide-react";
import { Combobox } from "@headlessui/react"; import { Combobox } from "@headlessui/react";
import { useTranslation } from "@plane/i18n";
// types // types
import { TIssuePriorities } from "@plane/types"; import { TIssuePriorities } from "@plane/types";
// ui // ui
@ -71,11 +72,12 @@ const BorderButton = (props: ButtonProps) => {
}; };
const { isMobile } = usePlatformOS(); const { isMobile } = usePlatformOS();
const { t } = useTranslation();
return ( return (
<Tooltip <Tooltip
tooltipHeading="Priority" tooltipHeading={t("priority")}
tooltipContent={priorityDetails?.title ?? "None"} tooltipContent={t(priorityDetails?.key ?? "none")}
disabled={!showTooltip} disabled={!showTooltip}
isMobile={isMobile} isMobile={isMobile}
renderByDefault={renderToolTipByDefault} renderByDefault={renderToolTipByDefault}
@ -119,7 +121,7 @@ const BorderButton = (props: ButtonProps) => {
) : ( ) : (
<SignalHigh className="size-3" /> <SignalHigh className="size-3" />
))} ))}
{!hideText && <span className="flex-grow truncate">{priorityDetails?.title ?? placeholder}</span>} {!hideText && <span className="flex-grow truncate">{t(priorityDetails?.key ?? "priority") ?? placeholder}</span>}
{dropdownArrow && ( {dropdownArrow && (
<ChevronDown className={cn("h-2.5 w-2.5 flex-shrink-0", dropdownArrowClassName)} aria-hidden="true" /> <ChevronDown className={cn("h-2.5 w-2.5 flex-shrink-0", dropdownArrowClassName)} aria-hidden="true" />
)} )}
@ -153,11 +155,12 @@ const BackgroundButton = (props: ButtonProps) => {
}; };
const { isMobile } = usePlatformOS(); const { isMobile } = usePlatformOS();
const { t } = useTranslation();
return ( return (
<Tooltip <Tooltip
tooltipHeading="Priority" tooltipHeading={t("priority")}
tooltipContent={priorityDetails?.title ?? "None"} tooltipContent={t(priorityDetails?.key ?? "none")}
disabled={!showTooltip} disabled={!showTooltip}
isMobile={isMobile} isMobile={isMobile}
renderByDefault={renderToolTipByDefault} renderByDefault={renderToolTipByDefault}
@ -201,7 +204,7 @@ const BackgroundButton = (props: ButtonProps) => {
) : ( ) : (
<SignalHigh className="size-3" /> <SignalHigh className="size-3" />
))} ))}
{!hideText && <span className="flex-grow truncate">{priorityDetails?.title ?? placeholder}</span>} {!hideText && <span className="flex-grow truncate">{t(priorityDetails?.key ?? "priority") ?? placeholder}</span>}
{dropdownArrow && ( {dropdownArrow && (
<ChevronDown className={cn("h-2.5 w-2.5 flex-shrink-0", dropdownArrowClassName)} aria-hidden="true" /> <ChevronDown className={cn("h-2.5 w-2.5 flex-shrink-0", dropdownArrowClassName)} aria-hidden="true" />
)} )}
@ -236,11 +239,12 @@ const TransparentButton = (props: ButtonProps) => {
}; };
const { isMobile } = usePlatformOS(); const { isMobile } = usePlatformOS();
const { t } = useTranslation();
return ( return (
<Tooltip <Tooltip
tooltipHeading="Priority" tooltipHeading={t("priority")}
tooltipContent={priorityDetails?.title ?? "None"} tooltipContent={t(priorityDetails?.key ?? "none")}
disabled={!showTooltip} disabled={!showTooltip}
isMobile={isMobile} isMobile={isMobile}
renderByDefault={renderToolTipByDefault} renderByDefault={renderToolTipByDefault}
@ -285,7 +289,7 @@ const TransparentButton = (props: ButtonProps) => {
) : ( ) : (
<SignalHigh className="size-3" /> <SignalHigh className="size-3" />
))} ))}
{!hideText && <span className="flex-grow truncate">{priorityDetails?.title ?? placeholder}</span>} {!hideText && <span className="flex-grow truncate">{t(priorityDetails?.key ?? "priority") ?? placeholder}</span>}
{dropdownArrow && ( {dropdownArrow && (
<ChevronDown className={cn("h-2.5 w-2.5 flex-shrink-0", dropdownArrowClassName)} aria-hidden="true" /> <ChevronDown className={cn("h-2.5 w-2.5 flex-shrink-0", dropdownArrowClassName)} aria-hidden="true" />
)} )}
@ -336,6 +340,8 @@ export const PriorityDropdown: React.FC<Props> = (props) => {
}, },
], ],
}); });
//hooks
const { t } = useTranslation();
// next-themes // next-themes
// TODO: remove this after new theming implementation // TODO: remove this after new theming implementation
const { resolvedTheme } = useTheme(); const { resolvedTheme } = useTheme();
@ -346,7 +352,7 @@ export const PriorityDropdown: React.FC<Props> = (props) => {
content: ( content: (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<PriorityIcon priority={priority.key} size={14} withContainer /> <PriorityIcon priority={priority.key} size={14} withContainer />
<span className="flex-grow truncate">{priority.title}</span> <span className="flex-grow truncate">{t(priority.key)}</span>
</div> </div>
), ),
})); }));
@ -456,7 +462,7 @@ export const PriorityDropdown: React.FC<Props> = (props) => {
className="w-full bg-transparent py-1 text-xs text-custom-text-200 placeholder:text-custom-text-400 focus:outline-none" className="w-full bg-transparent py-1 text-xs text-custom-text-200 placeholder:text-custom-text-400 focus:outline-none"
value={query} value={query}
onChange={(e) => setQuery(e.target.value)} onChange={(e) => setQuery(e.target.value)}
placeholder="Search" placeholder={t("search")}
displayValue={(assigned: any) => assigned?.name} displayValue={(assigned: any) => assigned?.name}
onKeyDown={searchInputKeyDown} onKeyDown={searchInputKeyDown}
/> />
@ -482,7 +488,7 @@ export const PriorityDropdown: React.FC<Props> = (props) => {
</Combobox.Option> </Combobox.Option>
)) ))
) : ( ) : (
<p className="text-custom-text-400 italic py-1 px-1.5">No matching results</p> <p className="text-custom-text-400 italic py-1 px-1.5">{t("no_matching_results")}</p>
)} )}
</div> </div>
</div> </div>

View File

@ -4,6 +4,7 @@ import { usePopper } from "react-popper";
import { Briefcase, Check, ChevronDown, Search } from "lucide-react"; import { Briefcase, Check, ChevronDown, Search } from "lucide-react";
import { Combobox } from "@headlessui/react"; import { Combobox } from "@headlessui/react";
// ui // ui
import { useTranslation } from "@plane/i18n";
import { ComboDropDown } from "@plane/ui"; import { ComboDropDown } from "@plane/ui";
// components // components
import { Logo } from "@/components/common"; import { Logo } from "@/components/common";
@ -86,7 +87,7 @@ export const ProjectDropdown: React.FC<Props> = observer((props) => {
}); });
// store hooks // store hooks
const { joinedProjectIds, getProjectById } = useProject(); const { joinedProjectIds, getProjectById } = useProject();
const { t } = useTranslation();
const options = joinedProjectIds?.map((projectId) => { const options = joinedProjectIds?.map((projectId) => {
const projectDetails = getProjectById(projectId); const projectDetails = getProjectById(projectId);
if (renderCondition && projectDetails && !renderCondition(projectDetails)) return; if (renderCondition && projectDetails && !renderCondition(projectDetails)) return;
@ -238,7 +239,7 @@ export const ProjectDropdown: React.FC<Props> = observer((props) => {
className="w-full bg-transparent py-1 text-xs text-custom-text-200 placeholder:text-custom-text-400 focus:outline-none" className="w-full bg-transparent py-1 text-xs text-custom-text-200 placeholder:text-custom-text-400 focus:outline-none"
value={query} value={query}
onChange={(e) => setQuery(e.target.value)} onChange={(e) => setQuery(e.target.value)}
placeholder="Search" placeholder={t("search")}
displayValue={(assigned: any) => assigned?.name} displayValue={(assigned: any) => assigned?.name}
onKeyDown={searchInputKeyDown} onKeyDown={searchInputKeyDown}
/> />
@ -268,10 +269,10 @@ export const ProjectDropdown: React.FC<Props> = observer((props) => {
); );
}) })
) : ( ) : (
<p className="text-custom-text-400 italic py-1 px-1.5">No matching results</p> <p className="text-custom-text-400 italic py-1 px-1.5">{t("no_matching_results")}</p>
) )
) : ( ) : (
<p className="text-custom-text-400 italic py-1 px-1.5">Loading...</p> <p className="text-custom-text-400 italic py-1 px-1.5">{t("loading")}</p>
)} )}
</div> </div>
</div> </div>

View File

@ -6,6 +6,7 @@ import { useParams } from "next/navigation";
import { usePopper } from "react-popper"; import { usePopper } from "react-popper";
import { ChevronDown, Search } from "lucide-react"; import { ChevronDown, Search } from "lucide-react";
import { Combobox } from "@headlessui/react"; import { Combobox } from "@headlessui/react";
import { useTranslation } from "@plane/i18n";
// ui // ui
import { ComboDropDown, Spinner, StateGroupIcon } from "@plane/ui"; import { ComboDropDown, Spinner, StateGroupIcon } from "@plane/ui";
// helpers // helpers
@ -82,6 +83,7 @@ export const StateDropdown: React.FC<Props> = observer((props) => {
], ],
}); });
// store hooks // store hooks
const { t } = useTranslation();
const { workspaceSlug } = useParams(); const { workspaceSlug } = useParams();
const { fetchProjectStates, getProjectStates, getStateById } = useProjectState(); const { fetchProjectStates, getProjectStates, getStateById } = useProjectState();
const statesList = stateIds const statesList = stateIds
@ -160,8 +162,8 @@ export const StateDropdown: React.FC<Props> = observer((props) => {
<DropdownButton <DropdownButton
className={buttonClassName} className={buttonClassName}
isActive={isOpen} isActive={isOpen}
tooltipHeading="State" tooltipHeading={t("state")}
tooltipContent={selectedState?.name ?? "State"} tooltipContent={selectedState?.name ?? t("state")}
showTooltip={showTooltip} showTooltip={showTooltip}
variant={buttonVariant} variant={buttonVariant}
renderToolTipByDefault={renderByDefault} renderToolTipByDefault={renderByDefault}
@ -178,7 +180,7 @@ export const StateDropdown: React.FC<Props> = observer((props) => {
/> />
)} )}
{BUTTON_VARIANTS_WITH_TEXT.includes(buttonVariant) && ( {BUTTON_VARIANTS_WITH_TEXT.includes(buttonVariant) && (
<span className="flex-grow truncate">{selectedState?.name ?? "State"}</span> <span className="flex-grow truncate">{selectedState?.name ?? t("state")}</span>
)} )}
{dropdownArrow && ( {dropdownArrow && (
<ChevronDown className={cn("h-2.5 w-2.5 flex-shrink-0", dropdownArrowClassName)} aria-hidden="true" /> <ChevronDown className={cn("h-2.5 w-2.5 flex-shrink-0", dropdownArrowClassName)} aria-hidden="true" />
@ -239,10 +241,10 @@ export const StateDropdown: React.FC<Props> = observer((props) => {
/> />
)) ))
) : ( ) : (
<p className="px-1.5 py-1 italic text-custom-text-400">No matches found</p> <p className="px-1.5 py-1 italic text-custom-text-400">{t("no_matching_results")}</p>
) )
) : ( ) : (
<p className="px-1.5 py-1 italic text-custom-text-400">Loading...</p> <p className="px-1.5 py-1 italic text-custom-text-400">{t("loading")}</p>
)} )}
</div> </div>
</div> </div>

View File

@ -1,4 +1,5 @@
import Image from "next/image"; import Image from "next/image";
import { useTranslation } from "@plane/i18n";
// ui // ui
import { getButtonStyling } from "@plane/ui"; import { getButtonStyling } from "@plane/ui";
// helpers // helpers
@ -6,7 +7,9 @@ import { cn } from "@/helpers/common.helper";
// assets // assets
import PlaneLogo from "@/public/plane-logos/blue-without-text.png"; import PlaneLogo from "@/public/plane-logos/blue-without-text.png";
export const ProductUpdatesFooter = () => ( export const ProductUpdatesFooter = () => {
const { t } = useTranslation();
return (
<div className="flex items-center justify-between flex-shrink-0 gap-4 m-6 mb-4"> <div className="flex items-center justify-between flex-shrink-0 gap-4 m-6 mb-4">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<a <a
@ -14,7 +17,7 @@ export const ProductUpdatesFooter = () => (
target="_blank" target="_blank"
className="text-sm text-custom-text-200 hover:text-custom-text-100 hover:underline underline-offset-1 outline-none" className="text-sm text-custom-text-200 hover:text-custom-text-100 hover:underline underline-offset-1 outline-none"
> >
Docs {t("docs")}
</a> </a>
<svg viewBox="0 0 2 2" className="h-0.5 w-0.5 fill-current"> <svg viewBox="0 0 2 2" className="h-0.5 w-0.5 fill-current">
<circle cx={1} cy={1} r={1} /> <circle cx={1} cy={1} r={1} />
@ -24,7 +27,7 @@ export const ProductUpdatesFooter = () => (
target="_blank" target="_blank"
className="text-sm text-custom-text-200 hover:text-custom-text-100 hover:underline underline-offset-1 outline-none" className="text-sm text-custom-text-200 hover:text-custom-text-100 hover:underline underline-offset-1 outline-none"
> >
Full changelog {t("full_changelog")}
</a> </a>
<svg viewBox="0 0 2 2" className="h-0.5 w-0.5 fill-current"> <svg viewBox="0 0 2 2" className="h-0.5 w-0.5 fill-current">
<circle cx={1} cy={1} r={1} /> <circle cx={1} cy={1} r={1} />
@ -34,7 +37,7 @@ export const ProductUpdatesFooter = () => (
target="_blank" target="_blank"
className="text-sm text-custom-text-200 hover:text-custom-text-100 hover:underline underline-offset-1 outline-none" className="text-sm text-custom-text-200 hover:text-custom-text-100 hover:underline underline-offset-1 outline-none"
> >
Support {t("support")}
</a> </a>
<svg viewBox="0 0 2 2" className="h-0.5 w-0.5 fill-current"> <svg viewBox="0 0 2 2" className="h-0.5 w-0.5 fill-current">
<circle cx={1} cy={1} r={1} /> <circle cx={1} cy={1} r={1} />
@ -56,7 +59,8 @@ export const ProductUpdatesFooter = () => (
)} )}
> >
<Image src={PlaneLogo} alt="Plane" width={12} height={12} /> <Image src={PlaneLogo} alt="Plane" width={12} height={12} />
Powered by Plane Pages {t("powered_by_plane_pages")}
</a> </a>
</div> </div>
); );
};

View File

@ -1,5 +1,6 @@
import { FC } from "react"; import { FC } from "react";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { useTranslation } from "@plane/i18n";
// ui // ui
import { EModalPosition, EModalWidth, ModalCore } from "@plane/ui"; import { EModalPosition, EModalWidth, ModalCore } from "@plane/ui";
// components // components
@ -16,7 +17,7 @@ export type ProductUpdatesModalProps = {
export const ProductUpdatesModal: FC<ProductUpdatesModalProps> = observer((props) => { export const ProductUpdatesModal: FC<ProductUpdatesModalProps> = observer((props) => {
const { isOpen, handleClose } = props; const { isOpen, handleClose } = props;
const { t } = useTranslation();
const { config } = useInstance(); const { config } = useInstance();
return ( return (
@ -27,17 +28,17 @@ export const ProductUpdatesModal: FC<ProductUpdatesModalProps> = observer((props
<iframe src={config?.instance_changelog_url} className="w-full h-full" /> <iframe src={config?.instance_changelog_url} className="w-full h-full" />
) : ( ) : (
<div className="flex flex-col items-center justify-center w-full h-full mb-8"> <div className="flex flex-col items-center justify-center w-full h-full mb-8">
<div className="text-lg font-medium">We are having trouble fetching the updates.</div> <div className="text-lg font-medium">{t("we_are_having_trouble_fetching_the_updates")}</div>
<div className="text-sm text-custom-text-200"> <div className="text-sm text-custom-text-200">
Please visit{" "} {t("please_visit")}
<a <a
href="https://go.plane.so/p-changelog" href="https://go.plane.so/p-changelog"
target="_blank" target="_blank"
className="text-sm text-custom-primary-100 font-medium hover:text-custom-primary-200 underline underline-offset-1 outline-none" className="text-sm text-custom-primary-100 font-medium hover:text-custom-primary-200 underline underline-offset-1 outline-none"
> >
our changelogs {t("our_changelogs")}
</a>{" "} </a>{" "}
for the latest updates. {t("for_the_latest_updates")}.
</div> </div>
</div> </div>
)} )}

View File

@ -3,6 +3,7 @@
import React, { useEffect, useRef, useState } from "react"; import React, { useEffect, useRef, useState } from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { useParams, usePathname } from "next/navigation"; import { useParams, usePathname } from "next/navigation";
import { useTranslation } from "@plane/i18n";
// types // types
import { EIssuesStoreType } from "@plane/constants"; import { EIssuesStoreType } from "@plane/constants";
import type { TBaseIssue, TIssue } from "@plane/types"; import type { TBaseIssue, TIssue } from "@plane/types";
@ -54,6 +55,7 @@ export const CreateUpdateIssueModalBase: React.FC<IssuesModalProps> = observer((
const [uploadedAssetIds, setUploadedAssetIds] = useState<string[]>([]); const [uploadedAssetIds, setUploadedAssetIds] = useState<string[]>([]);
const [isDuplicateModalOpen, setIsDuplicateModalOpen] = useState(false); const [isDuplicateModalOpen, setIsDuplicateModalOpen] = useState(false);
// store hooks // store hooks
const { t } = useTranslation();
const { captureIssueEvent } = useEventTracker(); const { captureIssueEvent } = useEventTracker();
const { workspaceSlug, projectId: routerProjectId, cycleId, moduleId } = useParams(); const { workspaceSlug, projectId: routerProjectId, cycleId, moduleId } = useParams();
const { projectsWithCreatePermissions } = useUser(); const { projectsWithCreatePermissions } = useUser();
@ -218,8 +220,8 @@ export const CreateUpdateIssueModalBase: React.FC<IssuesModalProps> = observer((
setToast({ setToast({
type: TOAST_TYPE.SUCCESS, type: TOAST_TYPE.SUCCESS,
title: "Success!", title: t("success"),
message: `${is_draft_issue ? "Draft created." : "Issue created successfully."} `, message: `${is_draft_issue ? t("draft_created") : t("issue_created_successfully")} `,
actionItems: !is_draft_issue && response?.project_id && ( actionItems: !is_draft_issue && response?.project_id && (
<CreateIssueToastActionItems <CreateIssueToastActionItems
workspaceSlug={workspaceSlug.toString()} workspaceSlug={workspaceSlug.toString()}
@ -241,8 +243,8 @@ export const CreateUpdateIssueModalBase: React.FC<IssuesModalProps> = observer((
} catch (error) { } catch (error) {
setToast({ setToast({
type: TOAST_TYPE.ERROR, type: TOAST_TYPE.ERROR,
title: "Error!", title: t("error"),
message: `${is_draft_issue ? "Draft issue" : "Issue"} could not be created. Please try again.`, message: t(is_draft_issue ? "draft_creation_failed" : "issue_creation_failed"),
}); });
captureIssueEvent({ captureIssueEvent({
eventName: ISSUE_CREATED, eventName: ISSUE_CREATED,
@ -287,8 +289,8 @@ export const CreateUpdateIssueModalBase: React.FC<IssuesModalProps> = observer((
setToast({ setToast({
type: TOAST_TYPE.SUCCESS, type: TOAST_TYPE.SUCCESS,
title: "Success!", title: t("success"),
message: "Issue updated successfully.", message: t("issue_updated_successfully"),
}); });
captureIssueEvent({ captureIssueEvent({
eventName: ISSUE_UPDATED, eventName: ISSUE_UPDATED,
@ -300,8 +302,8 @@ export const CreateUpdateIssueModalBase: React.FC<IssuesModalProps> = observer((
console.error(error); console.error(error);
setToast({ setToast({
type: TOAST_TYPE.ERROR, type: TOAST_TYPE.ERROR,
title: "Error!", title: t("error"),
message: "Issue could not be updated. Please try again.", message: t("issue_could_not_be_updated"),
}); });
captureIssueEvent({ captureIssueEvent({
eventName: ISSUE_UPDATED, eventName: ISSUE_UPDATED,

View File

@ -4,6 +4,7 @@ import React, { useState } from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { Control, Controller } from "react-hook-form"; import { Control, Controller } from "react-hook-form";
import { LayoutPanelTop } from "lucide-react"; import { LayoutPanelTop } from "lucide-react";
import { useTranslation } from "@plane/i18n";
// types // types
import { ISearchIssueResponse, TIssue } from "@plane/types"; import { ISearchIssueResponse, TIssue } from "@plane/types";
// ui // ui
@ -65,6 +66,7 @@ export const IssueDefaultProperties: React.FC<TIssueDefaultPropertiesProps> = ob
// states // states
const [parentIssueListModalOpen, setParentIssueListModalOpen] = useState(false); const [parentIssueListModalOpen, setParentIssueListModalOpen] = useState(false);
// store hooks // store hooks
const { t } = useTranslation();
const { areEstimateEnabledByProjectId } = useProjectEstimates(); const { areEstimateEnabledByProjectId } = useProjectEstimates();
const { getProjectById } = useProject(); const { getProjectById } = useProject();
const { isMobile } = usePlatformOS(); const { isMobile } = usePlatformOS();
@ -133,7 +135,7 @@ export const IssueDefaultProperties: React.FC<TIssueDefaultPropertiesProps> = ob
}} }}
buttonVariant={value?.length > 0 ? "transparent-without-text" : "border-with-text"} buttonVariant={value?.length > 0 ? "transparent-without-text" : "border-with-text"}
buttonClassName={value?.length > 0 ? "hover:bg-transparent" : ""} buttonClassName={value?.length > 0 ? "hover:bg-transparent" : ""}
placeholder="Assignees" placeholder={t("assignees")}
multiple multiple
tabIndex={getIndex("assignee_ids")} tabIndex={getIndex("assignee_ids")}
/> />
@ -172,7 +174,7 @@ export const IssueDefaultProperties: React.FC<TIssueDefaultPropertiesProps> = ob
}} }}
buttonVariant="border-with-text" buttonVariant="border-with-text"
maxDate={maxDate ?? undefined} maxDate={maxDate ?? undefined}
placeholder="Start date" placeholder={t("start_date")}
tabIndex={getIndex("start_date")} tabIndex={getIndex("start_date")}
/> />
</div> </div>
@ -191,7 +193,7 @@ export const IssueDefaultProperties: React.FC<TIssueDefaultPropertiesProps> = ob
}} }}
buttonVariant="border-with-text" buttonVariant="border-with-text"
minDate={minDate ?? undefined} minDate={minDate ?? undefined}
placeholder="Due date" placeholder={t("due_date")}
tabIndex={getIndex("target_date")} tabIndex={getIndex("target_date")}
/> />
</div> </div>
@ -209,7 +211,7 @@ export const IssueDefaultProperties: React.FC<TIssueDefaultPropertiesProps> = ob
onChange(cycleId); onChange(cycleId);
handleFormChange(); handleFormChange();
}} }}
placeholder="Cycle" placeholder={t("cycle")}
value={value} value={value}
buttonVariant="border-with-text" buttonVariant="border-with-text"
tabIndex={getIndex("cycle_id")} tabIndex={getIndex("cycle_id")}
@ -231,7 +233,7 @@ export const IssueDefaultProperties: React.FC<TIssueDefaultPropertiesProps> = ob
onChange(moduleIds); onChange(moduleIds);
handleFormChange(); handleFormChange();
}} }}
placeholder="Modules" placeholder={t("modules")}
buttonVariant="border-with-text" buttonVariant="border-with-text"
tabIndex={getIndex("module_ids")} tabIndex={getIndex("module_ids")}
multiple multiple
@ -256,7 +258,7 @@ export const IssueDefaultProperties: React.FC<TIssueDefaultPropertiesProps> = ob
projectId={projectId} projectId={projectId}
buttonVariant="border-with-text" buttonVariant="border-with-text"
tabIndex={getIndex("estimate_point")} tabIndex={getIndex("estimate_point")}
placeholder="Estimate" placeholder={t("estimate")}
/> />
</div> </div>
)} )}
@ -288,7 +290,7 @@ export const IssueDefaultProperties: React.FC<TIssueDefaultPropertiesProps> = ob
> >
<> <>
<CustomMenu.MenuItem className="!p-1" onClick={() => setParentIssueListModalOpen(true)}> <CustomMenu.MenuItem className="!p-1" onClick={() => setParentIssueListModalOpen(true)}>
Change parent issue {t("change_parent_issue")}
</CustomMenu.MenuItem> </CustomMenu.MenuItem>
<Controller <Controller
control={control} control={control}
@ -301,7 +303,7 @@ export const IssueDefaultProperties: React.FC<TIssueDefaultPropertiesProps> = ob
handleFormChange(); handleFormChange();
}} }}
> >
Remove parent issue {t("remove_parent_issue")}
</CustomMenu.MenuItem> </CustomMenu.MenuItem>
)} )}
/> />
@ -314,7 +316,7 @@ export const IssueDefaultProperties: React.FC<TIssueDefaultPropertiesProps> = ob
onClick={() => setParentIssueListModalOpen(true)} onClick={() => setParentIssueListModalOpen(true)}
> >
<LayoutPanelTop className="h-3 w-3 flex-shrink-0" /> <LayoutPanelTop className="h-3 w-3 flex-shrink-0" />
<span className="whitespace-nowrap">Add parent</span> <span className="whitespace-nowrap">{t("add_parent")}</span>
</button> </button>
)} )}
</div> </div>

View File

@ -4,6 +4,7 @@ import React from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { Control, Controller, FieldErrors } from "react-hook-form"; import { Control, Controller, FieldErrors } from "react-hook-form";
// types // types
import { useTranslation } from "@plane/i18n";
import { TIssue } from "@plane/types"; import { TIssue } from "@plane/types";
// ui // ui
import { Input } from "@plane/ui"; import { Input } from "@plane/ui";
@ -25,12 +26,13 @@ export const IssueTitleInput: React.FC<TIssueTitleInputProps> = observer((props)
const { control, issueTitleRef, errors, handleFormChange } = props; const { control, issueTitleRef, errors, handleFormChange } = props;
// store hooks // store hooks
const { isMobile } = usePlatformOS(); const { isMobile } = usePlatformOS();
const { t } = useTranslation();
const { getIndex } = getTabIndex(ETabIndices.ISSUE_FORM, isMobile); const { getIndex } = getTabIndex(ETabIndices.ISSUE_FORM, isMobile);
const validateWhitespace = (value: string) => { const validateWhitespace = (value: string) => {
if (value.trim() === "") { if (value.trim() === "") {
return "Title is required"; return t("title_is_required");
} }
return undefined; return undefined;
}; };
@ -41,10 +43,10 @@ export const IssueTitleInput: React.FC<TIssueTitleInputProps> = observer((props)
name="name" name="name"
rules={{ rules={{
validate: validateWhitespace, validate: validateWhitespace,
required: "Title is required", required: t("title_is_required"),
maxLength: { maxLength: {
value: 255, value: 255,
message: "Title should be less than 255 characters", message: t("title_should_be_less_than_255_characters"),
}, },
}} }}
render={({ field: { value, onChange, ref } }) => ( render={({ field: { value, onChange, ref } }) => (
@ -59,7 +61,7 @@ export const IssueTitleInput: React.FC<TIssueTitleInputProps> = observer((props)
}} }}
ref={issueTitleRef || ref} ref={issueTitleRef || ref}
hasError={Boolean(errors.name)} hasError={Boolean(errors.name)}
placeholder="Title" placeholder={t("title")}
className="w-full text-base" className="w-full text-base"
tabIndex={getIndex("name")} tabIndex={getIndex("name")}
autoFocus autoFocus

View File

@ -7,6 +7,8 @@ import { useForm } from "react-hook-form";
// editor // editor
import { EIssuesStoreType } from "@plane/constants"; import { EIssuesStoreType } from "@plane/constants";
import { EditorRefApi } from "@plane/editor"; import { EditorRefApi } from "@plane/editor";
// i18n
import { useTranslation } from "@plane/i18n";
// types // types
import type { TIssue, ISearchIssueResponse, TWorkspaceDraftIssue } from "@plane/types"; import type { TIssue, ISearchIssueResponse, TWorkspaceDraftIssue } from "@plane/types";
// hooks // hooks
@ -77,6 +79,7 @@ export interface IssueFormProps {
} }
export const IssueFormRoot: FC<IssueFormProps> = observer((props) => { export const IssueFormRoot: FC<IssueFormProps> = observer((props) => {
const { t } = useTranslation();
const { const {
data, data,
issueTitleRef, issueTitleRef,
@ -89,10 +92,10 @@ export const IssueFormRoot: FC<IssueFormProps> = observer((props) => {
onCreateMoreToggleChange, onCreateMoreToggleChange,
isDraft, isDraft,
moveToIssue = false, moveToIssue = false,
modalTitle, modalTitle = `${data?.id ? t("update") : isDraft ? t("create_a_draft") : t("create_new_issue")}`,
primaryButtonText = { primaryButtonText = {
default: `${data?.id ? "Update" : isDraft ? "Save to Drafts" : "Save"}`, default: `${data?.id ? t("update") : isDraft ? t("save_to_drafts") : t("save")}`,
loading: `${data?.id ? "Updating" : "Saving"}`, loading: `${data?.id ? t("updating") : t("saving")}`,
}, },
isDuplicateModalOpen, isDuplicateModalOpen,
handleDuplicateIssueModal, handleDuplicateIssueModal,
@ -198,8 +201,8 @@ export const IssueFormRoot: FC<IssueFormProps> = observer((props) => {
if (!editorRef.current?.isEditorReadyToDiscard()) { if (!editorRef.current?.isEditorReadyToDiscard()) {
setToast({ setToast({
type: TOAST_TYPE.ERROR, type: TOAST_TYPE.ERROR,
title: "Error!", title: t("error"),
message: "Editor is not ready to discard changes.", message: t("editor_is_not_ready_to_discard_changes"),
}); });
return; return;
} }
@ -391,7 +394,11 @@ export const IssueFormRoot: FC<IssueFormProps> = observer((props) => {
<DeDupeButtonRoot <DeDupeButtonRoot
workspaceSlug={workspaceSlug?.toString()} workspaceSlug={workspaceSlug?.toString()}
isDuplicateModalOpen={isDuplicateModalOpen} isDuplicateModalOpen={isDuplicateModalOpen}
label={`${duplicateIssues.length} duplicate issue${duplicateIssues.length > 1 ? "s" : ""} found!`} label={
duplicateIssues.length === 1
? `${duplicateIssues.length} ${t("duplicate_issue_found")}`
: `${duplicateIssues.length} ${t("duplicate_issues_found")}`
}
handleOnClick={() => handleDuplicateIssueModal(!isDuplicateModalOpen)} handleOnClick={() => handleDuplicateIssueModal(!isDuplicateModalOpen)}
/> />
)} )}
@ -491,7 +498,7 @@ export const IssueFormRoot: FC<IssueFormProps> = observer((props) => {
role="button" role="button"
> >
<ToggleSwitch value={isCreateMoreToggleEnabled} onChange={() => {}} size="sm" /> <ToggleSwitch value={isCreateMoreToggleEnabled} onChange={() => {}} size="sm" />
<span className="text-xs">Create more</span> <span className="text-xs">{t("create_more")}</span>
</div> </div>
)} )}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@ -511,7 +518,7 @@ export const IssueFormRoot: FC<IssueFormProps> = observer((props) => {
}} }}
tabIndex={getIndex("discard_button")} tabIndex={getIndex("discard_button")}
> >
Discard {t("discard")}
</Button> </Button>
<Button <Button
variant={moveToIssue ? "neutral-primary" : "primary"} variant={moveToIssue ? "neutral-primary" : "primary"}

View File

@ -4,8 +4,8 @@ import { useParams } from "next/navigation";
import { usePopper } from "react-popper"; import { usePopper } from "react-popper";
import { Check, Component, Plus, Search, Tag } from "lucide-react"; import { Check, Component, Plus, Search, Tag } from "lucide-react";
import { Combobox } from "@headlessui/react"; import { Combobox } from "@headlessui/react";
// plane helpers
import { useOutsideClickDetector } from "@plane/hooks"; import { useOutsideClickDetector } from "@plane/hooks";
import { useTranslation } from "@plane/i18n";
// components // components
import { IssueLabelsList } from "@/components/ui"; import { IssueLabelsList } from "@/components/ui";
// helpers // helpers
@ -39,6 +39,7 @@ export const IssueLabelSelect: React.FC<Props> = observer((props) => {
createLabelEnabled = false, createLabelEnabled = false,
buttonClassName, buttonClassName,
} = props; } = props;
const { t } = useTranslation();
// router // router
const { workspaceSlug } = useParams(); const { workspaceSlug } = useParams();
// store hooks // store hooks
@ -131,7 +132,7 @@ export const IssueLabelSelect: React.FC<Props> = observer((props) => {
) : ( ) : (
<div className="h-full flex items-center justify-center gap-1 rounded border-[0.5px] border-custom-border-300 px-2 py-1 text-xs hover:bg-custom-background-80"> <div className="h-full flex items-center justify-center gap-1 rounded border-[0.5px] border-custom-border-300 px-2 py-1 text-xs hover:bg-custom-background-80">
<Tag className="h-3 w-3 flex-shrink-0" /> <Tag className="h-3 w-3 flex-shrink-0" />
<span>Labels</span> <span>{t("labels")}</span>
</div> </div>
)} )}
</button> </button>
@ -152,7 +153,7 @@ export const IssueLabelSelect: React.FC<Props> = observer((props) => {
ref={inputRef} ref={inputRef}
className="w-full bg-transparent py-1 text-xs text-custom-text-200 placeholder:text-custom-text-400 focus:outline-none" className="w-full bg-transparent py-1 text-xs text-custom-text-200 placeholder:text-custom-text-400 focus:outline-none"
onChange={(event) => setQuery(event.target.value)} onChange={(event) => setQuery(event.target.value)}
placeholder="Search" placeholder={t("search")}
displayValue={(assigned: any) => assigned?.name} displayValue={(assigned: any) => assigned?.name}
/> />
</div> </div>
@ -232,10 +233,10 @@ export const IssueLabelSelect: React.FC<Props> = observer((props) => {
); );
}) })
) : ( ) : (
<p className="text-custom-text-400 italic py-1 px-1.5">No matching results</p> <p className="text-custom-text-400 italic py-1 px-1.5">{t("no_matching_results")}</p>
) )
) : ( ) : (
<p className="text-custom-text-400 italic py-1 px-1.5">Loading...</p> <p className="text-custom-text-400 italic py-1 px-1.5">{t("loading")}</p>
)} )}
{createLabelEnabled && ( {createLabelEnabled && (
<button <button
@ -244,7 +245,7 @@ export const IssueLabelSelect: React.FC<Props> = observer((props) => {
onClick={() => setIsOpen(true)} onClick={() => setIsOpen(true)}
> >
<Plus className="h-3 w-3" aria-hidden="true" /> <Plus className="h-3 w-3" aria-hidden="true" />
<span className="whitespace-nowrap">Create new label</span> <span className="whitespace-nowrap">{t("create_new_label")}</span>
</button> </button>
)} )}
</div> </div>

View File

@ -0,0 +1,463 @@
import React, { useState } from "react";
import { observer } from "mobx-react";
import { Controller, useForm } from "react-hook-form";
import { ChevronDown, CircleUserRound } from "lucide-react";
import { Disclosure, Transition } from "@headlessui/react";
import { useTranslation, SUPPORTED_LANGUAGES } from "@plane/i18n";
import type { IUser, TUserProfile } from "@plane/types";
import { Button, CustomSelect, Input, TOAST_TYPE, setPromiseToast, setToast } from "@plane/ui";
// components
import { DeactivateAccountModal } from "@/components/account";
import { ImagePickerPopover, UserImageUploadModal } from "@/components/core";
import { TimezoneSelect } from "@/components/global";
// constants
import { USER_ROLES } from "@/constants/workspace";
// helpers
import { getFileURL } from "@/helpers/file.helper";
// hooks
import { useUser, useUserProfile } from "@/hooks/store";
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;
};
export type TProfileFormProps = {
user: IUser;
profile: TUserProfile;
};
export const ProfileForm = observer((props: TProfileFormProps) => {
const { user, profile } = props;
// states
const [isLoading, setIsLoading] = useState(false);
const [isImageUploadModalOpen, setIsImageUploadModalOpen] = useState(false);
const [deactivateAccountModal, setDeactivateAccountModal] = 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: "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 getLanguageLabel = (value: string) => {
const selectedLanguage = SUPPORTED_LANGUAGES.find((l) => l.value === value);
if (!selectedLanguage) return value;
return selectedLanguage.label;
};
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", "");
})
.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,
user_timezone: formData.user_timezone,
};
// if unsplash or a pre-defined image is uploaded, delete the old uploaded asset
if (formData.cover_image_url?.startsWith("http")) {
userPayload.cover_image = formData.cover_image_url;
userPayload.cover_image_asset = null;
}
const profilePayload: Partial<TUserProfile> = {
role: formData.role,
language: formData.language,
};
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.`,
},
});
};
return (
<>
<DeactivateAccountModal isOpen={deactivateAccountModal} onClose={() => setDeactivateAccountModal(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)}>
<div className="flex w-full flex-col gap-6">
<div className="relative h-44 w-full">
<img
src={userCover ? getFileURL(userCover) : "https://images.unsplash.com/photo-1506383796573-caf02b4a79ab"}
className="h-44 w-full rounded-lg object-cover"
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-custom-background-90">
<button type="button" onClick={() => setIsImageUploadModalOpen(true)}>
{!userAvatar || userAvatar === "" ? (
<div className="h-16 w-16 rounded-md bg-custom-background-80 p-2">
<CircleUserRound className="h-full w-full text-custom-text-200" />
</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")}
onChange={(imageUrl) => onChange(imageUrl)}
control={control}
value={value ?? "https://images.unsplash.com/photo-1506383796573-caf02b4a79ab"}
isProfileCover
/>
)}
/>
</div>
</div>
<div className="item-center mt-6 flex justify-between">
<div className="flex flex-col">
<div className="item-center flex text-lg font-medium text-custom-text-200">
<span>{`${watch("first_name")} ${watch("last_name")}`}</span>
</div>
<span className="text-sm text-custom-text-300 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-sm font-medium text-custom-text-200">
{t("first_name")}&nbsp;
<span className="text-red-500">*</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-red-500" : ""}`}
maxLength={24}
autoComplete="on"
/>
)}
/>
{errors.first_name && <span className="text-xs text-red-500">{errors.first_name.message}</span>}
</div>
<div className="flex flex-col gap-1">
<h4 className="text-sm font-medium text-custom-text-200">{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-sm font-medium text-custom-text-200">
{t("display_name")}&nbsp;
<span className="text-red-500">*</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-red-500" : ""}`}
maxLength={24}
/>
)}
/>
{errors?.display_name && <span className="text-xs text-red-500">{errors?.display_name?.message}</span>}
</div>
<div className="flex flex-col gap-1">
<h4 className="text-sm font-medium text-custom-text-200">
{t("email")}&nbsp;
<span className="text-red-500">*</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-custom-background-90 ${
errors.email ? "border-red-500" : ""
}`}
autoComplete="on"
disabled
/>
)}
/>
</div>
<div className="flex flex-col gap-1">
<h4 className="text-sm font-medium text-custom-text-200">
{t("role")}&nbsp;
<span className="text-red-500">*</span>
</h4>
<Controller
name="role"
control={control}
rules={{ required: "Role is required." }}
render={({ field: { value, onChange } }) => (
<CustomSelect
value={value}
onChange={onChange}
label={value ? value.toString() : "Select your role"}
buttonClassName={errors.role ? "border-red-500" : "border-none"}
className="rounded-md border-[0.5px] !border-custom-border-200"
optionsClassName="w-full"
input
>
{USER_ROLES.map((item) => (
<CustomSelect.Option key={item.value} value={item.value}>
{item.label}
</CustomSelect.Option>
))}
</CustomSelect>
)}
/>
{errors.role && <span className="text-xs text-red-500">Please select a role</span>}
</div>
</div>
</div>
<div className="flex flex-col gap-1">
<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-sm font-medium text-custom-text-200">
{t("timezone")}&nbsp;
<span className="text-red-500">*</span>
</h4>
<Controller
name="user_timezone"
control={control}
rules={{ required: "Please select a timezone" }}
render={({ field: { value, onChange } }) => (
<TimezoneSelect
value={value}
onChange={(value: string) => {
onChange(value);
}}
error={Boolean(errors.user_timezone)}
/>
)}
/>
{errors.user_timezone && <span className="text-xs text-red-500">{errors.user_timezone.message}</span>}
</div>
<div className="flex flex-col gap-1">
<div className="flex gap-2 items-center">
<h4 className="text-sm font-medium text-custom-text-200">{t("language")} </h4>
<div className="w-fit cursor-pointer rounded-2xl text-custom-primary-200 bg-custom-primary-100/20 text-center font-medium outline-none text-xs px-2">
Alpha
</div>
</div>
<Controller
control={control}
name="language"
rules={{ required: "Please select a language" }}
render={({ field: { value, onChange } }) => (
<CustomSelect
value={value}
label={value ? getLanguageLabel(value) : "Select a language"}
onChange={onChange}
buttonClassName={errors.language ? "border-red-500" : "border-none"}
className="rounded-md border-[0.5px] !border-custom-border-200"
optionsClassName="w-full"
input
>
{SUPPORTED_LANGUAGES.map((item) => (
<CustomSelect.Option key={item.value} value={item.value}>
{item.label}
</CustomSelect.Option>
))}
</CustomSelect>
)}
/>
</div>
</div>
<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-custom-border-100">
{({ open }) => (
<>
<Disclosure.Button as="button" type="button" className="flex w-full items-center justify-between py-4">
<span className="text-lg font-medium tracking-tight">{t("deactivate_account")}</span>
<ChevronDown 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-sm tracking-tight">{t("deactivate_account_description")}</span>
<div>
<Button variant="danger" onClick={() => setDeactivateAccountModal(true)}>
{t("deactivate_account")}
</Button>
</div>
</div>
</Disclosure.Panel>
</Transition>
</>
)}
</Disclosure>
</>
);
});

View File

@ -3,5 +3,6 @@ export * from "./overview";
export * from "./profile-issues-filter"; export * from "./profile-issues-filter";
export * from "./sidebar"; export * from "./sidebar";
export * from "./time"; export * from "./time";
export * from "./profile-setting-content-wrapper" export * from "./profile-setting-content-wrapper";
export * from "./profile-setting-content-header" export * from "./profile-setting-content-header";
export * from "./form";

View File

@ -2,14 +2,14 @@
import React, { FC, useEffect } from "react"; import React, { FC, useEffect } from "react";
import { Controller, useForm } from "react-hook-form"; import { Controller, useForm } from "react-hook-form";
import { useTranslation } from "@plane/i18n";
import { IUserEmailNotificationSettings } from "@plane/types"; import { IUserEmailNotificationSettings } from "@plane/types";
// ui // ui
import { ToggleSwitch, TOAST_TYPE, setToast } from "@plane/ui"; import { ToggleSwitch, TOAST_TYPE, setToast } from "@plane/ui";
// services // services
import { UserService } from "@/services/user.service"; import { UserService } from "@/services/user.service";
// types // types
interface IEmailNotificationFormProps {
interface IEmailNotificationFormProps {
data: IUserEmailNotificationSettings; data: IUserEmailNotificationSettings;
} }
@ -18,6 +18,7 @@ const userService = new UserService();
export const EmailNotificationForm: FC<IEmailNotificationFormProps> = (props) => { export const EmailNotificationForm: FC<IEmailNotificationFormProps> = (props) => {
const { data } = props; const { data } = props;
const { t } = useTranslation();
// form data // form data
const { const {
control, control,
@ -34,16 +35,16 @@ export const EmailNotificationForm: FC<IEmailNotificationFormProps> = (props) =>
[key]: value, [key]: value,
}); });
setToast({ setToast({
title: "Success!", title: t("success"),
type: TOAST_TYPE.SUCCESS, type: TOAST_TYPE.SUCCESS,
message: "Email notification setting updated successfully", message: t("email_notification_setting_updated_successfully"),
}); });
} catch (err) { } catch (err) {
console.error(err); console.error(err);
setToast({ setToast({
title: "Error!", title: t("error"),
type: TOAST_TYPE.ERROR, type: TOAST_TYPE.ERROR,
message: "Failed to update email notification setting", message: t("failed_to_update_email_notification_setting"),
}); });
} }
}; };
@ -54,15 +55,13 @@ export const EmailNotificationForm: FC<IEmailNotificationFormProps> = (props) =>
return ( return (
<> <>
<div className="pt-6 text-lg font-medium text-custom-text-100">Notify me when:</div> <div className="pt-6 text-lg font-medium text-custom-text-100">{t("notify_me_when")}:</div>
{/* Notification Settings */} {/* Notification Settings */}
<div className="flex flex-col py-2"> <div className="flex flex-col py-2">
<div className="flex gap-2 items-center pt-6"> <div className="flex gap-2 items-center pt-6">
<div className="grow"> <div className="grow">
<div className="pb-1 text-base font-medium text-custom-text-100">Property changes</div> <div className="pb-1 text-base font-medium text-custom-text-100">{t("property_changes")}</div>
<div className="text-sm font-normal text-custom-text-300"> <div className="text-sm font-normal text-custom-text-300">{t("property_changes_description")}</div>
Notify me when issue&apos;s properties like assignees, priority, estimates or anything else changes.
</div>
</div> </div>
<div className="shrink-0"> <div className="shrink-0">
<Controller <Controller
@ -83,9 +82,9 @@ export const EmailNotificationForm: FC<IEmailNotificationFormProps> = (props) =>
</div> </div>
<div className="flex gap-2 items-center pt-6 pb-2"> <div className="flex gap-2 items-center pt-6 pb-2">
<div className="grow"> <div className="grow">
<div className="pb-1 text-base font-medium text-custom-text-100">State change</div> <div className="pb-1 text-base font-medium text-custom-text-100">{t("state_change")}</div>
<div className="text-sm font-normal text-custom-text-300"> <div className="text-sm font-normal text-custom-text-300">
Notify me when the issues moves to a different state {t("state_change_description")}
</div> </div>
</div> </div>
<div className="shrink-0"> <div className="shrink-0">
@ -107,8 +106,8 @@ export const EmailNotificationForm: FC<IEmailNotificationFormProps> = (props) =>
</div> </div>
<div className="flex gap-2 items-center border-0 border-l-[3px] border-custom-border-300 pl-3"> <div className="flex gap-2 items-center border-0 border-l-[3px] border-custom-border-300 pl-3">
<div className="grow"> <div className="grow">
<div className="pb-1 text-base font-medium text-custom-text-100">Issue completed</div> <div className="pb-1 text-base font-medium text-custom-text-100">{t("issue_completed")}</div>
<div className="text-sm font-normal text-custom-text-300">Notify me only when an issue is completed</div> <div className="text-sm font-normal text-custom-text-300">{t("issue_completed_description")}</div>
</div> </div>
<div className="shrink-0"> <div className="shrink-0">
<Controller <Controller
@ -129,9 +128,9 @@ export const EmailNotificationForm: FC<IEmailNotificationFormProps> = (props) =>
</div> </div>
<div className="flex gap-2 items-center pt-6"> <div className="flex gap-2 items-center pt-6">
<div className="grow"> <div className="grow">
<div className="pb-1 text-base font-medium text-custom-text-100">Comments</div> <div className="pb-1 text-base font-medium text-custom-text-100">{t("comments")}</div>
<div className="text-sm font-normal text-custom-text-300"> <div className="text-sm font-normal text-custom-text-300">
Notify me when someone leaves a comment on the issue {t("comments_description")}
</div> </div>
</div> </div>
<div className="shrink-0"> <div className="shrink-0">
@ -153,9 +152,9 @@ export const EmailNotificationForm: FC<IEmailNotificationFormProps> = (props) =>
</div> </div>
<div className="flex gap-2 items-center pt-6"> <div className="flex gap-2 items-center pt-6">
<div className="grow"> <div className="grow">
<div className="pb-1 text-base font-medium text-custom-text-100">Mentions</div> <div className="pb-1 text-base font-medium text-custom-text-100">{t("mentions")}</div>
<div className="text-sm font-normal text-custom-text-300"> <div className="text-sm font-normal text-custom-text-300">
Notify me only when someone mentions me in the comments or description {t("mentions_description")}
</div> </div>
</div> </div>
<div className="shrink-0"> <div className="shrink-0">

View File

@ -1,7 +1,8 @@
import { ChangeEvent } from "react"; import { ChangeEvent } from "react";
import { Controller, useFormContext, UseFormSetValue } from "react-hook-form"; import { Controller, useFormContext, UseFormSetValue } from "react-hook-form";
import { Info } from "lucide-react"; import { Info } from "lucide-react";
// plane ui import { useTranslation } from "@plane/i18n";
// ui
import { Input, TextArea, Tooltip } from "@plane/ui"; import { Input, TextArea, Tooltip } from "@plane/ui";
// plane utils // plane utils
import { cn } from "@plane/utils"; import { cn } from "@plane/utils";
@ -27,6 +28,7 @@ const ProjectCommonAttributes: React.FC<Props> = (props) => {
} = useFormContext<TProject>(); } = useFormContext<TProject>();
const { getIndex } = getTabIndex(ETabIndices.PROJECT_CREATE, isMobile); const { getIndex } = getTabIndex(ETabIndices.PROJECT_CREATE, isMobile);
const { t } = useTranslation();
const handleNameChange = (onChange: (...event: any[]) => void) => (e: ChangeEvent<HTMLInputElement>) => { const handleNameChange = (onChange: (...event: any[]) => void) => (e: ChangeEvent<HTMLInputElement>) => {
if (!isChangeInIdentifierRequired) { if (!isChangeInIdentifierRequired) {
@ -51,10 +53,10 @@ const ProjectCommonAttributes: React.FC<Props> = (props) => {
control={control} control={control}
name="name" name="name"
rules={{ rules={{
required: "Name is required", required: t("name_is_required"),
maxLength: { maxLength: {
value: 255, value: 255,
message: "Title should be less than 255 characters", message: t("title_should_be_less_than_255_characters"),
}, },
}} }}
render={({ field: { value, onChange } }) => ( render={({ field: { value, onChange } }) => (
@ -65,7 +67,7 @@ const ProjectCommonAttributes: React.FC<Props> = (props) => {
value={value} value={value}
onChange={handleNameChange(onChange)} onChange={handleNameChange(onChange)}
hasError={Boolean(errors.name)} hasError={Boolean(errors.name)}
placeholder="Project name" placeholder={t("project_name")}
className="w-full focus:border-blue-400" className="w-full focus:border-blue-400"
tabIndex={getIndex("name")} tabIndex={getIndex("name")}
/> />
@ -78,17 +80,17 @@ const ProjectCommonAttributes: React.FC<Props> = (props) => {
control={control} control={control}
name="identifier" name="identifier"
rules={{ rules={{
required: "Project ID is required", required: t("project_id_is_required"),
// allow only alphanumeric & non-latin characters // allow only alphanumeric & non-latin characters
validate: (value) => validate: (value) =>
/^[ÇŞĞIİÖÜA-Z0-9]+$/.test(value.toUpperCase()) || "Only Alphanumeric & Non-latin characters are allowed.", /^[ÇŞĞIİÖÜA-Z0-9]+$/.test(value.toUpperCase()) || t("only_alphanumeric_non_latin_characters_allowed"),
minLength: { minLength: {
value: 1, value: 1,
message: "Project ID must at least be of 1 character", message: t("project_id_must_be_at_least_1_character"),
}, },
maxLength: { maxLength: {
value: 5, value: 5,
message: "Project ID must at most be of 5 characters", message: t("project_id_must_be_at_most_5_characters"),
}, },
}} }}
render={({ field: { value, onChange } }) => ( render={({ field: { value, onChange } }) => (
@ -99,7 +101,7 @@ const ProjectCommonAttributes: React.FC<Props> = (props) => {
value={value} value={value}
onChange={handleIdentifierChange(onChange)} onChange={handleIdentifierChange(onChange)}
hasError={Boolean(errors.identifier)} hasError={Boolean(errors.identifier)}
placeholder="Project ID" placeholder={t("project_id")}
className={cn("w-full text-xs focus:border-blue-400 pr-7", { className={cn("w-full text-xs focus:border-blue-400 pr-7", {
uppercase: value, uppercase: value,
})} })}
@ -109,7 +111,7 @@ const ProjectCommonAttributes: React.FC<Props> = (props) => {
/> />
<Tooltip <Tooltip
isMobile={isMobile} isMobile={isMobile}
tooltipContent="Helps you identify issues in the project uniquely. Max 5 characters." tooltipContent={t("project_id_tooltip_content")}
className="text-sm" className="text-sm"
position="right-top" position="right-top"
> >
@ -126,7 +128,7 @@ const ProjectCommonAttributes: React.FC<Props> = (props) => {
id="description" id="description"
name="description" name="description"
value={value} value={value}
placeholder="Description..." placeholder={t("description")}
onChange={onChange} onChange={onChange}
className="!h-24 text-sm focus:border-blue-400" className="!h-24 text-sm focus:border-blue-400"
hasError={Boolean(errors?.description)} hasError={Boolean(errors?.description)}

View File

@ -1,6 +1,7 @@
import { useState } from "react"; import { useState } from "react";
import { Controller, useFormContext } from "react-hook-form"; import { Controller, useFormContext } from "react-hook-form";
import { X } from "lucide-react"; import { X } from "lucide-react";
import { useTranslation } from "@plane/i18n";
// plane types // plane types
import { IProject } from "@plane/types"; import { IProject } from "@plane/types";
// plane ui // plane ui
@ -21,6 +22,7 @@ type Props = {
const ProjectCreateHeader: React.FC<Props> = (props) => { const ProjectCreateHeader: React.FC<Props> = (props) => {
const { handleClose, isMobile = false } = props; const { handleClose, isMobile = false } = props;
const { watch, control } = useFormContext<IProject>(); const { watch, control } = useFormContext<IProject>();
const { t } = useTranslation();
// derived values // derived values
const coverImage = watch("cover_image_url"); const coverImage = watch("cover_image_url");
@ -33,7 +35,7 @@ const ProjectCreateHeader: React.FC<Props> = (props) => {
<img <img
src={getFileURL(coverImage)} src={getFileURL(coverImage)}
className="absolute left-0 top-0 h-full w-full rounded-lg object-cover" className="absolute left-0 top-0 h-full w-full rounded-lg object-cover"
alt="Project cover image" alt={t("project_cover_image_alt")}
/> />
)} )}
@ -48,7 +50,7 @@ const ProjectCreateHeader: React.FC<Props> = (props) => {
control={control} control={control}
render={({ field: { value, onChange } }) => ( render={({ field: { value, onChange } }) => (
<ImagePickerPopover <ImagePickerPopover
label="Change Cover" label={t("change_cover")}
onChange={onChange} onChange={onChange}
control={control} control={control}
value={value} value={value}

View File

@ -1,4 +1,5 @@
import { useFormContext } from "react-hook-form"; import { useFormContext } from "react-hook-form";
import { useTranslation } from "@plane/i18n";
import { IProject } from "@plane/types"; import { IProject } from "@plane/types";
// ui // ui
import { Button } from "@plane/ui"; import { Button } from "@plane/ui";
@ -13,6 +14,7 @@ type Props = {
}; };
const ProjectCreateButtons: React.FC<Props> = (props) => { const ProjectCreateButtons: React.FC<Props> = (props) => {
const { t } = useTranslation();
const { handleClose, isMobile = false } = props; const { handleClose, isMobile = false } = props;
const { const {
formState: { isSubmitting }, formState: { isSubmitting },
@ -23,10 +25,10 @@ const ProjectCreateButtons: React.FC<Props> = (props) => {
return ( return (
<div className="flex justify-end gap-2 py-4 border-t border-custom-border-100"> <div className="flex justify-end gap-2 py-4 border-t border-custom-border-100">
<Button variant="neutral-primary" size="sm" onClick={handleClose} tabIndex={getIndex("cancel")}> <Button variant="neutral-primary" size="sm" onClick={handleClose} tabIndex={getIndex("cancel")}>
Cancel {t("cancel")}
</Button> </Button>
<Button variant="primary" type="submit" size="sm" loading={isSubmitting} tabIndex={getIndex("submit")}> <Button variant="primary" type="submit" size="sm" loading={isSubmitting} tabIndex={getIndex("submit")}>
{isSubmitting ? "Creating" : "Create project"} {isSubmitting ? t("creating") : t("create_project")}
</Button> </Button>
</div> </div>
); );

View File

@ -3,6 +3,7 @@
import { FC, useEffect, useState } from "react"; import { FC, useEffect, useState } from "react";
import { Controller, useForm } from "react-hook-form"; import { Controller, useForm } from "react-hook-form";
import { Info, Lock } from "lucide-react"; import { Info, Lock } from "lucide-react";
import { useTranslation } from "@plane/i18n";
// plane types // plane types
import { IProject, IWorkspace } from "@plane/types"; import { IProject, IWorkspace } from "@plane/types";
// plane ui // plane ui
@ -43,6 +44,7 @@ export interface IProjectDetailsForm {
const projectService = new ProjectService(); const projectService = new ProjectService();
export const ProjectDetailsForm: FC<IProjectDetailsForm> = (props) => { export const ProjectDetailsForm: FC<IProjectDetailsForm> = (props) => {
const { project, workspaceSlug, projectId, isAdmin } = props; const { project, workspaceSlug, projectId, isAdmin } = props;
const { t } = useTranslation();
// states // states
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
@ -361,8 +363,8 @@ export const ProjectDetailsForm: FC<IProjectDetailsForm> = (props) => {
<div className="flex items-start gap-2"> <div className="flex items-start gap-2">
<network.icon className="h-3.5 w-3.5" /> <network.icon className="h-3.5 w-3.5" />
<div className="-mt-1"> <div className="-mt-1">
<p>{network.label}</p> <p>{t(network.label)}</p>
<p className="text-xs text-custom-text-400">{network.description}</p> <p className="text-xs text-custom-text-400">{t(network.description)}</p>
</div> </div>
</div> </div>
</CustomSelect.Option> </CustomSelect.Option>

View File

@ -3,6 +3,7 @@
import React, { FC } from "react"; import React, { FC } from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import Link from "next/link"; import Link from "next/link";
import { useTranslation } from "@plane/i18n";
// ui // ui
import { Button, getButtonStyling, Row } from "@plane/ui"; import { Button, getButtonStyling, Row } from "@plane/ui";
// components // components
@ -20,6 +21,7 @@ type Props = {
export const ProjectFeatureUpdate: FC<Props> = observer((props) => { export const ProjectFeatureUpdate: FC<Props> = observer((props) => {
const { workspaceSlug, projectId, onClose } = props; const { workspaceSlug, projectId, onClose } = props;
// store hooks // store hooks
const { t } = useTranslation();
const { getProjectById } = useProject(); const { getProjectById } = useProject();
if (!workspaceSlug || !projectId) return null; if (!workspaceSlug || !projectId) return null;
@ -33,12 +35,12 @@ export const ProjectFeatureUpdate: FC<Props> = observer((props) => {
</Row> </Row>
<div className="flex items-center justify-between gap-2 mt-4 px-6 py-4 border-t border-custom-border-100"> <div className="flex items-center justify-between gap-2 mt-4 px-6 py-4 border-t border-custom-border-100">
<div className="flex gap-1 text-sm text-custom-text-300 font-medium"> <div className="flex gap-1 text-sm text-custom-text-300 font-medium">
Congrats! Project <Logo logo={currentProjectDetails.logo_props} />{" "} {t("congrats")}! {t("project")} <Logo logo={currentProjectDetails.logo_props} />{" "}
<p className="break-all">{currentProjectDetails.name}</p> created. <p className="break-all">{currentProjectDetails.name}</p> {t("created").toLowerCase()}.
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
<Button variant="neutral-primary" size="sm" onClick={onClose} tabIndex={1}> <Button variant="neutral-primary" size="sm" onClick={onClose} tabIndex={1}>
Close {t("close")}
</Button> </Button>
<Link <Link
href={`/${workspaceSlug}/projects/${projectId}/issues`} href={`/${workspaceSlug}/projects/${projectId}/issues`}
@ -46,7 +48,7 @@ export const ProjectFeatureUpdate: FC<Props> = observer((props) => {
className={getButtonStyling("primary", "sm")} className={getButtonStyling("primary", "sm")}
tabIndex={2} tabIndex={2}
> >
Open project {t("open_project")}
</Link> </Link>
</div> </div>
</div> </div>

View File

@ -2,6 +2,7 @@
import { FC } from "react"; import { FC } from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { useTranslation } from "@plane/i18n";
import { IProject } from "@plane/types"; import { IProject } from "@plane/types";
import { ToggleSwitch, Tooltip, setPromiseToast } from "@plane/ui"; import { ToggleSwitch, Tooltip, setPromiseToast } from "@plane/ui";
// hooks // hooks
@ -20,6 +21,7 @@ type Props = {
export const ProjectFeaturesList: FC<Props> = observer((props) => { export const ProjectFeaturesList: FC<Props> = observer((props) => {
const { workspaceSlug, projectId, isAdmin } = props; const { workspaceSlug, projectId, isAdmin } = props;
// store hooks // store hooks
const { t } = useTranslation();
const { captureEvent } = useEventTracker(); const { captureEvent } = useEventTracker();
const { data: currentUser } = useUser(); const { data: currentUser } = useUser();
const { getProjectById, updateProject } = useProject(); const { getProjectById, updateProject } = useProject();
@ -62,8 +64,8 @@ export const ProjectFeaturesList: FC<Props> = observer((props) => {
return ( return (
<div key={featureSectionKey} className=""> <div key={featureSectionKey} className="">
<div className="flex flex-col justify-center pb-2 border-b border-custom-border-100"> <div className="flex flex-col justify-center pb-2 border-b border-custom-border-100">
<h3 className="text-xl font-medium">{feature.title}</h3> <h3 className="text-xl font-medium">{t(feature.key)}</h3>
<h4 className="text-sm leading-5 text-custom-text-200">{feature.description}</h4> <h4 className="text-sm leading-5 text-custom-text-200">{t(`${feature.key}_description`)}</h4>
</div> </div>
{Object.keys(feature.featureList).map((featureItemKey) => { {Object.keys(feature.featureList).map((featureItemKey) => {
const featureItem = feature.featureList[featureItemKey]; const featureItem = feature.featureList[featureItemKey];
@ -79,7 +81,7 @@ export const ProjectFeaturesList: FC<Props> = observer((props) => {
</div> </div>
<div> <div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<h4 className="text-sm font-medium leading-5">{featureItem.title}</h4> <h4 className="text-sm font-medium leading-5">{t(featureItem.key)}</h4>
{featureItem.isPro && ( {featureItem.isPro && (
<Tooltip tooltipContent="Pro feature" position="top"> <Tooltip tooltipContent="Pro feature" position="top">
<UpgradeBadge /> <UpgradeBadge />
@ -87,7 +89,7 @@ export const ProjectFeaturesList: FC<Props> = observer((props) => {
)} )}
</div> </div>
<p className="text-sm leading-5 tracking-tight text-custom-text-300"> <p className="text-sm leading-5 tracking-tight text-custom-text-300">
{featureItem.description} {t(`${featureItem.key}_description`)}
</p> </p>
</div> </div>
</div> </div>

View File

@ -3,8 +3,9 @@
import { Dispatch, SetStateAction, useEffect, useState, FC } from "react"; import { Dispatch, SetStateAction, useEffect, useState, FC } from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { Controller, useForm } from "react-hook-form"; import { Controller, useForm } from "react-hook-form";
// constants
import { ORGANIZATION_SIZE, RESTRICTED_URLS } from "@plane/constants"; import { ORGANIZATION_SIZE, RESTRICTED_URLS } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
// constants
// types // types
import { IWorkspace } from "@plane/types"; import { IWorkspace } from "@plane/types";
// ui // ui
@ -35,14 +36,15 @@ type Props = {
const workspaceService = new WorkspaceService(); const workspaceService = new WorkspaceService();
export const CreateWorkspaceForm: FC<Props> = observer((props) => { export const CreateWorkspaceForm: FC<Props> = observer((props) => {
const { t } = useTranslation();
const { const {
onSubmit, onSubmit,
defaultValues, defaultValues,
setDefaultValues, setDefaultValues,
secondaryButton, secondaryButton,
primaryButtonText = { primaryButtonText = {
loading: "Creating workspace", loading: t("creating_workspace"),
default: "Create workspace", default: t("create_workspace"),
}, },
} = props; } = props;
// states // states
@ -76,13 +78,13 @@ export const CreateWorkspaceForm: FC<Props> = observer((props) => {
payload: { payload: {
...res, ...res,
state: "SUCCESS", state: "SUCCESS",
element: "Create workspace page", element: t("create_workspace_page"),
}, },
}); });
setToast({ setToast({
type: TOAST_TYPE.SUCCESS, type: TOAST_TYPE.SUCCESS,
title: "Success!", title: t("success"),
message: "Workspace created successfully.", message: t("workspace_created_successfully"),
}); });
if (onSubmit) await onSubmit(res); if (onSubmit) await onSubmit(res);
@ -92,13 +94,13 @@ export const CreateWorkspaceForm: FC<Props> = observer((props) => {
eventName: WORKSPACE_CREATED, eventName: WORKSPACE_CREATED,
payload: { payload: {
state: "FAILED", state: "FAILED",
element: "Create workspace page", element: t("create_workspace_page"),
}, },
}); });
setToast({ setToast({
type: TOAST_TYPE.ERROR, type: TOAST_TYPE.ERROR,
title: "Error!", title: t("error"),
message: "Workspace could not be created. Please try again.", message: t("workspace_could_not_be_created_please_try_again"),
}); });
}); });
} else setSlugError(true); } else setSlugError(true);
@ -106,8 +108,8 @@ export const CreateWorkspaceForm: FC<Props> = observer((props) => {
.catch(() => { .catch(() => {
setToast({ setToast({
type: TOAST_TYPE.ERROR, type: TOAST_TYPE.ERROR,
title: "Error!", title: t("error"),
message: "Some error occurred while creating workspace. Please try again.", message: t("workspace_could_not_be_created_please_try_again"),
}); });
}); });
}; };
@ -125,7 +127,7 @@ export const CreateWorkspaceForm: FC<Props> = observer((props) => {
<div className="space-y-6 sm:space-y-7"> <div className="space-y-6 sm:space-y-7">
<div className="space-y-1 text-sm"> <div className="space-y-1 text-sm">
<label htmlFor="workspaceName"> <label htmlFor="workspaceName">
Name your workspace {t("name_your_workspace")}
<span className="ml-0.5 text-red-500">*</span> <span className="ml-0.5 text-red-500">*</span>
</label> </label>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
@ -133,13 +135,13 @@ export const CreateWorkspaceForm: FC<Props> = observer((props) => {
control={control} control={control}
name="name" name="name"
rules={{ rules={{
required: "This is a required field.", required: t("this_is_a_required_field"),
validate: (value) => validate: (value) =>
/^[\w\s-]*$/.test(value) || /^[\w\s-]*$/.test(value) ||
`Workspaces names can contain only (" "), ( - ), ( _ ) and alphanumeric characters.`, t("workspaces_names_can_contain_only_space_dash_and_alphanumeric_characters"),
maxLength: { maxLength: {
value: 80, value: 80,
message: "Limit your name to 80 characters.", message: t("limit_your_name_to_80_characters"),
}, },
}} }}
render={({ field: { value, ref, onChange } }) => ( render={({ field: { value, ref, onChange } }) => (
@ -156,7 +158,7 @@ export const CreateWorkspaceForm: FC<Props> = observer((props) => {
}} }}
ref={ref} ref={ref}
hasError={Boolean(errors.name)} hasError={Boolean(errors.name)}
placeholder="Something familiar and recognizable is always best." placeholder={t("something_familiar_and_recognizable_is_always_best")}
className="w-full" className="w-full"
/> />
)} )}
@ -166,7 +168,7 @@ export const CreateWorkspaceForm: FC<Props> = observer((props) => {
</div> </div>
<div className="space-y-1 text-sm"> <div className="space-y-1 text-sm">
<label htmlFor="workspaceUrl"> <label htmlFor="workspaceUrl">
Set your workspace&apos;s URL {t("set_your_workspace_url")}
<span className="ml-0.5 text-red-500">*</span> <span className="ml-0.5 text-red-500">*</span>
</label> </label>
<div className="flex w-full items-center rounded-md border-[0.5px] border-custom-border-200 px-3"> <div className="flex w-full items-center rounded-md border-[0.5px] border-custom-border-200 px-3">
@ -175,10 +177,10 @@ export const CreateWorkspaceForm: FC<Props> = observer((props) => {
control={control} control={control}
name="slug" name="slug"
rules={{ rules={{
required: "This is a required field.", required: t("this_is_a_required_field"),
maxLength: { maxLength: {
value: 48, value: 48,
message: "Limit your URL to 48 characters.", message: t("limit_your_url_to_48_characters"),
}, },
}} }}
render={({ field: { onChange, value, ref } }) => ( render={({ field: { onChange, value, ref } }) => (
@ -193,34 +195,34 @@ export const CreateWorkspaceForm: FC<Props> = observer((props) => {
}} }}
ref={ref} ref={ref}
hasError={Boolean(errors.slug)} hasError={Boolean(errors.slug)}
placeholder="workspace-name" placeholder={t("workspace_name")}
className="block w-full rounded-md border-none bg-transparent !px-0 py-2 text-sm" className="block w-full rounded-md border-none bg-transparent !px-0 py-2 text-sm"
/> />
)} )}
/> />
</div> </div>
{slugError && <p className="-mt-3 text-sm text-red-500">Workspace URL is already taken!</p>} {slugError && <p className="-mt-3 text-sm text-red-500">{t("workspace_url_is_already_taken")}</p>}
{invalidSlug && ( {invalidSlug && (
<p className="text-sm text-red-500">{`URLs can contain only ( - ), ( _ ) and alphanumeric characters.`}</p> <p className="text-sm text-red-500">{t("urls_can_contain_only_dash_and_alphanumeric_characters")}</p>
)} )}
{errors.slug && <span className="text-xs text-red-500">{errors.slug.message}</span>} {errors.slug && <span className="text-xs text-red-500">{errors.slug.message}</span>}
</div> </div>
<div className="space-y-1 text-sm"> <div className="space-y-1 text-sm">
<span> <span>
How many people will use this workspace?<span className="ml-0.5 text-red-500">*</span> {t("how_many_people_will_use_this_workspace")}<span className="ml-0.5 text-red-500">*</span>
</span> </span>
<div className="w-full"> <div className="w-full">
<Controller <Controller
name="organization_size" name="organization_size"
control={control} control={control}
rules={{ required: "This is a required field." }} rules={{ required: t("this_is_a_required_field") }}
render={({ field: { value, onChange } }) => ( render={({ field: { value, onChange } }) => (
<CustomSelect <CustomSelect
value={value} value={value}
onChange={onChange} onChange={onChange}
label={ label={
ORGANIZATION_SIZE.find((c) => c === value) ?? ( ORGANIZATION_SIZE.find((c) => c === value) ?? (
<span className="text-custom-text-400">Select a range</span> <span className="text-custom-text-400">{t("select_a_range")}</span>
) )
} }
buttonClassName="!border-[0.5px] !border-custom-border-200 !shadow-none" buttonClassName="!border-[0.5px] !border-custom-border-200 !shadow-none"
@ -249,7 +251,7 @@ export const CreateWorkspaceForm: FC<Props> = observer((props) => {
</Button> </Button>
{!secondaryButton && ( {!secondaryButton && (
<Button variant="neutral-primary" type="button" size="md" onClick={() => router.back()}> <Button variant="neutral-primary" type="button" size="md" onClick={() => router.back()}>
Go back {t("go_back")}
</Button> </Button>
)} )}
</div> </div>

View File

@ -1,6 +1,6 @@
"use client"; "use client";
import { Fragment, Ref, useState } from "react"; import { Fragment, Ref, useState, useMemo } from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import Link from "next/link"; import Link from "next/link";
import { useParams } from "next/navigation"; import { useParams } from "next/navigation";
@ -10,6 +10,7 @@ import { Check, ChevronDown, LogOut, Mails, PlusSquare, Settings } from "lucide-
// ui // ui
import { Menu, Transition } from "@headlessui/react"; import { Menu, Transition } from "@headlessui/react";
// types // types
import { useTranslation } from "@plane/i18n";
import { IWorkspace } from "@plane/types"; import { IWorkspace } from "@plane/types";
// plane ui // plane ui
import { Avatar, Loader, TOAST_TYPE, setToast } from "@plane/ui"; import { Avatar, Loader, TOAST_TYPE, setToast } from "@plane/ui";
@ -25,25 +26,29 @@ import { getIsWorkspaceCreationDisabled } from "@/plane-web/helpers/instance.hel
// components // components
import { WorkspaceLogo } from "../logo"; import { WorkspaceLogo } from "../logo";
// Static Data
const userLinks = (workspaceSlug: string) => [
export const SidebarDropdown = observer(() => {
const { t } = useTranslation();
const userLinks = useMemo(
() => (workspaceSlug: string) => [
{ {
key: "workspace_invites", key: "workspace_invites",
name: "Workspace invites", name: t("workspace_invites"),
href: "/invitations", href: "/invitations",
icon: Mails, icon: Mails,
access: [EUserPermissions.ADMIN, EUserPermissions.MEMBER, EUserPermissions.GUEST], access: [EUserPermissions.ADMIN, EUserPermissions.MEMBER, EUserPermissions.GUEST],
}, },
{ {
key: "settings", key: "settings",
name: "Workspace settings", name: t("workspace_settings"),
href: `/${workspaceSlug}/settings`, href: `/${workspaceSlug}/settings`,
icon: Settings, icon: Settings,
access: [EUserPermissions.ADMIN], access: [EUserPermissions.ADMIN],
}, },
]; ],
[t]
export const SidebarDropdown = observer(() => { );
// router params // router params
const { workspaceSlug } = useParams(); const { workspaceSlug } = useParams();
// store hooks // store hooks
@ -86,8 +91,8 @@ export const SidebarDropdown = observer(() => {
await signOut().catch(() => await signOut().catch(() =>
setToast({ setToast({
type: TOAST_TYPE.ERROR, type: TOAST_TYPE.ERROR,
title: "Error!", title: t("error"),
message: "Failed to sign out. Please try again.", message: t("failed_to_sign_out_please_try_again"),
}) })
); );
}; };
@ -121,7 +126,7 @@ export const SidebarDropdown = observer(() => {
<WorkspaceLogo logo={activeWorkspace?.logo_url} name={activeWorkspace?.name} /> <WorkspaceLogo logo={activeWorkspace?.logo_url} name={activeWorkspace?.name} />
{!sidebarCollapsed && ( {!sidebarCollapsed && (
<h4 className="truncate text-base font-medium text-custom-text-100"> <h4 className="truncate text-base font-medium text-custom-text-100">
{activeWorkspace?.name ?? "Loading..."} {activeWorkspace?.name ?? t("loading")}
</h4> </h4>
)} )}
</div> </div>
@ -177,7 +182,7 @@ export const SidebarDropdown = observer(() => {
<img <img
src={getFileURL(workspace.logo_url)} src={getFileURL(workspace.logo_url)}
className="absolute left-0 top-0 h-full w-full rounded object-cover" className="absolute left-0 top-0 h-full w-full rounded object-cover"
alt="Workspace Logo" alt={t("workspace_logo")}
/> />
) : ( ) : (
(workspace?.name?.[0] ?? "...") (workspace?.name?.[0] ?? "...")
@ -217,7 +222,7 @@ export const SidebarDropdown = observer(() => {
className="flex items-center gap-2 rounded px-2 py-1 text-sm font-medium text-custom-sidebar-text-200 hover:bg-custom-sidebar-background-80" className="flex items-center gap-2 rounded px-2 py-1 text-sm font-medium text-custom-sidebar-text-200 hover:bg-custom-sidebar-background-80"
> >
<PlusSquare strokeWidth={1.75} className="h-4 w-4 flex-shrink-0" /> <PlusSquare strokeWidth={1.75} className="h-4 w-4 flex-shrink-0" />
Create workspace {t("create_workspace")}
</Menu.Item> </Menu.Item>
</Link> </Link>
)} )}
@ -251,7 +256,7 @@ export const SidebarDropdown = observer(() => {
onClick={handleSignOut} onClick={handleSignOut}
> >
<LogOut className="size-4 flex-shrink-0" /> <LogOut className="size-4 flex-shrink-0" />
Sign out {t("sign_out")}
</Menu.Item> </Menu.Item>
</div> </div>
</div> </div>
@ -293,7 +298,7 @@ export const SidebarDropdown = observer(() => {
<Menu.Item as="div"> <Menu.Item as="div">
<span className="flex w-full items-center gap-2 rounded px-2 py-1 hover:bg-custom-sidebar-background-80"> <span className="flex w-full items-center gap-2 rounded px-2 py-1 hover:bg-custom-sidebar-background-80">
<Settings className="h-4 w-4 stroke-[1.5]" /> <Settings className="h-4 w-4 stroke-[1.5]" />
<span>Settings</span> <span>{t("settings")}</span>
</span> </span>
</Menu.Item> </Menu.Item>
</Link> </Link>
@ -306,7 +311,7 @@ export const SidebarDropdown = observer(() => {
onClick={handleSignOut} onClick={handleSignOut}
> >
<LogOut className="size-4 stroke-[1.5]" /> <LogOut className="size-4 stroke-[1.5]" />
Sign out {t("sign_out")}
</Menu.Item> </Menu.Item>
</div> </div>
{isUserInstanceAdmin && ( {isUserInstanceAdmin && (
@ -314,7 +319,7 @@ export const SidebarDropdown = observer(() => {
<Link href={GOD_MODE_URL}> <Link href={GOD_MODE_URL}>
<Menu.Item as="button" type="button" className="w-full"> <Menu.Item as="button" type="button" className="w-full">
<span className="flex w-full items-center justify-center rounded bg-custom-primary-100/20 px-2 py-1 text-sm font-medium text-custom-primary-100 hover:bg-custom-primary-100/30 hover:text-custom-primary-200"> <span className="flex w-full items-center justify-center rounded bg-custom-primary-100/20 px-2 py-1 text-sm font-medium text-custom-primary-100 hover:bg-custom-primary-100/30 hover:text-custom-primary-200">
Enter God Mode {t("enter_god_mode")}
</span> </span>
</Menu.Item> </Menu.Item>
</Link> </Link>

View File

@ -13,6 +13,7 @@ import { observer } from "mobx-react";
import { useParams } from "next/navigation"; import { useParams } from "next/navigation";
import { ChevronRight, FolderPlus } from "lucide-react"; import { ChevronRight, FolderPlus } from "lucide-react";
import { Disclosure, Transition } from "@headlessui/react"; import { Disclosure, Transition } from "@headlessui/react";
import { useTranslation } from "@plane/i18n";
// ui // ui
import { IFavorite } from "@plane/types"; import { IFavorite } from "@plane/types";
import { setToast, TOAST_TYPE, Tooltip } from "@plane/ui"; import { setToast, TOAST_TYPE, Tooltip } from "@plane/ui";
@ -38,6 +39,7 @@ export const SidebarFavoritesMenu = observer(() => {
const [isDragging, setIsDragging] = useState(false); const [isDragging, setIsDragging] = useState(false);
// store hooks // store hooks
const { t } = useTranslation();
const { sidebarCollapsed } = useAppTheme(); const { sidebarCollapsed } = useAppTheme();
const { const {
favoriteIds, favoriteIds,
@ -65,8 +67,8 @@ export const SidebarFavoritesMenu = observer(() => {
}).catch(() => { }).catch(() => {
setToast({ setToast({
type: TOAST_TYPE.ERROR, type: TOAST_TYPE.ERROR,
title: "Error!", title: t("error"),
message: "Failed to move favorite.", message: t("failed_to_move_favorite"),
}); });
}); });
}; };
@ -116,15 +118,15 @@ export const SidebarFavoritesMenu = observer(() => {
.then(() => { .then(() => {
setToast({ setToast({
type: TOAST_TYPE.SUCCESS, type: TOAST_TYPE.SUCCESS,
title: "Success!", title: t("success"),
message: "Favorite removed successfully.", message: t("favorite_removed_successfully"),
}); });
}) })
.catch(() => { .catch(() => {
setToast({ setToast({
type: TOAST_TYPE.ERROR, type: TOAST_TYPE.ERROR,
title: "Error!", title: t("error"),
message: "Something went wrong!", message: t("something_went_wrong"),
}); });
}); });
}; };
@ -132,8 +134,8 @@ export const SidebarFavoritesMenu = observer(() => {
removeFromFavoriteFolder(workspaceSlug.toString(), favoriteId).catch(() => { removeFromFavoriteFolder(workspaceSlug.toString(), favoriteId).catch(() => {
setToast({ setToast({
type: TOAST_TYPE.ERROR, type: TOAST_TYPE.ERROR,
title: "Error!", title: t("error"),
message: "Failed to move favorite.", message: t("failed_to_move_favorite"),
}); });
}); });
}; };
@ -143,8 +145,8 @@ export const SidebarFavoritesMenu = observer(() => {
reOrderFavorite(workspaceSlug.toString(), favoriteId, droppedFavId, edge).catch(() => { reOrderFavorite(workspaceSlug.toString(), favoriteId, droppedFavId, edge).catch(() => {
setToast({ setToast({
type: TOAST_TYPE.ERROR, type: TOAST_TYPE.ERROR,
title: "Error!", title: t("error"),
message: "Failed reorder favorite", message: t("failed_to_reorder_favorite"),
}); });
}); });
}, },
@ -198,10 +200,10 @@ export const SidebarFavoritesMenu = observer(() => {
)} )}
> >
<span onClick={() => toggleFavoriteMenu(!isFavoriteMenuOpen)} className="flex-1 text-start"> <span onClick={() => toggleFavoriteMenu(!isFavoriteMenuOpen)} className="flex-1 text-start">
YOUR FAVORITES {t("your_favorites").toUpperCase()}
</span> </span>
<span className="flex flex-shrink-0 opacity-0 pointer-events-none group-hover/workspace-button:opacity-100 group-hover/workspace-button:pointer-events-auto rounded p-0.5 "> <span className="flex flex-shrink-0 opacity-0 pointer-events-none group-hover/workspace-button:opacity-100 group-hover/workspace-button:pointer-events-auto rounded p-0.5 ">
<Tooltip tooltipHeading="Create folder" tooltipContent=""> <Tooltip tooltipHeading={t("create_folder")} tooltipContent="">
<FolderPlus <FolderPlus
onClick={() => { onClick={() => {
setCreateNewFolder(true); setCreateNewFolder(true);
@ -240,7 +242,9 @@ export const SidebarFavoritesMenu = observer(() => {
{Object.keys(groupedFavorites).length === 0 ? ( {Object.keys(groupedFavorites).length === 0 ? (
<> <>
{!sidebarCollapsed && ( {!sidebarCollapsed && (
<span className="text-custom-text-400 text-xs font-medium px-8 py-1.5">No favorites yet</span> <span className="text-custom-text-400 text-xs font-medium px-8 py-1.5">
{t("no_favorites_yet")}
</span>
)} )}
</> </>
) : ( ) : (

View File

@ -2,8 +2,9 @@ import { useEffect, useRef } from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { useParams } from "next/navigation"; import { useParams } from "next/navigation";
import { Controller, SubmitHandler, useForm } from "react-hook-form"; import { Controller, SubmitHandler, useForm } from "react-hook-form";
// plane helpers
import { useOutsideClickDetector } from "@plane/hooks"; import { useOutsideClickDetector } from "@plane/hooks";
import { useTranslation } from "@plane/i18n";
// plane helpers
// plane ui // plane ui
import { FavoriteFolderIcon, Input, setToast, TOAST_TYPE } from "@plane/ui"; import { FavoriteFolderIcon, Input, setToast, TOAST_TYPE } from "@plane/ui";
// hooks // hooks
@ -24,6 +25,7 @@ type TProps = {
}; };
export const NewFavoriteFolder = observer((props: TProps) => { export const NewFavoriteFolder = observer((props: TProps) => {
const { setCreateNewFolder, actionType, defaultName, favoriteId } = props; const { setCreateNewFolder, actionType, defaultName, favoriteId } = props;
const { t } = useTranslation();
const { workspaceSlug } = useParams(); const { workspaceSlug } = useParams();
const { addFavorite, updateFavorite, existingFolders } = useFavorite(); const { addFavorite, updateFavorite, existingFolders } = useFavorite();
@ -42,8 +44,8 @@ export const NewFavoriteFolder = observer((props: TProps) => {
if (existingFolders.includes(formData.name)) if (existingFolders.includes(formData.name))
return setToast({ return setToast({
type: TOAST_TYPE.ERROR, type: TOAST_TYPE.ERROR,
title: "Error!", title: t("error"),
message: "Folder already exists", message: t("folder_already_exists"),
}); });
formData = { formData = {
entity_type: "folder", entity_type: "folder",
@ -56,23 +58,23 @@ export const NewFavoriteFolder = observer((props: TProps) => {
if (formData.name === "") if (formData.name === "")
return setToast({ return setToast({
type: TOAST_TYPE.ERROR, type: TOAST_TYPE.ERROR,
title: "Error!", title: t("error"),
message: "Folder name cannot be empty", message: t("folder_name_cannot_be_empty"),
}); });
addFavorite(workspaceSlug.toString(), formData) addFavorite(workspaceSlug.toString(), formData)
.then(() => { .then(() => {
setToast({ setToast({
type: TOAST_TYPE.SUCCESS, type: TOAST_TYPE.SUCCESS,
title: "Success!", title: t("success"),
message: "Favorite created successfully.", message: t("favorite_created_successfully"),
}); });
}) })
.catch(() => { .catch(() => {
setToast({ setToast({
type: TOAST_TYPE.ERROR, type: TOAST_TYPE.ERROR,
title: "Error!", title: t("error"),
message: "Something went wrong!", message: t("something_went_wrong"),
}); });
}); });
setCreateNewFolder(false); setCreateNewFolder(false);
@ -84,8 +86,8 @@ export const NewFavoriteFolder = observer((props: TProps) => {
if (existingFolders.includes(formData.name)) if (existingFolders.includes(formData.name))
return setToast({ return setToast({
type: TOAST_TYPE.ERROR, type: TOAST_TYPE.ERROR,
title: "Error!", title: t("error"),
message: "Folder already exists", message: t("folder_already_exists"),
}); });
const payload = { const payload = {
name: formData.name.trim(), name: formData.name.trim(),
@ -94,23 +96,23 @@ export const NewFavoriteFolder = observer((props: TProps) => {
if (formData.name.trim() === "") if (formData.name.trim() === "")
return setToast({ return setToast({
type: TOAST_TYPE.ERROR, type: TOAST_TYPE.ERROR,
title: "Error!", title: t("error"),
message: "Folder name cannot be empty", message: t("folder_name_cannot_be_empty"),
}); });
updateFavorite(workspaceSlug.toString(), favoriteId, payload) updateFavorite(workspaceSlug.toString(), favoriteId, payload)
.then(() => { .then(() => {
setToast({ setToast({
type: TOAST_TYPE.SUCCESS, type: TOAST_TYPE.SUCCESS,
title: "Success!", title: t("success"),
message: "Favorite updated successfully.", message: t("favorite_updated_successfully"),
}); });
}) })
.catch(() => { .catch(() => {
setToast({ setToast({
type: TOAST_TYPE.ERROR, type: TOAST_TYPE.ERROR,
title: "Error!", title: t("error"),
message: "Something went wrong!", message: t("something_went_wrong"),
}); });
}); });
setCreateNewFolder(false); setCreateNewFolder(false);
@ -132,7 +134,7 @@ export const NewFavoriteFolder = observer((props: TProps) => {
name="name" name="name"
control={control} control={control}
rules={{ required: true }} rules={{ required: true }}
render={({ field }) => <Input className="w-full" placeholder="New folder" {...field} />} render={({ field }) => <Input className="w-full" placeholder={t("new_folder")} {...field} />}
/> />
</form> </form>
</div> </div>

View File

@ -4,6 +4,7 @@ import React, { useState } from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { useParams } from "next/navigation"; import { useParams } from "next/navigation";
import { FileText, HelpCircle, MessagesSquare, MoveLeft, User } from "lucide-react"; import { FileText, HelpCircle, MessagesSquare, MoveLeft, User } from "lucide-react";
import { useTranslation } from "@plane/i18n";
// ui // ui
import { CustomMenu, Tooltip, ToggleSwitch } from "@plane/ui"; import { CustomMenu, Tooltip, ToggleSwitch } from "@plane/ui";
// components // components
@ -24,6 +25,7 @@ export interface WorkspaceHelpSectionProps {
export const SidebarHelpSection: React.FC<WorkspaceHelpSectionProps> = observer(() => { export const SidebarHelpSection: React.FC<WorkspaceHelpSectionProps> = observer(() => {
const { workspaceSlug, projectId } = useParams(); const { workspaceSlug, projectId } = useParams();
// store hooks // store hooks
const { t } = useTranslation();
const { sidebarCollapsed, toggleSidebar } = useAppTheme(); const { sidebarCollapsed, toggleSidebar } = useAppTheme();
const { toggleShortcutModal } = useCommandPalette(); const { toggleShortcutModal } = useCommandPalette();
const { isMobile } = usePlatformOS(); const { isMobile } = usePlatformOS();
@ -83,7 +85,7 @@ export const SidebarHelpSection: React.FC<WorkspaceHelpSectionProps> = observer(
className="flex items-center justify- gap-x-2 rounded text-xs hover:bg-custom-background-80" className="flex items-center justify- gap-x-2 rounded text-xs hover:bg-custom-background-80"
> >
<FileText className="h-3.5 w-3.5 text-custom-text-200" size={14} /> <FileText className="h-3.5 w-3.5 text-custom-text-200" size={14} />
<span className="text-xs">Documentation</span> <span className="text-xs">{t("documentation")}</span>
</a> </a>
</CustomMenu.MenuItem> </CustomMenu.MenuItem>
{config?.intercom_app_id && config?.is_intercom_enabled && ( {config?.intercom_app_id && config?.is_intercom_enabled && (
@ -94,7 +96,7 @@ export const SidebarHelpSection: React.FC<WorkspaceHelpSectionProps> = observer(
className="flex w-full items-center gap-x-2 rounded text-xs hover:bg-custom-background-80" className="flex w-full items-center gap-x-2 rounded text-xs hover:bg-custom-background-80"
> >
<MessagesSquare className="h-3.5 w-3.5 text-custom-text-200" /> <MessagesSquare className="h-3.5 w-3.5 text-custom-text-200" />
<span className="text-xs">Message support</span> <span className="text-xs">{t("message_support")}</span>
</button> </button>
</CustomMenu.MenuItem> </CustomMenu.MenuItem>
)} )}
@ -105,7 +107,7 @@ export const SidebarHelpSection: React.FC<WorkspaceHelpSectionProps> = observer(
className="flex items-center justify- gap-x-2 rounded text-xs hover:bg-custom-background-80" className="flex items-center justify- gap-x-2 rounded text-xs hover:bg-custom-background-80"
> >
<User className="h-3.5 w-3.5 text-custom-text-200" size={14} /> <User className="h-3.5 w-3.5 text-custom-text-200" size={14} />
<span className="text-xs">Contact sales</span> <span className="text-xs">{t("contact_sales")}</span>
</a> </a>
</CustomMenu.MenuItem> </CustomMenu.MenuItem>
<div className="my-1 border-t border-custom-border-200" /> <div className="my-1 border-t border-custom-border-200" />
@ -117,7 +119,7 @@ export const SidebarHelpSection: React.FC<WorkspaceHelpSectionProps> = observer(
}} }}
className="flex w-full items-center justify-between text-xs hover:bg-custom-background-80" className="flex w-full items-center justify-between text-xs hover:bg-custom-background-80"
> >
<span className="racking-tight">Hyper Mode</span> <span className="racking-tight">{t("hyper_mode")}</span>
<ToggleSwitch <ToggleSwitch
value={canUseLocalDB} value={canUseLocalDB}
onChange={() => toggleLocalDB(workspaceSlug?.toString(), projectId?.toString())} onChange={() => toggleLocalDB(workspaceSlug?.toString(), projectId?.toString())}
@ -130,7 +132,7 @@ export const SidebarHelpSection: React.FC<WorkspaceHelpSectionProps> = observer(
onClick={() => toggleShortcutModal(true)} onClick={() => toggleShortcutModal(true)}
className="flex w-full items-center justify-start text-xs hover:bg-custom-background-80" className="flex w-full items-center justify-start text-xs hover:bg-custom-background-80"
> >
<span className="text-xs">Keyboard shortcuts</span> <span className="text-xs">{t("keyboard_shortcuts")}</span>
</button> </button>
</CustomMenu.MenuItem> </CustomMenu.MenuItem>
<CustomMenu.MenuItem> <CustomMenu.MenuItem>
@ -139,7 +141,7 @@ export const SidebarHelpSection: React.FC<WorkspaceHelpSectionProps> = observer(
onClick={() => setProductUpdatesModalOpen(true)} onClick={() => setProductUpdatesModalOpen(true)}
className="flex w-full items-center justify-start text-xs hover:bg-custom-background-80" className="flex w-full items-center justify-start text-xs hover:bg-custom-background-80"
> >
<span className="text-xs">What&apos;s new</span> <span className="text-xs">{t("whats_new")}</span>
</button> </button>
</CustomMenu.MenuItem> </CustomMenu.MenuItem>
<CustomMenu.MenuItem> <CustomMenu.MenuItem>

View File

@ -5,6 +5,7 @@ import { observer } from "mobx-react";
import Link from "next/link"; import Link from "next/link";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { FileText, Layers } from "lucide-react"; import { FileText, Layers } from "lucide-react";
import { useTranslation } from "@plane/i18n";
// plane ui // plane ui
import { Tooltip, DiceIcon, ContrastIcon, LayersIcon, Intake } from "@plane/ui"; import { Tooltip, DiceIcon, ContrastIcon, LayersIcon, Intake } from "@plane/ui";
// components // components
@ -17,6 +18,7 @@ import { EUserPermissions } from "@/plane-web/constants";
import { EUserPermissionsLevel } from "@/plane-web/constants/user-permissions"; import { EUserPermissionsLevel } from "@/plane-web/constants/user-permissions";
export type TNavigationItem = { export type TNavigationItem = {
key: string;
name: string; name: string;
href: string; href: string;
icon: React.ElementType; icon: React.ElementType;
@ -34,6 +36,7 @@ type TProjectItemsProps = {
export const ProjectNavigation: FC<TProjectItemsProps> = observer((props) => { export const ProjectNavigation: FC<TProjectItemsProps> = observer((props) => {
const { workspaceSlug, projectId, additionalNavigationItems } = props; const { workspaceSlug, projectId, additionalNavigationItems } = props;
// store hooks // store hooks
const { t } = useTranslation();
const { sidebarCollapsed: isSidebarCollapsed, toggleSidebar } = useAppTheme(); const { sidebarCollapsed: isSidebarCollapsed, toggleSidebar } = useAppTheme();
const { getProjectById } = useProject(); const { getProjectById } = useProject();
const { isMobile } = usePlatformOS(); const { isMobile } = usePlatformOS();
@ -54,6 +57,7 @@ export const ProjectNavigation: FC<TProjectItemsProps> = observer((props) => {
const baseNavigation = useCallback( const baseNavigation = useCallback(
(workspaceSlug: string, projectId: string): TNavigationItem[] => [ (workspaceSlug: string, projectId: string): TNavigationItem[] => [
{ {
key: "issues",
name: "Issues", name: "Issues",
href: `/${workspaceSlug}/projects/${projectId}/issues`, href: `/${workspaceSlug}/projects/${projectId}/issues`,
icon: LayersIcon, icon: LayersIcon,
@ -62,6 +66,7 @@ export const ProjectNavigation: FC<TProjectItemsProps> = observer((props) => {
sortOrder: 1, sortOrder: 1,
}, },
{ {
key: "cycles",
name: "Cycles", name: "Cycles",
href: `/${workspaceSlug}/projects/${projectId}/cycles`, href: `/${workspaceSlug}/projects/${projectId}/cycles`,
icon: ContrastIcon, icon: ContrastIcon,
@ -70,6 +75,7 @@ export const ProjectNavigation: FC<TProjectItemsProps> = observer((props) => {
sortOrder: 2, sortOrder: 2,
}, },
{ {
key: "modules",
name: "Modules", name: "Modules",
href: `/${workspaceSlug}/projects/${projectId}/modules`, href: `/${workspaceSlug}/projects/${projectId}/modules`,
icon: DiceIcon, icon: DiceIcon,
@ -78,6 +84,7 @@ export const ProjectNavigation: FC<TProjectItemsProps> = observer((props) => {
sortOrder: 3, sortOrder: 3,
}, },
{ {
key: "views",
name: "Views", name: "Views",
href: `/${workspaceSlug}/projects/${projectId}/views`, href: `/${workspaceSlug}/projects/${projectId}/views`,
icon: Layers, icon: Layers,
@ -86,6 +93,7 @@ export const ProjectNavigation: FC<TProjectItemsProps> = observer((props) => {
sortOrder: 4, sortOrder: 4,
}, },
{ {
key: "pages",
name: "Pages", name: "Pages",
href: `/${workspaceSlug}/projects/${projectId}/pages`, href: `/${workspaceSlug}/projects/${projectId}/pages`,
icon: FileText, icon: FileText,
@ -94,6 +102,7 @@ export const ProjectNavigation: FC<TProjectItemsProps> = observer((props) => {
sortOrder: 5, sortOrder: 5,
}, },
{ {
key: "intake",
name: "Intake", name: "Intake",
href: `/${workspaceSlug}/projects/${projectId}/inbox`, href: `/${workspaceSlug}/projects/${projectId}/inbox`,
icon: Intake, icon: Intake,
@ -137,7 +146,7 @@ export const ProjectNavigation: FC<TProjectItemsProps> = observer((props) => {
<Tooltip <Tooltip
key={item.name} key={item.name}
isMobile={isMobile} isMobile={isMobile}
tooltipContent={`${project?.name}: ${item.name}`} tooltipContent={`${project?.name}: ${t(item.key)}`}
position="right" position="right"
className="ml-2" className="ml-2"
disabled={!isSidebarCollapsed} disabled={!isSidebarCollapsed}
@ -151,7 +160,7 @@ export const ProjectNavigation: FC<TProjectItemsProps> = observer((props) => {
<item.icon <item.icon
className={`flex-shrink-0 size-4 ${item.name === "Intake" ? "stroke-1" : "stroke-[1.5]"}`} className={`flex-shrink-0 size-4 ${item.name === "Intake" ? "stroke-1" : "stroke-[1.5]"}`}
/> />
{!isSidebarCollapsed && <span className="text-xs font-medium">{item.name}</span>} {!isSidebarCollapsed && <span className="text-xs font-medium">{t(item.key)}</span>}
</div> </div>
</SidebarNavItem> </SidebarNavItem>
</Link> </Link>

View File

@ -14,6 +14,7 @@ import { LinkIcon, Star, Settings, Share2, LogOut, MoreHorizontal, ChevronRight
import { Disclosure, Transition } from "@headlessui/react"; import { Disclosure, Transition } from "@headlessui/react";
// plane helpers // plane helpers
import { useOutsideClickDetector } from "@plane/hooks"; import { useOutsideClickDetector } from "@plane/hooks";
import { useTranslation } from "@plane/i18n";
// ui // ui
import { CustomMenu, Tooltip, ArchiveIcon, setPromiseToast, DropIndicator, DragHandle, ControlLink } from "@plane/ui"; import { CustomMenu, Tooltip, ArchiveIcon, setPromiseToast, DropIndicator, DragHandle, ControlLink } from "@plane/ui";
// components // components
@ -49,6 +50,7 @@ export const SidebarProjectsListItem: React.FC<Props> = observer((props) => {
props; props;
// store hooks // store hooks
const { sidebarCollapsed: isSidebarCollapsed } = useAppTheme(); const { sidebarCollapsed: isSidebarCollapsed } = useAppTheme();
const { t } = useTranslation();
const { setTrackElement } = useEventTracker(); const { setTrackElement } = useEventTracker();
const { addProjectToFavorites, removeProjectFromFavorites, getProjectById } = useProject(); const { addProjectToFavorites, removeProjectFromFavorites, getProjectById } = useProject();
const { isMobile } = usePlatformOS(); const { isMobile } = usePlatformOS();
@ -88,14 +90,14 @@ export const SidebarProjectsListItem: React.FC<Props> = observer((props) => {
const addToFavoritePromise = addProjectToFavorites(workspaceSlug.toString(), project.id); const addToFavoritePromise = addProjectToFavorites(workspaceSlug.toString(), project.id);
setPromiseToast(addToFavoritePromise, { setPromiseToast(addToFavoritePromise, {
loading: "Adding project to favorites...", loading: t("adding_project_to_favorites"),
success: { success: {
title: "Success!", title: t("success"),
message: () => "Project added to favorites.", message: () => t("project_added_to_favorites"),
}, },
error: { error: {
title: "Error!", title: t("error"),
message: () => "Couldn't add the project to favorites. Please try again.", message: () => t("couldnt_add_the_project_to_favorites"),
}, },
}); });
}; };
@ -105,14 +107,14 @@ export const SidebarProjectsListItem: React.FC<Props> = observer((props) => {
const removeFromFavoritePromise = removeProjectFromFavorites(workspaceSlug.toString(), project.id); const removeFromFavoritePromise = removeProjectFromFavorites(workspaceSlug.toString(), project.id);
setPromiseToast(removeFromFavoritePromise, { setPromiseToast(removeFromFavoritePromise, {
loading: "Removing project from favorites...", loading: t("removing_project_from_favorites"),
success: { success: {
title: "Success!", title: t("success"),
message: () => "Project removed from favorites.", message: () => t("project_removed_from_favorites"),
}, },
error: { error: {
title: "Error!", title: t("error"),
message: () => "Couldn't remove the project from favorites. Please try again.", message: () => t("couldnt_remove_the_project_from_favorites"),
}, },
}); });
}; };
@ -251,7 +253,9 @@ export const SidebarProjectsListItem: React.FC<Props> = observer((props) => {
{!disableDrag && ( {!disableDrag && (
<Tooltip <Tooltip
isMobile={isMobile} isMobile={isMobile}
tooltipContent={project.sort_order === null ? "Join the project to rearrange" : "Drag to rearrange"} tooltipContent={
project.sort_order === null ? t("join_the_project_to_rearrange") : t("drag_to_rearrange")
}
position="top-right" position="top-right"
disabled={isDragging} disabled={isDragging}
> >
@ -343,7 +347,7 @@ export const SidebarProjectsListItem: React.FC<Props> = observer((props) => {
"fill-yellow-500 stroke-yellow-500": project.is_favorite, "fill-yellow-500 stroke-yellow-500": project.is_favorite,
})} })}
/> />
<span>{project.is_favorite ? "Remove from favorites" : "Add to favorites"}</span> <span>{project.is_favorite ? t("remove_from_favorites") : t("add_to_favorites")}</span>
</span> </span>
</CustomMenu.MenuItem> </CustomMenu.MenuItem>
)} )}
@ -355,7 +359,7 @@ export const SidebarProjectsListItem: React.FC<Props> = observer((props) => {
<div className="flex h-4 w-4 cursor-pointer items-center justify-center rounded text-custom-sidebar-text-200 transition-all duration-300 hover:bg-custom-sidebar-background-80"> <div className="flex h-4 w-4 cursor-pointer items-center justify-center rounded text-custom-sidebar-text-200 transition-all duration-300 hover:bg-custom-sidebar-background-80">
<Share2 className="h-3.5 w-3.5 stroke-[1.5]" /> <Share2 className="h-3.5 w-3.5 stroke-[1.5]" />
</div> </div>
<div>{project.anchor ? "Publish settings" : "Publish"}</div> <div>{project.anchor ? t("publish_settings") : t("publish")}</div>
</div> </div>
</CustomMenu.MenuItem> </CustomMenu.MenuItem>
)} )}
@ -372,7 +376,7 @@ export const SidebarProjectsListItem: React.FC<Props> = observer((props) => {
<CustomMenu.MenuItem onClick={handleCopyText}> <CustomMenu.MenuItem onClick={handleCopyText}>
<span className="flex items-center justify-start gap-2"> <span className="flex items-center justify-start gap-2">
<LinkIcon className="h-3.5 w-3.5 stroke-[1.5]" /> <LinkIcon className="h-3.5 w-3.5 stroke-[1.5]" />
<span>Copy link</span> <span>{t("copy_link")}</span>
</span> </span>
</CustomMenu.MenuItem> </CustomMenu.MenuItem>
{isAuthorized && ( {isAuthorized && (
@ -380,7 +384,7 @@ export const SidebarProjectsListItem: React.FC<Props> = observer((props) => {
<Link href={`/${workspaceSlug}/projects/${project?.id}/archives/issues`}> <Link href={`/${workspaceSlug}/projects/${project?.id}/archives/issues`}>
<div className="flex items-center justify-start gap-2"> <div className="flex items-center justify-start gap-2">
<ArchiveIcon className="h-3.5 w-3.5 stroke-[1.5]" /> <ArchiveIcon className="h-3.5 w-3.5 stroke-[1.5]" />
<span>Archives</span> <span>{t("archives")}</span>
</div> </div>
</Link> </Link>
</CustomMenu.MenuItem> </CustomMenu.MenuItem>
@ -389,7 +393,7 @@ export const SidebarProjectsListItem: React.FC<Props> = observer((props) => {
<Link href={`/${workspaceSlug}/projects/${project?.id}/settings`}> <Link href={`/${workspaceSlug}/projects/${project?.id}/settings`}>
<div className="flex items-center justify-start gap-2"> <div className="flex items-center justify-start gap-2">
<Settings className="h-3.5 w-3.5 stroke-[1.5]" /> <Settings className="h-3.5 w-3.5 stroke-[1.5]" />
<span>Settings</span> <span>{t("settings")}</span>
</div> </div>
</Link> </Link>
</CustomMenu.MenuItem> </CustomMenu.MenuItem>
@ -398,7 +402,7 @@ export const SidebarProjectsListItem: React.FC<Props> = observer((props) => {
<CustomMenu.MenuItem onClick={handleLeaveProject}> <CustomMenu.MenuItem onClick={handleLeaveProject}>
<div className="flex items-center justify-start gap-2"> <div className="flex items-center justify-start gap-2">
<LogOut className="h-3.5 w-3.5 stroke-[1.5]" /> <LogOut className="h-3.5 w-3.5 stroke-[1.5]" />
<span>Leave project</span> <span>{t("leave_project")}</span>
</div> </div>
</CustomMenu.MenuItem> </CustomMenu.MenuItem>
)} )}

View File

@ -7,6 +7,7 @@ import { observer } from "mobx-react";
import { useParams, usePathname } from "next/navigation"; import { useParams, usePathname } from "next/navigation";
import { Briefcase, ChevronRight, Plus } from "lucide-react"; import { Briefcase, ChevronRight, Plus } from "lucide-react";
import { Disclosure, Transition } from "@headlessui/react"; import { Disclosure, Transition } from "@headlessui/react";
import { useTranslation } from "@plane/i18n";
// ui // ui
import { TOAST_TYPE, Tooltip, setToast } from "@plane/ui"; import { TOAST_TYPE, Tooltip, setToast } from "@plane/ui";
// components // components
@ -34,6 +35,7 @@ export const SidebarProjectsList: FC = observer(() => {
// refs // refs
const containerRef = useRef<HTMLDivElement | null>(null); const containerRef = useRef<HTMLDivElement | null>(null);
// store hooks // store hooks
const { t } = useTranslation();
const { toggleCreateProjectModal } = useCommandPalette(); const { toggleCreateProjectModal } = useCommandPalette();
const { sidebarCollapsed } = useAppTheme(); const { sidebarCollapsed } = useAppTheme();
const { setTrackElement } = useEventTracker(); const { setTrackElement } = useEventTracker();
@ -54,8 +56,8 @@ export const SidebarProjectsList: FC = observer(() => {
copyUrlToClipboard(`${workspaceSlug}/projects/${projectId}/issues`).then(() => { copyUrlToClipboard(`${workspaceSlug}/projects/${projectId}/issues`).then(() => {
setToast({ setToast({
type: TOAST_TYPE.SUCCESS, type: TOAST_TYPE.SUCCESS,
title: "Link Copied!", title: t("link_copied"),
message: "Project link copied to clipboard.", message: t("project_link_copied_to_clipboard"),
}); });
}); });
}; };
@ -84,8 +86,8 @@ export const SidebarProjectsList: FC = observer(() => {
updateProjectView(workspaceSlug.toString(), sourceId, { sort_order: updatedSortOrder }).catch(() => { updateProjectView(workspaceSlug.toString(), sourceId, { sort_order: updatedSortOrder }).catch(() => {
setToast({ setToast({
type: TOAST_TYPE.ERROR, type: TOAST_TYPE.ERROR,
title: "Error!", title: t("error"),
message: "Something went wrong. Please try again.", message: t("something_went_wrong"),
}); });
}); });
}; };
@ -175,12 +177,17 @@ export const SidebarProjectsList: FC = observer(() => {
)} )}
onClick={() => toggleListDisclosure(!isAllProjectsListOpen)} onClick={() => toggleListDisclosure(!isAllProjectsListOpen)}
> >
<Tooltip tooltipHeading="YOUR PROJECTS" tooltipContent="" position="right" disabled={!isCollapsed}> <Tooltip
tooltipHeading={t("your_projects").toUpperCase()}
tooltipContent=""
position="right"
disabled={!isCollapsed}
>
<> <>
{isCollapsed ? ( {isCollapsed ? (
<Briefcase className="flex-shrink-0 size-3" /> <Briefcase className="flex-shrink-0 size-3" />
) : ( ) : (
<span className="text-xs font-semibold">YOUR PROJECTS</span> <span className="text-xs font-semibold">{t("your_projects").toUpperCase()}</span>
)} )}
</> </>
</Tooltip> </Tooltip>
@ -188,7 +195,7 @@ export const SidebarProjectsList: FC = observer(() => {
{!isCollapsed && ( {!isCollapsed && (
<div className="flex items-center opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto"> <div className="flex items-center opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto">
{isAuthorizedUser && ( {isAuthorizedUser && (
<Tooltip tooltipHeading="Create project" tooltipContent=""> <Tooltip tooltipHeading={t("create_project")} tooltipContent="">
<button <button
type="button" type="button"
className="p-0.5 rounded hover:bg-custom-sidebar-background-80 flex-shrink-0" className="p-0.5 rounded hover:bg-custom-sidebar-background-80 flex-shrink-0"
@ -265,7 +272,7 @@ export const SidebarProjectsList: FC = observer(() => {
toggleCreateProjectModal(true); toggleCreateProjectModal(true);
}} }}
> >
{!isCollapsed && "Add project"} {!isCollapsed && t("add_project")}
</button> </button>
)} )}
</div> </div>

View File

@ -2,6 +2,7 @@ import { useRef, useState } from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { useParams } from "next/navigation"; import { useParams } from "next/navigation";
import { ChevronUp, PenSquare, Search } from "lucide-react"; import { ChevronUp, PenSquare, Search } from "lucide-react";
import { useTranslation } from "@plane/i18n";
// types // types
import { TIssue } from "@plane/types"; import { TIssue } from "@plane/types";
// components // components
@ -15,6 +16,7 @@ import useLocalStorage from "@/hooks/use-local-storage";
import { EUserPermissions, EUserPermissionsLevel } from "@/plane-web/constants/user-permissions"; import { EUserPermissions, EUserPermissionsLevel } from "@/plane-web/constants/user-permissions";
export const SidebarQuickActions = observer(() => { export const SidebarQuickActions = observer(() => {
const { t } = useTranslation();
// states // states
const [isDraftIssueModalOpen, setIsDraftIssueModalOpen] = useState(false); const [isDraftIssueModalOpen, setIsDraftIssueModalOpen] = useState(false);
const [isDraftButtonOpen, setIsDraftButtonOpen] = useState(false); const [isDraftButtonOpen, setIsDraftButtonOpen] = useState(false);
@ -92,7 +94,7 @@ export const SidebarQuickActions = observer(() => {
disabled={disabled} disabled={disabled}
> >
<PenSquare className="size-4" /> <PenSquare className="size-4" />
{!isSidebarCollapsed && <span className="text-sm font-medium">New issue</span>} {!isSidebarCollapsed && <span className="text-sm font-medium">{t("new_issue")}</span>}
</button> </button>
<button <button
className={cn( className={cn(

View File

@ -4,6 +4,7 @@ import React from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import Link from "next/link"; import Link from "next/link";
import { useParams, usePathname } from "next/navigation"; import { useParams, usePathname } from "next/navigation";
import { useTranslation } from "@plane/i18n";
// components // components
import { Tooltip } from "@plane/ui"; import { Tooltip } from "@plane/ui";
import { SidebarNavItem } from "@/components/sidebar"; import { SidebarNavItem } from "@/components/sidebar";
@ -22,6 +23,7 @@ import { EUserPermissionsLevel } from "@/plane-web/constants/user-permissions";
import { isUserFeatureEnabled } from "@/plane-web/helpers/dashboard.helper"; import { isUserFeatureEnabled } from "@/plane-web/helpers/dashboard.helper";
export const SidebarUserMenu = observer(() => { export const SidebarUserMenu = observer(() => {
const { t } = useTranslation();
// store hooks // store hooks
const { toggleSidebar, sidebarCollapsed } = useAppTheme(); const { toggleSidebar, sidebarCollapsed } = useAppTheme();
const { captureEvent } = useEventTracker(); const { captureEvent } = useEventTracker();
@ -62,28 +64,28 @@ export const SidebarUserMenu = observer(() => {
})} })}
> >
{SIDEBAR_USER_MENU_ITEMS.map((link) => { {SIDEBAR_USER_MENU_ITEMS.map((link) => {
if (link.key === "drafts" && draftIssueCount === 0) return null; if (link.value === "drafts" && draftIssueCount === 0) return null;
if (!isUserFeatureEnabled(link.key)) return null; if (!isUserFeatureEnabled(link.value)) return null;
return ( return (
allowPermissions(link.access, EUserPermissionsLevel.WORKSPACE, workspaceSlug.toString()) && ( allowPermissions(link.access, EUserPermissionsLevel.WORKSPACE, workspaceSlug.toString()) && (
<Tooltip <Tooltip
key={link.key} key={link.value}
tooltipContent={link.label} tooltipContent={t(link.key)}
position="right" position="right"
className="ml-2" className="ml-2"
disabled={!sidebarCollapsed} disabled={!sidebarCollapsed}
isMobile={isMobile} isMobile={isMobile}
> >
<Link key={link.key} href={getHref(link)} onClick={() => handleLinkClick(link.key)}> <Link key={link.value} href={getHref(link)} onClick={() => handleLinkClick(link.value)}>
<SidebarNavItem <SidebarNavItem
className={`${sidebarCollapsed ? "p-0 size-8 aspect-square justify-center mx-auto" : ""}`} className={`${sidebarCollapsed ? "p-0 size-8 aspect-square justify-center mx-auto" : ""}`}
isActive={link.highlight(pathname, `/${workspaceSlug}`, { userId: currentUser?.id })} isActive={link.highlight(pathname, `/${workspaceSlug}`, { userId: currentUser?.id })}
> >
<div className="flex items-center gap-1.5 py-[1px]"> <div className="flex items-center gap-1.5 py-[1px]">
<link.Icon className="size-4 flex-shrink-0" /> <link.Icon className="size-4 flex-shrink-0" />
{!sidebarCollapsed && <p className="text-sm leading-5 font-medium">{link.label}</p>} {!sidebarCollapsed && <p className="text-sm leading-5 font-medium">{t(link.key)}</p>}
</div> </div>
{link.key === "notifications" && notificationIndicatorElement} {link.value === "notifications" && notificationIndicatorElement}
</SidebarNavItem> </SidebarNavItem>
</Link> </Link>
</Tooltip> </Tooltip>

View File

@ -6,8 +6,8 @@ import Link from "next/link";
import { useParams, usePathname } from "next/navigation"; import { useParams, usePathname } from "next/navigation";
import { ArchiveIcon, ChevronRight, MoreHorizontal, Settings } from "lucide-react"; import { ArchiveIcon, ChevronRight, MoreHorizontal, Settings } from "lucide-react";
import { Disclosure, Transition } from "@headlessui/react"; import { Disclosure, Transition } from "@headlessui/react";
// plane helpers
import { useOutsideClickDetector } from "@plane/hooks"; import { useOutsideClickDetector } from "@plane/hooks";
import { useTranslation } from "@plane/i18n";
// ui // ui
import { CustomMenu, Tooltip } from "@plane/ui"; import { CustomMenu, Tooltip } from "@plane/ui";
// components // components
@ -38,6 +38,7 @@ export const SidebarWorkspaceMenu = observer(() => {
// pathname // pathname
const pathname = usePathname(); const pathname = usePathname();
// store hooks // store hooks
const { t } = useTranslation();
const { toggleSidebar, sidebarCollapsed } = useAppTheme(); const { toggleSidebar, sidebarCollapsed } = useAppTheme();
const { captureEvent } = useEventTracker(); const { captureEvent } = useEventTracker();
const { isMobile } = usePlatformOS(); const { isMobile } = usePlatformOS();
@ -85,7 +86,7 @@ export const SidebarWorkspaceMenu = observer(() => {
className="flex-1 sticky top-0 z-10 w-full py-1.5 flex items-center justify-between gap-1 text-custom-sidebar-text-400 text-xs font-semibold" className="flex-1 sticky top-0 z-10 w-full py-1.5 flex items-center justify-between gap-1 text-custom-sidebar-text-400 text-xs font-semibold"
onClick={() => toggleWorkspaceMenu(!isWorkspaceMenuOpen)} onClick={() => toggleWorkspaceMenu(!isWorkspaceMenuOpen)}
> >
<span>WORKSPACE</span> <span>{t("workspace").toUpperCase()}</span>
</Disclosure.Button> </Disclosure.Button>
<CustomMenu <CustomMenu
customButton={ customButton={
@ -112,7 +113,7 @@ export const SidebarWorkspaceMenu = observer(() => {
<Link href={`/${workspaceSlug}/projects/archives`}> <Link href={`/${workspaceSlug}/projects/archives`}>
<div className="flex items-center justify-start gap-2"> <div className="flex items-center justify-start gap-2">
<ArchiveIcon className="h-3.5 w-3.5 stroke-[1.5]" /> <ArchiveIcon className="h-3.5 w-3.5 stroke-[1.5]" />
<span>Archives</span> <span>{t("archives")}</span>
</div> </div>
</Link> </Link>
</CustomMenu.MenuItem> </CustomMenu.MenuItem>
@ -122,7 +123,7 @@ export const SidebarWorkspaceMenu = observer(() => {
<Link href={`/${workspaceSlug}/settings`}> <Link href={`/${workspaceSlug}/settings`}>
<div className="flex items-center justify-start gap-2"> <div className="flex items-center justify-start gap-2">
<Settings className="h-3.5 w-3.5 stroke-[1.5]" /> <Settings className="h-3.5 w-3.5 stroke-[1.5]" />
<span>Settings</span> <span>{t("settings")}</span>
</div> </div>
</Link> </Link>
</CustomMenu.MenuItem> </CustomMenu.MenuItem>
@ -162,32 +163,32 @@ export const SidebarWorkspaceMenu = observer(() => {
static static
> >
{SIDEBAR_WORKSPACE_MENU_ITEMS.map((link) => { {SIDEBAR_WORKSPACE_MENU_ITEMS.map((link) => {
if (!isWorkspaceFeatureEnabled(link.key, workspaceSlug.toString())) return null; if (!isWorkspaceFeatureEnabled(link.value, workspaceSlug.toString())) return null;
return ( return (
allowPermissions(link.access, EUserPermissionsLevel.WORKSPACE, workspaceSlug.toString()) && ( allowPermissions(link.access, EUserPermissionsLevel.WORKSPACE, workspaceSlug.toString()) && (
<Tooltip <Tooltip
key={link.key} key={link.value}
tooltipContent={link.label} tooltipContent={t(link.key)}
position="right" position="right"
className="ml-2" className="ml-2"
disabled={!sidebarCollapsed} disabled={!sidebarCollapsed}
isMobile={isMobile} isMobile={isMobile}
> >
<Link href={`/${workspaceSlug}${link.href}`} onClick={() => handleLinkClick(link.key)}> <Link href={`/${workspaceSlug}${link.href}`} onClick={() => handleLinkClick(link.value)}>
<SidebarNavItem <SidebarNavItem
key={link.key} key={link.value}
className={`${sidebarCollapsed ? "p-0 size-8 aspect-square justify-center mx-auto" : ""}`} className={`${sidebarCollapsed ? "p-0 size-8 aspect-square justify-center mx-auto" : ""}`}
isActive={link.highlight(pathname, `/${workspaceSlug}`)} isActive={link.highlight(pathname, `/${workspaceSlug}`)}
> >
<div className="flex items-center gap-1.5 py-[1px]"> <div className="flex items-center gap-1.5 py-[1px]">
<link.Icon <link.Icon
className={cn("size-4", { className={cn("size-4", {
"rotate-180": link.key === "active-cycles", "rotate-180": link.value === "active-cycles",
})} })}
/> />
{!sidebarCollapsed && <p className="text-sm leading-5 font-medium">{link.label}</p>} {!sidebarCollapsed && <p className="text-sm leading-5 font-medium">{t(link.key)}</p>}
</div> </div>
{!sidebarCollapsed && link.key === "active-cycles" && indicatorElement} {!sidebarCollapsed && link.value === "active-cycles" && indicatorElement}
</SidebarNavItem> </SidebarNavItem>
</Link> </Link>
</Tooltip> </Tooltip>

View File

@ -93,34 +93,40 @@ export const CYCLE_STATUS: {
export const WORKSPACE_ACTIVE_CYCLES_DETAILS = [ export const WORKSPACE_ACTIVE_CYCLES_DETAILS = [
{ {
key: "10000_feet_view",
title: "10,000-feet view of all active cycles.", title: "10,000-feet view of all active cycles.",
description: description:
"Zoom out to see running cycles across all your projects at once instead of going from Cycle to Cycle in each project.", "Zoom out to see running cycles across all your projects at once instead of going from Cycle to Cycle in each project.",
icon: Folder, icon: Folder,
}, },
{ {
key: "get_snapshot_of_each_active_cycle",
title: "Get a snapshot of each active cycle.", title: "Get a snapshot of each active cycle.",
description: description:
"Track high-level metrics for all active cycles, see their state of progress, and get a sense of scope against deadlines.", "Track high-level metrics for all active cycles, see their state of progress, and get a sense of scope against deadlines.",
icon: CircleDashed, icon: CircleDashed,
}, },
{ {
key: "compare_burndowns",
title: "Compare burndowns.", title: "Compare burndowns.",
description: "Monitor how each of your teams are performing with a peek into each cycles burndown report.", description: "Monitor how each of your teams are performing with a peek into each cycles burndown report.",
icon: BarChart4, icon: BarChart4,
}, },
{ {
key: "quickly_see_make_or_break_issues",
title: "Quickly see make-or-break issues. ", title: "Quickly see make-or-break issues. ",
description: description:
"Preview high-priority issues for each cycle against due dates. See all of them per cycle in one click.", "Preview high-priority issues for each cycle against due dates. See all of them per cycle in one click.",
icon: AlertOctagon, icon: AlertOctagon,
}, },
{ {
key: "zoom_into_cycles_that_need_attention",
title: "Zoom into cycles that need attention. ", title: "Zoom into cycles that need attention. ",
description: "Investigate the state of any cycle that doesnt conform to expectations in one click.", description: "Investigate the state of any cycle that doesnt conform to expectations in one click.",
icon: Search, icon: Search,
}, },
{ {
key: "stay_ahead_of_blockers",
title: "Stay ahead of blockers.", title: "Stay ahead of blockers.",
description: description:
"Spot challenges from one project to another and see inter-cycle dependencies that arent obvious from any other view.", "Spot challenges from one project to another and see inter-cycle dependencies that arent obvious from any other view.",

View File

@ -48,6 +48,7 @@ export const PROFILE_ACTION_LINKS: {
export const PROFILE_VIEWER_TAB = [ export const PROFILE_VIEWER_TAB = [
{ {
key: "summary",
route: "", route: "",
label: "Summary", label: "Summary",
selected: "/", selected: "/",
@ -56,6 +57,7 @@ export const PROFILE_VIEWER_TAB = [
export const PROFILE_ADMINS_TAB = [ export const PROFILE_ADMINS_TAB = [
{ {
key: "assigned",
route: "assigned", route: "assigned",
label: "Assigned", label: "Assigned",
selected: "/assigned/", selected: "/assigned/",
@ -66,11 +68,13 @@ export const PROFILE_ADMINS_TAB = [
selected: "/created/", selected: "/created/",
}, },
{ {
key: "subscribed",
route: "subscribed", route: "subscribed",
label: "Subscribed", label: "Subscribed",
selected: "/subscribed/", selected: "/subscribed/",
}, },
{ {
key: "activity",
route: "activity", route: "activity",
label: "Activity", label: "Activity",
selected: "/activity/", selected: "/activity/",

View File

@ -1,6 +1,7 @@
export const THEMES = ["light", "dark", "light-contrast", "dark-contrast", "custom"]; export const THEMES = ["light", "dark", "light-contrast", "dark-contrast", "custom"];
export interface I_THEME_OPTION { export interface I_THEME_OPTION {
key: string;
value: string; value: string;
label: string; label: string;
type: string; type: string;
@ -13,6 +14,7 @@ export interface I_THEME_OPTION {
export const THEME_OPTIONS: I_THEME_OPTION[] = [ export const THEME_OPTIONS: I_THEME_OPTION[] = [
{ {
key: "system_preference",
value: "system", value: "system",
label: "System preference", label: "System preference",
type: "light", type: "light",
@ -23,6 +25,7 @@ export const THEME_OPTIONS: I_THEME_OPTION[] = [
}, },
}, },
{ {
key: "light",
value: "light", value: "light",
label: "Light", label: "Light",
type: "light", type: "light",
@ -33,6 +36,7 @@ export const THEME_OPTIONS: I_THEME_OPTION[] = [
}, },
}, },
{ {
key: "dark",
value: "dark", value: "dark",
label: "Dark", label: "Dark",
type: "dark", type: "dark",
@ -43,6 +47,7 @@ export const THEME_OPTIONS: I_THEME_OPTION[] = [
}, },
}, },
{ {
key: "light_contrast",
value: "light-contrast", value: "light-contrast",
label: "Light high contrast", label: "Light high contrast",
type: "light", type: "light",
@ -53,6 +58,7 @@ export const THEME_OPTIONS: I_THEME_OPTION[] = [
}, },
}, },
{ {
key: "dark_contrast",
value: "dark-contrast", value: "dark-contrast",
label: "Dark high contrast", label: "Dark high contrast",
type: "dark", type: "dark",
@ -63,6 +69,7 @@ export const THEME_OPTIONS: I_THEME_OPTION[] = [
}, },
}, },
{ {
key: "custom",
value: "custom", value: "custom",
label: "Custom theme", label: "Custom theme",
type: "light", type: "light",

View File

@ -1,7 +1,8 @@
import { ReactNode, useEffect, FC, useState } from "react"; import { ReactNode, useEffect, FC } from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { useParams } from "next/navigation"; import { useParams } from "next/navigation";
import { useTheme } from "next-themes"; import { useTheme } from "next-themes";
import { useTranslation, Language } from "@plane/i18n";
// helpers // helpers
import { applyTheme, unsetCustomCssVariables } from "@/helpers/theme.helper"; import { applyTheme, unsetCustomCssVariables } from "@/helpers/theme.helper";
// hooks // hooks
@ -21,6 +22,7 @@ const StoreWrapper: FC<TStoreWrapper> = observer((props) => {
const { setQuery } = useRouterParams(); const { setQuery } = useRouterParams();
const { sidebarCollapsed, toggleSidebar } = useAppTheme(); const { sidebarCollapsed, toggleSidebar } = useAppTheme();
const { data: userProfile } = useUserProfile(); const { data: userProfile } = useUserProfile();
const { changeLanguage } = useTranslation();
/** /**
* Sidebar collapsed fetching from local storage * Sidebar collapsed fetching from local storage
@ -28,7 +30,6 @@ const StoreWrapper: FC<TStoreWrapper> = observer((props) => {
useEffect(() => { useEffect(() => {
const localValue = localStorage && localStorage.getItem("app_sidebar_collapsed"); const localValue = localStorage && localStorage.getItem("app_sidebar_collapsed");
const localBoolValue = localValue ? (localValue === "true" ? true : false) : false; const localBoolValue = localValue ? (localValue === "true" ? true : false) : false;
if (localValue && sidebarCollapsed === undefined) toggleSidebar(localBoolValue); if (localValue && sidebarCollapsed === undefined) toggleSidebar(localBoolValue);
}, [sidebarCollapsed, setTheme, toggleSidebar]); }, [sidebarCollapsed, setTheme, toggleSidebar]);
@ -37,7 +38,6 @@ const StoreWrapper: FC<TStoreWrapper> = observer((props) => {
*/ */
useEffect(() => { useEffect(() => {
if (!userProfile?.theme?.theme) return; if (!userProfile?.theme?.theme) return;
const currentTheme = userProfile?.theme?.theme || "system"; const currentTheme = userProfile?.theme?.theme || "system";
const currentThemePalette = userProfile?.theme?.palette; const currentThemePalette = userProfile?.theme?.palette;
if (currentTheme) { if (currentTheme) {
@ -51,6 +51,11 @@ const StoreWrapper: FC<TStoreWrapper> = observer((props) => {
} }
}, [userProfile?.theme?.theme, userProfile?.theme?.palette, setTheme]); }, [userProfile?.theme?.theme, userProfile?.theme?.palette, setTheme]);
useEffect(() => {
if (!userProfile?.language) return;
changeLanguage(userProfile?.language as Language);
}, [userProfile?.language, changeLanguage]);
useEffect(() => { useEffect(() => {
if (!params) return; if (!params) return;
setQuery(params); setQuery(params);

View File

@ -58,6 +58,7 @@ export class ProfileStore implements IUserProfileStore {
has_billing_address: false, has_billing_address: false,
created_at: "", created_at: "",
updated_at: "", updated_at: "",
language: ""
}; };
// services // services

View File

@ -29,6 +29,7 @@ const nextConfig = {
images: { images: {
unoptimized: true, unoptimized: true,
}, },
transpilePackages: ["@plane/i18n"],
// webpack: (config, { isServer }) => { // webpack: (config, { isServer }) => {
// if (!isServer) { // if (!isServer) {
// // Ensure that all imports of 'yjs' resolve to the same instance // // Ensure that all imports of 'yjs' resolve to the same instance

View File

@ -31,6 +31,7 @@
"@plane/constants": "*", "@plane/constants": "*",
"@plane/editor": "*", "@plane/editor": "*",
"@plane/hooks": "*", "@plane/hooks": "*",
"@plane/i18n": "*",
"@plane/types": "*", "@plane/types": "*",
"@plane/ui": "*", "@plane/ui": "*",
"@plane/utils": "*", "@plane/utils": "*",