Merge remote-tracking branch 'origin/main'
# Conflicts: # server/src/db/migrations.ts
This commit is contained in:
@@ -21,6 +21,6 @@ You will receive a response within 48 hours. Once confirmed, a fix will be relea
|
||||
|
||||
## Scope
|
||||
|
||||
This policy covers the TREK application and its Docker image (`mauriceboe/nomad`).
|
||||
This policy covers the TREK application and its Docker image (`mauriceboe/trek`).
|
||||
|
||||
Third-party dependencies are monitored via GitHub Dependabot.
|
||||
|
||||
5
chart/Chart.yaml
Normal file
5
chart/Chart.yaml
Normal file
@@ -0,0 +1,5 @@
|
||||
apiVersion: v2
|
||||
name: trek
|
||||
version: 0.1.0
|
||||
description: Minimal Helm chart for TREK app
|
||||
appVersion: "latest"
|
||||
33
chart/README.md
Normal file
33
chart/README.md
Normal file
@@ -0,0 +1,33 @@
|
||||
# TREK Helm Chart
|
||||
|
||||
This is a minimal Helm chart for deploying the TREK app.
|
||||
|
||||
## Features
|
||||
- Deploys the TREK container
|
||||
- Exposes port 3000 via Service
|
||||
- Optional persistent storage for `/app/data` and `/app/uploads`
|
||||
- Configurable environment variables and secrets
|
||||
- Optional generic Ingress support
|
||||
- Health checks on `/api/health`
|
||||
|
||||
## Usage
|
||||
|
||||
```sh
|
||||
helm install trek ./chart \
|
||||
--set secretEnv.JWT_SECRET=your_jwt_secret \
|
||||
--set ingress.enabled=true \
|
||||
--set ingress.hosts[0].host=yourdomain.com
|
||||
```
|
||||
|
||||
See `values.yaml` for more options.
|
||||
|
||||
## Files
|
||||
- `Chart.yaml` — chart metadata
|
||||
- `values.yaml` — configuration values
|
||||
- `templates/` — Kubernetes manifests
|
||||
|
||||
## Notes
|
||||
- Ingress is off by default. Enable and configure hosts for your domain.
|
||||
- PVCs require a default StorageClass or specify one as needed.
|
||||
- JWT_SECRET must be set for production use.
|
||||
- If using ingress, you must manually keep `env.ALLOWED_ORIGINS` and `ingress.hosts` in sync to ensure CORS works correctly. The chart does not sync these automatically.
|
||||
13
chart/templates/NOTES.txt
Normal file
13
chart/templates/NOTES.txt
Normal file
@@ -0,0 +1,13 @@
|
||||
1. JWT_SECRET handling:
|
||||
- By default, the chart creates a secret with the value from `values.yaml: secretEnv.JWT_SECRET`.
|
||||
- To generate a random JWT_SECRET at install, set `generateJwtSecret: true`.
|
||||
- To use an existing Kubernetes secret, set `existingSecret` to the secret name. The secret must have a key matching `existingSecretKey` (defaults to `JWT_SECRET`).
|
||||
|
||||
2. Example usage:
|
||||
- Set a custom secret: `--set secretEnv.JWT_SECRET=your_secret`
|
||||
- Generate a random secret: `--set generateJwtSecret=true`
|
||||
- Use an existing secret: `--set existingSecret=my-k8s-secret`
|
||||
- Use a custom key in the existing secret: `--set existingSecret=my-k8s-secret --set existingSecretKey=MY_KEY`
|
||||
|
||||
3. Only one method should be used at a time. If both `generateJwtSecret` and `existingSecret` are set, `existingSecret` takes precedence.
|
||||
If using `existingSecret`, ensure the referenced secret and key exist in the target namespace.
|
||||
18
chart/templates/_helpers.tpl
Normal file
18
chart/templates/_helpers.tpl
Normal file
@@ -0,0 +1,18 @@
|
||||
{{/*
|
||||
Expand the name of the chart.
|
||||
*/}}
|
||||
{{- define "trek.name" -}}
|
||||
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" -}}
|
||||
{{- end -}}
|
||||
|
||||
{{/*
|
||||
Create a default fully qualified app name.
|
||||
*/}}
|
||||
{{- define "trek.fullname" -}}
|
||||
{{- if .Values.fullnameOverride -}}
|
||||
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" -}}
|
||||
{{- else -}}
|
||||
{{- $name := default .Chart.Name .Values.nameOverride -}}
|
||||
{{- printf "%s" $name | trunc 63 | trimSuffix "-" -}}
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
12
chart/templates/configmap.yaml
Normal file
12
chart/templates/configmap.yaml
Normal file
@@ -0,0 +1,12 @@
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: {{ include "trek.fullname" . }}-config
|
||||
labels:
|
||||
app: {{ include "trek.name" . }}
|
||||
data:
|
||||
NODE_ENV: {{ .Values.env.NODE_ENV | quote }}
|
||||
PORT: {{ .Values.env.PORT | quote }}
|
||||
{{- if .Values.env.ALLOWED_ORIGINS }}
|
||||
ALLOWED_ORIGINS: {{ .Values.env.ALLOWED_ORIGINS | quote }}
|
||||
{{- end }}
|
||||
61
chart/templates/deployment.yaml
Normal file
61
chart/templates/deployment.yaml
Normal file
@@ -0,0 +1,61 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: {{ include "trek.fullname" . }}
|
||||
labels:
|
||||
app: {{ include "trek.name" . }}
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: {{ include "trek.name" . }}
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: {{ include "trek.name" . }}
|
||||
spec:
|
||||
{{- if .Values.imagePullSecrets }}
|
||||
imagePullSecrets:
|
||||
{{- range .Values.imagePullSecrets }}
|
||||
- name: {{ .name }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
containers:
|
||||
- name: trek
|
||||
image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
|
||||
imagePullPolicy: {{ .Values.image.pullPolicy }}
|
||||
ports:
|
||||
- containerPort: 3000
|
||||
envFrom:
|
||||
- configMapRef:
|
||||
name: {{ include "trek.fullname" . }}-config
|
||||
env:
|
||||
- name: JWT_SECRET
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ default (printf "%s-secret" (include "trek.fullname" .)) .Values.existingSecret }}
|
||||
key: {{ .Values.existingSecretKey | default "JWT_SECRET" }}
|
||||
volumeMounts:
|
||||
- name: data
|
||||
mountPath: /app/data
|
||||
- name: uploads
|
||||
mountPath: /app/uploads
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /api/health
|
||||
port: 3000
|
||||
initialDelaySeconds: 15
|
||||
periodSeconds: 30
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /api/health
|
||||
port: 3000
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 10
|
||||
volumes:
|
||||
- name: data
|
||||
persistentVolumeClaim:
|
||||
claimName: {{ include "trek.fullname" . }}-data
|
||||
- name: uploads
|
||||
persistentVolumeClaim:
|
||||
claimName: {{ include "trek.fullname" . }}-uploads
|
||||
32
chart/templates/ingress.yaml
Normal file
32
chart/templates/ingress.yaml
Normal file
@@ -0,0 +1,32 @@
|
||||
{{- if .Values.ingress.enabled }}
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: {{ include "trek.fullname" . }}
|
||||
labels:
|
||||
app: {{ include "trek.name" . }}
|
||||
{{- with .Values.ingress.annotations }}
|
||||
annotations:
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
{{- if .Values.ingress.tls }}
|
||||
tls:
|
||||
{{- toYaml .Values.ingress.tls | nindent 4 }}
|
||||
{{- end }}
|
||||
rules:
|
||||
{{- range .Values.ingress.hosts }}
|
||||
- host: {{ .host }}
|
||||
http:
|
||||
paths:
|
||||
{{- range .paths }}
|
||||
- path: {{ . }}
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: {{ include "trek.fullname" $ }}
|
||||
port:
|
||||
number: {{ $.Values.service.port }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
25
chart/templates/pvc.yaml
Normal file
25
chart/templates/pvc.yaml
Normal file
@@ -0,0 +1,25 @@
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: {{ include "trek.fullname" . }}-data
|
||||
labels:
|
||||
app: {{ include "trek.name" . }}
|
||||
spec:
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
resources:
|
||||
requests:
|
||||
storage: {{ .Values.persistence.data.size }}
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: {{ include "trek.fullname" . }}-uploads
|
||||
labels:
|
||||
app: {{ include "trek.name" . }}
|
||||
spec:
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
resources:
|
||||
requests:
|
||||
storage: {{ .Values.persistence.uploads.size }}
|
||||
23
chart/templates/secret.yaml
Normal file
23
chart/templates/secret.yaml
Normal file
@@ -0,0 +1,23 @@
|
||||
{{- if and (not .Values.existingSecret) (not .Values.generateJwtSecret) }}
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: {{ include "trek.fullname" . }}-secret
|
||||
labels:
|
||||
app: {{ include "trek.name" . }}
|
||||
type: Opaque
|
||||
data:
|
||||
{{ .Values.existingSecretKey | default "JWT_SECRET" }}: {{ .Values.secretEnv.JWT_SECRET | b64enc | quote }}
|
||||
{{- end }}
|
||||
|
||||
{{- if and (not .Values.existingSecret) (.Values.generateJwtSecret) }}
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: {{ include "trek.fullname" . }}-secret
|
||||
labels:
|
||||
app: {{ include "trek.name" . }}
|
||||
type: Opaque
|
||||
stringData:
|
||||
{{ .Values.existingSecretKey | default "JWT_SECRET" }}: {{ randAlphaNum 32 }}
|
||||
{{- end }}
|
||||
15
chart/templates/service.yaml
Normal file
15
chart/templates/service.yaml
Normal file
@@ -0,0 +1,15 @@
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: {{ include "trek.fullname" . }}
|
||||
labels:
|
||||
app: {{ include "trek.name" . }}
|
||||
spec:
|
||||
type: {{ .Values.service.type }}
|
||||
ports:
|
||||
- port: {{ .Values.service.port }}
|
||||
targetPort: 3000
|
||||
protocol: TCP
|
||||
name: http
|
||||
selector:
|
||||
app: {{ include "trek.name" . }}
|
||||
53
chart/values.yaml
Normal file
53
chart/values.yaml
Normal file
@@ -0,0 +1,53 @@
|
||||
|
||||
image:
|
||||
repository: mauriceboe/trek
|
||||
tag: latest
|
||||
pullPolicy: IfNotPresent
|
||||
|
||||
# Optional image pull secrets for private registries
|
||||
imagePullSecrets: []
|
||||
# - name: my-registry-secret
|
||||
|
||||
service:
|
||||
type: ClusterIP
|
||||
port: 3000
|
||||
|
||||
env:
|
||||
NODE_ENV: production
|
||||
PORT: 3000
|
||||
# ALLOWED_ORIGINS: ""
|
||||
# NOTE: If using ingress, ensure env.ALLOWED_ORIGINS matches the domains in ingress.hosts for proper CORS configuration.
|
||||
|
||||
|
||||
# JWT secret configuration
|
||||
secretEnv:
|
||||
# If set, use this value for JWT_SECRET (base64-encoded in secret.yaml)
|
||||
JWT_SECRET: ""
|
||||
|
||||
# If true, a random JWT_SECRET will be generated during install (overrides secretEnv.JWT_SECRET)
|
||||
generateJwtSecret: false
|
||||
|
||||
# If set, use an existing Kubernetes secret for JWT_SECRET
|
||||
existingSecret: ""
|
||||
existingSecretKey: JWT_SECRET
|
||||
|
||||
persistence:
|
||||
enabled: true
|
||||
data:
|
||||
size: 1Gi
|
||||
uploads:
|
||||
size: 1Gi
|
||||
|
||||
resources: {}
|
||||
|
||||
ingress:
|
||||
enabled: false
|
||||
annotations: {}
|
||||
hosts:
|
||||
- host: chart-example.local
|
||||
paths:
|
||||
- /
|
||||
tls: []
|
||||
# - secretName: chart-example-tls
|
||||
# hosts:
|
||||
# - chart-example.local
|
||||
@@ -204,6 +204,9 @@ export const filesApi = {
|
||||
restore: (tripId: number | string, id: number) => apiClient.post(`/trips/${tripId}/files/${id}/restore`).then(r => r.data),
|
||||
permanentDelete: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/files/${id}/permanent`).then(r => r.data),
|
||||
emptyTrash: (tripId: number | string) => apiClient.delete(`/trips/${tripId}/files/trash/empty`).then(r => r.data),
|
||||
addLink: (tripId: number | string, fileId: number, data: { reservation_id?: number; assignment_id?: number }) => apiClient.post(`/trips/${tripId}/files/${fileId}/link`, data).then(r => r.data),
|
||||
removeLink: (tripId: number | string, fileId: number, linkId: number) => apiClient.delete(`/trips/${tripId}/files/${fileId}/link/${linkId}`).then(r => r.data),
|
||||
getLinks: (tripId: number | string, fileId: number) => apiClient.get(`/trips/${tripId}/files/${fileId}/links`).then(r => r.data),
|
||||
}
|
||||
|
||||
export const reservationsApi = {
|
||||
|
||||
@@ -302,10 +302,15 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
|
||||
|
||||
const renderFileRow = (file: TripFile, isTrash = false) => {
|
||||
const FileIcon = getFileIcon(file.mime_type)
|
||||
const linkedPlace = places?.find(p => p.id === file.place_id)
|
||||
const linkedReservation = file.reservation_id
|
||||
? (reservations?.find(r => r.id === file.reservation_id) || { title: file.reservation_title })
|
||||
: null
|
||||
const allLinkedPlaceIds = new Set<number>()
|
||||
if (file.place_id) allLinkedPlaceIds.add(file.place_id)
|
||||
for (const pid of (file.linked_place_ids || [])) allLinkedPlaceIds.add(pid)
|
||||
const linkedPlaces = [...allLinkedPlaceIds].map(pid => places?.find(p => p.id === pid)).filter(Boolean)
|
||||
// All linked reservations (primary + file_links)
|
||||
const allLinkedResIds = new Set<number>()
|
||||
if (file.reservation_id) allLinkedResIds.add(file.reservation_id)
|
||||
for (const rid of (file.linked_reservation_ids || [])) allLinkedResIds.add(rid)
|
||||
const linkedReservations = [...allLinkedResIds].map(rid => reservations?.find(r => r.id === rid)).filter(Boolean)
|
||||
const fileUrl = file.url || (file.filename?.startsWith('files/') ? `/uploads/${file.filename}` : `/uploads/files/${file.filename}`)
|
||||
|
||||
return (
|
||||
@@ -365,12 +370,12 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
|
||||
{file.file_size && <span style={{ fontSize: 11, color: 'var(--text-faint)' }}>{formatSize(file.file_size)}</span>}
|
||||
<span style={{ fontSize: 11, color: 'var(--text-faint)' }}>{formatDateWithLocale(file.created_at, locale)}</span>
|
||||
|
||||
{linkedPlace && (
|
||||
<SourceBadge icon={MapPin} label={`${t('files.sourcePlan')} · ${linkedPlace.name}`} />
|
||||
)}
|
||||
{linkedReservation && (
|
||||
<SourceBadge icon={Ticket} label={`${t('files.sourceBooking')} · ${linkedReservation.title || t('files.sourceBooking')}`} />
|
||||
)}
|
||||
{linkedPlaces.map(p => (
|
||||
<SourceBadge key={p.id} icon={MapPin} label={`${t('files.sourcePlan')} · ${p.name}`} />
|
||||
))}
|
||||
{linkedReservations.map(r => (
|
||||
<SourceBadge key={r.id} icon={Ticket} label={`${t('files.sourceBooking')} · ${r.title || t('files.sourceBooking')}`} />
|
||||
))}
|
||||
{file.note_id && (
|
||||
<SourceBadge icon={StickyNote} label={t('files.sourceCollab') || 'Collab Notes'} />
|
||||
)}
|
||||
@@ -477,20 +482,45 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
|
||||
}
|
||||
}
|
||||
const unassigned = places.filter(p => !assignedPlaceIds.has(p.id))
|
||||
const placeBtn = (p: Place) => (
|
||||
<button key={p.id} onClick={() => handleAssign(file.id, { place_id: file.place_id === p.id ? null : p.id })} style={{
|
||||
width: '100%', textAlign: 'left', padding: '6px 10px 6px 20px', background: file.place_id === p.id ? 'var(--bg-hover)' : 'none',
|
||||
border: 'none', cursor: 'pointer', fontSize: 13, color: 'var(--text-primary)',
|
||||
borderRadius: 8, fontFamily: 'inherit', fontWeight: file.place_id === p.id ? 600 : 400,
|
||||
display: 'flex', alignItems: 'center', gap: 6,
|
||||
}}
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = file.place_id === p.id ? 'var(--bg-hover)' : 'transparent'}>
|
||||
<MapPin size={12} style={{ flexShrink: 0, color: 'var(--text-muted)' }} />
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{p.name}</span>
|
||||
{file.place_id === p.id && <Check size={14} style={{ marginLeft: 'auto', flexShrink: 0, color: 'var(--accent)' }} />}
|
||||
</button>
|
||||
)
|
||||
const placeBtn = (p: Place) => {
|
||||
const isLinked = file.place_id === p.id || (file.linked_place_ids || []).includes(p.id)
|
||||
return (
|
||||
<button key={p.id} onClick={async () => {
|
||||
if (isLinked) {
|
||||
if (file.place_id === p.id) {
|
||||
await handleAssign(file.id, { place_id: null })
|
||||
} else {
|
||||
try {
|
||||
const linksRes = await filesApi.getLinks(tripId, file.id)
|
||||
const link = (linksRes.links || []).find((l: any) => l.place_id === p.id)
|
||||
if (link) await filesApi.removeLink(tripId, file.id, link.id)
|
||||
refreshFiles()
|
||||
} catch {}
|
||||
}
|
||||
} else {
|
||||
if (!file.place_id) {
|
||||
await handleAssign(file.id, { place_id: p.id })
|
||||
} else {
|
||||
try {
|
||||
await filesApi.addLink(tripId, file.id, { place_id: p.id })
|
||||
refreshFiles()
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
}} style={{
|
||||
width: '100%', textAlign: 'left', padding: '6px 10px 6px 20px', background: isLinked ? 'var(--bg-hover)' : 'none',
|
||||
border: 'none', cursor: 'pointer', fontSize: 13, color: 'var(--text-primary)',
|
||||
borderRadius: 8, fontFamily: 'inherit', fontWeight: isLinked ? 600 : 400,
|
||||
display: 'flex', alignItems: 'center', gap: 6,
|
||||
}}
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = isLinked ? 'var(--bg-hover)' : 'transparent'}>
|
||||
<MapPin size={12} style={{ flexShrink: 0, color: 'var(--text-muted)' }} />
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{p.name}</span>
|
||||
{isLinked && <Check size={14} style={{ marginLeft: 'auto', flexShrink: 0, color: 'var(--accent)' }} />}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
const placesSection = places.length > 0 && (
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
@@ -519,20 +549,47 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
|
||||
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-faint)', padding: '8px 10px 4px', textTransform: 'uppercase', letterSpacing: 0.5 }}>
|
||||
{t('files.assignBooking')}
|
||||
</div>
|
||||
{reservations.map(r => (
|
||||
<button key={r.id} onClick={() => handleAssign(file.id, { reservation_id: file.reservation_id === r.id ? null : r.id })} style={{
|
||||
width: '100%', textAlign: 'left', padding: '6px 10px 6px 20px', background: file.reservation_id === r.id ? 'var(--bg-hover)' : 'none',
|
||||
border: 'none', cursor: 'pointer', fontSize: 13, color: 'var(--text-primary)',
|
||||
borderRadius: 8, fontFamily: 'inherit', fontWeight: file.reservation_id === r.id ? 600 : 400,
|
||||
display: 'flex', alignItems: 'center', gap: 6,
|
||||
}}
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = file.reservation_id === r.id ? 'var(--bg-hover)' : 'transparent'}>
|
||||
<Ticket size={12} style={{ flexShrink: 0, color: 'var(--text-muted)' }} />
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{r.title || r.name}</span>
|
||||
{file.reservation_id === r.id && <Check size={14} style={{ marginLeft: 'auto', flexShrink: 0, color: 'var(--accent)' }} />}
|
||||
</button>
|
||||
))}
|
||||
{reservations.map(r => {
|
||||
const isLinked = file.reservation_id === r.id || (file.linked_reservation_ids || []).includes(r.id)
|
||||
return (
|
||||
<button key={r.id} onClick={async () => {
|
||||
if (isLinked) {
|
||||
// Unlink: if primary reservation_id, clear it; if via file_links, remove link
|
||||
if (file.reservation_id === r.id) {
|
||||
await handleAssign(file.id, { reservation_id: null })
|
||||
} else {
|
||||
try {
|
||||
const linksRes = await filesApi.getLinks(tripId, file.id)
|
||||
const link = (linksRes.links || []).find((l: any) => l.reservation_id === r.id)
|
||||
if (link) await filesApi.removeLink(tripId, file.id, link.id)
|
||||
refreshFiles()
|
||||
} catch {}
|
||||
}
|
||||
} else {
|
||||
// Link: if no primary, set it; otherwise use file_links
|
||||
if (!file.reservation_id) {
|
||||
await handleAssign(file.id, { reservation_id: r.id })
|
||||
} else {
|
||||
try {
|
||||
await filesApi.addLink(tripId, file.id, { reservation_id: r.id })
|
||||
refreshFiles()
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
}} style={{
|
||||
width: '100%', textAlign: 'left', padding: '6px 10px 6px 20px', background: isLinked ? 'var(--bg-hover)' : 'none',
|
||||
border: 'none', cursor: 'pointer', fontSize: 13, color: 'var(--text-primary)',
|
||||
borderRadius: 8, fontFamily: 'inherit', fontWeight: isLinked ? 600 : 400,
|
||||
display: 'flex', alignItems: 'center', gap: 6,
|
||||
}}
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = isLinked ? 'var(--bg-hover)' : 'transparent'}>
|
||||
<Ticket size={12} style={{ flexShrink: 0, color: 'var(--text-muted)' }} />
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{r.title || r.name}</span>
|
||||
{isLinked && <Check size={14} style={{ marginLeft: 'auto', flexShrink: 0, color: 'var(--accent)' }} />}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
|
||||
|
||||
@@ -169,7 +169,7 @@ export default function PlaceInspector({
|
||||
const selectedDay = days?.find(d => d.id === selectedDayId)
|
||||
const weekdayIndex = getWeekdayIndex(selectedDay?.date)
|
||||
|
||||
const placeFiles = (files || []).filter(f => String(f.place_id) === String(place.id))
|
||||
const placeFiles = (files || []).filter(f => String(f.place_id) === String(place.id) || (f.linked_place_ids || []).includes(place.id))
|
||||
|
||||
const handleFileUpload = useCallback(async (e) => {
|
||||
const selectedFiles = Array.from((e.target as HTMLInputElement).files || [])
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { useState, useEffect, useRef, useMemo } from 'react'
|
||||
import { useParams } from 'react-router-dom'
|
||||
import apiClient from '../../api/client'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import Modal from '../shared/Modal'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
import { Plane, Hotel, Utensils, Train, Car, Ship, Ticket, FileText, Users, Paperclip, X, ExternalLink, Link2 } from 'lucide-react'
|
||||
@@ -62,6 +65,8 @@ interface ReservationModalProps {
|
||||
}
|
||||
|
||||
export function ReservationModal({ isOpen, onClose, onSave, reservation, days, places, assignments, selectedDayId, files = [], onFileUpload, onFileDelete, accommodations = [] }: ReservationModalProps) {
|
||||
const { id: tripId } = useParams<{ id: string }>()
|
||||
const loadFiles = useTripStore(s => s.loadFiles)
|
||||
const toast = useToast()
|
||||
const { t, locale } = useTranslation()
|
||||
const fileInputRef = useRef(null)
|
||||
@@ -78,6 +83,9 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [uploadingFile, setUploadingFile] = useState(false)
|
||||
const [pendingFiles, setPendingFiles] = useState([])
|
||||
const [showFilePicker, setShowFilePicker] = useState(false)
|
||||
const [linkedFileIds, setLinkedFileIds] = useState<number[]>([])
|
||||
const [unlinkedFileIds, setUnlinkedFileIds] = useState<number[]>([])
|
||||
|
||||
const assignmentOptions = useMemo(
|
||||
() => buildAssignmentOptions(days, assignments, t, locale),
|
||||
@@ -204,7 +212,13 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
}
|
||||
}
|
||||
|
||||
const attachedFiles = reservation?.id ? files.filter(f => f.reservation_id === reservation.id) : []
|
||||
const attachedFiles = reservation?.id
|
||||
? files.filter(f =>
|
||||
f.reservation_id === reservation.id ||
|
||||
linkedFileIds.includes(f.id) ||
|
||||
(f.linked_reservation_ids && f.linked_reservation_ids.includes(reservation.id))
|
||||
)
|
||||
: []
|
||||
|
||||
const inputStyle = {
|
||||
width: '100%', border: '1px solid var(--border-primary)', borderRadius: 10,
|
||||
@@ -459,11 +473,23 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
<FileText size={12} style={{ color: 'var(--text-muted)', flexShrink: 0 }} />
|
||||
<span style={{ flex: 1, fontSize: 12, color: 'var(--text-secondary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{f.original_name}</span>
|
||||
<a href={f.url} target="_blank" rel="noreferrer" style={{ color: 'var(--text-faint)', display: 'flex', flexShrink: 0 }}><ExternalLink size={11} /></a>
|
||||
{onFileDelete && (
|
||||
<button type="button" onClick={() => onFileDelete(f.id)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', padding: 0, flexShrink: 0 }}>
|
||||
<X size={11} />
|
||||
</button>
|
||||
)}
|
||||
<button type="button" onClick={async () => {
|
||||
// Always unlink, never delete the file
|
||||
// Clear primary reservation_id if it points to this reservation
|
||||
if (f.reservation_id === reservation?.id) {
|
||||
try { await apiClient.put(`/trips/${tripId}/files/${f.id}`, { reservation_id: null }) } catch {}
|
||||
}
|
||||
// Remove from file_links if linked there
|
||||
try {
|
||||
const linksRes = await apiClient.get(`/trips/${tripId}/files/${f.id}/links`)
|
||||
const link = (linksRes.data.links || []).find((l: any) => l.reservation_id === reservation?.id)
|
||||
if (link) await apiClient.delete(`/trips/${tripId}/files/${f.id}/link/${link.id}`)
|
||||
} catch {}
|
||||
setLinkedFileIds(prev => prev.filter(id => id !== f.id))
|
||||
if (tripId) loadFiles(tripId)
|
||||
}} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', padding: 0, flexShrink: 0 }}>
|
||||
<X size={11} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{pendingFiles.map((f, i) => (
|
||||
@@ -477,14 +503,56 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
</div>
|
||||
))}
|
||||
<input ref={fileInputRef} type="file" accept=".pdf,.doc,.docx,.txt,image/*" style={{ display: 'none' }} onChange={handleFileChange} />
|
||||
<button type="button" onClick={() => fileInputRef.current?.click()} disabled={uploadingFile} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 5, padding: '6px 10px',
|
||||
border: '1px dashed var(--border-primary)', borderRadius: 8, background: 'none',
|
||||
fontSize: 11, color: 'var(--text-faint)', cursor: uploadingFile ? 'default' : 'pointer', fontFamily: 'inherit',
|
||||
}}>
|
||||
<Paperclip size={11} />
|
||||
{uploadingFile ? t('reservations.uploading') : t('reservations.attachFile')}
|
||||
</button>
|
||||
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
|
||||
<button type="button" onClick={() => fileInputRef.current?.click()} disabled={uploadingFile} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 5, padding: '6px 10px',
|
||||
border: '1px dashed var(--border-primary)', borderRadius: 8, background: 'none',
|
||||
fontSize: 11, color: 'var(--text-faint)', cursor: uploadingFile ? 'default' : 'pointer', fontFamily: 'inherit',
|
||||
}}>
|
||||
<Paperclip size={11} />
|
||||
{uploadingFile ? t('reservations.uploading') : t('reservations.attachFile')}
|
||||
</button>
|
||||
{/* Link existing file picker */}
|
||||
{reservation?.id && files.filter(f => !f.deleted_at && !attachedFiles.some(af => af.id === f.id)).length > 0 && (
|
||||
<div style={{ position: 'relative' }}>
|
||||
<button type="button" onClick={() => setShowFilePicker(v => !v)} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 5, padding: '6px 10px',
|
||||
border: '1px dashed var(--border-primary)', borderRadius: 8, background: 'none',
|
||||
fontSize: 11, color: 'var(--text-faint)', cursor: 'pointer', fontFamily: 'inherit',
|
||||
}}>
|
||||
<Link2 size={11} /> {t('reservations.linkExisting')}
|
||||
</button>
|
||||
{showFilePicker && (
|
||||
<div style={{
|
||||
position: 'absolute', bottom: '100%', left: 0, marginBottom: 4, zIndex: 50,
|
||||
background: 'var(--bg-card)', border: '1px solid var(--border-primary)', borderRadius: 10,
|
||||
boxShadow: '0 4px 16px rgba(0,0,0,0.12)', padding: 4, minWidth: 220, maxHeight: 200, overflowY: 'auto',
|
||||
}}>
|
||||
{files.filter(f => !f.deleted_at && !attachedFiles.some(af => af.id === f.id)).map(f => (
|
||||
<button key={f.id} type="button" onClick={async () => {
|
||||
try {
|
||||
await apiClient.post(`/trips/${tripId}/files/${f.id}/link`, { reservation_id: reservation.id })
|
||||
setLinkedFileIds(prev => [...prev, f.id])
|
||||
setShowFilePicker(false)
|
||||
if (tripId) loadFiles(tripId)
|
||||
} catch {}
|
||||
}}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8, width: '100%', padding: '6px 10px',
|
||||
background: 'none', border: 'none', cursor: 'pointer', fontSize: 12, fontFamily: 'inherit',
|
||||
color: 'var(--text-secondary)', borderRadius: 7, textAlign: 'left',
|
||||
}}
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-tertiary)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'none'}>
|
||||
<FileText size={12} style={{ color: 'var(--text-faint)', flexShrink: 0 }} />
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{f.original_name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -65,7 +65,7 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
|
||||
const typeInfo = getType(r.type)
|
||||
const TypeIcon = typeInfo.Icon
|
||||
const confirmed = r.status === 'confirmed'
|
||||
const attachedFiles = files.filter(f => f.reservation_id === r.id)
|
||||
const attachedFiles = files.filter(f => f.reservation_id === r.id || (f.linked_reservation_ids || []).includes(r.id))
|
||||
const linked = r.assignment_id ? assignmentLookup[r.assignment_id] : null
|
||||
|
||||
const handleToggle = async () => {
|
||||
|
||||
@@ -6,10 +6,15 @@ import type { HolidaysMap, VacayEntry } from '../../types'
|
||||
const WEEKDAYS_EN = ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su']
|
||||
const WEEKDAYS_DE = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So']
|
||||
const WEEKDAYS_ES = ['Lu', 'Ma', 'Mi', 'Ju', 'Vi', 'Sa', 'Do']
|
||||
const WEEKDAYS_FR = ['Lu', 'Ma', 'Me', 'Je', 'Ve', 'Sa', 'Di']
|
||||
const WEEKDAYS_BR = ['Seg', 'Ter', 'Qua', 'Qui', 'Sex', 'Sáb', 'Dom']
|
||||
const WEEKDAYS_AR = ['اث', 'ثل', 'أر', 'خم', 'جم', 'سب', 'أح']
|
||||
|
||||
const MONTHS_EN = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December']
|
||||
const MONTHS_DE = ['Januar', 'Februar', 'März', 'April', 'Mai', 'Juni', 'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember']
|
||||
const MONTHS_ES = ['Enero', 'Febrero', 'Marzo', 'Abril', 'Mayo', 'Junio', 'Julio', 'Agosto', 'Septiembre', 'Octubre', 'Noviembre', 'Diciembre']
|
||||
const MONTHS_FR = ['Janvier', 'Février', 'Mars', 'Avril', 'Mai', 'Juin', 'Juillet', 'Août', 'Septembre', 'Octobre', 'Novembre', 'Décembre']
|
||||
const MONTHS_BR = ['Janeiro', 'Fevereiro', 'Março', 'Abril', 'Maio', 'Junho', 'Julho', 'Agosto', 'Setembro', 'Outubro', 'Novembro', 'Dezembro']
|
||||
const MONTHS_AR = ['يناير', 'فبراير', 'مارس', 'أبريل', 'مايو', 'يونيو', 'يوليو', 'أغسطس', 'سبتمبر', 'أكتوبر', 'نوفمبر', 'ديسمبر']
|
||||
|
||||
function hexToRgba(hex: string, alpha: number): string {
|
||||
@@ -37,9 +42,10 @@ export default function VacayMonthCard({
|
||||
onCellClick, companyMode, blockWeekends, weekendDays = [0, 6]
|
||||
}: VacayMonthCardProps) {
|
||||
const { language } = useTranslation()
|
||||
const weekdays = language === 'de' ? WEEKDAYS_DE : language === 'es' ? WEEKDAYS_ES : language === 'ar' ? WEEKDAYS_AR : WEEKDAYS_EN
|
||||
const monthNames = language === 'de' ? MONTHS_DE : language === 'es' ? MONTHS_ES : language === 'ar' ? MONTHS_AR : MONTHS_EN
|
||||
|
||||
|
||||
const weekdays = language === 'de' ? WEEKDAYS_DE : language === 'es' ? WEEKDAYS_ES : language === 'fr' ? WEEKDAYS_FR : language === 'br' ? WEEKDAYS_BR : language === 'ar' ? WEEKDAYS_AR : WEEKDAYS_EN
|
||||
const monthNames = language === 'de' ? MONTHS_DE : language === 'es' ? MONTHS_ES : language === 'fr' ? MONTHS_FR : language === 'br' ? MONTHS_BR : language === 'ar' ? MONTHS_AR : MONTHS_EN
|
||||
|
||||
const weeks = useMemo(() => {
|
||||
const firstDay = new Date(year, month, 1)
|
||||
const daysInMonth = new Date(year, month + 1, 0).getDate()
|
||||
|
||||
@@ -8,6 +8,7 @@ import ru from './translations/ru'
|
||||
import zh from './translations/zh'
|
||||
import nl from './translations/nl'
|
||||
import ar from './translations/ar'
|
||||
import br from './translations/br'
|
||||
|
||||
type TranslationStrings = Record<string, string | { name: string; category: string }[]>
|
||||
|
||||
@@ -17,13 +18,14 @@ export const SUPPORTED_LANGUAGES = [
|
||||
{ value: 'es', label: 'Español' },
|
||||
{ value: 'fr', label: 'Français' },
|
||||
{ value: 'nl', label: 'Nederlands' },
|
||||
{ value: 'br', label: 'Português (Brasil)' },
|
||||
{ value: 'ru', label: 'Русский' },
|
||||
{ value: 'zh', label: '中文' },
|
||||
{ value: 'ar', label: 'العربية' },
|
||||
] as const
|
||||
|
||||
const translations: Record<string, TranslationStrings> = { de, en, es, fr, ru, zh, nl, ar }
|
||||
const LOCALES: Record<string, string> = { de: 'de-DE', en: 'en-US', es: 'es-ES', fr: 'fr-FR', ru: 'ru-RU', zh: 'zh-CN', nl: 'nl-NL', ar: 'ar-SA' }
|
||||
const translations: Record<string, TranslationStrings> = { de, en, es, fr, ru, zh, nl, ar, br }
|
||||
const LOCALES: Record<string, string> = { de: 'de-DE', en: 'en-US', es: 'es-ES', fr: 'fr-FR', ru: 'ru-RU', zh: 'zh-CN', nl: 'nl-NL', ar: 'ar-SA', br: 'pt-BR' }
|
||||
const RTL_LANGUAGES = new Set(['ar'])
|
||||
|
||||
export function getLocaleForLanguage(language: string): string {
|
||||
@@ -31,6 +33,7 @@ export function getLocaleForLanguage(language: string): string {
|
||||
}
|
||||
|
||||
export function getIntlLanguage(language: string): string {
|
||||
if (language === 'br') return 'pt-BR'
|
||||
return ['de', 'es', 'fr', 'ru', 'zh', 'nl', 'ar'].includes(language) ? language : 'en'
|
||||
}
|
||||
|
||||
|
||||
@@ -793,6 +793,7 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
||||
'reservations.pendingSave': 'سيتم الحفظ…',
|
||||
'reservations.uploading': 'جارٍ الرفع...',
|
||||
'reservations.attachFile': 'إرفاق ملف',
|
||||
'reservations.linkExisting': 'ربط ملف موجود',
|
||||
'reservations.toast.saveError': 'فشل الحفظ',
|
||||
'reservations.toast.updateError': 'فشل التحديث',
|
||||
'reservations.toast.deleteError': 'فشل الحذف',
|
||||
|
||||
1233
client/src/i18n/translations/br.ts
Normal file
1233
client/src/i18n/translations/br.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -803,6 +803,7 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
||||
'reservations.pendingSave': 'wird gespeichert…',
|
||||
'reservations.uploading': 'Wird hochgeladen...',
|
||||
'reservations.attachFile': 'Datei anhängen',
|
||||
'reservations.linkExisting': 'Vorhandene verknüpfen',
|
||||
'reservations.linkAssignment': 'Mit Tagesplanung verknüpfen',
|
||||
'reservations.pickAssignment': 'Zuordnung aus dem Plan wählen...',
|
||||
'reservations.noAssignment': 'Keine Verknüpfung',
|
||||
|
||||
@@ -797,6 +797,7 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
'reservations.pendingSave': 'will be saved…',
|
||||
'reservations.uploading': 'Uploading...',
|
||||
'reservations.attachFile': 'Attach file',
|
||||
'reservations.linkExisting': 'Link existing file',
|
||||
'reservations.toast.saveError': 'Failed to save',
|
||||
'reservations.toast.updateError': 'Failed to update',
|
||||
'reservations.toast.deleteError': 'Failed to delete',
|
||||
|
||||
@@ -749,6 +749,7 @@ const es: Record<string, string> = {
|
||||
'reservations.pendingSave': 'se guardará…',
|
||||
'reservations.uploading': 'Subiendo...',
|
||||
'reservations.attachFile': 'Adjuntar archivo',
|
||||
'reservations.linkExisting': 'Vincular archivo existente',
|
||||
'reservations.toast.saveError': 'No se pudo guardar',
|
||||
'reservations.toast.updateError': 'No se pudo actualizar',
|
||||
'reservations.toast.deleteError': 'No se pudo eliminar',
|
||||
|
||||
@@ -5,13 +5,13 @@ const fr: Record<string, string> = {
|
||||
'common.delete': 'Supprimer',
|
||||
'common.edit': 'Modifier',
|
||||
'common.add': 'Ajouter',
|
||||
'common.loading': 'Chargement...',
|
||||
'common.loading': 'Chargement…',
|
||||
'common.error': 'Erreur',
|
||||
'common.back': 'Retour',
|
||||
'common.all': 'Tout',
|
||||
'common.close': 'Fermer',
|
||||
'common.open': 'Ouvrir',
|
||||
'common.upload': 'Téléverser',
|
||||
'common.upload': 'Importer',
|
||||
'common.search': 'Rechercher',
|
||||
'common.confirm': 'Confirmer',
|
||||
'common.ok': 'OK',
|
||||
@@ -24,10 +24,10 @@ const fr: Record<string, string> = {
|
||||
'common.name': 'Nom',
|
||||
'common.email': 'E-mail',
|
||||
'common.password': 'Mot de passe',
|
||||
'common.saving': 'Enregistrement...',
|
||||
'common.saving': 'Enregistrement…',
|
||||
'common.update': 'Mettre à jour',
|
||||
'common.change': 'Modifier',
|
||||
'common.uploading': 'Téléversement…',
|
||||
'common.uploading': 'Import en cours…',
|
||||
'common.backToPlanning': 'Retour à la planification',
|
||||
'common.reset': 'Réinitialiser',
|
||||
|
||||
@@ -44,7 +44,7 @@ const fr: Record<string, string> = {
|
||||
|
||||
// Dashboard
|
||||
'dashboard.title': 'Mes voyages',
|
||||
'dashboard.subtitle.loading': 'Chargement des voyages...',
|
||||
'dashboard.subtitle.loading': 'Chargement des voyages…',
|
||||
'dashboard.subtitle.trips': '{count} voyages ({archived} archivés)',
|
||||
'dashboard.subtitle.empty': 'Commencez votre premier voyage',
|
||||
'dashboard.subtitle.activeOne': '{count} voyage actif',
|
||||
@@ -54,8 +54,8 @@ const fr: Record<string, string> = {
|
||||
'dashboard.gridView': 'Vue en grille',
|
||||
'dashboard.listView': 'Vue en liste',
|
||||
'dashboard.currency': 'Devise',
|
||||
'dashboard.timezone': 'Fuseaux horaires',
|
||||
'dashboard.localTime': 'Local',
|
||||
'dashboard.timezone': 'Fuseau horaire',
|
||||
'dashboard.localTime': 'Heure locale',
|
||||
'dashboard.timezoneCustomTitle': 'Fuseau horaire personnalisé',
|
||||
'dashboard.timezoneCustomLabelPlaceholder': 'Libellé (facultatif)',
|
||||
'dashboard.timezoneCustomTzPlaceholder': 'ex. America/New_York',
|
||||
@@ -105,7 +105,7 @@ const fr: Record<string, string> = {
|
||||
'dashboard.addMembers': 'Compagnons de voyage',
|
||||
'dashboard.addMember': 'Ajouter un membre',
|
||||
'dashboard.coverSaved': 'Image de couverture enregistrée',
|
||||
'dashboard.coverUploadError': 'Échec du téléversement',
|
||||
'dashboard.coverUploadError': 'Échec de l\'import',
|
||||
'dashboard.coverRemoveError': 'Échec de la suppression',
|
||||
'dashboard.titleRequired': 'Le titre est obligatoire',
|
||||
'dashboard.endDateError': 'La date de fin doit être postérieure à la date de début',
|
||||
@@ -115,7 +115,7 @@ const fr: Record<string, string> = {
|
||||
'settings.subtitle': 'Configurez vos paramètres personnels',
|
||||
'settings.map': 'Carte',
|
||||
'settings.mapTemplate': 'Modèle de carte',
|
||||
'settings.mapTemplatePlaceholder.select': 'Sélectionner un modèle...',
|
||||
'settings.mapTemplatePlaceholder.select': 'Sélectionner un modèle…',
|
||||
'settings.mapDefaultHint': 'Laissez vide pour OpenStreetMap (par défaut)',
|
||||
'settings.mapTemplatePlaceholder': 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||
'settings.mapHint': 'Modèle d\'URL pour les tuiles de carte',
|
||||
@@ -127,7 +127,7 @@ const fr: Record<string, string> = {
|
||||
'settings.mapsKeyHint': 'Pour la recherche de lieux. Nécessite l\'API Places (New). Obtenez-la sur console.cloud.google.com',
|
||||
'settings.weatherKey': 'Clé API OpenWeatherMap',
|
||||
'settings.weatherKeyHint': 'Pour les données météo. Gratuit sur openweathermap.org/api',
|
||||
'settings.keyPlaceholder': 'Saisir la clé...',
|
||||
'settings.keyPlaceholder': 'Saisir la clé…',
|
||||
'settings.configured': 'Configuré',
|
||||
'settings.saveKeys': 'Enregistrer les clés',
|
||||
'settings.display': 'Affichage',
|
||||
@@ -211,15 +211,15 @@ const fr: Record<string, string> = {
|
||||
'settings.toast.keysSaved': 'Clés API enregistrées',
|
||||
'settings.toast.displaySaved': 'Paramètres d\'affichage enregistrés',
|
||||
'settings.toast.profileSaved': 'Profil enregistré',
|
||||
'settings.uploadAvatar': 'Téléverser une photo de profil',
|
||||
'settings.uploadAvatar': 'Importer une photo de profil',
|
||||
'settings.removeAvatar': 'Supprimer la photo de profil',
|
||||
'settings.avatarUploaded': 'Photo de profil mise à jour',
|
||||
'settings.avatarRemoved': 'Photo de profil supprimée',
|
||||
'settings.avatarError': 'Échec du téléversement',
|
||||
'settings.avatarError': 'Échec de l\'import',
|
||||
|
||||
// Login
|
||||
'login.error': 'Échec de la connexion. Veuillez vérifier vos identifiants.',
|
||||
'login.tagline': 'Vos voyages.\nVotre plan.',
|
||||
'login.tagline': 'Vos voyages.\nVotre organisation.',
|
||||
'login.description': 'Planifiez vos voyages en collaboration avec des cartes interactives, des budgets et la synchronisation en temps réel.',
|
||||
'login.features.maps': 'Cartes interactives',
|
||||
'login.features.mapsDesc': 'Google Places, itinéraires et regroupement',
|
||||
@@ -234,7 +234,7 @@ const fr: Record<string, string> = {
|
||||
'login.features.bookings': 'Réservations',
|
||||
'login.features.bookingsDesc': 'Vols, hôtels, restaurants et plus',
|
||||
'login.features.files': 'Documents',
|
||||
'login.features.filesDesc': 'Téléversez et gérez vos documents',
|
||||
'login.features.filesDesc': 'Importez et gérez vos documents',
|
||||
'login.features.routes': 'Itinéraires intelligents',
|
||||
'login.features.routesDesc': 'Optimisation automatique et export Google Maps',
|
||||
'login.selfHosted': 'Auto-hébergé · Open Source · Vos données restent les vôtres',
|
||||
@@ -285,7 +285,7 @@ const fr: Record<string, string> = {
|
||||
'register.minChars': 'Min. 6 caractères',
|
||||
'register.confirmPassword': 'Confirmer le mot de passe',
|
||||
'register.repeatPassword': 'Répéter le mot de passe',
|
||||
'register.registering': 'Inscription en cours...',
|
||||
'register.registering': 'Inscription en cours…',
|
||||
'register.register': 'S\'inscrire',
|
||||
'register.hasAccount': 'Vous avez déjà un compte ?',
|
||||
'register.signIn': 'Se connecter',
|
||||
@@ -368,7 +368,7 @@ const fr: Record<string, string> = {
|
||||
|
||||
// File Types
|
||||
'admin.fileTypes': 'Types de fichiers autorisés',
|
||||
'admin.fileTypesHint': 'Configurez les types de fichiers que les utilisateurs peuvent téléverser.',
|
||||
'admin.fileTypesHint': 'Configurez les types de fichiers que les utilisateurs peuvent importer.',
|
||||
'admin.fileTypesFormat': 'Extensions séparées par des virgules (ex. jpg,png,pdf,doc). Utilisez * pour autoriser tous les types.',
|
||||
'admin.fileTypesSaved': 'Paramètres des types de fichiers enregistrés',
|
||||
|
||||
@@ -408,11 +408,11 @@ const fr: Record<string, string> = {
|
||||
'admin.addons.catalog.budget.description': 'Suivez les dépenses et planifiez votre budget de voyage',
|
||||
'admin.addons.catalog.documents.name': 'Documents',
|
||||
'admin.addons.catalog.documents.description': 'Stockez et gérez vos documents de voyage',
|
||||
'admin.addons.catalog.vacay.name': 'Vacay',
|
||||
'admin.addons.catalog.vacay.name': 'Vacances',
|
||||
'admin.addons.catalog.vacay.description': 'Planificateur de vacances personnel avec vue calendrier',
|
||||
'admin.addons.catalog.atlas.name': 'Atlas',
|
||||
'admin.addons.catalog.atlas.description': 'Carte du monde avec pays visités et statistiques de voyage',
|
||||
'admin.addons.catalog.collab.name': 'Collab',
|
||||
'admin.addons.catalog.collab.name': 'Collaboration',
|
||||
'admin.addons.catalog.collab.description': 'Notes en temps réel, sondages et chat pour la planification de voyage',
|
||||
'admin.addons.subtitleBefore': 'Activez ou désactivez des fonctionnalités pour personnaliser votre expérience ',
|
||||
'admin.addons.subtitleAfter': '.',
|
||||
@@ -437,7 +437,7 @@ const fr: Record<string, string> = {
|
||||
'admin.weather.climateDesc': 'Moyennes des 85 dernières années pour les jours au-delà des prévisions de 16 jours',
|
||||
'admin.weather.requests': '10 000 requêtes / jour',
|
||||
'admin.weather.requestsDesc': 'Gratuit, aucune clé API requise',
|
||||
'admin.weather.locationHint': 'La météo est basée sur le premier lieu avec des coordonnées de chaque jour. Si aucun lieu n\'est assigné à un jour, un lieu de la liste est utilisé comme référence.',
|
||||
'admin.weather.locationHint': 'La météo est basée sur le premier lieu avec des coordonnées de chaque jour. Si aucun lieu n\'est attribué à un jour, un lieu de la liste est utilisé comme référence.',
|
||||
|
||||
// MCP Tokens
|
||||
'admin.tabs.mcpTokens': 'Tokens MCP',
|
||||
@@ -464,8 +464,8 @@ const fr: Record<string, string> = {
|
||||
'admin.github.showDetails': 'Afficher les détails',
|
||||
'admin.github.hideDetails': 'Masquer les détails',
|
||||
'admin.github.loadMore': 'Charger plus',
|
||||
'admin.github.loading': 'Chargement...',
|
||||
'admin.github.support': 'Aide à continuer le développement de TREK',
|
||||
'admin.github.loading': 'Chargement…',
|
||||
'admin.github.support': 'Aidez à poursuivre le développement de TREK',
|
||||
'admin.github.error': 'Impossible de charger les versions',
|
||||
'admin.github.by': 'par',
|
||||
|
||||
@@ -475,7 +475,7 @@ const fr: Record<string, string> = {
|
||||
'admin.update.install': 'Installer la mise à jour',
|
||||
'admin.update.confirmTitle': 'Installer la mise à jour ?',
|
||||
'admin.update.confirmText': 'TREK sera mis à jour de {current} vers {version}. Le serveur redémarrera automatiquement ensuite.',
|
||||
'admin.update.dataInfo': 'Toutes vos données (voyages, utilisateurs, clés API, téléversements, Vacay, Atlas, budgets) seront préservées.',
|
||||
'admin.update.dataInfo': 'Toutes vos données (voyages, utilisateurs, clés API, importations, Vacances, Atlas, budgets) seront préservées.',
|
||||
'admin.update.warning': 'L\'application sera brièvement indisponible pendant le redémarrage.',
|
||||
'admin.update.confirm': 'Mettre à jour maintenant',
|
||||
'admin.update.installing': 'Mise à jour…',
|
||||
@@ -488,7 +488,7 @@ const fr: Record<string, string> = {
|
||||
'admin.update.reloadHint': 'Veuillez recharger la page dans quelques secondes.',
|
||||
|
||||
// Vacay addon
|
||||
'vacay.subtitle': 'Planifiez et gérez vos jours de congé',
|
||||
'vacay.subtitle': 'Planifiez et gérez vos jours de congés',
|
||||
'vacay.settings': 'Paramètres',
|
||||
'vacay.year': 'Année',
|
||||
'vacay.addYear': 'Ajouter une année',
|
||||
@@ -518,6 +518,14 @@ const fr: Record<string, string> = {
|
||||
'vacay.used': 'Utilisés',
|
||||
'vacay.remaining': 'Restants',
|
||||
'vacay.carriedOver': 'de {year}',
|
||||
'vacay.weekendDays': 'Jours de week-end',
|
||||
'vacay.mon': 'Lun',
|
||||
'vacay.tue': 'Mar',
|
||||
'vacay.wed': 'Mer',
|
||||
'vacay.thu': 'Jeu',
|
||||
'vacay.fri': 'Ven',
|
||||
'vacay.sat': 'Sam',
|
||||
'vacay.sun': 'Dim',
|
||||
'vacay.blockWeekends': 'Bloquer les week-ends',
|
||||
'vacay.blockWeekendsHint': 'Empêcher les entrées de vacances les samedis et dimanches',
|
||||
'vacay.publicHolidays': 'Jours fériés',
|
||||
@@ -535,11 +543,11 @@ const fr: Record<string, string> = {
|
||||
'vacay.shareEmailPlaceholder': 'E-mail de l\'utilisateur TREK',
|
||||
'vacay.shareSuccess': 'Plan partagé avec succès',
|
||||
'vacay.shareError': 'Impossible de partager le plan',
|
||||
'vacay.dissolve': 'Dissoudre la fusion',
|
||||
'vacay.dissolve': 'Séparer les calendriers',
|
||||
'vacay.dissolveHint': 'Séparer à nouveau les calendriers. Vos entrées seront conservées.',
|
||||
'vacay.dissolveAction': 'Dissoudre',
|
||||
'vacay.dissolved': 'Calendrier séparé',
|
||||
'vacay.fusedWith': 'Fusionné avec',
|
||||
'vacay.fusedWith': 'Partagé avec',
|
||||
'vacay.you': 'vous',
|
||||
'vacay.noData': 'Aucune donnée',
|
||||
'vacay.changeColor': 'Changer la couleur',
|
||||
@@ -625,14 +633,14 @@ const fr: Record<string, string> = {
|
||||
'trip.tabs.packingShort': 'Bagages',
|
||||
'trip.tabs.budget': 'Budget',
|
||||
'trip.tabs.files': 'Fichiers',
|
||||
'trip.loading': 'Chargement du voyage...',
|
||||
'trip.loading': 'Chargement du voyage…',
|
||||
'trip.mobilePlan': 'Plan',
|
||||
'trip.mobilePlaces': 'Lieux',
|
||||
'trip.toast.placeUpdated': 'Lieu mis à jour',
|
||||
'trip.toast.placeAdded': 'Lieu ajouté',
|
||||
'trip.toast.placeDeleted': 'Lieu supprimé',
|
||||
'trip.toast.selectDay': 'Veuillez d\'abord sélectionner un jour',
|
||||
'trip.toast.assignedToDay': 'Lieu assigné au jour',
|
||||
'trip.toast.assignedToDay': 'Lieu attribué au planning',
|
||||
'trip.toast.reorderError': 'Échec de la réorganisation',
|
||||
'trip.toast.reservationUpdated': 'Réservation mise à jour',
|
||||
'trip.toast.reservationAdded': 'Réservation ajoutée',
|
||||
@@ -650,7 +658,7 @@ const fr: Record<string, string> = {
|
||||
'dayplan.totalCost': 'Coût total',
|
||||
'dayplan.days': 'Jours',
|
||||
'dayplan.dayN': 'Jour {n}',
|
||||
'dayplan.calculating': 'Calcul en cours...',
|
||||
'dayplan.calculating': 'Calcul en cours…',
|
||||
'dayplan.route': 'Itinéraire',
|
||||
'dayplan.optimize': 'Optimiser',
|
||||
'dayplan.optimized': 'Itinéraire optimisé',
|
||||
@@ -665,11 +673,11 @@ const fr: Record<string, string> = {
|
||||
'dayplan.pdfError': 'Échec de l\'export PDF',
|
||||
|
||||
// Places Sidebar
|
||||
'places.addPlace': 'Ajouter un lieu/activité',
|
||||
'places.addPlace': 'Ajouter un lieu ou une activité',
|
||||
'places.assignToDay': 'Ajouter à quel jour ?',
|
||||
'places.all': 'Tous',
|
||||
'places.unplanned': 'Non planifiés',
|
||||
'places.search': 'Rechercher des lieux...',
|
||||
'places.search': 'Rechercher des lieux…',
|
||||
'places.allCategories': 'Toutes les catégories',
|
||||
'places.count': '{count} lieux',
|
||||
'places.countSingular': '1 lieu',
|
||||
@@ -679,7 +687,7 @@ const fr: Record<string, string> = {
|
||||
'places.formName': 'Nom',
|
||||
'places.formNamePlaceholder': 'ex. Tour Eiffel',
|
||||
'places.formDescription': 'Description',
|
||||
'places.formDescriptionPlaceholder': 'Brève description...',
|
||||
'places.formDescriptionPlaceholder': 'Brève description…',
|
||||
'places.formAddress': 'Adresse',
|
||||
'places.formAddressPlaceholder': 'Rue, ville, pays',
|
||||
'places.formLat': 'Latitude (ex. 48.8566)',
|
||||
@@ -693,10 +701,10 @@ const fr: Record<string, string> = {
|
||||
'places.endTimeBeforeStart': 'L\'heure de fin est antérieure à l\'heure de début',
|
||||
'places.timeCollision': 'Chevauchement horaire avec :',
|
||||
'places.formWebsite': 'Site web',
|
||||
'places.formNotesPlaceholder': 'Notes personnelles...',
|
||||
'places.formNotesPlaceholder': 'Notes personnelles…',
|
||||
'places.formReservation': 'Réservation',
|
||||
'places.reservationNotesPlaceholder': 'Notes de réservation, numéro de confirmation...',
|
||||
'places.mapsSearchPlaceholder': 'Rechercher des lieux...',
|
||||
'places.reservationNotesPlaceholder': 'Notes de réservation, numéro de confirmation…',
|
||||
'places.mapsSearchPlaceholder': 'Rechercher des lieux…',
|
||||
'places.mapsSearchError': 'La recherche de lieu a échoué.',
|
||||
'places.osmHint': 'Recherche via OpenStreetMap (pas de photos, horaires ni notes). Ajoutez une clé API Google dans les paramètres pour plus de détails.',
|
||||
'places.osmActive': 'Recherche via OpenStreetMap (pas de photos, notes ni horaires). Ajoutez une clé API Google dans les paramètres pour des données enrichies.',
|
||||
@@ -741,7 +749,7 @@ const fr: Record<string, string> = {
|
||||
'reservations.time': 'Heure',
|
||||
'reservations.timeAlt': 'Heure (alternative, ex. 19h30)',
|
||||
'reservations.notes': 'Notes',
|
||||
'reservations.notesPlaceholder': 'Notes supplémentaires...',
|
||||
'reservations.notesPlaceholder': 'Notes supplémentaires…',
|
||||
'reservations.meta.airline': 'Compagnie aérienne',
|
||||
'reservations.meta.flightNumber': 'N° de vol',
|
||||
'reservations.meta.from': 'De',
|
||||
@@ -771,14 +779,14 @@ const fr: Record<string, string> = {
|
||||
'reservations.confirm.delete': 'Voulez-vous vraiment supprimer la réservation « {name} » ?',
|
||||
'reservations.toast.updated': 'Réservation mise à jour',
|
||||
'reservations.toast.removed': 'Réservation supprimée',
|
||||
'reservations.toast.fileUploaded': 'Fichier téléversé',
|
||||
'reservations.toast.uploadError': 'Échec du téléversement',
|
||||
'reservations.toast.fileUploaded': 'Fichier importé',
|
||||
'reservations.toast.uploadError': 'Échec de l\'import',
|
||||
'reservations.newTitle': 'Nouvelle réservation',
|
||||
'reservations.bookingType': 'Type de réservation',
|
||||
'reservations.titleLabel': 'Titre',
|
||||
'reservations.titlePlaceholder': 'ex. Lufthansa LH123, Hôtel Adlon, ...',
|
||||
'reservations.titlePlaceholder': 'ex. Lufthansa LH123, Hôtel Adlon, …',
|
||||
'reservations.locationAddress': 'Lieu / Adresse',
|
||||
'reservations.locationPlaceholder': 'Adresse, aéroport, hôtel...',
|
||||
'reservations.locationPlaceholder': 'Adresse, aéroport, hôtel…',
|
||||
'reservations.confirmationCode': 'Code de réservation',
|
||||
'reservations.confirmationPlaceholder': 'ex. ABC12345',
|
||||
'reservations.day': 'Jour',
|
||||
@@ -786,21 +794,22 @@ const fr: Record<string, string> = {
|
||||
'reservations.place': 'Lieu',
|
||||
'reservations.noPlace': 'Aucun lieu',
|
||||
'reservations.pendingSave': 'sera enregistré…',
|
||||
'reservations.uploading': 'Téléversement...',
|
||||
'reservations.uploading': 'Importation…',
|
||||
'reservations.attachFile': 'Joindre un fichier',
|
||||
'reservations.linkExisting': 'Lier un fichier existant',
|
||||
'reservations.toast.saveError': 'Échec de l\'enregistrement',
|
||||
'reservations.toast.updateError': 'Échec de la mise à jour',
|
||||
'reservations.toast.deleteError': 'Échec de la suppression',
|
||||
'reservations.confirm.remove': 'Supprimer la réservation pour « {name} » ?',
|
||||
'reservations.linkAssignment': 'Lier à l\'assignation du jour',
|
||||
'reservations.pickAssignment': 'Sélectionnez une assignation de votre plan...',
|
||||
'reservations.linkAssignment': 'Lier à l\'affectation du jour',
|
||||
'reservations.pickAssignment': 'Sélectionnez une affectation de votre plan…',
|
||||
'reservations.noAssignment': 'Aucun lien (autonome)',
|
||||
|
||||
// Budget
|
||||
'budget.title': 'Budget',
|
||||
'budget.emptyTitle': 'Aucun budget créé',
|
||||
'budget.emptyText': 'Créez des catégories et des entrées pour planifier votre budget de voyage',
|
||||
'budget.emptyPlaceholder': 'Nom de la catégorie...',
|
||||
'budget.emptyPlaceholder': 'Nom de la catégorie…',
|
||||
'budget.createCategory': 'Créer une catégorie',
|
||||
'budget.category': 'Catégorie',
|
||||
'budget.categoryName': 'Nom de la catégorie',
|
||||
@@ -818,24 +827,24 @@ const fr: Record<string, string> = {
|
||||
'budget.total': 'Total',
|
||||
'budget.totalBudget': 'Budget total',
|
||||
'budget.byCategory': 'Par catégorie',
|
||||
'budget.editTooltip': 'Cliquer pour modifier',
|
||||
'budget.editTooltip': 'Cliquez pour modifier',
|
||||
'budget.confirm.deleteCategory': 'Voulez-vous vraiment supprimer la catégorie « {name} » avec {count} entrées ?',
|
||||
'budget.deleteCategory': 'Supprimer la catégorie',
|
||||
'budget.perPerson': 'Par personne',
|
||||
'budget.paid': 'Payé',
|
||||
'budget.open': 'Ouvert',
|
||||
'budget.noMembers': 'Aucun membre assigné',
|
||||
'budget.noMembers': 'Aucun membre attribué',
|
||||
|
||||
// Files
|
||||
'files.title': 'Fichiers',
|
||||
'files.count': '{count} fichiers',
|
||||
'files.countSingular': '1 fichier',
|
||||
'files.uploaded': '{count} téléversés',
|
||||
'files.uploadError': 'Échec du téléversement',
|
||||
'files.uploaded': '{count} importés',
|
||||
'files.uploadError': 'Échec de l\'import',
|
||||
'files.dropzone': 'Déposez les fichiers ici',
|
||||
'files.dropzoneHint': 'ou cliquez pour parcourir',
|
||||
'files.allowedTypes': 'Images, PDF, DOC, DOCX, XLS, XLSX, TXT, CSV · Max 50 Mo',
|
||||
'files.uploading': 'Téléversement...',
|
||||
'files.uploading': 'Importation…',
|
||||
'files.filterAll': 'Tous',
|
||||
'files.filterPdf': 'PDF',
|
||||
'files.filterImages': 'Images',
|
||||
@@ -843,7 +852,7 @@ const fr: Record<string, string> = {
|
||||
'files.filterCollab': 'Notes Collab',
|
||||
'files.sourceCollab': 'Depuis les notes Collab',
|
||||
'files.empty': 'Aucun fichier',
|
||||
'files.emptyHint': 'Téléversez des fichiers pour les joindre à votre voyage',
|
||||
'files.emptyHint': 'Importez des fichiers pour les joindre à votre voyage',
|
||||
'files.openTab': 'Ouvrir dans un nouvel onglet',
|
||||
'files.confirm.delete': 'Voulez-vous vraiment supprimer ce fichier ?',
|
||||
'files.toast.deleted': 'Fichier supprimé',
|
||||
@@ -862,18 +871,18 @@ const fr: Record<string, string> = {
|
||||
'files.assignTitle': 'Assigner le fichier',
|
||||
'files.assignPlace': 'Lieu',
|
||||
'files.assignBooking': 'Réservation',
|
||||
'files.unassigned': 'Non assigné',
|
||||
'files.unassigned': 'Non attribué',
|
||||
'files.unlink': 'Supprimer le lien',
|
||||
'files.toast.trashed': 'Déplacé dans la corbeille',
|
||||
'files.toast.restored': 'Fichier restauré',
|
||||
'files.toast.trashEmptied': 'Corbeille vidée',
|
||||
'files.toast.assigned': 'Fichier assigné',
|
||||
'files.toast.assigned': 'Fichier attribué',
|
||||
'files.toast.assignError': 'Échec de l\'assignation',
|
||||
'files.toast.restoreError': 'Échec de la restauration',
|
||||
'files.confirm.permanentDelete': 'Supprimer définitivement ce fichier ? Cette action est irréversible.',
|
||||
'files.confirm.emptyTrash': 'Supprimer définitivement tous les fichiers de la corbeille ? Cette action est irréversible.',
|
||||
'files.noteLabel': 'Note',
|
||||
'files.notePlaceholder': 'Ajouter une note...',
|
||||
'files.notePlaceholder': 'Ajouter une note…',
|
||||
|
||||
// Packing
|
||||
'packing.title': 'Liste de bagages',
|
||||
@@ -885,8 +894,8 @@ const fr: Record<string, string> = {
|
||||
'packing.suggestionsTitle': 'Ajouter des suggestions',
|
||||
'packing.allSuggested': 'Toutes les suggestions ajoutées',
|
||||
'packing.allPacked': 'Tout est emballé !',
|
||||
'packing.addPlaceholder': 'Ajouter un nouvel article...',
|
||||
'packing.categoryPlaceholder': 'Catégorie...',
|
||||
'packing.addPlaceholder': 'Ajouter un nouvel article…',
|
||||
'packing.categoryPlaceholder': 'Catégorie…',
|
||||
'packing.filterAll': 'Tous',
|
||||
'packing.filterOpen': 'À faire',
|
||||
'packing.filterDone': 'Fait',
|
||||
@@ -1000,10 +1009,10 @@ const fr: Record<string, string> = {
|
||||
|
||||
// Backup (Admin)
|
||||
'backup.title': 'Sauvegarde des données',
|
||||
'backup.subtitle': 'Base de données et tous les fichiers téléversés',
|
||||
'backup.subtitle': 'Base de données et tous les fichiers importés',
|
||||
'backup.refresh': 'Actualiser',
|
||||
'backup.upload': 'Téléverser une sauvegarde',
|
||||
'backup.uploading': 'Téléversement…',
|
||||
'backup.upload': 'Importer une sauvegarde',
|
||||
'backup.uploading': 'Importation…',
|
||||
'backup.create': 'Créer une sauvegarde',
|
||||
'backup.creating': 'Création…',
|
||||
'backup.empty': 'Aucune sauvegarde',
|
||||
@@ -1011,14 +1020,14 @@ const fr: Record<string, string> = {
|
||||
'backup.download': 'Télécharger',
|
||||
'backup.restore': 'Restaurer',
|
||||
'backup.confirm.restore': 'Restaurer la sauvegarde « {name} » ?\n\nToutes les données actuelles seront remplacées par la sauvegarde.',
|
||||
'backup.confirm.uploadRestore': 'Téléverser et restaurer le fichier de sauvegarde « {name} » ?\n\nToutes les données actuelles seront écrasées.',
|
||||
'backup.confirm.uploadRestore': 'Importer et restaurer le fichier de sauvegarde « {name} » ?\n\nToutes les données actuelles seront écrasées.',
|
||||
'backup.confirm.delete': 'Supprimer la sauvegarde « {name} » ?',
|
||||
'backup.toast.loadError': 'Impossible de charger les sauvegardes',
|
||||
'backup.toast.created': 'Sauvegarde créée avec succès',
|
||||
'backup.toast.createError': 'Impossible de créer la sauvegarde',
|
||||
'backup.toast.restored': 'Sauvegarde restaurée. La page va se recharger…',
|
||||
'backup.toast.restoreError': 'Échec de la restauration',
|
||||
'backup.toast.uploadError': 'Échec du téléversement',
|
||||
'backup.toast.uploadError': 'Échec de l\'import',
|
||||
'backup.toast.deleted': 'Sauvegarde supprimée',
|
||||
'backup.toast.deleteError': 'Échec de la suppression',
|
||||
'backup.toast.downloadError': 'Échec du téléchargement',
|
||||
@@ -1044,15 +1053,15 @@ const fr: Record<string, string> = {
|
||||
// Photos
|
||||
'photos.allDays': 'Tous les jours',
|
||||
'photos.noPhotos': 'Aucune photo',
|
||||
'photos.uploadHint': 'Téléversez vos photos de voyage',
|
||||
'photos.uploadHint': 'Importez vos photos de voyage',
|
||||
'photos.clickToSelect': 'ou cliquez pour sélectionner',
|
||||
'photos.linkPlace': 'Lier au lieu',
|
||||
'photos.noPlace': 'Aucun lieu',
|
||||
'photos.uploadN': '{n} photo(s) téléversées',
|
||||
'photos.uploadN': '{n} photo(s) importée(s)',
|
||||
|
||||
// Backup restore modal
|
||||
'backup.restoreConfirmTitle': 'Restaurer la sauvegarde ?',
|
||||
'backup.restoreWarning': 'Toutes les données actuelles (voyages, lieux, utilisateurs, téléversements) seront définitivement remplacées par la sauvegarde. Cette action est irréversible.',
|
||||
'backup.restoreWarning': 'Toutes les données actuelles (voyages, lieux, utilisateurs, importations) seront définitivement remplacées par la sauvegarde. Cette action est irréversible.',
|
||||
'backup.restoreTip': 'Conseil : créez une sauvegarde de l\'état actuel avant de restaurer.',
|
||||
'backup.restoreConfirm': 'Oui, restaurer',
|
||||
|
||||
@@ -1089,8 +1098,8 @@ const fr: Record<string, string> = {
|
||||
'planner.placeN': '{n} lieux',
|
||||
'planner.addNote': 'Ajouter une note',
|
||||
'planner.noEntries': 'Aucune entrée pour ce jour',
|
||||
'planner.addPlace': 'Ajouter un lieu/activité',
|
||||
'planner.addPlaceShort': '+ Ajouter un lieu/activité',
|
||||
'planner.addPlace': 'Ajouter un lieu ou une activité',
|
||||
'planner.addPlaceShort': '+ Ajouter un lieu ou une activité',
|
||||
'planner.resPending': 'Réservation en attente · ',
|
||||
'planner.resConfirmed': 'Réservation confirmée · ',
|
||||
'planner.notePlaceholder': 'Note…',
|
||||
@@ -1120,7 +1129,7 @@ const fr: Record<string, string> = {
|
||||
'planner.noDays': 'Aucun jour',
|
||||
'planner.editTripToAddDays': 'Modifiez le voyage pour ajouter des jours',
|
||||
'planner.dayCount': '{n} jours',
|
||||
'planner.clickToUnlock': 'Cliquer pour déverrouiller',
|
||||
'planner.clickToUnlock': 'Cliquez pour déverrouiller',
|
||||
'planner.keepPosition': 'Maintenir la position lors de l\'optimisation de l\'itinéraire',
|
||||
'planner.dayDetails': 'Détails du jour',
|
||||
'planner.dayN': 'Jour {n}',
|
||||
@@ -1181,9 +1190,22 @@ const fr: Record<string, string> = {
|
||||
'memories.oldest': 'Plus anciennes',
|
||||
'memories.newest': 'Plus récentes',
|
||||
'memories.allLocations': 'Tous les lieux',
|
||||
'memories.addPhotos': 'Ajouter des photos',
|
||||
'memories.selectPhotos': 'Sélectionner des photos depuis Immich',
|
||||
'memories.selectHint': 'Appuyez sur les photos pour les sélectionner.',
|
||||
'memories.selected': 'sélectionnées',
|
||||
'memories.addSelected': 'Ajouter {count} photos',
|
||||
'memories.alreadyAdded': 'Déjà ajoutée',
|
||||
'memories.private': 'Privé',
|
||||
'memories.stopSharing': 'Arrêter le partage',
|
||||
'memories.tripDates': 'Dates du voyage',
|
||||
'memories.allPhotos': 'Toutes les photos',
|
||||
'memories.confirmShareTitle': 'Partager avec les membres du voyage ?',
|
||||
'memories.confirmShareHint': '{count} photos seront visibles par tous les membres de ce voyage. Vous pourrez rendre des photos privées ultérieurement.',
|
||||
'memories.confirmShareButton': 'Partager les photos',
|
||||
|
||||
// Collab Addon
|
||||
'collab.tabs.chat': 'Chat',
|
||||
'collab.tabs.chat': 'Discussion',
|
||||
'collab.tabs.notes': 'Notes',
|
||||
'collab.tabs.polls': 'Sondages',
|
||||
'collab.whatsNext.title': 'À venir',
|
||||
@@ -1193,7 +1215,7 @@ const fr: Record<string, string> = {
|
||||
'collab.whatsNext.until': 'à',
|
||||
'collab.whatsNext.emptyHint': 'Les activités avec des horaires apparaîtront ici',
|
||||
'collab.chat.send': 'Envoyer',
|
||||
'collab.chat.placeholder': 'Écrire un message...',
|
||||
'collab.chat.placeholder': 'Écrire un message…',
|
||||
'collab.chat.empty': 'Commencez la conversation',
|
||||
'collab.chat.emptyHint': 'Les messages sont partagés avec tous les membres du voyage',
|
||||
'collab.chat.emptyDesc': 'Partagez des idées, des plans et des mises à jour avec votre groupe de voyage',
|
||||
@@ -1210,9 +1232,9 @@ const fr: Record<string, string> = {
|
||||
'collab.notes.emptyHint': 'Commencez à capturer vos idées et plans',
|
||||
'collab.notes.all': 'Toutes',
|
||||
'collab.notes.titlePlaceholder': 'Titre de la note',
|
||||
'collab.notes.contentPlaceholder': 'Écrivez quelque chose...',
|
||||
'collab.notes.contentPlaceholder': 'Écrivez quelque chose…',
|
||||
'collab.notes.categoryPlaceholder': 'Catégorie',
|
||||
'collab.notes.newCategory': 'Nouvelle catégorie...',
|
||||
'collab.notes.newCategory': 'Nouvelle catégorie…',
|
||||
'collab.notes.category': 'Catégorie',
|
||||
'collab.notes.noCategory': 'Sans catégorie',
|
||||
'collab.notes.color': 'Couleur',
|
||||
@@ -1226,7 +1248,7 @@ const fr: Record<string, string> = {
|
||||
'collab.notes.categorySettings': 'Gérer les catégories',
|
||||
'collab.notes.create': 'Créer',
|
||||
'collab.notes.website': 'Site web',
|
||||
'collab.notes.websitePlaceholder': 'https://...',
|
||||
'collab.notes.websitePlaceholder': 'https://…',
|
||||
'collab.notes.attachFiles': 'Joindre des fichiers',
|
||||
'collab.notes.noCategoriesYet': 'Aucune catégorie',
|
||||
'collab.notes.emptyDesc': 'Créez une note pour commencer',
|
||||
|
||||
@@ -788,6 +788,7 @@ const nl: Record<string, string> = {
|
||||
'reservations.pendingSave': 'wordt opgeslagen…',
|
||||
'reservations.uploading': 'Uploaden...',
|
||||
'reservations.attachFile': 'Bestand bijvoegen',
|
||||
'reservations.linkExisting': 'Bestaand bestand koppelen',
|
||||
'reservations.toast.saveError': 'Opslaan mislukt',
|
||||
'reservations.toast.updateError': 'Bijwerken mislukt',
|
||||
'reservations.toast.deleteError': 'Verwijderen mislukt',
|
||||
|
||||
@@ -788,6 +788,7 @@ const ru: Record<string, string> = {
|
||||
'reservations.pendingSave': 'будет сохранено…',
|
||||
'reservations.uploading': 'Загрузка...',
|
||||
'reservations.attachFile': 'Прикрепить файл',
|
||||
'reservations.linkExisting': 'Привязать существующий файл',
|
||||
'reservations.toast.saveError': 'Ошибка сохранения',
|
||||
'reservations.toast.updateError': 'Ошибка обновления',
|
||||
'reservations.toast.deleteError': 'Ошибка удаления',
|
||||
|
||||
@@ -788,6 +788,7 @@ const zh: Record<string, string> = {
|
||||
'reservations.pendingSave': '将被保存…',
|
||||
'reservations.uploading': '上传中...',
|
||||
'reservations.attachFile': '附加文件',
|
||||
'reservations.linkExisting': '关联已有文件',
|
||||
'reservations.toast.saveError': '保存失败',
|
||||
'reservations.toast.updateError': '更新失败',
|
||||
'reservations.toast.deleteError': '删除失败',
|
||||
|
||||
@@ -307,8 +307,22 @@ function runMigrations(db: Database.Database): void {
|
||||
db.prepare("INSERT INTO addons (id, name, type, icon, enabled, sort_order) VALUES (?, ?, ?, ?, ?, ?)").run('memories', 'Photos', 'trip', 'Image', 0, 7);
|
||||
} catch {}
|
||||
},
|
||||
// Migration 44: MCP long-lived API tokens
|
||||
() => db.exec(`
|
||||
() => {
|
||||
// Allow files to be linked to multiple reservations/assignments
|
||||
db.exec(`CREATE TABLE IF NOT EXISTS file_links (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
file_id INTEGER NOT NULL REFERENCES trip_files(id) ON DELETE CASCADE,
|
||||
reservation_id INTEGER REFERENCES reservations(id) ON DELETE CASCADE,
|
||||
assignment_id INTEGER REFERENCES day_assignments(id) ON DELETE CASCADE,
|
||||
place_id INTEGER REFERENCES places(id) ON DELETE CASCADE,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(file_id, reservation_id),
|
||||
UNIQUE(file_id, assignment_id),
|
||||
UNIQUE(file_id, place_id)
|
||||
)`);
|
||||
},
|
||||
// Migration 44: MCP long-lived API tokens
|
||||
() => db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS mcp_tokens (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
@@ -319,23 +333,23 @@ function runMigrations(db: Database.Database): void {
|
||||
last_used_at DATETIME
|
||||
)
|
||||
`),
|
||||
// Migration 45: MCP addon entry
|
||||
() => {
|
||||
try {
|
||||
db.prepare("INSERT OR IGNORE INTO addons (id, name, description, type, icon, enabled, sort_order) VALUES (?, ?, ?, ?, ?, ?, ?)")
|
||||
.run('mcp', 'MCP', 'Model Context Protocol for AI assistant integration', 'global', 'Terminal', 0, 12);
|
||||
} catch {}
|
||||
},
|
||||
// Migration 46: Index on mcp_tokens.token_hash for fast lookup
|
||||
() => db.exec(`
|
||||
// Migration 45: MCP addon entry
|
||||
() => {
|
||||
try {
|
||||
db.prepare("INSERT OR IGNORE INTO addons (id, name, description, type, icon, enabled, sort_order) VALUES (?, ?, ?, ?, ?, ?, ?)")
|
||||
.run('mcp', 'MCP', 'Model Context Protocol for AI assistant integration', 'global', 'Terminal', 0, 12);
|
||||
} catch {}
|
||||
},
|
||||
// Migration 46: Index on mcp_tokens.token_hash for fast lookup
|
||||
() => db.exec(`
|
||||
CREATE INDEX IF NOT EXISTS idx_mcp_tokens_hash ON mcp_tokens(token_hash)
|
||||
`),
|
||||
// Migration 47: Change MCP addon type from 'global' to 'integration'
|
||||
() => {
|
||||
try {
|
||||
db.prepare("UPDATE addons SET type = 'integration' WHERE id = 'mcp'").run();
|
||||
} catch {}
|
||||
},
|
||||
// Migration 47: Change MCP addon type from 'global' to 'integration'
|
||||
() => {
|
||||
try {
|
||||
db.prepare("UPDATE addons SET type = 'integration' WHERE id = 'mcp'").run();
|
||||
} catch {}
|
||||
},
|
||||
];
|
||||
|
||||
if (currentVersion < migrations.length) {
|
||||
|
||||
@@ -82,7 +82,27 @@ router.get('/', authenticate, (req: Request, res: Response) => {
|
||||
|
||||
const where = showTrash ? 'f.trip_id = ? AND f.deleted_at IS NOT NULL' : 'f.trip_id = ? AND f.deleted_at IS NULL';
|
||||
const files = db.prepare(`${FILE_SELECT} WHERE ${where} ORDER BY f.starred DESC, f.created_at DESC`).all(tripId) as TripFile[];
|
||||
res.json({ files: files.map(formatFile) });
|
||||
|
||||
// Get all file_links for this trip's files
|
||||
const fileIds = files.map(f => f.id);
|
||||
let linksMap: Record<number, number[]> = {};
|
||||
if (fileIds.length > 0) {
|
||||
const placeholders = fileIds.map(() => '?').join(',');
|
||||
const links = db.prepare(`SELECT file_id, reservation_id, place_id FROM file_links WHERE file_id IN (${placeholders})`).all(...fileIds) as { file_id: number; reservation_id: number | null; place_id: number | null }[];
|
||||
for (const link of links) {
|
||||
if (!linksMap[link.file_id]) linksMap[link.file_id] = [];
|
||||
linksMap[link.file_id].push(link);
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ files: files.map(f => {
|
||||
const fileLinks = linksMap[f.id] || [];
|
||||
return {
|
||||
...formatFile(f),
|
||||
linked_reservation_ids: fileLinks.filter(l => l.reservation_id).map(l => l.reservation_id),
|
||||
linked_place_ids: fileLinks.filter(l => l.place_id).map(l => l.place_id),
|
||||
};
|
||||
})});
|
||||
});
|
||||
|
||||
// Upload file
|
||||
@@ -239,4 +259,55 @@ router.delete('/trash/empty', authenticate, (req: Request, res: Response) => {
|
||||
res.json({ success: true, deleted: trashed.length });
|
||||
});
|
||||
|
||||
// Link a file to a reservation (many-to-many)
|
||||
router.post('/:id/link', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { tripId, id } = req.params;
|
||||
const { reservation_id, assignment_id, place_id } = req.body;
|
||||
|
||||
const trip = verifyTripOwnership(tripId, authReq.user.id);
|
||||
if (!trip) return res.status(404).json({ error: 'Trip not found' });
|
||||
|
||||
const file = db.prepare('SELECT * FROM trip_files WHERE id = ? AND trip_id = ?').get(id, tripId);
|
||||
if (!file) return res.status(404).json({ error: 'File not found' });
|
||||
|
||||
try {
|
||||
db.prepare('INSERT OR IGNORE INTO file_links (file_id, reservation_id, assignment_id, place_id) VALUES (?, ?, ?, ?)').run(
|
||||
id, reservation_id || null, assignment_id || null, place_id || null
|
||||
);
|
||||
} catch {}
|
||||
|
||||
const links = db.prepare('SELECT * FROM file_links WHERE file_id = ?').all(id);
|
||||
res.json({ success: true, links });
|
||||
});
|
||||
|
||||
// Unlink a file from a reservation
|
||||
router.delete('/:id/link/:linkId', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { tripId, id, linkId } = req.params;
|
||||
|
||||
const trip = verifyTripOwnership(tripId, authReq.user.id);
|
||||
if (!trip) return res.status(404).json({ error: 'Trip not found' });
|
||||
|
||||
db.prepare('DELETE FROM file_links WHERE id = ? AND file_id = ?').run(linkId, id);
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
// Get all links for a file
|
||||
router.get('/:id/links', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { tripId, id } = req.params;
|
||||
|
||||
const trip = verifyTripOwnership(tripId, authReq.user.id);
|
||||
if (!trip) return res.status(404).json({ error: 'Trip not found' });
|
||||
|
||||
const links = db.prepare(`
|
||||
SELECT fl.*, r.title as reservation_title
|
||||
FROM file_links fl
|
||||
LEFT JOIN reservations r ON fl.reservation_id = r.id
|
||||
WHERE fl.file_id = ?
|
||||
`).all(id);
|
||||
res.json({ links });
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
Reference in New Issue
Block a user