KI-AGENT: Terminpicker und Dokumentaufruf verbessert
This commit is contained in:
@@ -84,10 +84,12 @@ export default fp(async (server: FastifyInstance) => {
|
|||||||
|
|
||||||
const urlPath = req.url.split("?")[0]
|
const urlPath = req.url.split("?")[0]
|
||||||
const queryToken = (req.query as any)?.downloadToken
|
const queryToken = (req.query as any)?.downloadToken
|
||||||
|
const isDownloadTokenRoute =
|
||||||
|
(urlPath.startsWith("/api/email/attachments/") && urlPath.endsWith("/download"))
|
||||||
|
|| urlPath.startsWith("/api/files/content/")
|
||||||
const downloadToken =
|
const downloadToken =
|
||||||
typeof queryToken === "string"
|
typeof queryToken === "string"
|
||||||
&& urlPath.startsWith("/api/email/attachments/")
|
&& isDownloadTokenRoute
|
||||||
&& urlPath.endsWith("/download")
|
|
||||||
? queryToken
|
? queryToken
|
||||||
: null
|
: null
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
GetObjectCommand
|
GetObjectCommand
|
||||||
} from "@aws-sdk/client-s3"
|
} from "@aws-sdk/client-s3"
|
||||||
import archiver from "archiver"
|
import archiver from "archiver"
|
||||||
|
import jwt from "jsonwebtoken"
|
||||||
import { secrets } from "../utils/secrets"
|
import { secrets } from "../utils/secrets"
|
||||||
import { saveFile } from "../utils/files"
|
import { saveFile } from "../utils/files"
|
||||||
|
|
||||||
@@ -19,6 +20,17 @@ import {
|
|||||||
|
|
||||||
|
|
||||||
export default async function fileRoutes(server: FastifyInstance) {
|
export default async function fileRoutes(server: FastifyInstance) {
|
||||||
|
const createDownloadUrl = (req: any, fileId: string) => {
|
||||||
|
const downloadToken = jwt.sign({
|
||||||
|
user_id: req.user.user_id,
|
||||||
|
email: req.user.email,
|
||||||
|
tenant_id: req.user.tenant_id,
|
||||||
|
is_admin: Boolean(req.user.is_admin),
|
||||||
|
}, secrets.JWT_SECRET!, { expiresIn: "15m" })
|
||||||
|
|
||||||
|
return `/api/files/content/${fileId}?downloadToken=${encodeURIComponent(downloadToken)}`
|
||||||
|
}
|
||||||
|
|
||||||
const getPortalCustomerId = async (req: any) => {
|
const getPortalCustomerId = async (req: any) => {
|
||||||
const tenantId = req.user?.tenant_id
|
const tenantId = req.user?.tenant_id
|
||||||
const userId = req.user?.user_id
|
const userId = req.user?.user_id
|
||||||
@@ -282,7 +294,7 @@ export default async function fileRoutes(server: FastifyInstance) {
|
|||||||
const file = await loadSingleFileForRequest(req, id)
|
const file = await loadSingleFileForRequest(req, id)
|
||||||
if (!file) return reply.code(404).send({ error: "Not found" })
|
if (!file) return reply.code(404).send({ error: "Not found" })
|
||||||
|
|
||||||
return { ...file, url: `/api/files/content/${file.id}` }
|
return { ...file, url: createDownloadUrl(req, file.id) }
|
||||||
} else {
|
} else {
|
||||||
// -------------------------------------------------
|
// -------------------------------------------------
|
||||||
// MULTIPLE PRESIGNED URLs
|
// MULTIPLE PRESIGNED URLs
|
||||||
@@ -300,7 +312,7 @@ export default async function fileRoutes(server: FastifyInstance) {
|
|||||||
|
|
||||||
const output = selected.map(file => ({
|
const output = selected.map(file => ({
|
||||||
...file,
|
...file,
|
||||||
url: `/api/files/content/${file.id}`
|
url: createDownloadUrl(req, file.id)
|
||||||
}))
|
}))
|
||||||
|
|
||||||
return { files: output }
|
return { files: output }
|
||||||
|
|||||||
@@ -70,7 +70,8 @@
|
|||||||
},
|
},
|
||||||
"disableAndroid": true
|
"disableAndroid": true
|
||||||
}
|
}
|
||||||
]
|
],
|
||||||
|
"@react-native-community/datetimepicker"
|
||||||
],
|
],
|
||||||
"experiments": {
|
"experiments": {
|
||||||
"typedRoutes": true,
|
"typedRoutes": true,
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import {
|
|||||||
uploadCustomerFile,
|
uploadCustomerFile,
|
||||||
} from '@/src/lib/api';
|
} from '@/src/lib/api';
|
||||||
import { useAuth } from '@/src/providers/auth-provider';
|
import { useAuth } from '@/src/providers/auth-provider';
|
||||||
|
import { getOpenableFileUrl } from '@/src/lib/file-opening';
|
||||||
|
|
||||||
const PRIMARY = '#69c350';
|
const PRIMARY = '#69c350';
|
||||||
|
|
||||||
@@ -276,7 +277,7 @@ export default function CustomerDetailScreen() {
|
|||||||
|
|
||||||
async function onOpenFile(file: ProjectFile) {
|
async function onOpenFile(file: ProjectFile) {
|
||||||
if (!file.url) return;
|
if (!file.url) return;
|
||||||
await WebBrowser.openBrowserAsync(file.url, {
|
await WebBrowser.openBrowserAsync(getOpenableFileUrl(file.url), {
|
||||||
presentationStyle: WebBrowser.WebBrowserPresentationStyle.FORM_SHEET,
|
presentationStyle: WebBrowser.WebBrowserPresentationStyle.FORM_SHEET,
|
||||||
controlsColor: PRIMARY,
|
controlsColor: PRIMARY,
|
||||||
showTitle: true,
|
showTitle: true,
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import * as WebBrowser from 'expo-web-browser';
|
|||||||
import { HistorySection } from '@/components/history-section';
|
import { HistorySection } from '@/components/history-section';
|
||||||
import { fetchPlantById, fetchPlantFiles, Plant, ProjectFile, uploadPlantFile } from '@/src/lib/api';
|
import { fetchPlantById, fetchPlantFiles, Plant, ProjectFile, uploadPlantFile } from '@/src/lib/api';
|
||||||
import { useAuth } from '@/src/providers/auth-provider';
|
import { useAuth } from '@/src/providers/auth-provider';
|
||||||
|
import { getOpenableFileUrl } from '@/src/lib/file-opening';
|
||||||
|
|
||||||
const PRIMARY = '#69c350';
|
const PRIMARY = '#69c350';
|
||||||
|
|
||||||
@@ -90,7 +91,7 @@ export default function PlantDetailScreen() {
|
|||||||
|
|
||||||
async function onOpenFile(file: ProjectFile) {
|
async function onOpenFile(file: ProjectFile) {
|
||||||
if (!file.url) return;
|
if (!file.url) return;
|
||||||
await WebBrowser.openBrowserAsync(file.url, {
|
await WebBrowser.openBrowserAsync(getOpenableFileUrl(file.url), {
|
||||||
presentationStyle: WebBrowser.WebBrowserPresentationStyle.FORM_SHEET,
|
presentationStyle: WebBrowser.WebBrowserPresentationStyle.FORM_SHEET,
|
||||||
controlsColor: PRIMARY,
|
controlsColor: PRIMARY,
|
||||||
showTitle: true,
|
showTitle: true,
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ import {
|
|||||||
updateTask,
|
updateTask,
|
||||||
} from '@/src/lib/api';
|
} from '@/src/lib/api';
|
||||||
import { useAuth } from '@/src/providers/auth-provider';
|
import { useAuth } from '@/src/providers/auth-provider';
|
||||||
|
import { getOpenableFileUrl } from '@/src/lib/file-opening';
|
||||||
|
|
||||||
const PRIMARY = '#69c350';
|
const PRIMARY = '#69c350';
|
||||||
const TASK_STATUS_ORDER: TaskStatus[] = ['Offen', 'In Bearbeitung', 'Abgeschlossen'];
|
const TASK_STATUS_ORDER: TaskStatus[] = ['Offen', 'In Bearbeitung', 'Abgeschlossen'];
|
||||||
@@ -193,7 +194,7 @@ export default function ProjectDetailScreen() {
|
|||||||
|
|
||||||
async function onOpenFile(file: ProjectFile) {
|
async function onOpenFile(file: ProjectFile) {
|
||||||
if (!file.url) return;
|
if (!file.url) return;
|
||||||
await WebBrowser.openBrowserAsync(file.url, {
|
await WebBrowser.openBrowserAsync(getOpenableFileUrl(file.url), {
|
||||||
presentationStyle: WebBrowser.WebBrowserPresentationStyle.FORM_SHEET,
|
presentationStyle: WebBrowser.WebBrowserPresentationStyle.FORM_SHEET,
|
||||||
controlsColor: PRIMARY,
|
controlsColor: PRIMARY,
|
||||||
showTitle: true,
|
showTitle: true,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import DateTimePicker, { DateTimePickerEvent } from '@react-native-community/datetimepicker';
|
||||||
import {
|
import {
|
||||||
KeyboardAvoidingView,
|
KeyboardAvoidingView,
|
||||||
Modal,
|
Modal,
|
||||||
@@ -29,11 +30,11 @@ function pad(value: number): string {
|
|||||||
return String(value).padStart(2, '0');
|
return String(value).padStart(2, '0');
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDateInput(date: Date): string {
|
function formatDate(date: Date): string {
|
||||||
return `${pad(date.getDate())}.${pad(date.getMonth() + 1)}.${date.getFullYear()}`;
|
return `${pad(date.getDate())}.${pad(date.getMonth() + 1)}.${date.getFullYear()}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatTimeInput(date: Date): string {
|
function formatTime(date: Date): string {
|
||||||
return `${pad(date.getHours())}:${pad(date.getMinutes())}`;
|
return `${pad(date.getHours())}:${pad(date.getMinutes())}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,35 +46,7 @@ function createDefaults(initialDate?: Date | null) {
|
|||||||
start.setHours(9, 0, 0, 0);
|
start.setHours(9, 0, 0, 0);
|
||||||
}
|
}
|
||||||
const end = new Date(start.getTime() + 60 * 60 * 1000);
|
const end = new Date(start.getTime() + 60 * 60 * 1000);
|
||||||
return {
|
return { start, end };
|
||||||
startDate: formatDateInput(start),
|
|
||||||
startTime: formatTimeInput(start),
|
|
||||||
endDate: formatDateInput(end),
|
|
||||||
endTime: formatTimeInput(end),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseLocalDate(dateValue: string, timeValue: string): Date | null {
|
|
||||||
const match = dateValue.trim().match(/^(\d{1,2})\.(\d{1,2})\.(\d{4})$/);
|
|
||||||
const timeMatch = timeValue.trim().match(/^(\d{1,2}):(\d{2})$/);
|
|
||||||
if (!match || !timeMatch) return null;
|
|
||||||
|
|
||||||
const day = Number(match[1]);
|
|
||||||
const month = Number(match[2]);
|
|
||||||
const year = Number(match[3]);
|
|
||||||
const hours = Number(timeMatch[1]);
|
|
||||||
const minutes = Number(timeMatch[2]);
|
|
||||||
if (hours > 23 || minutes > 59) return null;
|
|
||||||
|
|
||||||
const date = new Date(year, month - 1, day, hours, minutes, 0, 0);
|
|
||||||
if (
|
|
||||||
date.getFullYear() !== year ||
|
|
||||||
date.getMonth() !== month - 1 ||
|
|
||||||
date.getDate() !== day
|
|
||||||
) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return date;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function relationId(value: unknown): number | null {
|
function relationId(value: unknown): number | null {
|
||||||
@@ -98,10 +71,9 @@ export function EventCreateModal({
|
|||||||
const [name, setName] = useState('');
|
const [name, setName] = useState('');
|
||||||
const [notes, setNotes] = useState('');
|
const [notes, setNotes] = useState('');
|
||||||
const [link, setLink] = useState('');
|
const [link, setLink] = useState('');
|
||||||
const [startDate, setStartDate] = useState('');
|
const [start, setStart] = useState(() => createDefaults(initialDate).start);
|
||||||
const [startTime, setStartTime] = useState('');
|
const [end, setEnd] = useState(() => createDefaults(initialDate).end);
|
||||||
const [endDate, setEndDate] = useState('');
|
const [activePicker, setActivePicker] = useState<'startDate' | 'startTime' | 'endDate' | 'endTime' | null>(null);
|
||||||
const [endTime, setEndTime] = useState('');
|
|
||||||
const [selectedProjectId, setSelectedProjectId] = useState<number | null>(initialProjectId);
|
const [selectedProjectId, setSelectedProjectId] = useState<number | null>(initialProjectId);
|
||||||
const [projectSearch, setProjectSearch] = useState('');
|
const [projectSearch, setProjectSearch] = useState('');
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
@@ -113,10 +85,9 @@ export function EventCreateModal({
|
|||||||
setName('');
|
setName('');
|
||||||
setNotes('');
|
setNotes('');
|
||||||
setLink('');
|
setLink('');
|
||||||
setStartDate(defaults.startDate);
|
setStart(defaults.start);
|
||||||
setStartTime(defaults.startTime);
|
setEnd(defaults.end);
|
||||||
setEndDate(defaults.endDate);
|
setActivePicker(null);
|
||||||
setEndTime(defaults.endTime);
|
|
||||||
setSelectedProjectId(initialProjectId);
|
setSelectedProjectId(initialProjectId);
|
||||||
setProjectSearch('');
|
setProjectSearch('');
|
||||||
setError(null);
|
setError(null);
|
||||||
@@ -138,6 +109,27 @@ export function EventCreateModal({
|
|||||||
.slice(0, 8);
|
.slice(0, 8);
|
||||||
}, [projectSearch, projects]);
|
}, [projectSearch, projects]);
|
||||||
|
|
||||||
|
function updatePicker(event: DateTimePickerEvent, selected?: Date) {
|
||||||
|
if (Platform.OS === 'android') setActivePicker(null);
|
||||||
|
if (event.type === 'dismissed' || !selected || !activePicker) return;
|
||||||
|
|
||||||
|
const isStart = activePicker.startsWith('start');
|
||||||
|
const isDate = activePicker.endsWith('Date');
|
||||||
|
const current = new Date(isStart ? start : end);
|
||||||
|
if (isDate) {
|
||||||
|
current.setFullYear(selected.getFullYear(), selected.getMonth(), selected.getDate());
|
||||||
|
} else {
|
||||||
|
current.setHours(selected.getHours(), selected.getMinutes(), 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isStart) {
|
||||||
|
setStart(current);
|
||||||
|
if (end <= current) setEnd(new Date(current.getTime() + 60 * 60 * 1000));
|
||||||
|
} else {
|
||||||
|
setEnd(current);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function save() {
|
async function save() {
|
||||||
if (!token || saving) return;
|
if (!token || saving) return;
|
||||||
const title = name.trim();
|
const title = name.trim();
|
||||||
@@ -146,12 +138,6 @@ export function EventCreateModal({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const start = parseLocalDate(startDate, startTime);
|
|
||||||
const end = parseLocalDate(endDate, endTime);
|
|
||||||
if (!start || !end) {
|
|
||||||
setError('Bitte Datum und Uhrzeit im angegebenen Format eingeben.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (end <= start) {
|
if (end <= start) {
|
||||||
setError('Das Ende muss nach dem Beginn liegen.');
|
setError('Das Ende muss nach dem Beginn liegen.');
|
||||||
return;
|
return;
|
||||||
@@ -198,44 +184,42 @@ export function EventCreateModal({
|
|||||||
|
|
||||||
<Text style={styles.label}>Beginn</Text>
|
<Text style={styles.label}>Beginn</Text>
|
||||||
<View style={styles.row}>
|
<View style={styles.row}>
|
||||||
<TextInput
|
<Pressable style={[styles.pickerButton, styles.dateInput]} onPress={() => setActivePicker('startDate')}>
|
||||||
placeholder="TT.MM.JJJJ"
|
<Text style={styles.pickerButtonText}>{formatDate(start)}</Text>
|
||||||
placeholderTextColor="#9ca3af"
|
</Pressable>
|
||||||
style={[styles.input, styles.dateInput]}
|
<Pressable style={[styles.pickerButton, styles.timeInput]} onPress={() => setActivePicker('startTime')}>
|
||||||
value={startDate}
|
<Text style={styles.pickerButtonText}>{formatTime(start)}</Text>
|
||||||
onChangeText={setStartDate}
|
</Pressable>
|
||||||
keyboardType="numbers-and-punctuation"
|
|
||||||
/>
|
|
||||||
<TextInput
|
|
||||||
placeholder="HH:MM"
|
|
||||||
placeholderTextColor="#9ca3af"
|
|
||||||
style={[styles.input, styles.timeInput]}
|
|
||||||
value={startTime}
|
|
||||||
onChangeText={setStartTime}
|
|
||||||
keyboardType="numbers-and-punctuation"
|
|
||||||
/>
|
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<Text style={styles.label}>Ende</Text>
|
<Text style={styles.label}>Ende</Text>
|
||||||
<View style={styles.row}>
|
<View style={styles.row}>
|
||||||
<TextInput
|
<Pressable style={[styles.pickerButton, styles.dateInput]} onPress={() => setActivePicker('endDate')}>
|
||||||
placeholder="TT.MM.JJJJ"
|
<Text style={styles.pickerButtonText}>{formatDate(end)}</Text>
|
||||||
placeholderTextColor="#9ca3af"
|
</Pressable>
|
||||||
style={[styles.input, styles.dateInput]}
|
<Pressable style={[styles.pickerButton, styles.timeInput]} onPress={() => setActivePicker('endTime')}>
|
||||||
value={endDate}
|
<Text style={styles.pickerButtonText}>{formatTime(end)}</Text>
|
||||||
onChangeText={setEndDate}
|
</Pressable>
|
||||||
keyboardType="numbers-and-punctuation"
|
|
||||||
/>
|
|
||||||
<TextInput
|
|
||||||
placeholder="HH:MM"
|
|
||||||
placeholderTextColor="#9ca3af"
|
|
||||||
style={[styles.input, styles.timeInput]}
|
|
||||||
value={endTime}
|
|
||||||
onChangeText={setEndTime}
|
|
||||||
keyboardType="numbers-and-punctuation"
|
|
||||||
/>
|
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
{activePicker ? (
|
||||||
|
<View style={styles.pickerWrap}>
|
||||||
|
<DateTimePicker
|
||||||
|
value={activePicker.startsWith('start') ? start : end}
|
||||||
|
mode={activePicker.endsWith('Date') ? 'date' : 'time'}
|
||||||
|
display={Platform.OS === 'ios' ? 'spinner' : 'default'}
|
||||||
|
locale="de-DE"
|
||||||
|
minuteInterval={5}
|
||||||
|
onChange={updatePicker}
|
||||||
|
/>
|
||||||
|
{Platform.OS === 'ios' ? (
|
||||||
|
<Pressable style={styles.pickerDoneButton} onPress={() => setActivePicker(null)}>
|
||||||
|
<Text style={styles.pickerDoneText}>Übernehmen</Text>
|
||||||
|
</Pressable>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<Text style={styles.label}>Projekt</Text>
|
<Text style={styles.label}>Projekt</Text>
|
||||||
{selectedProject ? (
|
{selectedProject ? (
|
||||||
<View style={styles.selectedProject}>
|
<View style={styles.selectedProject}>
|
||||||
@@ -324,8 +308,22 @@ const styles = StyleSheet.create({
|
|||||||
backgroundColor: '#ffffff',
|
backgroundColor: '#ffffff',
|
||||||
},
|
},
|
||||||
row: { flexDirection: 'row', gap: 8 },
|
row: { flexDirection: 'row', gap: 8 },
|
||||||
|
pickerButton: {
|
||||||
|
minHeight: 44,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#d1d5db',
|
||||||
|
borderRadius: 10,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
backgroundColor: '#ffffff',
|
||||||
|
},
|
||||||
|
pickerButtonText: { color: '#111827', fontSize: 15, fontWeight: '600' },
|
||||||
dateInput: { flex: 1 },
|
dateInput: { flex: 1 },
|
||||||
timeInput: { width: 92 },
|
timeInput: { width: 92 },
|
||||||
|
pickerWrap: { borderWidth: 1, borderColor: '#e5e7eb', borderRadius: 10, padding: 8 },
|
||||||
|
pickerDoneButton: { alignSelf: 'flex-end', paddingHorizontal: 10, paddingVertical: 7 },
|
||||||
|
pickerDoneText: { color: '#3d7a30', fontSize: 14, fontWeight: '700' },
|
||||||
multiline: { minHeight: 90, textAlignVertical: 'top' },
|
multiline: { minHeight: 90, textAlignVertical: 'top' },
|
||||||
selectedProject: {
|
selectedProject: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
|
|||||||
24
mobile/package-lock.json
generated
24
mobile/package-lock.json
generated
@@ -9,6 +9,7 @@
|
|||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@expo/vector-icons": "^15.0.3",
|
"@expo/vector-icons": "^15.0.3",
|
||||||
|
"@react-native-community/datetimepicker": "8.4.4",
|
||||||
"@react-navigation/bottom-tabs": "^7.4.0",
|
"@react-navigation/bottom-tabs": "^7.4.0",
|
||||||
"@react-navigation/elements": "^2.6.3",
|
"@react-navigation/elements": "^2.6.3",
|
||||||
"@react-navigation/native": "^7.1.8",
|
"@react-navigation/native": "^7.1.8",
|
||||||
@@ -2892,6 +2893,29 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@react-native-community/datetimepicker": {
|
||||||
|
"version": "8.4.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@react-native-community/datetimepicker/-/datetimepicker-8.4.4.tgz",
|
||||||
|
"integrity": "sha512-bc4ZixEHxZC9/qf5gbdYvIJiLZ5CLmEsC3j+Yhe1D1KC/3QhaIfGDVdUcid0PdlSoGOSEq4VlB93AWyetEyBSQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"invariant": "^2.2.4"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"expo": ">=52.0.0",
|
||||||
|
"react": "*",
|
||||||
|
"react-native": "*",
|
||||||
|
"react-native-windows": "*"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"expo": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"react-native-windows": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@react-native/assets-registry": {
|
"node_modules/@react-native/assets-registry": {
|
||||||
"version": "0.81.5",
|
"version": "0.81.5",
|
||||||
"resolved": "https://registry.npmjs.org/@react-native/assets-registry/-/assets-registry-0.81.5.tgz",
|
"resolved": "https://registry.npmjs.org/@react-native/assets-registry/-/assets-registry-0.81.5.tgz",
|
||||||
|
|||||||
@@ -22,6 +22,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@expo/vector-icons": "^15.0.3",
|
"@expo/vector-icons": "^15.0.3",
|
||||||
|
"@react-native-community/datetimepicker": "8.4.4",
|
||||||
"@react-navigation/bottom-tabs": "^7.4.0",
|
"@react-navigation/bottom-tabs": "^7.4.0",
|
||||||
"@react-navigation/elements": "^2.6.3",
|
"@react-navigation/elements": "^2.6.3",
|
||||||
"@react-navigation/native": "^7.1.8",
|
"@react-navigation/native": "^7.1.8",
|
||||||
|
|||||||
9
mobile/src/lib/file-opening.ts
Normal file
9
mobile/src/lib/file-opening.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { getApiBaseUrlSync } from '@/src/lib/server-config';
|
||||||
|
|
||||||
|
export function getOpenableFileUrl(rawUrl: string): string {
|
||||||
|
if (/^https?:\/\//i.test(rawUrl)) return rawUrl;
|
||||||
|
|
||||||
|
const apiBaseUrl = getApiBaseUrlSync().replace(/\/+$/, '');
|
||||||
|
const path = rawUrl.startsWith('/') ? rawUrl : `/${rawUrl}`;
|
||||||
|
return `${apiBaseUrl}${path}`;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user