Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 101 additions & 2 deletions src/components/pages/LanguagesPage.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { useEffect, useState } from "react"
import { Languages, Plus } from "lucide-react"
import { Languages, Plus, Trash2 } from "lucide-react"
import { toast } from "sonner"
import { languagesAPI } from "@/services/api"
import type { LanguageResponse, LanguageStatsResponse } from "@/types"
import { useLanguagesStore } from "@/stores/languagesStore"
import { useAuth } from "@/contexts/AuthContext"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
Expand All @@ -21,18 +23,26 @@ import { InfoTooltip } from "@/components/common/InfoTooltip"
import { formatDate } from "@/utils/format"

export default function LanguagesPage() {
const { user, isPlatformAdmin, isManager } = useAuth()
const { languages, loading: storeLoading, lastFetched, fetch: fetchLanguages } = useLanguagesStore()
const loading = storeLoading || (!lastFetched && languages.length === 0)
const [dialogOpen, setDialogOpen] = useState(false)
const [creating, setCreating] = useState(false)
const [name, setName] = useState("")
const [code, setCode] = useState("")
const [deleteTarget, setDeleteTarget] = useState<LanguageResponse | null>(null)
const [deleteStats, setDeleteStats] = useState<LanguageStatsResponse | null>(null)
const [deleting, setDeleting] = useState(false)

useEffect(() => {
fetchLanguages()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])

function canDeactivate(lang: LanguageResponse) {
return isPlatformAdmin || (isManager && lang.created_by === user?.id)
}

function openDialog() {
setName("")
setCode("")
Expand Down Expand Up @@ -60,6 +70,42 @@ export default function LanguagesPage() {
}
}

async function openDeleteDialog(lang: LanguageResponse) {
setDeleteTarget(lang)
setDeleteStats(null)
try {
const { data } = await languagesAPI.stats(lang.id)
setDeleteStats(data)
} catch {
setDeleteStats(null)
}
}

const blockedByProjects = (deleteStats?.project_count ?? 0) > 0

async function handleDeactivate() {
if (!deleteTarget || deleteStats === null || blockedByProjects) return
setDeleting(true)
try {
await languagesAPI.delete(deleteTarget.id)
toast.success("Language deactivated")
setDeleteTarget(null)
useLanguagesStore.getState().invalidate()
await fetchLanguages()
} catch (err: unknown) {
const status = (err as { response?: { status?: number } })?.response?.status
if (status === 409) {
toast.error("This language is used by projects and cannot be deactivated")
} else if (status === 403) {
toast.error("You can only deactivate languages you created")
} else {
toast.error("Failed to deactivate language")
}
} finally {
setDeleting(false)
}
}

if (loading) {
return <LoadingSpinner />
}
Expand Down Expand Up @@ -95,8 +141,19 @@ export default function LanguagesPage() {
{languages.map((lang) => (
<div
key={lang.id}
className="group rounded-2xl border border-areia/20 bg-surface p-5 shadow-sm hover:shadow-md hover:border-telha/30 transition-all duration-200 cursor-default"
className="group relative rounded-2xl border border-areia/20 bg-surface p-5 shadow-sm hover:shadow-md hover:border-telha/30 transition-all duration-200 cursor-default"
>
{canDeactivate(lang) && (
<Button
variant="ghost"
size="sm"
className="absolute top-2 right-2 h-8 w-8 p-0 text-red-600 hover:text-red-700 hover:bg-red-50 dark:hover:bg-red-950/30 opacity-0 group-hover:opacity-100 transition-opacity"
onClick={() => openDeleteDialog(lang)}
aria-label={`Deactivate ${lang.name}`}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
)}
<div className="w-14 h-14 rounded-2xl bg-gradient-to-br from-azul/15 to-azul/5 flex items-center justify-center mx-auto">
<span className="text-2xl font-mono font-bold text-azul">
{lang.code}
Expand Down Expand Up @@ -167,6 +224,48 @@ export default function LanguagesPage() {
</DialogFooter>
</DialogContent>
</Dialog>

<Dialog open={deleteTarget !== null} onOpenChange={(open) => { if (!open) setDeleteTarget(null) }}>
<DialogContent>
<DialogHeader>
<DialogTitle>Deactivate Language</DialogTitle>
<DialogDescription>
{deleteTarget
? deleteStats === null
? `Checking which projects use "${deleteTarget.name}"...`
: blockedByProjects
? `"${deleteTarget.name}" is used by ${deleteStats.project_count} project${deleteStats.project_count !== 1 ? "s" : ""} and cannot be deactivated. Reassign or remove it from these projects first.`
: `Deactivating "${deleteTarget.name}" hides it from new project creation. Existing projects are unaffected and it can be restored later.`
: ""}
</DialogDescription>
</DialogHeader>
{blockedByProjects && deleteStats && (
<ul className="mt-1 max-h-40 overflow-y-auto rounded-lg border border-areia/30 bg-surface-alt/50 divide-y divide-areia/20">
{deleteStats.projects.map((project) => (
<li key={project.id} className="px-3 py-2 text-sm text-preto">
{project.name}
</li>
))}
</ul>
)}
<DialogFooter className="border-t border-areia/10 pt-4 mt-2">
<Button
variant="outline"
onClick={() => setDeleteTarget(null)}
disabled={deleting}
>
{blockedByProjects ? "Close" : "Cancel"}
</Button>
<Button
variant="destructive"
onClick={handleDeactivate}
disabled={deleteStats === null || blockedByProjects || deleting}
>
{deleting ? "Deactivating..." : "Deactivate"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}
5 changes: 5 additions & 0 deletions src/services/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import type {
AppRoleResponse,
LanguageResponse,
LanguageCreate,
LanguageStatsResponse,
OrganizationResponse,
OrganizationCreate,
OrganizationUpdate,
Expand Down Expand Up @@ -174,6 +175,10 @@ export const languagesAPI = {
api.get<LanguageResponse>(`/languages/${languageId}`),
getByCode: (code: string) =>
api.get<LanguageResponse>(`/languages/code/${code}`),
stats: (languageId: string) =>
api.get<LanguageStatsResponse>(`/languages/${languageId}/stats`),
delete: (languageId: string) =>
api.delete<void>(`/languages/${languageId}`),
}

export const orgsAPI = {
Expand Down
2 changes: 2 additions & 0 deletions src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ export type {
export type {
LanguageResponse,
LanguageCreate,
LanguageProjectRef,
LanguageStatsResponse,
} from "./language"

export type {
Expand Down
12 changes: 12 additions & 0 deletions src/types/language.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,22 @@ export interface LanguageResponse {
id: string
name: string
code: string
created_by: string | null
created_at: string
}

export interface LanguageCreate {
name: string
code: string
}

export interface LanguageProjectRef {
id: string
name: string
}

export interface LanguageStatsResponse {
language_id: string
project_count: number
projects: LanguageProjectRef[]
}