Compare commits
5 Commits
1d883c3c69
...
e497f2d218
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e497f2d218 | ||
| 23373ab91a | |||
| 9104f6123d | |||
| e7b5ea9a61 | |||
| 8805044bb6 |
8
.gitignore
vendored
8
.gitignore
vendored
@@ -74,3 +74,11 @@ uv.lock
|
|||||||
# Screenshots and debug files at root
|
# Screenshots and debug files at root
|
||||||
*.png
|
*.png
|
||||||
*.jpg
|
*.jpg
|
||||||
|
|
||||||
|
# SSL Keys & Certificates
|
||||||
|
*.key
|
||||||
|
*.cer
|
||||||
|
*.crt
|
||||||
|
*.pem
|
||||||
|
*_ssl_certificate*.zip
|
||||||
|
|
||||||
|
|||||||
@@ -1,28 +0,0 @@
|
|||||||
-----BEGIN PRIVATE KEY-----
|
|
||||||
MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQDapg1/jcFA2gLR
|
|
||||||
4/q99np9r3hFJbduPpd9QKOQ1D8mIipHy5MHTgpBY7QkUjyBLuK0sZoq9CwWXxQe
|
|
||||||
uFp56hHZW5aM1JDZwLGlCqY7j+AZzNB2b51hJyvzxoNTTN98txuRyiiVi9CWycXZ
|
|
||||||
SSGXeWzfPAuyIm6KpM2QA58IHaLqawIAdlOuPqKC9Mc3/M42xoCz2cvFgsq2FLXG
|
|
||||||
U2wIpCC2Kw5hfT+wvYWJXYwsi1G8DW97VQd1eGXzSn0gOlyHOFmUiQJWqee98LTW
|
|
||||||
ZbjUkZ4wTQkeo36ghbqdirDq15rZfbGDFfgRxTcDY7crOET4yZ3qiMWkQZMGYgsK
|
|
||||||
5zAuPMtrAgMBAAECggEAa9P4VC9UXuck/N2xin1Dazhd17wB+hxkGSEHiSUi/2tZ
|
|
||||||
7o42tCOIazcAX+kubYbxxqrPnN9N3rqE8aF4/SayJr9EP6+Uv86HzRyzXixWGGMp
|
|
||||||
az8gHglzY4VCvpHqpVaoAf3whc+tzq8zIzUtm8YGKxExs2fHnBgqSBuBEj+LWI8D
|
|
||||||
se3uQZk1sBj6/T+fXIDHYhg05Whsxm48NZaNk/Q2JCdnLdUeH1vz3/WG0pu5HHji
|
|
||||||
TwUW5/4e90O14Iw/ZRCUzmWdSXPu9BqwqiU620tm+2FT3aNYNSUpp5NO+eH4OGj/
|
|
||||||
pY+n8FscxTuRl4g7KWG8wRiyZM7+wrdaf3V76lisAQKBgQD0nId6CQUHfAYUDW1g
|
|
||||||
H1BGtnLrSmRvY+z0VDFK983RcGTvW+aMIbxFYwZpdvc4tNnMCFk20mwqwwsbnhmY
|
|
||||||
Imgz0RSSFRt48Fl0sB0vSg0k0s7BJVUvZuWZpNMyAfaQxd8p4dzkXm2B0wqcxdZB
|
|
||||||
P7YrbShwpLDGgw3dwyThAqdWowKBgQDk1BQbrUba053BsPCIvHd2i5ljSbbMgJQ3
|
|
||||||
yKt7eDyhM8wdVTTE4rIEXUSUMoRKQyFmDEWGxl3Ku+LDDwmzyAuBx43yKhnmHyFZ
|
|
||||||
9vcioUef1pV6rNQ4OCAjZ8I3U/XvABe/yKRweAHuuQbMrzCxttHAR/qJvSWOBS5u
|
|
||||||
v0SgiLIsmQKBgCWT9mwU/qTLtNNXzLm7lcWHOdhTsWI7Y6eVV41AzGmkbp0m7SAR
|
|
||||||
/q5aLB3/nLMldWAW2D3Hzb40AHzZqOzqPD/ZmuNmQrYtllCKzbD6G1LQ39pkNdX2
|
|
||||||
ZMdZvugrzQkonMP+YsTijMgo97Rl4Chd9UpulovXgSC9+V9cH0pB8hspAoGAH87z
|
|
||||||
NJ2zmbuM1F3aMcew+4OQQOMATBEbiV2VqMYf93CR0QoFGdFBWaX1YHxdM019mQff
|
|
||||||
qkpswz+219KBUMJgBxxMOPttOjXdlvnjhKbSo/9aLAFWRrJWlVVZBAPhzeU/Z+Nm
|
|
||||||
KFQmQJ7ruQwpQqrdIVNgGkF2tPU3bSGdUott6RECgYBm/JO1oO80ERFqrAyiJziw
|
|
||||||
IOjdu0FzhPQMwrkl4xkCqHbI8Pa2PWsNYRL88QuMr9EACHsVFU6x62Y68WCzz9TZ
|
|
||||||
4UNKR51kGzEVzEpl4ayB52tCkB0fdZZDA4FZtnUAqX5uBVabO04Qhq4EUkyyhlPW
|
|
||||||
1ms5bI9LgyXstOOQ4jJl4w==
|
|
||||||
-----END PRIVATE KEY-----
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
-----BEGIN RSA PRIVATE KEY-----
|
|
||||||
MIIEogIBAAKCAQEA2qYNf43BQNoC0eP6vfZ6fa94RSW3bj6XfUCjkNQ/JiIqR8uT
|
|
||||||
B04KQWO0JFI8gS7itLGaKvQsFl8UHrhaeeoR2VuWjNSQ2cCxpQqmO4/gGczQdm+d
|
|
||||||
YScr88aDU0zffLcbkcoolYvQlsnF2Ukhl3ls3zwLsiJuiqTNkAOfCB2i6msCAHZT
|
|
||||||
rj6igvTHN/zONsaAs9nLxYLKthS1xlNsCKQgtisOYX0/sL2FiV2MLItRvA1ve1UH
|
|
||||||
dXhl80p9IDpchzhZlIkCVqnnvfC01mW41JGeME0JHqN+oIW6nYqw6tea2X2xgxX4
|
|
||||||
EcU3A2O3KzhE+Mmd6ojFpEGTBmILCucwLjzLawIDAQABAoIBAGvT+FQvVF7nJPzd
|
|
||||||
sYp9Q2s4Xde8AfocZBkhB4klIv9rWe6ONrQjiGs3AF/pLm2G8caqz5zfTd66hPGh
|
|
||||||
eP0msia/RD+vlL/Oh80cs14sVhhjKWs/IB4Jc2OFQr6R6qVWqAH98IXPrc6vMyM1
|
|
||||||
LZvGBisRMbNnx5wYKkgbgRI/i1iPA7Ht7kGZNbAY+v0/n1yAx2IYNOVobMZuPDWW
|
|
||||||
jZP0NiQnZy3VHh9b89/1htKbuRx44k8FFuf+HvdDteCMP2UQlM5lnUlz7vQasKol
|
|
||||||
OttLZvthU92jWDUlKaeTTvnh+Dho/6WPp/BbHMU7kZeIOylhvMEYsmTO/sK3Wn91
|
|
||||||
e+pYrAECgYEA9JyHegkFB3wGFA1tYB9QRrZy60pkb2Ps9FQxSvfN0XBk71vmjCG8
|
|
||||||
RWMGaXb3OLTZzAhZNtJsKsMLG54ZmCJoM9EUkhUbePBZdLAdL0oNJNLOwSVVL2bl
|
|
||||||
maTTMgH2kMXfKeHc5F5tgdMKnMXWQT+2K20ocKSwxoMN3cMk4QKnVqMCgYEA5NQU
|
|
||||||
G61G2tOdwbDwiLx3douZY0m2zICUN8ire3g8oTPMHVU0xOKyBF1ElDKESkMhZgxF
|
|
||||||
hsZdyrviww8Js8gLgceN8ioZ5h8hWfb3IqFHn9aVeqzUODggI2fCN1P17wAXv8ik
|
|
||||||
cHgB7rkGzK8wsbbRwEf6ib0ljgUubr9EoIiyLJkCgYAlk/ZsFP6ky7TTV8y5u5XF
|
|
||||||
hznYU7FiO2OnlVeNQMxppG6dJu0gEf6uWiwd/5yzJXVgFtg9x82+NAB82ajs6jw/
|
|
||||||
2ZrjZkK2LZZQis2w+htS0N/aZDXV9mTHWb7oK80JKJzD/mLE4ozIKPe0ZeAoXfVK
|
|
||||||
bpaL14EgvflfXB9KQfIbKQKBgB/O8zSds5m7jNRd2jHHsPuDkEDjAEwRG4ldlajG
|
|
||||||
H/dwkdEKBRnRQVml9WB8XTNNfZkH36pKbMM/ttfSgVDCYAccTDj7bTo13Zb544Sm
|
|
||||||
0qP/WiwBVkayVpVVWQQD4c3lP2fjZihUJkCe67kMKUKq3SFTYBpBdrT1N20hnVKL
|
|
||||||
bekRAoGAZvyTtaDvNBERaqwMoic4sCDo3btBc4T0DMK5JeMZAqh2yPD2tj1rDWES
|
|
||||||
/PELjK/RAAh7FRVOsetmOvFgs8/U2eFDSkedZBsxFcxKZeGsgedrQpAdH3WWQwOB
|
|
||||||
WbZ1AKl+bgVWmztOEIauBFJMsoZT1tZrOWyPS4Ml7LTjkOIyZeM=
|
|
||||||
-----END RSA PRIVATE KEY-----
|
|
||||||
Binary file not shown.
@@ -1,36 +0,0 @@
|
|||||||
-----BEGIN CERTIFICATE-----
|
|
||||||
MIIGTDCCBDSgAwIBAgIQOXpmzCdWNi4NqofKbqvjsTANBgkqhkiG9w0BAQwFADBf
|
|
||||||
MQswCQYDVQQGEwJHQjEYMBYGA1UEChMPU2VjdGlnbyBMaW1pdGVkMTYwNAYDVQQD
|
|
||||||
Ey1TZWN0aWdvIFB1YmxpYyBTZXJ2ZXIgQXV0aGVudGljYXRpb24gUm9vdCBSNDYw
|
|
||||||
HhcNMjEwMzIyMDAwMDAwWhcNMzYwMzIxMjM1OTU5WjBgMQswCQYDVQQGEwJHQjEY
|
|
||||||
MBYGA1UEChMPU2VjdGlnbyBMaW1pdGVkMTcwNQYDVQQDEy5TZWN0aWdvIFB1Ymxp
|
|
||||||
YyBTZXJ2ZXIgQXV0aGVudGljYXRpb24gQ0EgRFYgUjM2MIIBojANBgkqhkiG9w0B
|
|
||||||
AQEFAAOCAY8AMIIBigKCAYEAljZf2HIz7+SPUPQCQObZYcrxLTHYdf1ZtMRe7Yeq
|
|
||||||
RPSwygz16qJ9cAWtWNTcuICc++p8Dct7zNGxCpqmEtqifO7NvuB5dEVexXn9RFFH
|
|
||||||
12Hm+NtPRQgXIFjx6MSJcNWuVO3XGE57L1mHlcQYj+g4hny90aFh2SCZCDEVkAja
|
|
||||||
EMMfYPKuCjHuuF+bzHFb/9gV8P9+ekcHENF2nR1efGWSKwnfG5RawlkaQDpRtZTm
|
|
||||||
M64TIsv/r7cyFO4nSjs1jLdXYdz5q3a4L0NoabZfbdxVb+CUEHfB0bpulZQtH1Rv
|
|
||||||
38e/lIdP7OTTIlZh6OYL6NhxP8So0/sht/4J9mqIGxRFc0/pC8suja+wcIUna0HB
|
|
||||||
pXKfXTKpzgis+zmXDL06ASJf5E4A2/m+Hp6b84sfPAwQ766rI65mh50S0Di9E3Pn
|
|
||||||
2WcaJc+PILsBmYpgtmgWTR9eV9otfKRUBfzHUHcVgarub/XluEpRlTtZudU5xbFN
|
|
||||||
xx/DgMrXLUAPaI60fZ6wA+PTAgMBAAGjggGBMIIBfTAfBgNVHSMEGDAWgBRWc1hk
|
|
||||||
lfmSGrASKgRieaFAFYghSTAdBgNVHQ4EFgQUaMASFhgOr872h6YyV6NGUV3LBycw
|
|
||||||
DgYDVR0PAQH/BAQDAgGGMBIGA1UdEwEB/wQIMAYBAf8CAQAwHQYDVR0lBBYwFAYI
|
|
||||||
KwYBBQUHAwEGCCsGAQUFBwMCMBsGA1UdIAQUMBIwBgYEVR0gADAIBgZngQwBAgEw
|
|
||||||
VAYDVR0fBE0wSzBJoEegRYZDaHR0cDovL2NybC5zZWN0aWdvLmNvbS9TZWN0aWdv
|
|
||||||
UHVibGljU2VydmVyQXV0aGVudGljYXRpb25Sb290UjQ2LmNybDCBhAYIKwYBBQUH
|
|
||||||
AQEEeDB2ME8GCCsGAQUFBzAChkNodHRwOi8vY3J0LnNlY3RpZ28uY29tL1NlY3Rp
|
|
||||||
Z29QdWJsaWNTZXJ2ZXJBdXRoZW50aWNhdGlvblJvb3RSNDYucDdjMCMGCCsGAQUF
|
|
||||||
BzABhhdodHRwOi8vb2NzcC5zZWN0aWdvLmNvbTANBgkqhkiG9w0BAQwFAAOCAgEA
|
|
||||||
YtOC9Fy+TqECFw40IospI92kLGgoSZGPOSQXMBqmsGWZUQ7rux7cj1du6d9rD6C8
|
|
||||||
ze1B2eQjkrGkIL/OF1s7vSmgYVafsRoZd/IHUrkoQvX8FZwUsmPu7amgBfaY3g+d
|
|
||||||
q1x0jNGKb6I6Bzdl6LgMD9qxp+3i7GQOnd9J8LFSietY6Z4jUBzVoOoz8iAU84OF
|
|
||||||
h2HhAuiPw1ai0VnY38RTI+8kepGWVfGxfBWzwH9uIjeooIeaosVFvE8cmYUB4TSH
|
|
||||||
5dUyD0jHct2+8ceKEtIoFU/FfHq/mDaVnvcDCZXtIgitdMFQdMZaVehmObyhRdDD
|
|
||||||
4NQCs0gaI9AAgFj4L9QtkARzhQLNyRf87Kln+YU0lgCGr9HLg3rGO8q+Y4ppLsOd
|
|
||||||
unQZ6ZxPNGIfOApbPVf5hCe58EZwiWdHIMn9lPP6+F404y8NNugbQixBber+x536
|
|
||||||
WrZhFZLjEkhp7fFXf9r32rNPfb74X/U90Bdy4lzp3+X1ukh1BuMxA/EEhDoTOS3l
|
|
||||||
7ABvc7BYSQubQ2490OcdkIzUh3ZwDrakMVrbaTxUM2p24N6dB+ns2zptWCva6jzW
|
|
||||||
r8IWKIMxzxLPv5Kt3ePKcUdvkBU/smqujSczTzzSjIoR5QqQA6lN1ZRSnuHIWCvh
|
|
||||||
JEltkYnTAH41QJ6SAWO66GrrUESwN/cgZzL4JLEqz1Y=
|
|
||||||
-----END CERTIFICATE-----
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
-----BEGIN CERTIFICATE-----
|
|
||||||
MIIGlTCCBH2gAwIBAgIRANJ/u8HeNZ5SFq1hSVhgmcQwDQYJKoZIhvcNAQEMBQAw
|
|
||||||
gYgxCzAJBgNVBAYTAlVTMRMwEQYDVQQIEwpOZXcgSmVyc2V5MRQwEgYDVQQHEwtK
|
|
||||||
ZXJzZXkgQ2l0eTEeMBwGA1UEChMVVGhlIFVTRVJUUlVTVCBOZXR3b3JrMS4wLAYD
|
|
||||||
VQQDEyVVU0VSVHJ1c3QgUlNBIENlcnRpZmljYXRpb24gQXV0aG9yaXR5MB4XDTIx
|
|
||||||
MDMyMjAwMDAwMFoXDTM4MDExODIzNTk1OVowXzELMAkGA1UEBhMCR0IxGDAWBgNV
|
|
||||||
BAoTD1NlY3RpZ28gTGltaXRlZDE2MDQGA1UEAxMtU2VjdGlnbyBQdWJsaWMgU2Vy
|
|
||||||
dmVyIEF1dGhlbnRpY2F0aW9uIFJvb3QgUjQ2MIICIjANBgkqhkiG9w0BAQEFAAOC
|
|
||||||
Ag8AMIICCgKCAgEAk77VNlJ12AEjoBxHQknuY7a3If3EldVIKyZ8FFMQ2nn9K7ct
|
|
||||||
pNQs+uoy3UnCub0PSD17WphUr55dMXRPB/xQId2kz2hPGxJjbSWZTCqZ80gwYfqB
|
|
||||||
fB6nCErcPiscHxhMcao1jK34bug7StnllALWiYQTqm3ITzPMUJY3kjPcX4jnn1TZ
|
|
||||||
SPCYQ9Zm/Z8XOEPFAVEL1+MjDxRdWxTnS77d9MjaAzfR1jmhIVEwg7Bt1zBOlluR
|
|
||||||
8HAkq79FgWRDDb0hOi886Z4NyyC1QifM2m+b7mQwkDnNk2WBITG1I1AzNyLjOO34
|
|
||||||
MTDMRf5i+dFdMnlCh99qzFYZQE3Oqrv5tXZJlPEn+JGlg+UGs2MOgNzgElWApjtm
|
|
||||||
tDmHLcjw0NEU6eQNTQ72XVdyxTscR1ad4tX7gWGMzE2AkDRbt9cUddzYBEifwMEo
|
|
||||||
iLTpHMqnsfFWt3tJTFnlIBWohAIp+jiUaZpJBo/NH3kUFxIMg3reH7GX7vmXeCik
|
|
||||||
yESS6X0mBaZYcpt5E9gRX67FOGI0aLKGMI74kGGeMmz1BzbNokxu7Io27fLmmRVE
|
|
||||||
cMN8vJw5wLTha/eDJSNX2RKA5UnwdQ/vjescm1QotCE8/HwK/+97a3X/ix2gGQWr
|
|
||||||
+vgrgULoOLq7+6r9PeDzyt9Ol5cp7fMYVumllqy9w5CYsuD5otSmR0N8bc8CAwEA
|
|
||||||
AaOCASAwggEcMB8GA1UdIwQYMBaAFFN5v1qqK0rPVIDh2JvAnfKyA2bLMB0GA1Ud
|
|
||||||
DgQWBBRWc1hklfmSGrASKgRieaFAFYghSTAOBgNVHQ8BAf8EBAMCAYYwDwYDVR0T
|
|
||||||
AQH/BAUwAwEB/zAdBgNVHSUEFjAUBggrBgEFBQcDAQYIKwYBBQUHAwIwEQYDVR0g
|
|
||||||
BAowCDAGBgRVHSAAMFAGA1UdHwRJMEcwRaBDoEGGP2h0dHA6Ly9jcmwudXNlcnRy
|
|
||||||
dXN0LmNvbS9VU0VSVHJ1c3RSU0FDZXJ0aWZpY2F0aW9uQXV0aG9yaXR5LmNybDA1
|
|
||||||
BggrBgEFBQcBAQQpMCcwJQYIKwYBBQUHMAGGGWh0dHA6Ly9vY3NwLnVzZXJ0cnVz
|
|
||||||
dC5jb20wDQYJKoZIhvcNAQEMBQADggIBADpvBIlq7bMU0cFDT/9P9+BsgCkRgQs0
|
|
||||||
S6Bf7vJSlWMHwby0VGvxCS0hrbi0K2BINZbEbsVsgpQq04431yyoVn3Hldorgq24
|
|
||||||
RldRDOOipEZDTFB9wC9HYt1thHF00XeG2C8KC1plwoEzKAIhPvefI/C3cT0CfTXJ
|
|
||||||
uFjUbKIgSwjNjw6YHtLgoy/hd5+JLUlLco/gzFX/qWbT7tEquOMYpsNKWZj8TLqP
|
|
||||||
q6zMiG4Na6feEZte6YPXGrMWlTWN341vDedc+yxQqSug79HJUQcOZs7KyDWztmae
|
|
||||||
QxsPE49UV/8XwrfZtZaYyrs4FpD94Z4Q8dzXGL8+qEJjxgcza7W6PROaClubavd1
|
|
||||||
VKPm8+aCW77u7SxpR2TFGL6kPdxsKyFijpcunR5V79sUyROfNdzjrAcFWZXK8sbb
|
|
||||||
9FlnwuVG677JLv+ZVTX5AxLvW5OB4zt5uS+zB62wJ/Wv+jXGAttSAcJec4iFgCWH
|
|
||||||
Rvdi/jJoSzRLa3nEzx6pFIzclSCnh0u1xCeLcUBypSiPga8W+6PkuoyQq8U9qs9E
|
|
||||||
oxG5NvrvlyshwUS9yvcZRGw7Ljlx4jJH/BhIPR8kIBCQj1vna9TziZOrw1Of8hDU
|
|
||||||
bHKFG9Pm8Dp2vbjz/2JH39qvxshPKVllGfq+5klPm7yZRUYTiCMAbqwNdL/nsqF2
|
|
||||||
Rnnyp58XRStJ
|
|
||||||
-----END CERTIFICATE-----
|
|
||||||
@@ -26,6 +26,11 @@ services:
|
|||||||
- "5432:5432"
|
- "5432:5432"
|
||||||
networks:
|
networks:
|
||||||
- translate-network
|
- translate-network
|
||||||
|
logging:
|
||||||
|
driver: "json-file"
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "3"
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-translate} -d ${POSTGRES_DB:-translate_db}"]
|
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-translate} -d ${POSTGRES_DB:-translate_db}"]
|
||||||
interval: 5s
|
interval: 5s
|
||||||
@@ -47,6 +52,11 @@ services:
|
|||||||
- "6379:6379"
|
- "6379:6379"
|
||||||
networks:
|
networks:
|
||||||
- translate-network
|
- translate-network
|
||||||
|
logging:
|
||||||
|
driver: "json-file"
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "3"
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "redis-cli", "ping"]
|
test: ["CMD", "redis-cli", "ping"]
|
||||||
interval: 5s
|
interval: 5s
|
||||||
@@ -84,6 +94,11 @@ services:
|
|||||||
- "8000:8000"
|
- "8000:8000"
|
||||||
networks:
|
networks:
|
||||||
- translate-network
|
- translate-network
|
||||||
|
logging:
|
||||||
|
driver: "json-file"
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "3"
|
||||||
depends_on:
|
depends_on:
|
||||||
postgres:
|
postgres:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
@@ -119,6 +134,11 @@ services:
|
|||||||
- "${FRONTEND_PORT:-3010}:3000"
|
- "${FRONTEND_PORT:-3010}:3000"
|
||||||
networks:
|
networks:
|
||||||
- translate-network
|
- translate-network
|
||||||
|
logging:
|
||||||
|
driver: "json-file"
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "3"
|
||||||
depends_on:
|
depends_on:
|
||||||
backend:
|
backend:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
@@ -144,6 +164,11 @@ services:
|
|||||||
- nginx_cache:/var/cache/nginx
|
- nginx_cache:/var/cache/nginx
|
||||||
networks:
|
networks:
|
||||||
- translate-network
|
- translate-network
|
||||||
|
logging:
|
||||||
|
driver: "json-file"
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "3"
|
||||||
depends_on:
|
depends_on:
|
||||||
backend:
|
backend:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|||||||
@@ -28,6 +28,11 @@ services:
|
|||||||
- postgres_data:/var/lib/postgresql/data
|
- postgres_data:/var/lib/postgresql/data
|
||||||
networks:
|
networks:
|
||||||
- wordly-network
|
- wordly-network
|
||||||
|
logging:
|
||||||
|
driver: "json-file"
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "3"
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-translate} -d ${POSTGRES_DB:-translate_db}"]
|
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-translate} -d ${POSTGRES_DB:-translate_db}"]
|
||||||
interval: 10s
|
interval: 10s
|
||||||
@@ -53,6 +58,11 @@ services:
|
|||||||
- redis_data:/data
|
- redis_data:/data
|
||||||
networks:
|
networks:
|
||||||
- wordly-network
|
- wordly-network
|
||||||
|
logging:
|
||||||
|
driver: "json-file"
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "3"
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "redis-cli", "ping"]
|
test: ["CMD", "redis-cli", "ping"]
|
||||||
interval: 10s
|
interval: 10s
|
||||||
@@ -116,6 +126,11 @@ services:
|
|||||||
- logs_data:/app/logs
|
- logs_data:/app/logs
|
||||||
networks:
|
networks:
|
||||||
- wordly-network
|
- wordly-network
|
||||||
|
logging:
|
||||||
|
driver: "json-file"
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "3"
|
||||||
depends_on:
|
depends_on:
|
||||||
postgres:
|
postgres:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
@@ -155,6 +170,11 @@ services:
|
|||||||
- NEXT_PUBLIC_GOOGLE_CLIENT_ID=${NEXT_PUBLIC_GOOGLE_CLIENT_ID:-}
|
- NEXT_PUBLIC_GOOGLE_CLIENT_ID=${NEXT_PUBLIC_GOOGLE_CLIENT_ID:-}
|
||||||
networks:
|
networks:
|
||||||
- wordly-network
|
- wordly-network
|
||||||
|
logging:
|
||||||
|
driver: "json-file"
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "3"
|
||||||
depends_on:
|
depends_on:
|
||||||
backend:
|
backend:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
@@ -176,6 +196,11 @@ services:
|
|||||||
- ollama_data:/root/.ollama
|
- ollama_data:/root/.ollama
|
||||||
networks:
|
networks:
|
||||||
- wordly-network
|
- wordly-network
|
||||||
|
logging:
|
||||||
|
driver: "json-file"
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "3"
|
||||||
deploy:
|
deploy:
|
||||||
resources:
|
resources:
|
||||||
reservations:
|
reservations:
|
||||||
@@ -205,6 +230,11 @@ services:
|
|||||||
- '--web.enable-lifecycle'
|
- '--web.enable-lifecycle'
|
||||||
networks:
|
networks:
|
||||||
- wordly-network
|
- wordly-network
|
||||||
|
logging:
|
||||||
|
driver: "json-file"
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "3"
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "wget", "-q", "--spider", "http://localhost:9090/-/healthy"]
|
test: ["CMD", "wget", "-q", "--spider", "http://localhost:9090/-/healthy"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
@@ -232,6 +262,11 @@ services:
|
|||||||
- "3001:3000"
|
- "3001:3000"
|
||||||
networks:
|
networks:
|
||||||
- wordly-network
|
- wordly-network
|
||||||
|
logging:
|
||||||
|
driver: "json-file"
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "3"
|
||||||
depends_on:
|
depends_on:
|
||||||
prometheus:
|
prometheus:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
@@ -260,6 +295,11 @@ services:
|
|||||||
- '--collector.filesystem.mount-points-exclude=^/(sys|proc|dev|etc|var/lib/docker)($$|/)'
|
- '--collector.filesystem.mount-points-exclude=^/(sys|proc|dev|etc|var/lib/docker)($$|/)'
|
||||||
networks:
|
networks:
|
||||||
- wordly-network
|
- wordly-network
|
||||||
|
logging:
|
||||||
|
driver: "json-file"
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "3"
|
||||||
|
|
||||||
# ===========================================
|
# ===========================================
|
||||||
# cAdvisor - Container Metrics
|
# cAdvisor - Container Metrics
|
||||||
@@ -279,6 +319,11 @@ services:
|
|||||||
- /dev/kmsg
|
- /dev/kmsg
|
||||||
networks:
|
networks:
|
||||||
- wordly-network
|
- wordly-network
|
||||||
|
logging:
|
||||||
|
driver: "json-file"
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "3"
|
||||||
|
|
||||||
# ===========================================
|
# ===========================================
|
||||||
# Networks
|
# Networks
|
||||||
|
|||||||
598
frontend/src/app/dashboard/glossaries/[id]/page.tsx
Normal file
598
frontend/src/app/dashboard/glossaries/[id]/page.tsx
Normal file
@@ -0,0 +1,598 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
|
import { useParams, useRouter } from 'next/navigation';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import {
|
||||||
|
ArrowLeft, Library, Calendar, Hash, Save, Trash2, Loader2,
|
||||||
|
CheckCircle2, AlertCircle, Download, Upload, Plus, X, Search,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
import { useUser } from '@/app/dashboard/useUser';
|
||||||
|
import {
|
||||||
|
useGlossary,
|
||||||
|
useGlossaries,
|
||||||
|
} from '../useGlossaries';
|
||||||
|
import {
|
||||||
|
exportGlossaryToCsv,
|
||||||
|
parseFileToTerms,
|
||||||
|
generateCsvContent,
|
||||||
|
} from '../csvUtils';
|
||||||
|
import { SUPPORTED_LANGUAGES } from '../types';
|
||||||
|
import type { GlossaryTermInput } from '../types';
|
||||||
|
import { useToast } from '@/components/ui/toast';
|
||||||
|
import { ProUpgradePrompt } from '../ProUpgradePrompt';
|
||||||
|
|
||||||
|
const MAX_TERMS = 500;
|
||||||
|
|
||||||
|
function getDisplayTarget(
|
||||||
|
term: { target: string; translations?: Record<string, string> | null },
|
||||||
|
lang: string
|
||||||
|
): string {
|
||||||
|
if (lang === 'multi' || lang === 'en' || !lang) return term.target;
|
||||||
|
const translations = term.translations || {};
|
||||||
|
return translations[lang] || term.target;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function GlossaryDetailPage() {
|
||||||
|
const params = useParams();
|
||||||
|
const router = useRouter();
|
||||||
|
const id = (params?.id as string) || '';
|
||||||
|
const { t } = useI18n();
|
||||||
|
const { data: user, isLoading: isLoadingUser } = useUser();
|
||||||
|
const { glossary, isLoading, error } = useGlossary(id);
|
||||||
|
const { updateGlossary, deleteGlossary, isUpdating, isDeleting } = useGlossaries();
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const isPro = user?.tier === 'pro';
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
// Editable state
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [sourceLanguage, setSourceLanguage] = useState('fr');
|
||||||
|
const [targetLanguage, setTargetLanguage] = useState('multi');
|
||||||
|
const [terms, setTerms] = useState<GlossaryTermInput[]>([]);
|
||||||
|
const [initialized, setInitialized] = useState(false);
|
||||||
|
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
|
||||||
|
// Initialize from glossary data
|
||||||
|
useEffect(() => {
|
||||||
|
if (glossary && !initialized) {
|
||||||
|
setName(glossary.name);
|
||||||
|
setSourceLanguage(glossary.source_language || 'fr');
|
||||||
|
setTargetLanguage(glossary.target_language || 'multi');
|
||||||
|
setTerms(
|
||||||
|
glossary.terms.map((t) => ({
|
||||||
|
source: t.source,
|
||||||
|
target: t.target,
|
||||||
|
translations: t.translations || {},
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
setInitialized(true);
|
||||||
|
}
|
||||||
|
}, [glossary, initialized]);
|
||||||
|
|
||||||
|
// Reset on id change
|
||||||
|
useEffect(() => {
|
||||||
|
setInitialized(false);
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
const hasUnsavedChanges = useCallback(() => {
|
||||||
|
if (!glossary) return false;
|
||||||
|
if (name.trim() !== glossary.name) return true;
|
||||||
|
if (sourceLanguage !== (glossary.source_language || 'fr')) return true;
|
||||||
|
if (targetLanguage !== (glossary.target_language || 'multi')) return true;
|
||||||
|
const currentTerms = terms
|
||||||
|
.filter((t) => t.source.trim() && t.target.trim())
|
||||||
|
.map((t) => `${t.source}|${t.target}`).sort().join(';;');
|
||||||
|
const originalTerms = glossary.terms
|
||||||
|
.map((t) => `${t.source}|${t.target}`).sort().join(';;');
|
||||||
|
return currentTerms !== originalTerms;
|
||||||
|
}, [glossary, name, sourceLanguage, targetLanguage, terms]);
|
||||||
|
|
||||||
|
const validTerms = terms.filter((t) => t.source.trim() && t.target.trim());
|
||||||
|
const validTermsCount = validTerms.length;
|
||||||
|
const isDirty = hasUnsavedChanges();
|
||||||
|
|
||||||
|
const handleAddTerm = () => {
|
||||||
|
if (validTermsCount >= MAX_TERMS) {
|
||||||
|
toast({
|
||||||
|
variant: 'destructive',
|
||||||
|
title: t('glossaries.detail.maxTermsTitle') || 'Limite atteinte',
|
||||||
|
description: t('glossaries.detail.maxTermsDesc', { max: String(MAX_TERMS) }) ||
|
||||||
|
`Maximum ${MAX_TERMS} termes par glossaire.`,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setTerms([...terms, { source: '', target: '' }]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveTerm = (index: number) => {
|
||||||
|
setTerms(terms.filter((_, i) => i !== index));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTermChange = (index: number, field: 'source' | 'target', value: string) => {
|
||||||
|
setTerms(terms.map((t, i) => (i === index ? { ...t, [field]: value } : t)));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTargetLanguageChange = (newLang: string) => {
|
||||||
|
if (glossary) {
|
||||||
|
setTargetLanguage(newLang);
|
||||||
|
if (newLang === 'multi' || newLang === 'en') return;
|
||||||
|
// Remap each term to show translation for the new language (when available)
|
||||||
|
setTerms((prev) =>
|
||||||
|
prev.map((t) => {
|
||||||
|
const translations = (t.translations || {}) as Record<string, string>;
|
||||||
|
const langTarget = translations[newLang];
|
||||||
|
if (langTarget) {
|
||||||
|
return { ...t, target: langTarget };
|
||||||
|
}
|
||||||
|
return t;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!glossary || !name.trim()) return;
|
||||||
|
try {
|
||||||
|
await updateGlossary(glossary.id, {
|
||||||
|
name: name.trim(),
|
||||||
|
source_language: sourceLanguage,
|
||||||
|
target_language: targetLanguage,
|
||||||
|
terms: validTerms,
|
||||||
|
});
|
||||||
|
toast({
|
||||||
|
title: t('glossaries.detail.savedTitle') || 'Enregistré',
|
||||||
|
description: t('glossaries.detail.savedDesc') || 'Le glossaire a été mis à jour.',
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
toast({
|
||||||
|
variant: 'destructive',
|
||||||
|
title: t('glossaries.toast.error') || 'Erreur',
|
||||||
|
description: t('glossaries.toast.errorUpdate') || 'Impossible de mettre à jour le glossaire.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (!glossary) return;
|
||||||
|
try {
|
||||||
|
await deleteGlossary(glossary.id);
|
||||||
|
toast({
|
||||||
|
title: t('glossaries.toast.deleted') || 'Supprimé',
|
||||||
|
description: t('glossaries.toast.deletedDesc') || 'Le glossaire a été supprimé.',
|
||||||
|
});
|
||||||
|
router.push('/dashboard/glossaries');
|
||||||
|
} catch {
|
||||||
|
toast({
|
||||||
|
variant: 'destructive',
|
||||||
|
title: t('glossaries.toast.error') || 'Erreur',
|
||||||
|
description: t('glossaries.toast.errorDelete') || 'Impossible de supprimer le glossaire.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleExport = () => {
|
||||||
|
if (!glossary) return;
|
||||||
|
const csv = generateCsvContent(validTerms);
|
||||||
|
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.download = `${name.replace(/[^a-z0-9]/gi, '_') || 'glossary'}.csv`;
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleImportClick = () => fileInputRef.current?.click();
|
||||||
|
|
||||||
|
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
e.target.value = '';
|
||||||
|
try {
|
||||||
|
const parsed = await parseFileToTerms(file);
|
||||||
|
if (parsed.length === 0) {
|
||||||
|
toast({
|
||||||
|
variant: 'destructive',
|
||||||
|
title: t('glossaries.detail.importEmptyTitle') || 'Fichier vide',
|
||||||
|
description: t('glossaries.detail.importEmptyDesc') ||
|
||||||
|
'Aucun terme détecté dans ce fichier.',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (parsed.length > MAX_TERMS) {
|
||||||
|
toast({
|
||||||
|
variant: 'destructive',
|
||||||
|
title: t('glossaries.detail.maxTermsTitle') || 'Trop de termes',
|
||||||
|
description: t('glossaries.detail.maxTermsDesc', { max: String(MAX_TERMS) }) ||
|
||||||
|
`Maximum ${MAX_TERMS} termes par glossaire.`,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setTerms(parsed);
|
||||||
|
toast({
|
||||||
|
title: t('glossaries.detail.importedTitle') || 'Importé',
|
||||||
|
description: t('glossaries.detail.importedDesc', { count: String(parsed.length) }) ||
|
||||||
|
`${parsed.length} termes importés.`,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
toast({
|
||||||
|
variant: 'destructive',
|
||||||
|
title: t('glossaries.detail.importErrorTitle') || 'Erreur',
|
||||||
|
description: t('glossaries.detail.importErrorDesc') || 'Impossible de lire le fichier.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Filter terms by search
|
||||||
|
const filteredTerms = useCallback(() => {
|
||||||
|
if (!searchQuery.trim()) return terms.map((t, i) => ({ ...t, _index: i }));
|
||||||
|
const q = searchQuery.toLowerCase();
|
||||||
|
return terms
|
||||||
|
.map((t, i) => ({ ...t, _index: i }))
|
||||||
|
.filter((t) => t.source.toLowerCase().includes(q) || t.target.toLowerCase().includes(q));
|
||||||
|
}, [terms, searchQuery]);
|
||||||
|
|
||||||
|
if (isLoadingUser) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center min-h-[60vh]">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-4 border-brand-muted border-t-brand-accent" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isPro) {
|
||||||
|
return <ProUpgradePrompt />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center min-h-[60vh]">
|
||||||
|
<div className="text-center space-y-4">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-4 border-brand-muted border-t-brand-accent mx-auto" />
|
||||||
|
<p className="text-xs text-brand-dark/40 dark:text-white/40 font-light">
|
||||||
|
{t('glossaries.loading') || 'Chargement…'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !glossary) {
|
||||||
|
return (
|
||||||
|
<div className="max-w-4xl mx-auto p-6 lg:p-8">
|
||||||
|
<Link
|
||||||
|
href="/dashboard/glossaries"
|
||||||
|
className="inline-flex items-center gap-1.5 text-[11px] font-bold text-brand-dark/60 dark:text-white/60 hover:text-brand-accent mb-6"
|
||||||
|
>
|
||||||
|
<ArrowLeft size={12} />
|
||||||
|
{t('glossaries.detail.backToList') || 'Retour aux glossaires'}
|
||||||
|
</Link>
|
||||||
|
<div className="editorial-card p-8 bg-white dark:bg-[#141414] border border-black/5 dark:border-white/5 rounded-2xl shadow-sm text-center">
|
||||||
|
<AlertCircle size={32} className="mx-auto text-destructive mb-3" />
|
||||||
|
<p className="text-base font-serif font-medium text-brand-dark dark:text-white mb-1">
|
||||||
|
{t('glossaries.detail.notFoundTitle') || 'Glossaire introuvable'}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-brand-dark/50 dark:text-white/50 font-light">
|
||||||
|
{t('glossaries.detail.notFoundDesc') ||
|
||||||
|
'Ce glossaire n\'existe pas ou vous n\'y avez pas accès.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const srcInfo = SUPPORTED_LANGUAGES.find(l => l.code === glossary.source_language);
|
||||||
|
const tgtInfo = SUPPORTED_LANGUAGES.find(l => l.code === glossary.target_language);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-5xl mx-auto w-full p-6 lg:p-8">
|
||||||
|
|
||||||
|
{/* Back link */}
|
||||||
|
<Link
|
||||||
|
href="/dashboard/glossaries"
|
||||||
|
className="inline-flex items-center gap-1.5 text-[11px] font-bold text-brand-dark/60 dark:text-white/60 hover:text-brand-accent mb-6 uppercase tracking-wider"
|
||||||
|
>
|
||||||
|
<ArrowLeft size={12} />
|
||||||
|
{t('glossaries.detail.backToList') || 'Retour aux glossaires'}
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-start justify-between gap-6 mb-8">
|
||||||
|
<div className="flex items-start gap-4 min-w-0 flex-1">
|
||||||
|
<div className="w-12 h-12 bg-brand-muted dark:bg-white/10 rounded-2xl flex items-center justify-center text-brand-accent shrink-0">
|
||||||
|
<Library size={22} />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<input
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
disabled={isUpdating}
|
||||||
|
className="w-full text-2xl md:text-3xl font-serif font-semibold text-brand-dark dark:text-white tracking-tight leading-tight bg-transparent border-none focus:outline-none focus:ring-2 focus:ring-brand-accent/20 rounded px-2 py-1 -mx-2"
|
||||||
|
/>
|
||||||
|
<div className="flex items-center gap-3 mt-2 text-[11px] text-brand-dark/50 dark:text-white/50 font-light">
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
{srcInfo?.flag ?? '🌐'} {srcInfo?.label ?? glossary.source_language}
|
||||||
|
<span className="text-brand-accent font-bold mx-1">→</span>
|
||||||
|
{tgtInfo?.flag ?? '🌐'} {tgtInfo?.label ?? glossary.target_language}
|
||||||
|
</span>
|
||||||
|
<span>•</span>
|
||||||
|
<span className="flex items-center gap-1 font-mono">
|
||||||
|
<Hash size={10} /> {validTermsCount} {t('glossaries.defineTerms') || 'termes'}
|
||||||
|
</span>
|
||||||
|
<span>•</span>
|
||||||
|
<span className="flex items-center gap-1 font-mono">
|
||||||
|
<Calendar size={10} /> {new Date(glossary.created_at).toLocaleDateString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action bar */}
|
||||||
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
|
{isDirty && (
|
||||||
|
<span className="flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-wider text-amber-600 dark:text-amber-400 bg-amber-500/10 px-3 py-1 rounded-full border border-amber-500/20">
|
||||||
|
<AlertCircle size={11} />
|
||||||
|
{t('glossaries.status.unsaved') || 'Non enregistré'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={isUpdating || !isDirty || !name.trim()}
|
||||||
|
className="premium-button px-6 py-2.5 text-[11px] uppercase tracking-widest !rounded-lg flex items-center gap-2 disabled:opacity-50 cursor-pointer font-bold"
|
||||||
|
>
|
||||||
|
{isUpdating ? <Loader2 size={12} className="animate-spin" /> : <Save size={12} />}
|
||||||
|
{t('glossaries.detail.save') || 'Enregistrer'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Settings card */}
|
||||||
|
<section className="editorial-card p-6 lg:p-8 bg-white dark:bg-[#141414] border border-black/5 dark:border-white/5 rounded-2xl shadow-sm mb-6">
|
||||||
|
<h3 className="text-xs font-bold uppercase tracking-widest text-brand-dark/60 dark:text-white/60 mb-4">
|
||||||
|
{t('glossaries.detail.settingsTitle') || 'Paramètres'}
|
||||||
|
</h3>
|
||||||
|
<div className="grid sm:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="text-[10px] font-bold uppercase tracking-widest text-brand-dark/50 dark:text-white/50 mb-1.5 block">
|
||||||
|
{t('glossaries.detail.sourceLang') || 'Langue source'}
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={sourceLanguage}
|
||||||
|
onChange={(e) => setSourceLanguage(e.target.value)}
|
||||||
|
disabled={isUpdating}
|
||||||
|
className="w-full h-10 rounded-lg border border-input bg-background px-3 text-sm focus:outline-none focus:ring-2 focus:ring-brand-accent/20"
|
||||||
|
>
|
||||||
|
{SUPPORTED_LANGUAGES.filter((l) => l.code !== 'multi').map((l) => (
|
||||||
|
<option key={l.code} value={l.code}>
|
||||||
|
{l.flag} {l.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-[10px] font-bold uppercase tracking-widest text-brand-dark/50 dark:text-white/50 mb-1.5 block">
|
||||||
|
{t('glossaries.detail.targetLang') || 'Langue cible'}
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={targetLanguage}
|
||||||
|
onChange={(e) => handleTargetLanguageChange(e.target.value)}
|
||||||
|
disabled={isUpdating}
|
||||||
|
className="w-full h-10 rounded-lg border border-input bg-background px-3 text-sm focus:outline-none focus:ring-2 focus:ring-brand-accent/20"
|
||||||
|
>
|
||||||
|
{SUPPORTED_LANGUAGES.map((l) => (
|
||||||
|
<option key={l.code} value={l.code}>
|
||||||
|
{l.flag} {l.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Terms table card */}
|
||||||
|
<section className="editorial-card bg-white dark:bg-[#141414] border border-black/5 dark:border-white/5 rounded-2xl shadow-sm mb-6 overflow-hidden">
|
||||||
|
<div className="flex items-center justify-between p-6 border-b border-black/5 dark:border-white/5">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-xs font-bold uppercase tracking-widest text-brand-dark/60 dark:text-white/60">
|
||||||
|
{t('glossaries.detail.termsTitle') || 'Termes'}
|
||||||
|
</h3>
|
||||||
|
<p className="text-[11px] text-brand-dark/45 dark:text-white/40 font-light mt-0.5">
|
||||||
|
{validTermsCount} / {MAX_TERMS} {t('glossaries.detail.terms') || 'termes'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{terms.length > 5 && (
|
||||||
|
<div className="relative">
|
||||||
|
<Search size={12} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-brand-dark/30 dark:text-white/30" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
placeholder={t('glossaries.detail.searchTerms') || 'Filtrer…'}
|
||||||
|
className="pl-7 pr-2 py-1.5 text-[11px] w-32 sm:w-40 rounded-md border border-black/5 dark:border-white/10 bg-white dark:bg-[#141414] focus:outline-none focus:ring-2 focus:ring-brand-accent/20"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{terms.length === 0 ? (
|
||||||
|
<div className="p-8 text-center">
|
||||||
|
<p className="text-xs text-brand-dark/50 dark:text-white/50 font-light mb-4">
|
||||||
|
{t('glossaries.detail.noTerms') || 'Aucun terme pour l\'instant.'}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={handleAddTerm}
|
||||||
|
disabled={isUpdating}
|
||||||
|
className="inline-flex items-center gap-1.5 text-[11px] font-bold text-brand-accent hover:underline"
|
||||||
|
>
|
||||||
|
<Plus size={12} />
|
||||||
|
{t('glossaries.detail.addFirstTerm') || 'Ajouter le premier terme'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="max-h-[600px] overflow-y-auto">
|
||||||
|
<table className="w-full text-xs">
|
||||||
|
<thead className="sticky top-0 bg-white dark:bg-[#141414] border-b border-black/5 dark:border-white/5 z-10">
|
||||||
|
<tr>
|
||||||
|
<th className="text-left font-bold uppercase tracking-wider text-brand-dark/50 dark:text-white/50 px-6 py-2.5 w-[45%]">
|
||||||
|
{t('glossaries.detail.source') || 'Source'}
|
||||||
|
</th>
|
||||||
|
<th className="text-left font-bold uppercase tracking-wider text-brand-dark/50 dark:text-white/50 px-3 py-2.5 w-[45%]">
|
||||||
|
{t('glossaries.detail.target') || 'Cible'}
|
||||||
|
</th>
|
||||||
|
<th className="w-10" />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{filteredTerms().map((term) => (
|
||||||
|
<tr
|
||||||
|
key={term._index}
|
||||||
|
className="border-b border-black/5 dark:border-white/5 last:border-0 group hover:bg-brand-muted/30 dark:hover:bg-white/[0.02]"
|
||||||
|
>
|
||||||
|
<td className="px-6 py-1.5">
|
||||||
|
<input
|
||||||
|
value={term.source}
|
||||||
|
onChange={(e) => handleTermChange(term._index, 'source', e.target.value)}
|
||||||
|
disabled={isUpdating}
|
||||||
|
placeholder={t('glossaries.detail.sourcePlaceholder') || 'terme source'}
|
||||||
|
className="w-full bg-transparent border-none focus:outline-none focus:ring-2 focus:ring-brand-accent/20 rounded px-2 py-1.5 -mx-2 text-brand-dark dark:text-white"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-1.5">
|
||||||
|
<input
|
||||||
|
value={targetLanguage === 'multi' || targetLanguage === 'en'
|
||||||
|
? term.target
|
||||||
|
: (term.translations?.[targetLanguage] || term.target)}
|
||||||
|
onChange={(e) => handleTermChange(term._index, 'target', e.target.value)}
|
||||||
|
disabled={isUpdating}
|
||||||
|
placeholder={t('glossaries.detail.targetPlaceholder') || 'terme cible'}
|
||||||
|
className="w-full bg-transparent border-none focus:outline-none focus:ring-2 focus:ring-brand-accent/20 rounded px-2 py-1.5 -mx-2 text-brand-dark dark:text-white"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="px-3">
|
||||||
|
<button
|
||||||
|
onClick={() => handleRemoveTerm(term._index)}
|
||||||
|
disabled={isUpdating}
|
||||||
|
className="opacity-0 group-hover:opacity-100 transition-opacity p-1 rounded text-brand-dark/40 dark:text-white/40 hover:text-red-500 hover:bg-red-500/10"
|
||||||
|
>
|
||||||
|
<X size={14} />
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Add term footer */}
|
||||||
|
{terms.length > 0 && (
|
||||||
|
<div className="p-4 border-t border-black/5 dark:border-white/5 flex items-center justify-between">
|
||||||
|
<button
|
||||||
|
onClick={handleAddTerm}
|
||||||
|
disabled={isUpdating || validTermsCount >= MAX_TERMS}
|
||||||
|
className="inline-flex items-center gap-1.5 text-[11px] font-bold text-brand-accent hover:underline disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<Plus size={12} />
|
||||||
|
{t('glossaries.detail.addTerm') || 'Ajouter un terme'}
|
||||||
|
</button>
|
||||||
|
{validTermsCount >= MAX_TERMS && (
|
||||||
|
<span className="text-[10px] text-amber-600 dark:text-amber-400 font-medium">
|
||||||
|
{t('glossaries.detail.maxReached') || 'Limite maximale atteinte'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* CSV + Danger zone */}
|
||||||
|
<section className="grid sm:grid-cols-2 gap-4 mb-6">
|
||||||
|
<div className="editorial-card p-6 bg-white dark:bg-[#141414] border border-black/5 dark:border-white/5 rounded-2xl shadow-sm">
|
||||||
|
<h3 className="text-xs font-bold uppercase tracking-widest text-brand-dark/60 dark:text-white/60 mb-3">
|
||||||
|
{t('glossaries.detail.csvTitle') || 'CSV'}
|
||||||
|
</h3>
|
||||||
|
<p className="text-[11px] text-brand-dark/50 dark:text-white/50 font-light mb-4">
|
||||||
|
{t('glossaries.detail.csvDesc') ||
|
||||||
|
'Exportez vos termes en CSV ou importez-en de nouveaux (remplace la liste actuelle).'}
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={handleExport}
|
||||||
|
disabled={validTermsCount === 0}
|
||||||
|
className="flex-1 py-2.5 px-3 rounded-lg bg-brand-muted/60 dark:bg-white/5 hover:bg-brand-accent/10 text-brand-dark/70 dark:text-white/60 hover:text-brand-accent text-[10px] font-bold uppercase tracking-wider transition-all disabled:opacity-50 flex items-center justify-center gap-1.5"
|
||||||
|
>
|
||||||
|
<Download size={11} />
|
||||||
|
{t('glossaries.detail.export') || 'Exporter'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleImportClick}
|
||||||
|
disabled={isUpdating}
|
||||||
|
className="flex-1 py-2.5 px-3 rounded-lg bg-brand-muted/60 dark:bg-white/5 hover:bg-brand-accent/10 text-brand-dark/70 dark:text-white/60 hover:text-brand-accent text-[10px] font-bold uppercase tracking-wider transition-all disabled:opacity-50 flex items-center justify-center gap-1.5"
|
||||||
|
>
|
||||||
|
<Upload size={11} />
|
||||||
|
{t('glossaries.detail.import') || 'Importer'}
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept=".csv,.xlsx,.xls,.ods,.txt,.tsv"
|
||||||
|
onChange={handleFileChange}
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="editorial-card p-6 bg-red-500/5 border border-red-500/20 rounded-2xl">
|
||||||
|
<h3 className="text-xs font-bold uppercase tracking-widest text-red-600 dark:text-red-400 mb-3">
|
||||||
|
{t('glossaries.detail.dangerTitle') || 'Zone danger'}
|
||||||
|
</h3>
|
||||||
|
<p className="text-[11px] text-brand-dark/50 dark:text-white/50 font-light mb-4">
|
||||||
|
{t('glossaries.detail.dangerDesc') ||
|
||||||
|
'La suppression est définitive. Tous les termes associés seront perdus.'}
|
||||||
|
</p>
|
||||||
|
{!confirmDelete ? (
|
||||||
|
<button
|
||||||
|
onClick={() => setConfirmDelete(true)}
|
||||||
|
disabled={isDeleting}
|
||||||
|
className="w-full py-2.5 px-3 rounded-lg bg-red-500/10 hover:bg-red-500 hover:text-white text-red-500 text-[10px] font-bold uppercase tracking-wider transition-all disabled:opacity-50 flex items-center justify-center gap-1.5"
|
||||||
|
>
|
||||||
|
<Trash2 size={11} />
|
||||||
|
{t('glossaries.detail.deleteGlossary') || 'Supprimer ce glossaire'}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-[11px] text-red-600 dark:text-red-400 font-bold">
|
||||||
|
{t('glossaries.detail.confirmDelete') || 'Confirmer la suppression ?'}
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setConfirmDelete(false)}
|
||||||
|
disabled={isDeleting}
|
||||||
|
className="flex-1 py-2 px-3 rounded-lg bg-brand-muted dark:bg-white/5 text-brand-dark/70 dark:text-white/60 text-[10px] font-bold uppercase tracking-wider"
|
||||||
|
>
|
||||||
|
{t('glossaries.detail.cancel') || 'Annuler'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleDelete}
|
||||||
|
disabled={isDeleting}
|
||||||
|
className="flex-1 py-2 px-3 rounded-lg bg-red-500 text-white text-[10px] font-bold uppercase tracking-wider hover:bg-red-600 transition-colors disabled:opacity-50 flex items-center justify-center gap-1.5"
|
||||||
|
>
|
||||||
|
{isDeleting ? <Loader2 size={11} className="animate-spin" /> : <Trash2 size={11} />}
|
||||||
|
{t('glossaries.detail.confirm') || 'Confirmer'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,81 +1,52 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect, useMemo } from 'react';
|
||||||
import {
|
import {
|
||||||
BookText, Plus, Library, Calendar, Hash,
|
BookText, Plus, Library, Calendar, Hash,
|
||||||
Zap, Save, Trash2, MessageSquare, Loader2,
|
MessageSquare, Save, Trash2, Loader2,
|
||||||
Monitor, Scale, Stethoscope, BarChart3,
|
CheckCircle2, AlertCircle, ArrowRight, Info, ExternalLink, Search,
|
||||||
Megaphone, ShoppingCart, FlaskConical, Users,
|
|
||||||
CheckCircle2, AlertCircle, ArrowRight, MousePointerClick,
|
|
||||||
Info, ExternalLink,
|
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { useUser } from '@/app/dashboard/useUser';
|
import { useUser } from '@/app/dashboard/useUser';
|
||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
import { useGlossaries, useGlossary } from './useGlossaries';
|
import { useGlossaries } from './useGlossaries';
|
||||||
import type { Glossary, GlossaryTermInput, GlossaryListItem } from './types';
|
import type { GlossaryListItem } from './types';
|
||||||
import { ProUpgradePrompt } from './ProUpgradePrompt';
|
import { ProUpgradePrompt } from './ProUpgradePrompt';
|
||||||
import { CreateGlossaryDialog } from './CreateGlossaryDialog';
|
import { CreateGlossaryDialog } from './CreateGlossaryDialog';
|
||||||
import { EditGlossaryDialog } from './EditGlossaryDialog';
|
|
||||||
import { DeleteGlossaryDialog } from './DeleteGlossaryDialog';
|
|
||||||
import { useToast } from '@/components/ui/toast';
|
import { useToast } from '@/components/ui/toast';
|
||||||
import { SUPPORTED_LANGUAGES } from './types';
|
import { SUPPORTED_LANGUAGES } from './types';
|
||||||
import { useTranslationStore } from '@/lib/store';
|
import { useTranslationStore } from '@/lib/store';
|
||||||
import { API_BASE } from '@/lib/config';
|
|
||||||
|
|
||||||
const PRESETS = [
|
|
||||||
{ key: 'it', titleKey: 'glossaries.presets.it.title', descKey: 'glossaries.presets.it.desc', icon: Monitor, templateId: 'technology' },
|
|
||||||
{ key: 'legal', titleKey: 'glossaries.presets.legal.title', descKey: 'glossaries.presets.legal.desc', icon: Scale, templateId: 'legal' },
|
|
||||||
{ key: 'medical', titleKey: 'glossaries.presets.medical.title', descKey: 'glossaries.presets.medical.desc', icon: Stethoscope, templateId: 'medical' },
|
|
||||||
{ key: 'finance', titleKey: 'glossaries.presets.finance.title', descKey: 'glossaries.presets.finance.desc', icon: BarChart3, templateId: 'finance' },
|
|
||||||
{ key: 'marketing', titleKey: 'glossaries.presets.marketing.title', descKey: 'glossaries.presets.marketing.desc', icon: Megaphone, templateId: 'marketing' },
|
|
||||||
{ key: 'hr', titleKey: 'glossaries.presets.hr.title', descKey: 'glossaries.presets.hr.desc', icon: Users, templateId: 'hr' },
|
|
||||||
{ key: 'scientific', titleKey: 'glossaries.presets.scientific.title', descKey: 'glossaries.presets.scientific.desc', icon: FlaskConical, templateId: 'scientific' },
|
|
||||||
{ key: 'ecommerce', titleKey: 'glossaries.presets.ecommerce.title', descKey: 'glossaries.presets.ecommerce.desc', icon: ShoppingCart, templateId: 'ecommerce' },
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function GlossariesPage() {
|
export default function GlossariesPage() {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
const router = useRouter();
|
||||||
const { data: user, isLoading: isLoadingUser } = useUser();
|
const { data: user, isLoading: isLoadingUser } = useUser();
|
||||||
const {
|
const {
|
||||||
glossaries,
|
glossaries,
|
||||||
total,
|
total,
|
||||||
isLoading: isLoadingGlossaries,
|
isLoading: isLoadingGlossaries,
|
||||||
isCreating,
|
isCreating,
|
||||||
isUpdating,
|
|
||||||
isDeleting,
|
|
||||||
isImportingTemplate,
|
isImportingTemplate,
|
||||||
createGlossary,
|
createGlossary,
|
||||||
updateGlossary,
|
|
||||||
deleteGlossary,
|
|
||||||
importTemplate,
|
importTemplate,
|
||||||
} = useGlossaries();
|
} = useGlossaries();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const { settings, updateSettings } = useTranslationStore();
|
const { settings, updateSettings } = useTranslationStore();
|
||||||
|
|
||||||
const [createDialogOpen, setCreateDialogOpen] = useState(false);
|
const [createDialogOpen, setCreateDialogOpen] = useState(false);
|
||||||
const [editDialogOpen, setEditDialogOpen] = useState(false);
|
|
||||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
|
||||||
const [selectedGlossary, setSelectedGlossary] = useState<GlossaryListItem | null>(null);
|
|
||||||
const [glossaryToDelete, setGlossaryToDelete] = useState<{ id: string; name: string } | null>(null);
|
|
||||||
const [systemPrompt, setSystemPrompt] = useState(settings.systemPrompt);
|
const [systemPrompt, setSystemPrompt] = useState(settings.systemPrompt);
|
||||||
const [isSavingPrompt, setIsSavingPrompt] = useState(false);
|
const [isSavingPrompt, setIsSavingPrompt] = useState(false);
|
||||||
const [promptSaved, setPromptSaved] = useState(false);
|
const [promptSaved, setPromptSaved] = useState(false);
|
||||||
const [creatingPreset, setCreatingPreset] = useState<string | null>(null);
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
|
||||||
const { glossary: fullGlossary, isLoading: isLoadingGlossaryDetail } = useGlossary(
|
|
||||||
selectedGlossary?.id || null
|
|
||||||
);
|
|
||||||
|
|
||||||
const isPro = user?.tier === 'pro';
|
const isPro = user?.tier === 'pro';
|
||||||
const isLoading = isLoadingUser || isLoadingGlossaries;
|
const isLoading = isLoadingUser || isLoadingGlossaries;
|
||||||
|
|
||||||
// Current translation target from store
|
|
||||||
const currentTargetLang = settings.defaultTargetLanguage;
|
const currentTargetLang = settings.defaultTargetLanguage;
|
||||||
const currentTargetInfo = SUPPORTED_LANGUAGES.find(l => l.code === currentTargetLang);
|
const currentTargetInfo = SUPPORTED_LANGUAGES.find(l => l.code === currentTargetLang);
|
||||||
|
|
||||||
// Track whether prompt has unsaved changes
|
|
||||||
const promptHasUnsavedChanges = systemPrompt !== settings.systemPrompt;
|
const promptHasUnsavedChanges = systemPrompt !== settings.systemPrompt;
|
||||||
const promptIsActive = !!settings.systemPrompt?.trim();
|
const promptIsActive = !!settings.systemPrompt?.trim();
|
||||||
|
|
||||||
@@ -92,7 +63,9 @@ export default function GlossariesPage() {
|
|||||||
setPromptSaved(true);
|
setPromptSaved(true);
|
||||||
toast({ title: t('context.saved'), description: t('context.savedDesc') });
|
toast({ title: t('context.saved'), description: t('context.savedDesc') });
|
||||||
setTimeout(() => setPromptSaved(false), 3000);
|
setTimeout(() => setPromptSaved(false), 3000);
|
||||||
} finally { setIsSavingPrompt(false); }
|
} finally {
|
||||||
|
setIsSavingPrompt(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleClearPrompt = () => {
|
const handleClearPrompt = () => {
|
||||||
@@ -100,56 +73,7 @@ export default function GlossariesPage() {
|
|||||||
setSystemPrompt('');
|
setSystemPrompt('');
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCreatePresetGlossary = async (preset: typeof PRESETS[0]) => {
|
const handleCreateGlossary = async (data: { name: string; source_language: string; target_language: string; terms: { source: string; target: string; translations?: Record<string, string> }[] }) => {
|
||||||
setCreatingPreset(preset.key);
|
|
||||||
try {
|
|
||||||
const token = localStorage.getItem('token');
|
|
||||||
if (!token) return;
|
|
||||||
const headers: Record<string, string> = {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
};
|
|
||||||
const params = new URLSearchParams({ template_id: preset.templateId });
|
|
||||||
const res = await fetch(`${API_BASE}/api/v1/glossaries/import?${params.toString()}`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers,
|
|
||||||
});
|
|
||||||
if (res.ok) {
|
|
||||||
const result = await res.json();
|
|
||||||
const glossary = result.data;
|
|
||||||
toast({
|
|
||||||
title: t('context.presets.created'),
|
|
||||||
description: t('context.presets.createdDesc', {
|
|
||||||
name: glossary?.name ?? t(preset.titleKey),
|
|
||||||
count: String(glossary?.terms?.length ?? 0),
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
} else if (res.status === 409) {
|
|
||||||
toast({ title: t('glossaries.presets.alreadyImported') });
|
|
||||||
} else {
|
|
||||||
toast({ variant: 'destructive', title: t('glossaries.toast.error'), description: t('glossaries.toast.errorCreate') });
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
toast({ variant: 'destructive', title: t('glossaries.toast.error'), description: t('glossaries.toast.errorImport') });
|
|
||||||
} finally {
|
|
||||||
setCreatingPreset(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleEditClick = (id: string) => {
|
|
||||||
const glossary = glossaries.find((g: GlossaryListItem) => g.id === id);
|
|
||||||
if (glossary) {
|
|
||||||
setSelectedGlossary(glossary);
|
|
||||||
setEditDialogOpen(true);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDeleteClick = (id: string, name: string) => {
|
|
||||||
setGlossaryToDelete({ id, name });
|
|
||||||
setDeleteDialogOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCreateGlossary = async (data: { name: string; source_language: string; target_language: string; terms: GlossaryTermInput[] }) => {
|
|
||||||
try {
|
try {
|
||||||
await createGlossary(data);
|
await createGlossary(data);
|
||||||
setCreateDialogOpen(false);
|
setCreateDialogOpen(false);
|
||||||
@@ -187,43 +111,12 @@ export default function GlossariesPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSaveGlossary = async (id: string, data: { name: string; source_language: string; target_language: string; terms: GlossaryTermInput[] }) => {
|
// Filtered list (search)
|
||||||
try {
|
const filteredGlossaries = useMemo(() => {
|
||||||
await updateGlossary(id, data);
|
if (!searchQuery.trim()) return glossaries;
|
||||||
setEditDialogOpen(false);
|
const q = searchQuery.toLowerCase();
|
||||||
setSelectedGlossary(null);
|
return glossaries.filter((g) => g.name.toLowerCase().includes(q));
|
||||||
toast({
|
}, [glossaries, searchQuery]);
|
||||||
title: t('glossaries.toast.updated'),
|
|
||||||
description: t('glossaries.toast.updatedDesc', { name: data.name }),
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
toast({
|
|
||||||
variant: 'destructive',
|
|
||||||
title: t('glossaries.toast.error'),
|
|
||||||
description: t('glossaries.toast.errorUpdate'),
|
|
||||||
});
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDeleteConfirm = async () => {
|
|
||||||
if (!glossaryToDelete) return;
|
|
||||||
try {
|
|
||||||
await deleteGlossary(glossaryToDelete.id);
|
|
||||||
setDeleteDialogOpen(false);
|
|
||||||
setGlossaryToDelete(null);
|
|
||||||
toast({
|
|
||||||
title: t('glossaries.toast.deleted'),
|
|
||||||
description: t('glossaries.toast.deletedDesc'),
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
toast({
|
|
||||||
variant: 'destructive',
|
|
||||||
title: t('glossaries.toast.error'),
|
|
||||||
description: t('glossaries.toast.errorDelete'),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
@@ -240,18 +133,6 @@ export default function GlossariesPage() {
|
|||||||
return <ProUpgradePrompt />;
|
return <ProUpgradePrompt />;
|
||||||
}
|
}
|
||||||
|
|
||||||
const renderTitle = (title: string) => {
|
|
||||||
const lastSpaceIndex = title.lastIndexOf(' ');
|
|
||||||
if (lastSpaceIndex === -1) return title;
|
|
||||||
const firstPart = title.substring(0, lastSpaceIndex);
|
|
||||||
const lastWord = title.substring(lastSpaceIndex + 1);
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{firstPart} <span className="italic">{lastWord}</span>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-6xl mx-auto w-full p-6 lg:p-8">
|
<div className="max-w-6xl mx-auto w-full p-6 lg:p-8">
|
||||||
|
|
||||||
@@ -270,15 +151,15 @@ export default function GlossariesPage() {
|
|||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => setCreateDialogOpen(true)}
|
onClick={() => setCreateDialogOpen(true)}
|
||||||
disabled={isCreating}
|
disabled={isCreating || isImportingTemplate}
|
||||||
className="premium-button px-8 py-3 text-xs uppercase tracking-widest !rounded-xl flex items-center gap-2 disabled:opacity-50 shrink-0 cursor-pointer font-bold"
|
className="premium-button px-8 py-3 text-xs uppercase tracking-widest !rounded-xl flex items-center gap-2 disabled:opacity-50 shrink-0 cursor-pointer font-bold"
|
||||||
>
|
>
|
||||||
<Plus size={14} />
|
<Plus size={14} />
|
||||||
{t('glossaries.createNew')}
|
{t('glossaries.createNew') || "Créer un glossaire"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Comment ça marche ─────────────────────────────────── */}
|
{/* ── How it works ───────────────────────────────────────── */}
|
||||||
<div className="mb-10 p-5 rounded-2xl bg-brand-accent/5 dark:bg-brand-accent/10 border border-brand-accent/20 dark:border-brand-accent/15">
|
<div className="mb-10 p-5 rounded-2xl bg-brand-accent/5 dark:bg-brand-accent/10 border border-brand-accent/20 dark:border-brand-accent/15">
|
||||||
<div className="flex items-center gap-2 mb-4">
|
<div className="flex items-center gap-2 mb-4">
|
||||||
<Info size={15} className="text-brand-accent shrink-0" />
|
<Info size={15} className="text-brand-accent shrink-0" />
|
||||||
@@ -325,9 +206,8 @@ export default function GlossariesPage() {
|
|||||||
|
|
||||||
<div className="space-y-10">
|
<div className="space-y-10">
|
||||||
|
|
||||||
{/* ── System Prompt ────────────────────────────────────── */}
|
{/* ── System Prompt (Context) ─────────────────────────────── */}
|
||||||
<section className="editorial-card p-8 lg:p-10 bg-white dark:bg-[#141414] border border-black/5 dark:border-white/5 rounded-2xl shadow-sm">
|
<section className="editorial-card p-8 lg:p-10 bg-white dark:bg-[#141414] border border-black/5 dark:border-white/5 rounded-2xl shadow-sm">
|
||||||
{/* Header with status badge */}
|
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-3">
|
||||||
<div className="flex items-center gap-3 text-brand-accent">
|
<div className="flex items-center gap-3 text-brand-accent">
|
||||||
<MessageSquare size={18} />
|
<MessageSquare size={18} />
|
||||||
@@ -335,7 +215,6 @@ export default function GlossariesPage() {
|
|||||||
{t('context.instructions.title')}
|
{t('context.instructions.title')}
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
{/* Status badge */}
|
|
||||||
{promptHasUnsavedChanges ? (
|
{promptHasUnsavedChanges ? (
|
||||||
<span className="flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-wider text-amber-600 dark:text-amber-400 bg-amber-500/10 px-3 py-1 rounded-full border border-amber-500/20">
|
<span className="flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-wider text-amber-600 dark:text-amber-400 bg-amber-500/10 px-3 py-1 rounded-full border border-amber-500/20">
|
||||||
<AlertCircle size={11} />
|
<AlertCircle size={11} />
|
||||||
@@ -396,75 +275,9 @@ export default function GlossariesPage() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* ── Professional Presets ─────────────────────────────── */}
|
{/* ── Your Glossaries ────────────────────────────────────── */}
|
||||||
<section className="editorial-card p-8 lg:p-10 bg-white dark:bg-[#141414] border border-black/5 dark:border-white/5 rounded-2xl shadow-sm">
|
<section>
|
||||||
<div className="flex items-center gap-3 mb-3 text-brand-accent">
|
<div className="flex items-center justify-between mb-5 gap-4">
|
||||||
<Zap size={18} />
|
|
||||||
<h3 className="text-sm font-serif font-medium text-brand-dark dark:text-white tracking-tight">
|
|
||||||
{t('context.presets.title')}
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Explanation box */}
|
|
||||||
<div className="mb-6 p-3.5 rounded-xl bg-brand-muted/40 dark:bg-white/[0.03] border border-black/5 dark:border-white/5">
|
|
||||||
<p className="text-[11px] text-brand-dark/60 dark:text-white/50 font-light leading-relaxed">
|
|
||||||
<strong className="font-bold text-brand-dark/80 dark:text-white/80">{t('glossaries.presets.whatForBold')}</strong> {t('glossaries.presets.whatForDesc')}
|
|
||||||
</p>
|
|
||||||
<div className="flex items-center gap-2 mt-2.5">
|
|
||||||
<MousePointerClick size={11} className="text-brand-accent shrink-0" />
|
|
||||||
<p className="text-[11px] text-brand-accent/80 font-medium">
|
|
||||||
{t('glossaries.presets.clickHint')}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
|
||||||
{(() => {
|
|
||||||
const importedTemplateIds = new Set(
|
|
||||||
glossaries
|
|
||||||
.map((g: GlossaryListItem) => g.template_id)
|
|
||||||
.filter(Boolean) as string[]
|
|
||||||
);
|
|
||||||
return PRESETS.map((p) => {
|
|
||||||
const Icon = p.icon;
|
|
||||||
const isCreatingThis = creatingPreset === p.key;
|
|
||||||
const alreadyImported = importedTemplateIds.has(p.templateId);
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={p.key}
|
|
||||||
onClick={() => !alreadyImported && handleCreatePresetGlossary(p)}
|
|
||||||
disabled={!!creatingPreset || alreadyImported}
|
|
||||||
className="relative p-4 bg-brand-muted/40 dark:bg-white/5 hover:bg-brand-accent/5 dark:hover:bg-brand-accent/10 border border-black/5 dark:border-white/5 rounded-xl text-left transition-all hover:border-brand-accent/30 cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed group min-h-[105px] flex flex-col justify-between"
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between mb-2">
|
|
||||||
<div className="p-1.5 bg-brand-accent/10 rounded-lg text-brand-accent group-hover:scale-110 transition-transform">
|
|
||||||
{isCreatingThis ? <Loader2 size={16} className="animate-spin" /> : <Icon size={16} />}
|
|
||||||
</div>
|
|
||||||
{isCreatingThis && <span className="text-[10px] text-brand-accent font-bold uppercase">{t('glossaries.presets.creating')}</span>}
|
|
||||||
{alreadyImported && (
|
|
||||||
<span className="flex items-center gap-1 text-[9px] font-bold uppercase tracking-wider text-emerald-600 dark:text-emerald-400 bg-emerald-500/10 px-2 py-0.5 rounded-full border border-emerald-500/20">
|
|
||||||
<CheckCircle2 size={9} /> {t('glossaries.presets.alreadyImported')}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h4 className="text-xs font-bold text-brand-dark dark:text-white mb-1">
|
|
||||||
{t(p.titleKey)}
|
|
||||||
</h4>
|
|
||||||
<p className="text-[10px] text-brand-dark/45 dark:text-white/45 leading-normal font-light">
|
|
||||||
{t(p.descKey)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
})()}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* ── Glossary Grid ──────────────────────────────────────── */}
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center justify-between mb-5">
|
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-lg font-serif font-medium text-brand-dark dark:text-white tracking-tight">
|
<h2 className="text-lg font-serif font-medium text-brand-dark dark:text-white tracking-tight">
|
||||||
{t('glossaries.grid.title')} <span className="italic">{t('glossaries.grid.titleHighlight')}</span>
|
{t('glossaries.grid.title')} <span className="italic">{t('glossaries.grid.titleHighlight')}</span>
|
||||||
@@ -475,7 +288,7 @@ export default function GlossariesPage() {
|
|||||||
: t('glossaries.grid.emptyAction')}
|
: t('glossaries.grid.emptyAction')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3 shrink-0">
|
||||||
{currentTargetInfo && (
|
{currentTargetInfo && (
|
||||||
<span className="flex items-center gap-1.5 text-[10px] font-bold text-brand-dark/50 dark:text-white/40 bg-brand-muted dark:bg-white/5 border border-black/5 dark:border-white/5 px-3 py-1.5 rounded-full">
|
<span className="flex items-center gap-1.5 text-[10px] font-bold text-brand-dark/50 dark:text-white/40 bg-brand-muted dark:bg-white/5 border border-black/5 dark:border-white/5 px-3 py-1.5 rounded-full">
|
||||||
<span>{t('glossaries.grid.activeTranslation')}</span>
|
<span>{t('glossaries.grid.activeTranslation')}</span>
|
||||||
@@ -494,29 +307,57 @@ export default function GlossariesPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Search bar (only if more than 3 glossaries) */}
|
||||||
|
{glossaries.length > 3 && (
|
||||||
|
<div className="relative mb-5">
|
||||||
|
<Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-brand-dark/30 dark:text-white/30" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={e => setSearchQuery(e.target.value)}
|
||||||
|
placeholder={t('glossaries.grid.searchPlaceholder') || "Rechercher un glossaire…"}
|
||||||
|
className="w-full pl-9 pr-3 py-2.5 text-xs rounded-lg border border-black/5 dark:border-white/10 bg-white dark:bg-[#141414] focus:outline-none focus:ring-2 focus:ring-brand-accent/20 focus:border-brand-accent/30"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{glossaries.length === 0 ? (
|
{glossaries.length === 0 ? (
|
||||||
<div className="editorial-card p-12 bg-white dark:bg-[#141414] border border-black/5 dark:border-white/5 rounded-2xl shadow-sm text-center">
|
<div className="editorial-card p-12 bg-white dark:bg-[#141414] border border-black/5 dark:border-white/5 rounded-2xl shadow-sm text-center">
|
||||||
<div className="w-12 h-12 bg-brand-muted dark:bg-white/10 rounded-xl flex items-center justify-center text-brand-accent mx-auto mb-4">
|
<div className="w-12 h-12 bg-brand-muted dark:bg-white/10 rounded-xl flex items-center justify-center text-brand-accent mx-auto mb-4">
|
||||||
<Library size={24} />
|
<Library size={24} />
|
||||||
</div>
|
</div>
|
||||||
<p className="text-base font-serif font-medium text-brand-dark dark:text-white mb-1">{t('glossaries.empty')}</p>
|
<p className="text-base font-serif font-medium text-brand-dark dark:text-white mb-1">{t('glossaries.empty')}</p>
|
||||||
<p className="text-xs text-brand-dark/40 dark:text-white/40 font-light">{t('glossaries.emptyDesc')}</p>
|
<p className="text-xs text-brand-dark/40 dark:text-white/40 font-light mb-5">{t('glossaries.emptyDesc')}</p>
|
||||||
|
<button
|
||||||
|
onClick={() => setCreateDialogOpen(true)}
|
||||||
|
className="premium-button px-6 py-2.5 text-[11px] uppercase tracking-widest !rounded-lg inline-flex items-center gap-2 cursor-pointer font-bold"
|
||||||
|
>
|
||||||
|
<Plus size={12} />
|
||||||
|
{t('glossaries.createNew') || "Créer un glossaire"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : filteredGlossaries.length === 0 ? (
|
||||||
|
<div className="editorial-card p-8 bg-white dark:bg-[#141414] border border-black/5 dark:border-white/5 rounded-2xl shadow-sm text-center">
|
||||||
|
<p className="text-xs text-brand-dark/50 dark:text-white/50 font-light">
|
||||||
|
{t('glossaries.grid.noResults') || "Aucun résultat pour cette recherche."}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
|
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
{glossaries.map((glossary: GlossaryListItem) => {
|
{filteredGlossaries.map((glossary: GlossaryListItem) => {
|
||||||
const termCount = glossary.terms_count ?? 0;
|
const termCount = glossary.terms_count ?? 0;
|
||||||
const srcInfo = SUPPORTED_LANGUAGES.find(l => l.code === glossary.source_language);
|
const srcInfo = SUPPORTED_LANGUAGES.find(l => l.code === glossary.source_language);
|
||||||
const tgtInfo = SUPPORTED_LANGUAGES.find(l => l.code === glossary.target_language);
|
const tgtInfo = SUPPORTED_LANGUAGES.find(l => l.code === glossary.target_language);
|
||||||
const isMultilingual = glossary.target_language === 'multi';
|
const isMultilingual = glossary.target_language === 'multi';
|
||||||
// Does this glossary match the current translation target?
|
|
||||||
const matchesTarget = currentTargetLang && (isMultilingual || glossary.target_language === currentTargetLang);
|
const matchesTarget = currentTargetLang && (isMultilingual || glossary.target_language === currentTargetLang);
|
||||||
const mismatch = currentTargetLang && !isMultilingual && glossary.target_language && glossary.target_language !== currentTargetLang;
|
const mismatch = currentTargetLang && !isMultilingual && glossary.target_language && glossary.target_language !== currentTargetLang;
|
||||||
return (
|
return (
|
||||||
<div
|
<button
|
||||||
key={glossary.id}
|
key={glossary.id}
|
||||||
|
onClick={() => router.push(`/dashboard/glossaries/${glossary.id}`)}
|
||||||
className={cn(
|
className={cn(
|
||||||
'editorial-card p-6 bg-white dark:bg-[#141414] border rounded-2xl shadow-sm group transition-all relative',
|
'editorial-card p-6 bg-white dark:bg-[#141414] border rounded-2xl shadow-sm group transition-all relative text-left w-full',
|
||||||
|
'hover:shadow-md hover:-translate-y-0.5 cursor-pointer',
|
||||||
matchesTarget
|
matchesTarget
|
||||||
? 'border-brand-accent/40 ring-1 ring-brand-accent/20 hover:border-brand-accent/60'
|
? 'border-brand-accent/40 ring-1 ring-brand-accent/20 hover:border-brand-accent/60'
|
||||||
: mismatch
|
: mismatch
|
||||||
@@ -524,7 +365,6 @@ export default function GlossariesPage() {
|
|||||||
: 'border-black/5 dark:border-white/5 hover:border-brand-accent/30'
|
: 'border-black/5 dark:border-white/5 hover:border-brand-accent/30'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{/* Match / mismatch badge */}
|
|
||||||
{matchesTarget && (
|
{matchesTarget && (
|
||||||
<div className="absolute top-3 right-3 flex items-center gap-1 bg-brand-accent/10 text-brand-accent px-2 py-0.5 rounded-full text-[9px] font-black uppercase tracking-wider">
|
<div className="absolute top-3 right-3 flex items-center gap-1 bg-brand-accent/10 text-brand-accent px-2 py-0.5 rounded-full text-[9px] font-black uppercase tracking-wider">
|
||||||
<CheckCircle2 size={9} /> {t('glossaries.badge.compatible')}
|
<CheckCircle2 size={9} /> {t('glossaries.badge.compatible')}
|
||||||
@@ -564,45 +404,12 @@ export default function GlossariesPage() {
|
|||||||
{new Date(glossary.created_at).toLocaleDateString()}
|
{new Date(glossary.created_at).toLocaleDateString()}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
</button>
|
||||||
{/* Action buttons — always visible, unambiguous */}
|
|
||||||
<div className="flex gap-2 mt-4">
|
|
||||||
<button
|
|
||||||
onClick={() => handleEditClick(glossary.id)}
|
|
||||||
className="flex-1 py-2 px-3 rounded-lg bg-brand-muted/60 dark:bg-white/5 hover:bg-brand-accent/10 dark:hover:bg-brand-accent/15 text-brand-dark/70 dark:text-white/60 hover:text-brand-accent text-[10px] font-bold uppercase tracking-wider transition-all cursor-pointer flex items-center justify-center gap-1.5"
|
|
||||||
>
|
|
||||||
<Save size={10} /> {t('glossaries.card.editTerms')}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
handleDeleteClick(glossary.id, glossary.name);
|
|
||||||
}}
|
|
||||||
className="py-2 px-3 rounded-lg bg-red-500/5 hover:bg-red-500 hover:text-white text-red-500 text-[10px] font-bold uppercase tracking-wider transition-all cursor-pointer flex items-center gap-1.5"
|
|
||||||
>
|
|
||||||
<Trash2 size={10} /> {t('glossaries.card.delete')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</section>
|
||||||
|
|
||||||
{/* ── About section ──────────────────────────────────────── */}
|
|
||||||
<div className="editorial-card p-8 bg-white dark:bg-[#141414] border border-black/5 dark:border-white/5 rounded-2xl shadow-sm">
|
|
||||||
<div className="flex items-center gap-3 text-brand-accent mb-4">
|
|
||||||
<BookText size={18} />
|
|
||||||
<span className="text-xs font-serif font-medium text-brand-dark dark:text-white tracking-tight">
|
|
||||||
{t('glossaries.aboutTitle')}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-brand-dark/50 dark:text-white/40 font-light leading-relaxed mb-3">{t('glossaries.aboutDesc')}</p>
|
|
||||||
<p className="text-[10px] font-bold uppercase tracking-wider text-brand-dark/60 dark:text-white/60">
|
|
||||||
{t('glossaries.aboutFormat')}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Dialogs */}
|
{/* Dialogs */}
|
||||||
@@ -614,27 +421,18 @@ export default function GlossariesPage() {
|
|||||||
isCreating={isCreating}
|
isCreating={isCreating}
|
||||||
isImportingTemplate={isImportingTemplate}
|
isImportingTemplate={isImportingTemplate}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{editDialogOpen && (fullGlossary || !isLoadingGlossaryDetail) && (
|
|
||||||
<EditGlossaryDialog
|
|
||||||
open={editDialogOpen}
|
|
||||||
onOpenChange={(open) => {
|
|
||||||
setEditDialogOpen(open);
|
|
||||||
if (!open) setSelectedGlossary(null);
|
|
||||||
}}
|
|
||||||
glossary={fullGlossary}
|
|
||||||
onSave={handleSaveGlossary}
|
|
||||||
isSaving={isUpdating}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<DeleteGlossaryDialog
|
|
||||||
open={deleteDialogOpen}
|
|
||||||
onOpenChange={setDeleteDialogOpen}
|
|
||||||
onConfirm={handleDeleteConfirm}
|
|
||||||
isDeleting={isDeleting}
|
|
||||||
glossaryName={glossaryToDelete?.name}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderTitle(title: string) {
|
||||||
|
const lastSpaceIndex = title.lastIndexOf(' ');
|
||||||
|
if (lastSpaceIndex === -1) return title;
|
||||||
|
const firstPart = title.substring(0, lastSpaceIndex);
|
||||||
|
const lastWord = title.substring(lastSpaceIndex + 1);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{firstPart} <span className="italic">{lastWord}</span>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -408,6 +408,46 @@ const messages: Record<Locale, Record<string, string>> = {
|
|||||||
"glossaries.badge.otherTarget": "Other target",
|
"glossaries.badge.otherTarget": "Other target",
|
||||||
"glossaries.card.editTerms": "Edit terms",
|
"glossaries.card.editTerms": "Edit terms",
|
||||||
"glossaries.card.delete": "Delete",
|
"glossaries.card.delete": "Delete",
|
||||||
|
"glossaries.grid.searchPlaceholder": "Search a glossary…",
|
||||||
|
"glossaries.grid.noResults": "No results for this search.",
|
||||||
|
"glossaries.detail.backToList": "Back to glossaries",
|
||||||
|
"glossaries.detail.save": "Save",
|
||||||
|
"glossaries.detail.savedTitle": "Saved",
|
||||||
|
"glossaries.detail.savedDesc": "The glossary has been updated.",
|
||||||
|
"glossaries.detail.settingsTitle": "Settings",
|
||||||
|
"glossaries.detail.sourceLang": "Source language",
|
||||||
|
"glossaries.detail.targetLang": "Target language",
|
||||||
|
"glossaries.detail.termsTitle": "Terms",
|
||||||
|
"glossaries.detail.terms": "terms",
|
||||||
|
"glossaries.detail.searchTerms": "Filter…",
|
||||||
|
"glossaries.detail.noTerms": "No terms yet.",
|
||||||
|
"glossaries.detail.addFirstTerm": "Add the first term",
|
||||||
|
"glossaries.detail.addTerm": "Add a term",
|
||||||
|
"glossaries.detail.maxReached": "Maximum limit reached",
|
||||||
|
"glossaries.detail.source": "Source",
|
||||||
|
"glossaries.detail.target": "Target",
|
||||||
|
"glossaries.detail.sourcePlaceholder": "source term",
|
||||||
|
"glossaries.detail.targetPlaceholder": "target term",
|
||||||
|
"glossaries.detail.csvTitle": "CSV",
|
||||||
|
"glossaries.detail.csvDesc": "Export your terms as CSV or import new ones (replaces the current list).",
|
||||||
|
"glossaries.detail.export": "Export",
|
||||||
|
"glossaries.detail.import": "Import",
|
||||||
|
"glossaries.detail.dangerTitle": "Danger zone",
|
||||||
|
"glossaries.detail.dangerDesc": "Deletion is permanent. All associated terms will be lost.",
|
||||||
|
"glossaries.detail.deleteGlossary": "Delete this glossary",
|
||||||
|
"glossaries.detail.confirmDelete": "Confirm deletion?",
|
||||||
|
"glossaries.detail.confirm": "Confirm",
|
||||||
|
"glossaries.detail.cancel": "Cancel",
|
||||||
|
"glossaries.detail.notFoundTitle": "Glossary not found",
|
||||||
|
"glossaries.detail.notFoundDesc": "This glossary does not exist or you don't have access to it.",
|
||||||
|
"glossaries.detail.maxTermsTitle": "Limit reached",
|
||||||
|
"glossaries.detail.maxTermsDesc": "Maximum {max} terms per glossary.",
|
||||||
|
"glossaries.detail.importEmptyTitle": "Empty file",
|
||||||
|
"glossaries.detail.importEmptyDesc": "No terms detected in this file.",
|
||||||
|
"glossaries.detail.importedTitle": "Imported",
|
||||||
|
"glossaries.detail.importedDesc": "{count} terms imported.",
|
||||||
|
"glossaries.detail.importErrorTitle": "Read error",
|
||||||
|
"glossaries.detail.importErrorDesc": "Unable to read the file.",
|
||||||
"apiKeys.webhook.title": "Webhook Integration",
|
"apiKeys.webhook.title": "Webhook Integration",
|
||||||
"apiKeys.webhook.descriptionBefore": "Pass a ",
|
"apiKeys.webhook.descriptionBefore": "Pass a ",
|
||||||
"apiKeys.webhook.descriptionAfter": " parameter to receive a POST request when your translation is complete.",
|
"apiKeys.webhook.descriptionAfter": " parameter to receive a POST request when your translation is complete.",
|
||||||
@@ -1352,6 +1392,46 @@ const messages: Record<Locale, Record<string, string>> = {
|
|||||||
"glossaries.badge.otherTarget": "Autre cible",
|
"glossaries.badge.otherTarget": "Autre cible",
|
||||||
"glossaries.card.editTerms": "Modifier les termes",
|
"glossaries.card.editTerms": "Modifier les termes",
|
||||||
"glossaries.card.delete": "Supprimer",
|
"glossaries.card.delete": "Supprimer",
|
||||||
|
"glossaries.grid.searchPlaceholder": "Rechercher un glossaire…",
|
||||||
|
"glossaries.grid.noResults": "Aucun résultat pour cette recherche.",
|
||||||
|
"glossaries.detail.backToList": "Retour aux glossaires",
|
||||||
|
"glossaries.detail.save": "Enregistrer",
|
||||||
|
"glossaries.detail.savedTitle": "Enregistré",
|
||||||
|
"glossaries.detail.savedDesc": "Le glossaire a été mis à jour.",
|
||||||
|
"glossaries.detail.settingsTitle": "Paramètres",
|
||||||
|
"glossaries.detail.sourceLang": "Langue source",
|
||||||
|
"glossaries.detail.targetLang": "Langue cible",
|
||||||
|
"glossaries.detail.termsTitle": "Termes",
|
||||||
|
"glossaries.detail.terms": "termes",
|
||||||
|
"glossaries.detail.searchTerms": "Filtrer…",
|
||||||
|
"glossaries.detail.noTerms": "Aucun terme pour l'instant.",
|
||||||
|
"glossaries.detail.addFirstTerm": "Ajouter le premier terme",
|
||||||
|
"glossaries.detail.addTerm": "Ajouter un terme",
|
||||||
|
"glossaries.detail.maxReached": "Limite maximale atteinte",
|
||||||
|
"glossaries.detail.source": "Source",
|
||||||
|
"glossaries.detail.target": "Cible",
|
||||||
|
"glossaries.detail.sourcePlaceholder": "terme source",
|
||||||
|
"glossaries.detail.targetPlaceholder": "terme cible",
|
||||||
|
"glossaries.detail.csvTitle": "CSV",
|
||||||
|
"glossaries.detail.csvDesc": "Exportez vos termes en CSV ou importez-en de nouveaux (remplace la liste actuelle).",
|
||||||
|
"glossaries.detail.export": "Exporter",
|
||||||
|
"glossaries.detail.import": "Importer",
|
||||||
|
"glossaries.detail.dangerTitle": "Zone danger",
|
||||||
|
"glossaries.detail.dangerDesc": "La suppression est définitive. Tous les termes associés seront perdus.",
|
||||||
|
"glossaries.detail.deleteGlossary": "Supprimer ce glossaire",
|
||||||
|
"glossaries.detail.confirmDelete": "Confirmer la suppression ?",
|
||||||
|
"glossaries.detail.confirm": "Confirmer",
|
||||||
|
"glossaries.detail.cancel": "Annuler",
|
||||||
|
"glossaries.detail.notFoundTitle": "Glossaire introuvable",
|
||||||
|
"glossaries.detail.notFoundDesc": "Ce glossaire n'existe pas ou vous n'y avez pas accès.",
|
||||||
|
"glossaries.detail.maxTermsTitle": "Limite atteinte",
|
||||||
|
"glossaries.detail.maxTermsDesc": "Maximum {max} termes par glossaire.",
|
||||||
|
"glossaries.detail.importEmptyTitle": "Fichier vide",
|
||||||
|
"glossaries.detail.importEmptyDesc": "Aucun terme détecté dans ce fichier.",
|
||||||
|
"glossaries.detail.importedTitle": "Importé",
|
||||||
|
"glossaries.detail.importedDesc": "{count} termes importés.",
|
||||||
|
"glossaries.detail.importErrorTitle": "Erreur de lecture",
|
||||||
|
"glossaries.detail.importErrorDesc": "Impossible de lire le fichier.",
|
||||||
"apiKeys.webhook.title": "Intégration Webhook",
|
"apiKeys.webhook.title": "Intégration Webhook",
|
||||||
"apiKeys.webhook.descriptionBefore": "Passez un paramètre ",
|
"apiKeys.webhook.descriptionBefore": "Passez un paramètre ",
|
||||||
"apiKeys.webhook.descriptionAfter": " pour recevoir une requête POST lorsque votre traduction est terminée.",
|
"apiKeys.webhook.descriptionAfter": " pour recevoir une requête POST lorsque votre traduction est terminée.",
|
||||||
@@ -2282,6 +2362,46 @@ const messages: Record<Locale, Record<string, string>> = {
|
|||||||
"glossaries.badge.otherTarget": "Autre cible",
|
"glossaries.badge.otherTarget": "Autre cible",
|
||||||
"glossaries.card.editTerms": "Modifier les termes",
|
"glossaries.card.editTerms": "Modifier les termes",
|
||||||
"glossaries.card.delete": "Supprimer",
|
"glossaries.card.delete": "Supprimer",
|
||||||
|
"glossaries.grid.searchPlaceholder": "Search a glossary…",
|
||||||
|
"glossaries.grid.noResults": "No results for this search.",
|
||||||
|
"glossaries.detail.backToList": "Back to glossaries",
|
||||||
|
"glossaries.detail.save": "Save",
|
||||||
|
"glossaries.detail.savedTitle": "Saved",
|
||||||
|
"glossaries.detail.savedDesc": "The glossary has been updated.",
|
||||||
|
"glossaries.detail.settingsTitle": "Settings",
|
||||||
|
"glossaries.detail.sourceLang": "Source language",
|
||||||
|
"glossaries.detail.targetLang": "Target language",
|
||||||
|
"glossaries.detail.termsTitle": "Terms",
|
||||||
|
"glossaries.detail.terms": "terms",
|
||||||
|
"glossaries.detail.searchTerms": "Filter…",
|
||||||
|
"glossaries.detail.noTerms": "No terms yet.",
|
||||||
|
"glossaries.detail.addFirstTerm": "Add the first term",
|
||||||
|
"glossaries.detail.addTerm": "Add a term",
|
||||||
|
"glossaries.detail.maxReached": "Maximum limit reached",
|
||||||
|
"glossaries.detail.source": "Source",
|
||||||
|
"glossaries.detail.target": "Target",
|
||||||
|
"glossaries.detail.sourcePlaceholder": "source term",
|
||||||
|
"glossaries.detail.targetPlaceholder": "target term",
|
||||||
|
"glossaries.detail.csvTitle": "CSV",
|
||||||
|
"glossaries.detail.csvDesc": "Export your terms as CSV or import new ones (replaces the current list).",
|
||||||
|
"glossaries.detail.export": "Export",
|
||||||
|
"glossaries.detail.import": "Import",
|
||||||
|
"glossaries.detail.dangerTitle": "Danger zone",
|
||||||
|
"glossaries.detail.dangerDesc": "Deletion is permanent. All associated terms will be lost.",
|
||||||
|
"glossaries.detail.deleteGlossary": "Delete this glossary",
|
||||||
|
"glossaries.detail.confirmDelete": "Confirm deletion?",
|
||||||
|
"glossaries.detail.confirm": "Confirm",
|
||||||
|
"glossaries.detail.cancel": "Cancel",
|
||||||
|
"glossaries.detail.notFoundTitle": "Glossary not found",
|
||||||
|
"glossaries.detail.notFoundDesc": "This glossary does not exist or you don't have access to it.",
|
||||||
|
"glossaries.detail.maxTermsTitle": "Limit reached",
|
||||||
|
"glossaries.detail.maxTermsDesc": "Maximum {max} terms per glossary.",
|
||||||
|
"glossaries.detail.importEmptyTitle": "Empty file",
|
||||||
|
"glossaries.detail.importEmptyDesc": "No terms detected in this file.",
|
||||||
|
"glossaries.detail.importedTitle": "Imported",
|
||||||
|
"glossaries.detail.importedDesc": "{count} terms imported.",
|
||||||
|
"glossaries.detail.importErrorTitle": "Read error",
|
||||||
|
"glossaries.detail.importErrorDesc": "Unable to read the file.",
|
||||||
"apiKeys.webhook.title": "Intégration Webhook",
|
"apiKeys.webhook.title": "Intégration Webhook",
|
||||||
"apiKeys.webhook.descriptionBefore": "Passez un paramètre ",
|
"apiKeys.webhook.descriptionBefore": "Passez un paramètre ",
|
||||||
"apiKeys.webhook.descriptionAfter": " pour recevoir une requête POST lorsque votre traduction est terminée.",
|
"apiKeys.webhook.descriptionAfter": " pour recevoir une requête POST lorsque votre traduction est terminée.",
|
||||||
@@ -3167,6 +3287,46 @@ const messages: Record<Locale, Record<string, string>> = {
|
|||||||
"glossaries.badge.otherTarget": "Autre cible",
|
"glossaries.badge.otherTarget": "Autre cible",
|
||||||
"glossaries.card.editTerms": "Modifier les termes",
|
"glossaries.card.editTerms": "Modifier les termes",
|
||||||
"glossaries.card.delete": "Supprimer",
|
"glossaries.card.delete": "Supprimer",
|
||||||
|
"glossaries.grid.searchPlaceholder": "Search a glossary…",
|
||||||
|
"glossaries.grid.noResults": "No results for this search.",
|
||||||
|
"glossaries.detail.backToList": "Back to glossaries",
|
||||||
|
"glossaries.detail.save": "Save",
|
||||||
|
"glossaries.detail.savedTitle": "Saved",
|
||||||
|
"glossaries.detail.savedDesc": "The glossary has been updated.",
|
||||||
|
"glossaries.detail.settingsTitle": "Settings",
|
||||||
|
"glossaries.detail.sourceLang": "Source language",
|
||||||
|
"glossaries.detail.targetLang": "Target language",
|
||||||
|
"glossaries.detail.termsTitle": "Terms",
|
||||||
|
"glossaries.detail.terms": "terms",
|
||||||
|
"glossaries.detail.searchTerms": "Filter…",
|
||||||
|
"glossaries.detail.noTerms": "No terms yet.",
|
||||||
|
"glossaries.detail.addFirstTerm": "Add the first term",
|
||||||
|
"glossaries.detail.addTerm": "Add a term",
|
||||||
|
"glossaries.detail.maxReached": "Maximum limit reached",
|
||||||
|
"glossaries.detail.source": "Source",
|
||||||
|
"glossaries.detail.target": "Target",
|
||||||
|
"glossaries.detail.sourcePlaceholder": "source term",
|
||||||
|
"glossaries.detail.targetPlaceholder": "target term",
|
||||||
|
"glossaries.detail.csvTitle": "CSV",
|
||||||
|
"glossaries.detail.csvDesc": "Export your terms as CSV or import new ones (replaces the current list).",
|
||||||
|
"glossaries.detail.export": "Export",
|
||||||
|
"glossaries.detail.import": "Import",
|
||||||
|
"glossaries.detail.dangerTitle": "Danger zone",
|
||||||
|
"glossaries.detail.dangerDesc": "Deletion is permanent. All associated terms will be lost.",
|
||||||
|
"glossaries.detail.deleteGlossary": "Delete this glossary",
|
||||||
|
"glossaries.detail.confirmDelete": "Confirm deletion?",
|
||||||
|
"glossaries.detail.confirm": "Confirm",
|
||||||
|
"glossaries.detail.cancel": "Cancel",
|
||||||
|
"glossaries.detail.notFoundTitle": "Glossary not found",
|
||||||
|
"glossaries.detail.notFoundDesc": "This glossary does not exist or you don't have access to it.",
|
||||||
|
"glossaries.detail.maxTermsTitle": "Limit reached",
|
||||||
|
"glossaries.detail.maxTermsDesc": "Maximum {max} terms per glossary.",
|
||||||
|
"glossaries.detail.importEmptyTitle": "Empty file",
|
||||||
|
"glossaries.detail.importEmptyDesc": "No terms detected in this file.",
|
||||||
|
"glossaries.detail.importedTitle": "Imported",
|
||||||
|
"glossaries.detail.importedDesc": "{count} terms imported.",
|
||||||
|
"glossaries.detail.importErrorTitle": "Read error",
|
||||||
|
"glossaries.detail.importErrorDesc": "Unable to read the file.",
|
||||||
"apiKeys.webhook.title": "Intégration Webhook",
|
"apiKeys.webhook.title": "Intégration Webhook",
|
||||||
"apiKeys.webhook.descriptionBefore": "Passez un paramètre ",
|
"apiKeys.webhook.descriptionBefore": "Passez un paramètre ",
|
||||||
"apiKeys.webhook.descriptionAfter": " pour recevoir une requête POST lorsque votre traduction est terminée.",
|
"apiKeys.webhook.descriptionAfter": " pour recevoir une requête POST lorsque votre traduction est terminée.",
|
||||||
@@ -4052,6 +4212,46 @@ const messages: Record<Locale, Record<string, string>> = {
|
|||||||
"glossaries.badge.otherTarget": "Autre cible",
|
"glossaries.badge.otherTarget": "Autre cible",
|
||||||
"glossaries.card.editTerms": "Modifier les termes",
|
"glossaries.card.editTerms": "Modifier les termes",
|
||||||
"glossaries.card.delete": "Supprimer",
|
"glossaries.card.delete": "Supprimer",
|
||||||
|
"glossaries.grid.searchPlaceholder": "Search a glossary…",
|
||||||
|
"glossaries.grid.noResults": "No results for this search.",
|
||||||
|
"glossaries.detail.backToList": "Back to glossaries",
|
||||||
|
"glossaries.detail.save": "Save",
|
||||||
|
"glossaries.detail.savedTitle": "Saved",
|
||||||
|
"glossaries.detail.savedDesc": "The glossary has been updated.",
|
||||||
|
"glossaries.detail.settingsTitle": "Settings",
|
||||||
|
"glossaries.detail.sourceLang": "Source language",
|
||||||
|
"glossaries.detail.targetLang": "Target language",
|
||||||
|
"glossaries.detail.termsTitle": "Terms",
|
||||||
|
"glossaries.detail.terms": "terms",
|
||||||
|
"glossaries.detail.searchTerms": "Filter…",
|
||||||
|
"glossaries.detail.noTerms": "No terms yet.",
|
||||||
|
"glossaries.detail.addFirstTerm": "Add the first term",
|
||||||
|
"glossaries.detail.addTerm": "Add a term",
|
||||||
|
"glossaries.detail.maxReached": "Maximum limit reached",
|
||||||
|
"glossaries.detail.source": "Source",
|
||||||
|
"glossaries.detail.target": "Target",
|
||||||
|
"glossaries.detail.sourcePlaceholder": "source term",
|
||||||
|
"glossaries.detail.targetPlaceholder": "target term",
|
||||||
|
"glossaries.detail.csvTitle": "CSV",
|
||||||
|
"glossaries.detail.csvDesc": "Export your terms as CSV or import new ones (replaces the current list).",
|
||||||
|
"glossaries.detail.export": "Export",
|
||||||
|
"glossaries.detail.import": "Import",
|
||||||
|
"glossaries.detail.dangerTitle": "Danger zone",
|
||||||
|
"glossaries.detail.dangerDesc": "Deletion is permanent. All associated terms will be lost.",
|
||||||
|
"glossaries.detail.deleteGlossary": "Delete this glossary",
|
||||||
|
"glossaries.detail.confirmDelete": "Confirm deletion?",
|
||||||
|
"glossaries.detail.confirm": "Confirm",
|
||||||
|
"glossaries.detail.cancel": "Cancel",
|
||||||
|
"glossaries.detail.notFoundTitle": "Glossary not found",
|
||||||
|
"glossaries.detail.notFoundDesc": "This glossary does not exist or you don't have access to it.",
|
||||||
|
"glossaries.detail.maxTermsTitle": "Limit reached",
|
||||||
|
"glossaries.detail.maxTermsDesc": "Maximum {max} terms per glossary.",
|
||||||
|
"glossaries.detail.importEmptyTitle": "Empty file",
|
||||||
|
"glossaries.detail.importEmptyDesc": "No terms detected in this file.",
|
||||||
|
"glossaries.detail.importedTitle": "Imported",
|
||||||
|
"glossaries.detail.importedDesc": "{count} terms imported.",
|
||||||
|
"glossaries.detail.importErrorTitle": "Read error",
|
||||||
|
"glossaries.detail.importErrorDesc": "Unable to read the file.",
|
||||||
"apiKeys.webhook.title": "Intégration Webhook",
|
"apiKeys.webhook.title": "Intégration Webhook",
|
||||||
"apiKeys.webhook.descriptionBefore": "Passez un paramètre ",
|
"apiKeys.webhook.descriptionBefore": "Passez un paramètre ",
|
||||||
"apiKeys.webhook.descriptionAfter": " pour recevoir une requête POST lorsque votre traduction est terminée.",
|
"apiKeys.webhook.descriptionAfter": " pour recevoir une requête POST lorsque votre traduction est terminée.",
|
||||||
@@ -4937,6 +5137,46 @@ const messages: Record<Locale, Record<string, string>> = {
|
|||||||
"glossaries.badge.otherTarget": "Autre cible",
|
"glossaries.badge.otherTarget": "Autre cible",
|
||||||
"glossaries.card.editTerms": "Modifier les termes",
|
"glossaries.card.editTerms": "Modifier les termes",
|
||||||
"glossaries.card.delete": "Supprimer",
|
"glossaries.card.delete": "Supprimer",
|
||||||
|
"glossaries.grid.searchPlaceholder": "Search a glossary…",
|
||||||
|
"glossaries.grid.noResults": "No results for this search.",
|
||||||
|
"glossaries.detail.backToList": "Back to glossaries",
|
||||||
|
"glossaries.detail.save": "Save",
|
||||||
|
"glossaries.detail.savedTitle": "Saved",
|
||||||
|
"glossaries.detail.savedDesc": "The glossary has been updated.",
|
||||||
|
"glossaries.detail.settingsTitle": "Settings",
|
||||||
|
"glossaries.detail.sourceLang": "Source language",
|
||||||
|
"glossaries.detail.targetLang": "Target language",
|
||||||
|
"glossaries.detail.termsTitle": "Terms",
|
||||||
|
"glossaries.detail.terms": "terms",
|
||||||
|
"glossaries.detail.searchTerms": "Filter…",
|
||||||
|
"glossaries.detail.noTerms": "No terms yet.",
|
||||||
|
"glossaries.detail.addFirstTerm": "Add the first term",
|
||||||
|
"glossaries.detail.addTerm": "Add a term",
|
||||||
|
"glossaries.detail.maxReached": "Maximum limit reached",
|
||||||
|
"glossaries.detail.source": "Source",
|
||||||
|
"glossaries.detail.target": "Target",
|
||||||
|
"glossaries.detail.sourcePlaceholder": "source term",
|
||||||
|
"glossaries.detail.targetPlaceholder": "target term",
|
||||||
|
"glossaries.detail.csvTitle": "CSV",
|
||||||
|
"glossaries.detail.csvDesc": "Export your terms as CSV or import new ones (replaces the current list).",
|
||||||
|
"glossaries.detail.export": "Export",
|
||||||
|
"glossaries.detail.import": "Import",
|
||||||
|
"glossaries.detail.dangerTitle": "Danger zone",
|
||||||
|
"glossaries.detail.dangerDesc": "Deletion is permanent. All associated terms will be lost.",
|
||||||
|
"glossaries.detail.deleteGlossary": "Delete this glossary",
|
||||||
|
"glossaries.detail.confirmDelete": "Confirm deletion?",
|
||||||
|
"glossaries.detail.confirm": "Confirm",
|
||||||
|
"glossaries.detail.cancel": "Cancel",
|
||||||
|
"glossaries.detail.notFoundTitle": "Glossary not found",
|
||||||
|
"glossaries.detail.notFoundDesc": "This glossary does not exist or you don't have access to it.",
|
||||||
|
"glossaries.detail.maxTermsTitle": "Limit reached",
|
||||||
|
"glossaries.detail.maxTermsDesc": "Maximum {max} terms per glossary.",
|
||||||
|
"glossaries.detail.importEmptyTitle": "Empty file",
|
||||||
|
"glossaries.detail.importEmptyDesc": "No terms detected in this file.",
|
||||||
|
"glossaries.detail.importedTitle": "Imported",
|
||||||
|
"glossaries.detail.importedDesc": "{count} terms imported.",
|
||||||
|
"glossaries.detail.importErrorTitle": "Read error",
|
||||||
|
"glossaries.detail.importErrorDesc": "Unable to read the file.",
|
||||||
"apiKeys.webhook.title": "Intégration Webhook",
|
"apiKeys.webhook.title": "Intégration Webhook",
|
||||||
"apiKeys.webhook.descriptionBefore": "Passez un paramètre ",
|
"apiKeys.webhook.descriptionBefore": "Passez un paramètre ",
|
||||||
"apiKeys.webhook.descriptionAfter": " pour recevoir une requête POST lorsque votre traduction est terminée.",
|
"apiKeys.webhook.descriptionAfter": " pour recevoir une requête POST lorsque votre traduction est terminée.",
|
||||||
@@ -5822,6 +6062,46 @@ const messages: Record<Locale, Record<string, string>> = {
|
|||||||
"glossaries.badge.otherTarget": "Autre cible",
|
"glossaries.badge.otherTarget": "Autre cible",
|
||||||
"glossaries.card.editTerms": "Modifier les termes",
|
"glossaries.card.editTerms": "Modifier les termes",
|
||||||
"glossaries.card.delete": "Supprimer",
|
"glossaries.card.delete": "Supprimer",
|
||||||
|
"glossaries.grid.searchPlaceholder": "Search a glossary…",
|
||||||
|
"glossaries.grid.noResults": "No results for this search.",
|
||||||
|
"glossaries.detail.backToList": "Back to glossaries",
|
||||||
|
"glossaries.detail.save": "Save",
|
||||||
|
"glossaries.detail.savedTitle": "Saved",
|
||||||
|
"glossaries.detail.savedDesc": "The glossary has been updated.",
|
||||||
|
"glossaries.detail.settingsTitle": "Settings",
|
||||||
|
"glossaries.detail.sourceLang": "Source language",
|
||||||
|
"glossaries.detail.targetLang": "Target language",
|
||||||
|
"glossaries.detail.termsTitle": "Terms",
|
||||||
|
"glossaries.detail.terms": "terms",
|
||||||
|
"glossaries.detail.searchTerms": "Filter…",
|
||||||
|
"glossaries.detail.noTerms": "No terms yet.",
|
||||||
|
"glossaries.detail.addFirstTerm": "Add the first term",
|
||||||
|
"glossaries.detail.addTerm": "Add a term",
|
||||||
|
"glossaries.detail.maxReached": "Maximum limit reached",
|
||||||
|
"glossaries.detail.source": "Source",
|
||||||
|
"glossaries.detail.target": "Target",
|
||||||
|
"glossaries.detail.sourcePlaceholder": "source term",
|
||||||
|
"glossaries.detail.targetPlaceholder": "target term",
|
||||||
|
"glossaries.detail.csvTitle": "CSV",
|
||||||
|
"glossaries.detail.csvDesc": "Export your terms as CSV or import new ones (replaces the current list).",
|
||||||
|
"glossaries.detail.export": "Export",
|
||||||
|
"glossaries.detail.import": "Import",
|
||||||
|
"glossaries.detail.dangerTitle": "Danger zone",
|
||||||
|
"glossaries.detail.dangerDesc": "Deletion is permanent. All associated terms will be lost.",
|
||||||
|
"glossaries.detail.deleteGlossary": "Delete this glossary",
|
||||||
|
"glossaries.detail.confirmDelete": "Confirm deletion?",
|
||||||
|
"glossaries.detail.confirm": "Confirm",
|
||||||
|
"glossaries.detail.cancel": "Cancel",
|
||||||
|
"glossaries.detail.notFoundTitle": "Glossary not found",
|
||||||
|
"glossaries.detail.notFoundDesc": "This glossary does not exist or you don't have access to it.",
|
||||||
|
"glossaries.detail.maxTermsTitle": "Limit reached",
|
||||||
|
"glossaries.detail.maxTermsDesc": "Maximum {max} terms per glossary.",
|
||||||
|
"glossaries.detail.importEmptyTitle": "Empty file",
|
||||||
|
"glossaries.detail.importEmptyDesc": "No terms detected in this file.",
|
||||||
|
"glossaries.detail.importedTitle": "Imported",
|
||||||
|
"glossaries.detail.importedDesc": "{count} terms imported.",
|
||||||
|
"glossaries.detail.importErrorTitle": "Read error",
|
||||||
|
"glossaries.detail.importErrorDesc": "Unable to read the file.",
|
||||||
"apiKeys.webhook.title": "Intégration Webhook",
|
"apiKeys.webhook.title": "Intégration Webhook",
|
||||||
"apiKeys.webhook.descriptionBefore": "Passez un paramètre ",
|
"apiKeys.webhook.descriptionBefore": "Passez un paramètre ",
|
||||||
"apiKeys.webhook.descriptionAfter": " pour recevoir une requête POST lorsque votre traduction est terminée.",
|
"apiKeys.webhook.descriptionAfter": " pour recevoir une requête POST lorsque votre traduction est terminée.",
|
||||||
@@ -6707,6 +6987,46 @@ const messages: Record<Locale, Record<string, string>> = {
|
|||||||
"glossaries.badge.otherTarget": "Autre cible",
|
"glossaries.badge.otherTarget": "Autre cible",
|
||||||
"glossaries.card.editTerms": "Modifier les termes",
|
"glossaries.card.editTerms": "Modifier les termes",
|
||||||
"glossaries.card.delete": "Supprimer",
|
"glossaries.card.delete": "Supprimer",
|
||||||
|
"glossaries.grid.searchPlaceholder": "Search a glossary…",
|
||||||
|
"glossaries.grid.noResults": "No results for this search.",
|
||||||
|
"glossaries.detail.backToList": "Back to glossaries",
|
||||||
|
"glossaries.detail.save": "Save",
|
||||||
|
"glossaries.detail.savedTitle": "Saved",
|
||||||
|
"glossaries.detail.savedDesc": "The glossary has been updated.",
|
||||||
|
"glossaries.detail.settingsTitle": "Settings",
|
||||||
|
"glossaries.detail.sourceLang": "Source language",
|
||||||
|
"glossaries.detail.targetLang": "Target language",
|
||||||
|
"glossaries.detail.termsTitle": "Terms",
|
||||||
|
"glossaries.detail.terms": "terms",
|
||||||
|
"glossaries.detail.searchTerms": "Filter…",
|
||||||
|
"glossaries.detail.noTerms": "No terms yet.",
|
||||||
|
"glossaries.detail.addFirstTerm": "Add the first term",
|
||||||
|
"glossaries.detail.addTerm": "Add a term",
|
||||||
|
"glossaries.detail.maxReached": "Maximum limit reached",
|
||||||
|
"glossaries.detail.source": "Source",
|
||||||
|
"glossaries.detail.target": "Target",
|
||||||
|
"glossaries.detail.sourcePlaceholder": "source term",
|
||||||
|
"glossaries.detail.targetPlaceholder": "target term",
|
||||||
|
"glossaries.detail.csvTitle": "CSV",
|
||||||
|
"glossaries.detail.csvDesc": "Export your terms as CSV or import new ones (replaces the current list).",
|
||||||
|
"glossaries.detail.export": "Export",
|
||||||
|
"glossaries.detail.import": "Import",
|
||||||
|
"glossaries.detail.dangerTitle": "Danger zone",
|
||||||
|
"glossaries.detail.dangerDesc": "Deletion is permanent. All associated terms will be lost.",
|
||||||
|
"glossaries.detail.deleteGlossary": "Delete this glossary",
|
||||||
|
"glossaries.detail.confirmDelete": "Confirm deletion?",
|
||||||
|
"glossaries.detail.confirm": "Confirm",
|
||||||
|
"glossaries.detail.cancel": "Cancel",
|
||||||
|
"glossaries.detail.notFoundTitle": "Glossary not found",
|
||||||
|
"glossaries.detail.notFoundDesc": "This glossary does not exist or you don't have access to it.",
|
||||||
|
"glossaries.detail.maxTermsTitle": "Limit reached",
|
||||||
|
"glossaries.detail.maxTermsDesc": "Maximum {max} terms per glossary.",
|
||||||
|
"glossaries.detail.importEmptyTitle": "Empty file",
|
||||||
|
"glossaries.detail.importEmptyDesc": "No terms detected in this file.",
|
||||||
|
"glossaries.detail.importedTitle": "Imported",
|
||||||
|
"glossaries.detail.importedDesc": "{count} terms imported.",
|
||||||
|
"glossaries.detail.importErrorTitle": "Read error",
|
||||||
|
"glossaries.detail.importErrorDesc": "Unable to read the file.",
|
||||||
"apiKeys.webhook.title": "Intégration Webhook",
|
"apiKeys.webhook.title": "Intégration Webhook",
|
||||||
"apiKeys.webhook.descriptionBefore": "Passez un paramètre ",
|
"apiKeys.webhook.descriptionBefore": "Passez un paramètre ",
|
||||||
"apiKeys.webhook.descriptionAfter": " pour recevoir une requête POST lorsque votre traduction est terminée.",
|
"apiKeys.webhook.descriptionAfter": " pour recevoir une requête POST lorsque votre traduction est terminée.",
|
||||||
@@ -7594,6 +7914,46 @@ const messages: Record<Locale, Record<string, string>> = {
|
|||||||
"glossaries.badge.otherTarget": "Autre cible",
|
"glossaries.badge.otherTarget": "Autre cible",
|
||||||
"glossaries.card.editTerms": "Modifier les termes",
|
"glossaries.card.editTerms": "Modifier les termes",
|
||||||
"glossaries.card.delete": "Supprimer",
|
"glossaries.card.delete": "Supprimer",
|
||||||
|
"glossaries.grid.searchPlaceholder": "Search a glossary…",
|
||||||
|
"glossaries.grid.noResults": "No results for this search.",
|
||||||
|
"glossaries.detail.backToList": "Back to glossaries",
|
||||||
|
"glossaries.detail.save": "Save",
|
||||||
|
"glossaries.detail.savedTitle": "Saved",
|
||||||
|
"glossaries.detail.savedDesc": "The glossary has been updated.",
|
||||||
|
"glossaries.detail.settingsTitle": "Settings",
|
||||||
|
"glossaries.detail.sourceLang": "Source language",
|
||||||
|
"glossaries.detail.targetLang": "Target language",
|
||||||
|
"glossaries.detail.termsTitle": "Terms",
|
||||||
|
"glossaries.detail.terms": "terms",
|
||||||
|
"glossaries.detail.searchTerms": "Filter…",
|
||||||
|
"glossaries.detail.noTerms": "No terms yet.",
|
||||||
|
"glossaries.detail.addFirstTerm": "Add the first term",
|
||||||
|
"glossaries.detail.addTerm": "Add a term",
|
||||||
|
"glossaries.detail.maxReached": "Maximum limit reached",
|
||||||
|
"glossaries.detail.source": "Source",
|
||||||
|
"glossaries.detail.target": "Target",
|
||||||
|
"glossaries.detail.sourcePlaceholder": "source term",
|
||||||
|
"glossaries.detail.targetPlaceholder": "target term",
|
||||||
|
"glossaries.detail.csvTitle": "CSV",
|
||||||
|
"glossaries.detail.csvDesc": "Export your terms as CSV or import new ones (replaces the current list).",
|
||||||
|
"glossaries.detail.export": "Export",
|
||||||
|
"glossaries.detail.import": "Import",
|
||||||
|
"glossaries.detail.dangerTitle": "Danger zone",
|
||||||
|
"glossaries.detail.dangerDesc": "Deletion is permanent. All associated terms will be lost.",
|
||||||
|
"glossaries.detail.deleteGlossary": "Delete this glossary",
|
||||||
|
"glossaries.detail.confirmDelete": "Confirm deletion?",
|
||||||
|
"glossaries.detail.confirm": "Confirm",
|
||||||
|
"glossaries.detail.cancel": "Cancel",
|
||||||
|
"glossaries.detail.notFoundTitle": "Glossary not found",
|
||||||
|
"glossaries.detail.notFoundDesc": "This glossary does not exist or you don't have access to it.",
|
||||||
|
"glossaries.detail.maxTermsTitle": "Limit reached",
|
||||||
|
"glossaries.detail.maxTermsDesc": "Maximum {max} terms per glossary.",
|
||||||
|
"glossaries.detail.importEmptyTitle": "Empty file",
|
||||||
|
"glossaries.detail.importEmptyDesc": "No terms detected in this file.",
|
||||||
|
"glossaries.detail.importedTitle": "Imported",
|
||||||
|
"glossaries.detail.importedDesc": "{count} terms imported.",
|
||||||
|
"glossaries.detail.importErrorTitle": "Read error",
|
||||||
|
"glossaries.detail.importErrorDesc": "Unable to read the file.",
|
||||||
"apiKeys.webhook.title": "Intégration Webhook",
|
"apiKeys.webhook.title": "Intégration Webhook",
|
||||||
"apiKeys.webhook.descriptionBefore": "Passez un paramètre ",
|
"apiKeys.webhook.descriptionBefore": "Passez un paramètre ",
|
||||||
"apiKeys.webhook.descriptionAfter": " pour recevoir une requête POST lorsque votre traduction est terminée.",
|
"apiKeys.webhook.descriptionAfter": " pour recevoir une requête POST lorsque votre traduction est terminée.",
|
||||||
@@ -8478,6 +8838,46 @@ const messages: Record<Locale, Record<string, string>> = {
|
|||||||
"glossaries.badge.otherTarget": "Autre cible",
|
"glossaries.badge.otherTarget": "Autre cible",
|
||||||
"glossaries.card.editTerms": "Modifier les termes",
|
"glossaries.card.editTerms": "Modifier les termes",
|
||||||
"glossaries.card.delete": "Supprimer",
|
"glossaries.card.delete": "Supprimer",
|
||||||
|
"glossaries.grid.searchPlaceholder": "Search a glossary…",
|
||||||
|
"glossaries.grid.noResults": "No results for this search.",
|
||||||
|
"glossaries.detail.backToList": "Back to glossaries",
|
||||||
|
"glossaries.detail.save": "Save",
|
||||||
|
"glossaries.detail.savedTitle": "Saved",
|
||||||
|
"glossaries.detail.savedDesc": "The glossary has been updated.",
|
||||||
|
"glossaries.detail.settingsTitle": "Settings",
|
||||||
|
"glossaries.detail.sourceLang": "Source language",
|
||||||
|
"glossaries.detail.targetLang": "Target language",
|
||||||
|
"glossaries.detail.termsTitle": "Terms",
|
||||||
|
"glossaries.detail.terms": "terms",
|
||||||
|
"glossaries.detail.searchTerms": "Filter…",
|
||||||
|
"glossaries.detail.noTerms": "No terms yet.",
|
||||||
|
"glossaries.detail.addFirstTerm": "Add the first term",
|
||||||
|
"glossaries.detail.addTerm": "Add a term",
|
||||||
|
"glossaries.detail.maxReached": "Maximum limit reached",
|
||||||
|
"glossaries.detail.source": "Source",
|
||||||
|
"glossaries.detail.target": "Target",
|
||||||
|
"glossaries.detail.sourcePlaceholder": "source term",
|
||||||
|
"glossaries.detail.targetPlaceholder": "target term",
|
||||||
|
"glossaries.detail.csvTitle": "CSV",
|
||||||
|
"glossaries.detail.csvDesc": "Export your terms as CSV or import new ones (replaces the current list).",
|
||||||
|
"glossaries.detail.export": "Export",
|
||||||
|
"glossaries.detail.import": "Import",
|
||||||
|
"glossaries.detail.dangerTitle": "Danger zone",
|
||||||
|
"glossaries.detail.dangerDesc": "Deletion is permanent. All associated terms will be lost.",
|
||||||
|
"glossaries.detail.deleteGlossary": "Delete this glossary",
|
||||||
|
"glossaries.detail.confirmDelete": "Confirm deletion?",
|
||||||
|
"glossaries.detail.confirm": "Confirm",
|
||||||
|
"glossaries.detail.cancel": "Cancel",
|
||||||
|
"glossaries.detail.notFoundTitle": "Glossary not found",
|
||||||
|
"glossaries.detail.notFoundDesc": "This glossary does not exist or you don't have access to it.",
|
||||||
|
"glossaries.detail.maxTermsTitle": "Limit reached",
|
||||||
|
"glossaries.detail.maxTermsDesc": "Maximum {max} terms per glossary.",
|
||||||
|
"glossaries.detail.importEmptyTitle": "Empty file",
|
||||||
|
"glossaries.detail.importEmptyDesc": "No terms detected in this file.",
|
||||||
|
"glossaries.detail.importedTitle": "Imported",
|
||||||
|
"glossaries.detail.importedDesc": "{count} terms imported.",
|
||||||
|
"glossaries.detail.importErrorTitle": "Read error",
|
||||||
|
"glossaries.detail.importErrorDesc": "Unable to read the file.",
|
||||||
"apiKeys.webhook.title": "Intégration Webhook",
|
"apiKeys.webhook.title": "Intégration Webhook",
|
||||||
"apiKeys.webhook.descriptionBefore": "Passez un paramètre ",
|
"apiKeys.webhook.descriptionBefore": "Passez un paramètre ",
|
||||||
"apiKeys.webhook.descriptionAfter": " pour recevoir une requête POST lorsque votre traduction est terminée.",
|
"apiKeys.webhook.descriptionAfter": " pour recevoir une requête POST lorsque votre traduction est terminée.",
|
||||||
@@ -9362,6 +9762,46 @@ const messages: Record<Locale, Record<string, string>> = {
|
|||||||
"glossaries.badge.otherTarget": "Autre cible",
|
"glossaries.badge.otherTarget": "Autre cible",
|
||||||
"glossaries.card.editTerms": "Modifier les termes",
|
"glossaries.card.editTerms": "Modifier les termes",
|
||||||
"glossaries.card.delete": "Supprimer",
|
"glossaries.card.delete": "Supprimer",
|
||||||
|
"glossaries.grid.searchPlaceholder": "Search a glossary…",
|
||||||
|
"glossaries.grid.noResults": "No results for this search.",
|
||||||
|
"glossaries.detail.backToList": "Back to glossaries",
|
||||||
|
"glossaries.detail.save": "Save",
|
||||||
|
"glossaries.detail.savedTitle": "Saved",
|
||||||
|
"glossaries.detail.savedDesc": "The glossary has been updated.",
|
||||||
|
"glossaries.detail.settingsTitle": "Settings",
|
||||||
|
"glossaries.detail.sourceLang": "Source language",
|
||||||
|
"glossaries.detail.targetLang": "Target language",
|
||||||
|
"glossaries.detail.termsTitle": "Terms",
|
||||||
|
"glossaries.detail.terms": "terms",
|
||||||
|
"glossaries.detail.searchTerms": "Filter…",
|
||||||
|
"glossaries.detail.noTerms": "No terms yet.",
|
||||||
|
"glossaries.detail.addFirstTerm": "Add the first term",
|
||||||
|
"glossaries.detail.addTerm": "Add a term",
|
||||||
|
"glossaries.detail.maxReached": "Maximum limit reached",
|
||||||
|
"glossaries.detail.source": "Source",
|
||||||
|
"glossaries.detail.target": "Target",
|
||||||
|
"glossaries.detail.sourcePlaceholder": "source term",
|
||||||
|
"glossaries.detail.targetPlaceholder": "target term",
|
||||||
|
"glossaries.detail.csvTitle": "CSV",
|
||||||
|
"glossaries.detail.csvDesc": "Export your terms as CSV or import new ones (replaces the current list).",
|
||||||
|
"glossaries.detail.export": "Export",
|
||||||
|
"glossaries.detail.import": "Import",
|
||||||
|
"glossaries.detail.dangerTitle": "Danger zone",
|
||||||
|
"glossaries.detail.dangerDesc": "Deletion is permanent. All associated terms will be lost.",
|
||||||
|
"glossaries.detail.deleteGlossary": "Delete this glossary",
|
||||||
|
"glossaries.detail.confirmDelete": "Confirm deletion?",
|
||||||
|
"glossaries.detail.confirm": "Confirm",
|
||||||
|
"glossaries.detail.cancel": "Cancel",
|
||||||
|
"glossaries.detail.notFoundTitle": "Glossary not found",
|
||||||
|
"glossaries.detail.notFoundDesc": "This glossary does not exist or you don't have access to it.",
|
||||||
|
"glossaries.detail.maxTermsTitle": "Limit reached",
|
||||||
|
"glossaries.detail.maxTermsDesc": "Maximum {max} terms per glossary.",
|
||||||
|
"glossaries.detail.importEmptyTitle": "Empty file",
|
||||||
|
"glossaries.detail.importEmptyDesc": "No terms detected in this file.",
|
||||||
|
"glossaries.detail.importedTitle": "Imported",
|
||||||
|
"glossaries.detail.importedDesc": "{count} terms imported.",
|
||||||
|
"glossaries.detail.importErrorTitle": "Read error",
|
||||||
|
"glossaries.detail.importErrorDesc": "Unable to read the file.",
|
||||||
"apiKeys.webhook.title": "Intégration Webhook",
|
"apiKeys.webhook.title": "Intégration Webhook",
|
||||||
"apiKeys.webhook.descriptionBefore": "Passez un paramètre ",
|
"apiKeys.webhook.descriptionBefore": "Passez un paramètre ",
|
||||||
"apiKeys.webhook.descriptionAfter": " pour recevoir une requête POST lorsque votre traduction est terminée.",
|
"apiKeys.webhook.descriptionAfter": " pour recevoir une requête POST lorsque votre traduction est terminée.",
|
||||||
@@ -10204,6 +10644,46 @@ const messages: Record<Locale, Record<string, string>> = {
|
|||||||
"glossaries.badge.otherTarget": "Autre cible",
|
"glossaries.badge.otherTarget": "Autre cible",
|
||||||
"glossaries.card.editTerms": "Modifier les termes",
|
"glossaries.card.editTerms": "Modifier les termes",
|
||||||
"glossaries.card.delete": "Supprimer",
|
"glossaries.card.delete": "Supprimer",
|
||||||
|
"glossaries.grid.searchPlaceholder": "Search a glossary…",
|
||||||
|
"glossaries.grid.noResults": "No results for this search.",
|
||||||
|
"glossaries.detail.backToList": "Back to glossaries",
|
||||||
|
"glossaries.detail.save": "Save",
|
||||||
|
"glossaries.detail.savedTitle": "Saved",
|
||||||
|
"glossaries.detail.savedDesc": "The glossary has been updated.",
|
||||||
|
"glossaries.detail.settingsTitle": "Settings",
|
||||||
|
"glossaries.detail.sourceLang": "Source language",
|
||||||
|
"glossaries.detail.targetLang": "Target language",
|
||||||
|
"glossaries.detail.termsTitle": "Terms",
|
||||||
|
"glossaries.detail.terms": "terms",
|
||||||
|
"glossaries.detail.searchTerms": "Filter…",
|
||||||
|
"glossaries.detail.noTerms": "No terms yet.",
|
||||||
|
"glossaries.detail.addFirstTerm": "Add the first term",
|
||||||
|
"glossaries.detail.addTerm": "Add a term",
|
||||||
|
"glossaries.detail.maxReached": "Maximum limit reached",
|
||||||
|
"glossaries.detail.source": "Source",
|
||||||
|
"glossaries.detail.target": "Target",
|
||||||
|
"glossaries.detail.sourcePlaceholder": "source term",
|
||||||
|
"glossaries.detail.targetPlaceholder": "target term",
|
||||||
|
"glossaries.detail.csvTitle": "CSV",
|
||||||
|
"glossaries.detail.csvDesc": "Export your terms as CSV or import new ones (replaces the current list).",
|
||||||
|
"glossaries.detail.export": "Export",
|
||||||
|
"glossaries.detail.import": "Import",
|
||||||
|
"glossaries.detail.dangerTitle": "Danger zone",
|
||||||
|
"glossaries.detail.dangerDesc": "Deletion is permanent. All associated terms will be lost.",
|
||||||
|
"glossaries.detail.deleteGlossary": "Delete this glossary",
|
||||||
|
"glossaries.detail.confirmDelete": "Confirm deletion?",
|
||||||
|
"glossaries.detail.confirm": "Confirm",
|
||||||
|
"glossaries.detail.cancel": "Cancel",
|
||||||
|
"glossaries.detail.notFoundTitle": "Glossary not found",
|
||||||
|
"glossaries.detail.notFoundDesc": "This glossary does not exist or you don't have access to it.",
|
||||||
|
"glossaries.detail.maxTermsTitle": "Limit reached",
|
||||||
|
"glossaries.detail.maxTermsDesc": "Maximum {max} terms per glossary.",
|
||||||
|
"glossaries.detail.importEmptyTitle": "Empty file",
|
||||||
|
"glossaries.detail.importEmptyDesc": "No terms detected in this file.",
|
||||||
|
"glossaries.detail.importedTitle": "Imported",
|
||||||
|
"glossaries.detail.importedDesc": "{count} terms imported.",
|
||||||
|
"glossaries.detail.importErrorTitle": "Read error",
|
||||||
|
"glossaries.detail.importErrorDesc": "Unable to read the file.",
|
||||||
"apiKeys.webhook.title": "Intégration Webhook",
|
"apiKeys.webhook.title": "Intégration Webhook",
|
||||||
"apiKeys.webhook.descriptionBefore": "Passez un paramètre ",
|
"apiKeys.webhook.descriptionBefore": "Passez un paramètre ",
|
||||||
"apiKeys.webhook.descriptionAfter": " pour recevoir une requête POST lorsque votre traduction est terminée.",
|
"apiKeys.webhook.descriptionAfter": " pour recevoir une requête POST lorsque votre traduction est terminée.",
|
||||||
@@ -11055,6 +11535,46 @@ const messages: Record<Locale, Record<string, string>> = {
|
|||||||
"glossaries.badge.otherTarget": "Autre cible",
|
"glossaries.badge.otherTarget": "Autre cible",
|
||||||
"glossaries.card.editTerms": "Modifier les termes",
|
"glossaries.card.editTerms": "Modifier les termes",
|
||||||
"glossaries.card.delete": "Supprimer",
|
"glossaries.card.delete": "Supprimer",
|
||||||
|
"glossaries.grid.searchPlaceholder": "Search a glossary…",
|
||||||
|
"glossaries.grid.noResults": "No results for this search.",
|
||||||
|
"glossaries.detail.backToList": "Back to glossaries",
|
||||||
|
"glossaries.detail.save": "Save",
|
||||||
|
"glossaries.detail.savedTitle": "Saved",
|
||||||
|
"glossaries.detail.savedDesc": "The glossary has been updated.",
|
||||||
|
"glossaries.detail.settingsTitle": "Settings",
|
||||||
|
"glossaries.detail.sourceLang": "Source language",
|
||||||
|
"glossaries.detail.targetLang": "Target language",
|
||||||
|
"glossaries.detail.termsTitle": "Terms",
|
||||||
|
"glossaries.detail.terms": "terms",
|
||||||
|
"glossaries.detail.searchTerms": "Filter…",
|
||||||
|
"glossaries.detail.noTerms": "No terms yet.",
|
||||||
|
"glossaries.detail.addFirstTerm": "Add the first term",
|
||||||
|
"glossaries.detail.addTerm": "Add a term",
|
||||||
|
"glossaries.detail.maxReached": "Maximum limit reached",
|
||||||
|
"glossaries.detail.source": "Source",
|
||||||
|
"glossaries.detail.target": "Target",
|
||||||
|
"glossaries.detail.sourcePlaceholder": "source term",
|
||||||
|
"glossaries.detail.targetPlaceholder": "target term",
|
||||||
|
"glossaries.detail.csvTitle": "CSV",
|
||||||
|
"glossaries.detail.csvDesc": "Export your terms as CSV or import new ones (replaces the current list).",
|
||||||
|
"glossaries.detail.export": "Export",
|
||||||
|
"glossaries.detail.import": "Import",
|
||||||
|
"glossaries.detail.dangerTitle": "Danger zone",
|
||||||
|
"glossaries.detail.dangerDesc": "Deletion is permanent. All associated terms will be lost.",
|
||||||
|
"glossaries.detail.deleteGlossary": "Delete this glossary",
|
||||||
|
"glossaries.detail.confirmDelete": "Confirm deletion?",
|
||||||
|
"glossaries.detail.confirm": "Confirm",
|
||||||
|
"glossaries.detail.cancel": "Cancel",
|
||||||
|
"glossaries.detail.notFoundTitle": "Glossary not found",
|
||||||
|
"glossaries.detail.notFoundDesc": "This glossary does not exist or you don't have access to it.",
|
||||||
|
"glossaries.detail.maxTermsTitle": "Limit reached",
|
||||||
|
"glossaries.detail.maxTermsDesc": "Maximum {max} terms per glossary.",
|
||||||
|
"glossaries.detail.importEmptyTitle": "Empty file",
|
||||||
|
"glossaries.detail.importEmptyDesc": "No terms detected in this file.",
|
||||||
|
"glossaries.detail.importedTitle": "Imported",
|
||||||
|
"glossaries.detail.importedDesc": "{count} terms imported.",
|
||||||
|
"glossaries.detail.importErrorTitle": "Read error",
|
||||||
|
"glossaries.detail.importErrorDesc": "Unable to read the file.",
|
||||||
"apiKeys.webhook.title": "Intégration Webhook",
|
"apiKeys.webhook.title": "Intégration Webhook",
|
||||||
"apiKeys.webhook.descriptionBefore": "Passez un paramètre ",
|
"apiKeys.webhook.descriptionBefore": "Passez un paramètre ",
|
||||||
"apiKeys.webhook.descriptionAfter": " pour recevoir une requête POST lorsque votre traduction est terminée.",
|
"apiKeys.webhook.descriptionAfter": " pour recevoir une requête POST lorsque votre traduction est terminée.",
|
||||||
|
|||||||
5
main.py
5
main.py
@@ -378,6 +378,11 @@ _CORS_EXTRA_DEV_ORIGINS = [
|
|||||||
|
|
||||||
_cors_env = config.CORS_ORIGINS_RAW
|
_cors_env = config.CORS_ORIGINS_RAW
|
||||||
if _cors_env == "*" or not _cors_env:
|
if _cors_env == "*" or not _cors_env:
|
||||||
|
if config.ENV == "production":
|
||||||
|
logger.error(
|
||||||
|
"CORS_ORIGINS must be configured with specific domains in production! Wildcard '*' or empty values are forbidden."
|
||||||
|
)
|
||||||
|
sys.exit("CORS configuration error: Wildcard '*' or empty CORS_ORIGINS is forbidden in production.")
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"CORS_ORIGINS not properly configured. Using permissive settings for development only!"
|
"CORS_ORIGINS not properly configured. Using permissive settings for development only!"
|
||||||
)
|
)
|
||||||
|
|||||||
435
scripts/backup-database.sh
Executable file
435
scripts/backup-database.sh
Executable file
@@ -0,0 +1,435 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# ==============================================================================
|
||||||
|
# Wordly.art - Unified Database Backup & Restore Script
|
||||||
|
# ==============================================================================
|
||||||
|
# Sourced dynamically from .env. Supports PostgreSQL (Docker) and SQLite (Local).
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# ./backup-database.sh # Perform scheduled backup (daily/weekly/monthly)
|
||||||
|
# ./backup-database.sh --full # Run backup cycle with retention cleanup
|
||||||
|
# ./backup-database.sh --restore FILE.gz # Restore database from a specific archive
|
||||||
|
# ==============================================================================
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# SCRIPT DIRECTORY AND CONSTANTS
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
TIMESTAMP=$(date +"%Y%m%d_%H%M%S")
|
||||||
|
DAY_OF_WEEK=$(date +"%u") # 1=Mon, 7=Sun
|
||||||
|
DAY_OF_MONTH=$(date +"%d")
|
||||||
|
|
||||||
|
# Colors for terminal output
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
NC='\033[0m'
|
||||||
|
|
||||||
|
log() { echo -e "[Backup ${TIMESTAMP}] $1"; }
|
||||||
|
log_success() { echo -e "[Backup ${TIMESTAMP}] ${GREEN}$1${NC}"; }
|
||||||
|
log_warning() { echo -e "[Backup ${TIMESTAMP}] ${YELLOW}WARNING: $1${NC}"; }
|
||||||
|
log_error() { echo -e "[Backup ${TIMESTAMP}] ${RED}ERROR: $1${NC}"; }
|
||||||
|
|
||||||
|
# ==============================================================================
|
||||||
|
# 1. LOAD CONFIGURATION
|
||||||
|
# ==============================================================================
|
||||||
|
ENV_FILE="${SCRIPT_DIR}/../.env"
|
||||||
|
if [ -f "${ENV_FILE}" ]; then
|
||||||
|
set -a
|
||||||
|
source "${ENV_FILE}"
|
||||||
|
set +a
|
||||||
|
log "Loaded environment configuration from .env"
|
||||||
|
else
|
||||||
|
log_warning ".env file not found at ${ENV_FILE}. Using system environment variables."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Configuration Fallbacks
|
||||||
|
BACKUP_DIR="${BACKUP_DIR:-${SCRIPT_DIR}/../backups}"
|
||||||
|
DAILY_RETENTION=${DAILY_RETENTION:-7}
|
||||||
|
WEEKLY_RETENTION=${WEEKLY_RETENTION:-4}
|
||||||
|
MONTHLY_RETENTION=${MONTHLY_RETENTION:-6}
|
||||||
|
NOTIFICATION_WEBHOOK="${NOTIFICATION_WEBHOOK:-}"
|
||||||
|
|
||||||
|
# Parse DB configuration
|
||||||
|
DATABASE_URL="${DATABASE_URL:-}"
|
||||||
|
DB_TYPE="sqlite"
|
||||||
|
if [[ "${DATABASE_URL}" =~ ^postgres ]]; then
|
||||||
|
DB_TYPE="postgres"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# SQLite-specific configs
|
||||||
|
SQLITE_PATH="${SQLITE_PATH:-data/translate.db}"
|
||||||
|
# Ensure SQLite path is absolute
|
||||||
|
if [[ ! "$SQLITE_PATH" =~ ^/ ]] && [[ "$SQLITE_PATH" =~ ^[a-zA-Z]: ]]; then
|
||||||
|
# Already absolute (Windows path format)
|
||||||
|
true
|
||||||
|
else
|
||||||
|
# Convert to absolute path relative to project root
|
||||||
|
SQLITE_PATH="${SCRIPT_DIR}/../${SQLITE_PATH}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# PostgreSQL-specific configs
|
||||||
|
POSTGRES_CONTAINER="${POSTGRES_CONTAINER:-wordly-postgres}"
|
||||||
|
POSTGRES_USER="${POSTGRES_USER:-translate}"
|
||||||
|
POSTGRES_DB="${POSTGRES_DB:-translate_db}"
|
||||||
|
POSTGRES_PASSWORD="${POSTGRES_PASSWORD:-}"
|
||||||
|
|
||||||
|
# Resolve Python command for SQLite backups
|
||||||
|
PYTHON_CMD="python"
|
||||||
|
if [ -f "${SCRIPT_DIR}/../.venv/bin/python" ]; then
|
||||||
|
PYTHON_CMD="${SCRIPT_DIR}/../.venv/bin/python"
|
||||||
|
elif [ -f "${SCRIPT_DIR}/../.venv/Scripts/python" ]; then
|
||||||
|
PYTHON_CMD="${SCRIPT_DIR}/../.venv/Scripts/python"
|
||||||
|
elif [ -f "${SCRIPT_DIR}/../.venv/Scripts/python.exe" ]; then
|
||||||
|
PYTHON_CMD="${SCRIPT_DIR}/../.venv/Scripts/python.exe"
|
||||||
|
elif command -v python3 &>/dev/null; then
|
||||||
|
PYTHON_CMD="python3"
|
||||||
|
elif command -v python &>/dev/null; then
|
||||||
|
PYTHON_CMD="python"
|
||||||
|
elif command -v py &>/dev/null; then
|
||||||
|
PYTHON_CMD="py"
|
||||||
|
else
|
||||||
|
log_warning "Could not find a Python installation on PATH or in .venv. SQLite backups may fail."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ==============================================================================
|
||||||
|
# 2. VALIDATION AND PREPARATION
|
||||||
|
# ==============================================================================
|
||||||
|
check_prerequisites() {
|
||||||
|
mkdir -p "${BACKUP_DIR}/daily"
|
||||||
|
mkdir -p "${BACKUP_DIR}/weekly"
|
||||||
|
mkdir -p "${BACKUP_DIR}/monthly"
|
||||||
|
|
||||||
|
if [ "${DB_TYPE}" = "postgres" ]; then
|
||||||
|
# Check Docker PostgreSQL container
|
||||||
|
if ! command -v docker &>/dev/null; then
|
||||||
|
log_error "Docker is not installed or not in PATH."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if ! docker ps --format '{{.Names}}' | grep -q "^${POSTGRES_CONTAINER}$"; then
|
||||||
|
log_error "PostgreSQL container '${POSTGRES_CONTAINER}' is not running."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [ -z "${POSTGRES_PASSWORD}" ]; then
|
||||||
|
log_error "POSTGRES_PASSWORD is not set in environment or .env file."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
# Check SQLite db file
|
||||||
|
local db_dir
|
||||||
|
db_dir=$(dirname "${SQLITE_PATH}")
|
||||||
|
mkdir -p "${db_dir}"
|
||||||
|
if [ ! -f "${SQLITE_PATH}" ]; then
|
||||||
|
log_warning "SQLite database file not found at '${SQLITE_PATH}'. It will be created on first write."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
send_notification() {
|
||||||
|
local message="$1"
|
||||||
|
if [ -n "${NOTIFICATION_WEBHOOK}" ]; then
|
||||||
|
curl -s -X POST "${NOTIFICATION_WEBHOOK}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{\"text\": \"${message}\"}" >/dev/null 2>&1 || true
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# ==============================================================================
|
||||||
|
# 3. BACKUP OPERATION
|
||||||
|
# ==============================================================================
|
||||||
|
create_backup() {
|
||||||
|
local backup_name
|
||||||
|
local dest_path
|
||||||
|
|
||||||
|
if [ "${DB_TYPE}" = "postgres" ]; then
|
||||||
|
backup_name="wordly_postgres_${TIMESTAMP}.sql.gz"
|
||||||
|
dest_path="${BACKUP_DIR}/daily/${backup_name}"
|
||||||
|
log "Starting PostgreSQL backup for database '${POSTGRES_DB}'..."
|
||||||
|
|
||||||
|
# Dump using pg_dump inside Docker container
|
||||||
|
if ! docker exec -e PGPASSWORD="${POSTGRES_PASSWORD}" "${POSTGRES_CONTAINER}" pg_dump \
|
||||||
|
-U "${POSTGRES_USER}" \
|
||||||
|
-d "${POSTGRES_DB}" \
|
||||||
|
--format=custom \
|
||||||
|
--no-owner \
|
||||||
|
--no-acl \
|
||||||
|
2>/dev/null | gzip > "${dest_path}"; then
|
||||||
|
log_error "PostgreSQL pg_dump failed!"
|
||||||
|
send_notification "Wordly DB Backup FAILED: PostgreSQL dump error."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
backup_name="wordly_sqlite_${TIMESTAMP}.db.gz"
|
||||||
|
dest_path="${BACKUP_DIR}/daily/${backup_name}"
|
||||||
|
log "Starting SQLite backup for file '${SQLITE_PATH}'..."
|
||||||
|
|
||||||
|
# If the database file does not exist yet (fresh install), touch it to allow backup
|
||||||
|
if [ ! -f "${SQLITE_PATH}" ]; then
|
||||||
|
touch "${SQLITE_PATH}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Safe online backup using Python
|
||||||
|
local temp_db="${BACKUP_DIR}/daily/temp_${TIMESTAMP}.db"
|
||||||
|
if ! "${PYTHON_CMD}" -c "
|
||||||
|
import sqlite3, sys, os, re
|
||||||
|
try:
|
||||||
|
src_raw, dst_raw = sys.argv[1], sys.argv[2]
|
||||||
|
if sys.platform == 'win32':
|
||||||
|
m_src = re.match(r'^/(?:mnt/)?([a-zA-Z])/(.*)', src_raw)
|
||||||
|
if m_src:
|
||||||
|
src_raw = m_src.group(1) + ':/' + m_src.group(2)
|
||||||
|
m_dst = re.match(r'^/(?:mnt/)?([a-zA-Z])/(.*)', dst_raw)
|
||||||
|
if m_dst:
|
||||||
|
dst_raw = m_dst.group(1) + ':/' + m_dst.group(2)
|
||||||
|
src = sqlite3.connect(os.path.abspath(src_raw))
|
||||||
|
dst = sqlite3.connect(os.path.abspath(dst_raw))
|
||||||
|
with dst:
|
||||||
|
src.backup(dst)
|
||||||
|
src.close()
|
||||||
|
dst.close()
|
||||||
|
except Exception as e:
|
||||||
|
print(f'Python SQLite error: {e}', file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
" "${SQLITE_PATH}" "${temp_db}"; then
|
||||||
|
log_error "SQLite online backup failed! Checking if DB is locked."
|
||||||
|
send_notification "Wordly DB Backup FAILED: SQLite online backup script error."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Compress the temporary copy
|
||||||
|
gzip -c "${temp_db}" > "${dest_path}"
|
||||||
|
rm -f "${temp_db}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Verify backup size and integrity
|
||||||
|
if [ -f "${dest_path}" ] && [ -s "${dest_path}" ]; then
|
||||||
|
local size
|
||||||
|
size=$(du -h "${dest_path}" | cut -f1)
|
||||||
|
log_success "Backup file created successfully: ${backup_name} (${size})"
|
||||||
|
|
||||||
|
# Verify archive integrity
|
||||||
|
if gzip -t "${dest_path}" 2>/dev/null; then
|
||||||
|
log_success "Backup integrity verification passed."
|
||||||
|
else
|
||||||
|
log_error "Backup archive integrity check FAILED!"
|
||||||
|
send_notification "Wordly DB Backup WARNING: Integrity check failed for ${backup_name}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Distribute to weekly/monthly folders
|
||||||
|
if [ "${DAY_OF_WEEK}" = "7" ]; then
|
||||||
|
cp "${dest_path}" "${BACKUP_DIR}/weekly/${backup_name}"
|
||||||
|
log "Weekly backup archived."
|
||||||
|
fi
|
||||||
|
if [ "${DAY_OF_MONTH}" = "01" ]; then
|
||||||
|
cp "${dest_path}" "${BACKUP_DIR}/monthly/${backup_name}"
|
||||||
|
log "Monthly backup archived."
|
||||||
|
fi
|
||||||
|
|
||||||
|
send_notification "Wordly DB Backup SUCCESS: ${backup_name} (${size})"
|
||||||
|
else
|
||||||
|
log_error "Backup failed! Dest file is empty or missing."
|
||||||
|
send_notification "Wordly DB Backup FAILED: Destination archive is empty."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# ==============================================================================
|
||||||
|
# 4. ROTATION & CLEANUP
|
||||||
|
# ==============================================================================
|
||||||
|
cleanup_old_backups() {
|
||||||
|
log "Running rotation cleanup policy..."
|
||||||
|
|
||||||
|
# Daily backups retention
|
||||||
|
local daily_count
|
||||||
|
daily_count=$(ls -1 "${BACKUP_DIR}/daily/" 2>/dev/null | wc -l || echo 0)
|
||||||
|
if [ "${daily_count}" -gt "${DAILY_RETENTION}" ]; then
|
||||||
|
ls -1t "${BACKUP_DIR}/daily/" | tail -n +$((DAILY_RETENTION + 1)) | while read -r f; do
|
||||||
|
rm -f "${BACKUP_DIR}/daily/${f}"
|
||||||
|
log " Purged old daily backup: ${f}"
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Weekly backups retention
|
||||||
|
local weekly_count
|
||||||
|
weekly_count=$(ls -1 "${BACKUP_DIR}/weekly/" 2>/dev/null | wc -l || echo 0)
|
||||||
|
if [ "${weekly_count}" -gt "${WEEKLY_RETENTION}" ]; then
|
||||||
|
ls -1t "${BACKUP_DIR}/weekly/" | tail -n +$((WEEKLY_RETENTION + 1)) | while read -r f; do
|
||||||
|
rm -f "${BACKUP_DIR}/weekly/${f}"
|
||||||
|
log " Purged old weekly backup: ${f}"
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Monthly backups retention
|
||||||
|
local monthly_count
|
||||||
|
monthly_count=$(ls -1 "${BACKUP_DIR}/monthly/" 2>/dev/null | wc -l || echo 0)
|
||||||
|
if [ "${monthly_count}" -gt "${MONTHLY_RETENTION}" ]; then
|
||||||
|
ls -1t "${BACKUP_DIR}/monthly/" | tail -n +$((MONTHLY_RETENTION + 1)) | while read -r f; do
|
||||||
|
rm -f "${BACKUP_DIR}/monthly/${f}"
|
||||||
|
log " Purged old monthly backup: ${f}"
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
log_success "Backup rotation cleanup complete."
|
||||||
|
}
|
||||||
|
|
||||||
|
# ==============================================================================
|
||||||
|
# 5. RESTORE OPERATION
|
||||||
|
# ==============================================================================
|
||||||
|
restore_backup() {
|
||||||
|
local archive_name="$1"
|
||||||
|
|
||||||
|
if [ -z "${archive_name}" ]; then
|
||||||
|
log_error "No restore file specified."
|
||||||
|
echo "Usage: $0 --restore <archive_filename_only>"
|
||||||
|
echo "Available backups in ${BACKUP_DIR}:"
|
||||||
|
echo "--- Daily ---"
|
||||||
|
ls -lh "${BACKUP_DIR}/daily/" 2>/dev/null || echo " (empty)"
|
||||||
|
echo "--- Weekly ---"
|
||||||
|
ls -lh "${BACKUP_DIR}/weekly/" 2>/dev/null || echo " (empty)"
|
||||||
|
echo "--- Monthly ---"
|
||||||
|
ls -lh "${BACKUP_DIR}/monthly/" 2>/dev/null || echo " (empty)"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Locate archive in backup directories
|
||||||
|
local archive_path=""
|
||||||
|
for subdir in daily weekly monthly; do
|
||||||
|
if [ -f "${BACKUP_DIR}/${subdir}/${archive_name}" ]; then
|
||||||
|
archive_path="${BACKUP_DIR}/${subdir}/${archive_name}"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ -z "${archive_path}" ]; then
|
||||||
|
# Try if it's a full path
|
||||||
|
if [ -f "${archive_name}" ]; then
|
||||||
|
archive_path="${archive_name}"
|
||||||
|
else
|
||||||
|
log_error "Restore archive file not found: ${archive_name}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Safety prompt
|
||||||
|
echo ""
|
||||||
|
log_warning "RESTORE MODE - THIS WILL OVERWRITE YOUR ACTIVE DATABASE!"
|
||||||
|
echo " Archive Source: ${archive_path}"
|
||||||
|
echo " Database Type : ${DB_TYPE}"
|
||||||
|
if [ "${DB_TYPE}" = "postgres" ]; then
|
||||||
|
echo " Target DB : PostgreSQL Database '${POSTGRES_DB}'"
|
||||||
|
else
|
||||||
|
echo " Target File : SQLite File '${SQLITE_PATH}'"
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
read -p "To confirm restore, type 'RESTORE': " confirm_val
|
||||||
|
if [ "${confirm_val}" != "RESTORE" ]; then
|
||||||
|
log "Restore cancelled."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "Initiating restore sequence..."
|
||||||
|
|
||||||
|
# Create safety backup of the active database before restoring
|
||||||
|
log "Creating safety backup of the current database state..."
|
||||||
|
local safety_name
|
||||||
|
if [ "${DB_TYPE}" = "postgres" ]; then
|
||||||
|
safety_name="wordly_postgres_pre_restore_${TIMESTAMP}.sql.gz"
|
||||||
|
docker exec -e PGPASSWORD="${POSTGRES_PASSWORD}" "${POSTGRES_CONTAINER}" pg_dump \
|
||||||
|
-U "${POSTGRES_USER}" \
|
||||||
|
-d "${POSTGRES_DB}" \
|
||||||
|
--format=custom \
|
||||||
|
--no-owner \
|
||||||
|
--no-acl \
|
||||||
|
2>/dev/null | gzip > "${BACKUP_DIR}/daily/${safety_name}"
|
||||||
|
else
|
||||||
|
safety_name="wordly_sqlite_pre_restore_${TIMESTAMP}.db.gz"
|
||||||
|
if [ -f "${SQLITE_PATH}" ]; then
|
||||||
|
gzip -c "${SQLITE_PATH}" > "${BACKUP_DIR}/daily/${safety_name}"
|
||||||
|
else
|
||||||
|
touch "${SQLITE_PATH}"
|
||||||
|
gzip -c "${SQLITE_PATH}" > "${BACKUP_DIR}/daily/${safety_name}"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
log_success "Safety pre-restore backup created: ${safety_name}"
|
||||||
|
|
||||||
|
# Restore DB
|
||||||
|
if [ "${DB_TYPE}" = "postgres" ]; then
|
||||||
|
log "Restoring PostgreSQL database..."
|
||||||
|
if ! gunzip -c "${archive_path}" | docker exec -i -e PGPASSWORD="${POSTGRES_PASSWORD}" "${POSTGRES_CONTAINER}" \
|
||||||
|
pg_restore \
|
||||||
|
-U "${POSTGRES_USER}" \
|
||||||
|
-d "${POSTGRES_DB}" \
|
||||||
|
--clean \
|
||||||
|
--if-exists \
|
||||||
|
--no-owner \
|
||||||
|
--no-acl; then
|
||||||
|
log_error "PostgreSQL restore failed!"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
log "Restoring SQLite database file..."
|
||||||
|
|
||||||
|
# Decompress SQLite backup to a temp file
|
||||||
|
local temp_restore_db="${BACKUP_DIR}/daily/temp_restore_${TIMESTAMP}.db"
|
||||||
|
gunzip -c "${archive_path}" > "${temp_restore_db}"
|
||||||
|
|
||||||
|
# Overwrite database safely using Python (ensures open handles or locks are respected)
|
||||||
|
if ! "${PYTHON_CMD}" -c "
|
||||||
|
import sqlite3, sys, os, re
|
||||||
|
try:
|
||||||
|
src_raw, dst_raw = sys.argv[1], sys.argv[2]
|
||||||
|
if sys.platform == 'win32':
|
||||||
|
m_src = re.match(r'^/(?:mnt/)?([a-zA-Z])/(.*)', src_raw)
|
||||||
|
if m_src:
|
||||||
|
src_raw = m_src.group(1) + ':/' + m_src.group(2)
|
||||||
|
m_dst = re.match(r'^/(?:mnt/)?([a-zA-Z])/(.*)', dst_raw)
|
||||||
|
if m_dst:
|
||||||
|
dst_raw = m_dst.group(1) + ':/' + m_dst.group(2)
|
||||||
|
src = sqlite3.connect(os.path.abspath(src_raw))
|
||||||
|
dst = sqlite3.connect(os.path.abspath(dst_raw))
|
||||||
|
with dst:
|
||||||
|
src.backup(dst)
|
||||||
|
src.close()
|
||||||
|
dst.close()
|
||||||
|
except Exception as e:
|
||||||
|
print(f'Python SQLite restore error: {e}', file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
" "${temp_restore_db}" "${SQLITE_PATH}"; then
|
||||||
|
log_error "SQLite restore swap operation failed!"
|
||||||
|
rm -f "${temp_restore_db}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
rm -f "${temp_restore_db}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
log_success "Restore process completed successfully!"
|
||||||
|
log_warning "You should restart your app container to clear active connection caches:"
|
||||||
|
echo " docker compose restart backend"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ==============================================================================
|
||||||
|
# 6. ENTRYS
|
||||||
|
# ==============================================================================
|
||||||
|
main() {
|
||||||
|
case "${1:-}" in
|
||||||
|
--restore)
|
||||||
|
check_prerequisites
|
||||||
|
restore_backup "${2:-}"
|
||||||
|
;;
|
||||||
|
--full)
|
||||||
|
check_prerequisites
|
||||||
|
create_backup
|
||||||
|
cleanup_old_backups
|
||||||
|
log_success "Full backup and rotation sequence completed."
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
check_prerequisites
|
||||||
|
create_backup
|
||||||
|
cleanup_old_backups
|
||||||
|
log_success "Standard backup completed."
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
main "$@"
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
-----BEGIN CERTIFICATE-----
|
|
||||||
MIIGfTCCBOWgAwIBAgIQfibElS0gHjeTemMLeliMNTANBgkqhkiG9w0BAQsFADBg
|
|
||||||
MQswCQYDVQQGEwJHQjEYMBYGA1UEChMPU2VjdGlnbyBMaW1pdGVkMTcwNQYDVQQD
|
|
||||||
Ey5TZWN0aWdvIFB1YmxpYyBTZXJ2ZXIgQXV0aGVudGljYXRpb24gQ0EgRFYgUjM2
|
|
||||||
MB4XDTI2MDUxNTAwMDAwMFoXDTI2MTExMTIzNTk1OVowFzEVMBMGA1UEAwwMKi53
|
|
||||||
b3JkbHkuYXJ0MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA2qYNf43B
|
|
||||||
QNoC0eP6vfZ6fa94RSW3bj6XfUCjkNQ/JiIqR8uTB04KQWO0JFI8gS7itLGaKvQs
|
|
||||||
Fl8UHrhaeeoR2VuWjNSQ2cCxpQqmO4/gGczQdm+dYScr88aDU0zffLcbkcoolYvQ
|
|
||||||
lsnF2Ukhl3ls3zwLsiJuiqTNkAOfCB2i6msCAHZTrj6igvTHN/zONsaAs9nLxYLK
|
|
||||||
thS1xlNsCKQgtisOYX0/sL2FiV2MLItRvA1ve1UHdXhl80p9IDpchzhZlIkCVqnn
|
|
||||||
vfC01mW41JGeME0JHqN+oIW6nYqw6tea2X2xgxX4EcU3A2O3KzhE+Mmd6ojFpEGT
|
|
||||||
BmILCucwLjzLawIDAQABo4IC+jCCAvYwHwYDVR0jBBgwFoAUaMASFhgOr872h6Yy
|
|
||||||
V6NGUV3LBycwHQYDVR0OBBYEFIraQ9obMUDjXL7OiReA8rEFcrf8MA4GA1UdDwEB
|
|
||||||
/wQEAwIFoDAMBgNVHRMBAf8EAjAAMBMGA1UdJQQMMAoGCCsGAQUFBwMBMEkGA1Ud
|
|
||||||
IARCMEAwNAYLKwYBBAGyMQECAgcwJTAjBggrBgEFBQcCARYXaHR0cHM6Ly9zZWN0
|
|
||||||
aWdvLmNvbS9DUFMwCAYGZ4EMAQIBMIGEBggrBgEFBQcBAQR4MHYwTwYIKwYBBQUH
|
|
||||||
MAKGQ2h0dHA6Ly9jcnQuc2VjdGlnby5jb20vU2VjdGlnb1B1YmxpY1NlcnZlckF1
|
|
||||||
dGhlbnRpY2F0aW9uQ0FEVlIzNi5jcnQwIwYIKwYBBQUHMAGGF2h0dHA6Ly9vY3Nw
|
|
||||||
LnNlY3RpZ28uY29tMIIBiAYKKwYBBAHWeQIEAgSCAXgEggF0AXIAdwDXbX0Q0af1
|
|
||||||
d8LH6V/XAL/5gskzWmXh0LMBcxfAyMVpdwAAAZ4ses9uAAAEAwBIMEYCIQCAfqOA
|
|
||||||
cNrwaHb3Si2YNX/hNG04L5a6FuWmGYDfpWx7mwIhAKG28zUTSgQIzFVFg5lo33h1
|
|
||||||
c3PVoeaUTXlgFYgDt1VPAHYAyKPEf8ezrbk1awE/anoSbeM6TkOlxkb5l605dZkd
|
|
||||||
z5oAAAGeLHrQGAAABAMARzBFAiA7EBM6x5YO1DyLKU35g8ko3Oweb26iO3lEBuTh
|
|
||||||
awCWRgIhAPa37gVGReUck22Y5Kt7XF3AUoKepCS892Su7XYx1yGFAH8AbP5QGUOo
|
|
||||||
XqkWvFLRM+TcyR7xQRx9JYQg0XOAnhgY6zoAAAGeLHrRzwAIAAAFAAun2IsEAwBI
|
|
||||||
MEYCIQD9ELA86WaBTbz332uBnC3vOhgzfFQVPbWXEoVAmZ9uNwIhAOGQTNW+A79L
|
|
||||||
6tPQyBabAePy6ykxqy16C2kn6Dyg89pkMCMGA1UdEQQcMBqCDCoud29yZGx5LmFy
|
|
||||||
dIIKd29yZGx5LmFydDANBgkqhkiG9w0BAQsFAAOCAYEAXoPRuKCB1GWIvDsOES/i
|
|
||||||
Ayd1G0LM/Jqgv4qZdukyxm7IgtcDiFbkqCekZ9lGJ5OZXU82cbEgTkZ0qwoAvPfX
|
|
||||||
Ydz7CQ3WYwhmkkFrBTyIjVJhghpIVYBNbH2klPzmbjhW57gww6iPYbBvQSGbOmJ5
|
|
||||||
iok2qGkE/ZkNyQ07eG/exZNh6hUgWnc0Hr+iFT7o1RtUpyjQzQAwhM7YOor66bXT
|
|
||||||
7MK2Qknj6R6ldiF6/bV657VZ1txYUy9EHZSQDeYXR2SzIbDtW/QKl4E47JbrS2om
|
|
||||||
GGu6agNZxr8USYFxKfNc9K1Li1lL6naMnKxsiDoeQijE4djAamW+npWaiixwA3RE
|
|
||||||
dLulyPwbbW+jPDAWNJovg+ZZr47qBOA/494i9Iv/0eRseJvXradP+712sAhmbkHj
|
|
||||||
JMWgV+q0D8OeX0a8/oeH9TuLtZUob6ZwUjVQcZiGdTcqzAW2KF1TW1gNdjsD93wu
|
|
||||||
Qr4M/9/JxahdIILRfB+blXXhv9jodb0pCf2ryC1UhBdV
|
|
||||||
-----END CERTIFICATE-----
|
|
||||||
Reference in New Issue
Block a user